From ecd7b02a64ec576d9e52eb0b5c871ba6bbc1ec21 Mon Sep 17 00:00:00 2001 From: Martijn Dwars Date: Sun, 4 Nov 2018 16:16:41 +0100 Subject: [PATCH] Add aes128gcm * Update the HTTP Encrypted Content Encoding implementation to support aes128gcm, as defined in the 9th version of the draft [1]. * Update the Web Push implementation to use aes128gcm, as defined in the 9th version of the draft [2]. [1] https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-09 [2] https://tools.ietf.org/html/draft-ietf-webpush-encryption-09 --- .gitignore | 1 + CHANGELOG.md | 5 + .../nl/martijndwars/webpush/Encoding.java | 5 + .../java/nl/martijndwars/webpush/HttpEce.java | 387 +++++++++++++++--- .../nl/martijndwars/webpush/Notification.java | 17 +- .../nl/martijndwars/webpush/PushService.java | 115 ++++-- .../java/nl/martijndwars/webpush/Utils.java | 90 +++- .../cli/handlers/GenerateKeyHandler.java | 11 +- .../nl/martijndwars/webpush/HttpEceTest.java | 103 +++++ .../martijndwars/webpush/PushServiceTest.java | 84 ++++ .../nl/martijndwars/webpush/PushTest.java | 32 +- 11 files changed, 726 insertions(+), 124 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/main/java/nl/martijndwars/webpush/Encoding.java create mode 100644 src/test/java/nl/martijndwars/webpush/HttpEceTest.java diff --git a/.gitignore b/.gitignore index fb41e33..784ab8a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build/ .gradle/ *.iml target/ +out/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5a5c900 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# 4.0.0 + +* Support [aes128gcm content encoding](https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-09#section-2) + * Use `PushService.send(Notification, Encoding)` or the analogous `sendAsync` with `Encoding.AES128GCM`. + 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/HttpEce.java b/src/main/java/nl/martijndwars/webpush/HttpEce.java index e2b6708..4aacdc0 100644 --- a/src/main/java/nl/martijndwars/webpush/HttpEce.java +++ b/src/main/java/nl/martijndwars/webpush/HttpEce.java @@ -3,31 +3,183 @@ 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.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? * @@ -46,27 +198,35 @@ protected static byte[] buildInfo(String type, byte[] context) { } /** - * Convenience method for computing the HMAC Key Derivation Function. The - * real work is offloaded to BouncyCastle. + * 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) throws InvalidKeyException, NoSuchAlgorithmException { + 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[][] deriveKey(byte[] salt, byte[] key, String keyId, PublicKey dh, byte[] authSecret, int padSize) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, InvalidAlgorithmParameterException, BadPaddingException, IllegalBlockSizeException, NoSuchProviderException, IOException { + 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) { @@ -74,21 +234,44 @@ 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); + } + + return new byte[][]{ + secret, + context + }; + } + + public byte[][] deriveKeyAndNonce(byte[] salt, byte[] key, String keyId, ECPublicKey dh, byte[] authSecret, Encoding version, int mode) throws NoSuchAlgorithmException, InvalidKeyException { + byte[] secret; + byte[] keyInfo; + byte[] nonceInfo; + + if (version == Encoding.AESGCM) { + byte[][] secretAndContext = extractSecretAndContext(key, keyId, dh, authSecret); + secret = secretAndContext[0]; + + keyInfo = buildInfo("aesgcm", secretAndContext[1]); + nonceInfo = buildInfo("nonce", secretAndContext[1]); + } else if (version == Encoding.AES128GCM) { + keyInfo = "Content-Encoding: aes128gcm\0".getBytes(); + nonceInfo = "Content-Encoding: nonce\0".getBytes(); + + secret = extractSecret(key, keyId, dh, authSecret, mode); + } else { + throw new IllegalStateException("Unknown version: " + version); } - keyinfo = buildInfo("aesgcm", context); - nonceinfo = buildInfo("nonce", context); + 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, @@ -96,23 +279,89 @@ public byte[][] deriveKey(byte[] salt, byte[] key, String keyId, PublicKey dh, b }; } + 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); + } + /** - * Compute the shared secret using the server's key pair (indicated by - * keyId) and the client's public key. Also compute context. + * Combine Shared and Authentication Secrets + * + * See https://tools.ietf.org/html/draft-ietf-webpush-encryption-09#section-3.3. * * @param keyId + * @param dh + * @param authSecret + * @param mode + * @return + * @throws NoSuchAlgorithmException + * @throws InvalidKeyException + */ + public byte[] webpushSecret(String keyId, ECPublicKey dh, byte[] authSecret, int mode) throws NoSuchAlgorithmException, InvalidKeyException { + ECPublicKey senderPubKey; + ECPublicKey remotePubKey; + ECPublicKey receiverPubKey; + + if (mode == ENCRYPT_MODE) { + senderPubKey = getPublicKey(keyId); + remotePubKey = dh; + receiverPubKey = dh; + } else if (mode == DECRYPT_MODE) { + remotePubKey = getPublicKey(keyId); + senderPubKey = remotePubKey; + receiverPubKey = dh; + } else { + throw new IllegalArgumentException("Unsupported mode: " + mode); + } + + log("remote pubkey", encode(remotePubKey)); + log("sender pubkey", encode(senderPubKey)); + log("receiver pubkey", encode(receiverPubKey)); + + KeyAgreement keyAgreement = KeyAgreement.getInstance("ECDH"); + keyAgreement.init(getPrivateKey(keyId)); + keyAgreement.doPhase(remotePubKey, true); + byte[] secret = keyAgreement.generateSecret(); + + byte[] ikm = secret; + byte[] salt = authSecret; + byte[] info = concat(WEB_PUSH_INFO.getBytes(), encode(receiverPubKey), encode(senderPubKey)); + + return hkdfExpand(ikm, salt, info, SHA_256_LENGTH); + } + + /** + * Compute the shared secret (using the server's key pair and the client's public key) and the context. + * + * @param keyid * @param publicKey * @return */ - private byte[][] 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(UTF_8), 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, @@ -120,63 +369,77 @@ private byte[][] deriveDH(String keyId, PublicKey publicKey) throws NoSuchProvid }; } - private byte[] lengthPrefix(Key key) throws IOException { - byte[] bytes = Utils.savePublicKey((ECPublicKey) key); - - return concat(intToBytes(bytes.length), bytes); - } - /** - * Cast an integer to a two-byte array + * Get the public 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 ECPublicKey getPublicKey(String keyid) { + return (ECPublicKey) keys.get(keyid).getPublic(); } /** - * Utility to concat byte arrays + * Get the private key for the given keyid. + * + * @param keyid + * @return */ - private byte[] concat(byte[]... arrays) { - int lastPos = 0; - - byte[] combined = new byte[combinedLength(arrays)]; + private ECPrivateKey getPrivateKey(String keyid) { + return (ECPrivateKey) keys.get(keyid).getPrivate(); + } - for (byte[] array : arrays) { - System.arraycopy(array, 0, combined, lastPos, array.length); - lastPos += array.length; - } + /** + * Encode the public key as a byte array and prepend its length in two bytes. + * + * @param publicKey + * @return + */ + private static byte[] lengthPrefix(ECPublicKey publicKey) { + byte[] bytes = encode(publicKey); - return combined; + return concat(intToBytes(bytes.length), bytes); } /** - * Compute combined 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 int combinedLength(byte[]... arrays) { - int combinedLength = 0; + private static byte[] intToBytes(int number) { + if (number < 0) { + throw new IllegalArgumentException("Cannot convert a negative number, " + number + " given."); + } - for (byte[] array : arrays) { - combinedLength += array.length; + if (number >= TWO_BYTE_MAX) { + throw new IllegalArgumentException("Cannot convert an integer larger than " + (TWO_BYTE_MAX - 1) + " to two bytes."); } - return combinedLength; - } + 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 + "]: " + Base64Encoder.encodeUrlWithoutPadding(array)); + } - return cipher.doFinal(buffer); + return array; } } diff --git a/src/main/java/nl/martijndwars/webpush/Notification.java b/src/main/java/nl/martijndwars/webpush/Notification.java index b48e7af..8aa030d 100644 --- a/src/main/java/nl/martijndwars/webpush/Notification.java +++ b/src/main/java/nl/martijndwars/webpush/Notification.java @@ -1,5 +1,7 @@ package nl.martijndwars.webpush; +import org.bouncycastle.jce.interfaces.ECPublicKey; + import java.net.MalformedURLException; import java.net.URL; import java.security.NoSuchAlgorithmException; @@ -18,7 +20,7 @@ public class Notification { /** * The client's public key */ - private final PublicKey userPublicKey; + private final ECPublicKey userPublicKey; /** * The client's auth @@ -35,7 +37,8 @@ public class Notification { */ private final int ttl; - public Notification(String endpoint, PublicKey userPublicKey, byte[] userAuth, byte[] payload, int ttl) { + + public Notification(String endpoint, ECPublicKey userPublicKey, byte[] userAuth, byte[] payload, int ttl) { this.endpoint = endpoint; this.userPublicKey = userPublicKey; this.userAuth = userAuth; @@ -43,6 +46,10 @@ public Notification(String endpoint, PublicKey userPublicKey, byte[] userAuth, b this.ttl = ttl; } + public Notification(String endpoint, PublicKey userPublicKey, byte[] userAuth, byte[] payload, int ttl) { + this(endpoint, (ECPublicKey) userPublicKey, userAuth, payload, ttl); + } + public Notification(String endpoint, PublicKey userPublicKey, byte[] userAuth, byte[] payload) { this(endpoint, userPublicKey, userAuth, payload, 2419200); } @@ -63,7 +70,7 @@ public String getEndpoint() { return endpoint; } - public PublicKey getUserPublicKey() { + public ECPublicKey getUserPublicKey() { return userPublicKey; } @@ -92,10 +99,6 @@ public int getTTL() { return ttl; } - public int getPadSize() { - return 2; - } - public String getOrigin() throws MalformedURLException { URL url = new URL(getEndpoint()); diff --git a/src/main/java/nl/martijndwars/webpush/PushService.java b/src/main/java/nl/martijndwars/webpush/PushService.java index c4aae97..de23e1f 100644 --- a/src/main/java/nl/martijndwars/webpush/PushService.java +++ b/src/main/java/nl/martijndwars/webpush/PushService.java @@ -9,25 +9,24 @@ import org.bouncycastle.jce.ECNamedCurveTable; import org.bouncycastle.jce.interfaces.ECPublicKey; import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec; -import org.bouncycastle.math.ec.ECPoint; import org.jose4j.jws.AlgorithmIdentifiers; import org.jose4j.jws.JsonWebSignature; import org.jose4j.jwt.JwtClaims; import org.jose4j.lang.JoseException; import java.io.IOException; +import java.net.URI; import java.security.*; -import java.security.interfaces.ECPrivateKey; import java.security.spec.InvalidKeySpecException; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; -import static nl.martijndwars.webpush.Utils.CURVE; - 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) @@ -69,43 +68,60 @@ public PushService(String publicKey, String privateKey, String subject) throws G } /** - * Encrypt the getPayload using the user's public key using Elliptic Curve - * Diffie Hellman cryptography over the prime256v1 curve. + * Encrypt the payload. + * + * Encryption uses Elliptic curve Diffie-Hellman (ECDH) cryptography over the prime256v1 curve. * - * @return An Encrypted object containing the public key, salt, and - * ciphertext, which can be sent to the other party. + * @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[] buffer, PublicKey userPublicKey, byte[] userAuth, int padSize) throws GeneralSecurityException, IOException { - ECNamedCurveParameterSpec parameterSpec = ECNamedCurveTable.getParameterSpec("prime256v1"); - - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("ECDH", "BC"); - keyPairGenerator.initialize(parameterSpec); - - KeyPair serverKey = keyPairGenerator.generateKeyPair(); + 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", serverKey); + keys.put(SERVER_KEY_ID, localKeyPair); Map labels = new HashMap<>(); - labels.put("server-key-id", "P-256"); + 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(buffer, salt, null, "server-key-id", userPublicKey, userAuth, padSize); + byte[] ciphertext = httpEce.encrypt(payload, salt, null, SERVER_KEY_ID, userPublicKey, userAuth, encoding); return new Encrypted.Builder() .withSalt(salt) - .withPublicKey(serverKey.getPublic()) + .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(); + } + /** * Send a notification and wait for the response. * * @param notification + * @param encoding * @return * @throws GeneralSecurityException * @throws IOException @@ -113,21 +129,26 @@ public static Encrypted encrypt(byte[] buffer, PublicKey userPublicKey, byte[] u * @throws ExecutionException * @throws InterruptedException */ + public HttpResponse send(Notification notification, Encoding encoding) throws GeneralSecurityException, IOException, JoseException, ExecutionException, InterruptedException { + return sendAsync(notification, encoding).get(); + } + public HttpResponse send(Notification notification) throws GeneralSecurityException, IOException, JoseException, ExecutionException, InterruptedException { - return sendAsync(notification).get(); + return send(notification, Encoding.AESGCM); } /** * Send a notification, but don't wait for the response. * * @param notification + * @param encoding * @return * @throws GeneralSecurityException * @throws IOException * @throws JoseException */ - public Future sendAsync(Notification notification) throws GeneralSecurityException, IOException, JoseException { - HttpPost httpPost = preparePost(notification); + public Future sendAsync(Notification notification, Encoding encoding) throws GeneralSecurityException, IOException, JoseException { + HttpPost httpPost = preparePost(notification, encoding); final CloseableHttpAsyncClient closeableHttpAsyncClient = HttpAsyncClients.createSystem(); closeableHttpAsyncClient.start(); @@ -135,27 +156,31 @@ public Future sendAsync(Notification notification) throws GeneralS return closeableHttpAsyncClient.execute(httpPost, new ClosableCallback(closeableHttpAsyncClient)); } + public Future sendAsync(Notification notification) throws GeneralSecurityException, IOException, JoseException { + return sendAsync(notification, Encoding.AESGCM); + } + /** * Prepare a HttpPost for Apache async http client * * @param notification + * @param encoding * @return * @throws GeneralSecurityException * @throws IOException * @throws JoseException */ - public HttpPost preparePost(Notification notification) throws GeneralSecurityException, IOException, JoseException { - assert (verifyKeyPair()); - + public HttpPost preparePost(Notification notification, Encoding encoding) throws GeneralSecurityException, IOException, JoseException { + assert (Utils.verifyKeyPair(privateKey, publicKey)); Encrypted encrypted = encrypt( notification.getPayload(), notification.getUserPublicKey(), notification.getUserAuth(), - notification.getPadSize() + encoding ); - byte[] dh = Utils.savePublicKey((ECPublicKey) encrypted.getPublicKey()); + byte[] dh = Utils.encode((ECPublicKey) encrypted.getPublicKey()); byte[] salt = encrypted.getSalt(); HttpPost httpPost = new HttpPost(notification.getEndpoint()); @@ -165,9 +190,14 @@ public HttpPost preparePost(Notification notification) throws GeneralSecurityExc if (notification.hasPayload()) { headers.put("Content-Type", "application/octet-stream"); - headers.put("Content-Encoding", "aesgcm"); - headers.put("Encryption", "salt=" + Base64Encoder.encodeUrlWithoutPadding(salt)); - headers.put("Crypto-Key", "dh=" + Base64Encoder.encodeUrl(dh)); + + 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())); } @@ -178,9 +208,13 @@ public HttpPost preparePost(Notification notification) throws GeneralSecurityExc } 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"))); + } + } - if (vapidEnabled() && !notification.isGcm()) { JwtClaims claims = new JwtClaims(); claims.setAudience(notification.getOrigin()); claims.setExpirationTimeMinutesInTheFuture(12 * 60); @@ -193,9 +227,13 @@ public HttpPost preparePost(Notification notification) throws GeneralSecurityExc jws.setKey(privateKey); jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.ECDSA_USING_P256_CURVE_AND_SHA256); - headers.put("Authorization", "WebPush " + jws.getCompactSerialization()); + byte[] pk = Utils.encode((ECPublicKey) publicKey); - byte[] pk = Utils.savePublicKey((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)); @@ -207,15 +245,8 @@ public HttpPost preparePost(Notification notification) throws GeneralSecurityExc for (Map.Entry entry : headers.entrySet()) { httpPost.addHeader(new BasicHeader(entry.getKey(), entry.getValue())); } - return httpPost; - } - private boolean verifyKeyPair() { - ECNamedCurveParameterSpec curveParameters = ECNamedCurveTable.getParameterSpec(CURVE); - ECPoint g = curveParameters.getG(); - ECPoint sG = g.multiply(((ECPrivateKey) privateKey).getS()); - - return sG.equals(((ECPublicKey) publicKey).getQ()); + return httpPost; } /** diff --git a/src/main/java/nl/martijndwars/webpush/Utils.java b/src/main/java/nl/martijndwars/webpush/Utils.java index 5995362..f5553d6 100644 --- a/src/main/java/nl/martijndwars/webpush/Utils.java +++ b/src/main/java/nl/martijndwars/webpush/Utils.java @@ -3,6 +3,7 @@ import org.bouncycastle.jce.ECNamedCurveTable; import org.bouncycastle.jce.interfaces.ECPrivateKey; import org.bouncycastle.jce.interfaces.ECPublicKey; +import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec; import org.bouncycastle.jce.spec.ECParameterSpec; import org.bouncycastle.jce.spec.ECPrivateKeySpec; import org.bouncycastle.jce.spec.ECPublicKeySpec; @@ -11,6 +12,7 @@ import org.bouncycastle.util.BigIntegers; import java.math.BigInteger; +import java.nio.ByteBuffer; import java.security.*; import java.security.spec.InvalidKeySpecException; @@ -28,11 +30,11 @@ public class Utils { * @param publicKey * @return */ - public static byte[] savePublicKey(ECPublicKey publicKey) { + 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(); } @@ -71,4 +73,88 @@ public static PrivateKey loadPrivateKey(String encodedPrivateKey) throws NoSuchP return keyFactory.generatePrivate(privateKeySpec); } + + /** + * Load a public key from the private key. + * + * @param privateKey + * @return + */ + public static ECPublicKey loadPublicKey(ECPrivateKey privateKey) throws NoSuchProviderException, NoSuchAlgorithmException, InvalidKeySpecException { + KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM, PROVIDER_NAME); + ECParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec(CURVE); + ECPoint Q = ecSpec.getG().multiply(privateKey.getD()); + byte[] publicDerBytes = Q.getEncoded(false); + ECPoint point = ecSpec.getCurve().decodePoint(publicDerBytes); + ECPublicKeySpec pubSpec = new ECPublicKeySpec(point, ecSpec); + + return (ECPublicKey) keyFactory.generatePublic(pubSpec); + } + + /** + * Verify that the private key belongs to the public key. + * + * @param privateKey + * @param publicKey + * @return + */ + public static boolean verifyKeyPair(PrivateKey privateKey, PublicKey publicKey) { + ECNamedCurveParameterSpec curveParameters = ECNamedCurveTable.getParameterSpec(CURVE); + ECPoint g = curveParameters.getG(); + ECPoint sG = g.multiply(((java.security.interfaces.ECPrivateKey) privateKey).getS()); + + return sG.equals(((ECPublicKey) publicKey).getQ()); + } + + /** + * Utility to concat byte arrays + */ + public static byte[] concat(byte[]... arrays) { + int lastPos = 0; + + byte[] combined = new byte[combinedLength(arrays)]; + + for (byte[] array : arrays) { + if (array == null) { + continue; + } + + System.arraycopy(array, 0, combined, lastPos, array.length); + + lastPos += array.length; + } + + return combined; + } + + /** + * Compute combined array length + */ + public static int combinedLength(byte[]... arrays) { + int combinedLength = 0; + + for (byte[] array : arrays) { + if (array == null) { + continue; + } + + combinedLength += array.length; + } + + return combinedLength; + } + + /** + * Create a byte array of the given length from the given integer. + * + * @param integer + * @param size + * @return + */ + public static byte[] toByteArray(int integer, int size) { + ByteBuffer buffer = ByteBuffer.allocate(size); + buffer.putInt(integer); + + return buffer.array(); + } } 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 270c1c3..68bfd99 100644 --- a/src/main/java/nl/martijndwars/webpush/cli/handlers/GenerateKeyHandler.java +++ b/src/main/java/nl/martijndwars/webpush/cli/handlers/GenerateKeyHandler.java @@ -31,18 +31,21 @@ public GenerateKeyHandler(GenerateKeyCommand generateKeyCommand) { public void run() throws InvalidAlgorithmParameterException, NoSuchAlgorithmException, NoSuchProviderException, IOException { KeyPair keyPair = generateKeyPair(); - byte[] publicKey = Utils.savePublicKey((ECPublicKey) keyPair.getPublic()); - byte[] privateKey = Utils.savePrivateKey((ECPrivateKey) keyPair.getPrivate()); + 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(Base64Encoder.encodeUrl(publicKey)); + System.out.println(Base64Encoder.encodeUrl(encodedPublicKey)); System.out.println("PrivateKey:"); - System.out.println(Base64Encoder.encodeUrl(privateKey)); + System.out.println(Base64Encoder.encodeUrl(encodedPrivateKey)); } /** 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..fadc408 --- /dev/null +++ b/src/test/java/nl/martijndwars/webpush/HttpEceTest.java @@ -0,0 +1,103 @@ +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.HashMap; + +import static nl.martijndwars.webpush.Base64Encoder.decode; +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()); + } + + @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/PushServiceTest.java b/src/test/java/nl/martijndwars/webpush/PushServiceTest.java index 7165c18..25e9e6d 100644 --- a/src/test/java/nl/martijndwars/webpush/PushServiceTest.java +++ b/src/test/java/nl/martijndwars/webpush/PushServiceTest.java @@ -19,6 +19,90 @@ public static void addSecurityProvider() { Security.addProvider(new BouncyCastleProvider()); } + @Test + public void testAes128Gcm() throws Exception { + String endpoint = "https://fcm.googleapis.com/fcm/send/cZgR4jdX_a0:APA91bGtFF7ymruzk4OQak-Ck0phn_78WG5eU7ODdolIUB3bXlUUyH08j655HBlHZcRFkCywJRnP_lnFoBQ5WIiiUwdqXyG9ZQA72zV97c_h4i2m17CQcN_2Ycc8HtwDleSJCcsMAu_T"; + + // Base64 string user public key/auth + String userPublicKey = "BJ7H-eGuEwYMn2u4Rbue3YUvXfugPgEERt1nMW8DyJgFfXmAHgo0mZi5LfzHW65xvI4gYlCvL8qq1OzUks3IYzA"; + String userAuth = "05MiKXdBb159mUy6G84vaA"; + + // Base64 string server public/private key + String publicKey = "BG15nGlC3qDleDkI6VPDVT4Ar28t2v2zcHthuRMyoU4nD-t1XpU-PUw8Ve9FH0Zrb0saJDwzi6AJYJAL_CmwNcw"; + String privateKey = "gNc81rawd_fKWXpFOhllPUD6BJLIkePus7lQ46jMvTs"; + + // Construct notification + Notification notification = new Notification(endpoint, userPublicKey, userAuth, getPayload()); + + // Construct push service + PushService pushService = new PushService(); + pushService.setPublicKey(publicKey); + pushService.setPrivateKey(privateKey); + pushService.setSubject("mailto:admin@domain.com"); + + // Send notification! + HttpResponse httpResponse = pushService.send(notification, Encoding.AES128GCM); + + System.out.println(httpResponse.getStatusLine().getStatusCode()); + System.out.println(IOUtils.toString(httpResponse.getEntity().getContent(), StandardCharsets.UTF_8)); + } + + @Test + public void testAesGcm() throws Exception { + String endpoint = "https://fcm.googleapis.com/fcm/send/cZgR4jdX_a0:APA91bGtFF7ymruzk4OQak-Ck0phn_78WG5eU7ODdolIUB3bXlUUyH08j655HBlHZcRFkCywJRnP_lnFoBQ5WIiiUwdqXyG9ZQA72zV97c_h4i2m17CQcN_2Ycc8HtwDleSJCcsMAu_T"; + + // Base64 string user public key/auth + String userPublicKey = "BJ7H-eGuEwYMn2u4Rbue3YUvXfugPgEERt1nMW8DyJgFfXmAHgo0mZi5LfzHW65xvI4gYlCvL8qq1OzUks3IYzA"; + String userAuth = "05MiKXdBb159mUy6G84vaA"; + + // Base64 string server public/private key + String publicKey = "BG15nGlC3qDleDkI6VPDVT4Ar28t2v2zcHthuRMyoU4nD-t1XpU-PUw8Ve9FH0Zrb0saJDwzi6AJYJAL_CmwNcw"; + String privateKey = "gNc81rawd_fKWXpFOhllPUD6BJLIkePus7lQ46jMvTs"; + + // Construct notification + Notification notification = new Notification(endpoint, userPublicKey, userAuth, getPayload()); + + // Construct push service + PushService pushService = new PushService(); + pushService.setPublicKey(publicKey); + pushService.setPrivateKey(privateKey); + pushService.setSubject("mailto:admin@domain.com"); + + // Send notification! + HttpResponse httpResponse = pushService.send(notification, Encoding.AESGCM); + + System.out.println(httpResponse.getStatusLine().getStatusCode()); + System.out.println(IOUtils.toString(httpResponse.getEntity().getContent(), StandardCharsets.UTF_8)); + } + + @Test + public void testNoEncodingSpecified() throws Exception { + String endpoint = "https://fcm.googleapis.com/fcm/send/cZgR4jdX_a0:APA91bGtFF7ymruzk4OQak-Ck0phn_78WG5eU7ODdolIUB3bXlUUyH08j655HBlHZcRFkCywJRnP_lnFoBQ5WIiiUwdqXyG9ZQA72zV97c_h4i2m17CQcN_2Ycc8HtwDleSJCcsMAu_T"; + + // Base64 string user public key/auth + String userPublicKey = "BJ7H-eGuEwYMn2u4Rbue3YUvXfugPgEERt1nMW8DyJgFfXmAHgo0mZi5LfzHW65xvI4gYlCvL8qq1OzUks3IYzA"; + String userAuth = "05MiKXdBb159mUy6G84vaA"; + + // Base64 string server public/private key + String publicKey = "BG15nGlC3qDleDkI6VPDVT4Ar28t2v2zcHthuRMyoU4nD-t1XpU-PUw8Ve9FH0Zrb0saJDwzi6AJYJAL_CmwNcw"; + String privateKey = "gNc81rawd_fKWXpFOhllPUD6BJLIkePus7lQ46jMvTs"; + + // Construct notification + Notification notification = new Notification(endpoint, userPublicKey, userAuth, getPayload()); + + // Construct push service + PushService pushService = new PushService(); + pushService.setPublicKey(publicKey); + pushService.setPrivateKey(privateKey); + pushService.setSubject("mailto:admin@domain.com"); + + // Send notification! + HttpResponse httpResponse = pushService.send(notification); + + System.out.println(httpResponse.getStatusLine().getStatusCode()); + System.out.println(IOUtils.toString(httpResponse.getEntity().getContent(), StandardCharsets.UTF_8)); + } + @Test public void testPushFirefoxVapid() throws Exception { String endpoint = "https://updates.push.services.mozilla.com/wpush/v1/gAAAAABX1ZgBNvDz6ZIAh6OqNh3hN4ZLEa57oS22mHI70mnvrDbIi-MnJu7FxFzvMV31L_AnIxP_p1Ot47KP8Xmit3XIQjZDjTahqBPmmntWX8JM6AtRxcAHxmXH6KqhyWwL1QEA0jBp"; diff --git a/src/test/java/nl/martijndwars/webpush/PushTest.java b/src/test/java/nl/martijndwars/webpush/PushTest.java index 0de71bf..541ce6f 100644 --- a/src/test/java/nl/martijndwars/webpush/PushTest.java +++ b/src/test/java/nl/martijndwars/webpush/PushTest.java @@ -10,11 +10,33 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.*; +import java.security.spec.InvalidKeySpecException; import java.util.concurrent.ExecutionException; public class PushTest { @Test - public void companionPushTest() throws GeneralSecurityException, InterruptedException, JoseException, ExecutionException, IOException { + public void testAes128Gcm() throws Exception { + Security.addProvider(new BouncyCastleProvider()); + + String publicKey = "BG15nGlC3qDleDkI6VPDVT4Ar28t2v2zcHthuRMyoU4nD-t1XpU-PUw8Ve9FH0Zrb0saJDwzi6AJYJAL_CmwNcw"; + String privateKey = "gNc81rawd_fKWXpFOhllPUD6BJLIkePus7lQ46jMvTs"; + + Subscription subscription = new Gson().fromJson("{\"endpoint\":\"https://fcm.googleapis.com/fcm/send/cZgR4jdX_a0:APA91bGtFF7ymruzk4OQak-Ck0phn_78WG5eU7ODdolIUB3bXlUUyH08j655HBlHZcRFkCywJRnP_lnFoBQ5WIiiUwdqXyG9ZQA72zV97c_h4i2m17CQcN_2Ycc8HtwDleSJCcsMAu_T\",\"expirationTime\":null,\"keys\":{\"p256dh\":\"BJ7H-eGuEwYMn2u4Rbue3YUvXfugPgEERt1nMW8DyJgFfXmAHgo0mZi5LfzHW65xvI4gYlCvL8qq1OzUks3IYzA\",\"auth\":\"05MiKXdBb159mUy6G84vaA\"}}", Subscription.class); + Notification notification = new Notification(subscription, "Hello"); + + PushService pushService = new PushService(); + pushService.setPublicKey(publicKey); + pushService.setPrivateKey(privateKey); + pushService.setSubject("mailto:admin@domain.com"); + + HttpResponse httpResponse = pushService.send(notification, Encoding.AES128GCM); + + System.out.println(httpResponse.getStatusLine().getStatusCode()); + System.out.println(IOUtils.toString(httpResponse.getEntity().getContent(), StandardCharsets.UTF_8)); + } + + @Test + public void companionPushTest() throws Exception { Security.addProvider(new BouncyCastleProvider()); String publicKey = "BLzPK96e2_tX5pE9HA9D6j_H1fkZi3yEgpG1HGifioFtM1wWSoJBcV7vWAsXzIVngaVAm5lmnD2TwvF46ouYx0M"; @@ -38,10 +60,8 @@ public void companionPushTest() throws GeneralSecurityException, InterruptedExce public void testPush() throws Exception { Security.addProvider(new BouncyCastleProvider()); - Gson gson = new Gson(); - // Deserialize subscription object - Subscription subscription = gson.fromJson( + Subscription subscription = new Gson().fromJson( "{\"endpoint\":\"https://fcm.googleapis.com/fcm/send/efI2iY2iI7g:APA91bFJMK9cNaCh9dDyQ8X3kuXEzVYlHGEJ2BLKG57n7H_NCjTyjJ87wczJKkAV8wfqo5iZRFnTJf1LgaqZ5NsNhGX2PTQQM5pPaCS41ogYfSY9KpfKZJTY410sUQG6yEDGjSuXrtbP\",\"keys\":{\"p256dh\":\"BHj7LOv2ARShKqY_RXP5zoSSpvAevF-VTzJFm9dXfTtnFg5wHVqei_74UOF8vr8kzY-3hR-wgdhGQOw10AxkmBI=\",\"auth\":\"cJN5ZAvblDfOo_Y_ibFZSg==\"}}", Subscription.class ); @@ -82,10 +102,8 @@ public void testPush() throws Exception { public void testPush2() throws Exception { Security.addProvider(new BouncyCastleProvider()); - Gson gson = new Gson(); - // Deserialize subscription object - Subscription subscription = gson.fromJson( + Subscription subscription = new Gson().fromJson( "{\"endpoint\":\"https://fcm.googleapis.com/fcm/send/crTeErRUPTc:APA91bFVWbWwmV5pT-ChX-lQRdR-e_WFB9TKlTgbrA7Ipq8s87pwgxtrSfmAItENo_uL6MDhv5n_G-HCqUR2YmgBF07dprhbwAsVOkpvv07H0CmYrMC7oss27oeIT5pUKbejBWQ1gcik\",\"keys\":{\"p256dh\":\"BOpf2C_Lt26VMbY9JfLCSEKfe3-MZ89KF3rDpZqBdweckBxvaw753hOj0ox5isqoBki8UgPox7FsgTCZ3CwDa5s=\",\"auth\":\"YupUeBKECwzdSwHNre11HA==\"}}", Subscription.class );