use anyhow::{bail, Context as _, Result};
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD};
use base64::Engine;
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN};
use ring::digest::SHA256;
const PREFIX_V1: &str = "enc.v1.gcm.";
const PREFIX_V2: &str = "enc.v2.gcm.";
const KEY_LEN: usize = 32;
const TAG_LEN: usize = 16;
const PASSPHRASE_DOMAIN: &str = "featherreader-sidecar-enc:v1:";
fn decode_exact_key(raw: &str) -> Option<[u8; KEY_LEN]> {
let mut out = [0u8; KEY_LEN];
if raw.len() == KEY_LEN * 2 && raw.bytes().all(|b| b.is_ascii_hexdigit()) {
for (i, byte) in out.iter_mut().enumerate() {
*byte = u8::from_str_radix(&raw[i * 2..i * 2 + 2], 16).ok()?;
}
return Some(out);
}
for engine in [&STANDARD, &STANDARD_NO_PAD, &URL_SAFE_NO_PAD] {
if let Ok(bytes) = engine.decode(raw) {
if bytes.len() == KEY_LEN && engine.encode(&bytes) == raw {
out.copy_from_slice(&bytes);
return Some(out);
}
}
}
None
}
pub fn derive_key(raw: &str) -> [u8; KEY_LEN] {
if let Some(exact) = decode_exact_key(raw) {
return exact;
}
let mut ctx = ring::digest::Context::new(&SHA256);
ctx.update(PASSPHRASE_DOMAIN.as_bytes());
ctx.update(raw.as_bytes());
let mut out = [0u8; KEY_LEN];
out.copy_from_slice(ctx.finish().as_ref());
out
}
pub struct Aead {
key: LessSafeKey,
}
impl Aead {
pub fn new(raw_key: &str) -> Result<Self> {
let key = UnboundKey::new(&AES_256_GCM, &derive_key(raw_key))
.map_err(|_| anyhow::anyhow!("failed to build an AES-256-GCM key"))?;
Ok(Self {
key: LessSafeKey::new(key),
})
}
pub fn is_ciphertext(value: &str) -> bool {
value.starts_with(PREFIX_V1) || value.starts_with(PREFIX_V2)
}
pub fn encrypt(&self, plaintext: &str) -> String {
self.seal(plaintext, PREFIX_V1, b"")
}
pub fn encrypt_bound(&self, plaintext: &str, aad: &[u8]) -> String {
self.seal(plaintext, PREFIX_V2, aad)
}
fn seal(&self, plaintext: &str, prefix: &str, aad: &[u8]) -> String {
let mut nonce = [0u8; NONCE_LEN];
getrandom::fill(&mut nonce)
.expect("OS CSPRNG unavailable; refusing to encrypt with a non-random GCM nonce");
let mut in_out = plaintext.as_bytes().to_vec();
let tag = self
.key
.seal_in_place_separate_tag(
Nonce::assume_unique_for_key(nonce),
Aad::from(aad),
&mut in_out,
)
.expect("AES-256-GCM sealing cannot fail for a well-formed key and nonce");
format!(
"{prefix}{}.{}.{}",
URL_SAFE_NO_PAD.encode(nonce),
URL_SAFE_NO_PAD.encode(tag.as_ref()),
URL_SAFE_NO_PAD.encode(&in_out),
)
}
pub fn decrypt(&self, token: &str) -> Result<String> {
self.open(token, PREFIX_V1, b"")
}
pub fn decrypt_bound(&self, token: &str, aad: &[u8]) -> Result<String> {
self.open(token, PREFIX_V2, aad)
}
fn open(&self, token: &str, prefix: &str, aad: &[u8]) -> Result<String> {
let rest = token
.strip_prefix(prefix)
.with_context(|| format!("not an {}ciphertext token", &prefix[..7]))?;
let parts: Vec<&str> = rest.split('.').collect();
if parts.len() != 3 {
bail!(
"malformed ciphertext token: expected 3 segments, got {}",
parts.len()
);
}
let nonce = URL_SAFE_NO_PAD
.decode(parts[0])
.context("bad nonce encoding")?;
let tag = URL_SAFE_NO_PAD
.decode(parts[1])
.context("bad tag encoding")?;
let ciphertext = URL_SAFE_NO_PAD
.decode(parts[2])
.context("bad ciphertext encoding")?;
if nonce.len() != NONCE_LEN {
bail!("bad nonce length: {} (want {NONCE_LEN})", nonce.len());
}
if tag.len() != TAG_LEN {
bail!("bad tag length: {} (want {TAG_LEN})", tag.len());
}
let mut nonce_bytes = [0u8; NONCE_LEN];
nonce_bytes.copy_from_slice(&nonce);
let mut in_out = ciphertext;
in_out.extend_from_slice(&tag);
let plaintext = self
.key
.open_in_place(
Nonce::assume_unique_for_key(nonce_bytes),
Aad::from(aad),
&mut in_out,
)
.map_err(|_| anyhow::anyhow!("ciphertext failed authentication"))?;
String::from_utf8(plaintext.to_vec()).context("decrypted bytes are not valid UTF-8")
}
pub fn maybe_decrypt(&self, value: &str) -> Result<String> {
if Self::is_ciphertext(value) {
self.decrypt(value)
} else {
Ok(value.to_string())
}
}
}
pub enum Codec {
Aead(Box<Aead>),
Null,
}
impl Codec {
pub fn new(raw_key: Option<&str>) -> Result<Self> {
match raw_key {
Some(raw) => Ok(Codec::Aead(Box::new(Aead::new(raw)?))),
None => Ok(Codec::Null),
}
}
pub fn encrypt(&self, plaintext: &str) -> String {
match self {
Codec::Aead(a) => a.encrypt(plaintext),
Codec::Null => plaintext.to_string(),
}
}
pub fn encrypt_bound(&self, plaintext: &str, aad: &[u8]) -> String {
match self {
Codec::Aead(a) => a.encrypt_bound(plaintext, aad),
Codec::Null => plaintext.to_string(),
}
}
pub fn decrypt_bound(&self, token: &str, aad: &[u8]) -> Result<String> {
match self {
Codec::Aead(a) => a.decrypt_bound(token, aad),
Codec::Null => Ok(token.to_string()),
}
}
pub fn maybe_decrypt(&self, value: &str) -> Result<String> {
match self {
Codec::Aead(a) => a.maybe_decrypt(value),
Codec::Null => Ok(value.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
#[test]
fn derive_key_uses_an_exact_32_byte_base64_value_directly() {
let raw = base64::engine::general_purpose::STANDARD.encode([7u8; 32]);
assert_eq!(derive_key(&raw), [7u8; 32]);
}
#[test]
fn derive_key_uses_a_64_char_hex_value_directly() {
let raw = "ab".repeat(32);
assert_eq!(derive_key(&raw), [0xabu8; 32]);
}
#[test]
fn derive_key_uses_an_exact_32_byte_base64url_value_directly() {
let raw = "-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_s";
assert!(raw.contains('-') && raw.contains('_'));
assert_eq!(derive_key(raw), [0xfbu8; 32]);
}
#[test]
fn derive_key_hashes_a_human_passphrase_with_the_domain_separator() {
let pass = "correct horse battery staple pad!";
let mut ctx = ring::digest::Context::new(&SHA256);
ctx.update(b"featherreader-sidecar-enc:v1:");
ctx.update(pass.as_bytes());
let expected: [u8; 32] = ctx.finish().as_ref().try_into().unwrap();
assert_eq!(derive_key(pass), expected);
assert_ne!(derive_key(pass), pass.as_bytes()[..32]);
let undomained = ring::digest::digest(&SHA256, pass.as_bytes());
assert_ne!(derive_key(pass).as_slice(), undomained.as_ref());
}
#[test]
fn derive_key_is_deterministic_and_distinguishes_passphrases() {
let a = derive_key("some-long-passphrase-value");
assert_eq!(a, derive_key("some-long-passphrase-value"));
assert_ne!(a, derive_key("different"));
}
#[test]
fn a_non_canonical_base64_lookalike_takes_the_passphrase_path() {
let mut ctx = ring::digest::Context::new(&SHA256);
ctx.update(PASSPHRASE_DOMAIN.as_bytes());
ctx.update(KEY.as_bytes());
let expected: [u8; 32] = ctx.finish().as_ref().try_into().unwrap();
assert_eq!(derive_key(KEY), expected, "not the passphrase path");
let canonical = URL_SAFE_NO_PAD.encode([0x11u8; 32]);
assert_eq!(derive_key(&canonical), [0x11u8; 32]);
}
#[test]
fn aead_round_trips_and_produces_enc_v1_tokens() {
let aead = Aead::new(KEY).unwrap();
let ct = aead.encrypt("hello secret");
assert!(ct.starts_with("enc.v1.gcm."));
assert!(Aead::is_ciphertext(&ct));
assert_eq!(aead.decrypt(&ct).unwrap(), "hello secret");
}
#[test]
fn aead_uses_a_fresh_random_nonce_per_record() {
let aead = Aead::new(KEY).unwrap();
let nonce_of = |token: &str| token.split('.').nth(3).unwrap().to_string();
let mut seen = std::collections::HashSet::new();
for _ in 0..32 {
let token = aead.encrypt("same");
let nonce = nonce_of(&token);
assert!(seen.insert(nonce), "GCM nonce reused across records");
assert_eq!(aead.decrypt(&token).unwrap(), "same");
}
let a = nonce_of(&aead.encrypt("x"));
let b = nonce_of(&aead.encrypt("x"));
let shared_prefix = a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count();
assert!(
shared_prefix < a.len() / 2,
"nonces look sequential rather than random: {a} vs {b}"
);
}
#[test]
fn aead_round_trips_empty_and_non_ascii_plaintext() {
let aead = Aead::new(KEY).unwrap();
for pt in ["", "dídj — ünïcode ✓"] {
let ct = aead.encrypt(pt);
assert_eq!(aead.decrypt(&ct).unwrap(), pt);
}
}
#[test]
fn aead_rejects_tampered_ciphertext() {
let aead = Aead::new(KEY).unwrap();
let ct = aead.encrypt("tamperme");
let mut parts: Vec<&str> = ct.split('.').collect();
let mut bad = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(parts[5])
.unwrap();
bad[0] ^= 0xff;
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bad);
parts[5] = &encoded;
assert!(aead.decrypt(&parts.join(".")).is_err());
}
#[test]
fn aead_rejects_a_ciphertext_sealed_under_a_different_key() {
let ct = Aead::new(KEY).unwrap().encrypt("cross-key");
let other = Aead::new("totally-different-passphrase-here").unwrap();
assert!(other.decrypt(&ct).is_err());
}
#[test]
fn aead_rejects_malformed_tokens() {
let aead = Aead::new(KEY).unwrap();
for bad in [
"enc.v1.gcm.only-two.parts",
"enc.v1.gcm.AAAA.AAAA.AAAA.AAAA",
"enc.v1.gcm...",
"not-a-token",
] {
assert!(aead.decrypt(bad).is_err(), "should reject {bad:?}");
}
}
#[test]
fn aead_rejects_wrong_length_nonce_and_tag() {
let aead = Aead::new(KEY).unwrap();
let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
let short_nonce = format!(
"enc.v1.gcm.{}.{}.{}",
b64(&[0u8; 4]),
b64(&[0u8; 16]),
b64(b"")
);
let short_tag = format!(
"enc.v1.gcm.{}.{}.{}",
b64(&[0u8; 12]),
b64(&[0u8; 4]),
b64(b"")
);
assert!(aead.decrypt(&short_nonce).is_err());
assert!(aead.decrypt(&short_tag).is_err());
}
#[test]
fn maybe_decrypt_passes_legacy_plaintext_through_unchanged() {
let aead = Aead::new(KEY).unwrap();
assert_eq!(
aead.maybe_decrypt(r#"{"legacy":true}"#).unwrap(),
r#"{"legacy":true}"#
);
let ct = aead.encrypt(r#"{"legacy":true}"#);
assert_eq!(aead.maybe_decrypt(&ct).unwrap(), r#"{"legacy":true}"#);
}
#[test]
fn null_codec_passes_through_in_both_directions() {
let n = Codec::new(None).unwrap();
assert!(matches!(n, Codec::Null));
assert_eq!(n.encrypt("x"), "x");
assert_eq!(n.maybe_decrypt("x").unwrap(), "x");
}
#[test]
fn aead_codec_round_trips_and_still_reads_legacy_plaintext() {
let c = Codec::new(Some(KEY)).unwrap();
let ct = c.encrypt("secret");
assert!(Aead::is_ciphertext(&ct));
assert_eq!(c.maybe_decrypt(&ct).unwrap(), "secret");
assert_eq!(c.maybe_decrypt("legacy").unwrap(), "legacy");
}
const STATE_AAD: &[u8] = b"oauth_state:abc123:dpop_key_jwk";
const OTHER_AAD: &[u8] = b"oauth_state:def456:dpop_key_jwk";
#[test]
fn bound_records_round_trip_under_their_own_binding() {
let aead = Aead::new(KEY).unwrap();
let ct = aead.encrypt_bound("secret", STATE_AAD);
assert!(ct.starts_with("enc.v2.gcm."));
assert_eq!(aead.decrypt_bound(&ct, STATE_AAD).unwrap(), "secret");
}
#[test]
fn a_bound_record_does_not_authenticate_under_a_different_binding() {
let aead = Aead::new(KEY).unwrap();
let ct = aead.encrypt_bound("secret", STATE_AAD);
assert!(
aead.decrypt_bound(&ct, OTHER_AAD).is_err(),
"a ciphertext moved between rows still authenticated"
);
assert!(aead.decrypt_bound(&ct, b"").is_err());
}
#[test]
fn an_unbound_v1_token_is_rejected_where_a_bound_one_is_expected() {
let aead = Aead::new(KEY).unwrap();
let v1 = aead.encrypt("secret");
assert!(v1.starts_with("enc.v1.gcm."));
assert!(
aead.decrypt_bound(&v1, STATE_AAD).is_err(),
"a v1 token was accepted as bound -- the binding is bypassable"
);
assert!(aead.decrypt_bound(&v1, b"").is_err());
}
#[test]
fn a_bound_v2_token_is_rejected_by_the_unbound_path() {
let aead = Aead::new(KEY).unwrap();
let v2 = aead.encrypt_bound("secret", STATE_AAD);
assert!(aead.decrypt(&v2).is_err());
let returned = aead.maybe_decrypt(&v2);
assert!(
returned.is_err(),
"v2 token was treated as legacy plaintext"
);
}
#[test]
fn is_ciphertext_recognises_both_formats() {
let aead = Aead::new(KEY).unwrap();
assert!(Aead::is_ciphertext(&aead.encrypt("x")));
assert!(Aead::is_ciphertext(&aead.encrypt_bound("x", STATE_AAD)));
assert!(!Aead::is_ciphertext("{\"legacy\":true}"));
}
#[test]
fn bound_records_use_a_fresh_nonce_and_reject_tampering() {
let aead = Aead::new(KEY).unwrap();
let a = aead.encrypt_bound("same", STATE_AAD);
let b = aead.encrypt_bound("same", STATE_AAD);
assert_ne!(
a.split('.').nth(3).unwrap(),
b.split('.').nth(3).unwrap(),
"GCM nonce reused"
);
let mut parts: Vec<&str> = a.split('.').collect();
let mut bad = URL_SAFE_NO_PAD.decode(parts[5]).unwrap();
bad[0] ^= 0xff;
let encoded = URL_SAFE_NO_PAD.encode(&bad);
parts[5] = &encoded;
assert!(aead.decrypt_bound(&parts.join("."), STATE_AAD).is_err());
}
#[test]
fn the_codec_exposes_the_bound_path_and_null_passes_through() {
let real = Codec::new(Some(KEY)).unwrap();
let ct = real.encrypt_bound("secret", STATE_AAD);
assert_eq!(real.decrypt_bound(&ct, STATE_AAD).unwrap(), "secret");
assert!(real.decrypt_bound(&ct, OTHER_AAD).is_err());
let null = Codec::new(None).unwrap();
assert_eq!(null.encrypt_bound("secret", STATE_AAD), "secret");
assert_eq!(null.decrypt_bound("secret", OTHER_AAD).unwrap(), "secret");
}
#[test]
fn decrypts_ciphertext_written_by_the_node_sidecar() {
let aead = Aead::new(KEY).unwrap();
for (ct, want) in [
("enc.v1.gcm.DPcybWacAm5WDhlF.j0n0Xyp9NH7ZtEmYcF9--A.OZ_jVOWQQEmIdTA2", "hello secret"),
("enc.v1.gcm.SmooV-sJqpA9v36g.S_xxfASFlnW0Fq0wLrCGRA.bBKUffzXmA73IEJ_ohLy", r#"{"legacy":true}"#),
("enc.v1.gcm.K3c6m783VlJRypbr.G7xjgmQ8vca736zsGpoTMg.", ""),
("enc.v1.gcm.hZNdycctWKVXf7eV.qWe0AQOCUeNKmGzoKN79gw.Btk3lyNkAHPGAx4YqxJFa3EqGnJl0Pk", "dídj — ünïcode ✓"),
] {
assert_eq!(aead.decrypt(ct).unwrap(), want, "failed on {ct}");
}
}
#[test]
fn derive_key_matches_the_node_sidecar() {
let hex = |s: &str| {
let mut out = [0u8; 32];
for (i, b) in out.iter_mut().enumerate() {
*b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap();
}
out
};
assert_eq!(
derive_key("some-long-passphrase-value"),
hex("9597cec213096d8f62f3a917434421efea7b6b4be0ab623e87c72c9c96ee283b"),
);
assert_eq!(
derive_key(KEY),
hex("49dbed3b7aed2c3a965b9bae6032107cfaee9bedac29022507d39867b628155f"),
);
}
}