I'm trying to create a server capable of sending push messages using the Push API: https://developer.mozilla.org/en-US/docs/Web/API/Push_API
I've got the client side working but now I want to be able to send messages with a payload from a Java server.
I saw the nodejs web-push example (https://www.npmjs.com/package/web-push) but I couldn't translate that correctly to Java.
I tried following the example to use the DH key exchange found here: http://docs.oracle.com/javase/7/docs/technotes/guides/security/crypto/CryptoSpec.html#DH2Ex
With the help of sheltond below I was able to figure out some code that should be working but isn't.
When I post the encrypted message to the Push service, I get back the expected 201 status code but the push never reaches Firefox. If I remove the payload and headers and simply send a POST request to the same URL the message successfully arrives in Firefox with no data. I suspect it may have something to do with the way I'm encrypting the data with Cipher.getInstance("AES/GCM/NoPadding");
This is the code I'm using currently:
try {
final byte[] alicePubKeyEnc = Util.fromBase64("BASE_64_PUBLIC_KEY_FROM_PUSH_SUBSCRIPTION");
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
ECGenParameterSpec kpgparams = new ECGenParameterSpec("secp256r1");
kpg.initialize(kpgparams);
ECParameterSpec params = ((ECPublicKey) kpg.generateKeyPair().getPublic()).getParams();
final ECPublicKey alicePubKey = fromUncompressedPoint(alicePubKeyEnc, params);
KeyPairGenerator bobKpairGen = KeyPairGenerator.getInstance("EC");
bobKpairGen.initialize(params);
KeyPair bobKpair = bobKpairGen.generateKeyPair();
KeyAgreement bobKeyAgree = KeyAgreement.getInstance("ECDH");
bobKeyAgree.init(bobKpair.getPrivate());
byte[] bobPubKeyEnc = toUncompressedPoint((ECPublicKey) bobKpair.getPublic());
bobKeyAgree.doPhase(alicePubKey, true);
Cipher bobCipher = Cipher.getInstance("AES/GCM/NoPadding");
SecretKey bobDesKey = bobKeyAgree.generateSecret("AES");
byte[] saltBytes = new byte[16];
new SecureRandom().nextBytes(saltBytes);
Mac extract = Mac.getInstance("HmacSHA256");
extract.init(new SecretKeySpec(saltBytes, "HmacSHA256"));
final byte[] prk = extract.doFinal(bobDesKey.getEncoded());
// Expand
Mac expand = Mac.getInstance("HmacSHA256");
expand.init(new SecretKeySpec(prk, "HmacSHA256"));
String info = "Content-Encoding: aesgcm128";
expand.update(info.getBytes(StandardCharsets.US_ASCII));
expand.update((byte) 1);
final byte[] key_bytes = expand.doFinal();
// Use the result
SecretKeySpec key = new SecretKeySpec(key_bytes, 0, 16, "AES");
bobCipher.init(Cipher.ENCRYPT_MODE, key);
byte[] cleartext = "{\"this\":\"is a test that is supposed to be working but it is not\"}".getBytes();
byte[] ciphertext = bobCipher.doFinal(cleartext);
URL url = new URL("PUSH_ENDPOINT_URL");
HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection();
urlConnection.setRequestMethod("POST");
urlConnection.setRequestProperty("Content-Length", ciphertext.length + "");
urlConnection.setRequestProperty("Content-Type", "application/octet-stream");
urlConnection.setRequestProperty("Encryption-Key", "keyid=p256dh;dh=" + Util.toBase64UrlSafe(bobPubKeyEnc));
urlConnection.setRequestProperty("Encryption", "keyid=p256dh;salt=" + Util.toBase64UrlSafe(saltBytes));
urlConnection.setRequestProperty("Content-Encoding", "aesgcm128");
urlConnection.setDoInput(true);
urlConnection.setDoOutput(true);
final OutputStream outputStream = urlConnection.getOutputStream();
outputStream.write(ciphertext);
outputStream.flush();
outputStream.close();
if (urlConnection.getResponseCode() == 201) {
String result = Util.readStream(urlConnection.getInputStream());
Log.v("PUSH", "OK: " + result);
} else {
InputStream errorStream = urlConnection.getErrorStream();
String error = Util.readStream(errorStream);
Log.v("PUSH", "Not OK: " + error);
}
} catch (Exception e) {
Log.v("PUSH", "Not OK: " + e.toString());
}
where "BASE_64_PUBLIC_KEY_FROM_PUSH_SUBSCRIPTION" is the key the Push API subscription method in the browser provided and "PUSH_ENDPOINT_URL" is the push endpoint the browser provided.
If I get values (ciphertext, base64 bobPubKeyEnc and salt) from a successful nodejs web-push request and hard-code them in Java, it works. If I use the code above with dynamic values it does not work.
I did notice that the ciphertext that worked in the nodejs implementation is always 1 byte bigger then the Java ciphertext with the code above. The example I used here always produces a 81 byte cipher text but in nodejs it's always 82 bytes for example. Does this give us a clue on what might be wrong?
How do I correctly encrypt the payload so that it reaches Firefox?
Thanks in advance for any help
Able to receive notifications after changing code as per https://jrconlin.github.io/WebPushDataTestPage/
Find the modified code below :
import com.sun.org.apache.xerces.internal.impl.dv.util.Base64;
import java.io.BufferedInputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.Security;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECFieldFp;
import java.security.spec.ECParameterSpec;
import java.security.spec.ECPoint;
import java.security.spec.ECPublicKeySpec;
import java.security.spec.EllipticCurve;
import java.util.Arrays;
import javax.crypto.Cipher;
import javax.crypto.KeyAgreement;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
public class WebPushEncryption {
private static final byte UNCOMPRESSED_POINT_INDICATOR = 0x04;
private static final ECParameterSpec params = new ECParameterSpec(
new EllipticCurve(new ECFieldFp(new BigInteger(
"FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF",
16)), new BigInteger(
"FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC",
16), new BigInteger(
"5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B",
16)), new ECPoint(new BigInteger(
"6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296",
16), new BigInteger(
"4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5",
16)), new BigInteger(
"FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551",
16), 1);
public static void main(String[] args) throws Exception {
Security.addProvider(new BouncyCastleProvider());
String endpoint = "https://updates.push.services.mozilla.com/push/v1/xxx";
final byte[] alicePubKeyEnc = Base64.decode("base64 encoded public key ");
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("ECDH", "BC");
keyGen.initialize(params);
KeyPair bobKpair = keyGen.generateKeyPair();
PrivateKey localPrivateKey = bobKpair.getPrivate();
PublicKey localpublickey = bobKpair.getPublic();
final ECPublicKey remoteKey = fromUncompressedPoint(alicePubKeyEnc, params);
KeyAgreement bobKeyAgree = KeyAgreement.getInstance("ECDH", "BC");
bobKeyAgree.init(localPrivateKey);
byte[] bobPubKeyEnc = toUncompressedPoint((ECPublicKey) bobKpair.getPublic());
bobKeyAgree.doPhase(remoteKey, true);
SecretKey bobDesKey = bobKeyAgree.generateSecret("AES");
byte[] saltBytes = new byte[16];
new SecureRandom().nextBytes(saltBytes);
Mac extract = Mac.getInstance("HmacSHA256", "BC");
extract.init(new SecretKeySpec(saltBytes, "HmacSHA256"));
final byte[] prk = extract.doFinal(bobDesKey.getEncoded());
// Expand
Mac expand = Mac.getInstance("HmacSHA256", "BC");
expand.init(new SecretKeySpec(prk, "HmacSHA256"));
//aes algorithm
String info = "Content-Encoding: aesgcm128";
expand.update(info.getBytes(StandardCharsets.US_ASCII));
expand.update((byte) 1);
final byte[] key_bytes = expand.doFinal();
byte[] key_bytes16 = Arrays.copyOf(key_bytes, 16);
SecretKeySpec key = new SecretKeySpec(key_bytes16, 0, 16, "AES-GCM");
//nonce
expand.reset();
expand.init(new SecretKeySpec(prk, "HmacSHA256"));
String nonceinfo = "Content-Encoding: nonce";
expand.update(nonceinfo.getBytes(StandardCharsets.US_ASCII));
expand.update((byte) 1);
final byte[] nonce_bytes = expand.doFinal();
byte[] nonce_bytes12 = Arrays.copyOf(nonce_bytes, 12);
Cipher bobCipher = Cipher.getInstance("AES/GCM/NoPadding", "BC");
byte[] iv = generateNonce(nonce_bytes12, 0);
bobCipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv));
byte[] cleartext = ("{\n"
+ " \"message\" : \"great match41eeee!\",\n"
+ " \"title\" : \"Portugal vs. Denmark4255\",\n"
+ " \"icon\" : \"http://icons.iconarchive.com/icons/artdesigner/tweet-my-web/256/single-bird-icon.png\",\n"
+ " \"tag\" : \"testtag1\",\n"
+ " \"url\" : \"http://www.yahoo.com\"\n"
+ " }").getBytes();
byte[] cc = new byte[cleartext.length + 1];
cc[0] = 0;
for (int i = 0; i < cleartext.length; i++) {
cc[i + 1] = cleartext[i];
}
cleartext = cc;
byte[] ciphertext = bobCipher.doFinal(cleartext);
URL url = new URL(endpoint);
HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection();
urlConnection.setRequestMethod("POST");
urlConnection.setRequestProperty("Content-Length", ciphertext.length + "");
urlConnection.setRequestProperty("Content-Type", "application/octet-stream");
urlConnection.setRequestProperty("encryption-key", "keyid=p256dh;dh=" + Base64.encode(bobPubKeyEnc));
urlConnection.setRequestProperty("encryption", "keyid=p256dh;salt=" + Base64.encode(saltBytes));
urlConnection.setRequestProperty("content-encoding", "aesgcm128");
urlConnection.setRequestProperty("ttl", "60");
urlConnection.setDoInput(true);
urlConnection.setDoOutput(true);
final OutputStream outputStream = urlConnection.getOutputStream();
outputStream.write(ciphertext);
outputStream.flush();
outputStream.close();
if (urlConnection.getResponseCode() == 201) {
String result = readStream(urlConnection.getInputStream());
System.out.println("PUSH OK: " + result);
} else {
InputStream errorStream = urlConnection.getErrorStream();
String error = readStream(errorStream);
System.out.println("PUSH" + "Not OK: " + error);
}
}
static byte[] generateNonce(byte[] base, int index) {
byte[] nonce = Arrays.copyOfRange(base, 0, 12);
for (int i = 0; i < 6; ++i) {
nonce[nonce.length - 1 - i] ^= (byte) ((index / Math.pow(256, i))) & (0xff);
}
return nonce;
}
private static String readStream(InputStream errorStream) throws Exception {
BufferedInputStream bs = new BufferedInputStream(errorStream);
int i = 0;
byte[] b = new byte[1024];
StringBuilder sb = new StringBuilder();
while ((i = bs.read(b)) != -1) {
sb.append(new String(b, 0, i));
}
return sb.toString();
}
public static ECPublicKey fromUncompressedPoint(
final byte[] uncompressedPoint, final ECParameterSpec params)
throws Exception {
int offset = 0;
if (uncompressedPoint[offset++] != UNCOMPRESSED_POINT_INDICATOR) {
throw new IllegalArgumentException(
"Invalid uncompressedPoint encoding, no uncompressed point indicator");
}
int keySizeBytes = (params.getOrder().bitLength() + Byte.SIZE - 1)
/ Byte.SIZE;
if (uncompressedPoint.length != 1 + 2 * keySizeBytes) {
throw new IllegalArgumentException(
"Invalid uncompressedPoint encoding, not the correct size");
}
final BigInteger x = new BigInteger(1, Arrays.copyOfRange(
uncompressedPoint, offset, offset + keySizeBytes));
offset += keySizeBytes;
final BigInteger y = new BigInteger(1, Arrays.copyOfRange(
uncompressedPoint, offset, offset + keySizeBytes));
final ECPoint w = new ECPoint(x, y);
final ECPublicKeySpec ecPublicKeySpec = new ECPublicKeySpec(w, params);
final KeyFactory keyFactory = KeyFactory.getInstance("EC");
return (ECPublicKey) keyFactory.generatePublic(ecPublicKeySpec);
}
public static byte[] toUncompressedPoint(final ECPublicKey publicKey) {
int keySizeBytes = (publicKey.getParams().getOrder().bitLength() + Byte.SIZE - 1)
/ Byte.SIZE;
final byte[] uncompressedPoint = new byte[1 + 2 * keySizeBytes];
int offset = 0;
uncompressedPoint[offset++] = 0x04;
final byte[] x = publicKey.getW().getAffineX().toByteArray();
if (x.length <= keySizeBytes) {
System.arraycopy(x, 0, uncompressedPoint, offset + keySizeBytes
- x.length, x.length);
} else if (x.length == keySizeBytes + 1 && x[0] == 0) {
System.arraycopy(x, 1, uncompressedPoint, offset, keySizeBytes);
} else {
throw new IllegalStateException("x value is too large");
}
offset += keySizeBytes;
final byte[] y = publicKey.getW().getAffineY().toByteArray();
if (y.length <= keySizeBytes) {
System.arraycopy(y, 0, uncompressedPoint, offset + keySizeBytes
- y.length, y.length);
} else if (y.length == keySizeBytes + 1 && y[0] == 0) {
System.arraycopy(y, 1, uncompressedPoint, offset, keySizeBytes);
} else {
throw new IllegalStateException("y value is too large");
}
return uncompressedPoint;
}
}
See https://datatracker.ietf.org/doc/html/draft-ietf-webpush-encryption-01#section-5 and https://w3c.github.io/push-api/#widl-PushSubscription-getKey-ArrayBuffer-PushEncryptionKeyName-name (point 4).
The key is encoded using the uncompressed format defined in ANSI X9.62, so you can't use x509EncodedKeySpec.
You could use BouncyCastle, that should support the X9.62 encoding.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With