use std::{
collections::HashSet,
fs::{self, File},
io::Write,
path::{Path, PathBuf},
sync::Arc,
time::SystemTime,
};
use aes_gcm::KeyInit;
use chacha20poly1305::{
AeadCore, Error as EncryptionError, XChaCha20Poly1305, XNonce,
aead::{Aead, OsRng},
};
use dashmap::DashMap;
use freenet_stdlib::prelude::*;
use hkdf::Hkdf;
use sha2::Sha256;
use zeroize::Zeroizing;
use crate::config::{KEK_SIZE, KekBackendKind, Secrets, ensure_kek_loaded};
use crate::contract::storages::Storage;
use super::RuntimeResult;
use super::secret_snapshots::{
RestoreError, RetentionPolicy, SnapshotMetadata, list_snapshots, restore_snapshot_file,
snapshot_active_value, snapshot_dir_for, thin_snapshots,
};
const DISABLE_SNAPSHOTS_ENV: &str = "FREENET_DISABLE_SECRET_SNAPSHOTS";
const VERSION_V1: u8 = 0x01;
const HEADER_LEN: usize = 1 + 24;
type SecretKey = [u8; 32];
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId([u8; 32]);
impl UserId {
pub const fn new(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn encode(&self) -> String {
bs58::encode(self.0)
.with_alphabet(bs58::Alphabet::BITCOIN)
.into_string()
}
}
impl std::fmt::Debug for UserId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "UserId({})", self.encode())
}
}
pub enum SecretScope<'a> {
Local,
User {
id: &'a UserId,
dek_secret: &'a Zeroizing<[u8; 32]>,
},
}
#[derive(Clone)]
pub struct UserSecretContext {
user_id: UserId,
dek_secret: Zeroizing<[u8; 32]>,
}
impl UserSecretContext {
pub fn from_token(token: &[u8]) -> Self {
Self {
user_id: user_id(token),
dek_secret: user_dek_secret(token),
}
}
pub fn user_id(&self) -> &UserId {
&self.user_id
}
pub fn scope(&self) -> SecretScope<'_> {
SecretScope::User {
id: &self.user_id,
dek_secret: &self.dek_secret,
}
}
}
impl std::fmt::Debug for UserSecretContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UserSecretContext")
.field("user_id", &self.user_id)
.field("dek_secret", &"<redacted>")
.finish()
}
}
const USER_ID_DOMAIN: &[u8] = b"freenet-user-id";
const USER_DEK_SECRET_DOMAIN: &[u8] = b"freenet-user-dek";
pub fn user_id(token: &[u8]) -> UserId {
let mut hasher = blake3::Hasher::new();
hasher.update(USER_ID_DOMAIN);
hasher.update(token);
UserId(*hasher.finalize().as_bytes())
}
pub fn user_dek_secret(token: &[u8]) -> Zeroizing<[u8; 32]> {
let mut hasher = blake3::Hasher::new();
hasher.update(USER_DEK_SECRET_DOMAIN);
hasher.update(token);
Zeroizing::new(*hasher.finalize().as_bytes())
}
#[derive(Debug, thiserror::Error)]
pub enum SecretStoreError {
#[error("encryption error: {0}")]
Encryption(EncryptionError),
#[error("{0}")]
IO(#[from] std::io::Error),
#[error("missing cipher")]
MissingCipher,
#[error("missing secret: {0}")]
MissingSecret(SecretsId),
#[error("no snapshot for secret {key} at timestamp_ms {timestamp_ms}")]
SnapshotNotFound { key: SecretsId, timestamp_ms: u64 },
}
#[derive(Clone)]
struct Encryption {
cipher: XChaCha20Poly1305,
legacy_nonce: XNonce,
}
pub struct SecretsStore {
base_path: PathBuf,
#[allow(unused)]
secrets: Secrets,
kek: Zeroizing<[u8; KEK_SIZE]>,
kek_backend: KekBackendKind,
ciphers: std::collections::HashMap<DelegateKey, Encryption>,
key_to_secret_part: Arc<DashMap<DelegateKey, HashSet<SecretKey>>>,
user_key_to_secret_part: Arc<DashMap<(DelegateKey, UserId), HashSet<SecretKey>>>,
db: Storage,
default_encryption: Encryption,
legacy_migration_encryption: Option<Encryption>,
retention: RetentionPolicy,
snapshots_enabled: bool,
}
const DEK_HKDF_INFO: &[u8] = b"freenet-delegate-dek-v1";
const USER_DEK_HKDF_INFO: &[u8] = b"freenet-delegate-dek-user-v1";
pub(super) fn create_owner_only(path: &Path) -> std::io::Result<File> {
match std::fs::remove_file(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
opts.open(path)
}
#[cfg(unix)]
pub(super) fn ensure_owner_only_dir(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
let mode = perms.mode() & 0o777;
if mode != 0o700 {
tracing::warn!(
path = %path.display(),
existing_mode = format_args!("{mode:o}"),
"secrets directory was not 0o700; tightening to owner-only"
);
perms.set_mode(0o700);
std::fs::set_permissions(path, perms)?;
}
Ok(())
}
#[cfg(not(unix))]
pub(super) fn ensure_owner_only_dir(_path: &Path) -> std::io::Result<()> {
Ok(())
}
fn ensure_owner_only_tree(base: &Path, full: &Path) -> std::io::Result<()> {
let Ok(rel) = full.strip_prefix(base) else {
return ensure_owner_only_dir(full);
};
let mut current = base.to_path_buf();
for component in rel.components() {
current.push(component);
ensure_owner_only_dir(¤t)?;
}
Ok(())
}
impl SecretsStore {
pub fn new(secrets_dir: PathBuf, secrets: Secrets, db: Storage) -> RuntimeResult<Self> {
std::fs::create_dir_all(&secrets_dir).map_err(|err| {
tracing::error!("error creating secrets dir: {err}");
err
})?;
if let Err(e) = ensure_owner_only_dir(&secrets_dir) {
tracing::warn!(
path = %secrets_dir.display(),
error = %e,
"failed to tighten secrets-dir permissions; continuing"
);
}
let (kek_backend, kek) = ensure_kek_loaded(&secrets_dir, || {
use chacha20poly1305::aead::OsRng;
use chacha20poly1305::aead::rand_core::RngCore;
let mut kek = Zeroizing::new([0u8; KEK_SIZE]);
OsRng.fill_bytes(kek.as_mut_slice());
kek
})
.map_err(|e| {
tracing::error!("failed to load node KEK: {e}");
std::io::Error::other(format!("KEK load failed: {e}"))
})?;
let key_to_secret_part = Arc::new(DashMap::new());
match db.load_all_secrets_index() {
Ok(entries) => {
for (delegate_key, secret_keys) in entries {
let secret_set: HashSet<SecretKey> = secret_keys.into_iter().collect();
key_to_secret_part.insert(delegate_key, secret_set);
}
tracing::debug!(
"Loaded {} secrets index entries from ReDb",
key_to_secret_part.len()
);
}
Err(e) => {
tracing::warn!("Failed to load secrets index from ReDb: {e}");
}
}
let user_key_to_secret_part = Arc::new(DashMap::new());
match db.load_all_user_secrets_index() {
Ok(entries) => {
for ((delegate_key, user_bytes), secret_keys) in entries {
let secret_set: HashSet<SecretKey> = secret_keys.into_iter().collect();
user_key_to_secret_part
.insert((delegate_key, UserId::new(user_bytes)), secret_set);
}
tracing::debug!(
"Loaded {} user-scoped secrets index entries from ReDb",
user_key_to_secret_part.len()
);
}
Err(e) => {
tracing::warn!("Failed to load user-scoped secrets index from ReDb: {e}");
}
}
use crate::config::{LEGACY_DEFAULT_CIPHER, LEGACY_DEFAULT_NONCE};
let legacy_migration_encryption = Some(Encryption {
cipher: XChaCha20Poly1305::new((&LEGACY_DEFAULT_CIPHER).into()),
legacy_nonce: LEGACY_DEFAULT_NONCE.into(),
});
Ok(Self {
base_path: secrets_dir,
kek,
kek_backend,
ciphers: std::collections::HashMap::new(),
key_to_secret_part,
user_key_to_secret_part,
db,
default_encryption: Encryption {
cipher: secrets.cipher(),
legacy_nonce: secrets.nonce(),
},
legacy_migration_encryption,
secrets,
retention: RetentionPolicy::default(),
snapshots_enabled: std::env::var_os(DISABLE_SNAPSHOTS_ENV).is_none(),
})
}
pub fn kek_backend(&self) -> KekBackendKind {
self.kek_backend
}
fn derive_delegate_dek(&self, delegate: &DelegateKey) -> Encryption {
let salt = delegate.encode();
let hk = Hkdf::<Sha256>::new(Some(salt.as_bytes()), self.kek.as_slice());
let mut okm = Zeroizing::new([0u8; KEK_SIZE]);
hk.expand(DEK_HKDF_INFO, okm.as_mut_slice())
.expect("HKDF expand with 32-byte OKM never fails for SHA-256");
Encryption {
cipher: XChaCha20Poly1305::new(okm.as_slice().into()),
legacy_nonce: chacha20poly1305::XNonce::from_slice(&[0u8; 24]).to_owned(),
}
}
fn derive_user_dek(
&self,
delegate: &DelegateKey,
dek_secret: &Zeroizing<[u8; 32]>,
) -> Encryption {
let salt = delegate.encode();
let hk = Hkdf::<Sha256>::new(Some(salt.as_bytes()), dek_secret.as_slice());
let mut okm = Zeroizing::new([0u8; KEK_SIZE]);
hk.expand(USER_DEK_HKDF_INFO, okm.as_mut_slice())
.expect("HKDF expand with 32-byte OKM never fails for SHA-256");
Encryption {
cipher: XChaCha20Poly1305::new(okm.as_slice().into()),
legacy_nonce: chacha20poly1305::XNonce::from_slice(&[0u8; 24]).to_owned(),
}
}
fn scope_dir(&self, delegate: &DelegateKey, scope: &SecretScope<'_>) -> PathBuf {
let delegate_dir = self.base_path.join(delegate.encode());
match scope {
SecretScope::Local => delegate_dir,
SecretScope::User { id, .. } => delegate_dir.join("users").join(id.encode()),
}
}
fn cipher_for(&mut self, delegate: &DelegateKey) -> &Encryption {
if !self.ciphers.contains_key(delegate) {
let derived = self.derive_delegate_dek(delegate);
self.ciphers.insert(delegate.clone(), derived);
}
self.ciphers
.get(delegate)
.expect("cipher entry inserted above; cannot be missing in the same &mut self call")
}
fn cipher_for_read(&self, delegate: &DelegateKey) -> Encryption {
if let Some(enc) = self.ciphers.get(delegate) {
return enc.clone();
}
self.derive_delegate_dek(delegate)
}
#[cfg(test)]
pub(crate) fn set_retention_policy(&mut self, policy: RetentionPolicy) {
self.retention = policy;
}
#[cfg(test)]
pub(crate) fn set_snapshots_enabled(&mut self, enabled: bool) {
self.snapshots_enabled = enabled;
}
pub fn register_delegate(
&mut self,
delegate: DelegateKey,
_cipher: XChaCha20Poly1305,
_nonce: XNonce,
) -> Result<(), SecretStoreError> {
tracing::info!(
delegate = %delegate.encode(),
"RegisterDelegate cipher/nonce ignored; using HKDF-derived DEK from node KEK \
(this is the expected behavior since #4140)."
);
let derived = self.derive_delegate_dek(&delegate);
self.ciphers.insert(delegate, derived);
Ok(())
}
pub fn remove_delegate_cipher(&mut self, delegate: &DelegateKey) {
self.ciphers.remove(delegate);
}
pub fn store_secret(
&mut self,
delegate: &DelegateKey,
key: &SecretsId,
scope: SecretScope<'_>,
plaintext: Zeroizing<Vec<u8>>,
) -> RuntimeResult<()> {
let scope_path = self.scope_dir(delegate, &scope);
let secret_file_path = scope_path.join(key.encode());
let secret_key = *key.hash();
let encryption = match &scope {
SecretScope::Local => self.cipher_for(delegate).clone(),
SecretScope::User { dek_secret, .. } => self.derive_user_dek(delegate, dek_secret),
};
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let aead = encryption
.cipher
.encrypt(&nonce, plaintext.as_slice())
.map_err(SecretStoreError::Encryption)?;
let mut ciphertext = Vec::with_capacity(HEADER_LEN + aead.len());
ciphertext.push(VERSION_V1);
ciphertext.extend_from_slice(nonce.as_slice());
ciphertext.extend_from_slice(&aead);
fs::create_dir_all(&scope_path)?;
if let Err(e) = ensure_owner_only_tree(&self.base_path, &scope_path) {
tracing::warn!(path = %scope_path.display(), error = %e, "chmod scope dir tree failed");
}
if self.snapshots_enabled
&& secret_file_path.exists()
&& let Err(e) = self.snapshot_prior_value(&scope_path, key, &secret_file_path)
{
tracing::warn!(
"failed to snapshot prior secret value for delegate {}: {e}",
delegate.encode()
);
}
tracing::debug!("storing secret `{key}` at {secret_file_path:?}");
let tmp_path = secret_file_path.with_extension("tmp");
{
let mut file = create_owner_only(&tmp_path)?;
file.write_all(&ciphertext)?;
file.sync_all()?;
}
if let Err(err) = fs::rename(&tmp_path, &secret_file_path) {
if let Err(rm_err) = fs::remove_file(&tmp_path) {
tracing::debug!(
"failed to clean up tmp file {tmp_path:?} after rename failure: {rm_err}"
);
}
return Err(err.into());
}
self.add_to_index(delegate, &scope, secret_key)?;
if self.snapshots_enabled {
let snap_dir = snapshot_dir_for(&scope_path, key);
if snap_dir.exists() {
thin_snapshots(&snap_dir, &self.retention, SystemTime::now());
}
}
Ok(())
}
fn add_to_index(
&self,
delegate: &DelegateKey,
scope: &SecretScope<'_>,
secret_key: SecretKey,
) -> RuntimeResult<()> {
match scope {
SecretScope::Local => {
let mut current: Vec<SecretKey> = self
.key_to_secret_part
.get(delegate)
.map(|entry| entry.value().iter().copied().collect())
.unwrap_or_default();
if current.contains(&secret_key) {
return Ok(());
}
current.push(secret_key);
self.db
.store_secrets_index(delegate, ¤t)
.map_err(|e| anyhow::anyhow!("Failed to store secrets index: {e}"))?;
let secret_set: HashSet<SecretKey> = current.into_iter().collect();
self.key_to_secret_part.insert(delegate.clone(), secret_set);
}
SecretScope::User { id, .. } => {
let map_key = (delegate.clone(), **id);
let mut current: Vec<SecretKey> = self
.user_key_to_secret_part
.get(&map_key)
.map(|entry| entry.value().iter().copied().collect())
.unwrap_or_default();
if current.contains(&secret_key) {
return Ok(());
}
current.push(secret_key);
self.db
.store_user_secrets_index(delegate, id.as_bytes(), ¤t)
.map_err(|e| anyhow::anyhow!("Failed to store user secrets index: {e}"))?;
let secret_set: HashSet<SecretKey> = current.into_iter().collect();
self.user_key_to_secret_part.insert(map_key, secret_set);
}
}
Ok(())
}
fn snapshot_prior_value(
&self,
delegate_path: &Path,
key: &SecretsId,
secret_file_path: &Path,
) -> std::io::Result<()> {
snapshot_active_value(delegate_path, &key.encode(), secret_file_path)
}
pub fn remove_secret(
&mut self,
delegate: &DelegateKey,
key: &SecretsId,
scope: SecretScope<'_>,
) -> Result<(), SecretStoreError> {
let scope_path = self.scope_dir(delegate, &scope);
let secret_path = scope_path.join(key.encode());
let snap_dir = snapshot_dir_for(&scope_path, key);
if snap_dir.exists() {
if let Err(e) = fs::remove_dir_all(&snap_dir) {
tracing::warn!(
"failed to remove snapshots for {} / {key}: {e}",
delegate.encode()
);
}
}
match fs::remove_file(&secret_path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}
let secret_key = *key.hash();
match &scope {
SecretScope::Local => {
let mut current: Vec<SecretKey> = self
.key_to_secret_part
.get(delegate)
.map(|e| e.value().iter().copied().collect())
.unwrap_or_default();
current.retain(|k| k != &secret_key);
self.db
.store_secrets_index(delegate, ¤t)
.map_err(|e| {
std::io::Error::other(format!("Failed to update secrets index: {e}"))
})?;
let secret_set: HashSet<SecretKey> = current.into_iter().collect();
self.key_to_secret_part.insert(delegate.clone(), secret_set);
}
SecretScope::User { id, .. } => {
let map_key = (delegate.clone(), **id);
let mut current: Vec<SecretKey> = self
.user_key_to_secret_part
.get(&map_key)
.map(|e| e.value().iter().copied().collect())
.unwrap_or_default();
current.retain(|k| k != &secret_key);
self.db
.store_user_secrets_index(delegate, id.as_bytes(), ¤t)
.map_err(|e| {
std::io::Error::other(format!("Failed to update user secrets index: {e}"))
})?;
let secret_set: HashSet<SecretKey> = current.into_iter().collect();
self.user_key_to_secret_part.insert(map_key, secret_set);
}
}
Ok(())
}
pub fn get_secret(
&self,
delegate: &DelegateKey,
key: &SecretsId,
scope: SecretScope<'_>,
) -> Result<Zeroizing<Vec<u8>>, SecretStoreError> {
let secret_path = self.scope_dir(delegate, &scope).join(key.encode());
let blob =
fs::read(secret_path).map_err(|_| SecretStoreError::MissingSecret(key.clone()))?;
match &scope {
SecretScope::Local => {
let encryption = self.cipher_for_read(delegate);
let legacy_chain = [&self.default_encryption];
decrypt_secret_blob(
&encryption,
&legacy_chain,
self.legacy_migration_encryption.as_ref(),
&blob,
&key.encode(),
)
}
SecretScope::User { dek_secret, .. } => {
let encryption = self.derive_user_dek(delegate, dek_secret);
decrypt_secret_blob(&encryption, &[], None, &blob, &key.encode())
}
}
}
pub fn list_snapshots(
&self,
delegate: &DelegateKey,
key: &SecretsId,
scope: SecretScope<'_>,
) -> Result<Vec<SnapshotMetadata>, SecretStoreError> {
let scope_path = self.scope_dir(delegate, &scope);
let snap_dir = snapshot_dir_for(&scope_path, key);
Ok(list_snapshots(&snap_dir)?)
}
pub fn restore_snapshot(
&mut self,
delegate: &DelegateKey,
key: &SecretsId,
scope: SecretScope<'_>,
timestamp_ms: u64,
) -> Result<(), SecretStoreError> {
let scope_path = self.scope_dir(delegate, &scope);
match restore_snapshot_file(
&scope_path,
&key.encode(),
timestamp_ms,
None,
self.snapshots_enabled,
) {
Ok(()) => {}
Err(RestoreError::NotFound(timestamp_ms)) => {
return Err(SecretStoreError::SnapshotNotFound {
key: key.clone(),
timestamp_ms,
});
}
Err(RestoreError::Io(e)) => return Err(e.into()),
}
if let Err(e) = ensure_owner_only_tree(&self.base_path, &scope_path) {
tracing::warn!(path = %scope_path.display(), error = %e, "chmod scope dir tree failed");
}
let secret_key = *key.hash();
match &scope {
SecretScope::Local => {
let mut current_secrets: Vec<[u8; 32]> = self
.key_to_secret_part
.get(delegate)
.map(|entry| entry.value().iter().copied().collect())
.unwrap_or_default();
if !current_secrets.contains(&secret_key) {
current_secrets.push(secret_key);
self.db
.store_secrets_index(delegate, ¤t_secrets)
.map_err(|e| {
std::io::Error::other(format!("Failed to update secrets index: {e}"))
})?;
let secret_set: HashSet<SecretKey> = current_secrets.into_iter().collect();
self.key_to_secret_part.insert(delegate.clone(), secret_set);
}
}
SecretScope::User { id, .. } => {
let map_key = (delegate.clone(), **id);
let mut current_secrets: Vec<[u8; 32]> = self
.user_key_to_secret_part
.get(&map_key)
.map(|entry| entry.value().iter().copied().collect())
.unwrap_or_default();
if !current_secrets.contains(&secret_key) {
current_secrets.push(secret_key);
self.db
.store_user_secrets_index(delegate, id.as_bytes(), ¤t_secrets)
.map_err(|e| {
std::io::Error::other(format!(
"Failed to update user secrets index: {e}"
))
})?;
let secret_set: HashSet<SecretKey> = current_secrets.into_iter().collect();
self.user_key_to_secret_part.insert(map_key, secret_set);
}
}
}
if self.snapshots_enabled {
let snap_dir = snapshot_dir_for(&scope_path, key);
if snap_dir.exists() {
thin_snapshots(&snap_dir, &self.retention, SystemTime::now());
}
}
Ok(())
}
fn enumerate_scope(&self, scope: &SecretScope<'_>) -> Vec<(DelegateKey, SecretKey)> {
let mut out = Vec::new();
match scope {
SecretScope::Local => {
for entry in self.key_to_secret_part.iter() {
let delegate = entry.key().clone();
for hash in entry.value() {
out.push((delegate.clone(), *hash));
}
}
}
SecretScope::User { id, .. } => {
for entry in self.user_key_to_secret_part.iter() {
let (delegate, user) = entry.key();
if user != *id {
continue;
}
for hash in entry.value() {
out.push((delegate.clone(), *hash));
}
}
}
}
out
}
fn read_secret_by_hash(
&self,
delegate: &DelegateKey,
secret_hash: &SecretKey,
scope: &SecretScope<'_>,
) -> Result<Zeroizing<Vec<u8>>, SecretStoreError> {
let encoded = bs58::encode(secret_hash)
.with_alphabet(bs58::Alphabet::BITCOIN)
.into_string();
let secret_path = self.scope_dir(delegate, scope).join(&encoded);
let blob = fs::read(&secret_path).map_err(|_| {
SecretStoreError::IO(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("secret blob not found at {}", secret_path.display()),
))
})?;
match scope {
SecretScope::Local => {
let encryption = self.cipher_for_read(delegate);
let legacy_chain = [&self.default_encryption];
decrypt_secret_blob(
&encryption,
&legacy_chain,
self.legacy_migration_encryption.as_ref(),
&blob,
&encoded,
)
}
SecretScope::User { dek_secret, .. } => {
let encryption = self.derive_user_dek(delegate, dek_secret);
decrypt_secret_blob(&encryption, &[], None, &blob, &encoded)
}
}
}
pub fn export_scope_entries(
&self,
scope: SecretScope<'_>,
) -> Result<Vec<ExportSecretEntry>, SecretStoreError> {
let refs = self.enumerate_scope(&scope);
let mut entries = Vec::with_capacity(refs.len());
for (delegate, secret_hash) in refs {
let plaintext = self.read_secret_by_hash(&delegate, &secret_hash, &scope)?;
entries.push(ExportSecretEntry {
delegate_key: delegate,
secret_hash,
plaintext,
});
}
Ok(entries)
}
pub fn import_secret_by_hash(
&mut self,
delegate: &DelegateKey,
secret_hash: &SecretKey,
scope: SecretScope<'_>,
plaintext: Zeroizing<Vec<u8>>,
overwrite: bool,
) -> RuntimeResult<bool> {
let encoded = bs58::encode(secret_hash)
.with_alphabet(bs58::Alphabet::BITCOIN)
.into_string();
let scope_path = self.scope_dir(delegate, &scope);
let secret_file_path = scope_path.join(&encoded);
if secret_file_path.exists() && !overwrite {
self.add_to_index(delegate, &scope, *secret_hash)?;
return Ok(false);
}
let encryption = match &scope {
SecretScope::Local => self.cipher_for(delegate).clone(),
SecretScope::User { dek_secret, .. } => self.derive_user_dek(delegate, dek_secret),
};
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let aead = encryption
.cipher
.encrypt(&nonce, plaintext.as_slice())
.map_err(SecretStoreError::Encryption)?;
let mut ciphertext = Vec::with_capacity(HEADER_LEN + aead.len());
ciphertext.push(VERSION_V1);
ciphertext.extend_from_slice(nonce.as_slice());
ciphertext.extend_from_slice(&aead);
fs::create_dir_all(&scope_path)?;
if let Err(e) = ensure_owner_only_tree(&self.base_path, &scope_path) {
tracing::warn!(path = %scope_path.display(), error = %e, "chmod scope dir tree failed");
}
if self.snapshots_enabled
&& secret_file_path.exists()
&& let Err(e) = snapshot_active_value(&scope_path, &encoded, &secret_file_path)
{
tracing::warn!(
"failed to snapshot prior secret value during import for delegate {}: {e}",
delegate.encode()
);
}
let tmp_path = secret_file_path.with_extension("tmp");
{
let mut file = create_owner_only(&tmp_path)?;
file.write_all(&ciphertext)?;
file.sync_all()?;
}
if let Err(err) = fs::rename(&tmp_path, &secret_file_path) {
if let Err(rm_err) = fs::remove_file(&tmp_path) {
tracing::debug!(
"failed to clean up tmp file {tmp_path:?} after rename failure: {rm_err}"
);
}
return Err(err.into());
}
self.add_to_index(delegate, &scope, *secret_hash)?;
Ok(true)
}
}
pub struct ExportSecretEntry {
pub delegate_key: DelegateKey,
pub secret_hash: [u8; 32],
pub plaintext: Zeroizing<Vec<u8>>,
}
fn decrypt_secret_blob(
encryption: &Encryption,
legacy_chain: &[&Encryption],
legacy_migration: Option<&Encryption>,
blob: &[u8],
key: &str,
) -> Result<Zeroizing<Vec<u8>>, SecretStoreError> {
if blob.first().copied() == Some(VERSION_V1) && blob.len() >= HEADER_LEN {
let nonce = XNonce::from_slice(&blob[1..HEADER_LEN]);
if let Ok(pt) = encryption.cipher.decrypt(nonce, &blob[HEADER_LEN..]) {
return Ok(Zeroizing::new(pt));
}
for (idx, fallback) in legacy_chain.iter().enumerate() {
if let Ok(pt) = fallback.cipher.decrypt(nonce, &blob[HEADER_LEN..]) {
log_legacy_decrypt(key, idx + 1, false, "versioned");
return Ok(Zeroizing::new(pt));
}
}
if let Some(migration) = legacy_migration
&& let Ok(pt) = migration.cipher.decrypt(nonce, &blob[HEADER_LEN..])
{
log_legacy_decrypt(key, 1 + legacy_chain.len(), true, "versioned");
return Ok(Zeroizing::new(pt));
}
}
if let Ok(pt) = encryption.cipher.decrypt(&encryption.legacy_nonce, blob) {
tracing::debug!(
key = %key,
"Decrypted pre-#4143 raw-AEAD blob with the registered/derived cipher; \
will be migrated to per-write-nonce format on next write."
);
return Ok(Zeroizing::new(pt));
}
if let Some(migration) = legacy_migration
&& let Ok(pt) = migration.cipher.decrypt(&migration.legacy_nonce, blob)
{
log_legacy_decrypt(key, 1 + legacy_chain.len(), true, "raw-aead");
return Ok(Zeroizing::new(pt));
}
Err(SecretStoreError::Encryption(
chacha20poly1305::Error,
))
}
fn log_legacy_decrypt(key: &str, idx: usize, is_migration: bool, format: &str) {
if is_migration {
tracing::warn!(
key = %key,
chain_idx = idx,
format = format,
"Decrypted secret blob via the legacy-default-cipher migration fallback; \
this file pre-dates PR #4143. Will be re-encrypted under the current \
derived DEK on next write."
);
} else {
tracing::info!(
key = %key,
chain_idx = idx,
format = format,
"Decrypted secret blob via a legacy fallback cipher; will be re-encrypted \
under the current derived DEK on next write."
);
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::wasm_runtime::secret_snapshots::{RetentionBucket, RetentionPolicy};
use aes_gcm::KeyInit;
use std::time::Duration;
async fn create_test_db(path: &std::path::Path) -> Storage {
Storage::new(path).await.expect("failed to create test db")
}
fn fresh_cipher() -> (XChaCha20Poly1305, XNonce) {
let cipher = XChaCha20Poly1305::new(&XChaCha20Poly1305::generate_key(&mut OsRng));
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
(cipher, nonce)
}
#[tokio::test]
async fn store_and_load() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![0, 1, 2].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
let secret_id = SecretsId::new(vec![0, 1, 2]);
let text = vec![0, 1, 2];
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(text),
)?;
let f = store.get_secret(delegate.key(), &secret_id, SecretScope::Local);
assert!(f.is_ok());
let _cleanup = std::fs::remove_dir_all(&secrets_dir);
Ok(())
}
#[tokio::test]
async fn second_write_snapshots_prior_value() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![1].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![42]);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"v1".to_vec()),
)?;
std::thread::sleep(Duration::from_millis(5));
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"v2".to_vec()),
)?;
assert_eq!(
store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec(),
b"v2".to_vec()
);
let snap_dir = secrets_dir
.join(delegate.key().encode())
.join(".snapshots")
.join(secret_id.encode());
let entries: Vec<_> = std::fs::read_dir(&snap_dir)?.flatten().collect();
assert_eq!(
entries.len(),
1,
"expected exactly one snapshot, got {entries:?}"
);
let blob = std::fs::read(entries[0].path())?;
let encryption = store
.ciphers
.get(delegate.key())
.expect("cipher registered");
let plaintext = decrypt_secret_blob(encryption, &[], None, &blob, &secret_id.encode())
.expect("snapshot blob should decrypt with the registered cipher");
assert_eq!(plaintext.to_vec(), b"v1".to_vec());
Ok(())
}
#[tokio::test]
async fn burst_writes_are_thinned() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
store.set_retention_policy(RetentionPolicy {
keep_last: 3,
buckets: vec![RetentionBucket {
interval: Duration::from_secs(60),
max_count: 1,
}],
max_age: None,
});
let delegate = Delegate::from((&vec![2].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![7]);
for i in 0u32..50 {
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(i.to_le_bytes().to_vec()),
)?;
std::thread::sleep(Duration::from_millis(5));
}
let snap_dir = secrets_dir
.join(delegate.key().encode())
.join(".snapshots")
.join(secret_id.encode());
let count = std::fs::read_dir(&snap_dir)?.count();
assert!(
count <= 4,
"tight policy should bound snapshot count to <=4; got {count}"
);
assert!(count >= 2, "expected snapshots to be retained; got {count}");
Ok(())
}
#[tokio::test]
async fn remove_secret_clears_index_and_snapshots() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![3].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![9]);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"a".to_vec()),
)?;
std::thread::sleep(Duration::from_millis(5));
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"b".to_vec()),
)?;
let secret_hash = *secret_id.hash();
let pre_index = store
.db
.get_secrets_index(delegate.key())
.expect("index lookup")
.unwrap_or_default();
assert!(
pre_index.contains(&secret_hash),
"index should contain the secret before removal"
);
let snap_dir = secrets_dir
.join(delegate.key().encode())
.join(".snapshots")
.join(secret_id.encode());
assert!(
snap_dir.exists(),
"snapshot dir should exist before removal"
);
store.remove_secret(delegate.key(), &secret_id, SecretScope::Local)?;
let post_index = store
.db
.get_secrets_index(delegate.key())
.expect("index lookup")
.unwrap_or_default();
assert!(
!post_index.contains(&secret_hash),
"ReDb index still contains removed secret hash"
);
let in_mem = store
.key_to_secret_part
.get(delegate.key())
.map(|e| e.value().contains(&secret_hash))
.unwrap_or(false);
assert!(!in_mem, "in-memory map still contains removed secret hash");
assert!(
!snap_dir.exists(),
"snapshot dir should be deleted with the secret"
);
assert!(matches!(
store.get_secret(delegate.key(), &secret_id, SecretScope::Local),
Err(SecretStoreError::MissingSecret(_))
));
Ok(())
}
#[tokio::test]
async fn remove_nonexistent_secret_is_noop() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![4].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![11]);
store.remove_secret(delegate.key(), &secret_id, SecretScope::Local)?;
let post_index = store
.db
.get_secrets_index(delegate.key())
.expect("index lookup")
.unwrap_or_default();
assert!(post_index.is_empty());
Ok(())
}
#[tokio::test]
async fn disabled_flag_suppresses_snapshots() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
store.set_snapshots_enabled(false);
let delegate = Delegate::from((&vec![6].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![14]);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"a".to_vec()),
)?;
std::thread::sleep(Duration::from_millis(5));
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"b".to_vec()),
)?;
let snap_dir = secrets_dir
.join(delegate.key().encode())
.join(".snapshots")
.join(secret_id.encode());
assert!(
!snap_dir.exists(),
"no snapshot dir should be created when snapshots are disabled"
);
assert_eq!(
store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec(),
b"b".to_vec()
);
Ok(())
}
#[tokio::test]
async fn delegates_have_disjoint_snapshot_histories() -> Result<(), Box<dyn std::error::Error>>
{
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate_a = Delegate::from((&vec![10].into(), &vec![].into()));
let delegate_b = Delegate::from((&vec![11].into(), &vec![].into()));
let (ca, na) = fresh_cipher();
let (cb, nb) = fresh_cipher();
store.register_delegate(delegate_a.key().clone(), ca, na)?;
store.register_delegate(delegate_b.key().clone(), cb, nb)?;
let shared_id = SecretsId::new(vec![99]);
for value in [&b"a1"[..], &b"a2"[..]] {
store.store_secret(
delegate_a.key(),
&shared_id,
SecretScope::Local,
Zeroizing::new(value.to_vec()),
)?;
std::thread::sleep(Duration::from_millis(5));
}
for value in [&b"b1"[..], &b"b2"[..]] {
store.store_secret(
delegate_b.key(),
&shared_id,
SecretScope::Local,
Zeroizing::new(value.to_vec()),
)?;
std::thread::sleep(Duration::from_millis(5));
}
let snap_a = secrets_dir
.join(delegate_a.key().encode())
.join(".snapshots")
.join(shared_id.encode());
let snap_b = secrets_dir
.join(delegate_b.key().encode())
.join(".snapshots")
.join(shared_id.encode());
assert!(
snap_a != snap_b,
"snapshot dirs must differ across delegates"
);
assert!(snap_a.exists() && snap_b.exists());
assert_eq!(std::fs::read_dir(&snap_a)?.count(), 1);
assert_eq!(std::fs::read_dir(&snap_b)?.count(), 1);
assert_eq!(
store
.get_secret(delegate_a.key(), &shared_id, SecretScope::Local)?
.to_vec(),
b"a2".to_vec()
);
assert_eq!(
store
.get_secret(delegate_b.key(), &shared_id, SecretScope::Local)?
.to_vec(),
b"b2".to_vec()
);
Ok(())
}
#[tokio::test]
async fn first_write_creates_no_snapshot() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![5].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![13]);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"first".to_vec()),
)?;
let snap_dir = secrets_dir
.join(delegate.key().encode())
.join(".snapshots")
.join(secret_id.encode());
assert!(
!snap_dir.exists(),
"no snapshot should exist after a single write"
);
Ok(())
}
#[tokio::test]
async fn list_snapshots_on_unwritten_secret_is_empty() -> Result<(), Box<dyn std::error::Error>>
{
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let store = SecretsStore::new(secrets_dir, Default::default(), db)?;
let delegate = Delegate::from((&vec![20].into(), &vec![].into()));
let secret_id = SecretsId::new(vec![21]);
let snaps = store.list_snapshots(delegate.key(), &secret_id, SecretScope::Local)?;
assert!(snaps.is_empty(), "no writes → no snapshots");
Ok(())
}
#[tokio::test]
async fn list_snapshots_returns_history() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir, Default::default(), db)?;
let delegate = Delegate::from((&vec![30].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![31]);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"v1".to_vec()),
)?;
std::thread::sleep(Duration::from_millis(5));
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"v2".to_vec()),
)?;
std::thread::sleep(Duration::from_millis(5));
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"v3".to_vec()),
)?;
let snaps = store.list_snapshots(delegate.key(), &secret_id, SecretScope::Local)?;
assert_eq!(snaps.len(), 2, "expected two snapshots after 3 writes");
assert!(
snaps[0].timestamp_ms <= snaps[1].timestamp_ms,
"must be oldest-first"
);
Ok(())
}
#[tokio::test]
async fn restore_snapshot_replaces_active_value() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir, Default::default(), db)?;
let delegate = Delegate::from((&vec![40].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![41]);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"v1".to_vec()),
)?;
std::thread::sleep(Duration::from_millis(5));
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"v2".to_vec()),
)?;
assert_eq!(
store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec(),
b"v2".to_vec()
);
let snaps = store.list_snapshots(delegate.key(), &secret_id, SecretScope::Local)?;
assert_eq!(snaps.len(), 1);
let v1_ts = snaps[0].timestamp_ms;
store.restore_snapshot(delegate.key(), &secret_id, SecretScope::Local, v1_ts)?;
assert_eq!(
store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec(),
b"v1".to_vec(),
"restore must put the v1 plaintext back"
);
let snaps_after = store.list_snapshots(delegate.key(), &secret_id, SecretScope::Local)?;
assert!(
!snaps_after.is_empty(),
"restore must snapshot the prior active value; got {} snapshots",
snaps_after.len()
);
Ok(())
}
#[tokio::test]
async fn restore_snapshot_unknown_timestamp_errors() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir, Default::default(), db)?;
let delegate = Delegate::from((&vec![50].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![51]);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"a".to_vec()),
)?;
std::thread::sleep(Duration::from_millis(5));
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"b".to_vec()),
)?;
let err = store
.restore_snapshot(delegate.key(), &secret_id, SecretScope::Local, 0)
.expect_err("timestamp 0 should not exist");
match err {
SecretStoreError::SnapshotNotFound { timestamp_ms, .. } => {
assert_eq!(timestamp_ms, 0);
}
SecretStoreError::Encryption(_)
| SecretStoreError::IO(_)
| SecretStoreError::MissingCipher
| SecretStoreError::MissingSecret(_) => {
panic!("expected SnapshotNotFound, got {err:?}");
}
}
Ok(())
}
#[tokio::test]
async fn restore_after_remove_repopulates_index() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![60].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![61]);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"keep".to_vec()),
)?;
std::thread::sleep(Duration::from_millis(5));
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"overwrite".to_vec()),
)?;
let snaps = store.list_snapshots(delegate.key(), &secret_id, SecretScope::Local)?;
assert_eq!(snaps.len(), 1);
let prior_ts = snaps[0].timestamp_ms;
let snap_src = snaps[0].path.clone();
let snap_backup = temp_dir.path().join("backup-snapshot");
std::fs::copy(&snap_src, &snap_backup)?;
store.remove_secret(delegate.key(), &secret_id, SecretScope::Local)?;
let snap_dir = secrets_dir
.join(delegate.key().encode())
.join(".snapshots")
.join(secret_id.encode());
std::fs::create_dir_all(&snap_dir)?;
std::fs::copy(&snap_backup, snap_src)?;
let secret_hash = *secret_id.hash();
let in_mem_before = store
.key_to_secret_part
.get(delegate.key())
.map(|e| e.value().contains(&secret_hash))
.unwrap_or(false);
assert!(!in_mem_before, "index should be empty after remove_secret");
store.restore_snapshot(delegate.key(), &secret_id, SecretScope::Local, prior_ts)?;
let post_index = store
.db
.get_secrets_index(delegate.key())
.expect("index lookup")
.unwrap_or_default();
assert!(
post_index.contains(&secret_hash),
"ReDb index must re-include the restored secret"
);
let in_mem_after = store
.key_to_secret_part
.get(delegate.key())
.map(|e| e.value().contains(&secret_hash))
.unwrap_or(false);
assert!(
in_mem_after,
"in-memory map must re-include the restored secret"
);
assert_eq!(
store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec(),
b"keep".to_vec()
);
Ok(())
}
#[tokio::test]
async fn restore_snapshot_prefers_unsuffixed_collision()
-> Result<(), Box<dyn std::error::Error>> {
use crate::wasm_runtime::secret_snapshots::SNAPSHOT_NAME_WIDTH;
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
store.set_retention_policy(RetentionPolicy {
keep_last: 100,
buckets: vec![],
max_age: None,
});
let delegate = Delegate::from((&vec![70].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![71]);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"active".to_vec()),
)?;
let snap_dir = secrets_dir
.join(delegate.key().encode())
.join(".snapshots")
.join(secret_id.encode());
std::fs::create_dir_all(&snap_dir)?;
let stamp = 1_700_000_000_000u64;
let base = format!("{stamp:0width$}", width = SNAPSHOT_NAME_WIDTH);
let encryption = store
.ciphers
.get(delegate.key())
.expect("cipher registered");
let mk = |pt: &[u8]| -> Vec<u8> {
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let aead = encryption.cipher.encrypt(&nonce, pt).expect("encrypt");
let mut out = Vec::with_capacity(HEADER_LEN + aead.len());
out.push(VERSION_V1);
out.extend_from_slice(nonce.as_slice());
out.extend_from_slice(&aead);
out
};
std::fs::write(snap_dir.join(&base), mk(b"unsuffixed-winner"))?;
std::fs::write(snap_dir.join(format!("{base}.0")), mk(b"suffix-0"))?;
std::fs::write(snap_dir.join(format!("{base}.1")), mk(b"suffix-1"))?;
store.restore_snapshot(delegate.key(), &secret_id, SecretScope::Local, stamp)?;
assert_eq!(
store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec(),
b"unsuffixed-winner".to_vec(),
"unsuffixed file must win the collision tiebreak"
);
std::fs::remove_file(snap_dir.join(&base))?;
store.restore_snapshot(delegate.key(), &secret_id, SecretScope::Local, stamp)?;
assert_eq!(
store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec(),
b"suffix-0".to_vec(),
"with the unsuffixed entry gone, lowest-numbered suffix wins"
);
Ok(())
}
#[tokio::test]
async fn per_write_nonce_makes_identical_plaintext_ciphertext_distinct()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![80].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![81]);
let plaintext = b"identical".to_vec();
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(plaintext.clone()),
)?;
let active = secrets_dir
.join(delegate.key().encode())
.join(secret_id.encode());
let first = std::fs::read(&active)?;
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(plaintext.clone()),
)?;
let second = std::fs::read(&active)?;
assert_ne!(
first, second,
"two writes of the same plaintext under nonce-per-write MUST differ on disk"
);
assert_ne!(
&first[1..HEADER_LEN],
&second[1..HEADER_LEN],
"nonce field must differ across writes"
);
assert_eq!(
store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec(),
plaintext
);
Ok(())
}
#[tokio::test]
async fn legacy_blob_with_version_byte_falls_through_to_legacy_decrypt()
-> Result<(), Box<dyn std::error::Error>> {
unsafe {
std::env::remove_var("CREDENTIALS_DIRECTORY");
}
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![88].into(), &vec![].into()));
let derived = store.derive_delegate_dek(delegate.key());
let cipher = derived.cipher.clone();
let registration_nonce = derived.legacy_nonce;
let secret_id = SecretsId::new(vec![89]);
let mut legacy_blob: Option<(u8, Vec<u8>)> = None;
for first_byte in 0u8..=u8::MAX {
let plaintext = vec![first_byte; 16];
let aead = cipher
.encrypt(®istration_nonce, plaintext.as_ref())
.expect("legacy encrypt");
if aead.first().copied() == Some(VERSION_V1) {
legacy_blob = Some((first_byte, aead));
break;
}
if first_byte == u8::MAX {
break;
}
}
let (winning_byte, legacy_blob) = legacy_blob.expect(
"XChaCha20 keystream byte 0 should make aead[0]=0x01 reachable for some plaintext byte",
);
assert_eq!(legacy_blob.first().copied(), Some(VERSION_V1));
assert!(
legacy_blob.len() >= HEADER_LEN,
"legacy blob too short to even *look* like a new-format blob: {} bytes",
legacy_blob.len()
);
let delegate_dir = secrets_dir.join(delegate.key().encode());
std::fs::create_dir_all(&delegate_dir)?;
std::fs::write(delegate_dir.join(secret_id.encode()), &legacy_blob)?;
let recovered = store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec();
assert_eq!(
recovered,
vec![winning_byte; 16],
"fallback must recover the original 16-byte plaintext"
);
Ok(())
}
#[tokio::test]
async fn store_secret_writes_version_header() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![82].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![83]);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"hello".to_vec()),
)?;
let active = secrets_dir
.join(delegate.key().encode())
.join(secret_id.encode());
let blob = std::fs::read(&active)?;
assert_eq!(
blob.first().copied(),
Some(VERSION_V1),
"new-format blob must start with VERSION_V1"
);
assert!(
blob.len() >= HEADER_LEN + 16,
"blob too short: {} bytes",
blob.len()
);
let secret_id_2 = SecretsId::new(vec![84]);
store.store_secret(
delegate.key(),
&secret_id_2,
SecretScope::Local,
Zeroizing::new(b"hello".to_vec()),
)?;
let blob_2 = std::fs::read(
secrets_dir
.join(delegate.key().encode())
.join(secret_id_2.encode()),
)?;
assert_ne!(
&blob[1..HEADER_LEN],
&blob_2[1..HEADER_LEN],
"nonce field must be random per write"
);
Ok(())
}
#[tokio::test]
async fn legacy_format_blob_is_decryptable() -> Result<(), Box<dyn std::error::Error>> {
unsafe {
std::env::remove_var("CREDENTIALS_DIRECTORY");
}
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![84].into(), &vec![].into()));
let secret_id = SecretsId::new(vec![85]);
let derived = store.derive_delegate_dek(delegate.key());
let plaintext = b"legacy-payload".to_vec();
let legacy_blob = derived
.cipher
.encrypt(&derived.legacy_nonce, plaintext.as_ref())
.expect("legacy encrypt under derived DEK");
let delegate_dir = secrets_dir.join(delegate.key().encode());
std::fs::create_dir_all(&delegate_dir)?;
std::fs::write(delegate_dir.join(secret_id.encode()), &legacy_blob)?;
let recovered = store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec();
assert_eq!(
recovered, plaintext,
"legacy-format blob must decrypt via tier 1 raw-AEAD fallback"
);
Ok(())
}
#[tokio::test]
async fn corrupt_versioned_blob_errors_cleanly() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![86].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![87]);
let mut bogus = vec![VERSION_V1];
bogus.extend_from_slice(&[0u8; 24]);
bogus.extend_from_slice(&[0u8; 32]);
let delegate_dir = secrets_dir.join(delegate.key().encode());
std::fs::create_dir_all(&delegate_dir)?;
std::fs::write(delegate_dir.join(secret_id.encode()), &bogus)?;
let err = store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)
.expect_err("corrupt blob must fail");
assert!(
matches!(err, SecretStoreError::Encryption(_)),
"expected Encryption error, got {err:?}"
);
Ok(())
}
#[tokio::test]
async fn legacy_snapshot_survives_restore_and_get_secret()
-> Result<(), Box<dyn std::error::Error>> {
use crate::wasm_runtime::secret_snapshots::SNAPSHOT_NAME_WIDTH;
unsafe {
std::env::remove_var("CREDENTIALS_DIRECTORY");
}
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![90].into(), &vec![].into()));
let derived = store.derive_delegate_dek(delegate.key());
let cipher = derived.cipher.clone();
let registration_nonce = derived.legacy_nonce;
let secret_id = SecretsId::new(vec![91]);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"current".to_vec()),
)?;
let snap_dir = secrets_dir
.join(delegate.key().encode())
.join(".snapshots")
.join(secret_id.encode());
std::fs::create_dir_all(&snap_dir)?;
let stamp = 1_700_000_000_000u64;
let snap_path = snap_dir.join(format!("{stamp:0width$}", width = SNAPSHOT_NAME_WIDTH));
let plaintext = b"legacy-snapshot-payload".to_vec();
let legacy_aead = cipher
.encrypt(®istration_nonce, plaintext.as_ref())
.expect("legacy encrypt");
assert_ne!(
legacy_aead.first().copied(),
Some(VERSION_V1),
"test setup unlucky: legacy AEAD happens to start with VERSION_V1; \
pick a different plaintext"
);
std::fs::write(&snap_path, &legacy_aead)?;
store.set_retention_policy(RetentionPolicy {
keep_last: 100,
buckets: vec![],
max_age: None,
});
store.restore_snapshot(delegate.key(), &secret_id, SecretScope::Local, stamp)?;
let recovered = store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec();
assert_eq!(
recovered, plaintext,
"legacy snapshot must remain decryptable after restore + get_secret"
);
Ok(())
}
#[tokio::test]
async fn register_with_default_cipher_decrypts_legacy_default_blob()
-> Result<(), Box<dyn std::error::Error>> {
use crate::config::{LEGACY_DEFAULT_CIPHER, LEGACY_DEFAULT_NONCE};
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![92].into(), &vec![].into()));
let default_cipher = XChaCha20Poly1305::new((&LEGACY_DEFAULT_CIPHER).into());
let default_nonce: XNonce = LEGACY_DEFAULT_NONCE.into();
store.register_delegate(
delegate.key().clone(),
default_cipher.clone(),
default_nonce,
)?;
let secret_id = SecretsId::new(vec![93]);
let plaintext = b"upgraded-from-default-config".to_vec();
let legacy_aead = default_cipher
.encrypt(&default_nonce, plaintext.as_ref())
.expect("legacy encrypt");
let delegate_dir = secrets_dir.join(delegate.key().encode());
std::fs::create_dir_all(&delegate_dir)?;
std::fs::write(delegate_dir.join(secret_id.encode()), &legacy_aead)?;
let recovered = store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec();
assert_eq!(
recovered, plaintext,
"default-cipher legacy blob must remain readable after register_delegate \
(behavioral equivalence with the removed skip-on-default-nonce branch)"
);
Ok(())
}
#[tokio::test]
async fn legacy_default_blob_decryptable_without_register_after_upgrade()
-> Result<(), Box<dyn std::error::Error>> {
use crate::config::{LEGACY_DEFAULT_CIPHER, LEGACY_DEFAULT_NONCE};
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let secrets = Secrets::default();
assert_ne!(
secrets.cipher, LEGACY_DEFAULT_CIPHER,
"test precondition: Secrets::default() must be random per call (post-PR-#4144)"
);
let store = SecretsStore::new(secrets_dir.clone(), secrets, db)?;
let delegate = Delegate::from((&vec![94].into(), &vec![].into()));
let legacy_cipher = XChaCha20Poly1305::new((&LEGACY_DEFAULT_CIPHER).into());
let legacy_nonce: XNonce = LEGACY_DEFAULT_NONCE.into();
let plaintext = b"survives-the-upgrade".to_vec();
let legacy_aead = legacy_cipher
.encrypt(&legacy_nonce, plaintext.as_ref())
.expect("legacy encrypt");
let secret_id = SecretsId::new(vec![95]);
let delegate_dir = secrets_dir.join(delegate.key().encode());
std::fs::create_dir_all(&delegate_dir)?;
std::fs::write(delegate_dir.join(secret_id.encode()), &legacy_aead)?;
let recovered = store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec();
assert_eq!(
recovered, plaintext,
"legacy-default blob MUST be decryptable via legacy_migration_encryption \
fallback, even without register_delegate having been called"
);
Ok(())
}
#[tokio::test]
async fn restart_roundtrip_recovers_secret_without_re_registering()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db_path = temp_dir.path().to_path_buf();
let delegate = Delegate::from((&vec![100].into(), &vec![].into()));
let secret_id = SecretsId::new(vec![101]);
let plaintext = b"persisted-across-restart".to_vec();
unsafe {
std::env::remove_var("CREDENTIALS_DIRECTORY");
}
{
let db = create_test_db(&db_path).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(plaintext.clone()),
)?;
let marker_path = secrets_dir.join("kek_backend");
assert!(
marker_path.exists(),
"first start must persist a backend marker at {}",
marker_path.display()
);
}
let db = create_test_db(&db_path).await;
let store = SecretsStore::new(secrets_dir, Default::default(), db)?;
let recovered = store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec();
assert_eq!(
recovered, plaintext,
"second-start get_secret MUST recover plaintext via HKDF re-derivation"
);
Ok(())
}
#[tokio::test]
async fn derive_delegate_dek_deterministic_and_per_delegate()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let store = SecretsStore::new(secrets_dir, Default::default(), db)?;
let delegate_a = Delegate::from((&vec![110].into(), &vec![].into()));
let delegate_b = Delegate::from((&vec![111].into(), &vec![].into()));
let dek_a1 = store.derive_delegate_dek(delegate_a.key());
let dek_a2 = store.derive_delegate_dek(delegate_a.key());
let dek_b = store.derive_delegate_dek(delegate_b.key());
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let pt = b"determinism-pin".as_slice();
let ct1 = dek_a1.cipher.encrypt(&nonce, pt).expect("encrypt");
let ct2 = dek_a2.cipher.encrypt(&nonce, pt).expect("encrypt");
assert_eq!(ct1, ct2, "same KEK + same delegate must yield same DEK");
let ct3 = dek_b.cipher.encrypt(&nonce, pt).expect("encrypt");
assert_ne!(ct1, ct3, "different delegate_key must yield different DEK");
Ok(())
}
#[tokio::test]
async fn backcompat_versioned_blob_under_legacy_default_cipher()
-> Result<(), Box<dyn std::error::Error>> {
use crate::config::{LEGACY_DEFAULT_CIPHER, LEGACY_DEFAULT_NONCE};
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![120].into(), &vec![].into()));
let secret_id = SecretsId::new(vec![121]);
let legacy_cipher = XChaCha20Poly1305::new((&LEGACY_DEFAULT_CIPHER).into());
let _ = LEGACY_DEFAULT_NONCE;
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let plaintext = b"era-4143-payload".to_vec();
let aead = legacy_cipher
.encrypt(&nonce, plaintext.as_ref())
.expect("encrypt");
let mut blob = vec![VERSION_V1];
blob.extend_from_slice(nonce.as_slice());
blob.extend_from_slice(&aead);
let delegate_dir = secrets_dir.join(delegate.key().encode());
std::fs::create_dir_all(&delegate_dir)?;
std::fs::write(delegate_dir.join(secret_id.encode()), &blob)?;
let recovered = store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec();
assert_eq!(recovered, plaintext);
Ok(())
}
#[tokio::test]
async fn backcompat_versioned_blob_under_post_4144_delegate_cipher()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut secrets = Secrets::default();
let old_install_cipher_bytes = secrets.cipher; let old_install_cipher = XChaCha20Poly1305::new((&old_install_cipher_bytes).into());
let store = SecretsStore::new(secrets_dir.clone(), secrets.clone(), db)?;
let delegate = Delegate::from((&vec![122].into(), &vec![].into()));
let secret_id = SecretsId::new(vec![123]);
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let plaintext = b"era-4144-payload".to_vec();
let aead = old_install_cipher
.encrypt(&nonce, plaintext.as_ref())
.expect("encrypt");
let mut blob = vec![VERSION_V1];
blob.extend_from_slice(nonce.as_slice());
blob.extend_from_slice(&aead);
let delegate_dir = secrets_dir.join(delegate.key().encode());
std::fs::create_dir_all(&delegate_dir)?;
std::fs::write(delegate_dir.join(secret_id.encode()), &blob)?;
let recovered = store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec();
assert_eq!(recovered, plaintext);
secrets.cipher_path = None;
Ok(())
}
#[tokio::test]
async fn backcompat_raw_aead_under_legacy_default_cipher()
-> Result<(), Box<dyn std::error::Error>> {
use crate::config::{LEGACY_DEFAULT_CIPHER, LEGACY_DEFAULT_NONCE};
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![124].into(), &vec![].into()));
let secret_id = SecretsId::new(vec![125]);
let legacy_cipher = XChaCha20Poly1305::new((&LEGACY_DEFAULT_CIPHER).into());
let legacy_nonce: XNonce = LEGACY_DEFAULT_NONCE.into();
let plaintext = b"pre-4143-payload".to_vec();
let aead = legacy_cipher
.encrypt(&legacy_nonce, plaintext.as_ref())
.expect("encrypt");
let delegate_dir = secrets_dir.join(delegate.key().encode());
std::fs::create_dir_all(&delegate_dir)?;
std::fs::write(delegate_dir.join(secret_id.encode()), &aead)?;
let recovered = store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec();
assert_eq!(recovered, plaintext);
Ok(())
}
#[tokio::test]
async fn backcompat_register_delegate_wire_still_works()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![126].into(), &vec![].into()));
let (client_cipher, client_nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), client_cipher, client_nonce)?;
let secret_id = SecretsId::new(vec![127]);
let plaintext = b"register-then-write".to_vec();
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(plaintext.clone()),
)?;
let recovered = store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec();
assert_eq!(recovered, plaintext);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn secret_files_are_owner_only_on_unix() -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::PermissionsExt;
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
std::fs::set_permissions(&secrets_dir, std::fs::Permissions::from_mode(0o755))?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let root_mode = std::fs::metadata(&secrets_dir)?.permissions().mode() & 0o777;
assert_eq!(
root_mode, 0o700,
"secrets root must be 0o700, got {root_mode:o}"
);
let delegate = Delegate::from((&vec![200].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![201]);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"v1".to_vec()),
)?;
std::thread::sleep(Duration::from_millis(5));
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"v2".to_vec()),
)?;
let delegate_dir = secrets_dir.join(delegate.key().encode());
let secret_file = delegate_dir.join(secret_id.encode());
let snap_dir = delegate_dir.join(".snapshots").join(secret_id.encode());
let delegate_mode = std::fs::metadata(&delegate_dir)?.permissions().mode() & 0o777;
assert_eq!(
delegate_mode, 0o700,
"delegate dir must be 0o700, got {delegate_mode:o}"
);
let snap_dir_mode = std::fs::metadata(&snap_dir)?.permissions().mode() & 0o777;
assert_eq!(
snap_dir_mode, 0o700,
"snapshot dir must be 0o700, got {snap_dir_mode:o}"
);
let secret_mode = std::fs::metadata(&secret_file)?.permissions().mode() & 0o777;
assert_eq!(
secret_mode, 0o600,
"active secret file must be 0o600, got {secret_mode:o}"
);
for entry in std::fs::read_dir(&snap_dir)? {
let entry = entry?;
let mode = entry.metadata()?.permissions().mode() & 0o777;
assert_eq!(
mode,
0o600,
"snapshot file {} must be 0o600, got {mode:o}",
entry.path().display()
);
}
Ok(())
}
#[test]
fn debug_format_redacts_cipher_and_nonce() {
let secrets = crate::config::Secrets {
transport_keypair: crate::transport::TransportKeypair::new(),
transport_keypair_path: None,
nonce: [0xAA; 24],
nonce_path: None,
cipher: [0xBB; 32],
cipher_path: None,
};
let rendered = format!("{secrets:?}");
assert!(
!rendered.contains("AA"),
"nonce hex byte leaked: {rendered}"
);
assert!(
!rendered.contains("BB"),
"cipher hex byte leaked: {rendered}"
);
assert!(
!rendered.contains("170"),
"nonce decimal byte leaked: {rendered}"
);
assert!(
!rendered.contains("187"),
"cipher decimal byte leaked: {rendered}"
);
assert!(
rendered.contains("redacted"),
"expected redaction marker: {rendered}"
);
}
#[tokio::test]
async fn zeroizing_roundtrip_preserves_plaintext() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir, Default::default(), db)?;
let delegate = Delegate::from((&vec![210].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![211]);
let plaintext: Vec<u8> = (0u8..=255).collect();
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(plaintext.clone()),
)?;
let recovered = store.get_secret(delegate.key(), &secret_id, SecretScope::Local)?;
assert_eq!(recovered.as_slice(), plaintext.as_slice());
Ok(())
}
fn user_dek(byte: u8) -> Zeroizing<[u8; 32]> {
Zeroizing::new([byte; 32])
}
#[tokio::test]
async fn local_scope_uses_legacy_path_and_blob_layout() -> Result<(), Box<dyn std::error::Error>>
{
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![230].into(), &vec![].into()));
let secret_id = SecretsId::new(vec![231]);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"local-value".to_vec()),
)?;
let legacy_path = secrets_dir
.join(delegate.key().encode())
.join(secret_id.encode());
assert!(
legacy_path.exists(),
"Local secret must land at the unchanged legacy path {}",
legacy_path.display()
);
let users_dir = secrets_dir.join(delegate.key().encode()).join("users");
assert!(
!users_dir.exists(),
"a Local write must not create a users/ directory"
);
let blob = std::fs::read(&legacy_path)?;
assert_eq!(
blob.first().copied(),
Some(VERSION_V1),
"Local blob must keep the VERSION_V1 header"
);
assert!(
blob.len() >= HEADER_LEN + 16,
"Local blob must be [VER][24-nonce][AEAD>=16]; got {} bytes",
blob.len()
);
assert_eq!(
store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec(),
b"local-value".to_vec()
);
Ok(())
}
#[tokio::test]
async fn local_and_user_writes_touch_disjoint_redb_tables()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![232].into(), &vec![].into()));
let secret_id = SecretsId::new(vec![233]);
let alice = UserId::new([1u8; 32]);
let alice_dek = user_dek(0x11);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"L".to_vec()),
)?;
let local_index = store
.db
.get_secrets_index(delegate.key())?
.unwrap_or_default();
assert!(
local_index.contains(secret_id.hash()),
"Local write must populate the single-user index"
);
let user_index_after_local = store
.db
.get_user_secrets_index(delegate.key(), alice.as_bytes())?
.unwrap_or_default();
assert!(
user_index_after_local.is_empty(),
"Local write must NOT touch the per-user index"
);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &alice,
dek_secret: &alice_dek,
},
Zeroizing::new(b"U".to_vec()),
)?;
let user_index = store
.db
.get_user_secrets_index(delegate.key(), alice.as_bytes())?
.unwrap_or_default();
assert!(
user_index.contains(secret_id.hash()),
"User write must populate the per-user index"
);
let local_index_after_user = store
.db
.get_secrets_index(delegate.key())?
.unwrap_or_default();
assert_eq!(
local_index, local_index_after_user,
"User write must not perturb the single-user index"
);
Ok(())
}
#[tokio::test]
async fn cross_user_isolation() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![240].into(), &vec![].into()));
let secret_id = SecretsId::new(vec![241]);
let alice = UserId::new([0xAA; 32]);
let bob = UserId::new([0xBB; 32]);
let alice_dek = user_dek(0xA1);
let bob_dek = user_dek(0xB1);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"local-secret".to_vec()),
)?;
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &alice,
dek_secret: &alice_dek,
},
Zeroizing::new(b"alice-secret".to_vec()),
)?;
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &bob,
dek_secret: &bob_dek,
},
Zeroizing::new(b"bob-secret".to_vec()),
)?;
assert_eq!(
store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec(),
b"local-secret".to_vec()
);
assert_eq!(
store
.get_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &alice,
dek_secret: &alice_dek
}
)?
.to_vec(),
b"alice-secret".to_vec()
);
assert_eq!(
store
.get_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &bob,
dek_secret: &bob_dek
}
)?
.to_vec(),
b"bob-secret".to_vec()
);
let err = store
.get_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &alice,
dek_secret: &bob_dek,
},
)
.expect_err("A's secret must not decrypt under B's dek_secret");
assert!(
matches!(err, SecretStoreError::Encryption(_)),
"wrong dek_secret must surface Encryption error, got {err:?}"
);
let carol = UserId::new([0xCC; 32]);
let carol_dek = user_dek(0xC1);
let absent = store.get_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &carol,
dek_secret: &carol_dek,
},
);
assert!(
matches!(absent, Err(SecretStoreError::MissingSecret(_))),
"an unwritten user namespace must be MissingSecret, got {absent:?}"
);
let local_file = secrets_dir
.join(delegate.key().encode())
.join(secret_id.encode());
let alice_file = secrets_dir
.join(delegate.key().encode())
.join("users")
.join(alice.encode())
.join(secret_id.encode());
let bob_file = secrets_dir
.join(delegate.key().encode())
.join("users")
.join(bob.encode())
.join(secret_id.encode());
assert!(local_file.exists() && alice_file.exists() && bob_file.exists());
assert!(
local_file != alice_file && alice_file != bob_file,
"each scope must occupy a distinct on-disk path"
);
let carol_dir = secrets_dir
.join(delegate.key().encode())
.join("users")
.join(carol.encode());
assert!(
!carol_dir.exists(),
"no directory should exist for a user who never wrote"
);
Ok(())
}
#[tokio::test]
async fn remove_user_secret_is_scoped() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![242].into(), &vec![].into()));
let secret_id = SecretsId::new(vec![243]);
let alice = UserId::new([0xA0; 32]);
let bob = UserId::new([0xB0; 32]);
let alice_dek = user_dek(0xA2);
let bob_dek = user_dek(0xB2);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"L".to_vec()),
)?;
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &alice,
dek_secret: &alice_dek,
},
Zeroizing::new(b"A".to_vec()),
)?;
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &bob,
dek_secret: &bob_dek,
},
Zeroizing::new(b"B".to_vec()),
)?;
store.remove_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &alice,
dek_secret: &alice_dek,
},
)?;
assert!(matches!(
store.get_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &alice,
dek_secret: &alice_dek
}
),
Err(SecretStoreError::MissingSecret(_))
));
assert!(
store
.db
.get_user_secrets_index(delegate.key(), alice.as_bytes())?
.unwrap_or_default()
.is_empty(),
"Alice's per-user index entry must be cleared"
);
assert_eq!(
store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec(),
b"L".to_vec(),
"Local secret must survive a User remove"
);
assert_eq!(
store
.get_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &bob,
dek_secret: &bob_dek
}
)?
.to_vec(),
b"B".to_vec(),
"Bob's secret must survive Alice's remove"
);
Ok(())
}
#[tokio::test]
async fn redb_backcompat_and_user_roundtrip_across_reopen()
-> Result<(), Box<dyn std::error::Error>> {
unsafe {
std::env::remove_var("CREDENTIALS_DIRECTORY");
}
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db_path = temp_dir.path().to_path_buf();
let delegate = Delegate::from((&vec![250].into(), &vec![].into()));
let local_id = SecretsId::new(vec![251]);
let user_id_secret = SecretsId::new(vec![252]);
let alice = UserId::new([0x5A; 32]);
let alice_dek = user_dek(0x5A);
{
let db = create_test_db(&db_path).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
store.store_secret(
delegate.key(),
&local_id,
SecretScope::Local,
Zeroizing::new(b"legacy-local".to_vec()),
)?;
let local_index_before = store
.db
.get_secrets_index(delegate.key())?
.unwrap_or_default();
assert!(local_index_before.contains(local_id.hash()));
store.store_secret(
delegate.key(),
&user_id_secret,
SecretScope::User {
id: &alice,
dek_secret: &alice_dek,
},
Zeroizing::new(b"alice-persisted".to_vec()),
)?;
let local_index_after = store
.db
.get_secrets_index(delegate.key())?
.unwrap_or_default();
assert_eq!(
local_index_before, local_index_after,
"User write must not perturb the persisted single-user index"
);
}
let db = create_test_db(&db_path).await;
let store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
assert_eq!(
store
.get_secret(delegate.key(), &local_id, SecretScope::Local)?
.to_vec(),
b"legacy-local".to_vec(),
"pre-existing Local secret must remain readable after reopen"
);
assert_eq!(
store
.get_secret(
delegate.key(),
&user_id_secret,
SecretScope::User {
id: &alice,
dek_secret: &alice_dek
}
)?
.to_vec(),
b"alice-persisted".to_vec(),
"User secret must round-trip across a store reopen"
);
let rehydrated = store
.user_key_to_secret_part
.get(&(delegate.key().clone(), alice))
.map(|e| e.value().contains(user_id_secret.hash()))
.unwrap_or(false);
assert!(
rehydrated,
"per-user in-memory index must rehydrate from ReDb on reopen"
);
Ok(())
}
#[tokio::test]
async fn user_dek_deterministic_and_kek_independent() -> Result<(), Box<dyn std::error::Error>>
{
unsafe {
std::env::remove_var("CREDENTIALS_DIRECTORY");
}
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![160].into(), &vec![].into()));
let dek_a = user_dek(0x01);
let dek_b = user_dek(0x02);
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let pt = b"user-dek-determinism".as_slice();
let enc_a1 = store.derive_user_dek(delegate.key(), &dek_a);
let enc_a2 = store.derive_user_dek(delegate.key(), &dek_a);
let ct_a1 = enc_a1.cipher.encrypt(&nonce, pt).expect("encrypt");
let ct_a2 = enc_a2.cipher.encrypt(&nonce, pt).expect("encrypt");
assert_eq!(
ct_a1, ct_a2,
"same (delegate, dek_secret) must yield the same user DEK"
);
let enc_b = store.derive_user_dek(delegate.key(), &dek_b);
let ct_b = enc_b.cipher.encrypt(&nonce, pt).expect("encrypt");
assert_ne!(
ct_a1, ct_b,
"different dek_secret must yield a different user DEK"
);
let secrets_dir2 = temp_dir.path().join("secrets-store-test-2");
std::fs::create_dir_all(&secrets_dir2)?;
let db2_dir = temp_dir.path().join("db2");
std::fs::create_dir_all(&db2_dir)?;
let db2 = create_test_db(&db2_dir).await;
let store2 = SecretsStore::new(secrets_dir2, Default::default(), db2)?;
let local1 = store.derive_delegate_dek(delegate.key());
let local2 = store2.derive_delegate_dek(delegate.key());
let lct1 = local1.cipher.encrypt(&nonce, pt).expect("encrypt");
let lct2 = local2.cipher.encrypt(&nonce, pt).expect("encrypt");
assert_ne!(
lct1, lct2,
"test precondition: the two stores must have distinct node KEKs"
);
let enc_a_store2 = store2.derive_user_dek(delegate.key(), &dek_a);
let ct_a_store2 = enc_a_store2.cipher.encrypt(&nonce, pt).expect("encrypt");
assert_eq!(
ct_a1, ct_a_store2,
"user DEK must be independent of the node KEK"
);
Ok(())
}
#[tokio::test]
async fn user_secret_portable_across_nodes() -> Result<(), Box<dyn std::error::Error>> {
unsafe {
std::env::remove_var("CREDENTIALS_DIRECTORY");
}
let temp_dir = tempfile::tempdir()?;
let dir1 = temp_dir.path().join("node1-secrets");
let dir2 = temp_dir.path().join("node2-secrets");
let db1_dir = temp_dir.path().join("db-node1");
let db2_dir = temp_dir.path().join("db-node2");
std::fs::create_dir_all(&dir1)?;
std::fs::create_dir_all(&dir2)?;
std::fs::create_dir_all(&db1_dir)?;
std::fs::create_dir_all(&db2_dir)?;
let delegate = Delegate::from((&vec![161].into(), &vec![].into()));
let secret_id = SecretsId::new(vec![163]);
let alice = UserId::new([0x77; 32]);
let alice_dek = user_dek(0x77);
let db1 = create_test_db(&db1_dir).await;
let mut store1 = SecretsStore::new(dir1.clone(), Default::default(), db1)?;
store1.store_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &alice,
dek_secret: &alice_dek,
},
Zeroizing::new(b"portable-payload".to_vec()),
)?;
let rel = std::path::Path::new(&delegate.key().encode())
.join("users")
.join(alice.encode())
.join(secret_id.encode());
let src = dir1.join(&rel);
let dst = dir2.join(&rel);
std::fs::create_dir_all(dst.parent().unwrap())?;
std::fs::copy(&src, &dst)?;
let db2 = create_test_db(&db2_dir).await;
let store2 = SecretsStore::new(dir2.clone(), Default::default(), db2)?;
let recovered = store2
.get_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &alice,
dek_secret: &alice_dek,
},
)?
.to_vec();
assert_eq!(
recovered,
b"portable-payload".to_vec(),
"User secret must be portable: decryptable on another node with the same dek_secret"
);
Ok(())
}
#[test]
fn token_helpers_are_domain_separated_and_deterministic() {
for token in [&b""[..], b"t", b"a-much-longer-bearer-token-value"] {
let id = user_id(token);
let dek = user_dek_secret(token);
assert_ne!(
id.as_bytes(),
&*dek,
"user_id and user_dek_secret must differ for token {token:?}"
);
assert_eq!(
id.as_bytes(),
user_id(token).as_bytes(),
"user_id must be deterministic"
);
assert_eq!(
&*dek,
&*user_dek_secret(token),
"user_dek_secret must be deterministic"
);
}
assert_ne!(
user_id(b"alice").as_bytes(),
user_id(b"bob").as_bytes(),
"distinct tokens should map to distinct user ids"
);
}
#[cfg(unix)]
#[tokio::test]
async fn user_secret_files_are_owner_only_on_unix() -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::PermissionsExt;
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![170].into(), &vec![].into()));
let secret_id = SecretsId::new(vec![172]);
let alice = UserId::new([0x90; 32]);
let alice_dek = user_dek(0x90);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &alice,
dek_secret: &alice_dek,
},
Zeroizing::new(b"v1".to_vec()),
)?;
let delegate_dir = secrets_dir.join(delegate.key().encode());
let users_dir = delegate_dir.join("users");
let user_dir = users_dir.join(alice.encode());
let secret_file = user_dir.join(secret_id.encode());
let mode = |p: &std::path::Path| -> std::io::Result<u32> {
Ok(std::fs::metadata(p)?.permissions().mode() & 0o777)
};
assert_eq!(
mode(&delegate_dir)?,
0o700,
"<delegate> dir must be 0o700, got {:o}",
mode(&delegate_dir)?
);
assert_eq!(
mode(&users_dir)?,
0o700,
"<delegate>/users dir must be 0o700, got {:o}",
mode(&users_dir)?
);
assert_eq!(
mode(&user_dir)?,
0o700,
"users/<id> dir must be 0o700, got {:o}",
mode(&user_dir)?
);
assert_eq!(
mode(&secret_file)?,
0o600,
"user secret file must be 0o600, got {:o}",
mode(&secret_file)?
);
Ok(())
}
#[tokio::test]
async fn user_scope_second_write_creates_listable_snapshot()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir, Default::default(), db)?;
let delegate = Delegate::from((&vec![180].into(), &vec![].into()));
let secret_id = SecretsId::new(vec![181]);
let alice = UserId::new([0xA5; 32]);
let alice_dek = user_dek(0xA5);
let user_scope = || SecretScope::User {
id: &alice,
dek_secret: &alice_dek,
};
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"local-untouched".to_vec()),
)?;
store.store_secret(
delegate.key(),
&secret_id,
user_scope(),
Zeroizing::new(b"user-v1".to_vec()),
)?;
assert!(
store
.list_snapshots(delegate.key(), &secret_id, user_scope())?
.is_empty(),
"first User write must not create a snapshot"
);
std::thread::sleep(Duration::from_millis(5));
store.store_secret(
delegate.key(),
&secret_id,
user_scope(),
Zeroizing::new(b"user-v2".to_vec()),
)?;
let snaps = store.list_snapshots(delegate.key(), &secret_id, user_scope())?;
assert_eq!(
snaps.len(),
1,
"second User write must snapshot the prior version"
);
assert_eq!(
store
.get_secret(delegate.key(), &secret_id, user_scope())?
.to_vec(),
b"user-v2".to_vec()
);
store.restore_snapshot(
delegate.key(),
&secret_id,
user_scope(),
snaps[0].timestamp_ms,
)?;
assert_eq!(
store
.get_secret(delegate.key(), &secret_id, user_scope())?
.to_vec(),
b"user-v1".to_vec(),
"restore must put the User v1 plaintext back"
);
assert_eq!(
store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)?
.to_vec(),
b"local-untouched".to_vec(),
"User-scope writes/restore must not perturb the Local secret"
);
assert!(
store
.list_snapshots(delegate.key(), &secret_id, SecretScope::Local)?
.is_empty(),
"Local secret written once must have no snapshot history"
);
Ok(())
}
#[tokio::test]
async fn same_dek_secret_distinct_users_isolated_only_by_path()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![190].into(), &vec![].into()));
let secret_id = SecretsId::new(vec![191]);
let alice = UserId::new([0x01; 32]);
let bob = UserId::new([0x02; 32]);
let shared_dek = user_dek(0xDE);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &alice,
dek_secret: &shared_dek,
},
Zeroizing::new(b"alice-value".to_vec()),
)?;
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &bob,
dek_secret: &shared_dek,
},
Zeroizing::new(b"bob-value".to_vec()),
)?;
let alice_file = secrets_dir
.join(delegate.key().encode())
.join("users")
.join(alice.encode())
.join(secret_id.encode());
let bob_file = secrets_dir
.join(delegate.key().encode())
.join("users")
.join(bob.encode())
.join(secret_id.encode());
assert_ne!(
alice_file, bob_file,
"distinct users must occupy distinct on-disk paths"
);
assert!(alice_file.exists() && bob_file.exists());
assert_eq!(
store
.get_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &alice,
dek_secret: &shared_dek
}
)?
.to_vec(),
b"alice-value".to_vec()
);
assert_eq!(
store
.get_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &bob,
dek_secret: &shared_dek
}
)?
.to_vec(),
b"bob-value".to_vec()
);
Ok(())
}
#[test]
fn secrets_id_encode_never_collides_with_users_segment() {
for seed in [0u8, 1, 42, 0xAA, 0xFF] {
let id = SecretsId::new(vec![seed; 4]);
assert_ne!(
id.encode(),
"users",
"a SecretsId must never encode to the reserved `users/` path segment"
);
}
}
#[tokio::test]
async fn import_skip_branch_repairs_missing_index_entry()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![70].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![71]);
let secret_hash = *secret_id.hash();
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"value".to_vec()),
)?;
store.db.store_secrets_index(delegate.key(), &[])?;
store.key_to_secret_part.remove(delegate.key());
let file_path = secrets_dir
.join(delegate.key().encode())
.join(secret_id.encode());
assert!(file_path.exists(), "secret file should still be on disk");
assert!(
store.export_scope_entries(SecretScope::Local)?.is_empty(),
"pre-condition: unindexed secret must be invisible to enumeration"
);
let wrote = store.import_secret_by_hash(
delegate.key(),
&secret_hash,
SecretScope::Local,
Zeroizing::new(b"value".to_vec()),
false,
)?;
assert!(
!wrote,
"existing file must not be rewritten without --overwrite"
);
assert!(
store
.db
.get_secrets_index(delegate.key())?
.unwrap_or_default()
.contains(&secret_hash),
"ReDb index must be repaired"
);
assert!(
store
.key_to_secret_part
.get(delegate.key())
.map(|e| e.value().contains(&secret_hash))
.unwrap_or(false),
"in-memory index must be repaired"
);
let entries = store.export_scope_entries(SecretScope::Local)?;
assert_eq!(entries.len(), 1, "secret must be enumerable after repair");
assert_eq!(entries[0].secret_hash, secret_hash);
assert_eq!(entries[0].plaintext.to_vec(), b"value");
Ok(())
}
#[tokio::test]
async fn import_skip_branch_repairs_missing_user_index_entry()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("secrets-store-test");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
let delegate = Delegate::from((&vec![72].into(), &vec![].into()));
let ctx = UserSecretContext::from_token(b"converge-user");
let secret_id = SecretsId::new(vec![73]);
let secret_hash = *secret_id.hash();
store.store_secret(
delegate.key(),
&secret_id,
ctx.scope(),
Zeroizing::new(b"uval".to_vec()),
)?;
store
.db
.store_user_secrets_index(delegate.key(), ctx.user_id().as_bytes(), &[])?;
store
.user_key_to_secret_part
.remove(&(delegate.key().clone(), *ctx.user_id()));
assert!(
store.export_scope_entries(ctx.scope())?.is_empty(),
"pre-condition: unindexed user secret invisible"
);
let wrote = store.import_secret_by_hash(
delegate.key(),
&secret_hash,
ctx.scope(),
Zeroizing::new(b"uval".to_vec()),
false,
)?;
assert!(!wrote);
let entries = store.export_scope_entries(ctx.scope())?;
assert_eq!(
entries.len(),
1,
"user secret must be enumerable after repair"
);
assert_eq!(entries[0].secret_hash, secret_hash);
Ok(())
}
}