use crate::crypto::{FipsCrypto, SALT_SIZE};
use crate::error::{Result, VaultError};
pub const MAGIC: &[u8; 8] = b"AIMVSEAL";
pub const FORMAT_VERSION: u8 = 1;
const KDF_ARGON2ID: u8 = 1;
const HEADER_PREFIX_LEN: usize = 8 + 1 + 1 + 2;
#[must_use]
pub fn is_sealed(data: &[u8]) -> bool {
data.len() >= MAGIC.len() && &data[..MAGIC.len()] == MAGIC
}
pub fn seal(plaintext: &[u8], passphrase: Vec<u8>) -> Result<Vec<u8>> {
let crypto = FipsCrypto::new()?;
let (key, salt) = crypto.derive_key(passphrase, None)?;
let body = crypto.encrypt(plaintext, &key)?;
let salt_len = u16::try_from(salt.len())
.map_err(|_| VaultError::CryptoError("Salt too long to encode".to_string()))?;
let mut out = Vec::with_capacity(HEADER_PREFIX_LEN + salt.len() + body.len());
out.extend_from_slice(MAGIC);
out.push(FORMAT_VERSION);
out.push(KDF_ARGON2ID);
out.extend_from_slice(&salt_len.to_be_bytes());
out.extend_from_slice(&salt);
out.extend_from_slice(&body);
Ok(out)
}
pub fn open(sealed: &[u8], passphrase: Vec<u8>) -> Result<Vec<u8>> {
if !is_sealed(sealed) {
return Err(VaultError::CryptoError(
"Not an AIMVSEAL object: missing magic. An object uploaded by \
a version before 4.3.0 is stored as plaintext and needs no key."
.to_string(),
));
}
if sealed.len() < HEADER_PREFIX_LEN {
return Err(VaultError::CryptoError(
"Sealed object truncated: shorter than its header".to_string(),
));
}
let version = sealed[8];
if version != FORMAT_VERSION {
return Err(VaultError::CryptoError(format!(
"Unsupported envelope version {version}: this build understands \
version {FORMAT_VERSION}. Upgrade `aim` to read it."
)));
}
let kdf = sealed[9];
if kdf != KDF_ARGON2ID {
return Err(VaultError::CryptoError(format!(
"Unsupported KDF id {kdf} in sealed object"
)));
}
let salt_len = usize::from(u16::from_be_bytes([sealed[10], sealed[11]]));
let body_start = HEADER_PREFIX_LEN
.checked_add(salt_len)
.ok_or_else(|| VaultError::CryptoError("Salt length overflows".to_string()))?;
if sealed.len() <= body_start {
return Err(VaultError::CryptoError(
"Sealed object truncated: no ciphertext after the salt".to_string(),
));
}
let salt = sealed[HEADER_PREFIX_LEN..body_start].to_vec();
let crypto = FipsCrypto::new()?;
let (key, _) = crypto.derive_key(passphrase, Some(salt))?;
crypto.decrypt(&sealed[body_start..], &key)
}
#[must_use]
pub const fn salt_len() -> usize {
SALT_SIZE
}
#[cfg(test)]
mod tests {
use super::*;
fn pass() -> Vec<u8> {
b"correct horse battery staple".to_vec()
}
#[test]
fn test_roundtrip() {
let plaintext = b"model weights, or near enough".to_vec();
let sealed = seal(&plaintext, pass()).unwrap();
assert_eq!(open(&sealed, pass()).unwrap(), plaintext);
}
#[test]
fn test_plaintext_does_not_appear_in_the_sealed_object() {
let needle = b"SECRET-TENSOR-DATA-DO-NOT-LEAK";
let sealed = seal(needle, pass()).unwrap();
assert!(
!sealed.windows(needle.len()).any(|w| w == needle),
"plaintext survived into the sealed object"
);
}
#[test]
fn test_wrong_passphrase_is_rejected() {
let sealed = seal(b"payload", pass()).unwrap();
let err = open(&sealed, b"wrong passphrase".to_vec()).unwrap_err();
assert!(
matches!(err, VaultError::AuthenticationFailed),
"expected AuthenticationFailed, got {err:?}"
);
}
#[test]
fn test_tampered_ciphertext_is_rejected() {
let mut sealed = seal(b"payload that is long enough to poke at", pass()).unwrap();
let last = sealed.len() - 1;
sealed[last] ^= 0x01;
assert!(matches!(
open(&sealed, pass()).unwrap_err(),
VaultError::AuthenticationFailed
));
}
#[test]
fn test_tampered_salt_is_rejected() {
let mut sealed = seal(b"payload", pass()).unwrap();
sealed[HEADER_PREFIX_LEN] ^= 0xff;
assert!(matches!(
open(&sealed, pass()).unwrap_err(),
VaultError::AuthenticationFailed
));
}
#[test]
fn test_each_seal_uses_a_fresh_salt_and_nonce() {
let a = seal(b"same input", pass()).unwrap();
let b = seal(b"same input", pass()).unwrap();
assert_ne!(a, b, "two seals of the same payload were identical");
assert_eq!(open(&a, pass()).unwrap(), open(&b, pass()).unwrap());
}
#[test]
fn test_openable_without_the_originating_vault() {
let sealed = seal(b"shared model", pass()).unwrap();
assert_eq!(open(&sealed, pass()).unwrap(), b"shared model");
}
#[test]
fn test_is_sealed_discriminates_legacy_plaintext() {
assert!(is_sealed(&seal(b"x", pass()).unwrap()));
assert!(!is_sealed(b"raw plaintext model bytes"));
assert!(!is_sealed(b""));
assert!(!is_sealed(b"AIMV"));
}
#[test]
fn test_legacy_plaintext_gets_an_actionable_error() {
let err = open(b"raw plaintext model bytes", pass()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("AIMVSEAL"), "unhelpful message: {msg}");
}
#[test]
fn test_truncated_object_is_rejected_without_panicking() {
let sealed = seal(b"payload", pass()).unwrap();
for cut in [1, 8, 9, 11, HEADER_PREFIX_LEN, HEADER_PREFIX_LEN + 4] {
let truncated = &sealed[..cut.min(sealed.len())];
assert!(
open(truncated, pass()).is_err(),
"accepted {cut}-byte object"
);
}
let no_body = &sealed[..HEADER_PREFIX_LEN + salt_len()];
assert!(open(no_body, pass()).is_err());
}
#[test]
fn test_unknown_version_is_refused() {
let mut sealed = seal(b"payload", pass()).unwrap();
sealed[8] = 99;
let msg = open(&sealed, pass()).unwrap_err().to_string();
assert!(msg.contains("version"), "unhelpful message: {msg}");
}
#[test]
fn test_empty_payload_roundtrips() {
let sealed = seal(b"", pass()).unwrap();
assert!(open(&sealed, pass()).unwrap().is_empty());
}
}