diff --git a/.travis.yml b/.travis.yml index e05e2d3..eefe331 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,21 +11,9 @@ env: before_cache: - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ -before_install: - - nvm i node -install: - - npm i -g github:Minishlink/web-push-testing-service#update/deps -before_script: - - "export DISPLAY=:99.0" - - "sh -e /etc/init.d/xvfb start || echo \"Unable to start virtual display.\"" - - sleep 3 script: - - web-push-testing-service start wpts - ./gradlew clean check - - web-push-testing-service stop wpts cache: directories: - $HOME/.gradle/caches/ - $HOME/.gradle/wrapper/ - - ~/.selenium-assistant - - node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md index b47e891..f4d77ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +# 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). diff --git a/README.md b/README.md index 0c79876..746878b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # WebPush -A Web Push library for Java 7. Supports payloads and VAPID. +A Web Push library for Java 8. Supports payloads and VAPID. [![Build Status](https://travis-ci.org/web-push-libs/webpush-java.svg?branch=master)](https://travis-ci.org/web-push-libs/webpush-java) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/nl.martijndwars/web-push/badge.svg)](https://search.maven.org/search?q=g:nl.martijndwars%20AND%20a:web-push) @@ -10,7 +10,7 @@ A Web Push library for Java 7. Supports payloads and VAPID. For Gradle, add the following dependency to `build.gradle`: ```groovy -compile group: 'nl.martijndwars', name: 'web-push', version: '5.0.2-SNAPSHOT' +compile group: 'nl.martijndwars', name: 'web-push', version: '5.1.2' ``` For Maven, add the following dependency to `pom.xml`: @@ -19,7 +19,7 @@ For Maven, add the following dependency to `pom.xml`:     nl.martijndwars     web-push -    5.0.2-SNAPSHOT +    5.1.2 ``` @@ -94,7 +94,7 @@ First, make sure you add the BouncyCastle security provider: Security.addProvider(new BouncyCastleProvider()); ``` -Then, create an instance of the push service: +Then, create an instance of the push service, either `nl.martijndwars.webpush.PushService` for synchronous blocking HTTP calls, or `nl.martijndwars.webpush.PushAsyncService` for asynchronous non-blocking HTTP calls: ```java PushService pushService = new PushService(...); @@ -112,12 +112,6 @@ To send a push notification: pushService.send(notification); ``` -Use `sendAsync` instead of `send` to get a `Future`: - -```java -pushService.sendAsync(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). diff --git a/RELEASE.md b/RELEASE.md index 803949d..dfa4724 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,7 @@ # 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. ``` diff --git a/build.gradle b/build.gradle index 89dc784..83ca068 100644 --- a/build.gradle +++ b/build.gradle @@ -1,18 +1,18 @@ plugins { id 'application' - id 'com.github.johnrengelman.shadow' version '4.0.3' + 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.20.0' + id 'io.codearte.nexus-staging' version '0.30.0' } apply plugin: 'application' apply plugin: 'com.github.johnrengelman.shadow' group 'nl.martijndwars' -version '5.0.2-SNAPSHOT' +version '5.1.2' repositories { mavenLocal() @@ -21,37 +21,40 @@ repositories { dependencies { // For CLI - compile group: 'com.beust', name: 'jcommander', version: '1.72' + implementation group: 'com.beust', name: 'jcommander', version: '1.81' - // For parsing JSON - compile group: 'com.google.code.gson', name: 'gson', version: '2.8.5' + // For making HTTP requests + implementation group: 'org.apache.httpcomponents', name: 'httpasyncclient', version: '4.1.5' // For making async HTTP requests - compile group: 'org.apache.httpcomponents', name: 'httpasyncclient', version: '4.1.4' + implementation group: 'org.asynchttpclient', name: 'async-http-client', version: '2.12.4' // For cryptographic operations - shadow group: 'org.bouncycastle', name: 'bcprov-jdk15on', version: '1.54' + shadow group: 'org.bouncycastle', name: 'bcprov-jdk15on', version: '1.70' // For creating and signing JWT - compile group: 'org.bitbucket.b_c', name: 'jose4j', version: '0.6.4' + implementation group: 'org.bitbucket.b_c', name: 'jose4j', version: '0.9.6' + + // For parsing JSON + testImplementation group: 'com.google.code.gson', name: 'gson', version: '2.8.9' // For making HTTP requests - testCompile group: 'org.apache.httpcomponents', name: 'fluent-hc', version: '4.5.6' + testImplementation group: 'org.apache.httpcomponents', name: 'fluent-hc', version: '4.5.13' // For testing, obviously - testCompile group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: '5.3.2' + testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: '5.8.1' // For running JUnit tests - testRuntime group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: '5.3.2' + testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: '5.8.1' // For turning InputStream to String - testCompile group: 'commons-io', name: 'commons-io', version: '2.6' + testImplementation group: 'commons-io', name: 'commons-io', version: '2.11.0' // For reading the demo vapid keypair from a pem file - testCompile group: 'org.bouncycastle', name: 'bcpkix-jdk15on', version: '1.57' + testImplementation group: 'org.bouncycastle', name: 'bcpkix-jdk15on', version: '1.70' // For verifying Base64Encoder results in unit tests - testCompile group: 'com.google.guava', name: 'guava', version: '27.0.1-jre' + testImplementation group: 'com.google.guava', name: 'guava', version: '33.4.8-jre' } wrapper { @@ -59,8 +62,8 @@ wrapper { } compileJava { - sourceCompatibility = 1.7 - targetCompatibility = 1.7 + sourceCompatibility = 1.8 + targetCompatibility = 1.8 } compileTestJava { @@ -70,7 +73,7 @@ compileTestJava { mainClassName = 'nl.martijndwars.webpush.cli.Cli' run { - classpath 'bcprov-jdk15on-154.jar' + classpath configurations.shadow.files } test { @@ -79,7 +82,10 @@ test { testLogging { events 'PASSED', 'FAILED', 'SKIPPED' showStandardStreams true + exceptionFormat 'full' } + + exclude '**/SeleniumTests.class' } task javadocJar(type: Jar) { diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 457aad0..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 6b3851a..ffed3a2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index af6708f..1b6c787 100755 --- a/gradlew +++ b/gradlew @@ -1,78 +1,129 @@ -#!/usr/bin/env sh +#!/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/. +# ############################################################################## # 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 +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 -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null + +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"' +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 () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + 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 @@ -81,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 @@ -89,84 +140,95 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "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"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # 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 -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" - -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" -fi +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 0f8d593..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 @@ -13,15 +29,18 @@ 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" +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,28 +64,14 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_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=%* - :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/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/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/Base64Encoder.java b/src/main/java/nl/martijndwars/webpush/Base64Encoder.java deleted file mode 100644 index b9f05ee..0000000 --- a/src/main/java/nl/martijndwars/webpush/Base64Encoder.java +++ /dev/null @@ -1,51 +0,0 @@ -package nl.martijndwars.webpush; - - -import org.apache.commons.codec.binary.Base64; - -/** - * Java 7 compatible Base64 encode/decode functions. Based on Apache Commons Codec. - * - *

