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.
+
+[](https://travis-ci.org/web-push-libs/webpush-java)
+[](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();
+ }
+}