web-dev-qa-db-ja.com

Java)でWebプッシュAPIのメッセージを暗号化する

プッシュ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に到達することはありません。ペイロードとヘッダーを削除して、同じURLにPOSTリクエストを送信すると、メッセージはデータなしで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」は、ブラウザーが提供したプッシュエンドポイントです。

成功したnodejsweb-Pushリクエストから値(暗号文、base64 bobPubKeyEnc、salt)を取得し、Javaでハードコーディングすると、機能します。上記のコードを動的な値で使用すると、機能しません。

Nodejsの実装で機能する暗号文は常にJava上記のコードの暗号文よりも1バイト大きいことに気付きました。ここで使用した例では常に81バイトの暗号文が生成されますが、nodejsではたとえば、常に82バイトです。これにより、何が問題になっているのかがわかりますか?

Firefoxに到達するようにペイロードを正しく暗号化するにはどうすればよいですか?

助けてくれてありがとう

17
joaomgcd

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;
    }
}
5
santosh kumar

https://tools.ietf.org/html/draft-ietf-webpush-encryption-01#section-5 および https://w3c.github.io/Push-)を参照してください。 api /#widl-PushSubscription-getKey-ArrayBuffer-PushEncryptionKeyName-name (ポイント4)。

キーはANSIX9.62で定義されている非圧縮形式を使用してエンコードされているため、x509EncodedKeySpecを使用することはできません。

X9.62エンコーディングをサポートするBouncyCastleを使用できます。

3
Marco

この質問 のMaartenBodewesからの回答をご覧ください。

彼はJava X9.62非圧縮形式からECPublicKeyにエンコード/デコードするためのソースを提供します。これは、あなたがやろうとしていることに適していると思います。

==更新1 ==

仕様には、「暗号化を実施するユーザーエージェントは、P-256曲線上で楕円曲線Diffie-Hellman共有を公開する必要があります "と記載されています。

P-256曲線は、米国政府の暗号化アプリケーションで使用するためにNISTによって承認された標準曲線です。この特定の曲線を選択するための定義、パラメーター値、および理論的根拠(および他のいくつかの曲線)が与えられています ここ

「secp256r1」という名前を使用する標準ライブラリでこの曲線がサポートされていますが、完全に解決できなかったため(JDK自体から暗号化プロバイダーを分離したためだと思います)、この名前からこれらのECParameterSpec値の1つを取得するには、いくつかの非常に非効率的なフープをジャンプする必要があるようです。

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 ==

実際の暗号化キーは、 暗号化されたコンテンツエンコーディング)のセクション3.2で説明されているHMACベースのキー導出関数(HKDF)を使用して、ソルト(ランダムに生成)と共有シークレット(DHキー交換によって合意)から導出されます。 HTTPの場合

そのドキュメントは RFC 5869 を参照し、HKDFで使用されるハッシュとしてSHA-256の使用を指定しています。

このRFCは、抽出と展開の2段階のプロセスについて説明しています。抽出フェーズは次のように定義されます。

PRK = HMAC-Hash(salt, IKM)

Web-Pushの場合、これはHMAC-SHA-256操作である必要があり、salt値は既に持っている「saltBytes」値である必要があり、私が見る限り、IKM値は共有シークレットである必要があります( webpushドキュメントには、共有シークレットがIKMであると明記せずに、「これらの値はコンテンツ暗号化キーの計算に使用されます」とだけ記載されています。

展開フェーズは、抽出フェーズで生成された値と「info」値を取得し、使用している暗号化アルゴリズムに十分なキーデータが生成されるまで、それらを繰り返しHMACします(各HMACの出力は次のHMACに送られます) RFC を参照してください)。

この場合、アルゴリズムはAEAD_AES_128_GCMであり、SHA-256の出力よりも小さい128ビットキーを必要とするため、Expandステージで1つのハッシュを実行するだけで済みます。

この場合の「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- 。このシステムを使用したい人は、仕様の最新のドラフトを使用していることを確認する必要があります。これは進行中の作業であり、数か月ごとにわずかに変更されているようです。

次に、そのドキュメントの セクション2 で、暗号化の前に平文にパディングを追加する(そして復号化の後に削除する)必要があることを指定しています。

これは、取得していると述べたものとNode.jsの例で生成されたものとの間の長さの1バイトの違いを説明します。

ドキュメントには次のように書かれています。

各レコードには、1〜256オクテットのパディングが含まれ、暗号化されたコンテンツの前にレコードに挿入されます。パディングは、長さバイトとそれに続くゼロ値オクテットの数で構成されます。最初のパディングオクテット以外のパディングオクテットがゼロ以外の場合、またはレコードにレコードサイズが収容できるよりも多くのパディングがある場合、レシーバーは復号化に失敗する必要があります。

したがって、あなたがする必要があるのは、平文の前に単一の「0」バイトを暗号にプッシュすることだと思います。あなたcouldそれ以上のパディングを追加する-パディングが可能な最小量でなければならないことを指定したものは何も見当たりませんでしたが、単一の「0」バイトが最も単純です(これを読んでいる人は誰でもこれらのメッセージをもう一方の端からデコードするには、法的な量のパディングをサポートしていることを確認する必要があります)。

一般に、httpコンテンツの暗号化の場合、メカニズムはそれよりも少し複雑です(入力をレコードに分割し、それぞれにパディングを追加する必要があるため)が、webpush仕様では、暗号化されたメッセージは単一のレコードに収まる必要があるとされています、だからあなたはそれについて心配する必要はありません。

Webpush暗号化仕様の次のテキストに注意してください。

プッシュサービスは、4096オクテットを超えるペイロード本体をサポートする必要がないことに注意してください。これは、4080オクテットのクリアテキストに相当します。

ここでのクリアテキストの4080オクテットには、1バイトのパディングが含まれているため、事実上4079バイトの制限があるようです。 「Encryption」ヘッダーの「rs」パラメーターを使用して、より大きなレコードサイズを指定できますが、上記のテキストによると、受信者はそれをサポートする必要はありません。

警告:これを行うために私が見たコードのいくつかは、おそらくいくつかの提案された仕様変更の結果として、2バイトのパディングを使用するように変更されているようですが、これがどこに来るのかを追跡することができませんでしたから。現時点では1バイトのパディングで問題ありませんが、将来これが機能しなくなった場合は、2バイトにする必要があります。前述のように、この仕様は進行中の作業であり、ブラウザのサポートは現在実験段階です。

2
sheltond

Santosh kumarのソリューションは、次の1つの変更で機能します。

クリアテキストbyte []を定義する直前に、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 = {...};
0
physox