Skip to main content

cratefield_adapter_webpush/
ece.rs

1//! The payload encryption: the `aes128gcm` HTTP content coding
2//! ([RFC 8188]) and the Web Push key schedule layered on top of it
3//! ([RFC 8291]).
4//!
5//! The two are kept apart on purpose. RFC 8188 is a self-contained content
6//! coding — salt, record size, key id, `HKDF-SHA256` to a content-encryption
7//! key and nonce, one `AEAD_AES_128_GCM` record — and it has its own test
8//! vector (RFC 8188 §3.1) that needs nothing from Web Push. RFC 8291 only
9//! decides *what the input keying material and the key id are*: an ECDH
10//! between a per-message server key and the browser's `p256dh`, mixed with
11//! the subscription's `auth` secret. Splitting them means each half is
12//! checked against its own published vector rather than only through the
13//! other.
14//!
15//! Everything here is pure Rust ([`p256`], [`hkdf`], [`aes_gcm`]) and builds
16//! for `wasm32-unknown-unknown`.
17//!
18//! [RFC 8188]: https://www.rfc-editor.org/rfc/rfc8188
19//! [RFC 8291]: https://www.rfc-editor.org/rfc/rfc8291
20
21use aes_gcm::Aes128Gcm;
22use aes_gcm::aead::{Aead, Key, KeyInit, Nonce};
23use base64::Engine as _;
24use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
25use hkdf::Hkdf;
26use p256::PublicKey;
27use p256::SecretKey;
28use p256::elliptic_curve::sec1::ToSec1Point as _;
29use sha2::Sha256;
30use zeroize::Zeroizing;
31
32/// The fixed part of the RFC 8188 §2.1 header: `salt(16) | rs(4) | idlen(1)`.
33pub const HEADER_FIXED_LEN: usize = 21;
34/// The `salt` field, and the salt input to HKDF.
35pub const SALT_LEN: usize = 16;
36/// RFC 8291 §3.2: the subscription's authentication secret is 16 octets.
37pub const AUTH_SECRET_LEN: usize = 16;
38/// An uncompressed SEC1 P-256 point: `0x04 || x(32) || y(32)`.
39pub const PUBLIC_KEY_LEN: usize = 65;
40/// `AEAD_AES_128_GCM` expands its input by a 16-octet authentication tag.
41pub const GCM_TAG_LEN: usize = 16;
42/// Every record carries exactly one padding delimiter octet (RFC 8188 §2);
43/// this adapter adds no further padding, so that octet is the whole cost.
44pub const PAD_DELIMITER_LEN: usize = 1;
45/// The last (here: only) record's delimiter. A user agent MUST discard a
46/// message whose final delimiter is anything else (RFC 8291 §4).
47const LAST_RECORD_DELIMITER: u8 = 0x02;
48
49/// The header a Web Push body always carries: the fixed part plus the
50/// 65-octet application-server public key that RFC 8291 §4 requires as the
51/// `keyid`. The 86 in "86-octet header" throughout the RFCs is this.
52pub const WEB_PUSH_HEADER_LEN: usize = HEADER_FIXED_LEN + PUBLIC_KEY_LEN;
53
54/// What a push service is required to accept as a whole request body
55/// (RFC 8030 §7.2, quoted again by RFC 8291 §4). Vendors are known to cap
56/// there and answer `413` above it, so it is the budget the default record
57/// size is derived from rather than a number to hope about.
58pub const MIN_SUPPORTED_BODY_LEN: usize = 4096;
59
60/// RFC 8188 §2.1: "Values smaller than 18 are invalid" — a record has to
61/// hold at least a delimiter and a tag.
62pub const MIN_RECORD_SIZE: u32 = 18;
63
64/// The record size this adapter uses unless a venture raises it.
65///
66/// It is **derived, not chosen**: the header sits outside the record budget,
67/// so a body that must fit [`MIN_SUPPORTED_BODY_LEN`] leaves
68/// `4096 - 86 = 4010` octets for the record. Feeding that through
69/// [`max_plaintext`] gives 3993 octets of plaintext, which is the number RFC
70/// 8291 §4 arrives at by the same arithmetic ("Absent header (86 octets),
71/// padding (minimum 1 octet), and expansion for `AEAD_AES_128_GCM` (16
72/// octets), this equates to, at most, 3993 octets of plaintext").
73///
74/// The folklore numbers — 4078, 4079, 3052 — come from setting `rs` to 4096
75/// and forgetting that the header is *added* to it, which produces a
76/// 4182-octet body that a service capping at 4096 rejects.
77pub const DEFAULT_RECORD_SIZE: u32 = {
78    #[allow(clippy::cast_possible_truncation)] // 4010 fits u32
79    let size = (MIN_SUPPORTED_BODY_LEN - WEB_PUSH_HEADER_LEN) as u32;
80    size
81};
82
83/// RFC 8188 §2.2 / §2.3: the two `info` strings, each terminated by a single
84/// zero octet that is part of the input and not a C string terminator.
85const CEK_INFO: &[u8] = b"Content-Encoding: aes128gcm\x00";
86const NONCE_INFO: &[u8] = b"Content-Encoding: nonce\x00";
87/// RFC 8291 §3.3: `key_info = "WebPush: info" || 0x00 || ua_public || as_public`.
88const KEY_INFO_PREFIX: &[u8] = b"WebPush: info\x00";
89
90/// How much plaintext fits in one record of `record_size` octets.
91///
92/// RFC 8188 §2 draws it directly: "content — any length up to rs-17 octets",
93/// because a valid record always contains at least a padding delimiter octet
94/// (1) and an authentication tag (16). The header is **not** part of this: it
95/// precedes the records, which is why [`DEFAULT_RECORD_SIZE`] subtracts it
96/// from the body budget first.
97#[must_use]
98pub const fn max_plaintext(record_size: u32) -> usize {
99    // `usize::try_from` is not const; u32 -> usize is lossless on both
100    // targets this workspace builds for (64-bit native and wasm32).
101    #[allow(clippy::cast_possible_truncation)]
102    let size = record_size as usize;
103    size.saturating_sub(GCM_TAG_LEN + PAD_DELIMITER_LEN)
104}
105
106/// Encryption refused the inputs. Nothing here carries key material: the
107/// `Display` of every variant is safe to log.
108#[derive(Debug, thiserror::Error)]
109pub enum EceError {
110    /// The subscription's `p256dh` is not an uncompressed P-256 point.
111    #[error("subscription p256dh is not a {PUBLIC_KEY_LEN}-byte uncompressed P-256 point: {0}")]
112    SubscriptionKey(String),
113    /// The subscription's `auth` is not the 16 octets RFC 8291 §3.2 requires.
114    #[error("subscription auth secret must be {AUTH_SECRET_LEN} bytes, got {0}")]
115    AuthSecret(usize),
116    /// The subscription's `auth` is not base64url at all.
117    #[error("subscription auth secret is not base64url")]
118    AuthSecretEncoding,
119    /// The application server's own private key is unusable.
120    #[error("application server key is not a valid P-256 private key")]
121    ServerKey,
122    /// A record size below RFC 8188's floor.
123    #[error("record size {0} is invalid: RFC 8188 requires at least {MIN_RECORD_SIZE}")]
124    RecordSize(u32),
125    /// The plaintext does not fit one record. Checked before any key is
126    /// derived and long before any request is built.
127    #[error(
128        "payload too large: {plaintext} bytes of plaintext exceeds the {limit}-byte \
129         limit for a {record_size}-byte record"
130    )]
131    PayloadTooLarge {
132        plaintext: usize,
133        limit: usize,
134        record_size: u32,
135    },
136    /// AES-128-GCM refused. Deliberately opaque — an AEAD failure detail is
137    /// never useful to a caller and can be useful to an attacker.
138    #[error("AES-128-GCM encryption failed")]
139    Aead,
140    /// The platform would not produce random bytes, so no salt and no
141    /// ephemeral key. Failing is the only safe answer.
142    #[error("could not draw random bytes: {0}")]
143    Random(String),
144}
145
146/// Decodes a subscription key: base64url or standard base64, padded or not.
147///
148/// Browsers hand the keys over as **unpadded base64url**, which is the only
149/// form RFC 8291 needs — but a subscription is routinely stored and shipped
150/// by something other than the browser that made it, and every other form
151/// turns up on the way back:
152///
153/// - **padded** (`=`), from a JSON round trip through a library that pads;
154/// - the **standard alphabet** (`+` and `/` where base64url has `-` and
155///   `_`), from any encoder that re-encodes the raw bytes without asking for
156///   the URL-safe alphabet — Python's `base64.b64encode`, PHP's
157///   `base64_encode`, `JSON.stringify` over a `Buffer`.
158///
159/// The two alphabets do not overlap, so accepting both is unambiguous: a
160/// value is decoded by exactly one of them. Refusing the standard alphabet
161/// meant a `p256dh` that happens to contain a `+` or a `/` — roughly one
162/// subscription in a hundred does not, at 65 random-looking octets — could
163/// never be pushed to, and would look like a corrupt subscription rather
164/// than an encoding the adapter declined to read.
165fn decode_base64url(value: &str) -> Option<Vec<u8>> {
166    URL_SAFE_NO_PAD
167        .decode(value)
168        .or_else(|_| URL_SAFE.decode(value))
169        .or_else(|_| STANDARD_NO_PAD.decode(value))
170        .or_else(|_| STANDARD.decode(value))
171        .ok()
172}
173
174/// The two keys a browser hands over with a subscription, validated.
175///
176/// Holding the parsed form means a malformed subscription is one
177/// [`EceError`] at the top of a send rather than a mystery `400` from the
178/// push service — or, worse, a body no browser can decrypt.
179#[derive(Clone)]
180pub struct SubscriptionKeys {
181    /// The user agent's P-256 public key, uncompressed, exactly as it goes
182    /// into `key_info`.
183    ua_public: [u8; PUBLIC_KEY_LEN],
184    /// The RFC 8291 §3.2 authentication secret — the shared secret the
185    /// payload is encrypted under, so it is cleared on drop (the workspace
186    /// manifest's `zeroize` convention, the same one `crates/kms`,
187    /// `crates/secrets` and `module-linkedin` follow).
188    auth: Zeroizing<[u8; AUTH_SECRET_LEN]>,
189}
190
191impl SubscriptionKeys {
192    /// Parses the base64url `p256dh` and `auth` of a
193    /// [`Recipient::WebPush`](cratefield_core::Recipient::WebPush).
194    ///
195    /// # Errors
196    ///
197    /// [`EceError::SubscriptionKey`] if `p256dh` is not base64url of an
198    /// uncompressed P-256 point that is actually on the curve;
199    /// [`EceError::AuthSecret`] if `auth` is not 16 octets.
200    pub fn parse(p256dh: &str, auth: &str) -> Result<Self, EceError> {
201        let p256dh = decode_base64url(p256dh)
202            .ok_or_else(|| EceError::SubscriptionKey("not base64url".to_owned()))?;
203        // The decoded secret is wrapped before anything else can hold it:
204        // wrapping only the copy inside `SubscriptionKeys` would leave this
205        // `Vec` to drop uncleared on both the success and the error path.
206        let auth = Zeroizing::new(decode_base64url(auth).ok_or(EceError::AuthSecretEncoding)?);
207        Self::from_bytes(&p256dh, &auth)
208    }
209
210    /// The same, from already-decoded bytes.
211    ///
212    /// # Errors
213    ///
214    /// As [`SubscriptionKeys::parse`].
215    pub fn from_bytes(p256dh: &[u8], auth: &[u8]) -> Result<Self, EceError> {
216        let ua_public: [u8; PUBLIC_KEY_LEN] = p256dh.try_into().map_err(|_| {
217            EceError::SubscriptionKey(format!("{} bytes, expected {PUBLIC_KEY_LEN}", p256dh.len()))
218        })?;
219        if ua_public[0] != 0x04 {
220            return Err(EceError::SubscriptionKey(
221                "not the uncompressed point form (no 0x04 prefix)".to_owned(),
222            ));
223        }
224        // RFC 8291 Security Considerations: "The user agent and application
225        // MUST verify that the public key they receive is on the P-256
226        // curve. Failure to validate a public key can allow an attacker to
227        // extract a private key." `from_sec1_bytes` is that check — it
228        // rejects the point at infinity, out-of-range coordinates, and
229        // anything off the curve.
230        PublicKey::from_sec1_bytes(&ua_public)
231            .map_err(|err| EceError::SubscriptionKey(err.to_string()))?;
232        if auth.len() != AUTH_SECRET_LEN {
233            return Err(EceError::AuthSecret(auth.len()));
234        }
235        // Copied *into* the cleared-on-drop buffer rather than built beside
236        // it: `[u8; 16]` is `Copy`, so `Zeroizing::new(array)` would leave
237        // the original array on the stack uncleared.
238        let mut secret = Zeroizing::new([0u8; AUTH_SECRET_LEN]);
239        secret.copy_from_slice(auth);
240        Ok(Self {
241            ua_public,
242            auth: secret,
243        })
244    }
245}
246
247impl std::fmt::Debug for SubscriptionKeys {
248    /// Never prints the keys: `auth` is the secret the payload is encrypted
249    /// under, and `ua_public` identifies the subscriber.
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        f.write_str("SubscriptionKeys { .. }")
252    }
253}
254
255/// What the RFC 8291 §3.4 key schedule produces and a send actually reads:
256/// the input keying material for the content coding, and the public key the
257/// header carries as its `keyid`.
258///
259/// **Only what a send reads.** An earlier revision also carried
260/// `ecdh_secret`, `prk_key` and `key_info`, purely so one step-by-step test
261/// could assert them against Appendix A — the `#[cfg_attr(not(test),
262/// allow(dead_code))]` admitted it — so every production send copied ~96
263/// octets of key material that a release build never looks at and (before
264/// the `zeroize` wrapping below) never cleared. Those assertions live on
265/// against [`ecdh_secret`] and [`key_info`], which are the same functions the
266/// send itself calls, so the test still pins production's code rather than
267/// its own restatement of it.
268struct Schedule {
269    ikm: Zeroizing<[u8; 32]>,
270    as_public: [u8; PUBLIC_KEY_LEN],
271}
272
273/// RFC 8188 §2.2/§2.3, the record's own keys.
274///
275/// The PRK they are expanded from is not kept, for the reason [`Schedule`]
276/// gives: it is a plain `HKDF-Extract` of values the tests already hold, so
277/// the vectors' published PRK is asserted straight off `Hkdf::extract` there.
278struct RecordKeys {
279    cek: Zeroizing<[u8; 16]>,
280    nonce: Zeroizing<[u8; 12]>,
281}
282
283/// RFC 8291 §3.1: the ECDH shared secret between the per-message
284/// application-server key and the subscription's `p256dh`.
285///
286/// `p256`'s own `SharedSecret` zeroizes on drop, so the copy taken out of it
287/// is wrapped as well — an unwrapped copy outlives the protection the
288/// library already provides, which is the worst of both.
289fn ecdh_secret(as_secret: &SecretKey, ua_public: &PublicKey) -> Zeroizing<[u8; 32]> {
290    let shared = p256::ecdh::diffie_hellman(as_secret.to_nonzero_scalar(), ua_public.as_affine());
291    let mut out = Zeroizing::new([0u8; 32]);
292    out.copy_from_slice(shared.raw_secret_bytes());
293    out
294}
295
296/// RFC 8291 §3.3: `key_info = "WebPush: info" || 0x00 || ua_public || as_public`.
297///
298/// Order matters and is not symmetric: user agent first, application server
299/// second. Swapping them yields a body that decrypts to garbage on every real
300/// browser while every round-trip test still passes, which is why Appendix
301/// A's `key_info` is asserted byte for byte against this function.
302fn key_info(ua_public: &[u8; PUBLIC_KEY_LEN], as_public: &[u8; PUBLIC_KEY_LEN]) -> Vec<u8> {
303    let mut info = Vec::with_capacity(KEY_INFO_PREFIX.len() + 2 * PUBLIC_KEY_LEN);
304    info.extend_from_slice(KEY_INFO_PREFIX);
305    info.extend_from_slice(ua_public);
306    info.extend_from_slice(as_public);
307    info
308}
309
310/// RFC 8291 §3.1–§3.3: ECDH, then combine with the authentication secret.
311fn schedule(keys: &SubscriptionKeys, as_private: &[u8; 32]) -> Result<Schedule, EceError> {
312    let as_secret = SecretKey::from_slice(as_private).map_err(|_| EceError::ServerKey)?;
313    let encoded = as_secret.public_key().to_sec1_point(false);
314    let mut as_public = [0u8; PUBLIC_KEY_LEN];
315    // An uncompressed P-256 point is always 65 bytes.
316    as_public.copy_from_slice(encoded.as_bytes());
317
318    // Validated in `SubscriptionKeys::from_bytes`, so this cannot fail.
319    let ua_public = PublicKey::from_sec1_bytes(&keys.ua_public)
320        .map_err(|err| EceError::SubscriptionKey(err.to_string()))?;
321    let ecdh = ecdh_secret(&as_secret, &ua_public);
322    let info = key_info(&keys.ua_public, &as_public);
323
324    // salt = the authentication secret, IKM = the ECDH secret (RFC 8291 §3.3).
325    let hkdf = Hkdf::<Sha256>::new(Some(keys.auth.as_slice()), ecdh.as_slice());
326    let mut ikm = Zeroizing::new([0u8; 32]);
327    // L = 32, well inside HKDF's 255*HashLen ceiling.
328    hkdf.expand(&info, ikm.as_mut_slice())
329        .expect("HKDF-Expand of 32 octets is always in range");
330
331    Ok(Schedule { ikm, as_public })
332}
333
334/// RFC 8188 §2.2/§2.3.
335fn record_keys(salt: &[u8; SALT_LEN], ikm: &[u8]) -> RecordKeys {
336    let hkdf = Hkdf::<Sha256>::new(Some(salt), ikm);
337    let mut cek = Zeroizing::new([0u8; 16]);
338    hkdf.expand(CEK_INFO, cek.as_mut_slice())
339        .expect("HKDF-Expand of 16 octets is always in range");
340    let mut nonce = Zeroizing::new([0u8; 12]);
341    hkdf.expand(NONCE_INFO, nonce.as_mut_slice())
342        .expect("HKDF-Expand of 12 octets is always in range");
343    RecordKeys { cek, nonce }
344}
345
346/// One RFC 8188 body: the §2.1 header followed by a single record.
347///
348/// A single record is what RFC 8291 §4 requires of an application server, so
349/// the record sequence number is always zero and the nonce needs no XOR.
350fn encode(
351    ikm: &[u8],
352    salt: &[u8; SALT_LEN],
353    keyid: &[u8],
354    record_size: u32,
355    plaintext: &[u8],
356) -> Result<Vec<u8>, EceError> {
357    if record_size < MIN_RECORD_SIZE {
358        return Err(EceError::RecordSize(record_size));
359    }
360    let limit = max_plaintext(record_size);
361    if plaintext.len() > limit {
362        return Err(EceError::PayloadTooLarge {
363            plaintext: plaintext.len(),
364            limit,
365            record_size,
366        });
367    }
368    let idlen = u8::try_from(keyid.len()).map_err(|_| EceError::ServerKey)?;
369
370    let RecordKeys { cek, nonce } = record_keys(salt, ikm);
371
372    let mut padded = Vec::with_capacity(plaintext.len() + PAD_DELIMITER_LEN);
373    padded.extend_from_slice(plaintext);
374    padded.push(LAST_RECORD_DELIMITER);
375
376    // `aes-gcm` is built with its `zeroize` feature (see the workspace
377    // manifest), so the cipher clears its expanded round keys on drop; the
378    // `Key` here is a statement temporary and goes with it.
379    let cipher = Aes128Gcm::new(&Key::<Aes128Gcm>::from(*cek));
380    let ciphertext = cipher
381        .encrypt(&Nonce::<Aes128Gcm>::from(*nonce), padded.as_slice())
382        .map_err(|_| EceError::Aead)?;
383
384    let mut body = Vec::with_capacity(HEADER_FIXED_LEN + keyid.len() + ciphertext.len());
385    body.extend_from_slice(salt);
386    body.extend_from_slice(&record_size.to_be_bytes());
387    body.push(idlen);
388    body.extend_from_slice(keyid);
389    body.extend_from_slice(&ciphertext);
390    Ok(body)
391}
392
393/// The RFC 8291 sender: a subscription's keys and a record size in, one
394/// `Content-Encoding: aes128gcm` body out.
395#[derive(Debug, Clone, Copy)]
396pub struct Ece {
397    record_size: u32,
398}
399
400impl Default for Ece {
401    fn default() -> Self {
402        Self {
403            record_size: DEFAULT_RECORD_SIZE,
404        }
405    }
406}
407
408impl Ece {
409    /// An encoder with an explicit record size.
410    ///
411    /// Raising it past [`DEFAULT_RECORD_SIZE`] is a deliberate bet that the
412    /// push services a venture's subscribers use accept a body larger than
413    /// the [`MIN_SUPPORTED_BODY_LEN`] they are required to.
414    ///
415    /// # Errors
416    ///
417    /// [`EceError::RecordSize`] below RFC 8188's floor of 18.
418    pub fn new(record_size: u32) -> Result<Self, EceError> {
419        if record_size < MIN_RECORD_SIZE {
420            return Err(EceError::RecordSize(record_size));
421        }
422        Ok(Self { record_size })
423    }
424
425    /// The `rs` this encoder writes into the header.
426    #[must_use]
427    pub const fn record_size(self) -> u32 {
428        self.record_size
429    }
430
431    /// The largest plaintext this encoder accepts, computed from
432    /// [`Ece::record_size`] — see [`max_plaintext`].
433    #[must_use]
434    pub const fn max_plaintext(self) -> usize {
435        max_plaintext(self.record_size)
436    }
437
438    /// The whole body length a plaintext of `len` produces, header included.
439    /// The check a vendor's body cap actually applies.
440    #[must_use]
441    pub const fn body_len(len: usize) -> usize {
442        WEB_PUSH_HEADER_LEN + len + PAD_DELIMITER_LEN + GCM_TAG_LEN
443    }
444
445    /// Encrypts `plaintext` for `keys`, drawing a fresh salt and a fresh
446    /// application-server key pair for this one message — which is what
447    /// makes reusing a `(CEK, nonce)` pair impossible.
448    ///
449    /// # Errors
450    ///
451    /// [`EceError::PayloadTooLarge`] before any key is derived,
452    /// [`EceError::Random`] if the platform has no randomness, and the
453    /// key errors of [`Ece::seal_with`].
454    pub fn seal(self, keys: &SubscriptionKeys, plaintext: &[u8]) -> Result<Vec<u8>, EceError> {
455        let limit = self.max_plaintext();
456        if plaintext.len() > limit {
457            return Err(EceError::PayloadTooLarge {
458                plaintext: plaintext.len(),
459                limit,
460                record_size: self.record_size,
461            });
462        }
463        let mut salt = [0u8; SALT_LEN];
464        getrandom::fill(&mut salt).map_err(|err| EceError::Random(err.to_string()))?;
465        let as_private = random_scalar()?;
466        self.seal_with(keys, plaintext, &salt, &as_private)
467    }
468
469    /// The same, with the salt and the application-server private key
470    /// supplied.
471    ///
472    /// This is the seam the RFC 8291 Appendix A vector is reproduced
473    /// through: the vector fixes both, and nothing else about the encoding
474    /// is a choice. **Production code must use [`Ece::seal`]** — a reused
475    /// salt with a reused key repeats a `(CEK, nonce)` pair, which loses the
476    /// GCM authentication key.
477    ///
478    /// # Errors
479    ///
480    /// [`EceError::PayloadTooLarge`] if the plaintext does not fit one
481    /// record, [`EceError::ServerKey`] if `as_private` is not a P-256
482    /// scalar, [`EceError::SubscriptionKey`] if the subscription's key is
483    /// not on the curve, [`EceError::Aead`] if AES-GCM refuses.
484    pub fn seal_with(
485        self,
486        keys: &SubscriptionKeys,
487        plaintext: &[u8],
488        salt: &[u8; SALT_LEN],
489        as_private: &[u8; 32],
490    ) -> Result<Vec<u8>, EceError> {
491        let schedule = schedule(keys, as_private)?;
492        encode(
493            schedule.ikm.as_slice(),
494            salt,
495            &schedule.as_public,
496            self.record_size,
497            plaintext,
498        )
499    }
500}
501
502/// A uniformly random P-256 private scalar, cleared on drop.
503///
504/// Drawn as 32 raw octets and offered to [`SecretKey::from_slice`], which
505/// rejects zero and anything at or above the curve order. Both rejections
506/// are astronomically unlikely (roughly 2^-32 for the order, 2^-256 for
507/// zero), so the retry is bookkeeping rather than a hot path — but retrying
508/// is the only correct answer, since clamping or reducing the value would
509/// bias the key.
510fn random_scalar() -> Result<Zeroizing<[u8; 32]>, EceError> {
511    const ATTEMPTS: usize = 8;
512    let mut bytes = Zeroizing::new([0u8; 32]);
513    for _ in 0..ATTEMPTS {
514        getrandom::fill(bytes.as_mut_slice()).map_err(|err| EceError::Random(err.to_string()))?;
515        if SecretKey::from_slice(bytes.as_slice()).is_ok() {
516            return Ok(bytes);
517        }
518    }
519    Err(EceError::Random(
520        "no valid P-256 scalar in 8 draws".to_owned(),
521    ))
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    /// RFC 8291 Appendix A, the inputs. The user agent's half comes from
529    /// `cratefield-testing`, which is where the workspace keeps these:
530    /// four crates used to hold their own copy of a key pair that only
531    /// works if every copy agrees to the character.
532    use cratefield_testing::vectors::{
533        RFC8291_AUTH_SECRET as AUTH_SECRET, RFC8291_PLAINTEXT as PLAINTEXT,
534        RFC8291_UA_PUBLIC as UA_PUBLIC,
535    };
536    const SALT: &str = "DGv6ra1nlYgDCS1FRnbzlw";
537    const AS_PRIVATE: &str = "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw";
538    const AS_PUBLIC: &str =
539        "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8";
540
541    /// RFC 8291 Appendix A, the intermediate values.
542    const ECDH_SECRET: &str = "kyrL1jIIOHEzg3sM2ZWRHDRB62YACZhhSlknJ672kSs";
543    const PRK_KEY: &str = "Snr3JMxaHVDXHWJn5wdC52WjpCtd2EIEGBykDcZW32k";
544    const KEY_INFO: &str = "V2ViUHVzaDogaW5mbwAEJXGyvs3942BVGq8e0PTNNmwRzr5VX4m8t7GGpTM5FzFo7OL\
545                            r4BhZe9MEebhuPI-OztV3ylkYfpJGmQ22ggCLDgT-M_SrDepxkU21WCP3O1SUj0EwbZI\
546                            HMtu5pZpTKGSCIA5Zent7wmC6HCJ5mFgJkuk5cwAvMBKiiujwa7t45ewP";
547    const IKM: &str = "S4lYMb_L0FxCeq0WhDx813KgSYqU26kOyzWUdsXYyrg";
548    const PRK: &str = "09_eUZGrsvxChDCGRCdkLiDXrReGOEVeSCdCcPBSJSc";
549    const CEK: &str = "oIhVW04MRdy2XN9CiKLxTg";
550    const NONCE: &str = "4h_95klXJ5E_qnoN";
551    const HEADER: &str = "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZ\
552                          IIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8";
553    const CIPHERTEXT: &str =
554        "8pfeW0KbunFT06SuDKoJH9Ql87S1QUrdirN6GcG7sFz1y1sqLgVi1VhjVkHsUoEsbI_0LpXMuGvnzQ";
555
556    fn b64(value: &str) -> Vec<u8> {
557        URL_SAFE_NO_PAD.decode(value).expect("base64url")
558    }
559
560    fn fixed<const N: usize>(value: &str) -> [u8; N] {
561        b64(value).try_into().expect("fixed-size vector value")
562    }
563
564    fn vector_keys() -> SubscriptionKeys {
565        SubscriptionKeys::parse(UA_PUBLIC, AUTH_SECRET).expect("the RFC's own subscription")
566    }
567
568    /// Every published intermediate, in the order RFC 8291 §3.4 derives
569    /// them. Asserting the whole chain rather than only the body means a
570    /// break names the step it happened in — an ECDH that agrees but a
571    /// `key_info` assembled in the wrong order look identical from the
572    /// outside.
573    ///
574    /// Each step is read off the function the *send* calls
575    /// ([`ecdh_secret`], [`key_info`], [`schedule`], [`record_keys`]) rather
576    /// than off fields carried through a send for the test's benefit: the
577    /// assertions pin production code, and a release build carries none of
578    /// these values (see [`Schedule`]). The two PRKs are plain
579    /// `HKDF-Extract` outputs that no code here shapes, so they come
580    /// straight from `Hkdf`.
581    #[test]
582    fn the_rfc8291_key_schedule_matches_appendix_a_step_by_step() {
583        let keys = vector_keys();
584        let as_private: [u8; 32] = fixed(AS_PRIVATE);
585        let schedule = schedule(&keys, &as_private).expect("the RFC's own keys");
586        assert_eq!(schedule.as_public.as_slice(), b64(AS_PUBLIC), "as_public");
587
588        let as_secret = SecretKey::from_slice(&as_private).expect("the RFC's own key");
589        let ua_public = PublicKey::from_sec1_bytes(&keys.ua_public).expect("on the curve");
590        let ecdh = ecdh_secret(&as_secret, &ua_public);
591        assert_eq!(ecdh.as_slice(), b64(ECDH_SECRET), "ecdh");
592        assert_eq!(
593            key_info(&keys.ua_public, &schedule.as_public),
594            b64(KEY_INFO),
595            "key_info"
596        );
597        let (prk_key, _) = Hkdf::<Sha256>::extract(Some(keys.auth.as_slice()), ecdh.as_slice());
598        assert_eq!(prk_key.as_slice(), b64(PRK_KEY), "PRK_key");
599        assert_eq!(schedule.ikm.as_slice(), b64(IKM), "IKM");
600
601        let salt: [u8; SALT_LEN] = fixed(SALT);
602        let (prk, _) = Hkdf::<Sha256>::extract(Some(salt.as_slice()), schedule.ikm.as_slice());
603        assert_eq!(prk.as_slice(), b64(PRK), "PRK");
604        let record = record_keys(&salt, schedule.ikm.as_slice());
605        assert_eq!(record.cek.as_slice(), b64(CEK), "CEK");
606        assert_eq!(record.nonce.as_slice(), b64(NONCE), "NONCE");
607    }
608
609    /// The production key schedule carries the two values a send reads and
610    /// nothing else.
611    ///
612    /// A size assertion rather than prose, because the thing that went wrong
613    /// before was silent: three extra fields of key material rode every send
614    /// for a test's benefit, admitted only by an `allow(dead_code)`. Adding
615    /// a field back — for a test, for a log line, for "we might need it" —
616    /// fails here.
617    #[test]
618    fn the_key_schedule_carries_no_test_only_key_material() {
619        assert_eq!(
620            size_of::<Schedule>(),
621            32 + PUBLIC_KEY_LEN,
622            "Schedule is the 32-octet IKM and the 65-octet public key, nothing else"
623        );
624        assert_eq!(
625            size_of::<RecordKeys>(),
626            16 + 12,
627            "RecordKeys is the CEK and the nonce, nothing else"
628        );
629    }
630
631    /// The header and the ciphertext are published separately in Appendix A,
632    /// so each half of the body is pinned on its own.
633    #[test]
634    fn the_body_halves_match_appendix_a() {
635        let body = Ece::new(4096)
636            .expect("4096 is a legal record size")
637            .seal_with(
638                &vector_keys(),
639                PLAINTEXT.as_bytes(),
640                &fixed(SALT),
641                &fixed(AS_PRIVATE),
642            )
643            .expect("the RFC's own inputs");
644
645        assert_eq!(&body[..WEB_PUSH_HEADER_LEN], b64(HEADER), "86-octet header");
646        assert_eq!(&body[WEB_PUSH_HEADER_LEN..], b64(CIPHERTEXT), "ciphertext");
647    }
648
649    /// The record-size field really is the `rs` argument, in network byte
650    /// order, at the offset RFC 8188 §2.1 puts it.
651    #[test]
652    fn the_header_carries_the_record_size_and_the_key_id() {
653        let body = Ece::default()
654            .seal_with(&vector_keys(), b"x", &fixed(SALT), &fixed(AS_PRIVATE))
655            .expect("valid");
656        let rs = u32::from_be_bytes(body[SALT_LEN..SALT_LEN + 4].try_into().unwrap());
657        assert_eq!(rs, DEFAULT_RECORD_SIZE);
658        assert_eq!(rs, 4010, "4096 body budget minus the 86-octet header");
659        assert_eq!(usize::from(body[SALT_LEN + 4]), PUBLIC_KEY_LEN, "idlen");
660        assert_eq!(&body[HEADER_FIXED_LEN..WEB_PUSH_HEADER_LEN], b64(AS_PUBLIC));
661    }
662
663    /// RFC 8188 §3.1: the content coding on its own, with an empty `keyid`
664    /// and the input keying material handed over directly. Nothing in this
665    /// vector touches ECDH, so it isolates the half RFC 8291 builds on.
666    #[test]
667    fn the_rfc8188_content_coding_matches_the_section_3_1_vector() {
668        let ikm = b64("yqdlZ-tYemfogSmv7Ws5PQ");
669        let salt: [u8; SALT_LEN] = fixed("I1BsxtFttlv3u_Oo94xnmw");
670
671        let (prk, _) = Hkdf::<Sha256>::extract(Some(salt.as_slice()), &ikm);
672        assert_eq!(
673            prk.as_slice(),
674            b64("zyeH5phsIsgUyd4oiSEIy35x-gIi4aM7y0hCF8mwn9g"),
675            "PRK"
676        );
677        let keys = record_keys(&salt, &ikm);
678        assert_eq!(keys.cek.as_slice(), b64("_wniytB-ofscZDh4tbSjHw"), "CEK");
679        assert_eq!(keys.nonce.as_slice(), b64("Bcs8gkIRKLI8GeI8"), "NONCE");
680
681        let body = encode(&ikm, &salt, b"", 4096, b"I am the walrus").expect("valid");
682        assert_eq!(
683            body,
684            b64("I1BsxtFttlv3u_Oo94xnmwAAEAAA-NAVub2qFgBEuQKRapoZu-IxkIva3MEB1PD-ly8Thjg")
685        );
686        // The body is 53 octets: the RFC's prose ("shown here using 71
687        // base64url characters") agrees, and 71 unpadded base64url
688        // characters are 53 octets. The `Content-Length: 54` printed above
689        // the example is off by one, as is RFC 8291 §5's `Content-Length:
690        // 145` for a 144-octet body. The base64url is the normative part
691        // and it matches byte for byte.
692        assert_eq!(body.len(), 53);
693        // "unencrypted data = SSBhbSB0aGUgd2FscnVzAg" — the plaintext with
694        // the 0x02 delimiter appended, which is what was encrypted.
695        assert_eq!(b64("SSBhbSB0aGUgd2FscnVzAg"), b"I am the walrus\x02");
696    }
697
698    /// The limit is arithmetic on the record size, not a remembered number.
699    #[test]
700    fn the_plaintext_limit_is_computed_from_the_record_size() {
701        assert_eq!(max_plaintext(4096), 4096 - 16 - 1);
702        assert_eq!(max_plaintext(DEFAULT_RECORD_SIZE), 3993);
703        assert_eq!(max_plaintext(MIN_RECORD_SIZE), 1);
704        assert_eq!(max_plaintext(0), 0, "saturates rather than wrapping");
705        assert_eq!(Ece::default().max_plaintext(), 3993);
706
707        // 3993 is exactly RFC 8291 §4's own number, and it is the number
708        // that makes the whole body fit the 4096 octets RFC 8030 §7.2
709        // requires a push service to accept.
710        assert_eq!(Ece::body_len(3993), MIN_SUPPORTED_BODY_LEN);
711        assert_eq!(Ece::body_len(3994), MIN_SUPPORTED_BODY_LEN + 1);
712    }
713
714    /// The computed limit is the limit the encoder actually enforces, and
715    /// the body it produces at the limit is exactly the promised size.
716    #[test]
717    fn a_body_at_the_limit_is_exactly_4096_octets_and_one_over_is_refused() {
718        let ece = Ece::default();
719        let keys = vector_keys();
720        let salt: [u8; SALT_LEN] = fixed(SALT);
721        let as_private: [u8; 32] = fixed(AS_PRIVATE);
722
723        let at_limit = vec![b'a'; ece.max_plaintext()];
724        let body = ece
725            .seal_with(&keys, &at_limit, &salt, &as_private)
726            .expect("the limit is inclusive");
727        assert_eq!(body.len(), MIN_SUPPORTED_BODY_LEN);
728        assert_eq!(body.len(), WEB_PUSH_HEADER_LEN + at_limit.len() + 17);
729
730        let one_over = vec![b'a'; ece.max_plaintext() + 1];
731        let error = ece
732            .seal_with(&keys, &one_over, &salt, &as_private)
733            .unwrap_err();
734        assert!(
735            matches!(error, EceError::PayloadTooLarge { plaintext, limit, .. }
736                if plaintext == 3994 && limit == 3993),
737            "{error}"
738        );
739        assert!(error.to_string().contains("payload too large"), "{error}");
740
741        // A raised record size raises the limit with it, by the same
742        // arithmetic — nothing is hardcoded.
743        let wide = Ece::new(8192).expect("valid");
744        assert_eq!(wide.max_plaintext(), 8175);
745        assert!(wide.seal_with(&keys, &one_over, &salt, &as_private).is_ok());
746    }
747
748    #[test]
749    fn a_record_size_below_the_rfc_floor_is_refused() {
750        assert!(matches!(Ece::new(17), Err(EceError::RecordSize(17))));
751        assert!(Ece::new(MIN_RECORD_SIZE).is_ok());
752    }
753
754    #[test]
755    fn a_malformed_subscription_is_refused_by_name() {
756        // Not base64url at all.
757        assert!(matches!(
758            SubscriptionKeys::parse("!!!!", AUTH_SECRET),
759            Err(EceError::SubscriptionKey(_))
760        ));
761        // A compressed point: right curve, wrong form, and `key_info` would
762        // silently disagree with the browser's.
763        let compressed = URL_SAFE_NO_PAD.encode([0x02u8; 33]);
764        assert!(matches!(
765            SubscriptionKeys::parse(&compressed, AUTH_SECRET),
766            Err(EceError::SubscriptionKey(_))
767        ));
768        // Right length and prefix, not on the curve.
769        let mut off_curve = b64(UA_PUBLIC);
770        off_curve[64] ^= 0x01;
771        assert!(matches!(
772            SubscriptionKeys::parse(&URL_SAFE_NO_PAD.encode(&off_curve), AUTH_SECRET),
773            Err(EceError::SubscriptionKey(_))
774        ));
775        // A 12-byte auth secret is not a 16-byte one.
776        assert!(matches!(
777            SubscriptionKeys::parse(UA_PUBLIC, &URL_SAFE_NO_PAD.encode([0u8; 12])),
778            Err(EceError::AuthSecret(12))
779        ));
780        // Padded base64url is accepted: the same keys, after a round trip
781        // through a library that pads.
782        assert!(
783            SubscriptionKeys::parse(
784                &URL_SAFE.encode(b64(UA_PUBLIC)),
785                &URL_SAFE.encode(b64(AUTH_SECRET))
786            )
787            .is_ok()
788        );
789    }
790
791    #[test]
792    fn the_subscription_keys_never_print_themselves() {
793        let printed = format!("{:?}", vector_keys());
794        assert_eq!(printed, "SubscriptionKeys { .. }");
795    }
796
797    /// Two calls to `seal` must not agree on anything: a repeated salt with
798    /// a repeated ephemeral key repeats the (CEK, nonce) pair, which is the
799    /// one thing AES-GCM cannot survive.
800    #[test]
801    fn seal_draws_a_fresh_salt_and_key_every_time() {
802        let keys = vector_keys();
803        let first = Ece::default()
804            .seal(&keys, b"same plaintext")
805            .expect("sealed");
806        let second = Ece::default()
807            .seal(&keys, b"same plaintext")
808            .expect("sealed");
809        assert_ne!(first[..SALT_LEN], second[..SALT_LEN], "salt");
810        assert_ne!(
811            first[HEADER_FIXED_LEN..WEB_PUSH_HEADER_LEN],
812            second[HEADER_FIXED_LEN..WEB_PUSH_HEADER_LEN],
813            "ephemeral public key"
814        );
815        assert_ne!(first, second);
816        assert_eq!(first.len(), second.len());
817    }
818
819    #[test]
820    fn an_oversize_payload_is_refused_before_any_randomness_is_drawn() {
821        let ece = Ece::default();
822        let error = ece
823            .seal(&vector_keys(), &vec![b'a'; ece.max_plaintext() + 1])
824            .unwrap_err();
825        assert!(matches!(error, EceError::PayloadTooLarge { .. }), "{error}");
826    }
827
828    #[test]
829    fn a_random_scalar_is_a_usable_private_key() {
830        let scalar = random_scalar().expect("randomness");
831        assert!(SecretKey::from_slice(scalar.as_slice()).is_ok());
832        assert_ne!(scalar, random_scalar().expect("randomness"));
833    }
834
835    /// A subscription that came back through an encoder using the
836    /// **standard** base64 alphabet is the same subscription.
837    ///
838    /// The `p256dh` below contains both `+` and `/` in that alphabet, which
839    /// is exactly the value that could never be pushed to while only the
840    /// URL-safe alphabet was accepted — and "never" meant permanently, since
841    /// only the browser can re-subscribe.
842    #[test]
843    fn a_subscription_re_encoded_with_the_standard_alphabet_is_accepted() {
844        let standard = STANDARD_NO_PAD.encode(b64(UA_PUBLIC));
845        assert!(standard.contains('+'), "{standard}");
846        assert!(standard.contains('/'), "{standard}");
847
848        // The same keys, three encodings, one body: whichever alphabet the
849        // subscription arrived in, the schedule is identical.
850        let canonical = vector_keys();
851        for p256dh in [standard.clone(), STANDARD.encode(b64(UA_PUBLIC))] {
852            let keys = SubscriptionKeys::parse(&p256dh, AUTH_SECRET)
853                .unwrap_or_else(|err| panic!("{p256dh}: {err}"));
854            let salt: [u8; SALT_LEN] = fixed(SALT);
855            let as_private: [u8; 32] = fixed(AS_PRIVATE);
856            assert_eq!(
857                Ece::default()
858                    .seal_with(&keys, b"x", &salt, &as_private)
859                    .ok(),
860                Ece::default()
861                    .seal_with(&canonical, b"x", &salt, &as_private)
862                    .ok(),
863                "{p256dh}"
864            );
865        }
866
867        // The `auth` secret travels the same road.
868        assert!(
869            SubscriptionKeys::parse(UA_PUBLIC, &STANDARD_NO_PAD.encode(b64(AUTH_SECRET))).is_ok()
870        );
871    }
872}