example java encryption diffie-hellman push-api

example - Cifrar mensaje para Web Push API en Java



web push notifications example (4)

Estoy intentando crear un servidor capaz de enviar mensajes push usando la API Push: https://developer.mozilla.org/en-US/docs/Web/API/Push_API

Tengo el lado del cliente trabajando pero ahora quiero poder enviar mensajes con una carga útil desde un servidor Java.

Vi el ejemplo web-push de nodejs ( https://www.npmjs.com/package/web-push ) pero no pude traducirlo correctamente a Java.

Intenté seguir el ejemplo para usar el intercambio de claves DH que se encuentra aquí: http://docs.oracle.com/javase/7/docs/technotes/guides/security/crypto/CryptoSpec.html#DH2Ex

Con la ayuda de Sheltond a continuación, pude averiguar un código que debería funcionar pero que no.

Cuando publico el mensaje cifrado en el servicio Push, recupero el código de estado 201 esperado, pero el envío nunca llega a Firefox. Si elimino la carga útil y los encabezados y simplemente envío una solicitud POST a la misma URL, el mensaje llega con éxito a Firefox sin datos. Sospecho que puede tener algo que ver con la forma en que cifro los datos con Cipher.getInstance ("AES / GCM / NoPadding");

Este es el código que estoy usando actualmente:

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

donde "BASE_64_PUBLIC_KEY_FROM_PUSH_SUBSCRIPTION" es la clave del método de suscripción de la API de inserción en el navegador provisto y "PUSH_ENDPOINT_URL" es el punto final de inserción que proporcionó el navegador.

Si obtengo valores (texto cifrado, base64 bobPubKeyEnc y salt) de una solicitud web-push de nodejs exitosa y los codifico en Java, funciona. Si utilizo el código anterior con valores dinámicos, no funciona.

Noté que el texto cifrado que funcionó en la implementación de nodejs es siempre 1 byte más grande que el texto cifrado de Java con el código anterior. El ejemplo que utilicé aquí siempre produce un texto cifrado de 81 bytes, pero en nodejs siempre es de 82 bytes, por ejemplo. ¿Esto nos da una pista de lo que podría estar mal?

¿Cómo cifro correctamente la carga útil para que llegue a Firefox?

Gracias de antemano por cualquier ayuda


Capaz de recibir notificaciones después de cambiar el código según https://jrconlin.github.io/WebPushDataTestPage/

Encuentra el código modificado a continuación:

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



Echa un vistazo a la respuesta de Maarten Bodewes en esta pregunta .

Él le da a Java una fuente para codificar / decodificar desde el formato descomprimido X9.62 a un ECPublicKey, que creo que debería ser adecuado para lo que está tratando de hacer.

== Actualización 1 ==

La especificación dice " Los Agentes de usuario que hacen cumplir el cifrado DEBEN exponer una curva elíptica Diffie-Hellman compartida en la curva P-256 ".

La curva P-256 es una curva estándar aprobada por el NIST para su uso en aplicaciones de cifrado del gobierno de EE. UU. La definición, los valores de los parámetros y las razones para elegir esta curva en particular (junto con algunas otras) se dan here .

Hay soporte para esta curva en la biblioteca estándar que usa el nombre "secp256r1", pero por razones que no pude resolver por completo (creo que tiene que ver con la separación de los proveedores de criptografía del JDK), Parece que tienen que saltar algunos aros muy ineficientes para obtener uno de estos valores ECParameterSpec de este nombre:

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

Esto es bastante pesado porque en realidad genera un par de llaves utilizando el objeto ECGenParameterSpec nombrado, y luego extrae el ECParameterSpec de él. Entonces deberías poder usar esto para decodificar (recomendaría almacenar este valor en caché en algún lugar para evitar tener que hacer esta generación de claves con frecuencia).

Alternativamente, puede tomar los números de la página 8 del here y conectarlos directamente al constructor ECParameterSpec.

Hay algún código here que parece que hace exactamente eso (alrededor de la línea 124). Ese código tiene licencia Apache . Yo no he usado ese código, pero parece que las constantes coinciden con lo que hay en el documento NIST.

== Actualización 2 ==

La clave de cifrado real se deriva de la sal (generada aleatoriamente) y el secreto compartido (acordado por el intercambio de claves DH), utilizando la función de derivación de claves basada en HMAC (HKDF) descrita en la sección 3.2 de Codificación de contenido cifrado para HTTP .

Ese documento hace referencia a RFC 5869 y especifica el uso de SHA-256 como el hash utilizado en el HKDF.

Este RFC describe un proceso de dos etapas: Extraer y Expandir. La fase de extracción se define como:

PRK = HMAC-Hash(salt, IKM)

En el caso de web-push, esta debe ser una operación HMAC-SHA-256, el valor de sal debe ser el valor de "saltBytes" que ya tiene, y por lo que puedo ver, el valor de IKM debe ser el secreto compartido ( el documento webpush simplemente dice "Estos valores se utilizan para calcular la clave de cifrado de contenido" sin indicar específicamente que el secreto compartido es el IKM).

La fase de expansión toma el valor producido por la fase de extracción más un valor de ''información'', y los HMAC repetidamente hasta que haya generado suficientes datos clave para el algoritmo de cifrado que está utilizando (la salida de cada HMAC se envía al siguiente). - Consulte el RFC para más detalles).

