Зашифруйте сообщение для API Web Push в 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
С помощью нижеприведенного шелтонда я смог выяснить код, который должен работать, но не работает.
Когда я отправляю зашифрованное сообщение в службу 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, предоставленной браузером.
Если я получаю значения (ciphertext, base64 bobPubKeyEnc и salt) из успешного веб-push-запроса nodejs и жестко кодирую их в Java, это работает. Если я использую код выше с динамическими значениями, он не работает.
Я заметил, что зашифрованный текст, который работал в реализации nodejs, всегда на 1 байт больше, чем зашифрованный текст Java с приведенным выше кодом. В примере, который я здесь использовал, всегда получается 81-байтовый зашифрованный текст, но в nodejs это всегда 82 байта, например. Это дает нам ключ к пониманию того, что может быть не так?
Как правильно зашифровать полезную нагрузку, чтобы она достигла Firefox?
Заранее благодарю за любую помощь
4 ответа
Возможность получать уведомления после изменения кода в соответствии с 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;
}
}
См. https://tools.ietf.org/html/draft-ietf-webpush-encryption-01 и https://w3c.github.io/push-api/#widl-PushSubscription-getKey-ArrayBuffer- PushEncryptionKeyName-name (точка 4).
Ключ кодируется с использованием несжатого формата, определенного в ANSI X9.62, поэтому вы не можете использовать x509EncodedKeySpec.
Вы можете использовать BouncyCastle, который должен поддерживать кодировку X9.62.
Посмотрите на ответ от Мартена Бодьюса в этом вопросе.
Он дает исходный код 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 шифрованного кодирования содержимого для HTTP.
Этот документ ссылается на RFC 5869 и определяет использование SHA-256 в качестве хэша, используемого в HKDF.
Этот RFC описывает двухэтапный процесс: Извлечение и Расширение. Фаза извлечения определяется как:
PRK = HMAC-Hash(salt, IKM)
В случае web-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 для 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, чем та, с которой я ранее связывался: https://tools.ietf.org/html/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 байта - как я упоминал выше, эта спецификация находится в стадии разработки, и поддержка браузера в настоящее время является экспериментальной.
Решение сантош кумар работает с одной модификацией:
Я добавил 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 = {...};