web-dev-qa-db-fra.com

Chiffrer le message pour l'API Web Push en Java

J'essaie de créer un serveur capable d'envoyer des messages Push à l'aide de l'API Push: https://developer.mozilla.org/en-US/docs/Web/API/Push_API

Le côté client est actif, mais je souhaite maintenant pouvoir envoyer des messages avec une charge utile depuis un serveur Java.

J'ai vu l'exemple Web-Push de nodejs ( https://www.npmjs.com/package/web-Push ) mais je ne pouvais pas traduire cela correctement en Java.

J'ai essayé de suivre l'exemple pour utiliser l'échange de clé DH trouvé ici: http://docs.Oracle.com/javase/7/docs/technotes/guides/security/crypto/CryptoSpec.html#DH2Ex

Avec l'aide de sheltond ci-dessous, j'ai pu trouver un code qui devrait fonctionner mais ne le serait pas. 

Lorsque je poste le message crypté sur le service Push, je récupère le code d'état 201 attendu, mais le Push n'atteint jamais Firefox. Si je supprime les données utiles et les en-têtes et envoie simplement une demande POST à la même URL, le message parvient à Firefox sans aucune donnée. Je soupçonne que cela peut avoir un lien avec la façon dont je chiffre les données avec Cipher.getInstance ("AES/GCM/NoPadding");

C'est le code que j'utilise actuellement:

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

où "BASE_64_PUBLIC_KEY_FROM_Push_SUBSCRIPTION" est la clé de la méthode d'abonnement à l'API Push dans le navigateur fourni et "Push_ENDPOINT_URL" au point de terminaison Push fourni par le navigateur.

Si j'obtiens des valeurs (texte crypté, base64, bobPubKeyEnc et salt) d'une requête web-Push de nodejs réussie et si elles sont codées en dur en Java, cela fonctionne. Si j'utilise le code ci-dessus avec des valeurs dynamiques, cela ne fonctionne pas.

J'ai remarqué que le texte chiffré ayant fonctionné dans l'implémentation de nodejs est toujours supérieur d'un octet au texte chiffré de Java avec le code ci-dessus. L'exemple que j'ai utilisé ici produit toujours un texte chiffré de 81 octets, mais dans nodejs, il s'agit toujours de 82 octets par exemple. Cela nous donne-t-il un indice sur ce qui pourrait ne pas être correct?

Comment chiffrer correctement le contenu afin qu'il atteigne Firefox?

Merci d'avance pour votre aide

16
joaomgcd

Voir https://tools.ietf.org/html/draft-ietf-webpush-encryption-01#section-5 et https://w3c.github.io/Push-api/#widl- PushSubscription-getKey-ArrayBuffer-PushEncryptionKeyName-name (point 4).

La clé est codée au format non compressé défini dans ANSI X9.62. Vous ne pouvez donc pas utiliser x509EncodedKeySpec.

Vous pouvez utiliser BouncyCastle, qui devrait supporter le codage X9.62.

3
Marco

Regardez la réponse de Maarten Bodewes dans cette question .

Il donne le code source Java pour l’encodage/décodage du format non compressé X9.62 dans une clé ECPublicKey, ce qui, à mon avis, devrait convenir à ce que vous essayez de faire.

== Mise à jour 1 ==

La spécification indique "Les agents d'utilisateur qui appliquent le chiffrement DOIVENT exposer une courbe elliptique partagée par Diffie-Hellman sur la courbe P-256}".

La courbe P-256 est une courbe standard approuvée par le NIST pour une utilisation dans les applications de chiffrement du gouvernement américain. La définition, les valeurs des paramètres et la justification du choix de cette courbe particulière (ainsi que de quelques autres) sont données ici .

Cette courbe est prise en charge dans la bibliothèque standard sous le nom "secp256r1", mais pour des raisons que je n'ai pas pu comprendre complètement (je pense que cela a à voir avec la séparation des fournisseurs de cryptographie du JDK lui-même), vous semble devoir franchir des obstacles très inefficaces pour obtenir l’une de ces valeurs ECParameterSpec à partir de ce nom:

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

C'est assez lourd parce qu'il génère une paire de clés à l'aide de l'objet ECGenParameterSpec nommé, puis en extrait la propriété ECParameterSpec. Vous devriez alors pouvoir utiliser ceci pour décoder (je vous recommande de mettre cette valeur en cache quelque part pour éviter d'avoir à générer fréquemment cette génération de clé).

Sinon, vous pouvez simplement prendre les numéros de la page 8 du document NIST et les brancher directement au constructeur ECParameterSpec.

Il y a du code ici qui ressemble exactement à cela (autour de la ligne 124). Ce code est sous licence Apache . Je n'ai pas utilisé ce code moi-même, mais il semble que les constantes correspondent à ce qui est dans le document NIST.

== Mise à jour 2 ==

La clé de cryptage réelle est dérivée du sel (généré aléatoirement) et du secret partagé (accepté par l'échange de clé DH), à l'aide de la fonction de dérivation de clé basée sur HMAC (HKDF) décrite dans la section 3.2 de Encrypted Content-Encoding for HTTP. .

Ce document fait référence à RFC 5869 et spécifie l'utilisation de SHA-256 en tant que hachage utilisé dans HKDF.

Cette RFC décrit un processus en deux étapes: Extraire et Développer. La phase d'extraction est définie comme suit:

PRK = HMAC-Hash(salt, IKM)

Dans le cas de Web-Push, cela devrait être une opération HMAC-SHA-256, la valeur salt devrait être la valeur "saltBytes" que vous avez déjà et, autant que je sache, la valeur IKM devrait être le secret partagé ( Le document webpush indique simplement "Ces valeurs sont utilisées pour calculer la clé de chiffrement du contenu" sans indiquer spécifiquement que le secret partagé est l'IKM).

La phase Expand prend la valeur produite par la phase Extract plus une valeur 'info', et les répète HMAC jusqu'à ce qu'elle ait généré suffisamment de données de clé pour l'algorithme de cryptage que vous utilisez (la sortie de chaque HMAC est introduite dans le suivant. - voir le RFC pour plus de détails).

Dans ce cas, l'algorithme est AEAD_AES_128_GCM, qui requiert une clé de 128 bits, inférieure à la sortie de SHA-256. Vous ne devez donc effectuer qu'un seul hachage dans l'étape Développer.

Dans ce cas, la valeur "info" doit être "Content-Encoding: aesgcm128" (spécifié dans Encrypted Content-Encoding for HTTP ). L'opération dont vous avez besoin est la suivante:

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

où le '|' est la concaténation. Vous prenez alors les 16 premiers octets du résultat, ce qui devrait être la clé de cryptage.

En termes Java, cela ressemblerait à quelque chose comme:

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

Pour référence, voici un lien vers la partie de la bibliothèque BouncyCastle qui fait ce genre de choses.

Enfin, je viens de remarquer cette partie du document webpush: 

Les clés publiques, telles qu’elles sont codées dans le paramètre "dh", DOIVENT être sous la forme Sous la forme d’un point non compressé.

il semble donc que vous deviez utiliser quelque chose comme ceci:

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

au lieu d'utiliser la méthode standard getEncoded ().

== Mise à jour 3 ==

Tout d'abord, je tiens à signaler qu'il existe un projet plus récent de spécification pour le chiffrement de contenu http que celui auquel j'ai déjà été associé: draft-ietf-httpbis-encryption-encoding-00 . Les personnes qui souhaitent utiliser ce système doivent s’assurer qu’elles utilisent la dernière version disponible de la spécification. C’est un travail en cours qui semble changer légèrement tous les deux ou trois mois.

Deuxièmement, dans la section section 2 de ce document, il est spécifié qu'un remplissage doit être ajouté au texte en clair avant le chiffrement (et supprimé après le déchiffrement).

Cela expliquerait la différence de longueur d'un octet entre ce que vous avez dit que vous obtenez et ce que produit l'exemple Node.js.

Le document dit:

Chaque enregistrement contient entre 1 et 256 octets de remplissage, inséré Dans un enregistrement avant le contenu chiffré. Le rembourrage consiste en un octet de longueur , Suivi de ce nombre d'octets de valeur zéro. Un destinataire NE DOIT PAS décrypter si un octet de remplissage autre que le premier est Différent de zéro, ou si un enregistrement a une surface de remplissage supérieure à celle que la taille de l'enregistrement peut accueillir .

Donc, je pense que ce que vous devez faire est de pousser un seul octet '0' dans le chiffre avant le texte en clair. Vous pourriez ajouter plus de bourrage que cela - je ne pouvais rien voir qui spécifiait que le remplissage devait être le montant minimum possible, mais un seul octet '0' est le plus simple (toute personne lisant ceci essayant de décoder ces messages de l’autre extrémité doit s’assurer qu’ils supportent toute quantité légale de bourrage).

En général, pour le chiffrement de contenu http, le mécanisme est un peu plus compliqué que cela (vous devez fractionner les entrées dans les enregistrements et ajouter un remplissage à chacun), mais la spécification webpush indique que le message crypté doit tenir dans un seul enregistrement. , alors vous n'avez pas besoin de vous inquiéter à ce sujet.

Notez le texte suivant dans les spécifications de cryptage webpush:

Notez qu'un service Push n'est pas obligé de prendre en charge plus de 4096 Octets de corps de charge utile, ce qui équivaut à 4080 octets de texte en clair.

Les 4080 octets de texte en clair incluent ici un octet de remplissage, il semble donc effectivement y avoir une limite de 4079 octets. Vous pouvez spécifier une taille d'enregistrement plus grande en utilisant le paramètre "rs" dans l'en-tête "Encryption", mais selon le texte cité ci-dessus, le destinataire n'est pas obligé de la prendre en charge.

Un avertissement: une partie du code que j'ai vu faire cela semble changer pour utiliser 2 octets de remplissage, probablement à la suite d'un changement de spécification proposé, mais je n'ai pas été en mesure de localiser le problème. de. Pour le moment, un octet de remplissage devrait être correct, mais si cela ne fonctionne plus à l'avenir, vous devrez peut-être utiliser 2 octets. Comme je l'ai mentionné plus haut, cette spécification est un travail en cours et la prise en charge du navigateur est expérimentale.

2
sheltond

La solution de santosh kumar fonctionne avec une modification:

J'ai ajouté un remplissage de chiffrement sur 1 octet juste avant de définir l'octet en texte clair byte [].

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