use std::collections::HashMap;
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use rand::Rng;
use serde_json::Value;
use crate::errors::HaysonCipherError;
use crate::schema::{HaysonRecord, TableSchema};
const ENVELOPE_PREFIX: &str = "cypher";
const ENVELOPE_VERSION: &str = "v1";
const IV_BYTES: usize = 12;
const TAG_BYTES: usize = 16;
const KEY_BYTES: usize = 32;
const AAD_SEPARATOR: u8 = 0x1f;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MissingKeyPolicy {
#[default]
Throw,
Omit,
Raw,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CipherOptions {
pub keys: HashMap<String, Vec<u8>>,
pub key_id: Option<String>,
pub on_missing_key: Option<MissingKeyPolicy>,
}
fn is_key_id(id: &str) -> bool {
!id.is_empty()
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
}
pub fn is_envelope(value: &Value) -> bool {
matches!(value, Value::String(s) if s.starts_with(&format!("${ENVELOPE_PREFIX}$")))
}
fn envelope_shape(text: &str) -> Option<&str> {
let parts: Vec<&str> = text.strip_prefix('$')?.split('$').collect();
if parts.len() != 5 || parts[0] != ENVELOPE_PREFIX || parts[1] != ENVELOPE_VERSION || !is_key_id(parts[2]) {
return None;
}
let iv = URL_SAFE_NO_PAD.decode(parts[3]).ok()?;
let body = URL_SAFE_NO_PAD.decode(parts[4]).ok()?;
if iv.len() != IV_BYTES || body.len() < TAG_BYTES {
return None;
}
Some(parts[2])
}
pub fn envelope_key_id(value: &Value) -> Option<&str> {
let text = value.as_str()?;
if !is_envelope(value) {
return None;
}
let parts: Vec<&str> = text.get(1..)?.split('$').collect();
if parts.len() != 5 || parts[0] != ENVELOPE_PREFIX || parts[1] != ENVELOPE_VERSION {
return None;
}
Some(parts[2])
}
pub fn cypher_fields_of(schema: &TableSchema) -> Vec<String> {
let mut fields: Vec<String> = schema
.fields
.iter()
.filter(|(_, spec)| spec.cypher)
.map(|(name, _)| name.clone())
.collect();
fields.sort();
fields
}
fn key_from_slice(material: &[u8]) -> Key<Aes256Gcm> {
Key::<Aes256Gcm>::try_from(material).expect("key material is checked to be KEY_BYTES long")
}
fn aad_of(table: &str, key: &str, field: &str) -> Vec<u8> {
let mut aad = Vec::with_capacity(table.len() + key.len() + field.len() + 2);
aad.extend_from_slice(table.as_bytes());
aad.push(AAD_SEPARATOR);
aad.extend_from_slice(key.as_bytes());
aad.push(AAD_SEPARATOR);
aad.extend_from_slice(field.as_bytes());
aad
}
#[derive(Debug, Clone)]
pub struct FieldCipher {
keys: HashMap<String, Vec<u8>>,
write_key_id: String,
on_missing_key: MissingKeyPolicy,
}
impl FieldCipher {
pub fn new(options: CipherOptions) -> Result<Self, HaysonCipherError> {
let mut keys = HashMap::with_capacity(options.keys.len());
for (id, material) in options.keys {
if !is_key_id(&id) {
return Err(HaysonCipherError::new(
"",
"",
format!("key id \"{id}\" may only hold letters, digits, \".\", \"_\" and \"-\""),
));
}
if material.len() != KEY_BYTES {
return Err(HaysonCipherError::new(
"",
"",
format!("key \"{id}\" must be {KEY_BYTES} bytes, not {}", material.len()),
));
}
keys.insert(id, material);
}
if keys.is_empty() {
return Err(HaysonCipherError::new("", "", "needs at least one key"));
}
if let Some(key_id) = &options.key_id {
if !keys.contains_key(key_id) {
return Err(HaysonCipherError::new(
"",
"",
format!("keyId \"{key_id}\" is not among the keys given"),
));
}
}
if options.key_id.is_none() && keys.len() > 1 {
return Err(HaysonCipherError::new(
"",
"",
"several keys were given, so \"keyId\" has to say which one writes",
));
}
let write_key_id = match options.key_id {
Some(id) => id,
None => keys.keys().next().expect("checked non-empty above").clone(),
};
Ok(FieldCipher {
keys,
write_key_id,
on_missing_key: options.on_missing_key.unwrap_or_default(),
})
}
pub fn write_key_id(&self) -> &str {
&self.write_key_id
}
pub fn on_missing_key(&self) -> MissingKeyPolicy {
self.on_missing_key
}
pub fn knows(&self, key_id: &str) -> bool {
self.keys.contains_key(key_id)
}
pub fn encrypt(&self, table: &str, key: &str, field: &str, value: &Value) -> String {
let material = self
.keys
.get(&self.write_key_id)
.expect("write_key_id is always among the keys");
let cipher = Aes256Gcm::new(&key_from_slice(material));
let mut iv = [0u8; IV_BYTES];
rand::rng().fill_bytes(&mut iv);
let nonce = Nonce::from(iv);
let plaintext = serde_json::to_vec(value).expect("a validated field value always serializes to JSON");
let aad = aad_of(table, key, field);
let body = cipher
.encrypt(
&nonce,
Payload {
msg: &plaintext,
aad: &aad,
},
)
.expect("AES-256-GCM encryption with a valid 32-byte key must not fail");
format!(
"${ENVELOPE_PREFIX}${ENVELOPE_VERSION}${}${}${}",
self.write_key_id,
URL_SAFE_NO_PAD.encode(iv),
URL_SAFE_NO_PAD.encode(body),
)
}
pub fn decrypt(
&self,
table: &str,
key: &str,
field: &str,
envelope: &str,
) -> Result<Option<Value>, HaysonCipherError> {
let parts: Vec<&str> = envelope.get(1..).unwrap_or("").split('$').collect();
if parts.len() != 5 || parts[0] != ENVELOPE_PREFIX {
return Err(HaysonCipherError::new(
table,
field,
"holds a value that is not a cypher envelope",
));
}
let (version, key_id, iv_raw, body_raw) = (parts[1], parts[2], parts[3], parts[4]);
if version != ENVELOPE_VERSION {
return Err(HaysonCipherError::new(
table,
field,
format!("was written in envelope \"{version}\", which this build cannot read"),
));
}
let Some(material) = self.keys.get(key_id) else {
return match self.on_missing_key {
MissingKeyPolicy::Omit => Ok(None),
MissingKeyPolicy::Raw => Ok(Some(Value::String(envelope.to_string()))),
MissingKeyPolicy::Throw => Err(HaysonCipherError::new(
table,
field,
format!("was written with key \"{key_id}\", which was not given"),
)
.with_key_id(key_id)),
};
};
let iv = URL_SAFE_NO_PAD.decode(iv_raw).map_err(|_| {
HaysonCipherError::new(table, field, "holds a truncated cypher envelope").with_key_id(key_id)
})?;
let body = URL_SAFE_NO_PAD.decode(body_raw).map_err(|_| {
HaysonCipherError::new(table, field, "holds a truncated cypher envelope").with_key_id(key_id)
})?;
if iv.len() != IV_BYTES || body.len() < TAG_BYTES {
return Err(HaysonCipherError::new(table, field, "holds a truncated cypher envelope").with_key_id(key_id));
}
let cipher = Aes256Gcm::new(&key_from_slice(material));
let iv_arr: [u8; IV_BYTES] = iv.as_slice().try_into().expect("checked length above");
let nonce = Nonce::from(iv_arr);
let aad = aad_of(table, key, field);
let plain = cipher.decrypt(&nonce, Payload { msg: &body, aad: &aad }).map_err(|_| {
HaysonCipherError::new(
table,
field,
"failed its authentication tag — the value, the record it sits on or the field name changed under it",
)
.with_key_id(key_id)
})?;
let value = serde_json::from_slice(&plain).map_err(|_| {
HaysonCipherError::new(
table,
field,
"holds a cypher envelope whose plaintext is not valid JSON",
)
.with_key_id(key_id)
})?;
Ok(Some(value))
}
}
pub fn assert_carried_seals(
schema: &TableSchema,
key: &str,
record: &HaysonRecord,
cipher: Option<&FieldCipher>,
carried: &[String],
) -> Result<(), HaysonCipherError> {
for field in carried {
let Some(value) = record.get(field) else { continue };
if value.is_null() {
continue;
}
let shaped = value
.as_str()
.and_then(|envelope| Some((envelope, envelope_shape(envelope)?)));
let Some((envelope, key_id)) = shaped else {
return Err(HaysonCipherError::new(
&schema.table,
field.clone(),
"was handed over as already sealed, but it is not a cypher envelope",
));
};
let Some(cipher) = cipher.filter(|cipher| cipher.knows(key_id)) else {
continue;
};
cipher.decrypt(&schema.table, key, field, envelope).map_err(|_| {
HaysonCipherError::new(
&schema.table,
field.clone(),
"was handed over as already sealed, but it does not open for this record and field",
)
.with_key_id(key_id)
})?;
}
Ok(())
}
pub fn seal_record(
schema: &TableSchema,
key: &str,
record: &HaysonRecord,
cipher: Option<&FieldCipher>,
fields: &[String],
carried: &[String],
) -> Result<HaysonRecord, HaysonCipherError> {
if fields.is_empty() {
return Ok(record.clone());
}
assert_carried_seals(schema, key, record, cipher, carried)?;
let mut sealed = record.clone();
for field in fields {
let Some(value) = sealed.get(field) else { continue };
if value.is_null() || carried.iter().any(|carried| carried == field) {
continue;
}
let Some(cipher) = cipher else {
return Err(HaysonCipherError::new(
&schema.table,
field.clone(),
"cannot be written without a key",
));
};
let envelope = cipher.encrypt(&schema.table, key, field, value);
sealed.insert(field.clone(), Value::String(envelope));
}
Ok(sealed)
}
pub fn open_record(
schema: &TableSchema,
key: &str,
record: &HaysonRecord,
cipher: Option<&FieldCipher>,
fields: &[String],
) -> Result<HaysonRecord, HaysonCipherError> {
if fields.is_empty() {
return Ok(record.clone());
}
let mut opened = record.clone();
for field in fields {
let Some(value) = opened.get(field) else { continue };
if value.is_null() || !is_envelope(value) {
continue;
}
let Some(cipher) = cipher else {
return Err(HaysonCipherError::new(
&schema.table,
field.clone(),
"cannot be read without a key",
));
};
let envelope = value.as_str().expect("is_envelope guarantees a string").to_string();
match cipher.decrypt(&schema.table, key, field, &envelope)? {
Some(plain) => {
opened.insert(field.clone(), plain);
}
None => {
opened.remove(field);
}
}
}
Ok(opened)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::parse_schema;
use serde_json::json;
fn key(byte: u8) -> Vec<u8> {
vec![byte; KEY_BYTES]
}
fn secrets_schema() -> TableSchema {
parse_schema(&json!({
"schemaVersion": 1,
"table": "secrets",
"revision": 1,
"primaryKey": "id",
"fields": {
"id": { "type": "string" },
"label": { "type": "string", "index": "exact" },
"token": { "type": "string", "cypher": true },
"pin": { "type": "int", "cypher": true },
},
}))
.unwrap()
}
fn cipher_with(keys: &[(&str, u8)], key_id: Option<&str>) -> FieldCipher {
FieldCipher::new(CipherOptions {
keys: keys.iter().map(|(id, byte)| (id.to_string(), key(*byte))).collect(),
key_id: key_id.map(str::to_string),
on_missing_key: None,
})
.unwrap()
}
#[test]
fn round_trips_a_value_with_the_correct_key() {
let cipher = cipher_with(&[("k1", 1)], None);
let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
assert!(envelope.starts_with("$cypher$v1$k1$"));
let plain = cipher.decrypt("secrets", "s1", "token", &envelope).unwrap();
assert_eq!(plain, Some(json!("hunter2")));
}
#[test]
fn two_equal_values_get_two_different_envelopes() {
let cipher = cipher_with(&[("k1", 1)], None);
let a = cipher.encrypt("secrets", "s1", "token", &json!("same"));
let b = cipher.encrypt("secrets", "s2", "token", &json!("same"));
assert_ne!(a, b);
}
#[test]
fn decryption_fails_with_the_wrong_key() {
let writer = cipher_with(&[("k1", 1)], None);
let envelope = writer.encrypt("secrets", "s1", "token", &json!("hunter2"));
let reader = cipher_with(&[("k1", 9)], None);
let error = reader.decrypt("secrets", "s1", "token", &envelope).unwrap_err();
assert!(error.reason.contains("authentication tag"));
}
#[test]
fn decryption_fails_when_the_ciphertext_is_tampered_with() {
let cipher = cipher_with(&[("k1", 1)], None);
let mut envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
envelope.push('A');
let error = cipher.decrypt("secrets", "s1", "token", &envelope).unwrap_err();
assert!(error.reason.contains("authentication tag") || error.reason.contains("truncated"));
}
#[test]
fn moving_a_value_to_another_field_fails_authentication() {
let cipher = cipher_with(&[("k1", 1)], None);
let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
let error = cipher.decrypt("secrets", "s1", "pin", &envelope).unwrap_err();
assert!(error.reason.contains("authentication tag"));
}
#[test]
fn moving_a_value_to_another_record_fails_authentication() {
let cipher = cipher_with(&[("k1", 1)], None);
let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
let error = cipher.decrypt("secrets", "s2", "token", &envelope).unwrap_err();
assert!(error.reason.contains("authentication tag"));
}
#[test]
fn moving_a_value_to_another_table_fails_authentication() {
let cipher = cipher_with(&[("k1", 1)], None);
let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
let error = cipher.decrypt("other", "s1", "token", &envelope).unwrap_err();
assert!(error.reason.contains("authentication tag"));
}
#[test]
fn missing_key_policy_throw_is_the_default() {
let cipher = FieldCipher::new(CipherOptions {
keys: HashMap::from([("k1".to_string(), key(1))]),
key_id: None,
on_missing_key: None,
})
.unwrap();
let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
let other = cipher_with(&[("other", 2)], None);
let error = other.decrypt("secrets", "s1", "token", &envelope).unwrap_err();
assert!(error.reason.contains("was not given"));
assert_eq!(error.key_id.as_deref(), Some("k1"));
}
#[test]
fn missing_key_policy_omit_drops_the_field() {
let cipher = cipher_with(&[("k1", 1)], None);
let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
let reader = FieldCipher::new(CipherOptions {
keys: HashMap::from([("other".to_string(), key(2))]),
key_id: None,
on_missing_key: Some(MissingKeyPolicy::Omit),
})
.unwrap();
assert_eq!(reader.decrypt("secrets", "s1", "token", &envelope).unwrap(), None);
}
#[test]
fn missing_key_policy_raw_returns_the_envelope_unchanged() {
let cipher = cipher_with(&[("k1", 1)], None);
let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
let reader = FieldCipher::new(CipherOptions {
keys: HashMap::from([("other".to_string(), key(2))]),
key_id: None,
on_missing_key: Some(MissingKeyPolicy::Raw),
})
.unwrap();
assert_eq!(
reader.decrypt("secrets", "s1", "token", &envelope).unwrap(),
Some(Value::String(envelope))
);
}
#[test]
fn rejects_a_key_that_is_not_32_bytes() {
let error = FieldCipher::new(CipherOptions {
keys: HashMap::from([("k".to_string(), vec![0u8; 16])]),
key_id: None,
on_missing_key: None,
})
.unwrap_err();
assert!(error.reason.contains("must be 32 bytes"));
}
#[test]
fn rejects_an_unknown_key_id() {
let error = FieldCipher::new(CipherOptions {
keys: HashMap::from([("a".to_string(), key(1)), ("b".to_string(), key(2))]),
key_id: Some("c".to_string()),
on_missing_key: None,
})
.unwrap_err();
assert!(error.reason.contains("is not among the keys given"));
}
#[test]
fn insists_on_a_key_id_when_several_keys_are_given() {
let error = FieldCipher::new(CipherOptions {
keys: HashMap::from([("a".to_string(), key(1)), ("b".to_string(), key(2))]),
key_id: None,
on_missing_key: None,
})
.unwrap_err();
assert!(error.reason.contains("keyId"));
}
#[test]
fn seal_record_leaves_a_null_value_alone() {
let schema = secrets_schema();
let cipher = cipher_with(&[("k1", 1)], None);
let fields = cypher_fields_of(&schema);
let record = HaysonRecord::from([("id".to_string(), json!("s1")), ("token".to_string(), Value::Null)]);
let sealed = seal_record(&schema, "s1", &record, Some(&cipher), &fields, &[]).unwrap();
assert_eq!(sealed.get("token"), Some(&Value::Null));
}
#[test]
fn seal_record_seals_a_plaintext_that_only_looks_like_an_envelope() {
let schema = secrets_schema();
let cipher = cipher_with(&[("k1", 1)], None);
let fields = cypher_fields_of(&schema);
let masquerade = "$cypher$v1$k1$not$real";
let record = HaysonRecord::from([
("id".to_string(), json!("s1")),
("token".to_string(), json!(masquerade)),
]);
let sealed = seal_record(&schema, "s1", &record, Some(&cipher), &fields, &[]).unwrap();
let stored = sealed.get("token").unwrap().as_str().unwrap();
assert_ne!(stored, masquerade, "the value reached the log in the clear");
let opened = open_record(&schema, "s1", &sealed, Some(&cipher), &fields).unwrap();
assert_eq!(opened.get("token"), Some(&json!(masquerade)));
}
#[test]
fn seal_record_seals_a_genuine_envelope_again_when_it_is_not_carried() {
let schema = secrets_schema();
let cipher = cipher_with(&[("k1", 1)], None);
let fields = cypher_fields_of(&schema);
let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
let record = HaysonRecord::from([
("id".to_string(), json!("s1")),
("token".to_string(), json!(envelope.clone())),
]);
let sealed = seal_record(&schema, "s1", &record, Some(&cipher), &fields, &[]).unwrap();
assert_ne!(sealed.get("token").unwrap().as_str().unwrap(), envelope);
}
#[test]
fn seal_record_carries_a_verified_envelope_byte_for_byte() {
let schema = secrets_schema();
let cipher = cipher_with(&[("k1", 1)], None);
let fields = cypher_fields_of(&schema);
let envelope = cipher.encrypt("secrets", "s1", "token", &json!("hunter2"));
let record = HaysonRecord::from([
("id".to_string(), json!("s1")),
("token".to_string(), json!(envelope.clone())),
]);
let carried = vec!["token".to_string()];
let sealed = seal_record(&schema, "s1", &record, Some(&cipher), &fields, &carried).unwrap();
assert_eq!(sealed.get("token").unwrap().as_str().unwrap(), envelope);
let opened = open_record(&schema, "s1", &sealed, Some(&cipher), &fields).unwrap();
assert_eq!(opened.get("token"), Some(&json!("hunter2")));
}
#[test]
fn a_carried_value_that_is_not_an_envelope_is_refused() {
let schema = secrets_schema();
let cipher = cipher_with(&[("k1", 1)], None);
let fields = cypher_fields_of(&schema);
let record = HaysonRecord::from([
("id".to_string(), json!("s1")),
("token".to_string(), json!("$cypher$v1$k1$not$real")),
]);
let error = seal_record(&schema, "s1", &record, Some(&cipher), &fields, &["token".to_string()]).unwrap_err();
assert!(error.reason.contains("is not a cypher envelope"), "{}", error.reason);
}
#[test]
fn a_carried_envelope_sealed_for_another_record_is_refused() {
let schema = secrets_schema();
let cipher = cipher_with(&[("k1", 1)], None);
let fields = cypher_fields_of(&schema);
let stolen = cipher.encrypt("secrets", "s2", "token", &json!("yours"));
let record = HaysonRecord::from([("id".to_string(), json!("s1")), ("token".to_string(), json!(stolen))]);
let error = seal_record(&schema, "s1", &record, Some(&cipher), &fields, &["token".to_string()]).unwrap_err();
assert!(
error.reason.contains("does not open for this record and field"),
"{}",
error.reason
);
assert_eq!(error.key_id.as_deref(), Some("k1"));
}
#[test]
fn a_carried_envelope_under_a_key_that_is_not_on_hand_is_taken_on_trust() {
let schema = secrets_schema();
let writer = cipher_with(&[("k1", 1)], None);
let fields = cypher_fields_of(&schema);
let envelope = writer.encrypt("secrets", "s1", "token", &json!("hunter2"));
let record = HaysonRecord::from([
("id".to_string(), json!("s1")),
("token".to_string(), json!(envelope.clone())),
]);
let stranger = cipher_with(&[("other", 2)], None);
let sealed = seal_record(&schema, "s1", &record, Some(&stranger), &fields, &["token".to_string()]).unwrap();
assert_eq!(sealed.get("token").unwrap().as_str().unwrap(), envelope);
}
#[test]
fn seal_record_without_a_cipher_refuses_to_write_a_cypher_field() {
let schema = secrets_schema();
let fields = cypher_fields_of(&schema);
let record = HaysonRecord::from([("id".to_string(), json!("s1")), ("token".to_string(), json!("secret"))]);
let error = seal_record(&schema, "s1", &record, None, &fields, &[]).unwrap_err();
assert!(error.reason.contains("cannot be written without a key"));
}
#[test]
fn seal_record_without_a_cipher_still_refuses_a_carried_value_that_is_not_an_envelope() {
let schema = secrets_schema();
let fields = cypher_fields_of(&schema);
let record = HaysonRecord::from([("id".to_string(), json!("s1")), ("token".to_string(), json!("secret"))]);
let error = seal_record(&schema, "s1", &record, None, &fields, &["token".to_string()]).unwrap_err();
assert!(error.reason.contains("is not a cypher envelope"), "{}", error.reason);
}
#[test]
fn seal_then_open_round_trips_every_cypher_field() {
let schema = secrets_schema();
let cipher = cipher_with(&[("k1", 1)], None);
let fields = cypher_fields_of(&schema);
let record = HaysonRecord::from([
("id".to_string(), json!("s1")),
("token".to_string(), json!("hunter2")),
("pin".to_string(), json!(4321)),
]);
let sealed = seal_record(&schema, "s1", &record, Some(&cipher), &fields, &[]).unwrap();
assert!(is_envelope(sealed.get("token").unwrap()));
assert!(is_envelope(sealed.get("pin").unwrap()));
let opened = open_record(&schema, "s1", &sealed, Some(&cipher), &fields).unwrap();
assert_eq!(opened.get("token"), Some(&json!("hunter2")));
assert_eq!(opened.get("pin"), Some(&json!(4321)));
}
#[test]
fn open_record_with_omit_policy_drops_the_field() {
let schema = secrets_schema();
let writer = cipher_with(&[("k1", 1)], None);
let fields = cypher_fields_of(&schema);
let record = HaysonRecord::from([("id".to_string(), json!("s1")), ("token".to_string(), json!("hunter2"))]);
let sealed = seal_record(&schema, "s1", &record, Some(&writer), &fields, &[]).unwrap();
let reader = FieldCipher::new(CipherOptions {
keys: HashMap::from([("other".to_string(), key(2))]),
key_id: None,
on_missing_key: Some(MissingKeyPolicy::Omit),
})
.unwrap();
let opened = open_record(&schema, "s1", &sealed, Some(&reader), &fields).unwrap();
assert!(!opened.contains_key("token"));
}
}