pub const VAULT_LOGICAL_EXPORT_MAGIC: &[u8; 4] = b"RDVX";
pub const VAULT_LOGICAL_EXPORT_VERSION: u8 = 1;
pub const VAULT_LOGICAL_EXPORT_AAD: &[u8] = b"reddb-vault-logical-export-v1";
pub const VAULT_EXPORT_SALT_SIZE: usize = 16;
pub const VAULT_EXPORT_NONCE_SIZE: usize = 12;
const MAGIC_SIZE: usize = 4;
const VERSION_SIZE: usize = 1;
const GCM_TAG_SIZE: usize = 16;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VaultExportEnvelopeError {
BadHex,
TooShort,
BadMagic,
UnsupportedVersion(u8),
}
impl std::fmt::Display for VaultExportEnvelopeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VaultExportEnvelopeError::BadHex => write!(f, "bad hex"),
VaultExportEnvelopeError::TooShort => write!(f, "logical vault export too short"),
VaultExportEnvelopeError::BadMagic => write!(f, "bad logical vault export magic"),
VaultExportEnvelopeError::UnsupportedVersion(v) => {
write!(f, "unsupported logical vault export version: {v}")
}
}
}
}
impl std::error::Error for VaultExportEnvelopeError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VaultExportEnvelope {
pub salt: [u8; VAULT_EXPORT_SALT_SIZE],
pub nonce: [u8; VAULT_EXPORT_NONCE_SIZE],
pub ciphertext: Vec<u8>,
}
pub fn encode(
salt: &[u8; VAULT_EXPORT_SALT_SIZE],
nonce: &[u8; VAULT_EXPORT_NONCE_SIZE],
ciphertext: &[u8],
) -> String {
let mut out = Vec::with_capacity(
MAGIC_SIZE
+ VERSION_SIZE
+ VAULT_EXPORT_SALT_SIZE
+ VAULT_EXPORT_NONCE_SIZE
+ ciphertext.len(),
);
out.extend_from_slice(VAULT_LOGICAL_EXPORT_MAGIC);
out.push(VAULT_LOGICAL_EXPORT_VERSION);
out.extend_from_slice(salt);
out.extend_from_slice(nonce);
out.extend_from_slice(ciphertext);
hex::encode(out)
}
pub fn decode(blob_hex: &str) -> Result<VaultExportEnvelope, VaultExportEnvelopeError> {
let blob = hex::decode(blob_hex).map_err(|_| VaultExportEnvelopeError::BadHex)?;
let min_len =
MAGIC_SIZE + VERSION_SIZE + VAULT_EXPORT_SALT_SIZE + VAULT_EXPORT_NONCE_SIZE + GCM_TAG_SIZE;
if blob.len() < min_len {
return Err(VaultExportEnvelopeError::TooShort);
}
if &blob[0..MAGIC_SIZE] != VAULT_LOGICAL_EXPORT_MAGIC {
return Err(VaultExportEnvelopeError::BadMagic);
}
let version = blob[MAGIC_SIZE];
if version != VAULT_LOGICAL_EXPORT_VERSION {
return Err(VaultExportEnvelopeError::UnsupportedVersion(version));
}
let mut off = MAGIC_SIZE + VERSION_SIZE;
let mut salt = [0u8; VAULT_EXPORT_SALT_SIZE];
salt.copy_from_slice(&blob[off..off + VAULT_EXPORT_SALT_SIZE]);
off += VAULT_EXPORT_SALT_SIZE;
let mut nonce = [0u8; VAULT_EXPORT_NONCE_SIZE];
nonce.copy_from_slice(&blob[off..off + VAULT_EXPORT_NONCE_SIZE]);
off += VAULT_EXPORT_NONCE_SIZE;
Ok(VaultExportEnvelope {
salt,
nonce,
ciphertext: blob[off..].to_vec(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_lays_out_frozen_header() {
let salt = [0xABu8; VAULT_EXPORT_SALT_SIZE];
let nonce = [0xCDu8; VAULT_EXPORT_NONCE_SIZE];
let ciphertext = vec![0xEFu8; GCM_TAG_SIZE + 8];
let hexed = encode(&salt, &nonce, &ciphertext);
let raw = hex::decode(&hexed).unwrap();
assert_eq!(&raw[0..4], b"RDVX");
assert_eq!(raw[4], 1);
assert_eq!(&raw[5..21], &salt);
assert_eq!(&raw[21..33], &nonce);
assert_eq!(&raw[33..], &ciphertext[..]);
}
#[test]
fn round_trip_is_byte_identical() {
let salt = [7u8; VAULT_EXPORT_SALT_SIZE];
let nonce = [9u8; VAULT_EXPORT_NONCE_SIZE];
let ciphertext: Vec<u8> = (0..(GCM_TAG_SIZE as u8 + 40)).collect();
let hexed = encode(&salt, &nonce, &ciphertext);
let decoded = decode(&hexed).unwrap();
assert_eq!(decoded.salt, salt);
assert_eq!(decoded.nonce, nonce);
assert_eq!(decoded.ciphertext, ciphertext);
assert_eq!(
encode(&decoded.salt, &decoded.nonce, &decoded.ciphertext),
hexed
);
}
#[test]
fn decodes_pinned_legacy_blob() {
let salt = [0x11u8; VAULT_EXPORT_SALT_SIZE];
let nonce = [0x22u8; VAULT_EXPORT_NONCE_SIZE];
let ciphertext = vec![0xDEu8; GCM_TAG_SIZE + 4];
let mut raw = Vec::new();
raw.extend_from_slice(b"RDVX");
raw.push(1);
raw.extend_from_slice(&salt);
raw.extend_from_slice(&nonce);
raw.extend_from_slice(&ciphertext);
let pinned = hex::encode(&raw);
let decoded = decode(&pinned).unwrap();
assert_eq!(decoded.salt, salt);
assert_eq!(decoded.nonce, nonce);
assert_eq!(decoded.ciphertext, ciphertext);
}
#[test]
fn rejects_bad_hex() {
assert_eq!(decode("zz not hex"), Err(VaultExportEnvelopeError::BadHex));
}
#[test]
fn rejects_short_blob() {
let short = hex::encode(b"RDVX\x01tooshort");
assert_eq!(decode(&short), Err(VaultExportEnvelopeError::TooShort));
}
#[test]
fn rejects_bad_magic() {
let salt = [0u8; VAULT_EXPORT_SALT_SIZE];
let nonce = [0u8; VAULT_EXPORT_NONCE_SIZE];
let ct = vec![0u8; GCM_TAG_SIZE];
let mut raw = Vec::new();
raw.extend_from_slice(b"XXXX");
raw.push(1);
raw.extend_from_slice(&salt);
raw.extend_from_slice(&nonce);
raw.extend_from_slice(&ct);
assert_eq!(
decode(&hex::encode(&raw)),
Err(VaultExportEnvelopeError::BadMagic)
);
}
#[test]
fn rejects_unsupported_version() {
let salt = [0u8; VAULT_EXPORT_SALT_SIZE];
let nonce = [0u8; VAULT_EXPORT_NONCE_SIZE];
let ct = vec![0u8; GCM_TAG_SIZE];
let mut raw = Vec::new();
raw.extend_from_slice(b"RDVX");
raw.push(9);
raw.extend_from_slice(&salt);
raw.extend_from_slice(&nonce);
raw.extend_from_slice(&ct);
assert_eq!(
decode(&hex::encode(&raw)),
Err(VaultExportEnvelopeError::UnsupportedVersion(9))
);
}
}