18

我正在尝试创建一个能够使用 Push API 发送推送消息的服务器:https ://developer.mozilla.org/en-US/docs/Web/API/Push_API

我已经让客户端工作了,但现在我希望能够从 Java 服务器发送带有有效负载的消息。

我看到了 nodejs web-push 示例(https://www.npmjs.com/package/web-push),但我无法将其正确转换为 Java。

我尝试按照示例使用此处找到的 DH 密钥交换:http: //docs.oracle.com/javase/7/docs/technotes/guides/security/crypto/CryptoSpec.html#DH2Ex

在下面的 Sheltond 的帮助下,我能够找出一些应该可以工作但没有工作的代码。

当我将加密消息发布到推送服务时,我得到了预期的 201 状态代码,但推送从未到达 Firefox。如果我删除有效负载和标头并简单地将 POST 请求发送到相同的 URL,则消息成功到达 Firefox,但没有数据。我怀疑这可能与我使用 Cipher.getInstance("AES/GCM/NoPadding"); 加密数据的方式有关。

这是我目前使用的代码:

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());
}

其中“BASE_64_PUBLIC_KEY_FROM_PUSH_SUBSCRIPTION”是提供的浏览器中推送 API 订阅方法的密钥,“PUSH_ENDPOINT_URL”是浏览器提供的推送端点。

如果我从成功的 nodejs web-push 请求中获取值(密文、base64 bobPubKeyEnc 和 salt)并用 Java 对它们进行硬编码,它就可以工作。如果我将上面的代码与动态值一起使用,它将不起作用。

我确实注意到,在 nodejs 实现中工作的密文总是比上面代码的 Java 密文大 1 个字节。我在这里使用的示例总是产生一个 81 字节的密文,但在 nodejs 中它总是 82 字节。这是否为我们提供了可能出现问题的线索?

如何正确加密有效负载以使其到达 Firefox?

提前感谢您的帮助

4

4 回答 4

5

根据https://jrconlin.github.io/WebPushDataTestPage/更改代码后能够接收通知

在下面找到修改后的代码:

 

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; } }
于 2016-03-21T10:56:20.337 回答
3

请参阅https://datatracker.ietf.org/doc/html/draft-ietf-webpush-encryption-01#section-5https://w3c.github.io/push-api/#widl-PushSubscription-getKey- ArrayBuffer-PushEncryptionKeyName-name(第 4 点)。

密钥使用 ANSI X9.62 中定义的未压缩格式进行编码,因此您不能使用 x509EncodedKeySpec。

您可以使用 BouncyCastle,它应该支持 X9.62 编码。

于 2016-02-07T12:10:06.067 回答
2

看看 Maarten Bodewes 在这个问题中的回答。

他提供了用于将 X9.62 未压缩格式编码/解码为 ECPublicKey 的 Java 源代码,我认为它应该适合您正在尝试做的事情。

== 更新 1 ==

规范说“强制加密的用户代理必须在 P-256 曲线上公开椭圆曲线 Diffie-Hellman 共享”。

P-256 曲线是 NIST 批准用于美国政府加密应用的标准曲线。此处给出了选择此特定曲线(以及其他一些曲线)的定义、参数值和基本原理。

使用名称“secp256r1”的标准库中支持这条曲线,但由于我无法完全解决的原因(我认为这与密码学提供程序与 JDK 本身的分离有关),你似乎必须跳过一些非常低效的圈子才能从此名称中获取这些 ECParameterSpec 值之一:

KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
ECGenParameterSpec kpgparams = new ECGenParameterSpec("secp256r1");
kpg.initialize(kpgparams);
ECParameterSpec params = ((ECPublicKey) kpg.generateKeyPair().getPublic()).getParams();

这是相当重量级的,因为它实际上使用命名的 ECGenParameterSpec 对象生成一个密钥对,然后从中提取 ECParameterSpec。然后您应该能够使用它来解码(我建议将此值缓存在某处以避免频繁生成此密钥)。

或者,您可以只取NIST 文档第 8 页中的数字并将它们直接插入 ECParameterSpec 构造函数。

这里有一些代码看起来就是这样做的(大约第 124 行)。该代码是Apache 许可的。我自己没有使用过该代码,但看起来常量与 NIST 文档中的内容相匹配。

== 更新 2 ==

使用HTTP 加密内容编码第 3.2 节中描述的基于 HMAC 的密钥派生函数 (HKDF) 从 salt(随机生成)和共享密钥(由 DH 密钥交换同意)派生出实际的加密密钥。

