Шифрование сообщения для Web Push API в Java
Я пытаюсь создать сервер, способный отправлять push-сообщения с помощью Push API: https://developer.mozilla.org/en-US/docs/Web/API/Push_API
У меня работает клиентская сторона, но теперь я хочу иметь возможность отправлять сообщения с помощью полезной нагрузки с сервера Java.
Я увидел пример web-push nodejs (https://www.npmjs.com/package/web-push), но я не смог правильно перевести его на Java.
Я попытался следовать примеру использования обмена ключами DH, найденного здесь: http://docs.oracle.com/javase/7/docs/technotes/guides/security/crypto/CryptoSpec.html#DH2Ex
С помощью sheltond ниже я смог найти код, который должен работать, но это не так.
Когда я отправляю зашифрованное сообщение в службу Push, я возвращаю ожидаемый код состояния 201, но push никогда не доходит до 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" является ключом метода подписки Push API в предоставляемом браузере и "PUSH_ENDPOINT_URL" является конечной точкой push, предоставленной браузером.
Если я получаю значения (зашифрованный текст, base64 bobPubKeyEnc и соль) из успешного запроса веб-push nodejs и жесткого кода на Java, он работает. Если я использую приведенный выше код с динамическими значениями, он не работает.
Я заметил, что зашифрованный текст, который работал в реализации nodejs, всегда на 1 байт больше, чем зашифрованный текст Java с кодом выше. В примере, который я использовал здесь, всегда получается 81-байтовый шифрованный текст, но в nodejs он всегда составляет 82 байта. Означает ли это, что это может быть неправильно?
Как правильно закодировать полезную нагрузку, чтобы она дошла до Firefox?
Заранее благодарим за помощь
Ответы
Ответ 1
Возможность получать уведомления после изменения кода в соответствии с 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;
}
}
Ответ 2
См. 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).
Ключ кодируется с использованием несжатого формата, определенного в ANSI X9.62, поэтому вы не можете использовать x509EncodedKeySpec.
Вы можете использовать BouncyCastle, который должен поддерживать кодировку X9.62.
Ответ 3
Взгляните на ответ от Maarten Bodewes в на этот вопрос.
Он дает источник Java для кодирования/декодирования из несжатого формата X9.62 в ECPublicKey, который, я думаю, должен быть подходящим для того, что вы пытаетесь сделать.
== Обновление 1 ==
В спецификации указано, что "Пользовательские агенты, которые применяют шифрование, ДОЛЖНЫ выставлять эллиптическую кривую долю Диффи-Хеллмана на кривой P-256".
Кривая 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. Затем вы сможете использовать это для декодирования (я бы рекомендовал кэшировать это значение где-то, чтобы избежать необходимости часто делать это генерация ключей).
В качестве альтернативы вы можете просто взять цифры со страницы 8 документ NIST и подключить их непосредственно к конструктору ECParameterSpec.
Здесь есть код который выглядит так, как будто это делает (вокруг строки 124). Этот код лицензирован Apache. Я сам не использовал этот код, но похоже, что константы соответствуют тому, что в документе NIST.
== Обновление 2 ==
Фактический ключ шифрования выводится из соли (произвольно сгенерированной) и общего секретного ключа (согласованного обменом ключами DH), используя функцию определения ключа HMAC (HKDF), описанную в разделе 3.2 Encrypted Content-Encoding для HTTP.
Этот документ ссылается на RFC 5869 и указывает использование SHA-256 в качестве хэша, используемого в HKDF.
Этот RFC описывает двухэтапный процесс: Extract and Expand. Фаза извлечения определяется как:
PRK = HMAC-Hash(salt, IKM)
В случае веб-push это должна быть операция HMAC-SHA-256, значение соли должно быть значением "saltBytes", которое у вас уже есть, и насколько я вижу, значение IKM должно быть (в документе webpush сказано: "Эти значения используются для вычисления ключа шифрования содержимого", не указывая, что общий секрет - это IKM).
Фаза Expand принимает значение, полученное с помощью фазы Extract плюс значение "info", и повторно 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" байт является самым простым (кто читает это, кто пытается декодировать эти сообщения от другого конец должен удостовериться, что они поддерживают любое юридическое количество дополнений).
В общем, для шифрования HTTP-контента механизм немного сложнее, чем это (поскольку вам нужно разделить входные данные на записи и добавить дополнение к каждому из них), но спецификация webpush говорит, что зашифрованное сообщение должно вписываться в одна запись, поэтому вам не нужно беспокоиться об этом.
Обратите внимание на следующий текст в спецификации шифрования webpush:
Обратите внимание, что услуга push не требуется для поддержки более 4096 октетов тела полезной нагрузки, что соответствует 4080 октетам открытого текста
4080 октетов открытого текста здесь включают в себя 1 байт заполнения, поэтому, по-видимому, это ограничение составляет 4079 байт. Вы можете указать больший размер записи, используя параметр "rs" в заголовке "Шифрование", но в соответствии с приведенным выше текстом получатель не обязан поддерживать это.
Одно предупреждение: некоторые из кода, который я видел для этого, похоже, меняют на использование 2 байтов заполнения, предположительно в результате некоторых предлагаемых изменений спецификации, но я не смог отслеживать, где это исходит от. В настоящий момент 1 байт заполнения должен быть в порядке, но если это перестанет работать в будущем, вам может потребоваться перейти на 2 байта - как я уже упоминал выше, эта спецификация работает, и поддержка браузера является экспериментальной прямо сейчас.
Ответ 4
Решение santosh kumar работает с одной модификацией:
Я добавил 1-байтовое дополнение для шифрования прямо перед определением байта cleartext [].
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 = {...};