use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use thiserror::Error;
const MAGIC: u8 = 0xA7;
const VERSION: u8 = 0x01;
const ALG_AES_256_GCM: u8 = 0x01;
const MODE_RANDOMIZED: u8 = 0x00;
const MODE_DETERMINISTIC: u8 = 0x01;
const NONCE_LEN: usize = 12;
const HEADER_LEN: usize = 1 + 1 + 1 + 1 + 4 + NONCE_LEN;
pub const CREDENTIALS_NAMESPACE: &str = "active_record_encryption";
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Error)]
pub enum EncryptionError {
#[error(
"attribute encryption key ring is not installed; configure `{ns}.primary_key` via `autumn credentials edit`",
ns = CREDENTIALS_NAMESPACE
)]
NoKeyRing,
#[error("invalid {what} in `{ns}`: expected 64 hex characters, got {len}", ns = CREDENTIALS_NAMESPACE)]
InvalidKeyFormat {
what: String,
len: usize,
},
#[error(
"deterministic encryption requires `{ns}.deterministic_key`; add it via `autumn credentials edit`",
ns = CREDENTIALS_NAMESPACE
)]
NoDeterministicKey,
#[error(
"attribute encryption requires `{ns}.key_derivation_salt`; add it via `autumn credentials edit` (e.g. `openssl rand -hex 16`)",
ns = CREDENTIALS_NAMESPACE
)]
MissingSalt,
#[error("malformed encryption envelope: {0}")]
MalformedEnvelope(&'static str),
#[error("unsupported encryption envelope (version={version:#04x}, alg={alg:#04x})")]
UnsupportedEnvelope {
version: u8,
alg: u8,
},
#[error("no data key with id {0:#010x} is available; was it removed from the rotation list?")]
UnknownKeyId(u32),
#[error("decryption failed: wrong key or corrupted ciphertext")]
DecryptionFailed,
#[error(
"equality lookup on randomized-encrypted column `{table}.{column}` is impossible; \
mark it `#[encrypted(deterministic)]` to support `find_by`/`exists_by` lookups"
)]
RandomizedEqualityLookup {
table: String,
column: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Randomized,
Deterministic,
}
#[derive(Clone)]
pub struct DataKey {
id: u32,
key: [u8; 32],
}
impl std::fmt::Debug for DataKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DataKey")
.field("id", &format_args!("{:#010x}", self.id))
.field("key", &"[REDACTED]")
.finish()
}
}
impl DataKey {
#[must_use]
pub const fn id(&self) -> u32 {
self.id
}
}
fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
let mut mac = <HmacSha256 as Mac>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(msg);
mac.finalize().into_bytes().into()
}
fn decode_master_hex(hex_str: &str, what: &str) -> Result<[u8; 32], EncryptionError> {
let hex_str = hex_str.trim();
if hex_str.len() != 64 {
return Err(EncryptionError::InvalidKeyFormat {
what: what.to_owned(),
len: hex_str.len(),
});
}
let decoded = hex::decode(hex_str).map_err(|_| EncryptionError::InvalidKeyFormat {
what: what.to_owned(),
len: hex_str.len(),
})?;
let mut bytes = [0u8; 32];
bytes.copy_from_slice(&decoded);
Ok(bytes)
}
fn derive_data_key(master: &[u8; 32], salt: &[u8], domain: &[u8]) -> DataKey {
let mut msg = Vec::with_capacity(domain.len() + salt.len());
msg.extend_from_slice(domain);
msg.extend_from_slice(salt);
let key = hmac_sha256(master, &msg);
let id_digest = {
let mut h = Sha256::new();
h.update(b"autumn:id:v1:");
h.update(key);
h.finalize()
};
let id = u32::from_be_bytes([id_digest[0], id_digest[1], id_digest[2], id_digest[3]]);
DataKey { id, key }
}
#[derive(Debug, Clone)]
pub struct KeyRing {
primary: DataKey,
retired: Vec<DataKey>,
deterministic: Option<DataKey>,
retired_deterministic: Vec<DataKey>,
}
impl KeyRing {
pub fn from_master_hex(
primary_hex: &str,
retired_hex: &[String],
deterministic_hex: Option<&str>,
salt: &[u8],
) -> Result<Self, EncryptionError> {
let primary = derive_data_key(
&decode_master_hex(primary_hex, "primary_key")?,
salt,
b"autumn:data:v1:",
);
let mut retired = Vec::with_capacity(retired_hex.len());
let mut retired_deterministic = Vec::with_capacity(retired_hex.len());
for (i, k) in retired_hex.iter().enumerate() {
let bytes = decode_master_hex(k, &format!("retired_keys[{i}]"))?;
retired.push(derive_data_key(&bytes, salt, b"autumn:data:v1:"));
retired_deterministic.push(derive_data_key(&bytes, salt, b"autumn:det:v1:"));
}
let deterministic = match deterministic_hex {
Some(k) => Some(derive_data_key(
&decode_master_hex(k, "deterministic_key")?,
salt,
b"autumn:det:v1:",
)),
None => None,
};
Ok(Self {
primary,
retired,
deterministic,
retired_deterministic,
})
}
#[must_use]
pub const fn primary_key_id(&self) -> u32 {
self.primary.id
}
fn find_key(&self, mode: u8, key_id: u32) -> Option<&DataKey> {
if mode == MODE_DETERMINISTIC {
return self
.deterministic
.as_ref()
.filter(|k| k.id == key_id)
.or_else(|| self.retired_deterministic.iter().find(|k| k.id == key_id));
}
if self.primary.id == key_id {
return Some(&self.primary);
}
self.retired.iter().find(|k| k.id == key_id)
}
pub fn encrypt(&self, mode: Mode, plaintext: &[u8]) -> Result<String, EncryptionError> {
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::Engine as _;
let (mode_byte, data_key, nonce_bytes) = match mode {
Mode::Randomized => {
let mut nonce = [0u8; NONCE_LEN];
getrandom::getrandom(&mut nonce).expect("OS RNG failed");
(MODE_RANDOMIZED, &self.primary, nonce)
}
Mode::Deterministic => {
let det = self
.deterministic
.as_ref()
.ok_or(EncryptionError::NoDeterministicKey)?;
let tag = hmac_sha256(&det.key, plaintext);
let mut nonce = [0u8; NONCE_LEN];
nonce.copy_from_slice(&tag[..NONCE_LEN]);
(MODE_DETERMINISTIC, det, nonce)
}
};
let cipher = Aes256Gcm::new_from_slice(&data_key.key).expect("32-byte key");
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce_bytes), plaintext)
.expect("AES-GCM encryption cannot fail for valid inputs");
let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len());
out.push(MAGIC);
out.push(VERSION);
out.push(ALG_AES_256_GCM);
out.push(mode_byte);
out.extend_from_slice(&data_key.id.to_be_bytes());
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ciphertext);
Ok(base64::engine::general_purpose::STANDARD.encode(out))
}
pub fn decrypt(&self, envelope: &str) -> Result<Vec<u8>, EncryptionError> {
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::Engine as _;
let raw = base64::engine::general_purpose::STANDARD
.decode(envelope.trim())
.map_err(|_| EncryptionError::MalformedEnvelope("not valid base64"))?;
if raw.len() < HEADER_LEN {
return Err(EncryptionError::MalformedEnvelope("truncated header"));
}
if raw[0] != MAGIC {
return Err(EncryptionError::MalformedEnvelope("bad magic byte"));
}
let version = raw[1];
let alg = raw[2];
if version != VERSION || alg != ALG_AES_256_GCM {
return Err(EncryptionError::UnsupportedEnvelope { version, alg });
}
let mode = raw[3];
let key_id = u32::from_be_bytes([raw[4], raw[5], raw[6], raw[7]]);
let nonce_bytes = &raw[8..8 + NONCE_LEN];
let ciphertext = &raw[HEADER_LEN..];
let data_key = self
.find_key(mode, key_id)
.ok_or(EncryptionError::UnknownKeyId(key_id))?;
let cipher = Aes256Gcm::new_from_slice(&data_key.key).expect("32-byte key");
cipher
.decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
.map_err(|_| EncryptionError::DecryptionFailed)
}
}
static KEY_RING: OnceLock<KeyRing> = OnceLock::new();
static DEBUG_PLAINTEXT: AtomicBool = AtomicBool::new(false);
pub fn install_key_ring(ring: KeyRing) -> bool {
KEY_RING.set(ring).is_ok()
}
#[must_use]
pub fn key_ring() -> Option<&'static KeyRing> {
KEY_RING.get()
}
pub fn encrypt_text(mode: Mode, plaintext: &str) -> Result<String, EncryptionError> {
key_ring()
.ok_or(EncryptionError::NoKeyRing)?
.encrypt(mode, plaintext.as_bytes())
}
pub fn decrypt_text(envelope: &str) -> Result<String, EncryptionError> {
let bytes = key_ring()
.ok_or(EncryptionError::NoKeyRing)?
.decrypt(envelope)?;
String::from_utf8(bytes).map_err(|_| EncryptionError::DecryptionFailed)
}
pub fn deterministic_ciphertext(plaintext: &str) -> Result<String, EncryptionError> {
encrypt_text(Mode::Deterministic, plaintext)
}
pub fn set_debug_plaintext(enabled: bool) {
DEBUG_PLAINTEXT.store(enabled, Ordering::Relaxed);
}
#[must_use]
pub fn debug_plaintext_enabled() -> bool {
DEBUG_PLAINTEXT.load(Ordering::Relaxed)
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct EncryptionCredentials {
pub primary_key: String,
#[serde(default)]
pub deterministic_key: Option<String>,
#[serde(default)]
pub key_derivation_salt: Option<String>,
#[serde(default)]
pub retired_keys: Vec<String>,
}
impl EncryptionCredentials {
pub fn to_key_ring(&self) -> Result<KeyRing, EncryptionError> {
let salt = self
.key_derivation_salt
.as_deref()
.ok_or(EncryptionError::MissingSalt)?;
KeyRing::from_master_hex(
&self.primary_key,
&self.retired_keys,
self.deterministic_key.as_deref(),
salt.as_bytes(),
)
}
}
pub fn key_ring_from_credentials(
store: &crate::credentials::CredentialsStore,
) -> Result<Option<KeyRing>, EncryptionError> {
match store.get::<EncryptionCredentials>(CREDENTIALS_NAMESPACE) {
Some(creds) => Ok(Some(creds.to_key_ring()?)),
None => Ok(None),
}
}
#[derive(Debug)]
pub struct EncryptedColumnDescriptor {
pub model: &'static str,
pub table: &'static str,
pub column: &'static str,
pub deterministic: bool,
pub admin_visible: bool,
pub versioned_ciphertext: bool,
}
inventory::collect!(EncryptedColumnDescriptor);
#[must_use]
pub fn registered_encrypted_columns() -> Vec<&'static EncryptedColumnDescriptor> {
inventory::iter::<EncryptedColumnDescriptor>
.into_iter()
.collect()
}
#[must_use]
pub fn registered_encrypted_column_names() -> Vec<String> {
let mut names: Vec<String> = registered_encrypted_columns()
.iter()
.map(|d| d.column.to_owned())
.collect();
names.sort_unstable();
names.dedup();
names
}
#[must_use]
pub fn is_encrypted_column(table: &str, column: &str) -> bool {
registered_encrypted_columns()
.iter()
.any(|d| d.table == table && d.column == column)
}
pub fn encode_derived_query_param(
table: &str,
column: &str,
plaintext: impl AsRef<str>,
) -> Result<String, EncryptionError> {
let plaintext = plaintext.as_ref();
for d in registered_encrypted_columns() {
if d.table == table && d.column == column {
if d.deterministic {
return encrypt_text(Mode::Deterministic, plaintext);
}
return Err(EncryptionError::RandomizedEqualityLookup {
table: table.to_owned(),
column: column.to_owned(),
});
}
}
Ok(plaintext.to_owned())
}
#[must_use]
pub fn is_encrypted_column_name(column: &str) -> bool {
registered_encrypted_columns()
.iter()
.any(|d| d.column == column)
}
#[must_use]
pub fn admin_redacts_column_name(column: &str) -> bool {
for d in registered_encrypted_columns() {
if d.column == column && !d.admin_visible {
return true;
}
}
false
}
#[must_use]
pub fn encrypted_columns_for_table(table: &str) -> Vec<&'static str> {
registered_encrypted_columns()
.iter()
.filter(|d| d.table == table)
.map(|d| d.column)
.collect()
}
pub fn merge_encrypted_columns_for_table(table: &str, columns: &mut Vec<&'static str>) {
for d in registered_encrypted_columns() {
if d.table == table && !d.versioned_ciphertext && !columns.contains(&d.column) {
columns.push(d.column);
}
}
}
pub fn encrypt_versioned_columns_in_value(table: &str, value: &mut serde_json::Value) {
let Some(obj) = value.as_object_mut() else {
return;
};
for d in registered_encrypted_columns() {
if d.table != table || !d.versioned_ciphertext {
continue;
}
if let Some(field) = obj.get_mut(d.column) {
if field.is_null() {
continue;
}
let marker = || serde_json::Value::String("<encrypted>".to_owned());
let replacement = field.as_str().map_or_else(marker, |plaintext| {
encrypt_text(Mode::Deterministic, plaintext)
.map_or_else(|_| marker(), serde_json::Value::String)
});
*field = replacement;
}
}
}
pub fn encrypt_persisted_columns_in_value(table: &str, value: &mut serde_json::Value) {
let Some(obj) = value.as_object_mut() else {
return;
};
for d in registered_encrypted_columns() {
if d.table != table {
continue;
}
if let Some(field) = obj.get_mut(d.column) {
if field.is_null() {
continue;
}
let mode = if d.deterministic {
Mode::Deterministic
} else {
Mode::Randomized
};
let marker = || serde_json::Value::String("<encrypted>".to_owned());
let replacement = field.as_str().map_or_else(marker, |plaintext| {
encrypt_text(mode, plaintext).map_or_else(|_| marker(), serde_json::Value::String)
});
*field = replacement;
}
}
}
pub fn decrypt_persisted_columns_in_value(table: &str, value: &mut serde_json::Value) {
let Some(obj) = value.as_object_mut() else {
return;
};
for d in registered_encrypted_columns() {
if d.table != table {
continue;
}
if let Some(field) = obj.get_mut(d.column) {
let Some(envelope) = field.as_str() else {
continue;
};
if let Ok(plaintext) = decrypt_text(envelope) {
*field = serde_json::Value::String(plaintext);
}
}
}
}
pub fn init_attribute_encryption(
store: &crate::credentials::CredentialsStore,
) -> Result<(), String> {
let columns = registered_encrypted_columns();
if columns.is_empty() {
return Ok(());
}
let needs_deterministic = columns
.iter()
.any(|c| c.deterministic || c.versioned_ciphertext);
match key_ring_from_credentials(store) {
Ok(Some(ring)) => {
if needs_deterministic && ring.deterministic.is_none() {
return Err(format!(
"An encrypted column requires deterministic encryption (deterministic mode \
or `versioned_ciphertext`) but `{CREDENTIALS_NAMESPACE}.deterministic_key` is missing.\n \
hint: add it with `autumn credentials edit` (generate one with `openssl rand -hex 32`)."
));
}
install_key_ring(ring);
Ok(())
}
Ok(None) => {
let first = columns[0];
Err(format!(
"Encrypted column `{}.{}` requires a master key, but `{CREDENTIALS_NAMESPACE}.primary_key` \
is not configured.\n hint: run `autumn credentials edit` and add:\n \
[{CREDENTIALS_NAMESPACE}]\n primary_key = \"<64 hex chars from `openssl rand -hex 32`>\"",
first.table, first.column
))
}
Err(e) => Err(format!(
"Invalid attribute-encryption key material in `{CREDENTIALS_NAMESPACE}`: {e}\n \
hint: keys must be 64 hex characters; regenerate with `openssl rand -hex 32`."
)),
}
}
#[cfg(feature = "db")]
mod diesel_types;
#[cfg(feature = "db")]
pub use diesel_types::{DeterministicText, RandomizedText};
#[cfg(test)]
mod tests {
use super::*;
inventory::submit! {
EncryptedColumnDescriptor {
model: "VcModel",
table: "vc_table",
column: "vc_col",
deterministic: false,
admin_visible: false,
versioned_ciphertext: true,
}
}
inventory::submit! {
EncryptedColumnDescriptor {
model: "VcModel",
table: "vc_table",
column: "visible_col",
deterministic: false,
admin_visible: true,
versioned_ciphertext: false,
}
}
inventory::submit! {
EncryptedColumnDescriptor {
model: "DqModel",
table: "dq_table",
column: "det_col",
deterministic: true,
admin_visible: false,
versioned_ciphertext: false,
}
}
inventory::submit! {
EncryptedColumnDescriptor {
model: "DqModel",
table: "dq_table",
column: "rnd_col",
deterministic: false,
admin_visible: false,
versioned_ciphertext: false,
}
}
const KEY_A: &str = "1111111111111111111111111111111111111111111111111111111111111111";
const KEY_B: &str = "2222222222222222222222222222222222222222222222222222222222222222";
const DET: &str = "3333333333333333333333333333333333333333333333333333333333333333";
fn salt() -> &'static [u8] {
static S: OnceLock<[u8; 16]> = OnceLock::new();
S.get_or_init(|| {
let mut b = [0u8; 16];
getrandom::getrandom(&mut b).expect("OS RNG");
b
})
}
fn salt2() -> &'static [u8] {
static S: OnceLock<[u8; 16]> = OnceLock::new();
S.get_or_init(|| {
let mut b = [1u8; 16];
getrandom::getrandom(&mut b).expect("OS RNG");
b
})
}
fn ring() -> KeyRing {
KeyRing::from_master_hex(KEY_A, &[], Some(DET), salt()).unwrap()
}
#[test]
fn randomized_roundtrip() {
let r = ring();
let env = r.encrypt(Mode::Randomized, b"hello").unwrap();
assert_eq!(r.decrypt(&env).unwrap(), b"hello");
}
#[test]
fn deterministic_key_rotation_reads_old_rows() {
let old = KeyRing::from_master_hex(KEY_A, &[], Some(DET), salt()).unwrap();
let written = old.encrypt(Mode::Deterministic, b"pii").unwrap();
let rotated =
KeyRing::from_master_hex(KEY_A, &[DET.to_string()], Some(KEY_B), salt()).unwrap();
assert_eq!(rotated.decrypt(&written).unwrap(), b"pii");
let fresh = rotated.encrypt(Mode::Deterministic, b"pii").unwrap();
assert_ne!(fresh, written, "new det key produces different ciphertext");
assert_eq!(rotated.decrypt(&fresh).unwrap(), b"pii");
}
#[test]
fn randomized_is_nondeterministic() {
let r = ring();
let a = r.encrypt(Mode::Randomized, b"same").unwrap();
let b = r.encrypt(Mode::Randomized, b"same").unwrap();
assert_ne!(a, b, "fresh nonce per write must vary ciphertext");
assert_eq!(r.decrypt(&a).unwrap(), r.decrypt(&b).unwrap());
}
#[test]
fn deterministic_is_stable_and_equal_across_rings() {
let r1 = ring();
let r2 = ring();
let a = r1.encrypt(Mode::Deterministic, b"a@b.com").unwrap();
let b = r2.encrypt(Mode::Deterministic, b"a@b.com").unwrap();
assert_eq!(
a, b,
"deterministic ciphertext must be stable for equality lookups"
);
let c = r1.encrypt(Mode::Deterministic, b"other@b.com").unwrap();
assert_ne!(a, c);
assert_eq!(r1.decrypt(&a).unwrap(), b"a@b.com");
}
#[test]
fn deterministic_without_key_errors() {
let r = KeyRing::from_master_hex(KEY_A, &[], None, salt()).unwrap();
assert!(matches!(
r.encrypt(Mode::Deterministic, b"x"),
Err(EncryptionError::NoDeterministicKey)
));
}
#[test]
fn envelope_header_is_documented_format() {
use base64::Engine as _;
let r = ring();
let env = r.encrypt(Mode::Randomized, b"data").unwrap();
let raw = base64::engine::general_purpose::STANDARD
.decode(&env)
.unwrap();
assert_eq!(raw[0], MAGIC);
assert_eq!(raw[1], VERSION);
assert_eq!(raw[2], ALG_AES_256_GCM);
assert_eq!(raw[3], MODE_RANDOMIZED);
let key_id = u32::from_be_bytes([raw[4], raw[5], raw[6], raw[7]]);
assert_eq!(key_id, r.primary_key_id());
assert!(raw.len() > HEADER_LEN);
}
#[test]
fn key_id_is_stable_across_rebuilds() {
let r1 = KeyRing::from_master_hex(KEY_A, &[], None, salt()).unwrap();
let r2 = KeyRing::from_master_hex(KEY_A, &[], None, salt()).unwrap();
assert_eq!(r1.primary_key_id(), r2.primary_key_id());
}
#[test]
fn rotation_reads_old_writes_with_retired_key() {
let old = KeyRing::from_master_hex(KEY_A, &[], None, salt()).unwrap();
let written = old.encrypt(Mode::Randomized, b"legacy-row").unwrap();
let rotated = KeyRing::from_master_hex(KEY_B, &[KEY_A.to_string()], None, salt()).unwrap();
assert_eq!(rotated.decrypt(&written).unwrap(), b"legacy-row");
let fresh = rotated.encrypt(Mode::Randomized, b"new-row").unwrap();
assert_ne!(rotated.primary_key_id(), old.primary_key_id());
assert_eq!(rotated.decrypt(&fresh).unwrap(), b"new-row");
}
#[test]
fn fully_retired_key_id_is_named_in_error() {
let old = KeyRing::from_master_hex(KEY_A, &[], None, salt()).unwrap();
let written = old.encrypt(Mode::Randomized, b"x").unwrap();
let other = KeyRing::from_master_hex(KEY_B, &[], None, salt()).unwrap();
assert!(matches!(
other.decrypt(&written),
Err(EncryptionError::UnknownKeyId(_))
));
}
#[test]
fn wrong_salt_fails_decryption() {
let a = KeyRing::from_master_hex(KEY_A, &[], None, salt()).unwrap();
let b = KeyRing::from_master_hex(KEY_A, &[], None, salt2()).unwrap();
let env = a.encrypt(Mode::Randomized, b"x").unwrap();
assert!(b.decrypt(&env).is_err());
}
#[test]
fn invalid_key_hex_is_rejected() {
assert!(matches!(
KeyRing::from_master_hex("tooshort", &[], None, salt()),
Err(EncryptionError::InvalidKeyFormat { .. })
));
}
#[test]
fn credentials_namespace_builds_ring() {
let creds = EncryptionCredentials {
primary_key: KEY_A.to_string(),
deterministic_key: Some(DET.to_string()),
key_derivation_salt: Some(hex::encode(salt())),
retired_keys: vec![],
};
let ring = creds.to_key_ring().unwrap();
let env = ring.encrypt(Mode::Randomized, b"z").unwrap();
assert_eq!(ring.decrypt(&env).unwrap(), b"z");
}
#[test]
fn missing_salt_is_rejected() {
let creds = EncryptionCredentials {
primary_key: KEY_A.to_string(),
deterministic_key: None,
key_derivation_salt: None,
retired_keys: vec![],
};
assert!(matches!(
creds.to_key_ring(),
Err(EncryptionError::MissingSalt)
));
}
#[test]
fn versioned_ciphertext_column_excluded_from_sensitive_marker() {
let mut cols: Vec<&'static str> = Vec::new();
merge_encrypted_columns_for_table("vc_table", &mut cols);
assert!(
!cols.contains(&"vc_col"),
"versioned_ciphertext excluded: {cols:?}"
);
}
#[test]
fn versioned_ciphertext_payload_is_deterministic_ciphertext_not_plaintext() {
install_key_ring(KeyRing::from_master_hex(KEY_A, &[], Some(DET), salt()).unwrap());
let mut v = serde_json::json!({ "id": 1, "vc_col": "topsecret", "plain": "ok" });
encrypt_versioned_columns_in_value("vc_table", &mut v);
let stored = v["vc_col"].as_str().unwrap();
assert_ne!(stored, "topsecret", "version payload must be ciphertext");
assert!(!stored.contains("topsecret"), "no plaintext leak: {stored}");
assert_eq!(v["plain"], "ok", "non-encrypted column untouched");
let mut v2 = serde_json::json!({ "vc_col": "topsecret" });
encrypt_versioned_columns_in_value("vc_table", &mut v2);
assert_eq!(v2["vc_col"], v["vc_col"]);
let ring = key_ring().unwrap();
assert_eq!(
String::from_utf8(ring.decrypt(stored).unwrap()).unwrap(),
"topsecret"
);
}
#[test]
fn derived_query_param_encoding_matches_column_mode() {
install_key_ring(KeyRing::from_master_hex(KEY_A, &[], Some(DET), salt()).unwrap());
let encoded =
encode_derived_query_param("dq_table", "det_col", "alice@example.com").unwrap();
assert_ne!(encoded, "alice@example.com", "must be ciphertext");
assert_eq!(
encoded,
deterministic_ciphertext("alice@example.com").unwrap(),
"must equal the deterministic ciphertext used on write"
);
let err = encode_derived_query_param("dq_table", "rnd_col", "x").unwrap_err();
assert!(matches!(
err,
EncryptionError::RandomizedEqualityLookup { .. }
));
let msg = err.to_string();
assert!(msg.contains("dq_table.rnd_col"), "names the column: {msg}");
assert!(msg.contains("deterministic"), "suggests the fix: {msg}");
assert_eq!(
encode_derived_query_param("dq_table", "not_encrypted", "plain").unwrap(),
"plain"
);
}
#[test]
fn decrypt_persisted_columns_handles_each_branch() {
install_key_ring(KeyRing::from_master_hex(KEY_A, &[], Some(DET), salt()).unwrap());
let envelope = encrypt_text(Mode::Deterministic, "secret@x.com").unwrap();
let mut v = serde_json::json!({
"det_col": envelope,
"rnd_col": serde_json::Value::Null,
"not_registered": "plain",
});
decrypt_persisted_columns_in_value("dq_table", &mut v);
assert_eq!(
v["det_col"], "secret@x.com",
"recoverable envelope decrypts"
);
assert!(v["rnd_col"].is_null(), "null is left untouched");
assert_eq!(
v["not_registered"], "plain",
"unregistered column untouched"
);
let mut marker = serde_json::json!({ "det_col": "<encrypted>" });
decrypt_persisted_columns_in_value("dq_table", &mut marker);
assert_eq!(marker["det_col"], "<encrypted>");
let mut arr = serde_json::json!([1, 2, 3]);
decrypt_persisted_columns_in_value("dq_table", &mut arr);
assert!(arr.is_array());
}
#[test]
fn admin_visibility_helper_respects_opt_in() {
assert!(
admin_redacts_column_name("vc_col"),
"default column is redacted"
);
assert!(
!admin_redacts_column_name("visible_col"),
"admin_visible column is not redacted"
);
assert!(
!admin_redacts_column_name("not_encrypted_at_all"),
"non-encrypted column is not forced-redacted by this helper"
);
}
}