use crate::error::AppError;
const MAGIC: &[u8; 4] = b"VAE1";
const NONCE_LEN: usize = 12;
const TAG_LEN: usize = 16;
fn build_aad(keyspace: &str, key: &[u8]) -> Vec<u8> {
let ks = keyspace.as_bytes();
let mut aad = Vec::with_capacity(4 + ks.len() + key.len());
aad.extend_from_slice(&(ks.len() as u32).to_be_bytes());
aad.extend_from_slice(ks);
aad.extend_from_slice(key);
aad
}
pub fn encrypt_value(
enc_key: &[u8; 32],
keyspace: &str,
store_key: &[u8],
plaintext: &[u8],
) -> Result<Vec<u8>, AppError> {
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead, aead::Payload};
let cipher = Aes256Gcm::new_from_slice(enc_key)
.map_err(|e| AppError::Internal(format!("AES key error: {e}")))?;
let mut nonce_bytes = [0u8; NONCE_LEN];
rand::fill(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let aad = build_aad(keyspace, store_key);
let ciphertext = cipher
.encrypt(
nonce,
Payload {
msg: plaintext,
aad: &aad,
},
)
.map_err(|e| AppError::Internal(format!("AES-GCM encryption failed: {e}")))?;
let mut output = Vec::with_capacity(MAGIC.len() + NONCE_LEN + ciphertext.len());
output.extend_from_slice(MAGIC);
output.extend_from_slice(&nonce_bytes);
output.extend_from_slice(&ciphertext);
Ok(output)
}
pub(crate) fn is_v1_encrypted(data: &[u8]) -> bool {
data.len() >= MAGIC.len() && &data[..MAGIC.len()] == MAGIC
}
fn decrypt_value(
enc_key: &[u8; 32],
keyspace: &str,
store_key: &[u8],
data: &[u8],
) -> Result<Vec<u8>, AppError> {
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead, aead::Payload};
if data.len() < MAGIC.len() || &data[..MAGIC.len()] != MAGIC {
return Err(AppError::Internal(
"encrypted value is not in the AAD-authenticated v1 format \
(likely written by a pre-integrity-fix build); this store is \
incompatible — re-bootstrap or restore from backup"
.into(),
));
}
let body = &data[MAGIC.len()..];
if body.len() < NONCE_LEN + TAG_LEN {
return Err(AppError::Internal(
"encrypted value too short (missing nonce or auth tag)".into(),
));
}
let cipher = Aes256Gcm::new_from_slice(enc_key)
.map_err(|e| AppError::Internal(format!("AES key error: {e}")))?;
let nonce = Nonce::from_slice(&body[..NONCE_LEN]);
let ciphertext = &body[NONCE_LEN..];
let aad = build_aad(keyspace, store_key);
cipher
.decrypt(
nonce,
Payload {
msg: ciphertext,
aad: &aad,
},
)
.map_err(|e| {
AppError::Internal(format!(
"AES-GCM decryption failed (corrupt data, wrong key, or value \
moved to a different keyspace/key): {e}"
))
})
}
pub fn maybe_decrypt_bytes(
enc_key: Option<&[u8; 32]>,
keyspace: &str,
store_key: &[u8],
data: &[u8],
) -> Result<Vec<u8>, AppError> {
match enc_key {
Some(k) => decrypt_value(k, keyspace, store_key, data),
None => Ok(data.to_vec()),
}
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: [u8; 32] = [7u8; 32];
#[test]
fn round_trip_at_same_location() {
let ct = encrypt_value(&KEY, "acl", b"acl:did:key:zX", b"secret").unwrap();
let pt = decrypt_value(&KEY, "acl", b"acl:did:key:zX", &ct).unwrap();
assert_eq!(pt, b"secret");
}
#[test]
fn cross_key_paste_is_rejected() {
let ct = encrypt_value(&KEY, "acl", b"acl:victim", b"admin-row").unwrap();
let err = decrypt_value(&KEY, "acl", b"acl:attacker", &ct);
assert!(err.is_err(), "cross-key paste must fail authentication");
}
#[test]
fn cross_keyspace_paste_is_rejected() {
let ct = encrypt_value(&KEY, "keys", b"active_seed_id", b"\x01").unwrap();
let err = decrypt_value(&KEY, "contexts", b"active_seed_id", &ct);
assert!(
err.is_err(),
"cross-keyspace paste must fail authentication"
);
}
#[test]
fn wrong_key_is_rejected() {
let ct = encrypt_value(&KEY, "acl", b"k", b"v").unwrap();
let err = decrypt_value(&[9u8; 32], "acl", b"k", &ct);
assert!(err.is_err());
}
#[test]
fn legacy_unauthenticated_value_gives_clear_error() {
let legacy = vec![0u8; NONCE_LEN + TAG_LEN + 4];
let err = decrypt_value(&KEY, "acl", b"k", &legacy).unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("v1 format") || msg.contains("re-bootstrap"),
"expected a format-incompatibility hint, got: {msg}"
);
}
#[test]
fn maybe_decrypt_without_key_is_passthrough() {
let data = b"plain";
let out = maybe_decrypt_bytes(None, "acl", b"k", data).unwrap();
assert_eq!(out, data);
}
}