该文档引用RFC 5869并指定使用 SHA-256 作为 HKDF 中使用的哈希值。

该 RFC 描述了两个阶段的过程:提取和扩展。提取阶段定义为:

PRK = HMAC-Hash(salt, IKM)

在 web-push 的情况下,这应该是一个 HMAC-SHA-256 操作,salt 值应该是你已经拥有的“saltBytes”值,据我所知,IKM 值应该是共享密钥( webpush 文档只是说“这些值用于计算内容加密密钥”,而没有明确说明共享密钥是 IKM)。

Expand 阶段获取 Extract 阶段生成的值加上一个“信息”值,并重复 HMAC 对它们进行处理,直到它为您正在使用的加密算法生成足够的密钥数据(每个 HMAC 的输出被馈送到下一个- 有关详细信息,请参阅RFC)。

在这种情况下,算法是 AEAD_AES_128_GCM,它需要一个 128 位的密钥,比 SHA-256 的输出要小,所以你只需要在 Expand 阶段做一次哈希。

在这种情况下,'info' 值必须是“Content-Encoding: aesgcm128”(在Encrypted Content-Encoding for HTTP中指定),因此您需要的操作是:

HMAC-SHA-256(PRK, "Content-Encoding: aesgcm128" | 0x01)

'|' 是串联。然后获取结果的前 16 个字节,这应该是加密密钥。

在 Java 术语中,这看起来像:

// Extract
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);

作为参考,这里是BouncyCastle 库中执行此操作的部分的链接。

最后,我刚刚注意到 webpush 文档中的这一部分:

公钥,例如被编码到“dh”参数中,必须采用未压缩点的形式

所以看起来你需要使用这样的东西:

byte[] bobPubKeyEnc = toUncompressedPoint((ECPublicKey)bobKpair.getPublic());

而不是使用标准的 getEncoded() 方法。

== 更新 3 ==

首先,我应该指出,与我之前链接到的 http 内容加密规范相比,有一个更新的规范草案:draft-ietf-httpbis-encryption-encoding-00。想要使用这个系统的人应该确保他们使用的是最新的可用规范草案——这是正在进行的工作,似乎每隔几个月就会略有变化。

其次,在该文档的第 2 节中,它指定必须在加密之前将一些填充添加到明文中(并在解密后删除)。

这将解释您提到的内容与 Node.js 示例产生的内容之间的一个字节长度差异。

文件说:

每条记录包含 1 到 256 个八位字节的填充,插入到加密内容之前的记录中。填充由一个长度字节组成,后跟该数量的零值八位字节。如果除第一个以外的任何填充八位字节不为零,或者记录的填充量超过记录大小可以容纳的量,则接收器必须无法解密。

所以我认为您需要做的是在明文之前将单个“0”字节推入密码中。您可以添加比这更多的填充 - 我看不到任何指定填充必须是可能的最小数量的内容,但是单个“0”字节是最简单的(任何阅读此内容的人都试图从其他人那里解码这些消息end 应确保它们支持任何合法数量的填充)。

一般来说,对于 http 内容加密,该机制比这要复杂一些(因为您必须将输入拆分为记录并为每个记录添加填充),但是 webpush 规范说加密的消息必须适合单个记录,所以你不必担心这个。

请注意 webpush 加密规范中的以下文本:

请注意,推送服务不需要支持超过 4096 个八位字节的有效负载主体,这相当于 4080 个八位字节的明文

这里的 4080 个八位字节的明文包括 1 个字节的填充,因此实际上似乎有 4079 个字节的限制。您可以使用“加密”标头中的“rs”参数指定更大的记录大小,但根据上面引用的文本,收件人不需要支持。

一个警告:我看到的一些执行此操作的代码似乎正在更改为使用 2 个字节的填充,这可能是由于一些提议的规范更改的结果,但我无法追踪到这会发生在哪里从。目前 1 字节的填充应该是可以的,但如果这在未来停止工作,你可能需要转到 2 字节 - 正如我上面提到的,这个规范是一项正在进行的工作,浏览器支持现在是实验性的。

于 2016-02-26T13:58:50.950 回答
0

santosh kumar 的解决方案进行了一项修改:

我在定义明文字节 [] 之前添加了一个 1 字节的密码填充。

Cipher bobCipher = Cipher.getInstance("AES/GCM/NoPadding", "BC");
byte[] iv = generateNonce(nonce_bytes12, 0);
bobCipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv));

// adding firefox padding:
bobCipher.update(new byte[1]);

byte[] cleartext = {...};
于 2016-08-30T08:55:11.443 回答