Repository: web-push-libs/webpush-java Branch: master Commit: b4851e63ec52 Files: 39 Total size: 100.8 KB Directory structure: gitextract_7t6tufc7/ ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── RELEASE.md ├── build.gradle ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── release.gradle ├── renovate.json ├── scripts/ │ └── version.sh ├── settings.gradle └── src/ ├── main/ │ └── java/ │ └── nl/ │ └── martijndwars/ │ └── webpush/ │ ├── AbstractPushService.java │ ├── ClosableCallback.java │ ├── Encoding.java │ ├── Encrypted.java │ ├── HttpEce.java │ ├── HttpRequest.java │ ├── Notification.java │ ├── PushAsyncService.java │ ├── PushService.java │ ├── Subscription.java │ ├── Urgency.java │ ├── Utils.java │ └── cli/ │ ├── Cli.java │ ├── commands/ │ │ ├── GenerateKeyCommand.java │ │ └── SendNotificationCommand.java │ └── handlers/ │ ├── GenerateKeyHandler.java │ ├── HandlerInterface.java │ └── SendNotificationHandler.java └── test/ └── java/ └── nl/ └── martijndwars/ └── webpush/ ├── HttpEceTest.java ├── NotificationTest.java └── selenium/ ├── BrowserTest.java ├── Configuration.java ├── SeleniumTests.java └── TestingService.java ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ build/ .idea/ .gradle/ *.iml target/ out/ ================================================ FILE: .travis.yml ================================================ language: java sudo: required jdk: - openjdk8 - openjdk11 - oraclejdk8 - oraclejdk11 env: - JAVA_OPTS=-Dfile.encoding=cp1252 - JAVA_OPTS=-Dfile.encoding=UTF-8 before_cache: - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ script: - ./gradlew clean check cache: directories: - $HOME/.gradle/caches/ - $HOME/.gradle/wrapper/ ================================================ FILE: CHANGELOG.md ================================================ # 5.1.1 * Target Java 8 instead of Java 7. * Added an asynchronous version `PushAsyncService` of the `PushService` that performs non-blocking HTTP calls. Uses `async-http-client` under the hood. # 5.1.0 * Improvement: Add support for [urgency](https://tools.ietf.org/html/rfc8030#section-5.3) & [topic](https://tools.ietf.org/html/rfc8030#section-5.4) (contributed by jamie@checkin.tech). * Maintenance: Upgrade com.beust:jcommander to 1.78. * Maintenance: Upgrade org.bitbucket.b\_c:jose4j to 0.7.0. # 5.0.1 * Bugfix: Only verify the VAPID key pair if the keys are actually present (fixes #73). * Improvement: Add test configurations for GCM-only to the selenium test suite. # 5.0.0 * Use aes128gcm as the default encoding (#75). * Remove BouncyCastle JAR from source and let Gradle put together the class path for the CLI. # 4.0.0 * 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) ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2016 Martijn Dwars Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # WebPush A Web Push library for Java 8. Supports payloads and VAPID. [![Build Status](https://travis-ci.org/web-push-libs/webpush-java.svg?branch=master)](https://travis-ci.org/web-push-libs/webpush-java) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/nl.martijndwars/web-push/badge.svg)](https://search.maven.org/search?q=g:nl.martijndwars%20AND%20a:web-push) ## Installation For Gradle, add the following dependency to `build.gradle`: ```groovy compile group: 'nl.martijndwars', name: 'web-push', version: '5.1.2' ``` For Maven, add the following dependency to `pom.xml`: ```xml     nl.martijndwars     web-push     5.1.2 ``` This library depends on BouncyCastle, which acts as a Java Cryptography Extension (JCE) provider. BouncyCastle's JARs are signed, and depending on how you package your application, you may need to include BouncyCastle yourself as well. ## Building To assemble all archives in the project: ```sh ./gradlew assemble ``` ## Usage This library is meant to be used as a Java API. However, it also exposes a CLI to easily generate a VAPID keypair and send a push notification. ### CLI A command-line interface is available to easily generate a keypair (for VAPID) and to try sending a notification. ``` $ ./gradlew run Usage:
[command] [command options] Commands: generate-key Generate a VAPID keypair Usage: generate-key send-notification Send a push notification Usage: send-notification [options] Options: --subscription A subscription in JSON format. --publicKey The public key as base64url encoded string. --privateKey The private key as base64url encoded string. --payload The message to send. Default: Hello, world! --ttl The number of seconds that the push service should retain the message. ``` For example, to generate a keypair and output the keys in base64url encoding: ``` $ ./gradlew run --args="generate-key" PublicKey: BGgL7I82SAQM78oyGwaJdrQFhVfZqL9h4Y18BLtgJQ-9pSGXwxqAWQudqmcv41RcWgk1ssUeItv4-8khxbhYveM= PrivateKey: ANlfcVVFB4JiMYcI74_h9h04QZ1Ks96AyEa1yrMgDwn3 ``` Use the public key in the call to `pushManager.subscribe` to get a subscription. Then, to send a notification: ``` $ ./gradlew run --args='send-notification --endpoint="https://fcm.googleapis.com/fcm/send/fH-M3xRoLms:APA91bGB0rkNdxTFsXaJGyyyY7LtEmtHJXy8EqW48zSssxDXXACWCvc9eXjBVU54nrBkARTj4Xvl303PoNc0_rwAMrY9dvkQzi9fkaKLP0vlwoB0uqKygPeL77Y19VYHbj_v_FolUlHa" --key="BOtBVgsHVWXzwhDAoFE8P2IgQvabz_tuJjIlNacmS3XZ3fRDuVWiBp8bPR3vHCA78edquclcXXYb-olcj3QtIZ4=" --auth="IOScBh9LW5mJ_K2JwXyNqQ==" --publicKey="BGgL7I82SAQM78oyGwaJdrQFhVfZqL9h4Y18BLtgJQ-9pSGXwxqAWQudqmcv41RcWgk1ssUeItv4-8khxbhYveM=" --privateKey="ANlfcVVFB4JiMYcI74_h9h04QZ1Ks96AyEa1yrMgDwn3" --payload="Hello world"' ``` #### Proxy If you are behind a corporate proxy you may need to specify the proxy host. This library respects [Java's Network Properties](https://docs.oracle.com/javase/7/docs/api/java/net/doc-files/net-properties.html), which means that you can pass `https.proxyHost` and `http.proxyPort` when invoking `java`, e.g. `java -Dhttp.proxyHost=proxy.corp.com -Dhttp.proxyPort=80 -Dhttps.proxyHost=proxy.corp.com -Dhttps.proxyPort=443 -jar ...`. ### API First, make sure you add the BouncyCastle security provider: ```java Security.addProvider(new BouncyCastleProvider()); ``` Then, create an instance of the push service, either `nl.martijndwars.webpush.PushService` for synchronous blocking HTTP calls, or `nl.martijndwars.webpush.PushAsyncService` for asynchronous non-blocking HTTP calls: ```java PushService pushService = new PushService(...); ``` Then, create a notification based on the user's subscription: ```java Notification notification = new Notification(...); ``` To send a push notification: ```java pushService.send(notification); ``` See [wiki/Usage-Example](https://github.com/web-push-libs/webpush-java/wiki/Usage-Example) for detailed usage instructions. If you plan on using VAPID, read [wiki/VAPID](https://github.com/web-push-libs/webpush-java/wiki/VAPID). ## Testing The integration tests use [Web Push Testing Service (WPTS)](https://github.com/GoogleChromeLabs/web-push-testing-service) to handle the Selenium and browser orchestrating. We use a forked version that fixes a bug on macOS. To install WPTS: ``` npm i -g github:MartijnDwars/web-push-testing-service#bump-selenium-assistant ``` Then start WPTS: ``` web-push-testing-service start wpts ``` Then run the tests: ``` ./gradlew clean test ``` Finally, stop WPTS: ``` web-push-testing-service stop wpts ``` ## FAQ ### Why does encryption take multiple seconds? There may not be enough entropy to generate a random seed, which is common on headless servers. There exist two ways to overcome this problem: - Install [haveged](http://stackoverflow.com/a/31208558/368220), a _"random number generator that remedies low-entropy conditions in the Linux random device that can occur under some workloads, especially on headless servers."_ [This](https://www.digitalocean.com/community/tutorials/how-to-setup-additional-entropy-for-cloud-servers-using-haveged) tutorial explains how to install haveged on different Linux distributions. - Change the source for random number generation in the JVM from `/dev/random` to `/dev/urandom`. [This](https://docs.oracle.com/cd/E13209_01/wlcp/wlss30/configwlss/jvmrand.html) page offers some explanation. ## Credit To give credit where credit is due, the PushService is mostly a Java port of marco-c/web-push. The HttpEce class is mostly a Java port of martinthomson/encrypted-content-encoding. ## Resources ### Specifications - [Generic Event Delivery Using HTTP Push](https://tools.ietf.org/html/draft-ietf-webpush-protocol-11) - [Message Encryption for Web Push](https://tools.ietf.org/html/draft-ietf-webpush-encryption-08) - [Encrypted Content-Encoding for HTTP](https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-02) ### Miscellaneous - [Voluntary Application Server Identification for Web Push](https://tools.ietf.org/html/draft-ietf-webpush-vapid-01) - [Web Push Book](https://web-push-book.gauntface.com/) - [Simple Push Demo](https://gauntface.github.io/simple-push-demo/) - [Web Push: Data Encryption Test Page](https://jrconlin.github.io/WebPushDataTestPage/) - [Push Companion](https://web-push-codelab.appspot.com/) ## Related The web-push-libs organization hosts implementations of the Web Push protocol in several languages: - For PHP, see [web-push-libs/web-push-php](https://github.com/web-push-libs/web-push-php) - For NodeJS, see [web-push-libs/web-push](https://github.com/web-push-libs/web-push) - For Python, see [web-push-libs/pywebpush](https://github.com/web-push-libs/pywebpush) - For C#, see [web-push-libs/web-push-csharp](https://github.com/web-push-libs/web-push-csharp) - For Scala, see [zivver/web-push](https://github.com/zivver/web-push) ================================================ FILE: RELEASE.md ================================================ # Release process 0. Update CHANGELOG.md. Include changes to the source code, changes to the version of compile dependencies, etc. Do NOT include changes to the buildscript, version of test dependencies, etc. 1. Update version string in `build.gradle` (1x), `README.md` (2x) to the new (non-SNAPSHOT) version. ``` ./scripts/version.sh OLD_VERSION NEW_VERSION ``` 2. Commit "Release x.y.z", tag this commit with the new version "x.y.z". ``` git add README.md build.gradle git commit -m "Release NEW_VERSION" git tag -a NEW_VERSION -m "Version NEW_VERSION" git push --tags ``` 3. [Deploy to OSSRH with Gradle](http://central.sonatype.org/pages/gradle.html): ``` ./gradlew -Prelease clean publish ``` 4. [Releasing the Deployment](http://central.sonatype.org/pages/releasing-the-deployment.html): ``` ./gradlew -Prelease closeAndReleaseRepository ``` 5. Increment to next version and add a -SNAPSHOT suffix ``` ./scripts/version.sh OLD_VERSION NEW_VERSION-SNAPSHOT ``` 6. Create a commit for the new version "Set version to a.b.c-SNAPSHOT" ``` git add README.md build.gradle git commit -m "Set version to NEW_VERSION-SNAPSHOT" ``` ================================================ FILE: build.gradle ================================================ plugins { id 'application' 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' } apply plugin: 'application' apply plugin: 'com.github.johnrengelman.shadow' group 'nl.martijndwars' version '5.1.2' repositories { mavenLocal() mavenCentral() } dependencies { // For CLI implementation group: 'com.beust', name: 'jcommander', version: '1.81' // For making HTTP requests implementation 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' // 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' // For parsing JSON testImplementation group: 'com.google.code.gson', name: 'gson', version: '2.8.9' // For making HTTP requests testImplementation group: 'org.apache.httpcomponents', name: 'fluent-hc', version: '4.5.13' // For testing, obviously testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: '5.8.1' // For running JUnit tests testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: '5.8.1' // For turning InputStream to String testImplementation group: 'commons-io', name: 'commons-io', version: '2.11.0' // For reading the demo vapid keypair from a pem file testImplementation group: 'org.bouncycastle', name: 'bcpkix-jdk15on', version: '1.70' // For verifying Base64Encoder results in unit tests testImplementation group: 'com.google.guava', name: 'guava', version: '33.4.8-jre' } wrapper { gradleVersion = '5.1' } compileJava { sourceCompatibility = 1.8 targetCompatibility = 1.8 } compileTestJava { sourceCompatibility = 1.8 } mainClassName = 'nl.martijndwars.webpush.cli.Cli' run { classpath configurations.shadow.files } test { useJUnitPlatform() testLogging { events 'PASSED', 'FAILED', 'SKIPPED' showStandardStreams true exceptionFormat 'full' } exclude '**/SeleniumTests.class' } task javadocJar(type: Jar) { classifier = 'javadoc' from javadoc } task sourcesJar(type: Jar) { classifier = 'sources' from sourceSets.main.allSource } artifacts { archives javadocJar archives sourcesJar } if (hasProperty('release')) { apply from: 'release.gradle' } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in # double quotes to make sure that they get re-expanded; and # * put everything else in single quotes, so that it's not re-expanded. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ org.gradle.wrapper.GradleWrapperMain \ "$@" # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: release.gradle ================================================ publishing { publications { mavenJava(MavenPublication) { from components.java artifact sourcesJar artifact javadocJar pom { name = 'web-push' description = 'A Web Push library for Java.' url = 'https://github.com/web-push-libs/webpush-java' scm { connection = 'scm:git:git@github.com:web-push-libs/webpush-java.git' developerConnection = 'scm:git:git@github.com:web-push-libs/webpush-java.git' url = 'git@github.com:web-push-libs/webpush-java.git' } licenses { license { name = 'MIT License' url = 'https://opensource.org/licenses/MIT' } } developers { developer { id = 'martijndwars' name = 'Martijn Dwars' email = 'ikben@martijndwars.nl' } } } } } repositories { maven { credentials { username ossrhUsername password ossrhPassword } url getRepositoryUrl() } } } 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/' } } ================================================ FILE: renovate.json ================================================ { "extends": [ "config:base" ] } ================================================ FILE: scripts/version.sh ================================================ #!/usr/bin/env sh set -eu if [ "$#" -ne 2 ]; then echo "Usage: version.sh OLD_VERSION NEW_VERSION" && exit 1 fi OLD_VERSION=$1 NEW_VERSION=$2 files=( "build.gradle" "README.md" ) for file in ${files[@]}; do sed -i '' "s/$OLD_VERSION/$NEW_VERSION/g" $file done ================================================ FILE: settings.gradle ================================================ rootProject.name = 'web-push' ================================================ FILE: src/main/java/nl/martijndwars/webpush/AbstractPushService.java ================================================ package nl.martijndwars.webpush; import org.bouncycastle.jce.ECNamedCurveTable; import org.bouncycastle.jce.interfaces.ECPublicKey; import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec; import org.jose4j.jws.AlgorithmIdentifiers; import org.jose4j.jws.JsonWebSignature; import org.jose4j.jwt.JwtClaims; import org.jose4j.lang.JoseException; import java.io.IOException; import java.security.GeneralSecurityException; import java.security.InvalidAlgorithmParameterException; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; import java.security.PrivateKey; import java.security.PublicKey; import java.security.SecureRandom; import java.security.spec.InvalidKeySpecException; import java.util.Base64; import java.util.HashMap; import java.util.Map; public abstract class AbstractPushService> { private static final SecureRandom SECURE_RANDOM = new SecureRandom(); public static final String SERVER_KEY_ID = "server-key-id"; public static final String SERVER_KEY_CURVE = "P-256"; /** * The Google Cloud Messaging API key (for pre-VAPID in Chrome) */ private String gcmApiKey; /** * Subject used in the JWT payload (for VAPID). When left as null, then no subject will be used * (RFC-8292 2.1 says that it is optional) */ private String subject; /** * The public key (for VAPID) */ private PublicKey publicKey; /** * The private key (for VAPID) */ private PrivateKey privateKey; public AbstractPushService() { } public AbstractPushService(String gcmApiKey) { this.gcmApiKey = gcmApiKey; } public AbstractPushService(KeyPair keyPair) { this.publicKey = keyPair.getPublic(); this.privateKey = keyPair.getPrivate(); } public AbstractPushService(KeyPair keyPair, String subject) { this(keyPair); this.subject = subject; } public AbstractPushService(String publicKey, String privateKey) throws GeneralSecurityException { this.publicKey = Utils.loadPublicKey(publicKey); this.privateKey = Utils.loadPrivateKey(privateKey); } public AbstractPushService(String publicKey, String privateKey, String subject) throws GeneralSecurityException { this(publicKey, privateKey); this.subject = subject; } /** * Encrypt the payload. * * Encryption uses Elliptic curve Diffie-Hellman (ECDH) cryptography over the prime256v1 curve. * * @param payload Payload to encrypt. * @param userPublicKey The user agent's public key (keys.p256dh). * @param userAuth The user agent's authentication secret (keys.auth). * @param encoding * @return An Encrypted object containing the public key, salt, and ciphertext. * @throws GeneralSecurityException */ public static Encrypted encrypt(byte[] payload, ECPublicKey userPublicKey, byte[] userAuth, Encoding encoding) throws GeneralSecurityException { KeyPair localKeyPair = generateLocalKeyPair(); Map keys = new HashMap<>(); keys.put(SERVER_KEY_ID, localKeyPair); Map labels = new HashMap<>(); labels.put(SERVER_KEY_ID, SERVER_KEY_CURVE); byte[] salt = new byte[16]; SECURE_RANDOM.nextBytes(salt); HttpEce httpEce = new HttpEce(keys, labels); byte[] ciphertext = httpEce.encrypt(payload, salt, null, SERVER_KEY_ID, userPublicKey, userAuth, encoding); return new Encrypted.Builder() .withSalt(salt) .withPublicKey(localKeyPair.getPublic()) .withCiphertext(ciphertext) .build(); } /** * Generate the local (ephemeral) keys. * * @return * @throws NoSuchAlgorithmException * @throws NoSuchProviderException * @throws InvalidAlgorithmParameterException */ private static KeyPair generateLocalKeyPair() throws NoSuchAlgorithmException, NoSuchProviderException, InvalidAlgorithmParameterException { ECNamedCurveParameterSpec parameterSpec = ECNamedCurveTable.getParameterSpec("prime256v1"); KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("ECDH", "BC"); keyPairGenerator.initialize(parameterSpec); return keyPairGenerator.generateKeyPair(); } protected final HttpRequest prepareRequest(Notification notification, Encoding encoding) throws GeneralSecurityException, IOException, JoseException { if (getPrivateKey() != null && getPublicKey() != null) { if (!Utils.verifyKeyPair(getPrivateKey(), getPublicKey())) { throw new IllegalStateException("Public key and private key do not match."); } } Encrypted encrypted = encrypt( notification.getPayload(), notification.getUserPublicKey(), notification.getUserAuth(), encoding ); byte[] dh = Utils.encode((ECPublicKey) encrypted.getPublicKey()); byte[] salt = encrypted.getSalt(); String url = notification.getEndpoint(); Map headers = new HashMap<>(); byte[] body = null; headers.put("TTL", String.valueOf(notification.getTTL())); if (notification.hasUrgency()) { headers.put("Urgency", notification.getUrgency().getHeaderValue()); } if (notification.hasTopic()) { headers.put("Topic", notification.getTopic()); } if (notification.hasPayload()) { headers.put("Content-Type", "application/octet-stream"); if (encoding == Encoding.AES128GCM) { headers.put("Content-Encoding", "aes128gcm"); } else if (encoding == Encoding.AESGCM) { headers.put("Content-Encoding", "aesgcm"); headers.put("Encryption", "salt=" + Base64.getUrlEncoder().withoutPadding().encodeToString(salt)); headers.put("Crypto-Key", "dh=" + Base64.getUrlEncoder().withoutPadding().encodeToString(dh)); } body = encrypted.getCiphertext(); } if (notification.isGcm()) { if (getGcmApiKey() == null) { throw new IllegalStateException("An GCM API key is needed to send a push notification to a GCM endpoint."); } headers.put("Authorization", "key=" + getGcmApiKey()); } else if (vapidEnabled()) { if (encoding == Encoding.AES128GCM) { if (notification.getEndpoint().startsWith("https://fcm.googleapis.com")) { url = notification.getEndpoint().replace("fcm/send", "wp"); } } JwtClaims claims = new JwtClaims(); claims.setAudience(notification.getOrigin()); claims.setExpirationTimeMinutesInTheFuture(12 * 60); if (getSubject() != null) { claims.setSubject(getSubject()); } JsonWebSignature jws = new JsonWebSignature(); jws.setHeader("typ", "JWT"); jws.setHeader("alg", "ES256"); jws.setPayload(claims.toJson()); jws.setKey(getPrivateKey()); jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.ECDSA_USING_P256_CURVE_AND_SHA256); byte[] pk = Utils.encode((ECPublicKey) getPublicKey()); if (encoding == Encoding.AES128GCM) { headers.put("Authorization", "vapid t=" + jws.getCompactSerialization() + ", k=" + Base64.getUrlEncoder().withoutPadding().encodeToString(pk)); } else if (encoding == Encoding.AESGCM) { headers.put("Authorization", "WebPush " + jws.getCompactSerialization()); } if (headers.containsKey("Crypto-Key")) { headers.put("Crypto-Key", headers.get("Crypto-Key") + ";p256ecdsa=" + Base64.getUrlEncoder().withoutPadding().encodeToString(pk)); } else { headers.put("Crypto-Key", "p256ecdsa=" + Base64.getUrlEncoder().withoutPadding().encodeToString(pk)); } } else if (notification.isFcm() && getGcmApiKey() != null) { headers.put("Authorization", "key=" + getGcmApiKey()); } return new HttpRequest(url, headers, body); } /** * Set the Google Cloud Messaging (GCM) API key * * @param gcmApiKey * @return */ public T setGcmApiKey(String gcmApiKey) { this.gcmApiKey = gcmApiKey; return (T) this; } public String getGcmApiKey() { return gcmApiKey; } public String getSubject() { return subject; } /** * Set the JWT subject (for VAPID) * * @param subject * @return */ public T setSubject(String subject) { this.subject = subject; return (T) this; } /** * Set the public and private key (for VAPID). * * @param keyPair * @return */ public T setKeyPair(KeyPair keyPair) { setPublicKey(keyPair.getPublic()); setPrivateKey(keyPair.getPrivate()); return (T) this; } public PublicKey getPublicKey() { return publicKey; } /** * Set the public key using a base64url-encoded string. * * @param publicKey * @return */ public T setPublicKey(String publicKey) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { setPublicKey(Utils.loadPublicKey(publicKey)); return (T) this; } public PrivateKey getPrivateKey() { return privateKey; } public KeyPair getKeyPair() { return new KeyPair(publicKey, privateKey); } /** * Set the public key (for VAPID) * * @param publicKey * @return */ public T setPublicKey(PublicKey publicKey) { this.publicKey = publicKey; return (T) this; } /** * Set the public key using a base64url-encoded string. * * @param privateKey * @return */ public T setPrivateKey(String privateKey) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { setPrivateKey(Utils.loadPrivateKey(privateKey)); return (T) this; } /** * Set the private key (for VAPID) * * @param privateKey * @return */ public T setPrivateKey(PrivateKey privateKey) { this.privateKey = privateKey; return (T) this; } /** * Check if VAPID is enabled * * @return */ protected boolean vapidEnabled() { return publicKey != null && privateKey != null; } } ================================================ FILE: src/main/java/nl/martijndwars/webpush/ClosableCallback.java ================================================ package nl.martijndwars.webpush; import java.io.IOException; import org.apache.http.HttpResponse; import org.apache.http.concurrent.FutureCallback; import org.apache.http.impl.nio.client.CloseableHttpAsyncClient; /** * Java 7's try-with-resource closes the client before the future is completed. * This callback captures the client and closes it once the request is * completed. * * See also http://stackoverflow.com/a/35962718/368220. */ public class ClosableCallback implements FutureCallback { private CloseableHttpAsyncClient closeableHttpAsyncClient; public ClosableCallback(CloseableHttpAsyncClient closeableHttpAsyncClient) { this.closeableHttpAsyncClient = closeableHttpAsyncClient; } @Override public void completed(HttpResponse httpResponse) { close(); } @Override public void failed(Exception e) { close(); } @Override public void cancelled() { close(); } private void close() { try { closeableHttpAsyncClient.close(); } catch (IOException e) { e.printStackTrace(); } } } ================================================ FILE: src/main/java/nl/martijndwars/webpush/Encoding.java ================================================ package nl.martijndwars.webpush; public enum Encoding { AESGCM, AES128GCM } ================================================ FILE: src/main/java/nl/martijndwars/webpush/Encrypted.java ================================================ package nl.martijndwars.webpush; import java.security.PublicKey; public class Encrypted { private final PublicKey publicKey; private final byte[] salt; private final byte[] ciphertext; public Encrypted(final PublicKey publicKey, final byte[] salt, final byte[] ciphertext) { this.publicKey = publicKey; this.salt = salt; this.ciphertext = ciphertext; } public PublicKey getPublicKey() { return publicKey; } public byte[] getSalt() { return salt; } public byte[] getCiphertext() { return ciphertext; } public static class Builder { private PublicKey publicKey; private byte[] salt; private byte[] ciphertext; public Builder withPublicKey(PublicKey publicKey) { this.publicKey = publicKey; return this; } public Builder withSalt(byte[] salt) { this.salt = salt; return this; } public Builder withCiphertext(byte[] ciphertext) { this.ciphertext = ciphertext; return this; } public Encrypted build() { return new Encrypted(publicKey, salt, ciphertext); } } } ================================================ FILE: src/main/java/nl/martijndwars/webpush/HttpEce.java ================================================ package nl.martijndwars.webpush; import org.bouncycastle.crypto.digests.SHA256Digest; import org.bouncycastle.crypto.generators.HKDFBytesGenerator; import org.bouncycastle.crypto.params.HKDFParameters; import org.bouncycastle.jce.interfaces.ECPrivateKey; import org.bouncycastle.jce.interfaces.ECPublicKey; import javax.crypto.*; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.ByteBuffer; import java.security.*; import java.util.Arrays; import java.util.Base64; import java.util.HashMap; import java.util.Map; import static java.nio.charset.StandardCharsets.UTF_8; import static javax.crypto.Cipher.DECRYPT_MODE; import static javax.crypto.Cipher.ENCRYPT_MODE; import static nl.martijndwars.webpush.Utils.*; /** * An implementation of Encrypted Content-Encoding for HTTP. * * The first implementation follows the specification in [1]. The specification later moved from * "aesgcm" to "aes128gcm" as content encoding [2]. To remain backwards compatible this library * supports both. * * [1] https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-01 * [2] https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-09 * * TODO: Support multiple records (not needed for Web Push) */ public class HttpEce { public static final int KEY_LENGTH = 16; public static final int SHA_256_LENGTH = 32; public static final int TAG_SIZE = 16; public static final int TWO_BYTE_MAX = 65_536; public static final String WEB_PUSH_INFO = "WebPush: info\0"; private Map keys; private Map labels; public HttpEce() { this(new HashMap(), new HashMap()); } public HttpEce(Map keys, Map labels) { this.keys = keys; this.labels = labels; } /** * Encrypt the given plaintext. * * @param plaintext Payload to encrypt. * @param salt A random 16-byte buffer * @param privateKey A private key to encrypt this message with (Web Push: the local private key) * @param keyid An identifier for the local key. Only applies to AESGCM. For AES128GCM, the header contains the keyid. * @param dh An Elliptic curve Diffie-Hellman public privateKey on the P-256 curve (Web Push: the user's keys.p256dh) * @param authSecret An authentication secret (Web Push: the user's keys.auth) * @param version * @return * @throws GeneralSecurityException */ public byte[] encrypt(byte[] plaintext, byte[] salt, byte[] privateKey, String keyid, ECPublicKey dh, byte[] authSecret, Encoding version) throws GeneralSecurityException { log("encrypt", plaintext); byte[][] keyAndNonce = deriveKeyAndNonce(salt, privateKey, keyid, dh, authSecret, version, ENCRYPT_MODE); byte[] key = keyAndNonce[0]; byte[] nonce = keyAndNonce[1]; // Note: Cipher adds the tag to the end of the ciphertext Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding", "BC"); GCMParameterSpec params = new GCMParameterSpec(TAG_SIZE * 8, nonce); cipher.init(ENCRYPT_MODE, new SecretKeySpec(key, "AES"), params); // For AES128GCM suffix {0x02}, for AESGCM prefix {0x00, 0x00}. if (version == Encoding.AES128GCM) { byte[] header = buildHeader(salt, keyid); log("header", header); byte[] padding = new byte[] { 2 }; log("padding", padding); byte[][] encrypted = {cipher.update(plaintext), cipher.update(padding), cipher.doFinal()}; log("encrypted", concat(encrypted)); return log("ciphertext", concat(header, concat(encrypted))); } else { return concat(cipher.update(new byte[2]), cipher.doFinal(plaintext)); } } /** * Decrypt the payload. * * @param payload Header and body (ciphertext) * @param salt May be null when version is AES128GCM; the salt is extracted from the header. * @param version AES128GCM or AESGCM. * @return */ public byte[] decrypt(byte[] payload, byte[] salt, byte[] key, String keyid, Encoding version) throws InvalidKeyException, NoSuchAlgorithmException, IllegalBlockSizeException, InvalidAlgorithmParameterException, BadPaddingException, NoSuchProviderException, NoSuchPaddingException { byte[] body; // Parse and strip the header if (version == Encoding.AES128GCM) { byte[][] header = parseHeader(payload); salt = header[0]; keyid = new String(header[2]); body = header[3]; } else { body = payload; } // Derive key and nonce. byte[][] keyAndNonce = deriveKeyAndNonce(salt, key, keyid, null, null, version, DECRYPT_MODE); return decryptRecord(body, keyAndNonce[0], keyAndNonce[1], version); } public byte[][] parseHeader(byte[] payload) { byte[] salt = Arrays.copyOfRange(payload, 0, KEY_LENGTH); byte[] recordSize = Arrays.copyOfRange(payload, KEY_LENGTH, 20); int keyIdLength = Arrays.copyOfRange(payload, 20, 21)[0]; byte[] keyId = Arrays.copyOfRange(payload, 21, 21 + keyIdLength); byte[] body = Arrays.copyOfRange(payload, 21 + keyIdLength, payload.length); return new byte[][] { salt, recordSize, keyId, body }; } public byte[] decryptRecord(byte[] ciphertext, byte[] key, byte[] nonce, Encoding version) throws NoSuchPaddingException, NoSuchAlgorithmException, NoSuchProviderException, InvalidAlgorithmParameterException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding", "BC"); GCMParameterSpec params = new GCMParameterSpec(TAG_SIZE * 8, nonce); cipher.init(DECRYPT_MODE, new SecretKeySpec(key, "AES"), params); byte[] plaintext = cipher.doFinal(ciphertext); if (version == Encoding.AES128GCM) { // Remove one byte of padding at the end return Arrays.copyOfRange(plaintext, 0, plaintext.length - 1); } else { // Remove two bytes of padding at the start return Arrays.copyOfRange(plaintext, 2, plaintext.length); } } /** * Compute the Encryption Content Coding Header. * * See https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-09#section-2.1. * * @param salt Array of 16 bytes * @param keyid * @return */ private byte[] buildHeader(byte[] salt, String keyid) { byte[] keyIdBytes; if (keyid == null) { keyIdBytes = new byte[0]; } else { keyIdBytes = encode(getPublicKey(keyid)); } if (keyIdBytes.length > 255) { throw new IllegalArgumentException("They keyid is too large."); } byte[] rs = toByteArray(4096, 4); byte[] idlen = new byte[] { (byte) keyIdBytes.length }; return concat(salt, rs, idlen, keyIdBytes); } /** * Future versions might require a null-terminated info string? * * @param type * @return */ protected static byte[] buildInfo(String type, byte[] context) { ByteBuffer buffer = ByteBuffer.allocate(19 + type.length() + context.length); buffer.put("Content-Encoding: ".getBytes(UTF_8), 0, 18); buffer.put(type.getBytes(UTF_8), 0, type.length()); buffer.put(new byte[1], 0, 1); buffer.put(context, 0, context.length); return buffer.array(); } /** * Convenience method for computing the HMAC Key Derivation Function. The real work is offloaded to BouncyCastle. */ protected static byte[] hkdfExpand(byte[] ikm, byte[] salt, byte[] info, int length) { log("salt", salt); log("ikm", ikm); log("info", info); HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA256Digest()); hkdf.init(new HKDFParameters(ikm, salt, info)); byte[] okm = new byte[length]; hkdf.generateBytes(okm, 0, length); log("expand", okm); return okm; } public byte[][] extractSecretAndContext(byte[] key, String keyId, ECPublicKey dh, byte[] authSecret) throws InvalidKeyException, NoSuchAlgorithmException { byte[] secret = null; byte[] context = null; if (key != null) { secret = key; if (secret.length != KEY_LENGTH) { throw new IllegalStateException("An explicit key must be " + KEY_LENGTH + " bytes."); } } else if (dh != null) { byte[][] bytes = extractDH(keyId, dh); secret = bytes[0]; context = bytes[1]; } else if (keyId != null) { secret = keys.get(keyId).getPublic().getEncoded(); } if (secret == null) { throw new IllegalStateException("Unable to determine key."); } if (authSecret != null) { secret = hkdfExpand(secret, authSecret, buildInfo("auth", new byte[0]), SHA_256_LENGTH); } return new byte[][]{ secret, context }; } public byte[][] deriveKeyAndNonce(byte[] salt, byte[] key, String keyId, ECPublicKey dh, byte[] authSecret, Encoding version, int mode) throws NoSuchAlgorithmException, InvalidKeyException { byte[] secret; byte[] keyInfo; byte[] nonceInfo; if (version == Encoding.AESGCM) { byte[][] secretAndContext = extractSecretAndContext(key, keyId, dh, authSecret); secret = secretAndContext[0]; keyInfo = buildInfo("aesgcm", secretAndContext[1]); nonceInfo = buildInfo("nonce", secretAndContext[1]); } else if (version == Encoding.AES128GCM) { keyInfo = "Content-Encoding: aes128gcm\0".getBytes(); nonceInfo = "Content-Encoding: nonce\0".getBytes(); secret = extractSecret(key, keyId, dh, authSecret, mode); } else { throw new IllegalStateException("Unknown version: " + version); } byte[] hkdf_key = hkdfExpand(secret, salt, keyInfo, 16); byte[] hkdf_nonce = hkdfExpand(secret, salt, nonceInfo, 12); log("key", hkdf_key); log("nonce", hkdf_nonce); return new byte[][]{ hkdf_key, hkdf_nonce }; } private byte[] extractSecret(byte[] key, String keyId, ECPublicKey dh, byte[] authSecret, int mode) throws InvalidKeyException, NoSuchAlgorithmException { if (key != null) { if (key.length != KEY_LENGTH) { throw new IllegalArgumentException("An explicit key must be " + KEY_LENGTH + " bytes."); } return key; } if (dh == null) { KeyPair keyPair = keys.get(keyId); if (keyPair == null) { throw new IllegalArgumentException("No saved key for keyid '" + keyId + "'."); } return encode((ECPublicKey) keyPair.getPublic()); } return webpushSecret(keyId, dh, authSecret, mode); } /** * Combine Shared and Authentication Secrets * * See https://tools.ietf.org/html/draft-ietf-webpush-encryption-09#section-3.3. * * @param keyId * @param dh * @param authSecret * @param mode * @return * @throws NoSuchAlgorithmException * @throws InvalidKeyException */ public byte[] webpushSecret(String keyId, ECPublicKey dh, byte[] authSecret, int mode) throws NoSuchAlgorithmException, InvalidKeyException { ECPublicKey senderPubKey; ECPublicKey remotePubKey; ECPublicKey receiverPubKey; if (mode == ENCRYPT_MODE) { senderPubKey = getPublicKey(keyId); remotePubKey = dh; receiverPubKey = dh; } else if (mode == DECRYPT_MODE) { remotePubKey = getPublicKey(keyId); senderPubKey = remotePubKey; receiverPubKey = dh; } else { throw new IllegalArgumentException("Unsupported mode: " + mode); } log("remote pubkey", encode(remotePubKey)); log("sender pubkey", encode(senderPubKey)); log("receiver pubkey", encode(receiverPubKey)); KeyAgreement keyAgreement = KeyAgreement.getInstance("ECDH"); keyAgreement.init(getPrivateKey(keyId)); keyAgreement.doPhase(remotePubKey, true); byte[] secret = keyAgreement.generateSecret(); byte[] ikm = secret; byte[] salt = authSecret; byte[] info = concat(WEB_PUSH_INFO.getBytes(), encode(receiverPubKey), encode(senderPubKey)); return hkdfExpand(ikm, salt, info, SHA_256_LENGTH); } /** * Compute the shared secret (using the server's key pair and the client's public key) and the context. * * @param keyid * @param publicKey * @return */ private byte[][] extractDH(String keyid, ECPublicKey publicKey) throws NoSuchAlgorithmException, InvalidKeyException { ECPublicKey senderPubKey = getPublicKey(keyid); KeyAgreement keyAgreement = KeyAgreement.getInstance("ECDH"); keyAgreement.init(getPrivateKey(keyid)); keyAgreement.doPhase(publicKey, true); byte[] secret = keyAgreement.generateSecret(); byte[] context = concat(labels.get(keyid).getBytes(UTF_8), new byte[1], lengthPrefix(publicKey), lengthPrefix(senderPubKey)); return new byte[][]{ secret, context }; } /** * Get the public key for the given keyid. * * @param keyid * @return */ private ECPublicKey getPublicKey(String keyid) { return (ECPublicKey) keys.get(keyid).getPublic(); } /** * Get the private key for the given keyid. * * @param keyid * @return */ private ECPrivateKey getPrivateKey(String keyid) { return (ECPrivateKey) keys.get(keyid).getPrivate(); } /** * Encode the public key as a byte array and prepend its length in two bytes. * * @param publicKey * @return */ private static byte[] lengthPrefix(ECPublicKey publicKey) { byte[] bytes = encode(publicKey); return concat(intToBytes(bytes.length), bytes); } /** * Convert an integer number to a two-byte binary number. * * This implementation: * 1. masks all but the lowest eight bits * 2. discards the lowest eight bits by moving all bits 8 places to the right. * * @param number * @return */ private static byte[] intToBytes(int number) { if (number < 0) { throw new IllegalArgumentException("Cannot convert a negative number, " + number + " given."); } if (number >= TWO_BYTE_MAX) { throw new IllegalArgumentException("Cannot convert an integer larger than " + (TWO_BYTE_MAX - 1) + " to two bytes."); } byte[] bytes = new byte[2]; bytes[1] = (byte) (number & 0xff); bytes[0] = (byte) (number >> 8); return bytes; } /** * Print the length and unpadded url-safe base64 encoding of the byte array. * * @param info * @param array * @return */ private static byte[] log(String info, byte[] array) { if ("1".equals(System.getenv("ECE_KEYLOG"))) { System.out.println(info + " [" + array.length + "]: " + Base64.getUrlEncoder().withoutPadding().encodeToString(array)); } return array; } } ================================================ FILE: src/main/java/nl/martijndwars/webpush/HttpRequest.java ================================================ package nl.martijndwars.webpush; import java.util.Map; public class HttpRequest { private final String url; private final Map headers; private final byte[] body; public HttpRequest(String url, Map headers, byte[] body) { this.url = url; this.headers = headers; this.body = body; } public String getUrl() { return url; } public Map getHeaders() { return headers; } public byte[] getBody() { return body; } } ================================================ FILE: src/main/java/nl/martijndwars/webpush/Notification.java ================================================ package nl.martijndwars.webpush; import org.bouncycastle.jce.interfaces.ECPublicKey; import java.net.MalformedURLException; import java.net.URL; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; import java.security.PublicKey; import java.security.spec.InvalidKeySpecException; import java.util.Base64; import static java.nio.charset.StandardCharsets.UTF_8; public class Notification { /** * The endpoint associated with the push subscription */ private final String endpoint; /** * The client's public key */ private final ECPublicKey userPublicKey; /** * The client's auth */ private final byte[] userAuth; /** * An arbitrary payload */ private final byte[] payload; /** * Push Message Urgency * * @see Push Message Urgency * */ private Urgency urgency; /** * Push Message Topic * * @see Replacing Push Messages * */ private String topic; /** * Time in seconds that the push message is retained by the push service */ private final int ttl; private static final int ONE_DAY_DURATION_IN_SECONDS = 86400; private static int DEFAULT_TTL = 28 * ONE_DAY_DURATION_IN_SECONDS; public Notification(String endpoint, ECPublicKey userPublicKey, byte[] userAuth, byte[] payload, int ttl, Urgency urgency, String topic) { this.endpoint = endpoint; this.userPublicKey = userPublicKey; this.userAuth = userAuth; this.payload = payload; this.ttl = ttl; this.urgency = urgency; this.topic = topic; } public Notification(String endpoint, PublicKey userPublicKey, byte[] userAuth, byte[] payload, int ttl) { this(endpoint, (ECPublicKey) userPublicKey, userAuth, payload, ttl, null, null); } public Notification(String endpoint, String userPublicKey, String userAuth, byte[] payload, int ttl) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { this(endpoint, Utils.loadPublicKey(userPublicKey), Base64.getUrlDecoder().decode(userAuth), payload, ttl); } public Notification(String endpoint, PublicKey userPublicKey, byte[] userAuth, byte[] payload) { this(endpoint, userPublicKey, userAuth, payload, DEFAULT_TTL); } public Notification(String endpoint, String userPublicKey, String userAuth, byte[] payload) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { this(endpoint, Utils.loadPublicKey(userPublicKey), Base64.getUrlDecoder().decode(userAuth), payload); } public Notification(String endpoint, String userPublicKey, String userAuth, String payload) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { 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(Subscription subscription, String payload) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { this(subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth, payload); } public Notification(Subscription subscription, String payload, Urgency urgency) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { this(subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth, payload); this.urgency = urgency; } public String getEndpoint() { return endpoint; } public ECPublicKey getUserPublicKey() { return userPublicKey; } public byte[] getUserAuth() { return userAuth; } public byte[] getPayload() { return payload; } public boolean hasPayload() { return getPayload().length > 0; } public boolean hasUrgency() { return urgency != null; } public boolean hasTopic() { return topic != null; } /** * Detect if the notification is for a GCM-based subscription * * @return */ public boolean isGcm() { return getEndpoint().indexOf("https://android.googleapis.com/gcm/send") == 0; } public boolean isFcm() { return getEndpoint().indexOf("https://fcm.googleapis.com/fcm/send") == 0; } public int getTTL() { return ttl; } public Urgency getUrgency() { return urgency; } public String getTopic() { return topic; } public String getOrigin() throws MalformedURLException { URL url = new URL(getEndpoint()); return url.getProtocol() + "://" + url.getHost(); } public static NotificationBuilder builder() { return new Notification.NotificationBuilder(); } public static class NotificationBuilder { private String endpoint = null; private ECPublicKey userPublicKey = null; private byte[] userAuth = null; private byte[] payload = null; private int ttl = DEFAULT_TTL; private Urgency urgency = null; private String topic = null; private NotificationBuilder() { } public Notification build() { return new Notification(endpoint, userPublicKey, userAuth, payload, ttl, urgency, topic); } public NotificationBuilder endpoint(String endpoint) { this.endpoint = endpoint; return this; } public NotificationBuilder userPublicKey(PublicKey publicKey) { this.userPublicKey = (ECPublicKey) publicKey; return this; } public NotificationBuilder userPublicKey(String publicKey) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { this.userPublicKey = (ECPublicKey) Utils.loadPublicKey(publicKey); return this; } public NotificationBuilder userPublicKey(byte[] publicKey) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { this.userPublicKey = (ECPublicKey) Utils.loadPublicKey(publicKey); return this; } public NotificationBuilder userAuth(String userAuth) { this.userAuth = Base64.getUrlDecoder().decode(userAuth); return this; } public NotificationBuilder userAuth(byte[] userAuth) { this.userAuth = userAuth; return this; } public NotificationBuilder payload(byte[] payload) { this.payload = payload; return this; } public NotificationBuilder payload(String payload) { this.payload = payload.getBytes(UTF_8); return this; } public NotificationBuilder ttl(int ttl) { this.ttl = ttl; return this; } public NotificationBuilder urgency(Urgency urgency) { this.urgency = urgency; return this; } public NotificationBuilder topic(String topic) { this.topic = topic; return this; } } } ================================================ FILE: src/main/java/nl/martijndwars/webpush/PushAsyncService.java ================================================ package nl.martijndwars.webpush; import org.asynchttpclient.AsyncHttpClient; import org.asynchttpclient.BoundRequestBuilder; import org.asynchttpclient.Response; import org.jose4j.lang.JoseException; import java.io.IOException; import java.security.GeneralSecurityException; import java.security.KeyPair; import java.util.concurrent.CompletableFuture; import static org.asynchttpclient.Dsl.asyncHttpClient; public class PushAsyncService extends AbstractPushService { private final AsyncHttpClient httpClient = asyncHttpClient(); public PushAsyncService() { } public PushAsyncService(String gcmApiKey) { super(gcmApiKey); } public PushAsyncService(KeyPair keyPair) { super(keyPair); } public PushAsyncService(KeyPair keyPair, String subject) { super(keyPair, subject); } public PushAsyncService(String publicKey, String privateKey) throws GeneralSecurityException { super(publicKey, privateKey); } public PushAsyncService(String publicKey, String privateKey, String subject) throws GeneralSecurityException { super(publicKey, privateKey, subject); } /** * Send a notification asynchronously. * * @param notification * @param encoding * @return * @throws GeneralSecurityException * @throws IOException * @throws JoseException */ public CompletableFuture send(Notification notification, Encoding encoding) throws GeneralSecurityException, IOException, JoseException { BoundRequestBuilder httpPost = preparePost(notification, encoding); return httpPost.execute().toCompletableFuture(); } public CompletableFuture send(Notification notification) throws GeneralSecurityException, IOException, JoseException { return send(notification, Encoding.AES128GCM); } /** * Prepare a POST request for AHC. * * @param notification * @param encoding * @return * @throws GeneralSecurityException * @throws IOException * @throws JoseException */ public BoundRequestBuilder preparePost(Notification notification, Encoding encoding) throws GeneralSecurityException, IOException, JoseException { HttpRequest request = prepareRequest(notification, encoding); BoundRequestBuilder httpPost = httpClient.preparePost(request.getUrl()); request.getHeaders().forEach(httpPost::addHeader); if (request.getBody() != null) { httpPost.setBody(request.getBody()); } return httpPost; } } ================================================ FILE: src/main/java/nl/martijndwars/webpush/PushService.java ================================================ package nl.martijndwars.webpush; import org.apache.http.HttpResponse; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.ByteArrayEntity; import org.apache.http.impl.nio.client.CloseableHttpAsyncClient; import org.apache.http.impl.nio.client.HttpAsyncClients; import org.apache.http.message.BasicHeader; import org.bouncycastle.jce.ECNamedCurveTable; import org.bouncycastle.jce.interfaces.ECPublicKey; import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec; import org.jose4j.jws.AlgorithmIdentifiers; import org.jose4j.jws.JsonWebSignature; import org.jose4j.jwt.JwtClaims; import org.jose4j.lang.JoseException; import java.io.IOException; import java.net.URI; import java.security.*; import java.security.spec.InvalidKeySpecException; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; public class PushService extends AbstractPushService { public PushService() { } public PushService(String gcmApiKey) { super(gcmApiKey); } public PushService(KeyPair keyPair) { super(keyPair); } public PushService(KeyPair keyPair, String subject) { super(keyPair, subject); } public PushService(String publicKey, String privateKey) throws GeneralSecurityException { super(publicKey, privateKey); } public PushService(String publicKey, String privateKey, String subject) throws GeneralSecurityException { super(publicKey, privateKey, subject); } /** * Send a notification and wait for the response. * * @param notification * @param encoding * @return * @throws GeneralSecurityException * @throws IOException * @throws JoseException * @throws ExecutionException * @throws InterruptedException */ public HttpResponse send(Notification notification, Encoding encoding) throws GeneralSecurityException, IOException, JoseException, ExecutionException, InterruptedException { return sendAsync(notification, encoding).get(); } public HttpResponse send(Notification notification) throws GeneralSecurityException, IOException, JoseException, ExecutionException, InterruptedException { return send(notification, Encoding.AES128GCM); } /** * Send a notification, but don't wait for the response. * * @param notification * @param encoding * @return * @throws GeneralSecurityException * @throws IOException * @throws JoseException * * @deprecated Use {@link PushAsyncService#send(Notification, Encoding)} instead. */ @Deprecated public Future sendAsync(Notification notification, Encoding encoding) throws GeneralSecurityException, IOException, JoseException { HttpPost httpPost = preparePost(notification, encoding); final CloseableHttpAsyncClient closeableHttpAsyncClient = HttpAsyncClients.createSystem(); closeableHttpAsyncClient.start(); return closeableHttpAsyncClient.execute(httpPost, new ClosableCallback(closeableHttpAsyncClient)); } /** * @deprecated Use {@link PushAsyncService#send(Notification)} instead. */ @Deprecated public Future sendAsync(Notification notification) throws GeneralSecurityException, IOException, JoseException { return sendAsync(notification, Encoding.AES128GCM); } /** * Prepare a HttpPost for Apache async http client * * @param notification * @param encoding * @return * @throws GeneralSecurityException * @throws IOException * @throws JoseException */ public HttpPost preparePost(Notification notification, Encoding encoding) throws GeneralSecurityException, IOException, JoseException { HttpRequest request = prepareRequest(notification, encoding); HttpPost httpPost = new HttpPost(request.getUrl()); request.getHeaders().forEach(httpPost::addHeader); if (request.getBody() != null) { httpPost.setEntity(new ByteArrayEntity(request.getBody())); } return httpPost; } } ================================================ FILE: src/main/java/nl/martijndwars/webpush/Subscription.java ================================================ package nl.martijndwars.webpush; public class Subscription { public String endpoint; public Keys keys; public Subscription() { // No-args constructor } public Subscription(String endpoint, Keys keys) { this.endpoint = endpoint; this.keys = keys; } public static class Keys { public String p256dh; public String auth; public Keys() { // No-args constructor } public Keys(String key, String auth) { this.p256dh = key; this.auth = auth; } } } ================================================ FILE: src/main/java/nl/martijndwars/webpush/Urgency.java ================================================ package nl.martijndwars.webpush; /** * Web Push Message Urgency header field values * * @see Push Message Urgency */ public enum Urgency { VERY_LOW("very-low"), LOW("low"), NORMAL("normal"), HIGH("high"); private final String headerValue; Urgency(String urgency) { this.headerValue = urgency; } public String getHeaderValue() { return headerValue; } } ================================================ FILE: src/main/java/nl/martijndwars/webpush/Utils.java ================================================ package nl.martijndwars.webpush; import org.bouncycastle.jce.ECNamedCurveTable; import org.bouncycastle.jce.interfaces.ECPrivateKey; import org.bouncycastle.jce.interfaces.ECPublicKey; import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec; import org.bouncycastle.jce.spec.ECParameterSpec; import org.bouncycastle.jce.spec.ECPrivateKeySpec; import org.bouncycastle.jce.spec.ECPublicKeySpec; import org.bouncycastle.math.ec.ECCurve; import org.bouncycastle.math.ec.ECPoint; import org.bouncycastle.util.BigIntegers; import java.math.BigInteger; import java.nio.ByteBuffer; import java.security.*; import java.security.spec.InvalidKeySpecException; import java.util.Base64; import static org.bouncycastle.jce.provider.BouncyCastleProvider.PROVIDER_NAME; public class Utils { public static final String CURVE = "prime256v1"; public static final String ALGORITHM = "ECDH"; /** * Get the uncompressed encoding of the public key point. The resulting array * should be 65 bytes length and start with 0x04 followed by the x and y * coordinates (32 bytes each). * * @param publicKey * @return */ public static byte[] encode(ECPublicKey publicKey) { return publicKey.getQ().getEncoded(false); } public static byte[] encode(ECPrivateKey privateKey) { return privateKey.getD().toByteArray(); } /** * Load the public key from a URL-safe base64 encoded string. Takes into * account the different encodings, including point compression. * * @param encodedPublicKey */ public static PublicKey loadPublicKey(String encodedPublicKey) throws NoSuchProviderException, NoSuchAlgorithmException, InvalidKeySpecException { byte[] decodedPublicKey = Base64.getUrlDecoder().decode(encodedPublicKey); return loadPublicKey(decodedPublicKey); } /** * Load the public key from a byte array. * * @param decodedPublicKey */ public static PublicKey loadPublicKey(byte[] decodedPublicKey) throws NoSuchProviderException, NoSuchAlgorithmException, InvalidKeySpecException { KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM, PROVIDER_NAME); ECParameterSpec parameterSpec = ECNamedCurveTable.getParameterSpec(CURVE); ECCurve curve = parameterSpec.getCurve(); ECPoint point = curve.decodePoint(decodedPublicKey); ECPublicKeySpec pubSpec = new ECPublicKeySpec(point, parameterSpec); return keyFactory.generatePublic(pubSpec); } /** * Load the private key from a URL-safe base64 encoded string * * @param encodedPrivateKey * @return * @throws NoSuchProviderException * @throws NoSuchAlgorithmException * @throws InvalidKeySpecException */ public static PrivateKey loadPrivateKey(String encodedPrivateKey) throws NoSuchProviderException, NoSuchAlgorithmException, InvalidKeySpecException { byte[] decodedPrivateKey = Base64.getUrlDecoder().decode(encodedPrivateKey); return loadPrivateKey(decodedPrivateKey); } /** * Load the private key from a byte array * * @param decodedPrivateKey * @return * @throws NoSuchProviderException * @throws NoSuchAlgorithmException * @throws InvalidKeySpecException */ public static PrivateKey loadPrivateKey(byte[] decodedPrivateKey) throws NoSuchProviderException, NoSuchAlgorithmException, InvalidKeySpecException { BigInteger s = BigIntegers.fromUnsignedByteArray(decodedPrivateKey); ECParameterSpec parameterSpec = ECNamedCurveTable.getParameterSpec(CURVE); ECPrivateKeySpec privateKeySpec = new ECPrivateKeySpec(s, parameterSpec); KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM, PROVIDER_NAME); return keyFactory.generatePrivate(privateKeySpec); } /** * Load a public key from the private key. * * @param privateKey * @return */ public static ECPublicKey loadPublicKey(ECPrivateKey privateKey) throws NoSuchProviderException, NoSuchAlgorithmException, InvalidKeySpecException { KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM, PROVIDER_NAME); ECParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec(CURVE); ECPoint Q = ecSpec.getG().multiply(privateKey.getD()); byte[] publicDerBytes = Q.getEncoded(false); ECPoint point = ecSpec.getCurve().decodePoint(publicDerBytes); ECPublicKeySpec pubSpec = new ECPublicKeySpec(point, ecSpec); return (ECPublicKey) keyFactory.generatePublic(pubSpec); } /** * Verify that the private key belongs to the public key. * * @param privateKey * @param publicKey * @return */ public static boolean verifyKeyPair(PrivateKey privateKey, PublicKey publicKey) { ECNamedCurveParameterSpec curveParameters = ECNamedCurveTable.getParameterSpec(CURVE); ECPoint g = curveParameters.getG(); ECPoint sG = g.multiply(((java.security.interfaces.ECPrivateKey) privateKey).getS()); return sG.equals(((ECPublicKey) publicKey).getQ()); } /** * Utility to concat byte arrays */ public static byte[] concat(byte[]... arrays) { int lastPos = 0; byte[] combined = new byte[combinedLength(arrays)]; for (byte[] array : arrays) { if (array == null) { continue; } System.arraycopy(array, 0, combined, lastPos, array.length); lastPos += array.length; } return combined; } /** * Compute combined array length */ public static int combinedLength(byte[]... arrays) { int combinedLength = 0; for (byte[] array : arrays) { if (array == null) { continue; } combinedLength += array.length; } return combinedLength; } /** * Create a byte array of the given length from the given integer. * * @param integer * @param size * @return */ public static byte[] toByteArray(int integer, int size) { ByteBuffer buffer = ByteBuffer.allocate(size); buffer.putInt(integer); return buffer.array(); } } ================================================ FILE: src/main/java/nl/martijndwars/webpush/cli/Cli.java ================================================ package nl.martijndwars.webpush.cli; import com.beust.jcommander.JCommander; import com.beust.jcommander.ParameterException; import nl.martijndwars.webpush.cli.commands.GenerateKeyCommand; import nl.martijndwars.webpush.cli.commands.SendNotificationCommand; import nl.martijndwars.webpush.cli.handlers.GenerateKeyHandler; import nl.martijndwars.webpush.cli.handlers.SendNotificationHandler; import org.bouncycastle.jce.provider.BouncyCastleProvider; import java.security.Security; /** * Command-line interface */ public class Cli { private static final String GENERATE_KEY = "generate-key"; private static final String SEND_NOTIFICATION = "send-notification"; public static void main(String[] args) { Security.addProvider(new BouncyCastleProvider()); GenerateKeyCommand generateKeyCommand = new GenerateKeyCommand(); SendNotificationCommand sendNotificationCommand = new SendNotificationCommand(); JCommander jCommander = JCommander.newBuilder() .addCommand(GENERATE_KEY, generateKeyCommand) .addCommand(SEND_NOTIFICATION, sendNotificationCommand) .build(); try { jCommander.parse(args); if (jCommander.getParsedCommand() != null) { switch (jCommander.getParsedCommand()) { case GENERATE_KEY: new GenerateKeyHandler(generateKeyCommand).run(); break; case SEND_NOTIFICATION: new SendNotificationHandler(sendNotificationCommand).run(); break; } } else { jCommander.usage(); } } catch (ParameterException e) { e.usage(); } catch (Exception e) { e.printStackTrace(); } } } ================================================ FILE: src/main/java/nl/martijndwars/webpush/cli/commands/GenerateKeyCommand.java ================================================ package nl.martijndwars.webpush.cli.commands; import com.beust.jcommander.Parameter; import com.beust.jcommander.Parameters; @Parameters(separators = "=", commandDescription = "Generate a VAPID keypair") public class GenerateKeyCommand { @Parameter(names = "--publicKeyFile", description = "File to write keypair to.") private String publicKeyFile; public Boolean hasPublicKeyFile() { return publicKeyFile != null; } public String getPublicKeyFile() { return publicKeyFile; } } ================================================ FILE: src/main/java/nl/martijndwars/webpush/cli/commands/SendNotificationCommand.java ================================================ package nl.martijndwars.webpush.cli.commands; import com.beust.jcommander.Parameter; import com.beust.jcommander.Parameters; import nl.martijndwars.webpush.Subscription; @Parameters(separators = "=", commandDescription = "Send a push notification") public class SendNotificationCommand { @Parameter(names = "--endpoint", description = "The push subscription URL.", required = true) private String endpoint; @Parameter(names = "--key", description = "The user public encryption key.", required = true) private String key; @Parameter(names = "--auth", description = "The user auth secret.", required = true) private String auth; @Parameter(names = "--publicKey", description = "The public key as base64url encoded string.", required = true) private String publicKey; @Parameter(names = "--privateKey", description = "The private key as base64url encoded string.", required = true) private String privateKey; @Parameter(names = "--payload", description = "The message to send.") private String payload = "Hello world"; @Parameter(names = "--ttl", description = "The number of seconds that the push service should retain the message.") private int ttl; public Subscription getSubscription() { return new Subscription(endpoint, new Subscription.Keys(key, auth)); } public String getPublicKey() { return publicKey; } public String getPrivateKey() { return privateKey; } public String getPayload() { return payload; } public int getTtl() { return ttl; } } ================================================ FILE: src/main/java/nl/martijndwars/webpush/cli/handlers/GenerateKeyHandler.java ================================================ package nl.martijndwars.webpush.cli.handlers; import nl.martijndwars.webpush.Utils; import nl.martijndwars.webpush.cli.commands.GenerateKeyCommand; import org.bouncycastle.jce.ECNamedCurveTable; import org.bouncycastle.jce.interfaces.ECPrivateKey; import org.bouncycastle.jce.interfaces.ECPublicKey; import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec; import org.bouncycastle.util.io.pem.PemObject; import org.bouncycastle.util.io.pem.PemWriter; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStreamWriter; import java.security.*; import java.util.Base64; import static nl.martijndwars.webpush.Utils.ALGORITHM; import static nl.martijndwars.webpush.Utils.CURVE; import static org.bouncycastle.jce.provider.BouncyCastleProvider.PROVIDER_NAME; public class GenerateKeyHandler implements HandlerInterface { private GenerateKeyCommand generateKeyCommand; public GenerateKeyHandler(GenerateKeyCommand generateKeyCommand) { this.generateKeyCommand = generateKeyCommand; } @Override public void run() throws InvalidAlgorithmParameterException, NoSuchAlgorithmException, NoSuchProviderException, IOException { KeyPair keyPair = generateKeyPair(); ECPublicKey publicKey = (ECPublicKey) keyPair.getPublic(); ECPrivateKey privateKey = (ECPrivateKey) keyPair.getPrivate(); byte[] encodedPublicKey = Utils.encode(publicKey); byte[] encodedPrivateKey = Utils.encode(privateKey); if (generateKeyCommand.hasPublicKeyFile()) { writeKey(keyPair.getPublic(), new File(generateKeyCommand.getPublicKeyFile())); } System.out.println("PublicKey:"); System.out.println(Base64.getUrlEncoder().withoutPadding().encodeToString(encodedPublicKey)); System.out.println("PrivateKey:"); System.out.println(Base64.getUrlEncoder().withoutPadding().encodeToString(encodedPrivateKey)); } /** * Generate an EC keypair on the prime256v1 curve. * * @return * @throws InvalidAlgorithmParameterException * @throws NoSuchProviderException * @throws NoSuchAlgorithmException */ public KeyPair generateKeyPair() throws InvalidAlgorithmParameterException, NoSuchProviderException, NoSuchAlgorithmException { ECNamedCurveParameterSpec parameterSpec = ECNamedCurveTable.getParameterSpec(CURVE); KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(ALGORITHM, PROVIDER_NAME); keyPairGenerator.initialize(parameterSpec); return keyPairGenerator.generateKeyPair(); } /** * Write the given key to the given file. * * @param key * @param file */ private void writeKey(Key key, File file) throws IOException { file.createNewFile(); try (PemWriter pemWriter = new PemWriter(new OutputStreamWriter(new FileOutputStream(file)))) { PemObject pemObject = new PemObject("Key", key.getEncoded()); pemWriter.writeObject(pemObject); } } } ================================================ FILE: src/main/java/nl/martijndwars/webpush/cli/handlers/HandlerInterface.java ================================================ package nl.martijndwars.webpush.cli.handlers; import java.security.InvalidAlgorithmParameterException; public interface HandlerInterface { public void run() throws Exception; } ================================================ FILE: src/main/java/nl/martijndwars/webpush/cli/handlers/SendNotificationHandler.java ================================================ package nl.martijndwars.webpush.cli.handlers; import nl.martijndwars.webpush.Notification; import nl.martijndwars.webpush.PushService; import nl.martijndwars.webpush.Subscription; import nl.martijndwars.webpush.cli.commands.SendNotificationCommand; import org.apache.http.HttpResponse; public class SendNotificationHandler implements HandlerInterface { private SendNotificationCommand sendNotificationCommand; public SendNotificationHandler(SendNotificationCommand sendNotificationCommand) { this.sendNotificationCommand = sendNotificationCommand; } @Override public void run() throws Exception { PushService pushService = new PushService() .setPublicKey(sendNotificationCommand.getPublicKey()) .setPrivateKey(sendNotificationCommand.getPrivateKey()) .setSubject("mailto:admin@domain.com"); Subscription subscription = sendNotificationCommand.getSubscription(); Notification notification = new Notification(subscription, sendNotificationCommand.getPayload()); HttpResponse response = pushService.send(notification); System.out.println(response); } } ================================================ FILE: src/test/java/nl/martijndwars/webpush/HttpEceTest.java ================================================ package nl.martijndwars.webpush; import org.bouncycastle.jce.interfaces.ECPrivateKey; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import java.security.*; import java.util.Base64; import java.util.HashMap; import static nl.martijndwars.webpush.Encoding.AES128GCM; import static org.junit.jupiter.api.Assertions.assertArrayEquals; class HttpEceTest { @BeforeAll public static void addSecurityProvider() { Security.addProvider(new BouncyCastleProvider()); } private byte[] decode(String s) { return Base64.getUrlDecoder().decode(s); } @Test public void testZeroSaltAndKey() throws GeneralSecurityException { HttpEce httpEce = new HttpEce(); String plaintext = "Hello"; byte[] salt = new byte[16]; byte[] key = new byte[16]; byte[] actual = httpEce.encrypt(plaintext.getBytes(), salt, key, null, null, null, AES128GCM); byte[] expected = decode("AAAAAAAAAAAAAAAAAAAAAAAAEAAAMpsi6NfZUkOdJI96XyX0tavLqyIdiw"); assertArrayEquals(expected, actual); } /** * See https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-09#section-3.1 * * - Record size is 4096. * - Input keying material is identified by an empty string. * * @throws GeneralSecurityException */ @Test public void testSampleEncryption() throws GeneralSecurityException { HttpEce httpEce = new HttpEce(); byte[] plaintext = "I am the walrus".getBytes(); byte[] salt = decode("I1BsxtFttlv3u_Oo94xnmw"); byte[] key = decode("yqdlZ-tYemfogSmv7Ws5PQ"); byte[] actual = httpEce.encrypt(plaintext, salt, key, null, null, null, AES128GCM); byte[] expected = decode("I1BsxtFttlv3u_Oo94xnmwAAEAAA-NAVub2qFgBEuQKRapoZu-IxkIva3MEB1PD-ly8Thjg"); assertArrayEquals(expected, actual); } @Test public void testSampleEncryptDecrypt() throws GeneralSecurityException { String encodedKey = "yqdlZ-tYemfogSmv7Ws5PQ"; String encodedSalt = "I1BsxtFttlv3u_Oo94xnmw"; // Prepare the key map, which maps a keyid to a keypair. PrivateKey privateKey = Utils.loadPrivateKey(encodedKey); PublicKey publicKey = Utils.loadPublicKey((ECPrivateKey) privateKey); KeyPair keyPair = new KeyPair(publicKey, privateKey); HashMap keys = new HashMap<>(); keys.put("", keyPair); HashMap labels = new HashMap<>(); labels.put("", "P-256"); // Run the encryption and decryption HttpEce httpEce = new HttpEce(keys, labels); byte[] plaintext = "I am the walrus".getBytes(); byte[] salt = decode(encodedSalt); byte[] key = decode(encodedKey); byte[] ciphertext = httpEce.encrypt(plaintext, salt, key, null, null, null, AES128GCM); byte[] decrypted = httpEce.decrypt(ciphertext, null, key, null, AES128GCM); assertArrayEquals(plaintext, decrypted); } /** * See https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-09#section-3.2 * * TODO: This test is disabled because the library does not deal with multiple records yet. * * @throws GeneralSecurityException */ @Test @Disabled public void testEncryptionWithMultipleRecords() throws GeneralSecurityException { HttpEce httpEce = new HttpEce(); byte[] plaintext = "I am the walrus".getBytes(); byte[] salt = decode("uNCkWiNYzKTnBN9ji3-qWA"); byte[] key = decode("BO3ZVPxUlnLORbVGMpbT1Q"); byte[] actual = httpEce.encrypt(plaintext, salt, key, null, null, null, AES128GCM); byte[] expected = decode("uNCkWiNYzKTnBN9ji3-qWAAAABkCYTHOG8chz_gnvgOqdGYovxyjuqRyJFjEDyoF1Fvkj6hQPdPHI51OEUKEpgz3SsLWIqS_uA"); assertArrayEquals(expected, actual); } } ================================================ FILE: src/test/java/nl/martijndwars/webpush/NotificationTest.java ================================================ package nl.martijndwars.webpush; import java.security.GeneralSecurityException; import java.security.Security; import java.time.Duration; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; class NotificationTest { private static final String endpoint = "https://the-url.co.uk"; private static final String publicKey = "BGu3hOwCLOBfdMReXf7-SD2x5tKs_vPapOneyngBOnu6PgNYdgLPKFAodfBnG60MqkXC0McPFehN2Kyuh6TKm14="; private static int oneDayDurationInSeconds = 86400; @BeforeAll public static void addSecurityProvider() { Security.addProvider(new BouncyCastleProvider()); } @Test public void testNotificationBuilder() throws GeneralSecurityException { Notification notification = Notification.builder() .endpoint(endpoint) .userPublicKey(publicKey) .payload(new byte[16]) .ttl((int) Duration.ofDays(15).getSeconds()) .build(); assertEquals(endpoint, notification.getEndpoint()); assertEquals(15 * oneDayDurationInSeconds, notification.getTTL()); } @Test public void testDefaultTtl() throws GeneralSecurityException { Notification notification = Notification.builder() .userPublicKey(publicKey) .payload(new byte[16]) .build(); assertEquals(28 * oneDayDurationInSeconds, notification.getTTL()); } } ================================================ FILE: src/test/java/nl/martijndwars/webpush/selenium/BrowserTest.java ================================================ package nl.martijndwars.webpush.selenium; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import nl.martijndwars.webpush.Notification; import nl.martijndwars.webpush.PushService; import nl.martijndwars.webpush.Subscription; import org.apache.http.HttpResponse; import org.junit.jupiter.api.function.Executable; import java.security.GeneralSecurityException; import static org.junit.jupiter.api.Assertions.assertEquals; public class BrowserTest implements Executable { public static final String GCM_API_KEY = "AIzaSyBAU0VfXoskxUSg81K5VgLgwblHbZWe6tA"; public static final String PUBLIC_KEY = "BNFDO1MUnNpx0SuQyQcAAWYETa2+W8z/uc5sxByf/UZLHwAhFLwEDxS5iB654KHiryq0AxDhFXS7DVqXDKjjN+8="; public static final String PRIVATE_KEY = "AM0aAyoIryzARADnIsSCwg1p1aWFAL3Idc8dNXpf74MH"; public static final String VAPID_SUBJECT = "http://localhost:8090"; private TestingService testingService; private Configuration configuration; private int testSuiteId; public BrowserTest(TestingService testingService, Configuration configuration, int testSuiteId) { this.configuration = configuration; this.testingService = testingService; this.testSuiteId = testSuiteId; } /** * Execute the test for the given browser configuration. * * @throws Throwable */ @Override public void execute() throws Throwable { PushService pushService = getPushService(); JsonObject test = testingService.getSubscription(testSuiteId, configuration); int testId = test.get("testId").getAsInt(); Subscription subscription = new Gson().fromJson(test.get("subscription").getAsJsonObject(), Subscription.class); String message = "Hëllö, world!"; Notification notification = new Notification(subscription, message); HttpResponse response = pushService.send(notification); assertEquals(201, response.getStatusLine().getStatusCode()); JsonArray messages = testingService.getNotificationStatus(testSuiteId, testId); assertEquals(1, messages.size()); assertEquals(new JsonPrimitive(message), messages.get(0)); } protected PushService getPushService() throws GeneralSecurityException { PushService pushService; if (!configuration.isVapid()) { pushService = new PushService(GCM_API_KEY); } else { pushService = new PushService(PUBLIC_KEY, PRIVATE_KEY, VAPID_SUBJECT); } return pushService; } /** * The name used by JUnit to display the test. * * @return */ public String getDisplayName() { return "Browser " + configuration.browser + ", version " + configuration.version + ", vapid " + configuration.isVapid(); } } ================================================ FILE: src/test/java/nl/martijndwars/webpush/selenium/Configuration.java ================================================ package nl.martijndwars.webpush.selenium; public class Configuration { protected final String browser; protected final String version; protected final String publicKey; protected final String gcmSenderId; Configuration(String browser, String version, String publicKey, String gcmSenderId) { this.browser = browser; this.version = version; this.publicKey = publicKey; this.gcmSenderId = gcmSenderId; } public boolean isVapid() { return publicKey != null && !publicKey.isEmpty(); } @Override public String toString() { return browser + ", " + version + ", " + publicKey; } } ================================================ FILE: src/test/java/nl/martijndwars/webpush/selenium/SeleniumTests.java ================================================ package nl.martijndwars.webpush.selenium; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.TestFactory; import java.io.IOException; import java.security.Security; import java.util.Base64; import java.util.stream.Stream; import static org.junit.jupiter.api.DynamicTest.dynamicTest; /** * SeleniumTest performs integration testing. */ public class SeleniumTests { protected static final String GCM_SENDER_ID = "759071690750"; protected static final String PUBLIC_KEY = "BNFDO1MUnNpx0SuQyQcAAWYETa2+W8z/uc5sxByf/UZLHwAhFLwEDxS5iB654KHiryq0AxDhFXS7DVqXDKjjN+8="; protected static TestingService testingService = new TestingService("http://localhost:8090/api/"); protected static int testSuiteId; public SeleniumTests() { Security.addProvider(new BouncyCastleProvider()); } /** * End the test suite. * * @throws IOException */ @AfterAll public static void tearDown() throws IOException { testingService.endTestSuite(testSuiteId); } /** * Generate a stream of tests based on the configurations. * * @return */ @TestFactory public Stream dynamicTests() throws IOException { testSuiteId = testingService.startTestSuite(); return getConfigurations().map(configuration -> { BrowserTest browserTest = new BrowserTest(testingService, configuration, testSuiteId); return dynamicTest(browserTest.getDisplayName(), browserTest); }); } /** * Get browser configurations to test. * * @return */ protected Stream getConfigurations() { String PUBLIC_KEY_NO_PADDING = Base64.getUrlEncoder().withoutPadding().encodeToString( Base64.getUrlDecoder().decode(PUBLIC_KEY) ); return Stream.of( new Configuration("chrome", "stable", null, GCM_SENDER_ID), new Configuration("chrome", "beta", null, GCM_SENDER_ID), //new Configuration("chrome", "unstable", null, GCM_SENDER_ID), See #90 new Configuration("firefox", "stable", null, GCM_SENDER_ID), new Configuration("firefox", "beta", null, GCM_SENDER_ID), new Configuration("chrome", "stable", PUBLIC_KEY_NO_PADDING, null), new Configuration("chrome", "beta", PUBLIC_KEY_NO_PADDING, null), //new Configuration("chrome", "unstable", PUBLIC_KEY_NO_PADDING, null), See #90 new Configuration("firefox", "stable", PUBLIC_KEY_NO_PADDING, null), new Configuration("firefox", "beta", PUBLIC_KEY_NO_PADDING, null) ); } } ================================================ FILE: src/test/java/nl/martijndwars/webpush/selenium/TestingService.java ================================================ package nl.martijndwars.webpush.selenium; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import org.apache.commons.io.IOUtils; import org.apache.http.HttpEntity; import org.apache.http.client.fluent.Request; import org.apache.http.entity.ContentType; import org.apache.http.entity.StringEntity; import org.apache.http.util.EntityUtils; import java.io.IOException; import static java.nio.charset.StandardCharsets.UTF_8; /** * Java wrapper for interacting with the Web Push Testing Service. */ public class TestingService { private String baseUrl; public TestingService(String baseUrl) { this.baseUrl = baseUrl; } /** * Start a new test suite. * * @return */ public int startTestSuite() throws IOException { String startTestSuite = request(baseUrl + "start-test-suite/"); JsonElement root = JsonParser.parseString(startTestSuite); return root .getAsJsonObject() .get("data") .getAsJsonObject() .get("testSuiteId") .getAsInt(); } /** * Get a test ID and subscription for the given test case. * * @param testSuiteId * @param configuration * @return * @throws IOException */ public JsonObject getSubscription(int testSuiteId, Configuration configuration) throws IOException { JsonObject jsonObject = new JsonObject(); jsonObject.addProperty("testSuiteId", testSuiteId); jsonObject.addProperty("browserName", configuration.browser); jsonObject.addProperty("browserVersion", configuration.version); if (configuration.gcmSenderId != null) { jsonObject.addProperty("gcmSenderId", configuration.gcmSenderId); } if (configuration.publicKey != null) { jsonObject.addProperty("vapidPublicKey", configuration.publicKey); } HttpEntity entity = new StringEntity(jsonObject.toString(), ContentType.APPLICATION_JSON); String getSubscription = request(baseUrl + "get-subscription/", entity); return getData(getSubscription); } /** * Get the notification status for the given test case. * * @param testSuiteId * @param testId * @return * @throws IOException */ public JsonArray getNotificationStatus(int testSuiteId, int testId) throws IOException { JsonObject jsonObject = new JsonObject(); jsonObject.addProperty("testSuiteId", testSuiteId); jsonObject.addProperty("testId", testId); HttpEntity entity = new StringEntity(jsonObject.toString(), ContentType.APPLICATION_JSON); String notificationStatus = request(baseUrl + "get-notification-status/", entity); return getData(notificationStatus).get("messages").getAsJsonArray(); } /** * End the given test suite. * * @return */ public boolean endTestSuite(int testSuiteId) throws IOException { JsonObject jsonObject = new JsonObject(); jsonObject.addProperty("testSuiteId", testSuiteId); HttpEntity entity = new StringEntity(jsonObject.toString(), ContentType.APPLICATION_JSON); String endTestSuite = request(baseUrl + "end-test-suite/", entity); return getData(endTestSuite).get("success").getAsBoolean(); } /** * Perform HTTP request and return response. * * @param uri * @return */ protected String request(String uri) throws IOException { return request(uri, null); } /** * Perform HTTP request and return response. * * @param uri * @return */ protected String request(String uri, HttpEntity entity) throws IOException { return Request.Post(uri).body(entity).execute().handleResponse(httpResponse -> { String json = EntityUtils.toString(httpResponse.getEntity()); if (httpResponse.getStatusLine().getStatusCode() != 200) { JsonElement root = JsonParser.parseString(json); JsonObject error = root.getAsJsonObject().get("error").getAsJsonObject(); String errorId = error.get("id").getAsString(); String errorMessage = error.get("message").getAsString(); String body = IOUtils.toString(entity.getContent(), UTF_8); throw new IllegalStateException("Error while requesting " + uri + " with body " + body + " (" + errorId + ": " + errorMessage); } return json; }); } /** * Get the a JSON object of the data in the JSON response. * * @param response */ protected JsonObject getData(String response) { JsonElement root = JsonParser.parseString(response); return root .getAsJsonObject() .get("data") .getAsJsonObject(); } }