Skip to main content

feather_reader/oauth/
crypto.rs

1//! Application-layer at-rest encryption for the OAuth secrets.
2//!
3//! Everything security-sensitive that is persisted — the atproto OAuth
4//! access/refresh tokens, the per-DID DPoP key material, the short-lived
5//! per-auth-request state, and the confidential-client signing JWK — is
6//! AEAD-encrypted *before* it touches the SQLite volume (or, for the JWK, the
7//! disk file). The key comes only from the process environment, never from the
8//! volume, so a raw volume/snapshot read is useless without the running
9//! process's environment.
10//!
11//! Construction throughout: AES-256-GCM, random 96-bit nonce per record,
12//! 128-bit auth tag, serialized as a self-describing string. There are **two
13//! formats**, differing only in what is authenticated alongside the ciphertext:
14//!
15//! ```text
16//! enc.v1.gcm.<b64url(nonce)>.<b64url(tag)>.<b64url(ct)>    AAD = empty
17//! enc.v2.gcm.<b64url(nonce)>.<b64url(tag)>.<b64url(ct)>    AAD = binding context
18//! ```
19//!
20//! **v1 is byte-identical to the Node sidecar's `crypto.ts`**, and must stay
21//! that way: it is what lets this implementation read what the sidecar wrote,
22//! which is what makes the cutover reversible. The cross-implementation test at
23//! the bottom of this file pins that against ciphertext from the real sidecar.
24//! It is used for the signing-key file, the one artefact a rollback must read.
25//!
26//! **v2 binds a record to where it lives.** The AAD names the row and column, so
27//! a ciphertext lifted into a different row fails to authenticate rather than
28//! decrypting into the wrong place. Without it, anything able to write the
29//! database could graft one login flow's DPoP key or issuer onto another flow's
30//! state row. The OAuth state and session tables are new, so the bound form can
31//! be *required* there from the start — and it is: [`Aead::decrypt_bound`]
32//! rejects a v1 token, because accepting one would make the binding opt-out.
33//!
34//! The prefix also lets [`Aead::maybe_decrypt`] do migrate-on-read: a stored
35//! value with NEITHER prefix is treated as legacy plaintext and returned as-is,
36//! so a file written before encryption was enabled keeps working and is
37//! transparently re-encrypted on the next write.
38
39use anyhow::{bail, Context as _, Result};
40use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD};
41use base64::Engine;
42use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN};
43use ring::digest::SHA256;
44
45/// Unbound records: AAD is empty. **The sidecar-compatible format** — used for
46/// the signing-key file, where a rollback to the Node sidecar must still be able
47/// to read what we wrote.
48const PREFIX_V1: &str = "enc.v1.gcm.";
49
50/// Bound records: AAD is a binding context naming the row and column the
51/// ciphertext belongs to, so it authenticates nowhere else.
52///
53/// Introduced for the OAuth state and session tables. Those are new, so there
54/// are no legacy rows to tolerate and the bound form can be *required* from the
55/// start — which is the whole point. Accepting a [`PREFIX_V1`] token where a
56/// bound one is expected would make the binding opt-out: anything able to write
57/// the row would simply store the unbound form instead.
58const PREFIX_V2: &str = "enc.v2.gcm.";
59
60/// AES-256 key length.
61const KEY_LEN: usize = 32;
62
63/// AES-GCM authentication tag length.
64const TAG_LEN: usize = 16;
65
66/// Domain separator for the passphrase path. Part of the on-disk contract —
67/// changing it silently invalidates every stored ciphertext.
68const PASSPHRASE_DOMAIN: &str = "featherreader-sidecar-enc:v1:";
69
70/// If `raw` is EXACTLY a 32-byte key encoded as hex or canonical
71/// base64/base64url, return those bytes; otherwise `None`.
72///
73/// Base64 decoding is lenient in many implementations (the sidecar's `Buffer`
74/// silently drops invalid characters), so a passphrase that merely *happens* to
75/// be base64-shaped could decode to 32 bytes and be mistaken for a key. The
76/// guard is a round-trip: the decoded bytes must RE-ENCODE to exactly the input,
77/// which is only true if it really was a canonical 32-byte key.
78fn decode_exact_key(raw: &str) -> Option<[u8; KEY_LEN]> {
79    let mut out = [0u8; KEY_LEN];
80
81    if raw.len() == KEY_LEN * 2 && raw.bytes().all(|b| b.is_ascii_hexdigit()) {
82        for (i, byte) in out.iter_mut().enumerate() {
83            *byte = u8::from_str_radix(&raw[i * 2..i * 2 + 2], 16).ok()?;
84        }
85        return Some(out);
86    }
87
88    // Standard base64 (padded or not) and base64url are the three canonical
89    // spellings the sidecar accepts. Requiring a clean round-trip through one of
90    // them is equivalent to its `raw === std || raw === std-no-pad || raw === url`
91    // check, without inheriting the lenient decode.
92    for engine in [&STANDARD, &STANDARD_NO_PAD, &URL_SAFE_NO_PAD] {
93        if let Ok(bytes) = engine.decode(raw) {
94            if bytes.len() == KEY_LEN && engine.encode(&bytes) == raw {
95                out.copy_from_slice(&bytes);
96                return Some(out);
97            }
98        }
99    }
100    None
101}
102
103/// Derive the 32-byte AES key from the raw configured value.
104///
105/// A value that is EXACTLY a 32-byte key (hex or canonical base64/base64url) is
106/// used directly; anything else is treated as a passphrase and hashed with a
107/// domain-separated SHA-256. Deterministic — the same input always maps to the
108/// same key, so restarts and rolling deploys decrypt existing rows.
109pub fn derive_key(raw: &str) -> [u8; KEY_LEN] {
110    if let Some(exact) = decode_exact_key(raw) {
111        return exact;
112    }
113    let mut ctx = ring::digest::Context::new(&SHA256);
114    ctx.update(PASSPHRASE_DOMAIN.as_bytes());
115    ctx.update(raw.as_bytes());
116    let mut out = [0u8; KEY_LEN];
117    out.copy_from_slice(ctx.finish().as_ref());
118    out
119}
120
121/// A bound encryptor/decryptor holding the derived key.
122pub struct Aead {
123    key: LessSafeKey,
124}
125
126impl Aead {
127    /// Build a codec from the raw configured key value (see [`derive_key`]).
128    pub fn new(raw_key: &str) -> Result<Self> {
129        let key = UnboundKey::new(&AES_256_GCM, &derive_key(raw_key))
130            .map_err(|_| anyhow::anyhow!("failed to build an AES-256-GCM key"))?;
131        Ok(Self {
132            key: LessSafeKey::new(key),
133        })
134    }
135
136    /// True if `value` is one of our ciphertext tokens, in EITHER format (vs.
137    /// legacy plaintext).
138    ///
139    /// Recognising both matters: if this matched only v1, then
140    /// [`Aead::maybe_decrypt`] would classify a bound token as legacy plaintext
141    /// and hand the raw ciphertext back to the caller as though it were the
142    /// value.
143    pub fn is_ciphertext(value: &str) -> bool {
144        value.starts_with(PREFIX_V1) || value.starts_with(PREFIX_V2)
145    }
146
147    /// Encrypt into an unbound `enc.v1.gcm.…` token (AAD empty).
148    ///
149    /// **Panics if the OS CSPRNG is unavailable.** This is a deliberate
150    /// divergence from `crate::new_session_id`, which falls back to a weak
151    /// entropy mix: a guessable session id is bad, but a REPEATED GCM nonce is
152    /// catastrophic — it leaks the XOR of two plaintexts and enables tag
153    /// forgery. There is no safe degraded mode here, so this fails loudly.
154    pub fn encrypt(&self, plaintext: &str) -> String {
155        self.seal(plaintext, PREFIX_V1, b"")
156    }
157
158    /// Encrypt into a **bound** `enc.v2.gcm.…` token.
159    ///
160    /// `aad` names the row and column this ciphertext belongs to, so it
161    /// authenticates nowhere else — moving it to another row makes it
162    /// undecryptable rather than silently valid.
163    pub fn encrypt_bound(&self, plaintext: &str, aad: &[u8]) -> String {
164        self.seal(plaintext, PREFIX_V2, aad)
165    }
166
167    fn seal(&self, plaintext: &str, prefix: &str, aad: &[u8]) -> String {
168        let mut nonce = [0u8; NONCE_LEN];
169        getrandom::fill(&mut nonce)
170            .expect("OS CSPRNG unavailable; refusing to encrypt with a non-random GCM nonce");
171
172        let mut in_out = plaintext.as_bytes().to_vec();
173        let tag = self
174            .key
175            .seal_in_place_separate_tag(
176                Nonce::assume_unique_for_key(nonce),
177                Aad::from(aad),
178                &mut in_out,
179            )
180            .expect("AES-256-GCM sealing cannot fail for a well-formed key and nonce");
181
182        format!(
183            "{prefix}{}.{}.{}",
184            URL_SAFE_NO_PAD.encode(nonce),
185            URL_SAFE_NO_PAD.encode(tag.as_ref()),
186            URL_SAFE_NO_PAD.encode(&in_out),
187        )
188    }
189
190    /// Decrypt an UNBOUND `enc.v1.gcm.…` token. A bound token is rejected here:
191    /// reading one through this path would drop the binding check silently.
192    pub fn decrypt(&self, token: &str) -> Result<String> {
193        self.open(token, PREFIX_V1, b"")
194    }
195
196    /// Decrypt a **bound** `enc.v2.gcm.…` token, requiring `aad` to match the
197    /// binding it was sealed under.
198    ///
199    /// An unbound v1 token is REJECTED rather than accepted-without-checking:
200    /// otherwise the binding is opt-out and anything able to write the row would
201    /// simply store the unbound form.
202    pub fn decrypt_bound(&self, token: &str, aad: &[u8]) -> Result<String> {
203        self.open(token, PREFIX_V2, aad)
204    }
205
206    fn open(&self, token: &str, prefix: &str, aad: &[u8]) -> Result<String> {
207        let rest = token
208            .strip_prefix(prefix)
209            .with_context(|| format!("not an {}ciphertext token", &prefix[..7]))?;
210
211        let parts: Vec<&str> = rest.split('.').collect();
212        if parts.len() != 3 {
213            bail!(
214                "malformed ciphertext token: expected 3 segments, got {}",
215                parts.len()
216            );
217        }
218        let nonce = URL_SAFE_NO_PAD
219            .decode(parts[0])
220            .context("bad nonce encoding")?;
221        let tag = URL_SAFE_NO_PAD
222            .decode(parts[1])
223            .context("bad tag encoding")?;
224        let ciphertext = URL_SAFE_NO_PAD
225            .decode(parts[2])
226            .context("bad ciphertext encoding")?;
227
228        // Check lengths before handing anything to the AEAD, so a truncated
229        // token is a clear error rather than an opaque decrypt failure.
230        if nonce.len() != NONCE_LEN {
231            bail!("bad nonce length: {} (want {NONCE_LEN})", nonce.len());
232        }
233        if tag.len() != TAG_LEN {
234            bail!("bad tag length: {} (want {TAG_LEN})", tag.len());
235        }
236        let mut nonce_bytes = [0u8; NONCE_LEN];
237        nonce_bytes.copy_from_slice(&nonce);
238
239        // ring's `open_in_place` expects ciphertext||tag contiguously.
240        let mut in_out = ciphertext;
241        in_out.extend_from_slice(&tag);
242
243        let plaintext = self
244            .key
245            .open_in_place(
246                Nonce::assume_unique_for_key(nonce_bytes),
247                Aad::from(aad),
248                &mut in_out,
249            )
250            .map_err(|_| anyhow::anyhow!("ciphertext failed authentication"))?;
251
252        String::from_utf8(plaintext.to_vec()).context("decrypted bytes are not valid UTF-8")
253    }
254
255    /// Migrate-on-read: decrypt if the value is one of our tokens, otherwise
256    /// treat it as legacy plaintext and return it unchanged. Callers re-encrypt
257    /// on the next write, transparently upgrading old rows.
258    pub fn maybe_decrypt(&self, value: &str) -> Result<String> {
259        if Self::is_ciphertext(value) {
260            self.decrypt(value)
261        } else {
262            Ok(value.to_string())
263        }
264    }
265}
266
267/// The codec actually installed: real AEAD, or a pass-through used only on a
268/// localhost dev stack where no key is configured. Production refuses to boot
269/// without a real key, so [`Codec::Null`] never runs there.
270pub enum Codec {
271    /// Boxed: ring's expanded AES key schedule makes [`Aead`] ~544 bytes, and an
272    /// enum sized to its largest variant would be paid for by every `Null` too.
273    Aead(Box<Aead>),
274    Null,
275}
276
277impl Codec {
278    /// Build the codec for a configured key, or the pass-through when none is
279    /// set. Production validates that a key IS set before calling this.
280    pub fn new(raw_key: Option<&str>) -> Result<Self> {
281        match raw_key {
282            Some(raw) => Ok(Codec::Aead(Box::new(Aead::new(raw)?))),
283            None => Ok(Codec::Null),
284        }
285    }
286
287    pub fn encrypt(&self, plaintext: &str) -> String {
288        match self {
289            Codec::Aead(a) => a.encrypt(plaintext),
290            Codec::Null => plaintext.to_string(),
291        }
292    }
293
294    /// Encrypt bound to `aad` — see [`Aead::encrypt_bound`].
295    ///
296    /// [`Codec::Null`] passes through, so a dev stack with no key configured gets
297    /// no binding either. That is the same trade the unbound path already makes,
298    /// and production is required to configure a key.
299    pub fn encrypt_bound(&self, plaintext: &str, aad: &[u8]) -> String {
300        match self {
301            Codec::Aead(a) => a.encrypt_bound(plaintext, aad),
302            Codec::Null => plaintext.to_string(),
303        }
304    }
305
306    /// Decrypt a bound record — see [`Aead::decrypt_bound`].
307    pub fn decrypt_bound(&self, token: &str, aad: &[u8]) -> Result<String> {
308        match self {
309            Codec::Aead(a) => a.decrypt_bound(token, aad),
310            Codec::Null => Ok(token.to_string()),
311        }
312    }
313
314    pub fn maybe_decrypt(&self, value: &str) -> Result<String> {
315        match self {
316            Codec::Aead(a) => a.maybe_decrypt(value),
317            Codec::Null => Ok(value.to_string()),
318        }
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    /// 43 base64url chars. This decodes to 32 bytes but does NOT re-encode back
327    /// to the input, so it must take the PASSPHRASE path, not the raw-key path.
328    /// The sidecar's own test suite uses this exact value.
329    const KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
330
331    // ── derive_key ───────────────────────────────────────────────────────────
332
333    #[test]
334    fn derive_key_uses_an_exact_32_byte_base64_value_directly() {
335        let raw = base64::engine::general_purpose::STANDARD.encode([7u8; 32]);
336        assert_eq!(derive_key(&raw), [7u8; 32]);
337    }
338
339    #[test]
340    fn derive_key_uses_a_64_char_hex_value_directly() {
341        let raw = "ab".repeat(32);
342        assert_eq!(derive_key(&raw), [0xabu8; 32]);
343    }
344
345    #[test]
346    fn derive_key_uses_an_exact_32_byte_base64url_value_directly() {
347        // base64url of 0xFB… contains the url-safe `-` and `_` characters.
348        let raw = "-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_s";
349        assert!(raw.contains('-') && raw.contains('_'));
350        assert_eq!(derive_key(raw), [0xfbu8; 32]);
351    }
352
353    /// Not canonical hex/base64 of a 32-byte key, so it is hashed -- with the
354    /// domain separator. Asserting the exact expected digest rather than merely
355    /// "not the raw bytes": the weaker form passes for any hash, including one
356    /// with the separator dropped, which would silently invalidate every stored
357    /// ciphertext.
358    #[test]
359    fn derive_key_hashes_a_human_passphrase_with_the_domain_separator() {
360        let pass = "correct horse battery staple pad!";
361        let mut ctx = ring::digest::Context::new(&SHA256);
362        ctx.update(b"featherreader-sidecar-enc:v1:");
363        ctx.update(pass.as_bytes());
364        let expected: [u8; 32] = ctx.finish().as_ref().try_into().unwrap();
365
366        assert_eq!(derive_key(pass), expected);
367        assert_ne!(derive_key(pass), pass.as_bytes()[..32]);
368        // A bare SHA-256 with no domain separation must NOT be what we produce.
369        let undomained = ring::digest::digest(&SHA256, pass.as_bytes());
370        assert_ne!(derive_key(pass).as_slice(), undomained.as_ref());
371    }
372
373    #[test]
374    fn derive_key_is_deterministic_and_distinguishes_passphrases() {
375        let a = derive_key("some-long-passphrase-value");
376        assert_eq!(a, derive_key("some-long-passphrase-value"));
377        assert_ne!(a, derive_key("different"));
378    }
379
380    /// A base64-SHAPED value that is not a canonical 32-byte key must take the
381    /// passphrase path.
382    ///
383    /// Asserted as an EQUALITY against the domain-separated digest, not as a
384    /// `!=` against some value we guess a broken implementation would return.
385    /// Two earlier versions of this test guessed wrong: `[b'a'; 32]` (the ASCII
386    /// bytes) and then `69 A6 9A…` (the lenient decode Node's `Buffer` would
387    /// produce). base64's engines (0.22 then, re-verified on 0.23.1) are strict
388    /// and reject `"a"×43` outright
389    /// — `InvalidPadding` / `InvalidLastSymbol` — so neither value is reachable
390    /// and both assertions held for the wrong reason.
391    ///
392    /// Which also means: with these strict engines, a successful decode already
393    /// implies canonicality, so `decode_exact_key`'s re-encode check is
394    /// belt-and-braces rather than load-bearing. It is kept because it is the
395    /// property we actually want to hold, independent of how strict the decoder
396    /// happens to be.
397    #[test]
398    fn a_non_canonical_base64_lookalike_takes_the_passphrase_path() {
399        let mut ctx = ring::digest::Context::new(&SHA256);
400        ctx.update(PASSPHRASE_DOMAIN.as_bytes());
401        ctx.update(KEY.as_bytes());
402        let expected: [u8; 32] = ctx.finish().as_ref().try_into().unwrap();
403        assert_eq!(derive_key(KEY), expected, "not the passphrase path");
404
405        // And the raw-key path is still taken for a genuinely canonical value,
406        // so the two are distinguished rather than everything being hashed.
407        let canonical = URL_SAFE_NO_PAD.encode([0x11u8; 32]);
408        assert_eq!(derive_key(&canonical), [0x11u8; 32]);
409    }
410
411    // ── round-trip ───────────────────────────────────────────────────────────
412
413    #[test]
414    fn aead_round_trips_and_produces_enc_v1_tokens() {
415        let aead = Aead::new(KEY).unwrap();
416        let ct = aead.encrypt("hello secret");
417        assert!(ct.starts_with("enc.v1.gcm."));
418        assert!(Aead::is_ciphertext(&ct));
419        assert_eq!(aead.decrypt(&ct).unwrap(), "hello secret");
420    }
421
422    /// Compares the NONCE SEGMENT, not the whole token: two tokens differing
423    /// only in ciphertext would satisfy a whole-token comparison while reusing
424    /// the nonce, which is the catastrophic case for GCM. A counter nonce is
425    /// also rejected — 32 samples must all be distinct AND not sequential.
426    #[test]
427    fn aead_uses_a_fresh_random_nonce_per_record() {
428        let aead = Aead::new(KEY).unwrap();
429        let nonce_of = |token: &str| token.split('.').nth(3).unwrap().to_string();
430
431        let mut seen = std::collections::HashSet::new();
432        for _ in 0..32 {
433            let token = aead.encrypt("same");
434            let nonce = nonce_of(&token);
435            assert!(seen.insert(nonce), "GCM nonce reused across records");
436            assert_eq!(aead.decrypt(&token).unwrap(), "same");
437        }
438        // A counter would produce nonces differing only in the last bytes.
439        let a = nonce_of(&aead.encrypt("x"));
440        let b = nonce_of(&aead.encrypt("x"));
441        let shared_prefix = a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count();
442        assert!(
443            shared_prefix < a.len() / 2,
444            "nonces look sequential rather than random: {a} vs {b}"
445        );
446    }
447
448    #[test]
449    fn aead_round_trips_empty_and_non_ascii_plaintext() {
450        let aead = Aead::new(KEY).unwrap();
451        for pt in ["", "dídj — ünïcode ✓"] {
452            let ct = aead.encrypt(pt);
453            assert_eq!(aead.decrypt(&ct).unwrap(), pt);
454        }
455    }
456
457    // ── authentication ───────────────────────────────────────────────────────
458
459    #[test]
460    fn aead_rejects_tampered_ciphertext() {
461        let aead = Aead::new(KEY).unwrap();
462        let ct = aead.encrypt("tamperme");
463        let mut parts: Vec<&str> = ct.split('.').collect();
464        // Flip a byte in the ciphertext segment (enc . v1 . gcm . nonce . tag . ct).
465        let mut bad = base64::engine::general_purpose::URL_SAFE_NO_PAD
466            .decode(parts[5])
467            .unwrap();
468        bad[0] ^= 0xff;
469        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bad);
470        parts[5] = &encoded;
471        assert!(aead.decrypt(&parts.join(".")).is_err());
472    }
473
474    #[test]
475    fn aead_rejects_a_ciphertext_sealed_under_a_different_key() {
476        let ct = Aead::new(KEY).unwrap().encrypt("cross-key");
477        let other = Aead::new("totally-different-passphrase-here").unwrap();
478        assert!(other.decrypt(&ct).is_err());
479    }
480
481    #[test]
482    fn aead_rejects_malformed_tokens() {
483        let aead = Aead::new(KEY).unwrap();
484        for bad in [
485            "enc.v1.gcm.only-two.parts",
486            "enc.v1.gcm.AAAA.AAAA.AAAA.AAAA",
487            "enc.v1.gcm...",
488            "not-a-token",
489        ] {
490            assert!(aead.decrypt(bad).is_err(), "should reject {bad:?}");
491        }
492    }
493
494    /// A truncated nonce or tag must be rejected on length, not fed to the AEAD.
495    #[test]
496    fn aead_rejects_wrong_length_nonce_and_tag() {
497        let aead = Aead::new(KEY).unwrap();
498        let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
499        let short_nonce = format!(
500            "enc.v1.gcm.{}.{}.{}",
501            b64(&[0u8; 4]),
502            b64(&[0u8; 16]),
503            b64(b"")
504        );
505        let short_tag = format!(
506            "enc.v1.gcm.{}.{}.{}",
507            b64(&[0u8; 12]),
508            b64(&[0u8; 4]),
509            b64(b"")
510        );
511        assert!(aead.decrypt(&short_nonce).is_err());
512        assert!(aead.decrypt(&short_tag).is_err());
513    }
514
515    // ── migrate-on-read ──────────────────────────────────────────────────────
516
517    #[test]
518    fn maybe_decrypt_passes_legacy_plaintext_through_unchanged() {
519        let aead = Aead::new(KEY).unwrap();
520        assert_eq!(
521            aead.maybe_decrypt(r#"{"legacy":true}"#).unwrap(),
522            r#"{"legacy":true}"#
523        );
524        let ct = aead.encrypt(r#"{"legacy":true}"#);
525        assert_eq!(aead.maybe_decrypt(&ct).unwrap(), r#"{"legacy":true}"#);
526    }
527
528    #[test]
529    fn null_codec_passes_through_in_both_directions() {
530        let n = Codec::new(None).unwrap();
531        assert!(matches!(n, Codec::Null));
532        assert_eq!(n.encrypt("x"), "x");
533        assert_eq!(n.maybe_decrypt("x").unwrap(), "x");
534    }
535
536    #[test]
537    fn aead_codec_round_trips_and_still_reads_legacy_plaintext() {
538        let c = Codec::new(Some(KEY)).unwrap();
539        let ct = c.encrypt("secret");
540        assert!(Aead::is_ciphertext(&ct));
541        assert_eq!(c.maybe_decrypt(&ct).unwrap(), "secret");
542        // A row written before encryption was switched on still reads back.
543        assert_eq!(c.maybe_decrypt("legacy").unwrap(), "legacy");
544    }
545
546    // ── AAD-bound records (v2) ───────────────────────────────────────────────
547
548    const STATE_AAD: &[u8] = b"oauth_state:abc123:dpop_key_jwk";
549    const OTHER_AAD: &[u8] = b"oauth_state:def456:dpop_key_jwk";
550
551    #[test]
552    fn bound_records_round_trip_under_their_own_binding() {
553        let aead = Aead::new(KEY).unwrap();
554        let ct = aead.encrypt_bound("secret", STATE_AAD);
555        assert!(ct.starts_with("enc.v2.gcm."));
556        assert_eq!(aead.decrypt_bound(&ct, STATE_AAD).unwrap(), "secret");
557    }
558
559    /// **The property AAD exists for.** A ciphertext lifted out of one row must
560    /// not authenticate in another. Without this, anything with DB write access
561    /// can graft one login flow's DPoP key or issuer onto another flow's state.
562    #[test]
563    fn a_bound_record_does_not_authenticate_under_a_different_binding() {
564        let aead = Aead::new(KEY).unwrap();
565        let ct = aead.encrypt_bound("secret", STATE_AAD);
566        assert!(
567            aead.decrypt_bound(&ct, OTHER_AAD).is_err(),
568            "a ciphertext moved between rows still authenticated"
569        );
570        assert!(aead.decrypt_bound(&ct, b"").is_err());
571    }
572
573    /// **Downgrade prevention.** If a v1 (unbound) token were accepted where a
574    /// bound one is expected, the binding would be opt-out: an attacker who can
575    /// write the row just stores the unbound form instead.
576    #[test]
577    fn an_unbound_v1_token_is_rejected_where_a_bound_one_is_expected() {
578        let aead = Aead::new(KEY).unwrap();
579        let v1 = aead.encrypt("secret");
580        assert!(v1.starts_with("enc.v1.gcm."));
581        assert!(
582            aead.decrypt_bound(&v1, STATE_AAD).is_err(),
583            "a v1 token was accepted as bound -- the binding is bypassable"
584        );
585        assert!(aead.decrypt_bound(&v1, b"").is_err());
586    }
587
588    /// And the reverse, so a bound record cannot be read by the unbound path
589    /// (which would drop the binding check silently).
590    #[test]
591    fn a_bound_v2_token_is_rejected_by_the_unbound_path() {
592        let aead = Aead::new(KEY).unwrap();
593        let v2 = aead.encrypt_bound("secret", STATE_AAD);
594        assert!(aead.decrypt(&v2).is_err());
595        // And `maybe_decrypt` must NOT mistake it for legacy plaintext and hand
596        // the raw ciphertext back to the caller as though it were the value.
597        let returned = aead.maybe_decrypt(&v2);
598        assert!(
599            returned.is_err(),
600            "v2 token was treated as legacy plaintext"
601        );
602    }
603
604    #[test]
605    fn is_ciphertext_recognises_both_formats() {
606        let aead = Aead::new(KEY).unwrap();
607        assert!(Aead::is_ciphertext(&aead.encrypt("x")));
608        assert!(Aead::is_ciphertext(&aead.encrypt_bound("x", STATE_AAD)));
609        assert!(!Aead::is_ciphertext("{\"legacy\":true}"));
610    }
611
612    #[test]
613    fn bound_records_use_a_fresh_nonce_and_reject_tampering() {
614        let aead = Aead::new(KEY).unwrap();
615        let a = aead.encrypt_bound("same", STATE_AAD);
616        let b = aead.encrypt_bound("same", STATE_AAD);
617        assert_ne!(
618            a.split('.').nth(3).unwrap(),
619            b.split('.').nth(3).unwrap(),
620            "GCM nonce reused"
621        );
622
623        let mut parts: Vec<&str> = a.split('.').collect();
624        let mut bad = URL_SAFE_NO_PAD.decode(parts[5]).unwrap();
625        bad[0] ^= 0xff;
626        let encoded = URL_SAFE_NO_PAD.encode(&bad);
627        parts[5] = &encoded;
628        assert!(aead.decrypt_bound(&parts.join("."), STATE_AAD).is_err());
629    }
630
631    #[test]
632    fn the_codec_exposes_the_bound_path_and_null_passes_through() {
633        let real = Codec::new(Some(KEY)).unwrap();
634        let ct = real.encrypt_bound("secret", STATE_AAD);
635        assert_eq!(real.decrypt_bound(&ct, STATE_AAD).unwrap(), "secret");
636        assert!(real.decrypt_bound(&ct, OTHER_AAD).is_err());
637
638        // Dev-only: no key configured means no binding either. Documented, and
639        // the same pass-through semantics the unbound path already has.
640        let null = Codec::new(None).unwrap();
641        assert_eq!(null.encrypt_bound("secret", STATE_AAD), "secret");
642        assert_eq!(null.decrypt_bound("secret", OTHER_AAD).unwrap(), "secret");
643    }
644
645    // ── cross-implementation compatibility ───────────────────────────────────
646
647    /// **The property that makes the cutover reversible.** These vectors were
648    /// produced by the REAL Node sidecar (`oauth-sidecar/dist/crypto.js`), not
649    /// by this implementation, so they fail if the Rust codec drifts from the
650    /// sidecar's wire format in any way -- key derivation, nonce/tag ordering,
651    /// base64url alphabet, or padding.
652    ///
653    /// Regenerate with:
654    /// ```text
655    /// node --input-type=module -e 'import {Aead} from "./dist/crypto.js"; \
656    ///   console.log(new Aead("a".repeat(43)).encrypt("hello secret"))'
657    /// ```
658    #[test]
659    fn decrypts_ciphertext_written_by_the_node_sidecar() {
660        let aead = Aead::new(KEY).unwrap();
661        for (ct, want) in [
662            ("enc.v1.gcm.DPcybWacAm5WDhlF.j0n0Xyp9NH7ZtEmYcF9--A.OZ_jVOWQQEmIdTA2", "hello secret"),
663            ("enc.v1.gcm.SmooV-sJqpA9v36g.S_xxfASFlnW0Fq0wLrCGRA.bBKUffzXmA73IEJ_ohLy", r#"{"legacy":true}"#),
664            ("enc.v1.gcm.K3c6m783VlJRypbr.G7xjgmQ8vca736zsGpoTMg.", ""),
665            ("enc.v1.gcm.hZNdycctWKVXf7eV.qWe0AQOCUeNKmGzoKN79gw.Btk3lyNkAHPGAx4YqxJFa3EqGnJl0Pk", "dídj — ünïcode ✓"),
666        ] {
667            assert_eq!(aead.decrypt(ct).unwrap(), want, "failed on {ct}");
668        }
669    }
670
671    /// The derivation itself must match the sidecar's, for both the raw-key and
672    /// the domain-separated passphrase paths. Expected values come from the same
673    /// `deriveKey` the sidecar ships.
674    #[test]
675    fn derive_key_matches_the_node_sidecar() {
676        let hex = |s: &str| {
677            let mut out = [0u8; 32];
678            for (i, b) in out.iter_mut().enumerate() {
679                *b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap();
680            }
681            out
682        };
683        assert_eq!(
684            derive_key("some-long-passphrase-value"),
685            hex("9597cec213096d8f62f3a917434421efea7b6b4be0ab623e87c72c9c96ee283b"),
686        );
687        assert_eq!(
688            derive_key(KEY),
689            hex("49dbed3b7aed2c3a965b9bae6032107cfaee9bedac29022507d39867b628155f"),
690        );
691    }
692}