use rand::RngCore;
pub const VAULT_KEY_LEN: usize = 32;
const CONTEXT_PACK: &str = "atomic-vault 2026 pack envelope";
const CONTEXT_BLOB: &str = "atomic-vault 2026 blob chunk";
const CONTEXT_BLOB_ID: &str = "atomic-vault 2026 blob id";
const CONTEXT_INDEX: &str = "atomic-vault 2026 index metadata";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Purpose {
Pack,
Blob,
BlobId,
Index,
}
impl Purpose {
fn context(self) -> &'static str {
match self {
Purpose::Pack => CONTEXT_PACK,
Purpose::Blob => CONTEXT_BLOB,
Purpose::BlobId => CONTEXT_BLOB_ID,
Purpose::Index => CONTEXT_INDEX,
}
}
}
#[derive(Clone)]
pub struct DriveVaultKey {
key: [u8; VAULT_KEY_LEN],
epoch: u32,
}
impl DriveVaultKey {
pub fn generate(epoch: u32) -> Self {
let mut key = [0u8; VAULT_KEY_LEN];
rand::thread_rng().fill_bytes(&mut key);
Self { key, epoch }
}
pub fn from_bytes(key: [u8; VAULT_KEY_LEN], epoch: u32) -> Self {
Self { key, epoch }
}
pub fn expose_secret(&self) -> &[u8; VAULT_KEY_LEN] {
&self.key
}
pub fn epoch(&self) -> u32 {
self.epoch
}
pub fn subkey(&self, purpose: Purpose) -> [u8; VAULT_KEY_LEN] {
blake3::derive_key(purpose.context(), &self.key)
}
pub fn blob_id(&self, plaintext: &[u8]) -> [u8; 32] {
let subkey = self.subkey(Purpose::BlobId);
*blake3::keyed_hash(&subkey, plaintext).as_bytes()
}
}
impl std::fmt::Debug for DriveVaultKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DriveVaultKey")
.field("epoch", &self.epoch)
.field("key", &"<redacted>")
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subkeys_differ_per_purpose() {
let key = DriveVaultKey::generate(1);
let pack = key.subkey(Purpose::Pack);
let blob = key.subkey(Purpose::Blob);
let blob_id = key.subkey(Purpose::BlobId);
let index = key.subkey(Purpose::Index);
let all = [pack, blob, blob_id, index];
for (i, a) in all.iter().enumerate() {
for b in all.iter().skip(i + 1) {
assert_ne!(a, b, "subkeys for different purposes must not collide");
}
assert_ne!(a, key.expose_secret(), "a subkey must not be the root key");
}
}
#[test]
fn subkey_derivation_is_deterministic() {
let raw = [7u8; VAULT_KEY_LEN];
let a = DriveVaultKey::from_bytes(raw, 1);
let b = DriveVaultKey::from_bytes(raw, 1);
assert_eq!(a.subkey(Purpose::Pack), b.subkey(Purpose::Pack));
}
#[test]
fn different_drive_keys_derive_different_subkeys() {
let a = DriveVaultKey::generate(1);
let b = DriveVaultKey::generate(1);
assert_ne!(a.subkey(Purpose::Pack), b.subkey(Purpose::Pack));
}
#[test]
fn generate_does_not_repeat_itself() {
let a = DriveVaultKey::generate(1);
let b = DriveVaultKey::generate(1);
assert_ne!(a.expose_secret(), b.expose_secret());
}
#[test]
fn blob_ids_survive_a_key_epoch_bump() {
let raw = [3u8; VAULT_KEY_LEN];
let epoch1 = DriveVaultKey::from_bytes(raw, 1);
let epoch2 = DriveVaultKey::from_bytes(raw, 2);
assert_eq!(
epoch1.blob_id(b"file contents"),
epoch2.blob_id(b"file contents")
);
}
#[test]
fn blob_ids_are_drive_scoped() {
let a = DriveVaultKey::generate(1);
let b = DriveVaultKey::generate(1);
assert_ne!(a.blob_id(b"same bytes"), b.blob_id(b"same bytes"));
}
#[test]
fn blob_ids_distinguish_content() {
let key = DriveVaultKey::generate(1);
assert_ne!(key.blob_id(b"one"), key.blob_id(b"two"));
}
#[test]
fn debug_never_prints_key_material() {
let key = DriveVaultKey::from_bytes([0xAB; VAULT_KEY_LEN], 4);
let rendered = format!("{key:?}");
assert!(rendered.contains("epoch: 4"), "{rendered}");
assert!(rendered.contains("redacted"), "{rendered}");
assert!(!rendered.contains("ab"), "key bytes leaked: {rendered}");
assert!(!rendered.contains("171"), "key bytes leaked: {rendered}");
}
}