diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d77ef..277425a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# 5.2.1 (unreleased) + +* Fix Maven publication dependency scopes so dependencies required by the public API are available on consumers' compile classpaths (#219, #233). +* Preserve non-default endpoint ports when building the VAPID JWT audience origin (#153). +* Migrate the Gradle Maven Central publishing flow from the retired OSSRH service to the Central Portal OSSRH Staging API compatibility service. +* Include the fixes that were prepared after 5.1.2 for the tagged but unpublished 5.2.0 release: + * Encode the VAPID public application server key as unpadded URL-safe Base64 (#216). + * Use AES128GCM as the default content encoding. + * Upgrade `org.bitbucket.b_c:jose4j` to 0.9.6. + # 5.1.1 * Target Java 8 instead of Java 7. @@ -24,4 +34,3 @@ * Support [aes128gcm content encoding](https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-09#section-2) (#72) * Use `PushService.send(Notification, Encoding)` or the analogous `sendAsync` with `Encoding.AES128GCM`. * Remove Guava dependency (#69) - diff --git a/build.gradle b/build.gradle index 83ca068..dff0e22 100644 --- a/build.gradle +++ b/build.gradle @@ -1,11 +1,12 @@ plugins { id 'application' + id 'java-library' id 'com.github.johnrengelman.shadow' version '7.1.1' // Used by release.gradle id 'maven-publish' id 'signing' - id 'io.codearte.nexus-staging' version '0.30.0' + id 'io.github.gradle-nexus.publish-plugin' version '2.0.0' apply false } apply plugin: 'application' @@ -21,19 +22,19 @@ repositories { dependencies { // For CLI - implementation group: 'com.beust', name: 'jcommander', version: '1.81' + api group: 'com.beust', name: 'jcommander', version: '1.81' // For making HTTP requests - implementation group: 'org.apache.httpcomponents', name: 'httpasyncclient', version: '4.1.5' + api group: 'org.apache.httpcomponents', name: 'httpasyncclient', version: '4.1.5' // For making async HTTP requests - implementation group: 'org.asynchttpclient', name: 'async-http-client', version: '2.12.4' + api group: 'org.asynchttpclient', name: 'async-http-client', version: '2.12.4' // For cryptographic operations shadow group: 'org.bouncycastle', name: 'bcprov-jdk15on', version: '1.70' // For creating and signing JWT - implementation group: 'org.bitbucket.b_c', name: 'jose4j', version: '0.9.6' + api group: 'org.bitbucket.b_c', name: 'jose4j', version: '0.9.6' // For parsing JSON testImplementation group: 'com.google.code.gson', name: 'gson', version: '2.8.9' @@ -58,7 +59,7 @@ dependencies { } wrapper { - gradleVersion = '5.1' + gradleVersion = '7.2' } compileJava { @@ -104,5 +105,6 @@ artifacts { } if (hasProperty('release')) { + apply plugin: 'io.github.gradle-nexus.publish-plugin' apply from: 'release.gradle' } diff --git a/release.gradle b/release.gradle index 4c09f1d..6fd7dc6 100644 --- a/release.gradle +++ b/release.gradle @@ -27,16 +27,17 @@ publishing { } } } - } } +} + +nexusPublishing { repositories { - maven { - credentials { - username ossrhUsername - password ossrhPassword - } - url getRepositoryUrl() + sonatype { + nexusUrl.set(uri('https://ossrh-staging-api.central.sonatype.com/service/local/')) + snapshotRepositoryUrl.set(uri('https://central.sonatype.com/repository/maven-snapshots/')) + username = project.findProperty('sonatypeUsername') ?: project.findProperty('ossrhUsername') + password = project.findProperty('sonatypePassword') ?: project.findProperty('ossrhPassword') } } } @@ -45,10 +46,40 @@ signing { sign publishing.publications.mavenJava } -def getRepositoryUrl() { - if (version.endsWith('SNAPSHOT')) { - return 'https://oss.sonatype.org/content/repositories/snapshots/' - } else { - return 'https://oss.sonatype.org/service/local/staging/deploy/maven2/' +tasks.register('verifyPublicationPom') { + dependsOn 'generatePomFileForMavenJavaPublication' + + doLast { + def pomFile = file("$buildDir/publications/mavenJava/pom-default.xml") + def pom = new XmlSlurper().parse(pomFile) + def scopes = [:] + + pom.dependencies.dependency.each { dependency -> + scopes["${dependency.groupId.text()}:${dependency.artifactId.text()}"] = dependency.scope.text() + } + + [ + 'com.beust:jcommander', + 'org.apache.httpcomponents:httpasyncclient', + 'org.asynchttpclient:async-http-client', + 'org.bitbucket.b_c:jose4j' + ].each { dependency -> + if (scopes[dependency] != 'compile') { + throw new GradleException("Expected ${dependency} to be published with compile scope, got '${scopes[dependency]}'") + } + } } } + +// Keep the historical release commands stable while using the current Sonatype plugin internally. +gradle.projectsEvaluated { + tasks.named('publish') { + dependsOn 'publishToSonatype' + } +} + +tasks.register('closeAndReleaseRepository') { + group = 'publishing' + description = 'Closes and releases the current Sonatype staging repository.' + dependsOn 'findSonatypeStagingRepository', 'closeAndReleaseSonatypeStagingRepository' +} diff --git a/scripts/version.sh b/scripts/version.sh index 7f22121..c3bc7ea 100755 --- a/scripts/version.sh +++ b/scripts/version.sh @@ -2,19 +2,16 @@ set -eu if [ "$#" -ne 2 ]; then - echo "Usage: version.sh OLD_VERSION NEW_VERSION" && exit 1 + echo "Usage: version.sh OLD_VERSION NEW_VERSION" >&2 + exit 1 fi OLD_VERSION=$1 NEW_VERSION=$2 +OLD_VERSION_REGEX=$(printf '%s' "$OLD_VERSION" | sed 's/\./\\./g') -files=( - "build.gradle" - "README.md" -) - -for file in ${files[@]}; do - sed -i '' "s/$OLD_VERSION/$NEW_VERSION/g" $file +for file in build.gradle README.md; do + tmp_file="${file}.tmp" + sed "s/${OLD_VERSION_REGEX}/${NEW_VERSION}/g" "$file" > "$tmp_file" + mv "$tmp_file" "$file" done - - diff --git a/src/main/java/nl/martijndwars/webpush/Notification.java b/src/main/java/nl/martijndwars/webpush/Notification.java index 6fdc493..c3c6329 100644 --- a/src/main/java/nl/martijndwars/webpush/Notification.java +++ b/src/main/java/nl/martijndwars/webpush/Notification.java @@ -87,10 +87,10 @@ public Notification(String endpoint, String userPublicKey, String userAuth, Stri this(endpoint, Utils.loadPublicKey(userPublicKey), Base64.getUrlDecoder().decode(userAuth), payload.getBytes(UTF_8)); } - public Notification(String endpoint, String userPublicKey, String userAuth, String payload, Urgency urgency) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { - this(endpoint, Utils.loadPublicKey(userPublicKey), Base64.getUrlDecoder().decode(userAuth), payload.getBytes(UTF_8)); - this.urgency = urgency; - } + public Notification(String endpoint, String userPublicKey, String userAuth, String payload, Urgency urgency) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { + this(endpoint, Utils.loadPublicKey(userPublicKey), Base64.getUrlDecoder().decode(userAuth), payload.getBytes(UTF_8)); + this.urgency = urgency; + } public Notification(Subscription subscription, String payload) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { this(subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth, payload); @@ -156,8 +156,10 @@ public String getTopic() { public String getOrigin() throws MalformedURLException { URL url = new URL(getEndpoint()); + int port = url.getPort(); + boolean includePort = port != -1 && port != url.getDefaultPort(); - return url.getProtocol() + "://" + url.getHost(); + return url.getProtocol() + "://" + url.getHost() + (includePort ? ":" + port : ""); } public static NotificationBuilder builder() { diff --git a/src/test/java/nl/martijndwars/webpush/AbstractPushServiceTest.java b/src/test/java/nl/martijndwars/webpush/AbstractPushServiceTest.java new file mode 100644 index 0000000..79aa6f2 --- /dev/null +++ b/src/test/java/nl/martijndwars/webpush/AbstractPushServiceTest.java @@ -0,0 +1,59 @@ +package nl.martijndwars.webpush; + +import org.bouncycastle.jce.ECNamedCurveTable; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Security; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AbstractPushServiceTest { + + private static final String USER_PUBLIC_KEY = "BGu3hOwCLOBfdMReXf7-SD2x5tKs_vPapOneyngBOnu6PgNYdgLPKFAodfBnG60MqkXC0McPFehN2Kyuh6TKm14="; + + @BeforeAll + public static void addSecurityProvider() { + Security.addProvider(new BouncyCastleProvider()); + } + + @Test + public void vapidCryptoKeyUsesUnpaddedBase64Url() throws Exception { + ECNamedCurveParameterSpec parameterSpec = ECNamedCurveTable.getParameterSpec("prime256v1"); + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("ECDH", "BC"); + keyPairGenerator.initialize(parameterSpec); + + TestPushService service = new TestPushService(keyPairGenerator.generateKeyPair()); + String userAuth = Base64.getUrlEncoder().withoutPadding().encodeToString(new byte[16]); + Notification notification = new Notification( + "https://push.example.test/send/123", + USER_PUBLIC_KEY, + userAuth, + "payload" + ); + + HttpRequest request = service.prepare(notification, Encoding.AES128GCM); + String cryptoKey = request.getHeaders().get("Crypto-Key"); + + assertNotNull(cryptoKey); + assertTrue(cryptoKey.startsWith("p256ecdsa=")); + assertFalse(cryptoKey.substring("p256ecdsa=".length()).contains("=")); + } + + private static class TestPushService extends AbstractPushService { + TestPushService(KeyPair keyPair) { + super(keyPair); + } + + HttpRequest prepare(Notification notification, Encoding encoding) throws Exception { + return prepareRequest(notification, encoding); + } + } +} diff --git a/src/test/java/nl/martijndwars/webpush/NotificationTest.java b/src/test/java/nl/martijndwars/webpush/NotificationTest.java index a9d1c6c..3f20aeb 100644 --- a/src/test/java/nl/martijndwars/webpush/NotificationTest.java +++ b/src/test/java/nl/martijndwars/webpush/NotificationTest.java @@ -40,4 +40,22 @@ public void testDefaultTtl() throws GeneralSecurityException { .build(); assertEquals(28 * oneDayDurationInSeconds, notification.getTTL()); } + + @Test + public void testOriginOmitsDefaultPort() throws Exception { + Notification notification = Notification.builder() + .endpoint("https://push.example.test:443/send/123") + .build(); + + assertEquals("https://push.example.test", notification.getOrigin()); + } + + @Test + public void testOriginIncludesNonDefaultPort() throws Exception { + Notification notification = Notification.builder() + .endpoint("https://push.example.test:8443/send/123") + .build(); + + assertEquals("https://push.example.test:8443", notification.getOrigin()); + } }