use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes128Gcm, Key, Nonce};
use hmac::{Hmac, Mac};
use p256::elliptic_curve::sec1::ToEncodedPoint;
use sha2::Sha256;
use super::PushError;
use super::vapid::P256_UNCOMPRESSED_LEN;
const AUTH_SECRET_LEN: usize = 16;
const CEK_LEN: usize = 16;
const NONCE_LEN: usize = 12;
const TAG_LEN: usize = 16;
const HEADER_LEN: usize = 16 + 4 + 1 + P256_UNCOMPRESSED_LEN;
const LAST_RECORD_DELIMITER: u8 = 0x02;
pub const RECORD_SIZE: u32 = 4096;
pub const MAX_PLAINTEXT_LEN: usize = RECORD_SIZE as usize - HEADER_LEN - 1 - TAG_LEN;
pub fn encrypt(
plaintext: &[u8],
ua_public: &[u8],
auth_secret: &[u8],
) -> Result<Vec<u8>, PushError> {
let mut salt = [0_u8; 16];
rand::TryRng::try_fill_bytes(&mut rand::rngs::SysRng, &mut salt)
.map_err(|e| PushError::Encryption(format!("could not draw a random salt: {e}")))?;
let ephemeral = p256::SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
encrypt_with(plaintext, ua_public, auth_secret, salt, &ephemeral)
}
pub fn encrypt_with(
plaintext: &[u8],
ua_public: &[u8],
auth_secret: &[u8],
salt: [u8; 16],
ephemeral: &p256::SecretKey,
) -> Result<Vec<u8>, PushError> {
if plaintext.len() > MAX_PLAINTEXT_LEN {
return Err(PushError::PayloadTooLarge {
len: plaintext.len(),
max: MAX_PLAINTEXT_LEN,
});
}
if auth_secret.len() != AUTH_SECRET_LEN {
return Err(PushError::InvalidSubscriptionKey(format!(
"`auth` must be exactly {AUTH_SECRET_LEN} bytes, got {}",
auth_secret.len()
)));
}
let ua_key = p256::PublicKey::from_sec1_bytes(ua_public).map_err(|_| {
PushError::InvalidSubscriptionKey(
"`p256dh` is not an uncompressed P-256 public key on the curve".to_owned(),
)
})?;
let as_public_point = ephemeral.public_key().to_encoded_point(false);
let as_public = as_public_point.as_bytes();
debug_assert_eq!(as_public.len(), P256_UNCOMPRESSED_LEN);
let shared = p256::ecdh::diffie_hellman(ephemeral.to_nonzero_scalar(), ua_key.as_affine());
let mut key_info = Vec::with_capacity(14 + P256_UNCOMPRESSED_LEN * 2);
key_info.extend_from_slice(b"WebPush: info\0");
key_info.extend_from_slice(ua_key.to_encoded_point(false).as_bytes());
key_info.extend_from_slice(as_public);
let ikm = hkdf_sha256(auth_secret, shared.raw_secret_bytes(), &key_info, 32);
let cek = hkdf_sha256(&salt, &ikm, b"Content-Encoding: aes128gcm\0", CEK_LEN);
let nonce = hkdf_sha256(&salt, &ikm, b"Content-Encoding: nonce\0", NONCE_LEN);
let mut record = Vec::with_capacity(plaintext.len() + 1);
record.extend_from_slice(plaintext);
record.push(LAST_RECORD_DELIMITER);
let cipher = Aes128Gcm::new(<&Key<Aes128Gcm>>::from(cek.as_slice()));
let ciphertext = cipher
.encrypt(
<&Nonce<_>>::from(nonce.as_slice()),
Payload {
msg: &record,
aad: b"",
},
)
.map_err(|e| PushError::Encryption(format!("AES-128-GCM: {e}")))?;
let mut body = Vec::with_capacity(HEADER_LEN + ciphertext.len());
body.extend_from_slice(&salt);
body.extend_from_slice(&RECORD_SIZE.to_be_bytes());
body.push(u8::try_from(P256_UNCOMPRESSED_LEN).expect("65 fits in a u8"));
body.extend_from_slice(as_public);
body.extend_from_slice(&ciphertext);
Ok(body)
}
pub fn hkdf_sha256(salt: &[u8], ikm: &[u8], info: &[u8], len: usize) -> Vec<u8> {
type HmacSha256 = Hmac<Sha256>;
let mut extract =
<HmacSha256 as Mac>::new_from_slice(salt).expect("HMAC accepts any key length");
extract.update(ikm);
let prk = extract.finalize().into_bytes();
let mut okm = Vec::with_capacity(len);
let mut previous: Vec<u8> = Vec::new();
let mut counter: u8 = 1;
while okm.len() < len {
let mut expand =
<HmacSha256 as Mac>::new_from_slice(&prk).expect("HMAC accepts any key length");
expand.update(&previous);
expand.update(info);
expand.update(&[counter]);
previous = expand.finalize().into_bytes().to_vec();
okm.extend_from_slice(&previous);
counter += 1;
}
okm.truncate(len);
okm
}
#[cfg(test)]
mod tests {
use super::*;
use crate::push::vapid::decode_base64url;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
const RFC_PLAINTEXT: &str = "When I grow up, I want to be a watermelon";
const RFC_UA_PUBLIC: &str =
"BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4";
const RFC_AUTH_SECRET: &str = "BTBZMqHH6r4Tts7J_aSIgg";
const RFC_AS_PRIVATE: &str = "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw";
const RFC_AS_PUBLIC: &str =
"BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8";
const RFC_SALT: &str = "DGv6ra1nlYgDCS1FRnbzlw";
const RFC_BODY: &str = "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml\
mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT\
pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN";
fn b64(value: &str) -> Vec<u8> {
decode_base64url(value).expect("test vector is base64url")
}
fn rfc_salt() -> [u8; 16] {
let mut salt = [0_u8; 16];
salt.copy_from_slice(&b64(RFC_SALT));
salt
}
fn rfc_ephemeral() -> p256::SecretKey {
p256::SecretKey::from_slice(&b64(RFC_AS_PRIVATE)).expect("RFC private key parses")
}
#[test]
fn matches_the_rfc_8291_published_vector() {
let body = encrypt_with(
RFC_PLAINTEXT.as_bytes(),
&b64(RFC_UA_PUBLIC),
&b64(RFC_AUTH_SECRET),
rfc_salt(),
&rfc_ephemeral(),
)
.expect("encrypting the RFC vector succeeds");
assert_eq!(
URL_SAFE_NO_PAD.encode(&body),
RFC_BODY.replace([' ', '\n'], ""),
"the aes128gcm body must match RFC 8291 §5 byte-for-byte — anything \
else will not decrypt in a real browser"
);
}
#[test]
fn body_is_framed_as_rfc_8188_requires() {
let body = encrypt_with(
RFC_PLAINTEXT.as_bytes(),
&b64(RFC_UA_PUBLIC),
&b64(RFC_AUTH_SECRET),
rfc_salt(),
&rfc_ephemeral(),
)
.expect("encrypt");
assert_eq!(&body[..16], &rfc_salt()[..], "bytes 0..16 are the salt");
assert_eq!(
u32::from_be_bytes([body[16], body[17], body[18], body[19]]),
RECORD_SIZE,
"bytes 16..20 are the big-endian record size"
);
assert_eq!(body[20], 65, "byte 20 is the key id length (65 for P-256)");
assert_eq!(
URL_SAFE_NO_PAD.encode(&body[21..86]),
RFC_AS_PUBLIC,
"bytes 21..86 are the application server's ephemeral public key"
);
assert_eq!(
body.len(),
21 + 65 + RFC_PLAINTEXT.len() + 1 + 16,
"header + key + (plaintext ‖ 0x02 delimiter) + GCM tag"
);
}
#[test]
fn hkdf_matches_rfc_5869_test_case_1() {
let ikm = [0x0b_u8; 22];
let salt: Vec<u8> = (0x00..=0x0c).collect();
let info: Vec<u8> = (0xf0..=0xf9).collect();
let okm = hkdf_sha256(&salt, &ikm, &info, 42);
assert_eq!(
hex::encode(okm),
"3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"
);
}
#[test]
fn each_message_uses_a_fresh_salt_and_ephemeral_key() {
let first = encrypt(
RFC_PLAINTEXT.as_bytes(),
&b64(RFC_UA_PUBLIC),
&b64(RFC_AUTH_SECRET),
)
.expect("encrypt");
let second = encrypt(
RFC_PLAINTEXT.as_bytes(),
&b64(RFC_UA_PUBLIC),
&b64(RFC_AUTH_SECRET),
)
.expect("encrypt");
assert_ne!(&first[..16], &second[..16], "salt must be freshly random");
assert_ne!(
&first[21..86],
&second[21..86],
"the ephemeral key must be freshly random"
);
}
#[test]
fn rejects_a_malformed_p256dh_key() {
let err = encrypt(b"hi", &[0x04, 0x01, 0x02], &b64(RFC_AUTH_SECRET))
.expect_err("a 3-byte p256dh is not a P-256 point");
assert!(
matches!(err, PushError::InvalidSubscriptionKey(_)),
"{err:?}"
);
}
#[test]
fn rejects_a_p256dh_that_is_not_on_the_curve() {
let mut bogus = b64(RFC_UA_PUBLIC);
bogus[64] ^= 0x01;
let err = encrypt(b"hi", &bogus, &b64(RFC_AUTH_SECRET))
.expect_err("an off-curve point is rejected");
assert!(
matches!(err, PushError::InvalidSubscriptionKey(_)),
"{err:?}"
);
}
#[test]
fn rejects_a_wrong_length_auth_secret() {
let err =
encrypt(b"hi", &b64(RFC_UA_PUBLIC), &[0_u8; 8]).expect_err("auth is exactly 16 bytes");
assert!(
matches!(err, PushError::InvalidSubscriptionKey(_)),
"{err:?}"
);
assert!(err.to_string().contains("16"), "{err}");
}
#[test]
fn rejects_a_payload_that_would_not_fit_a_single_record() {
let too_big = vec![b'x'; MAX_PLAINTEXT_LEN + 1];
let err = encrypt(&too_big, &b64(RFC_UA_PUBLIC), &b64(RFC_AUTH_SECRET))
.expect_err("oversize payloads are refused, not silently truncated");
assert!(matches!(err, PushError::PayloadTooLarge { .. }), "{err:?}");
}
#[test]
fn accepts_a_payload_exactly_at_the_limit() {
let at_limit = vec![b'x'; MAX_PLAINTEXT_LEN];
let body = encrypt(&at_limit, &b64(RFC_UA_PUBLIC), &b64(RFC_AUTH_SECRET))
.expect("the documented maximum must actually be sendable");
assert!(
body.len() <= 4096,
"the encrypted body must fit the 4096-byte floor every push service \
is required to accept, got {}",
body.len()
);
}
#[test]
fn empty_payload_still_produces_a_valid_record() {
let body = encrypt(b"", &b64(RFC_UA_PUBLIC), &b64(RFC_AUTH_SECRET)).expect("encrypt");
assert_eq!(body.len(), 21 + 65 + 1 + 16, "delimiter + tag only");
}
}