diff --git a/.gitignore b/.gitignore index 94758e3..784ab8a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ build/ .idea/ .gradle/ *.iml +target/ +out/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..eefe331 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +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/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f4d77ef --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# 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) + diff --git a/README.md b/README.md index f5ba2f3..746878b 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,183 @@ # WebPush -A Web Push library for Java. +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`: -``` -compile group: 'nl.martijndwars', name: 'web-push', version: '1.0.0' +```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 -    1.0.0 +    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 -The server should have stored a subscription containing the `userPublicKey` and `userAuth` keys. Use these keys to create a `Notification` or `GcmNotification`, depending on whether the subscription is for Google Cloud Messaging. +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 -// Create a notification with the endpoint, userPublicKey from the subscription and a custom payload -Notification notification = new Notification(endpoint, userPublicKey, userAuth, payload, ttl); +PushService pushService = new PushService(...); +``` + +Then, create a notification based on the user's subscription: -// Or create a GcmNotification, in case of Google Cloud Messaging -Notification notification = new GcmNotification(endpointi, userPublicKey, userAuth, payload); +```java +Notification notification = new Notification(...); +``` -// Instantiate the push service with a GCM API key -PushService pushService = new PushService("gcm-api-key"); +To send a push notification: -// Send the 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 -- For PHP, see [Minishlink/web-push](https://github.com/Minishlink/web-push) -- For nodejs, see [marco-c/web-push](https://github.com/marco-c/web-push) and [GoogleChrome/push-encryption-node](https://github.com/GoogleChrome/push-encryption-node) -- For python, see [mozilla-services/pywebpush](https://github.com/mozilla-services/pywebpush) +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) + diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..dfa4724 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,44 @@ +# 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" +``` + diff --git a/build.gradle b/build.gradle index 2da28b8..83ca068 100644 --- a/build.gradle +++ b/build.gradle @@ -1,16 +1,91 @@ +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 '1.0.0' +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' -apply plugin: 'java' -apply plugin: 'maven' -apply plugin: 'signing' + // For cryptographic operations + shadow group: 'org.bouncycastle', name: 'bcprov-jdk15on', version: '1.70' -sourceCompatibility = 1.8 -targetCompatibility = 1.8 + // For creating and signing JWT + implementation group: 'org.bitbucket.b_c', name: 'jose4j', version: '0.9.6' -jar { - baseName = 'web-push' - version = '1.0.0' + // 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) { @@ -24,70 +99,10 @@ task sourcesJar(type: Jar) { } artifacts { - archives jar - archives javadocJar archives sourcesJar } -signing { - required { gradle.taskGraph.hasTask("uploadArchives") } - sign configurations.archives -} - -uploadArchives { - repositories { - mavenDeployer { - beforeDeployment { MavenDeployment deployment -> signing.signPom(deployment) } - - repository(url: "https://oss.sonatype.org/service/local/staging/deploy/maven2/") { - authentication(userName: hasProperty('ossrhUsername')?ossrhUsername:'', password: hasProperty('ossrhPassword')?ossrhPassword:'') - } - - snapshotRepository(url: "https://oss.sonatype.org/content/repositories/snapshots/") { - authentication(userName: hasProperty('ossrhUsername')?ossrhUsername:'', password: hasProperty('ossrhPassword')?ossrhPassword:'') - } - - pom.project { - name 'web-push' - packaging 'jar' - description 'A Web Push library for Java.' - url 'https://github.com/MartijnDwars/web-push' - - scm { - connection 'scm:git:git@github.com:MartijnDwars/web-push.git' - developerConnection 'scm:git:git@github.com:MartijnDwars/web-push.git' - url 'git@github.com:MartijnDwars/web-push.git' - } - - licenses { - license { - name 'MIT License' - url 'https://opensource.org/licenses/MIT' - } - } - - developers { - developer { - id 'martijndwars' - name 'Martijn Dwars' - email 'ikben@martijndwars.nl' - } - } - } - } - } -} - -repositories { - mavenCentral() -} - -dependencies { - compile group: 'org.apache.httpcomponents', name: 'fluent-hc', version: '4.5.2' - compile group: 'com.google.guava', name: 'guava', version: '19.0' - compile group: 'org.bouncycastle', name: 'bcprov-jdk15on', version: '1.54' - compile group: 'org.json', name: 'json', version: '20160212' - - testCompile group: 'junit', name: 'junit', version: '4.11' +if (hasProperty('release')) { + apply from: 'release.gradle' } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 30d399d..7454180 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f7955b8..ffed3a2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Mon Mar 07 08:39:14 CET 2016 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 -distributionUrl=https\://services.gradle.org/distributions/gradle-2.5-bin.zip diff --git a/gradlew b/gradlew index 91a7e26..1b6c787 100755 --- a/gradlew +++ b/gradlew @@ -1,79 +1,129 @@ -#!/usr/bin/env bash +#!/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 UN*X -## +# +# 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/. +# ############################################################################## -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# 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=`basename "$0"` +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" +MAX_FD=maximum -warn ( ) { +warn () { echo "$*" -} +} >&2 -die ( ) { +die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -# For Cygwin, ensure paths are in UNIX format before anything is touched. -if $cygwin ; then - [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` -fi - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >&- -APP_HOME="`pwd -P`" -cd "$SAVED" >&- - 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" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -82,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + 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 @@ -90,75 +140,95 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +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 -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -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" ) -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + 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 - i=$((i+1)) + # 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 - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") -} -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS -JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" - -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" +# 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" "$@" diff --git a/gradlew.bat b/gradlew.bat index aec9973..ac1b06f 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,3 +1,19 @@ +@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 @@ -8,20 +24,23 @@ @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal -@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= - 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 init +if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -35,7 +54,7 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% @@ -45,34 +64,14 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windowz variants - -if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ - :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 %CMD_LINE_ARGS% +"%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 diff --git a/pom.xml b/pom.xml deleted file mode 100644 index cf8b766..0000000 --- a/pom.xml +++ /dev/null @@ -1,96 +0,0 @@ - - - 4.0.0 - nl.martijndwars - web-push - 1.0.0 - jar - - - UTF-8 - 1.8 - 3.5.1 - 2.19.1 - 2.19.1 - - - - - com.google.guava - guava - 19.0 - compile - - - org.json - json - 20160212 - compile - - - junit - junit - 4.11 - test - - - org.bouncycastle - bcprov-jdk15on - 1.54 - compile - - - org.apache.httpcomponents - fluent-hc - 4.5.2 - compile - - - - - - org.apache.maven.plugins - maven-compiler-plugin - ${maven-compiler.version} - true - - 1.8 - 1.8 - -Xlint:-serial - true - - - - maven-surefire-plugin - ${maven-surefire-plugin.version} - - false - - **/*IT.java - - - - - org.apache.maven.plugins - maven-failsafe-plugin - ${maven-failsafe-plugin.version} - - - **/*IT.java - - - - - failsafe-integration-tests - integration-test - - integration-test - - - - - - - - diff --git a/release.gradle b/release.gradle new file mode 100644 index 0000000..4c09f1d --- /dev/null +++ b/release.gradle @@ -0,0 +1,54 @@ +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/' + } +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..f45d8f1 --- /dev/null +++ b/renovate.json @@ -0,0 +1,5 @@ +{ + "extends": [ + "config:base" + ] +} diff --git a/scripts/version.sh b/scripts/version.sh new file mode 100755 index 0000000..7f22121 --- /dev/null +++ b/scripts/version.sh @@ -0,0 +1,20 @@ +#!/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 + + diff --git a/src/main/java/nl/martijndwars/webpush/AbstractPushService.java b/src/main/java/nl/martijndwars/webpush/AbstractPushService.java new file mode 100644 index 0000000..f3f25ed --- /dev/null +++ b/src/main/java/nl/martijndwars/webpush/AbstractPushService.java @@ -0,0 +1,335 @@ +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; + } +} diff --git a/src/main/java/nl/martijndwars/webpush/ClosableCallback.java b/src/main/java/nl/martijndwars/webpush/ClosableCallback.java new file mode 100644 index 0000000..4367821 --- /dev/null +++ b/src/main/java/nl/martijndwars/webpush/ClosableCallback.java @@ -0,0 +1,45 @@ +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(); + } + } +} diff --git a/src/main/java/nl/martijndwars/webpush/Encoding.java b/src/main/java/nl/martijndwars/webpush/Encoding.java new file mode 100644 index 0000000..f4a2213 --- /dev/null +++ b/src/main/java/nl/martijndwars/webpush/Encoding.java @@ -0,0 +1,5 @@ +package nl.martijndwars.webpush; + +public enum Encoding { + AESGCM, AES128GCM +} diff --git a/src/main/java/nl/martijndwars/webpush/GcmNotification.java b/src/main/java/nl/martijndwars/webpush/GcmNotification.java deleted file mode 100644 index 12ab7c0..0000000 --- a/src/main/java/nl/martijndwars/webpush/GcmNotification.java +++ /dev/null @@ -1,28 +0,0 @@ -package nl.martijndwars.webpush; - -import java.security.PublicKey; - -public class GcmNotification extends Notification { - public GcmNotification(String endpoint, PublicKey userPublicKey, byte[] userAuth, byte[] payload) { - super(endpoint, userPublicKey, userAuth, payload); - } - - @Override - public String getEndpoint() { - return "https://android.googleapis.com/gcm/send"; - } - - /** - * Extract registration ID from a Google Cloud Messaging endpoint - * - * @return - */ - public String getRegistrationId() { - return super.getEndpoint().substring(super.getEndpoint().lastIndexOf("/") + 1); - } - - @Override - public int getPadSize() { - return 2; - } -} diff --git a/src/main/java/nl/martijndwars/webpush/HttpEce.java b/src/main/java/nl/martijndwars/webpush/HttpEce.java index 6089650..b9e9436 100644 --- a/src/main/java/nl/martijndwars/webpush/HttpEce.java +++ b/src/main/java/nl/martijndwars/webpush/HttpEce.java @@ -3,30 +3,184 @@ 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.io.IOException; 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 HTTP ECE (Encrypted Content Encoding) as described in - * https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-01 + * 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? * @@ -36,22 +190,44 @@ public HttpEce(Map keys, Map labels) { protected static byte[] buildInfo(String type, byte[] context) { ByteBuffer buffer = ByteBuffer.allocate(19 + type.length() + context.length); - buffer.put("Content-Encoding: ".getBytes(), 0, 18); - buffer.put(type.getBytes(), 0, type.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(); } - public byte[][] deriveKey(byte[] salt, byte[] key, String keyId, PublicKey dh, byte[] authSecret, int padSize) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, InvalidAlgorithmParameterException, BadPaddingException, IllegalBlockSizeException, NoSuchProviderException, IOException { + /** + * 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 = deriveDH(keyId, dh); + byte[][] bytes = extractDH(keyId, dh); secret = bytes[0]; context = bytes[1]; } else if (keyId != null) { @@ -59,118 +235,212 @@ public byte[][] deriveKey(byte[] salt, byte[] key, String keyId, PublicKey dh, b } if (secret == null) { - throw new IllegalStateException("Unable to determine the secret"); + throw new IllegalStateException("Unable to determine key."); } - byte[] keyinfo; - byte[] nonceinfo; - if (authSecret != null) { - secret = hkdfExpand(secret, authSecret, buildInfo("auth", new byte[0]), 32); + secret = hkdfExpand(secret, authSecret, buildInfo("auth", new byte[0]), SHA_256_LENGTH); } - if (padSize == 2) { - keyinfo = buildInfo("aesgcm", context); - nonceinfo = buildInfo("nonce", context); - } else if (padSize == 1) { - keyinfo = "Content-Encoding: aesgcm128".getBytes(); - nonceinfo = "Content-Encoding: nonce".getBytes(); + 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 IllegalArgumentException("Unable to set context for padSize " + padSize); + throw new IllegalStateException("Unknown version: " + version); } - byte[] hkdf_key = hkdfExpand(secret, salt, keyinfo, 16); - byte[] hkdf_nonce = hkdfExpand(secret, salt, nonceinfo, 12); + 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 + 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); + } + /** - * Convenience method for computing the HMAC Key Derivation Function. The - * real work is offloaded to BouncyCastle. + * 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 */ - protected static byte[] hkdfExpand(byte[] ikm, byte[] salt, byte[] info, int length) throws InvalidKeyException, NoSuchAlgorithmException { - HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA256Digest()); - hkdf.init(new HKDFParameters(ikm, salt, info)); + 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); + } - byte[] okm = new byte[length]; - hkdf.generateBytes(okm, 0, length); + log("remote pubkey", encode(remotePubKey)); + log("sender pubkey", encode(senderPubKey)); + log("receiver pubkey", encode(receiverPubKey)); - return okm; + 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 (indicated by - * keyId) and the client's public key. Also compute context. + * Compute the shared secret (using the server's key pair and the client's public key) and the context. * - * @param keyId + * @param keyid * @param publicKey * @return */ - private byte[][] deriveDH(String keyId, PublicKey publicKey) throws NoSuchProviderException, NoSuchAlgorithmException, InvalidKeyException, IOException { - PublicKey senderPubKey = keys.get(keyId).getPublic(); + private byte[][] extractDH(String keyid, ECPublicKey publicKey) throws NoSuchAlgorithmException, InvalidKeyException { + ECPublicKey senderPubKey = getPublicKey(keyid); KeyAgreement keyAgreement = KeyAgreement.getInstance("ECDH"); - keyAgreement.init(keys.get(keyId).getPrivate()); + keyAgreement.init(getPrivateKey(keyid)); keyAgreement.doPhase(publicKey, true); byte[] secret = keyAgreement.generateSecret(); - byte[] context = concat(labels.get(keyId).getBytes(), new byte[1], lengthPrefix(publicKey), lengthPrefix(senderPubKey)); + byte[] context = concat(labels.get(keyid).getBytes(UTF_8), new byte[1], lengthPrefix(publicKey), lengthPrefix(senderPubKey)); return new byte[][]{ - secret, - context + secret, + context }; } - private byte[] lengthPrefix(Key key) throws IOException { - byte[] bytes = Utils.savePublicKey((ECPublicKey) key); - - return concat(intToBytes(bytes.length), bytes); + /** + * Get the public key for the given keyid. + * + * @param keyid + * @return + */ + private ECPublicKey getPublicKey(String keyid) { + return (ECPublicKey) keys.get(keyid).getPublic(); } /** - * Cast an integer to a two-byte array + * Get the private key for the given keyid. + * + * @param keyid + * @return */ - private byte[] intToBytes(int x) throws IOException { - byte[] bytes = new byte[2]; - - bytes[1] = (byte) (x & 0xff); - bytes[0] = (byte) (x >> 8); - - return bytes; + private ECPrivateKey getPrivateKey(String keyid) { + return (ECPrivateKey) keys.get(keyid).getPrivate(); } + /** - * Utility to concat byte arrays + * Encode the public key as a byte array and prepend its length in two bytes. + * + * @param publicKey + * @return */ - private byte[] concat(byte[]... arrays) { - int combinedLength = Arrays.stream(arrays).mapToInt(array -> array.length).sum(); - int lastPos = 0; + private static byte[] lengthPrefix(ECPublicKey publicKey) { + byte[] bytes = encode(publicKey); - byte[] combined = new byte[combinedLength]; + return concat(intToBytes(bytes.length), bytes); + } - for (byte[] array : arrays) { - System.arraycopy(array, 0, combined, lastPos, array.length); + /** + * 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."); + } - lastPos += array.length; + if (number >= TWO_BYTE_MAX) { + throw new IllegalArgumentException("Cannot convert an integer larger than " + (TWO_BYTE_MAX - 1) + " to two bytes."); } - return combined; - } + byte[] bytes = new byte[2]; + bytes[1] = (byte) (number & 0xff); + bytes[0] = (byte) (number >> 8); - public byte[] encrypt(byte[] buffer, byte[] salt, byte[] key, String keyid, PublicKey dh, byte[] authSecret, int padSize) throws NoSuchPaddingException, InvalidAlgorithmParameterException, NoSuchAlgorithmException, IllegalBlockSizeException, BadPaddingException, InvalidKeyException, NoSuchProviderException, IOException { - byte[][] derivedKey = deriveKey(salt, key, keyid, dh, authSecret, padSize); - byte[] key_ = derivedKey[0]; - byte[] nonce_ = derivedKey[1]; + return bytes; + } - Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding", "BC"); - cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key_, "AES"), new GCMParameterSpec(16 * 8, nonce_)); - cipher.update(new byte[padSize]); + /** + * 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 cipher.doFinal(buffer); + return array; } } diff --git a/src/main/java/nl/martijndwars/webpush/HttpRequest.java b/src/main/java/nl/martijndwars/webpush/HttpRequest.java new file mode 100644 index 0000000..b871a8a --- /dev/null +++ b/src/main/java/nl/martijndwars/webpush/HttpRequest.java @@ -0,0 +1,31 @@ +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; + } + +} diff --git a/src/main/java/nl/martijndwars/webpush/Notification.java b/src/main/java/nl/martijndwars/webpush/Notification.java index 361b9ed..6fdc493 100644 --- a/src/main/java/nl/martijndwars/webpush/Notification.java +++ b/src/main/java/nl/martijndwars/webpush/Notification.java @@ -1,6 +1,16 @@ 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 { /** @@ -11,7 +21,7 @@ public class Notification { /** * The client's public key */ - private final PublicKey userPublicKey; + private final ECPublicKey userPublicKey; /** * The client's auth @@ -23,28 +33,79 @@ public class Notification { */ 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; - public Notification(final String endpoint, final PublicKey userPublicKey, byte[] userAuth, final byte[] payload, 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(final String endpoint, final PublicKey userPublicKey, byte[] userAuth, final byte[] payload) { - this(endpoint, userPublicKey, userAuth, payload, 2419200); + 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 PublicKey getUserPublicKey() { + public ECPublicKey getUserPublicKey() { return userPublicKey; } @@ -56,11 +117,123 @@ 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 int getPadSize() { - return 1; + 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; + } } + } diff --git a/src/main/java/nl/martijndwars/webpush/PushAsyncService.java b/src/main/java/nl/martijndwars/webpush/PushAsyncService.java new file mode 100644 index 0000000..870a7c9 --- /dev/null +++ b/src/main/java/nl/martijndwars/webpush/PushAsyncService.java @@ -0,0 +1,80 @@ +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; + } +} diff --git a/src/main/java/nl/martijndwars/webpush/PushService.java b/src/main/java/nl/martijndwars/webpush/PushService.java index e94f291..e15647b 100644 --- a/src/main/java/nl/martijndwars/webpush/PushService.java +++ b/src/main/java/nl/martijndwars/webpush/PushService.java @@ -1,121 +1,120 @@ package nl.martijndwars.webpush; -import com.google.common.io.BaseEncoding; -import org.apache.http.client.fluent.Async; -import org.apache.http.client.fluent.Content; -import org.apache.http.client.fluent.Request; -import org.apache.http.entity.ContentType; +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.json.JSONObject; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jws.JsonWebSignature; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.lang.JoseException; -import javax.crypto.BadPaddingException; -import javax.crypto.IllegalBlockSizeException; -import javax.crypto.NoSuchPaddingException; import java.io.IOException; +import java.net.URI; import java.security.*; import java.security.spec.InvalidKeySpecException; -import java.util.Collections; import java.util.HashMap; import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; -public class PushService { - private ExecutorService threadpool = Executors.newFixedThreadPool(1); - - private String gcmApiKey; +public class PushService extends AbstractPushService { public PushService() { } public PushService(String gcmApiKey) { - this.gcmApiKey = 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); } /** - * Encrypt the payload using the user's public key using Elliptic Curve - * Diffie Hellman cryptography over the prime256v1 curve. + * Send a notification and wait for the response. * - * @return An Encrypted object containing the public key, salt, and - * ciphertext, which can be sent to the other party. + * @param notification + * @param encoding + * @return + * @throws GeneralSecurityException + * @throws IOException + * @throws JoseException + * @throws ExecutionException + * @throws InterruptedException */ - public static Encrypted encrypt(byte[] buffer, PublicKey userPublicKey, byte[] userAuth, int padSize) throws NoSuchProviderException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, NoSuchPaddingException, BadPaddingException, IllegalBlockSizeException, InvalidKeySpecException, IOException { - ECNamedCurveParameterSpec parameterSpec = ECNamedCurveTable.getParameterSpec("prime256v1"); - - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("ECDH", "BC"); - keyPairGenerator.initialize(parameterSpec); - - KeyPair serverKey = keyPairGenerator.generateKeyPair(); + public HttpResponse send(Notification notification, Encoding encoding) throws GeneralSecurityException, IOException, JoseException, ExecutionException, InterruptedException { + return sendAsync(notification, encoding).get(); + } - Map keys = new HashMap<>(); - keys.put("server-key-id", serverKey); + public HttpResponse send(Notification notification) throws GeneralSecurityException, IOException, JoseException, ExecutionException, InterruptedException { + return send(notification, Encoding.AES128GCM); + } - Map labels = new HashMap<>(); - labels.put("server-key-id", "P-256"); + /** + * 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); - byte[] salt = SecureRandom.getSeed(16); + final CloseableHttpAsyncClient closeableHttpAsyncClient = HttpAsyncClients.createSystem(); + closeableHttpAsyncClient.start(); - HttpEce httpEce = new HttpEce(keys, labels); - byte[] ciphertext = httpEce.encrypt(buffer, salt, null, "server-key-id", userPublicKey, userAuth, padSize); + return closeableHttpAsyncClient.execute(httpPost, new ClosableCallback(closeableHttpAsyncClient)); + } - return new Encrypted.Builder() - .withSalt(salt) - .withPublicKey(serverKey.getPublic()) - .withCiphertext(ciphertext) - .build(); + /** + * @deprecated Use {@link PushAsyncService#send(Notification)} instead. + */ + @Deprecated + public Future sendAsync(Notification notification) throws GeneralSecurityException, IOException, JoseException { + return sendAsync(notification, Encoding.AES128GCM); } /** - * Send a notification + * Prepare a HttpPost for Apache async http client + * + * @param notification + * @param encoding + * @return + * @throws GeneralSecurityException + * @throws IOException + * @throws JoseException */ - public Future send(Notification notification) throws NoSuchPaddingException, InvalidKeyException, NoSuchAlgorithmException, IllegalBlockSizeException, BadPaddingException, NoSuchProviderException, InvalidAlgorithmParameterException, IOException, InvalidKeySpecException { - BaseEncoding base64url = BaseEncoding.base64Url(); - BaseEncoding base64 = BaseEncoding.base64(); - - Encrypted encrypted = encrypt( - notification.getPayload(), - notification.getUserPublicKey(), - notification.getUserAuth(), - notification.getPadSize() - ); - - byte[] dh = Utils.savePublicKey((ECPublicKey) encrypted.getPublicKey()); - byte[] salt = encrypted.getSalt(); - - Request request = Request - .Post(notification.getEndpoint()) - .addHeader("TTL", String.valueOf(notification.getTTL())); - - if (notification instanceof GcmNotification) { - if (null == gcmApiKey) { - throw new IllegalStateException("GCM API key required for using Google Cloud Messaging"); - } - - String body = new JSONObject() - .put("registration_ids", Collections.singletonList(((GcmNotification) notification).getRegistrationId())) - .put("raw_data", base64.encode(encrypted.getCiphertext())) - .toString(); - - request - .addHeader("Authorization", "key=" + gcmApiKey) - .addHeader("Encryption", "keyid=p256dh;salt=" + base64url.encode(salt)) - .addHeader("Crypto-Key", "dh=" + base64url.encode(dh)) - .addHeader("Content-Encoding", "aesgcm") - .bodyString(body, ContentType.APPLICATION_JSON); - } else { - request - .addHeader("Content-Type", "application/octet-stream") - .addHeader("Content-Encoding", "aesgcm128") - .addHeader("Encryption-Key", "keyid=p256dh;dh=" + base64url.omitPadding().encode(dh)) - .addHeader("Encryption", "keyid=p256dh;salt=" + base64url.omitPadding().encode(salt)) - .bodyByteArray(encrypted.getCiphertext()); + 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())); } - - Async async = Async.newInstance().use(threadpool); - - return async.execute(request); + return httpPost; } } diff --git a/src/main/java/nl/martijndwars/webpush/Subscription.java b/src/main/java/nl/martijndwars/webpush/Subscription.java new file mode 100644 index 0000000..aa0df85 --- /dev/null +++ b/src/main/java/nl/martijndwars/webpush/Subscription.java @@ -0,0 +1,29 @@ +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; + } + } +} diff --git a/src/main/java/nl/martijndwars/webpush/Urgency.java b/src/main/java/nl/martijndwars/webpush/Urgency.java new file mode 100644 index 0000000..39d5b35 --- /dev/null +++ b/src/main/java/nl/martijndwars/webpush/Urgency.java @@ -0,0 +1,24 @@ +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; + } +} diff --git a/src/main/java/nl/martijndwars/webpush/Utils.java b/src/main/java/nl/martijndwars/webpush/Utils.java index ddf481f..d135f38 100644 --- a/src/main/java/nl/martijndwars/webpush/Utils.java +++ b/src/main/java/nl/martijndwars/webpush/Utils.java @@ -1,6 +1,5 @@ package nl.martijndwars.webpush; -import com.google.common.io.BaseEncoding; import org.bouncycastle.jce.ECNamedCurveTable; import org.bouncycastle.jce.interfaces.ECPrivateKey; import org.bouncycastle.jce.interfaces.ECPublicKey; @@ -8,35 +7,62 @@ 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 byte[] savePublicKey(ECPublicKey publicKey) { + 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[] savePrivateKey(ECPrivateKey privateKey) { + public static byte[] encode(ECPrivateKey privateKey) { return privateKey.getD().toByteArray(); } /** - * Load the public key from a URL-safe base64 encoded string + * 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 = BaseEncoding.base64Url().decode(encodedPublicKey); + byte[] decodedPublicKey = Base64.getUrlDecoder().decode(encodedPublicKey); + return loadPublicKey(decodedPublicKey); + } - KeyFactory kf = KeyFactory.getInstance("ECDH", "BC"); - ECNamedCurveParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec("prime256v1"); - ECPoint point = ecSpec.getCurve().decodePoint(decodedPublicKey); - ECPublicKeySpec pubSpec = new ECPublicKeySpec(point, ecSpec); + /** + * 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 kf.generatePublic(pubSpec); + return keyFactory.generatePublic(pubSpec); } /** @@ -49,12 +75,109 @@ public static PublicKey loadPublicKey(String encodedPublicKey) throws NoSuchProv * @throws InvalidKeySpecException */ public static PrivateKey loadPrivateKey(String encodedPrivateKey) throws NoSuchProviderException, NoSuchAlgorithmException, InvalidKeySpecException { - byte[] decodedPrivateKey = BaseEncoding.base64Url().decode(encodedPrivateKey); + byte[] decodedPrivateKey = Base64.getUrlDecoder().decode(encodedPrivateKey); + return loadPrivateKey(decodedPrivateKey); + } - ECParameterSpec params = ECNamedCurveTable.getParameterSpec("prime192v1"); - ECPrivateKeySpec prvkey = new ECPrivateKeySpec(new BigInteger(decodedPrivateKey), params); - KeyFactory kf = KeyFactory.getInstance("ECDH", "BC"); + /** + * 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 kf.generatePrivate(prvkey); + return buffer.array(); } } diff --git a/src/main/java/nl/martijndwars/webpush/cli/Cli.java b/src/main/java/nl/martijndwars/webpush/cli/Cli.java new file mode 100644 index 0000000..5fd4834 --- /dev/null +++ b/src/main/java/nl/martijndwars/webpush/cli/Cli.java @@ -0,0 +1,52 @@ +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(); + } + } +} diff --git a/src/main/java/nl/martijndwars/webpush/cli/commands/GenerateKeyCommand.java b/src/main/java/nl/martijndwars/webpush/cli/commands/GenerateKeyCommand.java new file mode 100644 index 0000000..0feae26 --- /dev/null +++ b/src/main/java/nl/martijndwars/webpush/cli/commands/GenerateKeyCommand.java @@ -0,0 +1,20 @@ +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; + } + +} diff --git a/src/main/java/nl/martijndwars/webpush/cli/commands/SendNotificationCommand.java b/src/main/java/nl/martijndwars/webpush/cli/commands/SendNotificationCommand.java new file mode 100644 index 0000000..3c4702c --- /dev/null +++ b/src/main/java/nl/martijndwars/webpush/cli/commands/SendNotificationCommand.java @@ -0,0 +1,49 @@ +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; + } +} diff --git a/src/main/java/nl/martijndwars/webpush/cli/handlers/GenerateKeyHandler.java b/src/main/java/nl/martijndwars/webpush/cli/handlers/GenerateKeyHandler.java new file mode 100644 index 0000000..1747adf --- /dev/null +++ b/src/main/java/nl/martijndwars/webpush/cli/handlers/GenerateKeyHandler.java @@ -0,0 +1,83 @@ +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); + } + } +} diff --git a/src/main/java/nl/martijndwars/webpush/cli/handlers/HandlerInterface.java b/src/main/java/nl/martijndwars/webpush/cli/handlers/HandlerInterface.java new file mode 100644 index 0000000..54fbc31 --- /dev/null +++ b/src/main/java/nl/martijndwars/webpush/cli/handlers/HandlerInterface.java @@ -0,0 +1,7 @@ +package nl.martijndwars.webpush.cli.handlers; + +import java.security.InvalidAlgorithmParameterException; + +public interface HandlerInterface { + public void run() throws Exception; +} diff --git a/src/main/java/nl/martijndwars/webpush/cli/handlers/SendNotificationHandler.java b/src/main/java/nl/martijndwars/webpush/cli/handlers/SendNotificationHandler.java new file mode 100644 index 0000000..a8eb20f --- /dev/null +++ b/src/main/java/nl/martijndwars/webpush/cli/handlers/SendNotificationHandler.java @@ -0,0 +1,31 @@ +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); + } +} diff --git a/src/test/java/nl/martijndwars/webpush/HttpEceTest.java b/src/test/java/nl/martijndwars/webpush/HttpEceTest.java new file mode 100644 index 0000000..c3f6884 --- /dev/null +++ b/src/test/java/nl/martijndwars/webpush/HttpEceTest.java @@ -0,0 +1,107 @@ +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); + } +} diff --git a/src/test/java/nl/martijndwars/webpush/NotificationTest.java b/src/test/java/nl/martijndwars/webpush/NotificationTest.java new file mode 100644 index 0000000..a9d1c6c --- /dev/null +++ b/src/test/java/nl/martijndwars/webpush/NotificationTest.java @@ -0,0 +1,43 @@ +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()); + } +} diff --git a/src/test/java/nl/martijndwars/webpush/PushServiceTest.java b/src/test/java/nl/martijndwars/webpush/PushServiceTest.java deleted file mode 100644 index bec6f25..0000000 --- a/src/test/java/nl/martijndwars/webpush/PushServiceTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package nl.martijndwars.webpush; - -import com.google.common.io.BaseEncoding; -import org.apache.http.client.fluent.Content; -import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.junit.BeforeClass; -import org.junit.Test; - -import javax.crypto.BadPaddingException; -import javax.crypto.IllegalBlockSizeException; -import javax.crypto.NoSuchPaddingException; -import java.io.IOException; -import java.security.*; -import java.security.spec.InvalidKeySpecException; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; - -public class PushServiceTest { - @BeforeClass - public static void addSecurityProvider() { - Security.addProvider(new BouncyCastleProvider()); - } - - @Test - public void testPushChrome() throws NoSuchProviderException, NoSuchAlgorithmException, InvalidKeySpecException, NoSuchPaddingException, InvalidAlgorithmParameterException, IllegalBlockSizeException, BadPaddingException, InvalidKeyException, IOException, ExecutionException, InterruptedException { - Security.addProvider(new BouncyCastleProvider()); - - String gcmApiKey = "AIzaSyDSa2bw0b0UGOmkZRw-dqHGQRI_JqpiHug"; - String endpoint = "https://android.googleapis.com/gcm/send/fXYSGjgKDKA:APA91bE-sqRL3us1BfdzaF0E3DKh8fwVgEFEyT7BaUH7d6j-UauS_4cSRvhrdsbYFM4-6CWLfndYLU-FBqrlX_8xa793fSVUZxzHFz7Yec_cbtM-8J0NqKF0dUjh0mVBYWAwkWZYkmRU"; - String encodedUserPublicKey = "BB5bKjcRawntzacxKXRVMhfS60h_48ZVHWZDTEbrVufrtwsol4dMNxKvGw8HSpd770MkWi76ovbBj_mJBiLQ1SA="; - String encodedUserAuth = "px9ZH3w7m8tk8zuJxmeEng=="; - - PublicKey userPublicKey = Utils.loadPublicKey(encodedUserPublicKey); - byte[] userAuth = BaseEncoding.base64Url().decode(encodedUserAuth); - - Notification notification = new GcmNotification( - endpoint, - userPublicKey, - userAuth, - "{\"title\": \"Hello\", \"message\": \"World\"}".getBytes() - ); - - PushService pushService = new PushService(gcmApiKey); - Future httpResponse = pushService.send(notification); - - System.out.println(httpResponse.get().asString()); - } - - @Test - public void testPushFirefox() throws NoSuchProviderException, NoSuchAlgorithmException, InvalidKeySpecException, NoSuchPaddingException, InvalidAlgorithmParameterException, IllegalBlockSizeException, BadPaddingException, InvalidKeyException, IOException, ExecutionException, InterruptedException { - Security.addProvider(new BouncyCastleProvider()); - - String endpoint = "https://updates.push.services.mozilla.com/push/v1/gAAAAABXS0Nhothwqf0Je2mmjuRgyXjgVylY0yZ4qmP3cglrFoneY-XOLdJuGZOsv5Eh7ndhe8mMvge3VcLhpgbQ3w6_vWK7FZkSXhzjlaIxikL6cbW6Gok5BVw1tL1jqruy5Y-deSoz"; - String encodedUserPublicKey = "BNP6uzB5yqQDltCnO1snr-Qx3wLUPgeznuUQjfFbmehRHJK3s4eaqy04nOnm9796mceidVJPlFaobd94yjwtmpU="; - - PublicKey userPublicKey = Utils.loadPublicKey(encodedUserPublicKey); - - Notification notification = new Notification( - endpoint, - userPublicKey, - null, - "{\"title\": \"Hello\", \"message\": \"World\"}".getBytes() - ); - - PushService pushService = new PushService(); - Future httpResponse = pushService.send(notification); - - System.out.println(httpResponse.get().asString()); - } -} diff --git a/src/test/java/nl/martijndwars/webpush/selenium/BrowserTest.java b/src/test/java/nl/martijndwars/webpush/selenium/BrowserTest.java new file mode 100644 index 0000000..37ed6d2 --- /dev/null +++ b/src/test/java/nl/martijndwars/webpush/selenium/BrowserTest.java @@ -0,0 +1,78 @@ +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(); + } +} diff --git a/src/test/java/nl/martijndwars/webpush/selenium/Configuration.java b/src/test/java/nl/martijndwars/webpush/selenium/Configuration.java new file mode 100644 index 0000000..5b4e4e1 --- /dev/null +++ b/src/test/java/nl/martijndwars/webpush/selenium/Configuration.java @@ -0,0 +1,24 @@ +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; + } +} diff --git a/src/test/java/nl/martijndwars/webpush/selenium/SeleniumTests.java b/src/test/java/nl/martijndwars/webpush/selenium/SeleniumTests.java new file mode 100644 index 0000000..1054486 --- /dev/null +++ b/src/test/java/nl/martijndwars/webpush/selenium/SeleniumTests.java @@ -0,0 +1,81 @@ +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) + ); + } +} diff --git a/src/test/java/nl/martijndwars/webpush/selenium/TestingService.java b/src/test/java/nl/martijndwars/webpush/selenium/TestingService.java new file mode 100644 index 0000000..a89f9a9 --- /dev/null +++ b/src/test/java/nl/martijndwars/webpush/selenium/TestingService.java @@ -0,0 +1,160 @@ +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(); + } +}