use aes_gcm::aead::{Aead, Generate, KeyInit, Nonce};
use aes_gcm::{Aes256Gcm, Key};
use crate::encoding::{base64_decode, base64_encode};
const PREFIX: &str = "epv1:";
const NONCE_LEN: usize = 12;
const KEY_LEN: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum SealError {
#[error("sealing key must be exactly {KEY_LEN} bytes ({} hex characters)", KEY_LEN * 2)]
InvalidKeyLength,
#[error("sealing key is not valid hex")]
InvalidKeyEncoding,
#[error("value is not in the `epv1:` sealed format")]
NotSealed,
#[error("sealed value could not be opened")]
Undecryptable,
}
pub struct SealingKey {
cipher: Aes256Gcm,
}
impl SealingKey {
pub fn from_bytes(key: &[u8]) -> Result<Self, SealError> {
let key = Key::<Aes256Gcm>::try_from(key).map_err(|_| SealError::InvalidKeyLength)?;
Ok(Self {
cipher: Aes256Gcm::new(&key),
})
}
pub fn from_hex(key_hex: &str) -> Result<Self, SealError> {
let bytes = decode_hex(key_hex.trim())?;
Self::from_bytes(&bytes)
}
pub fn generate_key_hex() -> String {
crate::encoding::hex_lower(<[u8; KEY_LEN]>::generate())
}
pub fn seal(&self, plaintext: &[u8]) -> Result<String, SealError> {
let nonce_bytes = <[u8; NONCE_LEN]>::generate();
let nonce =
Nonce::<Aes256Gcm>::try_from(&nonce_bytes[..]).map_err(|_| SealError::Undecryptable)?;
let ciphertext = self
.cipher
.encrypt(&nonce, plaintext)
.map_err(|_| SealError::Undecryptable)?;
let mut envelope = Vec::with_capacity(NONCE_LEN + ciphertext.len());
envelope.extend_from_slice(&nonce_bytes);
envelope.extend_from_slice(&ciphertext);
Ok(format!("{PREFIX}{}", base64_encode(envelope)))
}
pub fn open(&self, sealed: &str) -> Result<Vec<u8>, SealError> {
let encoded = sealed.strip_prefix(PREFIX).ok_or(SealError::NotSealed)?;
let envelope = base64_decode(encoded).map_err(|_| SealError::Undecryptable)?;
if envelope.len() <= NONCE_LEN {
return Err(SealError::Undecryptable);
}
let (nonce_bytes, ciphertext) = envelope.split_at(NONCE_LEN);
let nonce =
Nonce::<Aes256Gcm>::try_from(nonce_bytes).map_err(|_| SealError::Undecryptable)?;
self.cipher
.decrypt(&nonce, ciphertext)
.map_err(|_| SealError::Undecryptable)
}
pub fn is_sealed(value: &str) -> bool {
value.starts_with(PREFIX)
}
}
impl std::fmt::Debug for SealingKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SealingKey(<redacted>)")
}
}
fn decode_hex(input: &str) -> Result<Vec<u8>, SealError> {
if input.len() % 2 != 0 {
return Err(SealError::InvalidKeyEncoding);
}
(0..input.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&input[i..i + 2], 16).map_err(|_| SealError::InvalidKeyEncoding)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn key() -> SealingKey {
SealingKey::from_hex(&SealingKey::generate_key_hex()).unwrap()
}
#[test]
fn round_trips() {
let k = key();
let sealed = k.seal(b"cardToken-73a05d99").unwrap();
assert!(SealingKey::is_sealed(&sealed));
assert_eq!(k.open(&sealed).unwrap(), b"cardToken-73a05d99");
}
#[test]
fn sealed_output_does_not_contain_the_plaintext() {
let sealed = key().seal(b"73a05d99-01fa-c218").unwrap();
assert!(!sealed.contains("73a05d99"));
}
#[test]
fn same_plaintext_seals_differently_each_time() {
let k = key();
assert_ne!(k.seal(b"same").unwrap(), k.seal(b"same").unwrap());
}
#[test]
fn a_different_key_cannot_open_it() {
let sealed = key().seal(b"secret").unwrap();
assert_eq!(key().open(&sealed), Err(SealError::Undecryptable));
}
#[test]
fn tampering_is_detected() {
let k = key();
let sealed = k.seal(b"secret").unwrap();
let mut envelope = base64_decode(sealed.strip_prefix(PREFIX).unwrap()).unwrap();
let last = envelope.len() - 1;
envelope[last] ^= 0xff;
let tampered = format!("{PREFIX}{}", base64_encode(envelope));
assert_eq!(k.open(&tampered), Err(SealError::Undecryptable));
}
#[test]
fn a_truncated_envelope_is_rejected() {
let tampered = format!("{PREFIX}{}", base64_encode([0u8; NONCE_LEN]));
assert_eq!(key().open(&tampered), Err(SealError::Undecryptable));
}
#[test]
fn unsealed_input_is_an_error_not_a_passthrough() {
assert_eq!(key().open("73a05d99-plaintext"), Err(SealError::NotSealed));
assert!(!SealingKey::is_sealed("73a05d99-plaintext"));
}
#[test]
fn rejects_malformed_keys() {
assert!(matches!(
SealingKey::from_hex("zz"),
Err(SealError::InvalidKeyEncoding)
));
assert!(matches!(
SealingKey::from_hex("abcd"),
Err(SealError::InvalidKeyLength)
));
assert!(matches!(
SealingKey::from_bytes(&[0u8; 16]),
Err(SealError::InvalidKeyLength)
));
}
#[test]
fn generated_keys_are_the_right_size_and_not_constant() {
let a = SealingKey::generate_key_hex();
let b = SealingKey::generate_key_hex();
assert_eq!(a.len(), KEY_LEN * 2);
assert_ne!(a, b);
}
#[test]
fn seals_empty_input() {
let k = key();
let sealed = k.seal(b"").unwrap();
assert_eq!(k.open(&sealed).unwrap(), b"");
}
}