use super::keys::{argon2id_derive_key, Argon2Params, KEK_LEN};
use crate::errors::AtomicResult;
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
use rand::RngCore;
use serde::{Deserialize, Serialize};
pub const SECRET_ENVELOPE_VERSION: u32 = 2;
const NONCE_LEN: usize = 24;
const DEK_LEN: usize = 32;
const RECOVERY_CODE_BYTES: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WrapperKind {
RecoveryCode,
WebauthnPrf,
Password,
AgentSecret,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoredKdfParams {
pub mem_kib: u32,
pub iterations: u32,
pub parallelism: u32,
}
impl From<Argon2Params> for StoredKdfParams {
fn from(p: Argon2Params) -> Self {
Self {
mem_kib: p.mem_kib,
iterations: p.iterations,
parallelism: p.parallelism,
}
}
}
impl From<StoredKdfParams> for Argon2Params {
fn from(p: StoredKdfParams) -> Self {
Self {
mem_kib: p.mem_kib,
iterations: p.iterations,
parallelism: p.parallelism,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Wrapper {
pub kind: WrapperKind,
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub kdf: Option<StoredKdfParams>,
#[serde(skip_serializing_if = "Option::is_none")]
pub salt: Option<String>,
pub nonce: String,
pub wrapped_dek: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretEnvelope {
pub format_version: u32,
pub nonce: String,
pub ciphertext: String,
pub wrappers: Vec<Wrapper>,
}
pub enum Unlock<'a> {
Secret(&'a str),
Kek([u8; KEK_LEN]),
AgentSecret(&'a [u8]),
}
pub enum NewWrapper<'a> {
RecoveryCode {
code: &'a str,
},
Password {
password: &'a str,
},
WebauthnPrf {
credential_id: String,
kek: [u8; KEK_LEN],
},
AgentSecret {
agent_secret: &'a [u8],
},
}
impl NewWrapper<'_> {
fn kind(&self) -> WrapperKind {
match self {
NewWrapper::RecoveryCode { .. } => WrapperKind::RecoveryCode,
NewWrapper::Password { .. } => WrapperKind::Password,
NewWrapper::WebauthnPrf { .. } => WrapperKind::WebauthnPrf,
NewWrapper::AgentSecret { .. } => WrapperKind::AgentSecret,
}
}
fn id(&self) -> String {
match self {
NewWrapper::RecoveryCode { .. } => "recovery-code".to_string(),
NewWrapper::Password { .. } => "password".to_string(),
NewWrapper::WebauthnPrf { credential_id, .. } => credential_id.clone(),
NewWrapper::AgentSecret { .. } => "agent-secret".to_string(),
}
}
}
pub fn generate_recovery_code() -> String {
let mut bytes = [0u8; RECOVERY_CODE_BYTES];
rand::thread_rng().fill_bytes(&mut bytes);
let raw = base32_encode(&bytes);
raw.as_bytes()
.chunks(5)
.map(|c| std::str::from_utf8(c).unwrap_or_default().to_string())
.collect::<Vec<_>>()
.join("-")
}
pub fn normalize_recovery_code(code: &str) -> String {
code.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_uppercase())
.map(|c| match c {
'I' | 'L' => '1',
'O' => '0',
other => other,
})
.collect()
}
const AGENT_SECRET_CONTEXT: &str = "atomic-vault 2026 agent secret wrapper";
pub const AGENT_VAULT_PROOF_MESSAGE: &[u8] = b"atomic-vault-key-derivation-v1";
pub fn agent_secret_kek(proof: &[u8]) -> [u8; KEK_LEN] {
blake3::derive_key(AGENT_SECRET_CONTEXT, proof)
}
const BASE32_ALPHABET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
fn base32_encode(bytes: &[u8]) -> String {
let mut out = String::new();
let mut buffer: u16 = 0;
let mut bits: u8 = 0;
for byte in bytes {
buffer = (buffer << 8) | u16::from(*byte);
bits += 8;
while bits >= 5 {
let index = ((buffer >> (bits - 5)) & 0x1F) as usize;
out.push(BASE32_ALPHABET[index] as char);
bits -= 5;
}
}
if bits > 0 {
let index = ((buffer << (5 - bits)) & 0x1F) as usize;
out.push(BASE32_ALPHABET[index] as char);
}
out
}
fn b64(bytes: &[u8]) -> String {
crate::agents::encode_base64(bytes)
}
fn unb64(text: &str) -> AtomicResult<Vec<u8>> {
crate::agents::decode_base64(text).map_err(|e| format!("malformed envelope field: {e}").into())
}
fn seal(key: &[u8; 32], plaintext: &[u8]) -> AtomicResult<(String, String)> {
let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
let mut nonce_bytes = [0u8; NONCE_LEN];
rand::thread_rng().fill_bytes(&mut nonce_bytes);
let ciphertext = cipher
.encrypt(XNonce::from_slice(&nonce_bytes), plaintext)
.map_err(|_| "failed to seal envelope")?;
Ok((b64(&nonce_bytes), b64(&ciphertext)))
}
fn unseal(key: &[u8; 32], nonce: &str, ciphertext: &str) -> AtomicResult<Vec<u8>> {
let nonce_bytes = unb64(nonce)?;
if nonce_bytes.len() != NONCE_LEN {
return Err("envelope nonce has the wrong length".into());
}
let cipher = XChaCha20Poly1305::new(Key::from_slice(key));
cipher
.decrypt(
XNonce::from_slice(&nonce_bytes),
unb64(ciphertext)?.as_slice(),
)
.map_err(|_| "could not open envelope: wrong credential, or the blob was altered".into())
}
fn wrap_dek(dek: &[u8; DEK_LEN], spec: &NewWrapper) -> AtomicResult<Wrapper> {
let (kdf, salt, kek) = match spec {
NewWrapper::RecoveryCode { code } | NewWrapper::Password { password: code } => {
let params = Argon2Params::default();
let mut salt_bytes = [0u8; 16];
rand::thread_rng().fill_bytes(&mut salt_bytes);
let kek =
argon2id_derive_key(normalize_for(spec, code).as_bytes(), &salt_bytes, params)
.map_err(|e| format!("failed to stretch credential: {e}"))?;
(Some(params.into()), Some(b64(&salt_bytes)), kek)
}
NewWrapper::WebauthnPrf { kek, .. } => (None, None, *kek),
NewWrapper::AgentSecret { agent_secret } => (None, None, agent_secret_kek(agent_secret)),
};
let (nonce, wrapped_dek) = seal(&kek, dek)?;
Ok(Wrapper {
kind: spec.kind(),
id: spec.id(),
kdf,
salt,
nonce,
wrapped_dek,
})
}
fn normalize_for(spec: &NewWrapper, secret: &str) -> String {
match spec {
NewWrapper::RecoveryCode { .. } => normalize_recovery_code(secret),
_ => secret.to_string(),
}
}
impl SecretEnvelope {
pub fn create(secret: &[u8], wrappers: &[NewWrapper]) -> AtomicResult<Self> {
if wrappers.is_empty() {
return Err("an envelope needs at least one wrapper, or nothing can open it".into());
}
let mut dek = [0u8; DEK_LEN];
rand::thread_rng().fill_bytes(&mut dek);
let (nonce, ciphertext) = seal(&dek, secret)?;
let wrapped = wrappers
.iter()
.map(|spec| wrap_dek(&dek, spec))
.collect::<AtomicResult<Vec<_>>>()?;
Ok(Self {
format_version: SECRET_ENVELOPE_VERSION,
nonce,
ciphertext,
wrappers: wrapped,
})
}
fn recover_dek(&self, unlock: &Unlock) -> AtomicResult<[u8; DEK_LEN]> {
self.check_version()?;
for wrapper in &self.wrappers {
let kek = match (unlock, wrapper.kind) {
(Unlock::Secret(secret), WrapperKind::RecoveryCode | WrapperKind::Password) => {
let (Some(kdf), Some(salt)) = (wrapper.kdf, wrapper.salt.as_ref()) else {
continue;
};
let normalized = if wrapper.kind == WrapperKind::RecoveryCode {
normalize_recovery_code(secret)
} else {
(*secret).to_string()
};
match argon2id_derive_key(normalized.as_bytes(), &unb64(salt)?, kdf.into()) {
Ok(kek) => kek,
Err(_) => continue,
}
}
(Unlock::Kek(kek), WrapperKind::WebauthnPrf) => *kek,
(Unlock::AgentSecret(secret), WrapperKind::AgentSecret) => agent_secret_kek(secret),
_ => continue,
};
if let Ok(dek) = unseal(&kek, &wrapper.nonce, &wrapper.wrapped_dek) {
return dek
.try_into()
.map_err(|_| "envelope contained a malformed DEK".into());
}
}
Err("no wrapper in this envelope accepted that credential".into())
}
pub fn unwrap_secret(&self, unlock: &Unlock) -> AtomicResult<Vec<u8>> {
let dek = self.recover_dek(unlock)?;
unseal(&dek, &self.nonce, &self.ciphertext)
}
pub fn add_wrapper(&mut self, unlock: &Unlock, spec: &NewWrapper) -> AtomicResult<()> {
let dek = self.recover_dek(unlock)?;
let new_id = spec.id();
if self.wrappers.iter().any(|w| w.id == new_id) {
return Err(format!("a wrapper named {new_id} is already registered").into());
}
self.wrappers.push(wrap_dek(&dek, spec)?);
Ok(())
}
pub fn remove_wrapper(&mut self, id: &str) -> AtomicResult<()> {
if self.wrappers.len() <= 1 {
return Err("cannot remove the only wrapper: the secret would be unrecoverable".into());
}
let before = self.wrappers.len();
self.wrappers.retain(|w| w.id != id);
if self.wrappers.len() == before {
return Err(format!("no wrapper named {id}").into());
}
Ok(())
}
pub fn has_kind(&self, kind: WrapperKind) -> bool {
self.wrappers.iter().any(|w| w.kind == kind)
}
fn check_version(&self) -> AtomicResult<()> {
if self.format_version != SECRET_ENVELOPE_VERSION {
return Err(format!(
"unsupported envelope format version {}, this build understands {SECRET_ENVELOPE_VERSION}",
self.format_version
)
.into());
}
Ok(())
}
pub fn to_json(&self) -> AtomicResult<String> {
serde_json::to_string(self).map_err(|e| format!("failed to serialize envelope: {e}").into())
}
pub fn from_json(json: &str) -> AtomicResult<Self> {
let envelope: Self =
serde_json::from_str(json).map_err(|e| format!("failed to parse envelope: {e}"))?;
envelope.check_version()?;
Ok(envelope)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fast_params() -> Argon2Params {
Argon2Params {
mem_kib: 8 * 1024,
iterations: 1,
parallelism: 1,
}
}
fn prf(id: &str, byte: u8) -> NewWrapper<'static> {
NewWrapper::WebauthnPrf {
credential_id: id.to_string(),
kek: [byte; KEK_LEN],
}
}
const SECRET: &[u8] = b"an ed25519 agent secret";
#[test]
fn round_trips_through_a_prf_wrapper() {
let envelope = SecretEnvelope::create(SECRET, &[prf("passkey-a", 1)]).unwrap();
let opened = envelope.unwrap_secret(&Unlock::Kek([1; KEK_LEN])).unwrap();
assert_eq!(opened, SECRET);
}
#[test]
fn the_secret_is_not_in_the_blob() {
let envelope = SecretEnvelope::create(SECRET, &[prf("passkey-a", 1)]).unwrap();
let json = envelope.to_json().unwrap();
assert!(!json.contains("ed25519 agent secret"));
assert!(!json.as_bytes().windows(SECRET.len()).any(|w| w == SECRET));
}
#[test]
fn any_registered_credential_opens_it() {
let envelope =
SecretEnvelope::create(SECRET, &[prf("passkey-a", 1), prf("passkey-b", 2)]).unwrap();
assert_eq!(
envelope.unwrap_secret(&Unlock::Kek([1; KEK_LEN])).unwrap(),
SECRET
);
assert_eq!(
envelope.unwrap_secret(&Unlock::Kek([2; KEK_LEN])).unwrap(),
SECRET
);
}
#[test]
fn an_unregistered_credential_does_not() {
let envelope = SecretEnvelope::create(SECRET, &[prf("passkey-a", 1)]).unwrap();
assert!(envelope.unwrap_secret(&Unlock::Kek([9; KEK_LEN])).is_err());
}
#[test]
fn a_recovery_code_opens_it() {
let code = generate_recovery_code();
let envelope =
SecretEnvelope::create(SECRET, &[NewWrapper::RecoveryCode { code: &code }]).unwrap();
assert_eq!(
envelope.unwrap_secret(&Unlock::Secret(&code)).unwrap(),
SECRET
);
assert!(envelope
.unwrap_secret(&Unlock::Secret("WRONG-CODE-HERE"))
.is_err());
}
#[test]
fn recovery_codes_tolerate_how_humans_retype_them() {
let code = generate_recovery_code();
let envelope =
SecretEnvelope::create(SECRET, &[NewWrapper::RecoveryCode { code: &code }]).unwrap();
let no_dashes = code.replace('-', "");
let lowercased = code.to_lowercase();
let spaced = code.replace('-', " ");
for variant in [no_dashes, lowercased, spaced] {
assert_eq!(
envelope.unwrap_secret(&Unlock::Secret(&variant)).unwrap(),
SECRET,
"failed for {variant:?}"
);
}
}
#[test]
fn generated_codes_are_unique_and_shaped_for_transcription() {
let a = generate_recovery_code();
let b = generate_recovery_code();
assert_ne!(a, b);
assert_eq!(a.replace('-', "").len(), 26);
assert!(a.contains('-'));
for c in a.chars().filter(|c| *c != '-') {
assert!(
!matches!(c, 'I' | 'L' | 'O' | 'U'),
"confusable character {c} in {a}"
);
}
}
#[test]
fn adding_a_credential_requires_opening_the_envelope_first() {
let mut envelope = SecretEnvelope::create(SECRET, &[prf("passkey-a", 1)]).unwrap();
assert!(envelope
.add_wrapper(&Unlock::Kek([9; KEK_LEN]), &prf("attacker", 3))
.is_err());
envelope
.add_wrapper(&Unlock::Kek([1; KEK_LEN]), &prf("passkey-b", 2))
.unwrap();
assert_eq!(
envelope.unwrap_secret(&Unlock::Kek([2; KEK_LEN])).unwrap(),
SECRET
);
}
#[test]
fn duplicate_wrapper_ids_are_refused() {
let mut envelope = SecretEnvelope::create(SECRET, &[prf("passkey-a", 1)]).unwrap();
assert!(envelope
.add_wrapper(&Unlock::Kek([1; KEK_LEN]), &prf("passkey-a", 5))
.is_err());
}
#[test]
fn removing_a_credential_leaves_the_others_working() {
let mut envelope =
SecretEnvelope::create(SECRET, &[prf("passkey-a", 1), prf("passkey-b", 2)]).unwrap();
envelope.remove_wrapper("passkey-a").unwrap();
assert!(envelope.unwrap_secret(&Unlock::Kek([1; KEK_LEN])).is_err());
assert_eq!(
envelope.unwrap_secret(&Unlock::Kek([2; KEK_LEN])).unwrap(),
SECRET
);
}
#[test]
fn the_last_credential_cannot_be_removed() {
let mut envelope = SecretEnvelope::create(SECRET, &[prf("passkey-a", 1)]).unwrap();
let err = envelope
.remove_wrapper("passkey-a")
.unwrap_err()
.to_string();
assert!(err.contains("unrecoverable"), "{err}");
assert_eq!(
envelope.unwrap_secret(&Unlock::Kek([1; KEK_LEN])).unwrap(),
SECRET
);
}
#[test]
fn an_envelope_with_no_wrappers_is_refused() {
assert!(SecretEnvelope::create(SECRET, &[]).is_err());
}
#[test]
fn survives_json_storage() {
let envelope =
SecretEnvelope::create(SECRET, &[prf("passkey-a", 1), prf("passkey-b", 2)]).unwrap();
let restored = SecretEnvelope::from_json(&envelope.to_json().unwrap()).unwrap();
assert_eq!(restored, envelope);
assert_eq!(
restored.unwrap_secret(&Unlock::Kek([2; KEK_LEN])).unwrap(),
SECRET
);
}
#[test]
fn v1_blobs_are_refused_rather_than_misread() {
let v1 = r#"{"format_version":1,"nonce":"","ciphertext":"","wrappers":[]}"#;
let err = SecretEnvelope::from_json(v1).unwrap_err().to_string();
assert!(err.contains("version"), "{err}");
}
#[test]
fn tampering_with_the_ciphertext_is_detected() {
let mut envelope = SecretEnvelope::create(SECRET, &[prf("passkey-a", 1)]).unwrap();
envelope.ciphertext = b64(b"replaced by an attacker");
assert!(envelope.unwrap_secret(&Unlock::Kek([1; KEK_LEN])).is_err());
}
#[test]
fn protects_a_drive_vault_key_too() {
let drive_key = super::super::dek::DriveVaultKey::generate(1);
let envelope =
SecretEnvelope::create(drive_key.expose_secret(), &[prf("passkey-a", 1)]).unwrap();
let recovered = envelope.unwrap_secret(&Unlock::Kek([1; KEK_LEN])).unwrap();
assert_eq!(recovered, drive_key.expose_secret());
}
#[test]
fn an_agent_secret_opens_the_envelope() {
let agent_secret = b"an ed25519 private key's bytes";
let drive_key = super::super::dek::DriveVaultKey::generate(1);
let envelope = SecretEnvelope::create(
drive_key.expose_secret(),
&[NewWrapper::AgentSecret { agent_secret }],
)
.unwrap();
let recovered = envelope
.unwrap_secret(&Unlock::AgentSecret(agent_secret))
.unwrap();
assert_eq!(recovered, drive_key.expose_secret());
}
#[test]
fn a_different_agent_secret_does_not() {
let envelope = SecretEnvelope::create(
SECRET,
&[NewWrapper::AgentSecret {
agent_secret: b"mine",
}],
)
.unwrap();
assert!(envelope
.unwrap_secret(&Unlock::AgentSecret(b"someone else's"))
.is_err());
}
#[test]
fn the_wrapping_key_is_derived_not_the_agent_secret_itself() {
let agent_secret = [42u8; 32];
let kek = agent_secret_kek(&agent_secret);
assert_ne!(kek, agent_secret, "the KEK must not be the secret itself");
assert_eq!(kek, agent_secret_kek(&agent_secret));
}
#[test]
fn a_recovery_code_can_be_added_to_an_agent_wrapped_envelope() {
let agent_secret = b"agent bytes";
let mut envelope =
SecretEnvelope::create(SECRET, &[NewWrapper::AgentSecret { agent_secret }]).unwrap();
let code = generate_recovery_code();
envelope
.add_wrapper(
&Unlock::AgentSecret(agent_secret),
&NewWrapper::RecoveryCode { code: &code },
)
.unwrap();
assert_eq!(
envelope.unwrap_secret(&Unlock::Secret(&code)).unwrap(),
SECRET
);
assert_eq!(
envelope
.unwrap_secret(&Unlock::AgentSecret(agent_secret))
.unwrap(),
SECRET
);
}
#[test]
fn without_the_agent_secret_there_is_no_way_in() {
let envelope = SecretEnvelope::create(
SECRET,
&[NewWrapper::AgentSecret {
agent_secret: b"lost forever",
}],
)
.unwrap();
assert!(envelope.unwrap_secret(&Unlock::Kek([0; KEK_LEN])).is_err());
assert!(envelope.unwrap_secret(&Unlock::Secret("guess")).is_err());
}
#[test]
fn stored_kdf_params_survive_a_defaults_change() {
let stored: StoredKdfParams = fast_params().into();
let back: Argon2Params = stored.into();
assert_eq!(back.mem_kib, fast_params().mem_kib);
assert_eq!(back.iterations, fast_params().iterations);
}
}