use base64::Engine as _;
use chacha20poly1305::aead::{Aead as _, Payload};
use chacha20poly1305::{Key, KeyInit as _, XChaCha20Poly1305, XNonce};
use thiserror::Error;
use zeroize::Zeroizing;
pub const KEY_LEN: usize = 32;
const VERSION: u8 = 1;
const NONCE_LEN: usize = 24;
const TAG_LEN: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SealContext<'a> {
pub namespace: &'a str,
pub table: &'a str,
pub row_id: &'a str,
pub column: &'a str,
}
impl SealContext<'_> {
#[must_use]
pub fn aad(&self) -> String {
format!(
"{}/{}/{}/{}",
self.namespace, self.table, self.row_id, self.column
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SealError {
#[error("the seal key is not 32 bytes of base64")]
Key,
#[error("no randomness available for a nonce")]
Random,
#[error("sealed value is malformed")]
Malformed,
#[error("sealed under unknown key id {0}")]
UnknownKeyId(u8),
#[error("sealed value failed authentication")]
Authentication,
}
pub trait TokenSealer: Send + Sync {
fn seal(&self, plaintext: &str, context: &SealContext<'_>) -> Result<String, SealError>;
fn open(&self, sealed: &str, context: &SealContext<'_>)
-> Result<Zeroizing<String>, SealError>;
}
pub struct XChaChaSealer {
id: u8,
key: Zeroizing<[u8; KEY_LEN]>,
}
impl XChaChaSealer {
pub fn from_base64_key(encoded: &str, id: u8) -> Result<Self, SealError> {
let trimmed = encoded.trim();
let raw = [
base64::engine::general_purpose::STANDARD,
base64::engine::general_purpose::STANDARD_NO_PAD,
base64::engine::general_purpose::URL_SAFE,
base64::engine::general_purpose::URL_SAFE_NO_PAD,
]
.iter()
.find_map(|engine| engine.decode(trimmed).ok())
.ok_or(SealError::Key)?;
let key: [u8; KEY_LEN] = raw.as_slice().try_into().map_err(|_| SealError::Key)?;
Ok(Self {
id,
key: Zeroizing::new(key),
})
}
fn cipher(&self) -> XChaCha20Poly1305 {
let key = Key::try_from(self.key.as_slice()).expect("key is 32 bytes by construction");
XChaCha20Poly1305::new(&key)
}
}
impl TokenSealer for XChaChaSealer {
fn seal(&self, plaintext: &str, context: &SealContext<'_>) -> Result<String, SealError> {
let mut nonce_bytes = [0u8; NONCE_LEN];
getrandom::fill(&mut nonce_bytes).map_err(|_| SealError::Random)?;
let nonce = XNonce::try_from(&nonce_bytes[..]).map_err(|_| SealError::Random)?;
let aad = context.aad();
let ciphertext = self
.cipher()
.encrypt(
&nonce,
Payload {
msg: plaintext.as_bytes(),
aad: aad.as_bytes(),
},
)
.map_err(|_| SealError::Authentication)?;
let mut blob = Vec::with_capacity(2 + NONCE_LEN + ciphertext.len());
blob.push(VERSION);
blob.push(self.id);
blob.extend_from_slice(&nonce_bytes);
blob.extend_from_slice(&ciphertext);
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(blob))
}
fn open(
&self,
sealed: &str,
context: &SealContext<'_>,
) -> Result<Zeroizing<String>, SealError> {
let blob = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(sealed.trim())
.map_err(|_| SealError::Malformed)?;
if blob.len() < 2 + NONCE_LEN + TAG_LEN {
return Err(SealError::Malformed);
}
if blob[0] != VERSION {
return Err(SealError::Malformed);
}
if blob[1] != self.id {
return Err(SealError::UnknownKeyId(blob[1]));
}
let nonce = XNonce::try_from(&blob[2..2 + NONCE_LEN]).map_err(|_| SealError::Malformed)?;
let aad = context.aad();
let plaintext = self
.cipher()
.decrypt(
&nonce,
Payload {
msg: &blob[2 + NONCE_LEN..],
aad: aad.as_bytes(),
},
)
.map_err(|_| SealError::Authentication)?;
let text = String::from_utf8(plaintext).map_err(|_| SealError::Malformed)?;
Ok(Zeroizing::new(text))
}
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: &str = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
const AAD: SealContext<'static> = SealContext {
namespace: "linkedin",
table: "linkedin_accounts",
row_id: "acc_1",
column: "access_token",
};
fn key(id: u8) -> XChaChaSealer {
XChaChaSealer::from_base64_key(KEY, id).expect("32-byte key")
}
#[test]
fn round_trips() {
let key = key(1);
let stored = key.seal("AQXNnd2kXITHELmWblJigb", &AAD).expect("seals");
assert_eq!(
&*key.open(&stored, &AAD).expect("opens"),
"AQXNnd2kXITHELmWblJigb"
);
}
#[test]
fn a_ciphertext_cannot_move_between_columns_or_rows() {
let key = key(1);
let stored = key.seal("secret", &AAD).expect("seals");
let refresh_column = SealContext {
column: "refresh_token",
..AAD
};
let other_row = SealContext {
row_id: "acc_2",
..AAD
};
assert_eq!(
key.open(&stored, &refresh_column),
Err(SealError::Authentication)
);
assert_eq!(
key.open(&stored, &other_row),
Err(SealError::Authentication)
);
}
#[test]
fn tampering_is_caught() {
let key = key(1);
let stored = key.seal("secret", &AAD).expect("seals");
let mut blob = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&stored)
.expect("base64");
let last = blob.len() - 1;
blob[last] ^= 0x01;
let tampered = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(blob);
assert_eq!(key.open(&tampered, &AAD), Err(SealError::Authentication));
}
#[test]
fn a_blob_from_another_key_id_is_named_not_guessed() {
let stored = key(1).seal("secret", &AAD).expect("seals");
assert_eq!(key(2).open(&stored, &AAD), Err(SealError::UnknownKeyId(1)));
}
#[test]
fn the_blob_layout_is_version_keyid_nonce_ciphertext() {
let key = key(7);
let stored = key.seal("secret", &AAD).expect("seals");
let blob = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&stored)
.expect("base64");
assert_eq!(blob[0], 1, "version");
assert_eq!(blob[1], 7, "key id");
assert_eq!(blob.len(), 2 + NONCE_LEN + "secret".len() + TAG_LEN);
assert_eq!(&*key.open(&stored, &AAD).expect("opens"), "secret");
}
#[test]
fn nonces_are_never_reused() {
let sealer = key(1);
let first = sealer.seal("secret", &AAD).expect("seals");
let second = sealer.seal("secret", &AAD).expect("seals");
assert_ne!(first, second, "same plaintext sealed to the same bytes");
}
#[test]
fn malformed_input_never_panics() {
let sealer = key(1);
for bad in ["", "!!!!", "AAAA", &"A".repeat(200)] {
assert_eq!(sealer.open(bad, &AAD), Err(SealError::Malformed));
}
}
#[test]
fn keys_are_accepted_in_either_base64_alphabet() {
assert!(XChaChaSealer::from_base64_key(KEY, 1).is_ok());
assert!(XChaChaSealer::from_base64_key(KEY.trim_end_matches('='), 1).is_ok());
assert!(XChaChaSealer::from_base64_key("too short", 1).is_err());
assert!(XChaChaSealer::from_base64_key("", 1).is_err());
}
#[test]
fn the_aad_renders_namespace_table_row_column() {
assert_eq!(AAD.aad(), "linkedin/linkedin_accounts/acc_1/access_token");
}
}