use aes_gcm::Aes128Gcm;
use aes_gcm::aead::{Aead, Key, KeyInit, Nonce};
use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
use hkdf::Hkdf;
use p256::PublicKey;
use p256::SecretKey;
use p256::elliptic_curve::sec1::ToSec1Point as _;
use sha2::Sha256;
use zeroize::Zeroizing;
pub const HEADER_FIXED_LEN: usize = 21;
pub const SALT_LEN: usize = 16;
pub const AUTH_SECRET_LEN: usize = 16;
pub const PUBLIC_KEY_LEN: usize = 65;
pub const GCM_TAG_LEN: usize = 16;
pub const PAD_DELIMITER_LEN: usize = 1;
const LAST_RECORD_DELIMITER: u8 = 0x02;
pub const WEB_PUSH_HEADER_LEN: usize = HEADER_FIXED_LEN + PUBLIC_KEY_LEN;
pub const MIN_SUPPORTED_BODY_LEN: usize = 4096;
pub const MIN_RECORD_SIZE: u32 = 18;
pub const DEFAULT_RECORD_SIZE: u32 = {
#[allow(clippy::cast_possible_truncation)] let size = (MIN_SUPPORTED_BODY_LEN - WEB_PUSH_HEADER_LEN) as u32;
size
};
const CEK_INFO: &[u8] = b"Content-Encoding: aes128gcm\x00";
const NONCE_INFO: &[u8] = b"Content-Encoding: nonce\x00";
const KEY_INFO_PREFIX: &[u8] = b"WebPush: info\x00";
#[must_use]
pub const fn max_plaintext(record_size: u32) -> usize {
#[allow(clippy::cast_possible_truncation)]
let size = record_size as usize;
size.saturating_sub(GCM_TAG_LEN + PAD_DELIMITER_LEN)
}
#[derive(Debug, thiserror::Error)]
pub enum EceError {
#[error("subscription p256dh is not a {PUBLIC_KEY_LEN}-byte uncompressed P-256 point: {0}")]
SubscriptionKey(String),
#[error("subscription auth secret must be {AUTH_SECRET_LEN} bytes, got {0}")]
AuthSecret(usize),
#[error("subscription auth secret is not base64url")]
AuthSecretEncoding,
#[error("application server key is not a valid P-256 private key")]
ServerKey,
#[error("record size {0} is invalid: RFC 8188 requires at least {MIN_RECORD_SIZE}")]
RecordSize(u32),
#[error(
"payload too large: {plaintext} bytes of plaintext exceeds the {limit}-byte \
limit for a {record_size}-byte record"
)]
PayloadTooLarge {
plaintext: usize,
limit: usize,
record_size: u32,
},
#[error("AES-128-GCM encryption failed")]
Aead,
#[error("could not draw random bytes: {0}")]
Random(String),
}
fn decode_base64url(value: &str) -> Option<Vec<u8>> {
URL_SAFE_NO_PAD
.decode(value)
.or_else(|_| URL_SAFE.decode(value))
.or_else(|_| STANDARD_NO_PAD.decode(value))
.or_else(|_| STANDARD.decode(value))
.ok()
}
#[derive(Clone)]
pub struct SubscriptionKeys {
ua_public: [u8; PUBLIC_KEY_LEN],
auth: Zeroizing<[u8; AUTH_SECRET_LEN]>,
}
impl SubscriptionKeys {
pub fn parse(p256dh: &str, auth: &str) -> Result<Self, EceError> {
let p256dh = decode_base64url(p256dh)
.ok_or_else(|| EceError::SubscriptionKey("not base64url".to_owned()))?;
let auth = Zeroizing::new(decode_base64url(auth).ok_or(EceError::AuthSecretEncoding)?);
Self::from_bytes(&p256dh, &auth)
}
pub fn from_bytes(p256dh: &[u8], auth: &[u8]) -> Result<Self, EceError> {
let ua_public: [u8; PUBLIC_KEY_LEN] = p256dh.try_into().map_err(|_| {
EceError::SubscriptionKey(format!("{} bytes, expected {PUBLIC_KEY_LEN}", p256dh.len()))
})?;
if ua_public[0] != 0x04 {
return Err(EceError::SubscriptionKey(
"not the uncompressed point form (no 0x04 prefix)".to_owned(),
));
}
PublicKey::from_sec1_bytes(&ua_public)
.map_err(|err| EceError::SubscriptionKey(err.to_string()))?;
if auth.len() != AUTH_SECRET_LEN {
return Err(EceError::AuthSecret(auth.len()));
}
let mut secret = Zeroizing::new([0u8; AUTH_SECRET_LEN]);
secret.copy_from_slice(auth);
Ok(Self {
ua_public,
auth: secret,
})
}
}
impl std::fmt::Debug for SubscriptionKeys {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SubscriptionKeys { .. }")
}
}
struct Schedule {
ikm: Zeroizing<[u8; 32]>,
as_public: [u8; PUBLIC_KEY_LEN],
}
struct RecordKeys {
cek: Zeroizing<[u8; 16]>,
nonce: Zeroizing<[u8; 12]>,
}
fn ecdh_secret(as_secret: &SecretKey, ua_public: &PublicKey) -> Zeroizing<[u8; 32]> {
let shared = p256::ecdh::diffie_hellman(as_secret.to_nonzero_scalar(), ua_public.as_affine());
let mut out = Zeroizing::new([0u8; 32]);
out.copy_from_slice(shared.raw_secret_bytes());
out
}
fn key_info(ua_public: &[u8; PUBLIC_KEY_LEN], as_public: &[u8; PUBLIC_KEY_LEN]) -> Vec<u8> {
let mut info = Vec::with_capacity(KEY_INFO_PREFIX.len() + 2 * PUBLIC_KEY_LEN);
info.extend_from_slice(KEY_INFO_PREFIX);
info.extend_from_slice(ua_public);
info.extend_from_slice(as_public);
info
}
fn schedule(keys: &SubscriptionKeys, as_private: &[u8; 32]) -> Result<Schedule, EceError> {
let as_secret = SecretKey::from_slice(as_private).map_err(|_| EceError::ServerKey)?;
let encoded = as_secret.public_key().to_sec1_point(false);
let mut as_public = [0u8; PUBLIC_KEY_LEN];
as_public.copy_from_slice(encoded.as_bytes());
let ua_public = PublicKey::from_sec1_bytes(&keys.ua_public)
.map_err(|err| EceError::SubscriptionKey(err.to_string()))?;
let ecdh = ecdh_secret(&as_secret, &ua_public);
let info = key_info(&keys.ua_public, &as_public);
let hkdf = Hkdf::<Sha256>::new(Some(keys.auth.as_slice()), ecdh.as_slice());
let mut ikm = Zeroizing::new([0u8; 32]);
hkdf.expand(&info, ikm.as_mut_slice())
.expect("HKDF-Expand of 32 octets is always in range");
Ok(Schedule { ikm, as_public })
}
fn record_keys(salt: &[u8; SALT_LEN], ikm: &[u8]) -> RecordKeys {
let hkdf = Hkdf::<Sha256>::new(Some(salt), ikm);
let mut cek = Zeroizing::new([0u8; 16]);
hkdf.expand(CEK_INFO, cek.as_mut_slice())
.expect("HKDF-Expand of 16 octets is always in range");
let mut nonce = Zeroizing::new([0u8; 12]);
hkdf.expand(NONCE_INFO, nonce.as_mut_slice())
.expect("HKDF-Expand of 12 octets is always in range");
RecordKeys { cek, nonce }
}
fn encode(
ikm: &[u8],
salt: &[u8; SALT_LEN],
keyid: &[u8],
record_size: u32,
plaintext: &[u8],
) -> Result<Vec<u8>, EceError> {
if record_size < MIN_RECORD_SIZE {
return Err(EceError::RecordSize(record_size));
}
let limit = max_plaintext(record_size);
if plaintext.len() > limit {
return Err(EceError::PayloadTooLarge {
plaintext: plaintext.len(),
limit,
record_size,
});
}
let idlen = u8::try_from(keyid.len()).map_err(|_| EceError::ServerKey)?;
let RecordKeys { cek, nonce } = record_keys(salt, ikm);
let mut padded = Vec::with_capacity(plaintext.len() + PAD_DELIMITER_LEN);
padded.extend_from_slice(plaintext);
padded.push(LAST_RECORD_DELIMITER);
let cipher = Aes128Gcm::new(&Key::<Aes128Gcm>::from(*cek));
let ciphertext = cipher
.encrypt(&Nonce::<Aes128Gcm>::from(*nonce), padded.as_slice())
.map_err(|_| EceError::Aead)?;
let mut body = Vec::with_capacity(HEADER_FIXED_LEN + keyid.len() + ciphertext.len());
body.extend_from_slice(salt);
body.extend_from_slice(&record_size.to_be_bytes());
body.push(idlen);
body.extend_from_slice(keyid);
body.extend_from_slice(&ciphertext);
Ok(body)
}
#[derive(Debug, Clone, Copy)]
pub struct Ece {
record_size: u32,
}
impl Default for Ece {
fn default() -> Self {
Self {
record_size: DEFAULT_RECORD_SIZE,
}
}
}
impl Ece {
pub fn new(record_size: u32) -> Result<Self, EceError> {
if record_size < MIN_RECORD_SIZE {
return Err(EceError::RecordSize(record_size));
}
Ok(Self { record_size })
}
#[must_use]
pub const fn record_size(self) -> u32 {
self.record_size
}
#[must_use]
pub const fn max_plaintext(self) -> usize {
max_plaintext(self.record_size)
}
#[must_use]
pub const fn body_len(len: usize) -> usize {
WEB_PUSH_HEADER_LEN + len + PAD_DELIMITER_LEN + GCM_TAG_LEN
}
pub fn seal(self, keys: &SubscriptionKeys, plaintext: &[u8]) -> Result<Vec<u8>, EceError> {
let limit = self.max_plaintext();
if plaintext.len() > limit {
return Err(EceError::PayloadTooLarge {
plaintext: plaintext.len(),
limit,
record_size: self.record_size,
});
}
let mut salt = [0u8; SALT_LEN];
getrandom::fill(&mut salt).map_err(|err| EceError::Random(err.to_string()))?;
let as_private = random_scalar()?;
self.seal_with(keys, plaintext, &salt, &as_private)
}
pub fn seal_with(
self,
keys: &SubscriptionKeys,
plaintext: &[u8],
salt: &[u8; SALT_LEN],
as_private: &[u8; 32],
) -> Result<Vec<u8>, EceError> {
let schedule = schedule(keys, as_private)?;
encode(
schedule.ikm.as_slice(),
salt,
&schedule.as_public,
self.record_size,
plaintext,
)
}
}
fn random_scalar() -> Result<Zeroizing<[u8; 32]>, EceError> {
const ATTEMPTS: usize = 8;
let mut bytes = Zeroizing::new([0u8; 32]);
for _ in 0..ATTEMPTS {
getrandom::fill(bytes.as_mut_slice()).map_err(|err| EceError::Random(err.to_string()))?;
if SecretKey::from_slice(bytes.as_slice()).is_ok() {
return Ok(bytes);
}
}
Err(EceError::Random(
"no valid P-256 scalar in 8 draws".to_owned(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use cratefield_testing::vectors::{
RFC8291_AUTH_SECRET as AUTH_SECRET, RFC8291_PLAINTEXT as PLAINTEXT,
RFC8291_UA_PUBLIC as UA_PUBLIC,
};
const SALT: &str = "DGv6ra1nlYgDCS1FRnbzlw";
const AS_PRIVATE: &str = "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw";
const AS_PUBLIC: &str =
"BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8";
const ECDH_SECRET: &str = "kyrL1jIIOHEzg3sM2ZWRHDRB62YACZhhSlknJ672kSs";
const PRK_KEY: &str = "Snr3JMxaHVDXHWJn5wdC52WjpCtd2EIEGBykDcZW32k";
const KEY_INFO: &str = "V2ViUHVzaDogaW5mbwAEJXGyvs3942BVGq8e0PTNNmwRzr5VX4m8t7GGpTM5FzFo7OL\
r4BhZe9MEebhuPI-OztV3ylkYfpJGmQ22ggCLDgT-M_SrDepxkU21WCP3O1SUj0EwbZI\
HMtu5pZpTKGSCIA5Zent7wmC6HCJ5mFgJkuk5cwAvMBKiiujwa7t45ewP";
const IKM: &str = "S4lYMb_L0FxCeq0WhDx813KgSYqU26kOyzWUdsXYyrg";
const PRK: &str = "09_eUZGrsvxChDCGRCdkLiDXrReGOEVeSCdCcPBSJSc";
const CEK: &str = "oIhVW04MRdy2XN9CiKLxTg";
const NONCE: &str = "4h_95klXJ5E_qnoN";
const HEADER: &str = "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZ\
IIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8";
const CIPHERTEXT: &str =
"8pfeW0KbunFT06SuDKoJH9Ql87S1QUrdirN6GcG7sFz1y1sqLgVi1VhjVkHsUoEsbI_0LpXMuGvnzQ";
fn b64(value: &str) -> Vec<u8> {
URL_SAFE_NO_PAD.decode(value).expect("base64url")
}
fn fixed<const N: usize>(value: &str) -> [u8; N] {
b64(value).try_into().expect("fixed-size vector value")
}
fn vector_keys() -> SubscriptionKeys {
SubscriptionKeys::parse(UA_PUBLIC, AUTH_SECRET).expect("the RFC's own subscription")
}
#[test]
fn the_rfc8291_key_schedule_matches_appendix_a_step_by_step() {
let keys = vector_keys();
let as_private: [u8; 32] = fixed(AS_PRIVATE);
let schedule = schedule(&keys, &as_private).expect("the RFC's own keys");
assert_eq!(schedule.as_public.as_slice(), b64(AS_PUBLIC), "as_public");
let as_secret = SecretKey::from_slice(&as_private).expect("the RFC's own key");
let ua_public = PublicKey::from_sec1_bytes(&keys.ua_public).expect("on the curve");
let ecdh = ecdh_secret(&as_secret, &ua_public);
assert_eq!(ecdh.as_slice(), b64(ECDH_SECRET), "ecdh");
assert_eq!(
key_info(&keys.ua_public, &schedule.as_public),
b64(KEY_INFO),
"key_info"
);
let (prk_key, _) = Hkdf::<Sha256>::extract(Some(keys.auth.as_slice()), ecdh.as_slice());
assert_eq!(prk_key.as_slice(), b64(PRK_KEY), "PRK_key");
assert_eq!(schedule.ikm.as_slice(), b64(IKM), "IKM");
let salt: [u8; SALT_LEN] = fixed(SALT);
let (prk, _) = Hkdf::<Sha256>::extract(Some(salt.as_slice()), schedule.ikm.as_slice());
assert_eq!(prk.as_slice(), b64(PRK), "PRK");
let record = record_keys(&salt, schedule.ikm.as_slice());
assert_eq!(record.cek.as_slice(), b64(CEK), "CEK");
assert_eq!(record.nonce.as_slice(), b64(NONCE), "NONCE");
}
#[test]
fn the_key_schedule_carries_no_test_only_key_material() {
assert_eq!(
size_of::<Schedule>(),
32 + PUBLIC_KEY_LEN,
"Schedule is the 32-octet IKM and the 65-octet public key, nothing else"
);
assert_eq!(
size_of::<RecordKeys>(),
16 + 12,
"RecordKeys is the CEK and the nonce, nothing else"
);
}
#[test]
fn the_body_halves_match_appendix_a() {
let body = Ece::new(4096)
.expect("4096 is a legal record size")
.seal_with(
&vector_keys(),
PLAINTEXT.as_bytes(),
&fixed(SALT),
&fixed(AS_PRIVATE),
)
.expect("the RFC's own inputs");
assert_eq!(&body[..WEB_PUSH_HEADER_LEN], b64(HEADER), "86-octet header");
assert_eq!(&body[WEB_PUSH_HEADER_LEN..], b64(CIPHERTEXT), "ciphertext");
}
#[test]
fn the_header_carries_the_record_size_and_the_key_id() {
let body = Ece::default()
.seal_with(&vector_keys(), b"x", &fixed(SALT), &fixed(AS_PRIVATE))
.expect("valid");
let rs = u32::from_be_bytes(body[SALT_LEN..SALT_LEN + 4].try_into().unwrap());
assert_eq!(rs, DEFAULT_RECORD_SIZE);
assert_eq!(rs, 4010, "4096 body budget minus the 86-octet header");
assert_eq!(usize::from(body[SALT_LEN + 4]), PUBLIC_KEY_LEN, "idlen");
assert_eq!(&body[HEADER_FIXED_LEN..WEB_PUSH_HEADER_LEN], b64(AS_PUBLIC));
}
#[test]
fn the_rfc8188_content_coding_matches_the_section_3_1_vector() {
let ikm = b64("yqdlZ-tYemfogSmv7Ws5PQ");
let salt: [u8; SALT_LEN] = fixed("I1BsxtFttlv3u_Oo94xnmw");
let (prk, _) = Hkdf::<Sha256>::extract(Some(salt.as_slice()), &ikm);
assert_eq!(
prk.as_slice(),
b64("zyeH5phsIsgUyd4oiSEIy35x-gIi4aM7y0hCF8mwn9g"),
"PRK"
);
let keys = record_keys(&salt, &ikm);
assert_eq!(keys.cek.as_slice(), b64("_wniytB-ofscZDh4tbSjHw"), "CEK");
assert_eq!(keys.nonce.as_slice(), b64("Bcs8gkIRKLI8GeI8"), "NONCE");
let body = encode(&ikm, &salt, b"", 4096, b"I am the walrus").expect("valid");
assert_eq!(
body,
b64("I1BsxtFttlv3u_Oo94xnmwAAEAAA-NAVub2qFgBEuQKRapoZu-IxkIva3MEB1PD-ly8Thjg")
);
assert_eq!(body.len(), 53);
assert_eq!(b64("SSBhbSB0aGUgd2FscnVzAg"), b"I am the walrus\x02");
}
#[test]
fn the_plaintext_limit_is_computed_from_the_record_size() {
assert_eq!(max_plaintext(4096), 4096 - 16 - 1);
assert_eq!(max_plaintext(DEFAULT_RECORD_SIZE), 3993);
assert_eq!(max_plaintext(MIN_RECORD_SIZE), 1);
assert_eq!(max_plaintext(0), 0, "saturates rather than wrapping");
assert_eq!(Ece::default().max_plaintext(), 3993);
assert_eq!(Ece::body_len(3993), MIN_SUPPORTED_BODY_LEN);
assert_eq!(Ece::body_len(3994), MIN_SUPPORTED_BODY_LEN + 1);
}
#[test]
fn a_body_at_the_limit_is_exactly_4096_octets_and_one_over_is_refused() {
let ece = Ece::default();
let keys = vector_keys();
let salt: [u8; SALT_LEN] = fixed(SALT);
let as_private: [u8; 32] = fixed(AS_PRIVATE);
let at_limit = vec![b'a'; ece.max_plaintext()];
let body = ece
.seal_with(&keys, &at_limit, &salt, &as_private)
.expect("the limit is inclusive");
assert_eq!(body.len(), MIN_SUPPORTED_BODY_LEN);
assert_eq!(body.len(), WEB_PUSH_HEADER_LEN + at_limit.len() + 17);
let one_over = vec![b'a'; ece.max_plaintext() + 1];
let error = ece
.seal_with(&keys, &one_over, &salt, &as_private)
.unwrap_err();
assert!(
matches!(error, EceError::PayloadTooLarge { plaintext, limit, .. }
if plaintext == 3994 && limit == 3993),
"{error}"
);
assert!(error.to_string().contains("payload too large"), "{error}");
let wide = Ece::new(8192).expect("valid");
assert_eq!(wide.max_plaintext(), 8175);
assert!(wide.seal_with(&keys, &one_over, &salt, &as_private).is_ok());
}
#[test]
fn a_record_size_below_the_rfc_floor_is_refused() {
assert!(matches!(Ece::new(17), Err(EceError::RecordSize(17))));
assert!(Ece::new(MIN_RECORD_SIZE).is_ok());
}
#[test]
fn a_malformed_subscription_is_refused_by_name() {
assert!(matches!(
SubscriptionKeys::parse("!!!!", AUTH_SECRET),
Err(EceError::SubscriptionKey(_))
));
let compressed = URL_SAFE_NO_PAD.encode([0x02u8; 33]);
assert!(matches!(
SubscriptionKeys::parse(&compressed, AUTH_SECRET),
Err(EceError::SubscriptionKey(_))
));
let mut off_curve = b64(UA_PUBLIC);
off_curve[64] ^= 0x01;
assert!(matches!(
SubscriptionKeys::parse(&URL_SAFE_NO_PAD.encode(&off_curve), AUTH_SECRET),
Err(EceError::SubscriptionKey(_))
));
assert!(matches!(
SubscriptionKeys::parse(UA_PUBLIC, &URL_SAFE_NO_PAD.encode([0u8; 12])),
Err(EceError::AuthSecret(12))
));
assert!(
SubscriptionKeys::parse(
&URL_SAFE.encode(b64(UA_PUBLIC)),
&URL_SAFE.encode(b64(AUTH_SECRET))
)
.is_ok()
);
}
#[test]
fn the_subscription_keys_never_print_themselves() {
let printed = format!("{:?}", vector_keys());
assert_eq!(printed, "SubscriptionKeys { .. }");
}
#[test]
fn seal_draws_a_fresh_salt_and_key_every_time() {
let keys = vector_keys();
let first = Ece::default()
.seal(&keys, b"same plaintext")
.expect("sealed");
let second = Ece::default()
.seal(&keys, b"same plaintext")
.expect("sealed");
assert_ne!(first[..SALT_LEN], second[..SALT_LEN], "salt");
assert_ne!(
first[HEADER_FIXED_LEN..WEB_PUSH_HEADER_LEN],
second[HEADER_FIXED_LEN..WEB_PUSH_HEADER_LEN],
"ephemeral public key"
);
assert_ne!(first, second);
assert_eq!(first.len(), second.len());
}
#[test]
fn an_oversize_payload_is_refused_before_any_randomness_is_drawn() {
let ece = Ece::default();
let error = ece
.seal(&vector_keys(), &vec![b'a'; ece.max_plaintext() + 1])
.unwrap_err();
assert!(matches!(error, EceError::PayloadTooLarge { .. }), "{error}");
}
#[test]
fn a_random_scalar_is_a_usable_private_key() {
let scalar = random_scalar().expect("randomness");
assert!(SecretKey::from_slice(scalar.as_slice()).is_ok());
assert_ne!(scalar, random_scalar().expect("randomness"));
}
#[test]
fn a_subscription_re_encoded_with_the_standard_alphabet_is_accepted() {
let standard = STANDARD_NO_PAD.encode(b64(UA_PUBLIC));
assert!(standard.contains('+'), "{standard}");
assert!(standard.contains('/'), "{standard}");
let canonical = vector_keys();
for p256dh in [standard.clone(), STANDARD.encode(b64(UA_PUBLIC))] {
let keys = SubscriptionKeys::parse(&p256dh, AUTH_SECRET)
.unwrap_or_else(|err| panic!("{p256dh}: {err}"));
let salt: [u8; SALT_LEN] = fixed(SALT);
let as_private: [u8; 32] = fixed(AS_PRIVATE);
assert_eq!(
Ece::default()
.seal_with(&keys, b"x", &salt, &as_private)
.ok(),
Ece::default()
.seal_with(&canonical, b"x", &salt, &as_private)
.ok(),
"{p256dh}"
);
}
assert!(
SubscriptionKeys::parse(UA_PUBLIC, &STANDARD_NO_PAD.encode(b64(AUTH_SECRET))).is_ok()
);
}
}