- * Note: Once upgrading to Java 8+, replace by native Base64 encoder. - *

- */ -public class Base64Encoder { - - public static byte[] decode(String base64Encoded) { - return Base64.decodeBase64(base64Encoded); - } - - public static String encodeWithoutPadding(byte[] bytes) { - return unpad(Base64.encodeBase64String(bytes)); - } - - public static String encodeUrl(byte[] bytes) { - return pad(Base64.encodeBase64URLSafeString(bytes)); - } - - public static String encodeUrlWithoutPadding(byte[] bytes) { - return Base64.encodeBase64URLSafeString(bytes); - } - - private static String pad(String base64Encoded) { - int m = base64Encoded.length() % 4; - if (m == 2) { - return base64Encoded + "=="; - } else if (m == 3) { - return base64Encoded + "="; - } else { - return base64Encoded; - } - } - - private static String unpad(String base64Encoded) { - if (base64Encoded.endsWith("==")) { - return base64Encoded.substring(0, base64Encoded.length() - 2); - } else if (base64Encoded.endsWith("=")) { - return base64Encoded.substring(0, base64Encoded.length() - 1); - } else { - return base64Encoded; - } - } -} diff --git a/src/main/java/nl/martijndwars/webpush/HttpEce.java b/src/main/java/nl/martijndwars/webpush/HttpEce.java index 4aacdc0..b9e9436 100644 --- a/src/main/java/nl/martijndwars/webpush/HttpEce.java +++ b/src/main/java/nl/martijndwars/webpush/HttpEce.java @@ -12,6 +12,7 @@ import java.nio.ByteBuffer; import java.security.*; import java.util.Arrays; +import java.util.Base64; import java.util.HashMap; import java.util.Map; @@ -437,7 +438,7 @@ private static byte[] intToBytes(int number) { */ private static byte[] log(String info, byte[] array) { if ("1".equals(System.getenv("ECE_KEYLOG"))) { - System.out.println(info + " [" + array.length + "]: " + Base64Encoder.encodeUrlWithoutPadding(array)); + System.out.println(info + " [" + array.length + "]: " + Base64.getUrlEncoder().withoutPadding().encodeToString(array)); } 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 8a67e7b..6fdc493 100644 --- a/src/main/java/nl/martijndwars/webpush/Notification.java +++ b/src/main/java/nl/martijndwars/webpush/Notification.java @@ -8,6 +8,7 @@ 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; @@ -32,40 +33,74 @@ 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; + 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) { + 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); + 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, 2419200); + this(endpoint, userPublicKey, userAuth, payload, DEFAULT_TTL); } public Notification(String endpoint, String userPublicKey, String userAuth, byte[] payload) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { - this(endpoint, Utils.loadPublicKey(userPublicKey), Base64Encoder.decode(userAuth), payload); + 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), Base64Encoder.decode(userAuth), payload.getBytes(UTF_8)); + 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; } @@ -86,6 +121,14 @@ 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 * @@ -103,9 +146,94 @@ public int getTTL() { return ttl; } + public Urgency getUrgency() { + return urgency; + } + + public String getTopic() { + return topic; + } + public String getOrigin() throws MalformedURLException { URL url = new URL(getEndpoint()); return url.getProtocol() + "://" + url.getHost(); } + + public static NotificationBuilder builder() { + return new Notification.NotificationBuilder(); + } + + public static class NotificationBuilder { + private String endpoint = null; + private ECPublicKey userPublicKey = null; + private byte[] userAuth = null; + private byte[] payload = null; + private int ttl = DEFAULT_TTL; + private Urgency urgency = null; + private String topic = null; + + private NotificationBuilder() { + } + + public Notification build() { + return new Notification(endpoint, userPublicKey, userAuth, payload, ttl, urgency, topic); + } + + public NotificationBuilder endpoint(String endpoint) { + this.endpoint = endpoint; + return this; + } + + public NotificationBuilder userPublicKey(PublicKey publicKey) { + this.userPublicKey = (ECPublicKey) publicKey; + return this; + } + + public NotificationBuilder userPublicKey(String publicKey) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { + this.userPublicKey = (ECPublicKey) Utils.loadPublicKey(publicKey); + return this; + } + + public NotificationBuilder userPublicKey(byte[] publicKey) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { + this.userPublicKey = (ECPublicKey) Utils.loadPublicKey(publicKey); + return this; + } + + public NotificationBuilder userAuth(String userAuth) { + this.userAuth = Base64.getUrlDecoder().decode(userAuth); + return this; + } + + public NotificationBuilder userAuth(byte[] userAuth) { + this.userAuth = userAuth; + return this; + } + + public NotificationBuilder payload(byte[] payload) { + this.payload = payload; + return this; + } + + public NotificationBuilder payload(String payload) { + this.payload = payload.getBytes(UTF_8); + return this; + } + + public NotificationBuilder ttl(int ttl) { + this.ttl = ttl; + return this; + } + + public NotificationBuilder urgency(Urgency urgency) { + this.urgency = urgency; + return this; + } + + public NotificationBuilder topic(String topic) { + this.topic = topic; + return this; + } + } + } 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 60b2d97..e15647b 100644 --- a/src/main/java/nl/martijndwars/webpush/PushService.java +++ b/src/main/java/nl/martijndwars/webpush/PushService.java @@ -23,98 +23,29 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; -public class PushService { - 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) - */ - private String subject; - - /** - * The public key (for VAPID) - */ - private PublicKey publicKey; - - /** - * The private key (for VAPID) - */ - private PrivateKey privateKey; +public class PushService extends AbstractPushService { public PushService() { } public PushService(String gcmApiKey) { - this.gcmApiKey = gcmApiKey; + super(gcmApiKey); } - public PushService(KeyPair keyPair, String subject) { - this.publicKey = keyPair.getPublic(); - this.privateKey = keyPair.getPrivate(); - this.subject = subject; + public PushService(KeyPair keyPair) { + super(keyPair); } - public PushService(String publicKey, String privateKey, String subject) throws GeneralSecurityException { - this.publicKey = Utils.loadPublicKey(publicKey); - this.privateKey = Utils.loadPrivateKey(privateKey); - this.subject = subject; + public PushService(KeyPair keyPair, String subject) { + super(keyPair, 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(); + public PushService(String publicKey, String privateKey) throws GeneralSecurityException { + super(publicKey, privateKey); } - /** - * 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(); + public PushService(String publicKey, String privateKey, String subject) throws GeneralSecurityException { + super(publicKey, privateKey, subject); } /** @@ -134,7 +65,7 @@ public HttpResponse send(Notification notification, Encoding encoding) throws Ge } public HttpResponse send(Notification notification) throws GeneralSecurityException, IOException, JoseException, ExecutionException, InterruptedException { - return send(notification, Encoding.AESGCM); + return send(notification, Encoding.AES128GCM); } /** @@ -146,7 +77,10 @@ public HttpResponse send(Notification notification) throws GeneralSecurityExcept * @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); @@ -156,6 +90,10 @@ public Future sendAsync(Notification notification, Encoding encodi return closeableHttpAsyncClient.execute(httpPost, new ClosableCallback(closeableHttpAsyncClient)); } + /** + * @deprecated Use {@link PushAsyncService#send(Notification)} instead. + */ + @Deprecated public Future sendAsync(Notification notification) throws GeneralSecurityException, IOException, JoseException { return sendAsync(notification, Encoding.AES128GCM); } @@ -171,193 +109,12 @@ public Future sendAsync(Notification notification) throws GeneralS * @throws JoseException */ public HttpPost preparePost(Notification notification, Encoding encoding) throws GeneralSecurityException, IOException, JoseException { - if (privateKey != null && publicKey != null) { - if (!Utils.verifyKeyPair(privateKey, publicKey)) { - throw new IllegalStateException("Public key and private key do not match."); - } + 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())); } - - Encrypted encrypted = encrypt( - notification.getPayload(), - notification.getUserPublicKey(), - notification.getUserAuth(), - encoding - ); - - byte[] dh = Utils.encode((ECPublicKey) encrypted.getPublicKey()); - byte[] salt = encrypted.getSalt(); - - HttpPost httpPost = new HttpPost(notification.getEndpoint()); - httpPost.addHeader("TTL", String.valueOf(notification.getTTL())); - - Map headers = new HashMap<>(); - - 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=" + Base64Encoder.encodeUrlWithoutPadding(salt)); - headers.put("Crypto-Key", "dh=" + Base64Encoder.encodeUrl(dh)); - } - - httpPost.setEntity(new ByteArrayEntity(encrypted.getCiphertext())); - } - - if (notification.isGcm()) { - if (gcmApiKey == null) { - throw new IllegalStateException("An GCM API key is needed to send a push notification to a GCM endpoint."); - } - - headers.put("Authorization", "key=" + gcmApiKey); - } else if (vapidEnabled()) { - if (encoding == Encoding.AES128GCM) { - if (notification.getEndpoint().startsWith("https://fcm.googleapis.com")) { - httpPost.setURI(URI.create(notification.getEndpoint().replace("fcm/send", "wp"))); - } - } - - JwtClaims claims = new JwtClaims(); - claims.setAudience(notification.getOrigin()); - claims.setExpirationTimeMinutesInTheFuture(12 * 60); - claims.setSubject(subject); - - JsonWebSignature jws = new JsonWebSignature(); - jws.setHeader("typ", "JWT"); - jws.setHeader("alg", "ES256"); - jws.setPayload(claims.toJson()); - jws.setKey(privateKey); - jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.ECDSA_USING_P256_CURVE_AND_SHA256); - - byte[] pk = Utils.encode((ECPublicKey) publicKey); - - if (encoding == Encoding.AES128GCM) { - headers.put("Authorization", "vapid t=" + jws.getCompactSerialization() + ", k=" + Base64Encoder.encodeUrlWithoutPadding(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=" + Base64Encoder.encodeUrlWithoutPadding(pk)); - } else { - headers.put("Crypto-Key", "p256ecdsa=" + Base64Encoder.encodeUrl(pk)); - } - } else if (notification.isFcm() && gcmApiKey != null) { - headers.put("Authorization", "key=" + gcmApiKey); - } - - for (Map.Entry entry : headers.entrySet()) { - httpPost.addHeader(new BasicHeader(entry.getKey(), entry.getValue())); - } - return httpPost; } - - /** - * Set the Google Cloud Messaging (GCM) API key - * - * @param gcmApiKey - * @return - */ - public PushService setGcmApiKey(String gcmApiKey) { - this.gcmApiKey = gcmApiKey; - - return this; - } - - /** - * Set the JWT subject (for VAPID) - * - * @param subject - * @return - */ - public PushService setSubject(String subject) { - this.subject = subject; - - return this; - } - - /** - * Set the public and private key (for VAPID). - * - * @param keyPair - * @return - */ - public PushService setKeyPair(KeyPair keyPair) { - setPublicKey(keyPair.getPublic()); - setPrivateKey(keyPair.getPrivate()); - - return this; - } - - public PublicKey getPublicKey() { - return publicKey; - } - - /** - * Set the public key using a base64url-encoded string. - * - * @param publicKey - * @return - */ - public PushService setPublicKey(String publicKey) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { - setPublicKey(Utils.loadPublicKey(publicKey)); - - return this; - } - - public PrivateKey getPrivateKey() { - return privateKey; - } - - public KeyPair getKeyPair() { - return new KeyPair(publicKey, privateKey); - } - - /** - * Set the public key (for VAPID) - * - * @param publicKey - * @return - */ - public PushService setPublicKey(PublicKey publicKey) { - this.publicKey = publicKey; - - return this; - } - - /** - * Set the public key using a base64url-encoded string. - * - * @param privateKey - * @return - */ - public PushService setPrivateKey(String privateKey) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException { - setPrivateKey(Utils.loadPrivateKey(privateKey)); - - return this; - } - - /** - * Set the private key (for VAPID) - * - * @param privateKey - * @return - */ - public PushService setPrivateKey(PrivateKey privateKey) { - this.privateKey = privateKey; - - return this; - } - - /** - * Check if VAPID is enabled - * - * @return - */ - protected boolean vapidEnabled() { - return publicKey != null && privateKey != null; - } } 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 f5553d6..d135f38 100644 --- a/src/main/java/nl/martijndwars/webpush/Utils.java +++ b/src/main/java/nl/martijndwars/webpush/Utils.java @@ -15,6 +15,7 @@ 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; @@ -45,7 +46,16 @@ public static byte[] encode(ECPrivateKey privateKey) { * @param encodedPublicKey */ public static PublicKey loadPublicKey(String encodedPublicKey) throws NoSuchProviderException, NoSuchAlgorithmException, InvalidKeySpecException { - byte[] decodedPublicKey = Base64Encoder.decode(encodedPublicKey); + byte[] decodedPublicKey = Base64.getUrlDecoder().decode(encodedPublicKey); + return loadPublicKey(decodedPublicKey); + } + + /** + * Load the public key from a byte array. + * + * @param decodedPublicKey + */ + public static PublicKey loadPublicKey(byte[] decodedPublicKey) throws NoSuchProviderException, NoSuchAlgorithmException, InvalidKeySpecException { KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM, PROVIDER_NAME); ECParameterSpec parameterSpec = ECNamedCurveTable.getParameterSpec(CURVE); ECCurve curve = parameterSpec.getCurve(); @@ -65,7 +75,20 @@ public static PublicKey loadPublicKey(String encodedPublicKey) throws NoSuchProv * @throws InvalidKeySpecException */ public static PrivateKey loadPrivateKey(String encodedPrivateKey) throws NoSuchProviderException, NoSuchAlgorithmException, InvalidKeySpecException { - byte[] decodedPrivateKey = Base64Encoder.decode(encodedPrivateKey); + byte[] decodedPrivateKey = Base64.getUrlDecoder().decode(encodedPrivateKey); + return loadPrivateKey(decodedPrivateKey); + } + + /** + * Load the private key from a byte array + * + * @param decodedPrivateKey + * @return + * @throws NoSuchProviderException + * @throws NoSuchAlgorithmException + * @throws InvalidKeySpecException + */ + public static PrivateKey loadPrivateKey(byte[] decodedPrivateKey) throws NoSuchProviderException, NoSuchAlgorithmException, InvalidKeySpecException { BigInteger s = BigIntegers.fromUnsignedByteArray(decodedPrivateKey); ECParameterSpec parameterSpec = ECNamedCurveTable.getParameterSpec(CURVE); ECPrivateKeySpec privateKeySpec = new ECPrivateKeySpec(s, parameterSpec); diff --git a/src/main/java/nl/martijndwars/webpush/cli/handlers/GenerateKeyHandler.java b/src/main/java/nl/martijndwars/webpush/cli/handlers/GenerateKeyHandler.java index 68bfd99..1747adf 100644 --- a/src/main/java/nl/martijndwars/webpush/cli/handlers/GenerateKeyHandler.java +++ b/src/main/java/nl/martijndwars/webpush/cli/handlers/GenerateKeyHandler.java @@ -1,6 +1,5 @@ package nl.martijndwars.webpush.cli.handlers; -import nl.martijndwars.webpush.Base64Encoder; import nl.martijndwars.webpush.Utils; import nl.martijndwars.webpush.cli.commands.GenerateKeyCommand; import org.bouncycastle.jce.ECNamedCurveTable; @@ -15,6 +14,7 @@ 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; @@ -42,10 +42,10 @@ public void run() throws InvalidAlgorithmParameterException, NoSuchAlgorithmExce } System.out.println("PublicKey:"); - System.out.println(Base64Encoder.encodeUrl(encodedPublicKey)); + System.out.println(Base64.getUrlEncoder().withoutPadding().encodeToString(encodedPublicKey)); System.out.println("PrivateKey:"); - System.out.println(Base64Encoder.encodeUrl(encodedPrivateKey)); + System.out.println(Base64.getUrlEncoder().withoutPadding().encodeToString(encodedPrivateKey)); } /** diff --git a/src/test/java/nl/martijndwars/webpush/Base64EncoderTest.java b/src/test/java/nl/martijndwars/webpush/Base64EncoderTest.java deleted file mode 100644 index 962e405..0000000 --- a/src/test/java/nl/martijndwars/webpush/Base64EncoderTest.java +++ /dev/null @@ -1,128 +0,0 @@ -package nl.martijndwars.webpush; - -import org.junit.jupiter.api.Test; - -import static com.google.common.io.BaseEncoding.base64; -import static com.google.common.io.BaseEncoding.base64Url; -import static java.nio.charset.StandardCharsets.UTF_8; -import static nl.martijndwars.webpush.Base64Encoder.*; -import static org.junit.jupiter.api.Assertions.assertEquals; - -class Base64EncoderTest { - - @Test - void decodeTest() { - // first compare with previous guava implementation, make sure non-breaking changes - assertEquals(new String(base64().decode("")), new String(decode(""))); - assertEquals(new String(base64().decode("dw")), new String(decode("dw"))); - assertEquals(new String(base64().decode("dw==")), new String(decode("dw=="))); - assertEquals(new String(base64().decode("d2U")), new String(decode("d2U"))); - assertEquals(new String(base64().decode("d2Vi")), new String(decode("d2Vi"))); - assertEquals(new String(base64().decode("d2ViLQ")), new String(decode("d2ViLQ"))); - assertEquals(new String(base64().decode("d2ViLQ==")), new String(decode("d2ViLQ=="))); - assertEquals(new String(base64().decode("d2ViLXA")), new String(decode("d2ViLXA"))); - assertEquals(new String(base64().decode("d2ViLXA=")), new String(decode("d2ViLXA="))); - assertEquals(new String(base64().decode("d2ViLXB1")), new String(decode("d2ViLXB1"))); - assertEquals(new String(base64().decode("d2ViLXB1cw")), new String(decode("d2ViLXB1cw"))); - assertEquals(new String(base64().decode("d2ViLXB1cw==")), new String(decode("d2ViLXB1cw=="))); - assertEquals(new String(base64().decode("d2ViLXB1c2g")), new String(decode("d2ViLXB1c2g"))); - assertEquals(new String(base64().decode("d2ViLXB1c2g=")), new String(decode("d2ViLXB1c2g="))); - assertEquals(new String(base64().decode("d2ViLXB1c2g/")), new String(decode("d2ViLXB1c2g/"))); - assertEquals(new String(base64Url().decode("d2ViLXB1c2g_")), new String(decode("d2ViLXB1c2g_"))); - - assertEquals("", new String(decode(""))); - assertEquals("w", new String(decode("dw"))); - assertEquals("w", new String(decode("dw=="))); - assertEquals("we", new String(decode("d2U"))); - assertEquals("web", new String(decode("d2Vi"))); - assertEquals("web-", new String(decode("d2ViLQ"))); - assertEquals("web-", new String(decode("d2ViLQ=="))); - assertEquals("web-p", new String(decode("d2ViLXA"))); - assertEquals("web-p", new String(decode("d2ViLXA="))); - assertEquals("web-pu", new String(decode("d2ViLXB1"))); - assertEquals("web-pus", new String(decode("d2ViLXB1cw"))); - assertEquals("web-pus", new String(decode("d2ViLXB1cw=="))); - assertEquals("web-push", new String(decode("d2ViLXB1c2g"))); - assertEquals("web-push", new String(decode("d2ViLXB1c2g="))); - assertEquals("web-push?", new String(decode("d2ViLXB1c2g/"))); - assertEquals("web-push?", new String(decode("d2ViLXB1c2g_"))); - } - - @Test - void encodeWithoutPaddingTest() { - // first verify non breaking changes after removing guava as compile dependency - assertEquals(base64().omitPadding().encode("".getBytes()), encodeWithoutPadding("".getBytes(UTF_8))); - assertEquals(base64().omitPadding().encode("w".getBytes()), encodeWithoutPadding("w".getBytes(UTF_8))); - assertEquals(base64().omitPadding().encode("we".getBytes()), encodeWithoutPadding("we".getBytes(UTF_8))); - assertEquals(base64().omitPadding().encode("web".getBytes()), encodeWithoutPadding("web".getBytes(UTF_8))); - assertEquals(base64().omitPadding().encode("web-".getBytes()), encodeWithoutPadding("web-".getBytes(UTF_8))); - assertEquals(base64().omitPadding().encode("web-p".getBytes()), encodeWithoutPadding("web-p".getBytes(UTF_8))); - assertEquals(base64().omitPadding().encode("web-pu".getBytes()), encodeWithoutPadding("web-pu".getBytes(UTF_8))); - assertEquals(base64().omitPadding().encode("web-pus".getBytes()), encodeWithoutPadding("web-pus".getBytes(UTF_8))); - assertEquals(base64().omitPadding().encode("web-push".getBytes()), encodeWithoutPadding("web-push".getBytes(UTF_8))); - assertEquals(base64().omitPadding().encode("web-push?".getBytes()), encodeWithoutPadding("web-push?".getBytes(UTF_8))); - - assertEquals("", encodeWithoutPadding("".getBytes(UTF_8))); - assertEquals("dw", encodeWithoutPadding("w".getBytes(UTF_8))); - assertEquals("d2U", encodeWithoutPadding("we".getBytes(UTF_8))); - assertEquals("d2Vi", encodeWithoutPadding("web".getBytes(UTF_8))); - assertEquals("d2ViLQ", encodeWithoutPadding("web-".getBytes(UTF_8))); - assertEquals("d2ViLXA", encodeWithoutPadding("web-p".getBytes(UTF_8))); - assertEquals("d2ViLXB1", encodeWithoutPadding("web-pu".getBytes(UTF_8))); - assertEquals("d2ViLXB1cw", encodeWithoutPadding("web-pus".getBytes(UTF_8))); - assertEquals("d2ViLXB1c2g", encodeWithoutPadding("web-push".getBytes(UTF_8))); - assertEquals("d2ViLXB1c2g/", encodeWithoutPadding("web-push?".getBytes(UTF_8))); - } - - @Test - void encodeUrlTest() { - // first verify non breaking changes after removing guava as compile dependency - assertEquals(base64Url().encode("".getBytes()), encodeUrl("".getBytes(UTF_8))); - assertEquals(base64Url().encode("w".getBytes()), encodeUrl("w".getBytes(UTF_8))); - assertEquals(base64Url().encode("we".getBytes()), encodeUrl("we".getBytes(UTF_8))); - assertEquals(base64Url().encode("web".getBytes()), encodeUrl("web".getBytes(UTF_8))); - assertEquals(base64Url().encode("web-".getBytes()), encodeUrl("web-".getBytes(UTF_8))); - assertEquals(base64Url().encode("web-p".getBytes()), encodeUrl("web-p".getBytes(UTF_8))); - assertEquals(base64Url().encode("web-pu".getBytes()), encodeUrl("web-pu".getBytes(UTF_8))); - assertEquals(base64Url().encode("web-pus".getBytes()), encodeUrl("web-pus".getBytes(UTF_8))); - assertEquals(base64Url().encode("web-push".getBytes()), encodeUrl("web-push".getBytes(UTF_8))); - assertEquals(base64Url().encode("web-push?".getBytes()), encodeUrl("web-push?".getBytes(UTF_8))); - - assertEquals("", encodeUrl("".getBytes(UTF_8))); - assertEquals("dw==", encodeUrl("w".getBytes(UTF_8))); - assertEquals("d2U=", encodeUrl("we".getBytes(UTF_8))); - assertEquals("d2Vi", encodeUrl("web".getBytes(UTF_8))); - assertEquals("d2ViLQ==", encodeUrl("web-".getBytes(UTF_8))); - assertEquals("d2ViLXA=", encodeUrl("web-p".getBytes(UTF_8))); - assertEquals("d2ViLXB1", encodeUrl("web-pu".getBytes(UTF_8))); - assertEquals("d2ViLXB1cw==", encodeUrl("web-pus".getBytes(UTF_8))); - assertEquals("d2ViLXB1c2g=", encodeUrl("web-push".getBytes(UTF_8))); - assertEquals("d2ViLXB1c2g_", encodeUrl("web-push?".getBytes(UTF_8))); - } - - @Test - void encodeUrlWithoutPaddingTest() { - // first verify non breaking changes after removing guava as compile dependency - assertEquals(base64Url().omitPadding().encode("".getBytes()), encodeUrlWithoutPadding("".getBytes(UTF_8))); - assertEquals(base64Url().omitPadding().encode("w".getBytes()), encodeUrlWithoutPadding("w".getBytes(UTF_8))); - assertEquals(base64Url().omitPadding().encode("we".getBytes()), encodeUrlWithoutPadding("we".getBytes(UTF_8))); - assertEquals(base64Url().omitPadding().encode("web".getBytes()), encodeUrlWithoutPadding("web".getBytes(UTF_8))); - assertEquals(base64Url().omitPadding().encode("web-".getBytes()), encodeUrlWithoutPadding("web-".getBytes(UTF_8))); - assertEquals(base64Url().omitPadding().encode("web-p".getBytes()), encodeUrlWithoutPadding("web-p".getBytes(UTF_8))); - assertEquals(base64Url().omitPadding().encode("web-pu".getBytes()), encodeUrlWithoutPadding("web-pu".getBytes(UTF_8))); - assertEquals(base64Url().omitPadding().encode("web-pus".getBytes()), encodeUrlWithoutPadding("web-pus".getBytes(UTF_8))); - assertEquals(base64Url().omitPadding().encode("web-push".getBytes()), encodeUrlWithoutPadding("web-push".getBytes(UTF_8))); - assertEquals(base64Url().omitPadding().encode("web-push?".getBytes()), encodeUrlWithoutPadding("web-push?".getBytes(UTF_8))); - - assertEquals("", encodeUrlWithoutPadding("".getBytes(UTF_8))); - assertEquals("dw", encodeUrlWithoutPadding("w".getBytes(UTF_8))); - assertEquals("d2U", encodeUrlWithoutPadding("we".getBytes(UTF_8))); - assertEquals("d2Vi", encodeUrlWithoutPadding("web".getBytes(UTF_8))); - assertEquals("d2ViLQ", encodeUrlWithoutPadding("web-".getBytes(UTF_8))); - assertEquals("d2ViLXA", encodeUrlWithoutPadding("web-p".getBytes(UTF_8))); - assertEquals("d2ViLXB1", encodeUrlWithoutPadding("web-pu".getBytes(UTF_8))); - assertEquals("d2ViLXB1cw", encodeUrlWithoutPadding("web-pus".getBytes(UTF_8))); - assertEquals("d2ViLXB1c2g", encodeUrlWithoutPadding("web-push".getBytes(UTF_8))); - assertEquals("d2ViLXB1c2g_", encodeUrlWithoutPadding("web-push?".getBytes(UTF_8))); - } -} \ No newline at end of file diff --git a/src/test/java/nl/martijndwars/webpush/HttpEceTest.java b/src/test/java/nl/martijndwars/webpush/HttpEceTest.java index fadc408..c3f6884 100644 --- a/src/test/java/nl/martijndwars/webpush/HttpEceTest.java +++ b/src/test/java/nl/martijndwars/webpush/HttpEceTest.java @@ -7,9 +7,9 @@ import org.junit.jupiter.api.Test; import java.security.*; +import java.util.Base64; import java.util.HashMap; -import static nl.martijndwars.webpush.Base64Encoder.decode; import static nl.martijndwars.webpush.Encoding.AES128GCM; import static org.junit.jupiter.api.Assertions.assertArrayEquals; @@ -19,6 +19,10 @@ 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(); 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/selenium/SeleniumTests.java b/src/test/java/nl/martijndwars/webpush/selenium/SeleniumTests.java index 00522bb..1054486 100644 --- a/src/test/java/nl/martijndwars/webpush/selenium/SeleniumTests.java +++ b/src/test/java/nl/martijndwars/webpush/selenium/SeleniumTests.java @@ -1,6 +1,5 @@ package nl.martijndwars.webpush.selenium; -import nl.martijndwars.webpush.Base64Encoder; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.DynamicTest; @@ -8,6 +7,7 @@ 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; @@ -58,21 +58,21 @@ public Stream dynamicTests() throws IOException { * @return */ protected Stream getConfigurations() { - String PUBLIC_KEY_NO_PADDING = Base64Encoder.encodeWithoutPadding( - Base64Encoder.decode(PUBLIC_KEY) + 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), + //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), + //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 index 8d0f7ed..a89f9a9 100644 --- a/src/test/java/nl/martijndwars/webpush/selenium/TestingService.java +++ b/src/test/java/nl/martijndwars/webpush/selenium/TestingService.java @@ -33,7 +33,7 @@ public TestingService(String baseUrl) { public int startTestSuite() throws IOException { String startTestSuite = request(baseUrl + "start-test-suite/"); - JsonElement root = new JsonParser().parse(startTestSuite); + JsonElement root = JsonParser.parseString(startTestSuite); return root .getAsJsonObject() @@ -129,7 +129,7 @@ protected String request(String uri, HttpEntity entity) throws IOException { String json = EntityUtils.toString(httpResponse.getEntity()); if (httpResponse.getStatusLine().getStatusCode() != 200) { - JsonElement root = new JsonParser().parse(json); + JsonElement root = JsonParser.parseString(json); JsonObject error = root.getAsJsonObject().get("error").getAsJsonObject(); String errorId = error.get("id").getAsString(); @@ -150,7 +150,7 @@ protected String request(String uri, HttpEntity entity) throws IOException { * @param response */ protected JsonObject getData(String response) { - JsonElement root = new JsonParser().parse(response); + JsonElement root = JsonParser.parseString(response); return root .getAsJsonObject()