En este caso, el algoritmo es AEAD_AES_128_GCM, que requiere una clave de 128 bits, que es más pequeña que la salida de SHA-256, por lo que solo necesita hacer un hash en la etapa Expandir.

El valor de ''información'' en este caso tiene que ser "Content-Encoding: aesgcm128" (especificado en Encrypted Content-Encoding para HTTP ), por lo que la operación que necesita es:

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

donde el ''|'' Es concatenación. Luego toma los primeros 16 bytes del resultado, y esa debe ser la clave de cifrado.

En términos de Java, eso sería algo así como:

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

Para referencia, aquí hay un enlace a la parte de la biblioteca BouncyCastle que hace esto.

Finalmente, acabo de notar esta parte en el documento webpush:

Las claves públicas, como las codificadas en el parámetro "dh", DEBEN tener la forma de un punto sin comprimir

así que parece que necesitarás usar algo como esto:

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

En lugar de usar el método estándar getEncoded ().

== Actualización 3 ==

Primero, debo señalar que hay un borrador más reciente de la especificación para el cifrado de contenido http que el que he vinculado anteriormente: draft-ietf-httpbis-encryption-encoding-00 . Las personas que quieran usar este sistema deben asegurarse de que están utilizando el último borrador disponible de la especificación; este es un trabajo en progreso y parece estar cambiando ligeramente cada pocos meses.

Segundo, en la sección 2 de ese documento, especifica que se debe agregar algo de relleno al texto sin formato antes del cifrado (y se debe eliminar después del descifrado).

Esto explicaría la diferencia de longitud de un byte entre lo que mencionó que está obteniendo y lo que produce el ejemplo Node.js.

El documento dice:

Cada registro contiene entre 1 y 256 octetos de relleno, insertados en un registro antes del contenido cifrado. El relleno consiste en un byte de longitud, seguido de ese número de octetos de valor cero. Un receptor DEBE dejar de descifrar si cualquier octeto de relleno que no sea el primero es distinto de cero, o si un registro tiene más relleno del que puede aceptar el tamaño del registro.

Así que creo que lo que debe hacer es insertar un solo byte ''0'' en el cifrado antes del texto sin formato. Podría agregar más relleno que eso. No pude ver nada que especificara que el relleno debe ser la cantidad mínima posible, pero un byte de ''0'' es el más simple (cualquiera que lea este mensaje que intente descifrar estos mensajes del otro) final debe asegurarse de que admiten cualquier cantidad legal de relleno).

En general, para el cifrado de contenido http, el mecanismo es un poco más complicado que eso (ya que tiene que dividir la entrada en registros y agregar relleno a cada uno), pero la especificación de webpush dice que el mensaje cifrado debe caber en un solo registro , así que no tienes que preocuparte por eso.

Tenga en cuenta el siguiente texto en la especificación de cifrado webpush:

Tenga en cuenta que no se requiere un servicio push para admitir más de 4096 octetos del cuerpo de la carga útil, lo que equivale a 4080 octetos de texto claro

Los 4080 octetos de texto claro aquí incluyen el 1 byte de relleno, por lo que efectivamente parece haber un límite de 4079 bytes. Puede especificar un tamaño de registro mayor utilizando el parámetro "rs" en el encabezado "Encriptación", pero de acuerdo con el texto citado anteriormente, no es necesario que el destinatario lo admita.

Una advertencia: parte del código que he visto hacer esto parece estar cambiando a usar 2 bytes de relleno, probablemente como resultado de un cambio de especificación propuesto, pero no he podido rastrear a dónde viene esto. desde. En este momento, 1 byte de relleno debería estar bien, pero si esto deja de funcionar en el futuro, es posible que deba ir a 2 bytes. Como mencioné anteriormente, esta especificación es un trabajo en progreso y el soporte del navegador es experimental en este momento.


La solución de santosh kumar funciona con una modificación:

Agregué un relleno de cifrado de 1 byte justo antes de definir el byte de texto claro [].

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 = {...};