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}