1use 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
45const PREFIX_V1: &str = "enc.v1.gcm.";
49
50const PREFIX_V2: &str = "enc.v2.gcm.";
59
60const KEY_LEN: usize = 32;
62
63const TAG_LEN: usize = 16;
65
66const PASSPHRASE_DOMAIN: &str = "featherreader-sidecar-enc:v1:";
69
70fn 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 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
103pub 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
121pub struct Aead {
123 key: LessSafeKey,
124}
125
126impl Aead {
127 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 pub fn is_ciphertext(value: &str) -> bool {
144 value.starts_with(PREFIX_V1) || value.starts_with(PREFIX_V2)
145 }
146
147 pub fn encrypt(&self, plaintext: &str) -> String {
155 self.seal(plaintext, PREFIX_V1, b"")
156 }
157
158 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 pub fn decrypt(&self, token: &str) -> Result<String> {
193 self.open(token, PREFIX_V1, b"")
194 }
195
196 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 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 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 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
267pub enum Codec {
271 Aead(Box<Aead>),
274 Null,
275}
276
277impl Codec {
278 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 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 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 const KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
330
331 #[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 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 #[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 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 #[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 let canonical = URL_SAFE_NO_PAD.encode([0x11u8; 32]);
408 assert_eq!(derive_key(&canonical), [0x11u8; 32]);
409 }
410
411 #[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 #[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 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 #[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 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 #[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 #[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 assert_eq!(c.maybe_decrypt("legacy").unwrap(), "legacy");
544 }
545
546 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 #[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 #[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 #[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 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 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 #[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 #[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}