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, SNAPSHOTS_DIR, 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;
const TAG_LEN: usize = 16;
type SecretKey = [u8; 32];
const KEY_REGISTRY_FILE: &str = ".keys";
const USERS_DIR: &str = "users";
const LAST_SEEN_FILE: &str = ".last_seen";
pub const DEFAULT_LAST_SEEN_DEBOUNCE_SECS: u64 = 3_600;
const MAX_REGISTERED_KEYS_PER_SCOPE: usize = 4096;
#[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())
}
pub const DEFAULT_PER_USER_SECRET_QUOTA_BYTES: usize = 4 * 1024 * 1024;
pub const DEFAULT_PER_USER_INACTIVE_TTL_SECS: u64 = 2_592_000;
pub const MAX_RECLAIMS_PER_SWEEP: usize = 256;
pub const SWEEP_MAX_GAP_INTERVAL_MULTIPLE: u64 = 8;
struct QuotaTracker {
per_user_bytes: DashMap<(PathBuf, UserId), std::sync::atomic::AtomicU64>,
}
impl QuotaTracker {
fn new() -> Self {
Self {
per_user_bytes: DashMap::new(),
}
}
fn get(&self, base_path: &Path, user: &UserId) -> Option<u64> {
self.per_user_bytes
.get(&(base_path.to_path_buf(), *user))
.map(|e| e.value().load(std::sync::atomic::Ordering::Relaxed))
}
fn seed_if_absent(&self, base_path: &Path, user: UserId, bytes: u64) {
self.per_user_bytes
.entry((base_path.to_path_buf(), user))
.or_insert_with(|| std::sync::atomic::AtomicU64::new(bytes));
}
fn add(&self, base_path: &Path, user: &UserId, delta: u64) {
if let Some(e) = self.per_user_bytes.get(&(base_path.to_path_buf(), *user)) {
e.value()
.fetch_add(delta, std::sync::atomic::Ordering::Relaxed);
}
}
fn sub_saturating(&self, base_path: &Path, user: &UserId, delta: u64) {
if let Some(e) = self.per_user_bytes.get(&(base_path.to_path_buf(), *user)) {
let atomic = e.value();
let cur = atomic.load(std::sync::atomic::Ordering::Relaxed);
atomic.store(
cur.saturating_sub(delta),
std::sync::atomic::Ordering::Relaxed,
);
}
}
}
static USER_QUOTA_TRACKER: std::sync::LazyLock<QuotaTracker> =
std::sync::LazyLock::new(QuotaTracker::new);
fn apply_signed_delta(base_path: &Path, user: &UserId, delta: i128) {
use std::cmp::Ordering;
match delta.cmp(&0) {
Ordering::Greater => USER_QUOTA_TRACKER.add(base_path, user, delta as u64),
Ordering::Less => USER_QUOTA_TRACKER.sub_saturating(base_path, user, (-delta) as u64),
Ordering::Equal => {}
}
}
struct QuotaCommit {
user_id: UserId,
old_blob_size: u64,
new_blob_size: u64,
old_keys_size: u64,
}
#[cfg(test)]
pub(crate) fn quota_tracked_total_for_test(base_path: &Path, user: &UserId) -> Option<u64> {
USER_QUOTA_TRACKER.get(base_path, user)
}
#[cfg(test)]
pub(crate) fn quota_reset_user_for_test(base_path: &Path, user: &UserId) {
USER_QUOTA_TRACKER
.per_user_bytes
.remove(&(base_path.to_path_buf(), *user));
}
#[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 },
#[error(
"per-user secret quota exceeded: used {used} bytes, limit {limit} bytes, attempted {attempted} more bytes"
)]
QuotaExceeded {
used: u64,
limit: u64,
attempted: 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,
max_registered_keys_per_scope: usize,
user_quota_limit_bytes: u64,
}
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 decode_bs58_32(name: &str) -> Option<[u8; 32]> {
let mut out = [0u8; 32];
match bs58::decode(name)
.with_alphabet(bs58::Alphabet::BITCOIN)
.onto(&mut out)
{
Ok(32) => Some(out),
_ => None,
}
}
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(())
}
fn user_activity_dir(base_path: &Path, user_id: &UserId) -> PathBuf {
base_path.join(USERS_DIR).join(user_id.encode())
}
pub fn wall_clock_unix_secs() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn read_user_last_seen(base_path: &Path, user_id: &UserId) -> Option<u64> {
let path = user_activity_dir(base_path, user_id).join(LAST_SEEN_FILE);
let raw = fs::read_to_string(&path).ok()?;
raw.trim().parse::<u64>().ok()
}
pub fn stamp_user_last_seen(
base_path: &Path,
user_id: &UserId,
now: u64,
debounce_secs: u64,
) -> bool {
if let Some(prev) = read_user_last_seen(base_path, user_id) {
if now.saturating_sub(prev) < debounce_secs {
return false;
}
}
let dir = user_activity_dir(base_path, user_id);
if let Err(e) = fs::create_dir_all(&dir) {
tracing::debug!(user_id = %user_id.encode(), error = %e, "last_seen: mkdir failed");
return false;
}
ensure_owner_only_tree(base_path, &dir).ok();
let tmp = dir.join(format!("{LAST_SEEN_FILE}.tmp"));
let final_path = dir.join(LAST_SEEN_FILE);
let write_then_rename = || -> std::io::Result<()> {
fs::write(&tmp, now.to_string())?;
fs::rename(&tmp, &final_path)
};
match write_then_rename() {
Ok(()) => true,
Err(e) => {
tracing::debug!(user_id = %user_id.encode(), error = %e, "last_seen: write failed");
fs::remove_file(&tmp).ok();
false
}
}
}
fn enumerate_marked_users(base_path: &Path) -> Vec<UserId> {
let users_root = base_path.join(USERS_DIR);
let rd = match fs::read_dir(&users_root) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(e) => {
tracing::warn!(error = %e, dir = %users_root.display(), "inactive-user sweep: cannot read users dir");
return Vec::new();
}
};
let mut out = Vec::new();
for entry in rd.flatten() {
if !entry.path().is_dir() {
continue;
}
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
if let Some(bytes) = decode_bs58_32(&name) {
out.push(UserId::new(bytes));
}
}
out
}
fn enumerate_delegate_keys(base_path: &Path) -> Vec<[u8; 32]> {
let rd = match fs::read_dir(base_path) {
Ok(rd) => rd,
Err(_) => return Vec::new(),
};
let mut out = Vec::new();
for entry in rd.flatten() {
if !entry.path().is_dir() {
continue;
}
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
if name == USERS_DIR {
continue;
}
if let Some(bytes) = decode_bs58_32(&name) {
out.push(bytes);
}
}
out
}
#[cfg(test)]
pub fn reclaim_user(base_path: &Path, db: &Storage, user_id: &UserId) {
let delegate_keys = group_index_rows_by_user(db)
.and_then(|mut m| m.remove(user_id))
.unwrap_or_default();
reclaim_user_with_index_rows(base_path, db, user_id, &delegate_keys);
}
fn reclaim_user_with_index_rows(
base_path: &Path,
db: &Storage,
user_id: &UserId,
index_delegate_keys: &[DelegateKey],
) {
for key_bytes in enumerate_delegate_keys(base_path) {
let user_dir = base_path
.join(DelegateKey::new(key_bytes, CodeHash::from(&[0u8; 32])).encode())
.join(USERS_DIR)
.join(user_id.encode());
match fs::remove_dir_all(&user_dir) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
tracing::warn!(
user_id = %user_id.encode(),
dir = %user_dir.display(),
error = %e,
"inactive-user reclaim: failed to remove user secret dir (will retry next sweep)"
);
}
}
}
remove_user_index_rows(db, user_id, index_delegate_keys);
let marker_dir = user_activity_dir(base_path, user_id);
match fs::remove_dir_all(&marker_dir) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
tracing::warn!(
user_id = %user_id.encode(),
dir = %marker_dir.display(),
error = %e,
"inactive-user reclaim: failed to remove activity-marker dir (will retry next sweep)"
);
}
}
USER_QUOTA_TRACKER
.per_user_bytes
.remove(&(base_path.to_path_buf(), *user_id));
}
fn remove_user_index_rows(db: &Storage, user_id: &UserId, delegate_keys: &[DelegateKey]) {
for delegate_key in delegate_keys {
if let Err(e) = db.remove_user_secrets_index(delegate_key, user_id.as_bytes()) {
tracing::warn!(
user_id = %user_id.encode(),
error = %e,
"inactive-user reclaim: failed to remove ReDb index row (will retry next sweep)"
);
}
}
}
fn group_index_rows_by_user(
db: &Storage,
) -> Option<std::collections::HashMap<UserId, Vec<DelegateKey>>> {
let rows = match db.load_all_user_secrets_index() {
Ok(rows) => rows,
Err(e) => {
tracing::warn!(
error = %e,
"inactive-user sweep: cannot read user-secrets index (will retry next sweep)"
);
return None;
}
};
let mut by_user: std::collections::HashMap<UserId, Vec<DelegateKey>> =
std::collections::HashMap::new();
for ((delegate_key, uid_bytes), _secrets) in rows {
by_user
.entry(UserId::new(uid_bytes))
.or_default()
.push(delegate_key);
}
Some(by_user)
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct SweepOutcome {
pub reclaimed: usize,
pub capped: bool,
pub skipped_gap: bool,
}
pub fn reclaim_inactive_users(
base_path: &Path,
db: &Storage,
now: u64,
ttl: u64,
prev_sweep_now: Option<u64>,
max_gap_secs: u64,
max_reclaims: usize,
) -> SweepOutcome {
if ttl == 0 {
return SweepOutcome::default();
}
if let Some(prev) = prev_sweep_now {
if max_gap_secs > 0 && now.saturating_sub(prev) > max_gap_secs {
tracing::warn!(
now,
prev_sweep_now = prev,
gap_secs = now.saturating_sub(prev),
max_gap_secs,
"inactive-user sweep: time advanced implausibly far since the last \
sweep (suspected forward clock jump or long suspend) — SKIPPING \
reclaim this pass to avoid a clock-fault mass-delete; will \
re-evaluate next pass"
);
return SweepOutcome {
reclaimed: 0,
capped: false,
skipped_gap: true,
};
}
}
let mut reclaimed = 0usize;
let mut capped = false;
let Some(mut index_by_user) = group_index_rows_by_user(db) else {
return SweepOutcome::default();
};
for user_id in enumerate_marked_users(base_path) {
if max_reclaims > 0 && reclaimed >= max_reclaims {
capped = true;
break;
}
let Some(last_seen) = read_user_last_seen(base_path, &user_id) else {
continue;
};
if now.saturating_sub(last_seen) > ttl {
tracing::info!(
user_id = %user_id.encode(),
idle_secs = now.saturating_sub(last_seen),
ttl_secs = ttl,
"inactive-user sweep: reclaiming abandoned hosted user"
);
let delegate_keys = index_by_user.remove(&user_id).unwrap_or_default();
reclaim_user_with_index_rows(base_path, db, &user_id, &delegate_keys);
reclaimed += 1;
}
}
if capped {
tracing::warn!(
reclaimed,
cap = max_reclaims,
"inactive-user sweep hit reclaim cap {max_reclaims} — possible clock \
fault or large backlog; remaining users deferred to next sweep"
);
}
SweepOutcome {
reclaimed,
capped,
skipped_gap: false,
}
}
pub fn should_spawn_inactive_user_sweep(hosted_mode: bool, ttl_secs: u64) -> bool {
hosted_mode && ttl_secs > 0
}
#[must_use = "the returned JoinHandle must be retained so the sweep is aborted on shutdown"]
pub fn spawn_inactive_user_sweep(
base_path: PathBuf,
db: Storage,
ttl_secs: u64,
sweep_interval_secs: u64,
) -> Option<tokio::task::JoinHandle<()>> {
if ttl_secs == 0 {
return None;
}
let interval = std::time::Duration::from_secs(sweep_interval_secs.max(1));
let max_gap_secs = interval
.as_secs()
.saturating_mul(SWEEP_MAX_GAP_INTERVAL_MULTIPLE);
let handle = crate::config::GlobalExecutor::spawn(async move {
tracing::info!(
ttl_secs,
sweep_interval_secs = interval.as_secs(),
max_gap_secs,
max_reclaims_per_sweep = MAX_RECLAIMS_PER_SWEEP,
"Inactive-user reclaim sweep started (hosted mode)"
);
let mut ticker = tokio::time::interval(interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ticker.tick().await;
let mut prev_sweep_now: Option<u64> = None;
loop {
ticker.tick().await;
let now = wall_clock_unix_secs();
let base = base_path.clone();
let storage = db.clone();
let result = tokio::task::spawn_blocking(move || {
reclaim_inactive_users(
&base,
&storage,
now,
ttl_secs,
prev_sweep_now,
max_gap_secs,
MAX_RECLAIMS_PER_SWEEP,
)
})
.await;
let ran = matches!(&result, Ok(o) if !o.skipped_gap);
if ran {
prev_sweep_now = Some(now);
}
match result {
Ok(o) => {
if o.reclaimed > 0 {
tracing::info!(
reclaimed = o.reclaimed,
"inactive-user sweep: reclaimed abandoned users"
);
}
}
Err(e) => {
tracing::warn!(error = %e, "inactive-user sweep pass panicked/cancelled");
}
}
}
});
Some(handle)
}
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(),
max_registered_keys_per_scope: MAX_REGISTERED_KEYS_PER_SCOPE,
user_quota_limit_bytes: 0,
})
}
pub fn with_user_quota(mut self, limit_bytes: u64) -> Self {
self.user_quota_limit_bytes = limit_bytes;
self
}
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;
}
#[cfg(test)]
pub(crate) fn set_max_registered_keys_per_scope(&mut self, cap: usize) {
self.max_registered_keys_per_scope = cap;
}
#[cfg(test)]
pub(crate) fn user_quota_limit_bytes(&self) -> u64 {
self.user_quota_limit_bytes
}
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);
let mut quota_commit: Option<QuotaCommit> = None;
if let SecretScope::User { id, .. } = &scope
&& self.user_quota_limit_bytes > 0
{
let limit = self.user_quota_limit_bytes;
let new_blob_size = ciphertext.len() as u64;
let old_blob_size = self.on_disk_blob_size(delegate, key, &scope);
let old_keys_size = self.keys_registry_size(delegate, &scope);
let projected_keys_size = self.projected_keys_size_after_add(delegate, &scope, key);
let current_total = match USER_QUOTA_TRACKER.get(&self.base_path, id) {
Some(t) => t,
None => {
let seeded = self.seeded_user_total(&scope)?;
USER_QUOTA_TRACKER.seed_if_absent(&self.base_path, **id, seeded);
USER_QUOTA_TRACKER
.get(&self.base_path, id)
.unwrap_or(seeded)
}
};
let projected = current_total
.saturating_sub(old_blob_size)
.saturating_add(new_blob_size)
.saturating_sub(old_keys_size)
.saturating_add(projected_keys_size);
if projected > limit {
let attempted = new_blob_size
.saturating_sub(old_blob_size)
.saturating_add(projected_keys_size.saturating_sub(old_keys_size));
tracing::warn!(
user_id = %id.encode(),
used = current_total,
limit,
attempted,
"rejecting per-user secret write: would exceed quota (total footprint)"
);
return Err(SecretStoreError::QuotaExceeded {
used: current_total,
limit,
attempted,
}
.into());
}
quota_commit = Some(QuotaCommit {
user_id: **id,
old_blob_size,
new_blob_size,
old_keys_size,
});
}
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");
}
let take_snapshots = self.snapshots_enabled && !matches!(scope, SecretScope::User { .. });
if take_snapshots
&& 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());
}
if let Some(commit) = "a_commit {
apply_signed_delta(
&self.base_path,
&commit.user_id,
commit.new_blob_size as i128 - commit.old_blob_size as i128,
);
}
self.add_to_index(delegate, &scope, secret_key)?;
self.register_key(delegate, &scope, &encryption, key);
if let Some(commit) = "a_commit {
let new_keys_size = self.keys_registry_size(delegate, &scope);
apply_signed_delta(
&self.base_path,
&commit.user_id,
new_keys_size as i128 - commit.old_keys_size as i128,
);
}
if take_snapshots {
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);
let removed_blob_size = match &scope {
SecretScope::User { .. } => self.on_disk_blob_size(delegate, key, &scope),
SecretScope::Local => 0,
};
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(()) => {
if let SecretScope::User { id, .. } = &scope
&& removed_blob_size > 0
{
USER_QUOTA_TRACKER.sub_saturating(&self.base_path, id, removed_blob_size);
}
}
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);
}
}
let keys_size_before = match &scope {
SecretScope::User { .. } => self.keys_registry_size(delegate, &scope),
SecretScope::Local => 0,
};
self.deregister_key(delegate, &scope, key);
if let SecretScope::User { id, .. } = &scope {
let keys_size_after = self.keys_registry_size(delegate, &scope);
if keys_size_before > keys_size_after {
USER_QUOTA_TRACKER.sub_saturating(
&self.base_path,
id,
keys_size_before - keys_size_after,
);
}
}
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())
}
}
}
fn encryption_for_scope_read(
&self,
delegate: &DelegateKey,
scope: &SecretScope<'_>,
) -> Encryption {
match scope {
SecretScope::Local => self.cipher_for_read(delegate),
SecretScope::User { dek_secret, .. } => self.derive_user_dek(delegate, dek_secret),
}
}
fn key_registry_path(&self, delegate: &DelegateKey, scope: &SecretScope<'_>) -> PathBuf {
self.scope_dir(delegate, scope).join(KEY_REGISTRY_FILE)
}
fn read_key_registry(
&self,
delegate: &DelegateKey,
scope: &SecretScope<'_>,
) -> std::io::Result<Vec<Vec<u8>>> {
let path = self.key_registry_path(delegate, scope);
let blob = match fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"key registry UNREADABLE (transient IO error); preserving on-disk registry \
and refusing to overwrite it from empty (secret VALUES unaffected)"
);
return Err(e);
}
};
if blob.len() < HEADER_LEN || blob.first().copied() != Some(VERSION_V1) {
tracing::warn!(
path = %path.display(),
"key registry blob MALFORMED; preserving it on disk and refusing to overwrite \
from empty so a recoverable/legacy blob is not destroyed (enumeration returns \
empty for this scope until the blob is readable again; secret VALUES unaffected)"
);
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"key registry blob malformed (bad header)",
));
}
let encryption = self.encryption_for_scope_read(delegate, scope);
let nonce = XNonce::from_slice(&blob[1..HEADER_LEN]);
match encryption.cipher.decrypt(nonce, &blob[HEADER_LEN..]) {
Ok(plaintext) => Ok(decode_secret_key_list(&plaintext)),
Err(_) => {
tracing::warn!(
path = %path.display(),
"key registry decrypt FAILED; preserving the blob on disk and refusing to \
overwrite from empty (enumeration returns empty for this scope until the \
blob decrypts again; secret VALUES are unaffected)"
);
Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"key registry decrypt failed",
))
}
}
}
fn write_key_registry(
&self,
delegate: &DelegateKey,
scope: &SecretScope<'_>,
encryption: &Encryption,
keys: &[Vec<u8>],
) {
let scope_path = self.scope_dir(delegate, scope);
let path = scope_path.join(KEY_REGISTRY_FILE);
if keys.is_empty() {
match fs::remove_file(&path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "failed to clear key registry")
}
}
return;
}
let plaintext = encode_secret_key_list(keys.iter().map(|k| k.as_slice()));
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let aead = match encryption.cipher.encrypt(&nonce, plaintext.as_slice()) {
Ok(a) => a,
Err(e) => {
tracing::warn!(error = %e, "failed to encrypt key registry");
return;
}
};
let mut blob = Vec::with_capacity(HEADER_LEN + aead.len());
blob.push(VERSION_V1);
blob.extend_from_slice(nonce.as_slice());
blob.extend_from_slice(&aead);
if let Err(e) = fs::create_dir_all(&scope_path) {
tracing::warn!(path = %scope_path.display(), error = %e, "failed to create scope dir for key registry");
return;
}
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 (key registry)");
}
let tmp_path = {
let mut name = path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_else(|| std::ffi::OsString::from(KEY_REGISTRY_FILE));
name.push(".tmp");
scope_path.join(name)
};
let write_res = (|| -> std::io::Result<()> {
let mut file = create_owner_only(&tmp_path)?;
file.write_all(&blob)?;
file.sync_all()?;
fs::rename(&tmp_path, &path)
})();
if let Err(e) = write_res {
tracing::warn!(path = %path.display(), error = %e, "failed to persist key registry");
if let Err(rm_err) = fs::remove_file(&tmp_path) {
tracing::debug!(path = %tmp_path.display(), error = %rm_err, "tmp registry cleanup failed (harmless)");
}
}
}
fn register_key(
&self,
delegate: &DelegateKey,
scope: &SecretScope<'_>,
encryption: &Encryption,
key: &SecretsId,
) {
let mut keys = match self.read_key_registry(delegate, scope) {
Ok(keys) => keys,
Err(e) => {
tracing::warn!(
delegate = %delegate.encode(),
error = %e,
"skipping key-registry update on unreadable registry; on-disk registry left \
intact, this key is temporarily not enumerable (secret VALUE was stored)"
);
return;
}
};
if keys.iter().any(|k| k.as_slice() == key.key()) {
return;
}
if keys.len() >= self.max_registered_keys_per_scope {
tracing::warn!(
delegate = %delegate.encode(),
cap = self.max_registered_keys_per_scope,
"key enumeration registry at capacity; new key not enumerable (value still stored)"
);
return;
}
keys.push(key.key().to_vec());
self.write_key_registry(delegate, scope, encryption, &keys);
}
fn deregister_key(&self, delegate: &DelegateKey, scope: &SecretScope<'_>, key: &SecretsId) {
let mut keys = match self.read_key_registry(delegate, scope) {
Ok(keys) => keys,
Err(e) => {
tracing::warn!(
delegate = %delegate.encode(),
error = %e,
"skipping key-registry deregister on unreadable registry; on-disk registry \
left intact (a stale entry for the removed key is harmless)"
);
return;
}
};
let before = keys.len();
keys.retain(|k| k.as_slice() != key.key());
if keys.len() == before {
return;
}
let encryption = self.encryption_for_scope_read(delegate, scope);
self.write_key_registry(delegate, scope, &encryption, &keys);
}
pub fn list_secret_keys(
&self,
delegate: &DelegateKey,
scope: SecretScope<'_>,
prefix: &[u8],
) -> Vec<Vec<u8>> {
let mut keys = self.read_key_registry(delegate, &scope).unwrap_or_default();
if !prefix.is_empty() {
keys.retain(|k| k.starts_with(prefix));
}
keys
}
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 index_code_hashes_by_key(
&self,
scope: &SecretScope<'_>,
) -> std::collections::HashMap<[u8; 32], CodeHash> {
let mut out = std::collections::HashMap::new();
match scope {
SecretScope::Local => {
for entry in self.key_to_secret_part.iter() {
let dk = entry.key();
out.insert(**dk, *dk.code_hash());
}
}
SecretScope::User { id, .. } => {
for entry in self.user_key_to_secret_part.iter() {
let (dk, user) = entry.key();
if user == *id {
out.insert(**dk, *dk.code_hash());
}
}
}
}
out
}
fn count_scope_secrets_on_disk(
&self,
scope: &SecretScope<'_>,
stop_at: usize,
) -> Result<usize, SecretStoreError> {
let mut count = 0usize;
self.walk_scope_secrets_on_disk(scope, |_delegate, _secret_hash| {
count += 1;
if count >= stop_at {
std::ops::ControlFlow::Break(())
} else {
std::ops::ControlFlow::Continue(())
}
})?;
Ok(count)
}
fn walk_scope_secrets_on_disk(
&self,
scope: &SecretScope<'_>,
mut visit: impl FnMut(DelegateKey, SecretKey) -> std::ops::ControlFlow<()>,
) -> Result<(), SecretStoreError> {
use std::ops::ControlFlow;
let delegate_dirs = match fs::read_dir(&self.base_path) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(SecretStoreError::IO(e)),
};
let code_hashes = self.index_code_hashes_by_key(scope);
for delegate_entry in delegate_dirs {
let delegate_entry = delegate_entry.map_err(SecretStoreError::IO)?;
let delegate_path = delegate_entry.path();
if !delegate_path.is_dir() {
continue;
}
let Some(delegate_name) = delegate_entry.file_name().to_str().map(str::to_owned) else {
continue;
};
let Some(delegate_key_bytes) = decode_bs58_32(&delegate_name) else {
continue; };
let code_hash = code_hashes
.get(&delegate_key_bytes)
.copied()
.unwrap_or_else(|| CodeHash::from(&[0u8; 32]));
let delegate = DelegateKey::new(delegate_key_bytes, code_hash);
let scope_path = match scope {
SecretScope::Local => delegate_path.clone(),
SecretScope::User { id, .. } => delegate_path.join("users").join(id.encode()),
};
let files = match fs::read_dir(&scope_path) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(SecretStoreError::IO(e)),
};
for file_entry in files {
let file_entry = file_entry.map_err(SecretStoreError::IO)?;
let name = file_entry.file_name();
let Some(name) = name.to_str() else { continue };
if name == KEY_REGISTRY_FILE || name == SNAPSHOTS_DIR || name.ends_with(".tmp") {
continue;
}
let Some(secret_hash) = decode_bs58_32(name) else {
continue;
};
match file_entry.file_type() {
Ok(ft) if ft.is_file() => {}
_ => continue,
}
if let ControlFlow::Break(()) = visit(delegate.clone(), secret_hash) {
return Ok(());
}
}
}
Ok(())
}
fn seeded_user_total(&self, scope: &SecretScope<'_>) -> Result<u64, SecretStoreError> {
let mut total: u64 = 0;
let mut walk_err: Option<SecretStoreError> = None;
let mut keys_counted: HashSet<DelegateKey> = HashSet::new();
self.walk_scope_secrets_on_disk(scope, |delegate, secret_hash| {
if keys_counted.insert(delegate.clone()) {
total = total.saturating_add(self.keys_registry_size(&delegate, scope));
}
let encoded = bs58::encode(&secret_hash)
.with_alphabet(bs58::Alphabet::BITCOIN)
.into_string();
let path = self.scope_dir(&delegate, scope).join(&encoded);
match fs::metadata(&path) {
Ok(md) => {
total = total.saturating_add(md.len());
std::ops::ControlFlow::Continue(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
std::ops::ControlFlow::Continue(())
}
Err(e) => {
walk_err = Some(SecretStoreError::IO(e));
std::ops::ControlFlow::Break(())
}
}
})?;
if let Some(e) = walk_err {
return Err(e);
}
if let SecretScope::User { .. } = scope {
total = total.saturating_add(self.orphan_user_keys_size(scope, &keys_counted)?);
}
Ok(total)
}
fn orphan_user_keys_size(
&self,
scope: &SecretScope<'_>,
already_counted: &HashSet<DelegateKey>,
) -> Result<u64, SecretStoreError> {
let SecretScope::User { id, .. } = scope else {
return Ok(0);
};
let mut total: u64 = 0;
let delegate_dirs = match fs::read_dir(&self.base_path) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(e) => return Err(SecretStoreError::IO(e)),
};
for entry in delegate_dirs {
let entry = entry.map_err(SecretStoreError::IO)?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
let Some(key_bytes) = decode_bs58_32(&name) else {
continue; };
let delegate = DelegateKey::new(key_bytes, CodeHash::from(&[0u8; 32]));
if already_counted
.iter()
.any(|d| d.encode() == delegate.encode())
{
continue;
}
let keys_path = path.join("users").join(id.encode()).join(KEY_REGISTRY_FILE);
match fs::metadata(&keys_path) {
Ok(md) => total = total.saturating_add(md.len()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(SecretStoreError::IO(e)),
}
}
Ok(total)
}
fn keys_registry_size(&self, delegate: &DelegateKey, scope: &SecretScope<'_>) -> u64 {
match fs::metadata(self.key_registry_path(delegate, scope)) {
Ok(md) => md.len(),
Err(_) => 0,
}
}
fn projected_keys_size_after_add(
&self,
delegate: &DelegateKey,
scope: &SecretScope<'_>,
key: &SecretsId,
) -> u64 {
let current = self.keys_registry_size(delegate, scope);
let existing = match self.read_key_registry(delegate, scope) {
Ok(keys) => keys,
Err(_) => return current,
};
if existing.iter().any(|k| k.as_slice() == key.key()) {
return current; }
if existing.len() >= self.max_registered_keys_per_scope {
return current; }
let entry_len = 4u64.saturating_add(key.key().len() as u64);
if current == 0 {
(HEADER_LEN as u64)
.saturating_add(entry_len)
.saturating_add(TAG_LEN as u64)
} else {
current.saturating_add(entry_len)
}
}
fn on_disk_blob_size(
&self,
delegate: &DelegateKey,
key: &SecretsId,
scope: &SecretScope<'_>,
) -> u64 {
let path = self.scope_dir(delegate, scope).join(key.encode());
match fs::metadata(&path) {
Ok(md) => md.len(),
Err(_) => 0,
}
}
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 scope_count_exceeds(
&self,
scope: &SecretScope<'_>,
max_count: usize,
) -> Result<bool, SecretStoreError> {
let stop_at = max_count.saturating_add(1);
Ok(self.count_scope_secrets_on_disk(scope, stop_at)? > max_count)
}
pub fn scope_entry_count(&self, scope: &SecretScope<'_>) -> Result<usize, SecretStoreError> {
self.count_scope_secrets_on_disk(scope, usize::MAX)
}
pub fn export_scope_entries(
&self,
scope: SecretScope<'_>,
) -> Result<Vec<ExportSecretEntry>, SecretStoreError> {
match self.export_scope_entries_bounded(scope, usize::MAX, usize::MAX) {
Ok(entries) => Ok(entries),
Err(ExportScopeError::Store(e)) => Err(e),
Err(ExportScopeError::TooLarge { .. } | ExportScopeError::CountTooLarge { .. }) => {
unreachable!("count/byte caps are usize::MAX")
}
}
}
pub fn export_scope_entries_bounded(
&self,
scope: SecretScope<'_>,
max_count: usize,
max_total_bytes: usize,
) -> Result<Vec<ExportSecretEntry>, ExportScopeError> {
use std::ops::ControlFlow;
let mut entries: Vec<ExportSecretEntry> = Vec::new();
let mut total_bytes: usize = 0;
let mut count: usize = 0;
let mut bail: Option<ExportScopeError> = None;
self.walk_scope_secrets_on_disk(&scope, |delegate, secret_hash| {
count += 1;
if count > max_count {
bail = Some(ExportScopeError::CountTooLarge {
actual: count,
limit: max_count,
});
return ControlFlow::Break(());
}
let plaintext = match self.read_secret_by_hash(&delegate, &secret_hash, &scope) {
Ok(p) => p,
Err(e) => {
bail = Some(ExportScopeError::Store(e));
return ControlFlow::Break(());
}
};
total_bytes = total_bytes.saturating_add(plaintext.len());
if total_bytes > max_total_bytes {
bail = Some(ExportScopeError::TooLarge {
actual: total_bytes,
limit: max_total_bytes,
});
return ControlFlow::Break(());
}
entries.push(ExportSecretEntry {
delegate_key: delegate,
secret_hash,
plaintext,
});
ControlFlow::Continue(())
})?; if let Some(err) = bail {
return Err(err);
}
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>>,
}
#[derive(Debug, thiserror::Error)]
pub enum ExportScopeError {
#[error(transparent)]
Store(#[from] SecretStoreError),
#[error("export plaintext total {actual} exceeds limit {limit}")]
TooLarge { actual: usize, limit: usize },
#[error("export secret count {actual} exceeds limit {limit}")]
CountTooLarge { actual: usize, limit: usize },
}
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::RuntimeInnerError;
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 list_secret_keys_empty_store() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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![7].into(), &vec![].into()));
assert!(
store
.list_secret_keys(delegate.key(), SecretScope::Local, b"")
.is_empty()
);
assert!(
store
.list_secret_keys(delegate.key(), SecretScope::Local, b"room:")
.is_empty()
);
Ok(())
}
#[tokio::test]
async fn list_secret_keys_enumerates_and_filters() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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![8].into(), &vec![].into()));
let keys: Vec<Vec<u8>> = vec![
b"room:alice".to_vec(),
b"room:bob".to_vec(),
b"private_key".to_vec(),
];
for k in &keys {
store.store_secret(
delegate.key(),
&SecretsId::new(k.clone()),
SecretScope::Local,
Zeroizing::new(b"v".to_vec()),
)?;
}
let mut all = store.list_secret_keys(delegate.key(), SecretScope::Local, b"");
all.sort();
let mut expected = keys.clone();
expected.sort();
assert_eq!(all, expected);
let mut rooms = store.list_secret_keys(delegate.key(), SecretScope::Local, b"room:");
rooms.sort();
assert_eq!(rooms, vec![b"room:alice".to_vec(), b"room:bob".to_vec()]);
assert!(
store
.list_secret_keys(delegate.key(), SecretScope::Local, b"nope")
.is_empty()
);
store.store_secret(
delegate.key(),
&SecretsId::new(b"room:alice".to_vec()),
SecretScope::Local,
Zeroizing::new(b"v2".to_vec()),
)?;
assert_eq!(
store
.list_secret_keys(delegate.key(), SecretScope::Local, b"room:")
.len(),
2
);
store.remove_secret(
delegate.key(),
&SecretsId::new(b"room:alice".to_vec()),
SecretScope::Local,
)?;
let rooms_after = store.list_secret_keys(delegate.key(), SecretScope::Local, b"room:");
assert_eq!(rooms_after, vec![b"room:bob".to_vec()]);
Ok(())
}
#[tokio::test]
async fn list_secret_keys_scope_isolation() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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![9].into(), &vec![].into()));
let alice = UserId::new([0xAA; 32]);
let alice_dek = user_dek(0xA1);
store.store_secret(
delegate.key(),
&SecretsId::new(b"local-only".to_vec()),
SecretScope::Local,
Zeroizing::new(b"v".to_vec()),
)?;
store.store_secret(
delegate.key(),
&SecretsId::new(b"user-only".to_vec()),
SecretScope::User {
id: &alice,
dek_secret: &alice_dek,
},
Zeroizing::new(b"v".to_vec()),
)?;
assert_eq!(
store.list_secret_keys(delegate.key(), SecretScope::Local, b""),
vec![b"local-only".to_vec()]
);
assert_eq!(
store.list_secret_keys(
delegate.key(),
SecretScope::User {
id: &alice,
dek_secret: &alice_dek,
},
b"",
),
vec![b"user-only".to_vec()]
);
Ok(())
}
#[tokio::test]
async fn list_secret_keys_at_cap() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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)?;
store.set_max_registered_keys_per_scope(3);
let delegate = Delegate::from((&vec![10].into(), &vec![].into()));
for i in 0..3 {
store.store_secret(
delegate.key(),
&SecretsId::new(format!("k{i}").into_bytes()),
SecretScope::Local,
Zeroizing::new(b"v".to_vec()),
)?;
}
assert_eq!(
store
.list_secret_keys(delegate.key(), SecretScope::Local, b"")
.len(),
3
);
let overflow = SecretsId::new(b"overflow".to_vec());
store.store_secret(
delegate.key(),
&overflow,
SecretScope::Local,
Zeroizing::new(b"v".to_vec()),
)?;
assert!(
store
.get_secret(delegate.key(), &overflow, SecretScope::Local)
.is_ok(),
"overflow secret value must still be stored and readable"
);
let listed = store.list_secret_keys(delegate.key(), SecretScope::Local, b"");
assert_eq!(listed.len(), 3, "registry stays bounded at cap");
assert!(
!listed.iter().any(|k| k.as_slice() == b"overflow"),
"over-cap key must not appear in enumeration"
);
Ok(())
}
#[tokio::test]
async fn list_secret_keys_persist_across_restart() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
std::fs::create_dir_all(&secrets_dir)?;
let delegate = Delegate::from((&vec![11].into(), &vec![].into()));
{
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
store.store_secret(
delegate.key(),
&SecretsId::new(b"room:carol".to_vec()),
SecretScope::Local,
Zeroizing::new(b"v".to_vec()),
)?;
}
let db = create_test_db(temp_dir.path()).await;
let store = SecretsStore::new(secrets_dir, Default::default(), db)?;
assert_eq!(
store.list_secret_keys(delegate.key(), SecretScope::Local, b""),
vec![b"room:carol".to_vec()]
);
Ok(())
}
#[tokio::test]
async fn key_registry_tmp_path_is_correct() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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![12].into(), &vec![].into()));
store.store_secret(
delegate.key(),
&SecretsId::new(b"room:dave".to_vec()),
SecretScope::Local,
Zeroizing::new(b"v".to_vec()),
)?;
let scope_dir = secrets_dir.join(delegate.key().encode());
let names: Vec<String> = std::fs::read_dir(&scope_dir)?
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert!(
names.iter().any(|n| n == KEY_REGISTRY_FILE),
"expected active registry file {KEY_REGISTRY_FILE:?}, dir held {names:?}"
);
assert!(
!names.iter().any(|n| n == ".keys.tmp"),
"stray .keys.tmp left behind: {names:?}"
);
assert!(
!names.iter().any(|n| n == ".keys.keys.tmp"),
"buggy .keys.keys.tmp tmp name produced: {names:?}"
);
assert_eq!(
store.list_secret_keys(delegate.key(), SecretScope::Local, b""),
vec![b"room:dave".to_vec()]
);
Ok(())
}
#[tokio::test]
async fn corrupt_registry_does_not_shrink_enumerable_set()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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![13].into(), &vec![].into()));
for k in [b"room:alice".as_slice(), b"room:bob".as_slice()] {
store.store_secret(
delegate.key(),
&SecretsId::new(k.to_vec()),
SecretScope::Local,
Zeroizing::new(b"v".to_vec()),
)?;
}
assert_eq!(
store
.list_secret_keys(delegate.key(), SecretScope::Local, b"")
.len(),
2,
"precondition: two keys registered"
);
let reg_path = store.key_registry_path(delegate.key(), &SecretScope::Local);
let mut bogus = vec![VERSION_V1];
bogus.extend_from_slice(&[0u8; 24]);
bogus.extend_from_slice(&[0xAB; 32]);
std::fs::write(®_path, &bogus)?;
let new_key = SecretsId::new(b"room:carol".to_vec());
store.store_secret(
delegate.key(),
&new_key,
SecretScope::Local,
Zeroizing::new(b"v-new".to_vec()),
)?;
assert_eq!(
store
.get_secret(delegate.key(), &new_key, SecretScope::Local)?
.to_vec(),
b"v-new".to_vec(),
"secret VALUE write must succeed even when the registry is corrupt"
);
let on_disk = std::fs::read(®_path)?;
assert_eq!(
on_disk, bogus,
"corrupt registry must be preserved untouched, not overwritten from empty"
);
store.remove_secret(
delegate.key(),
&SecretsId::new(b"room:alice".to_vec()),
SecretScope::Local,
)?;
assert_eq!(
std::fs::read(®_path)?,
bogus,
"deregister must also leave the corrupt registry intact"
);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn unreadable_registry_does_not_shrink_enumerable_set()
-> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::PermissionsExt;
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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![14].into(), &vec![].into()));
for k in [b"room:alice".as_slice(), b"room:bob".as_slice()] {
store.store_secret(
delegate.key(),
&SecretsId::new(k.to_vec()),
SecretScope::Local,
Zeroizing::new(b"v".to_vec()),
)?;
}
let reg_path = store.key_registry_path(delegate.key(), &SecretScope::Local);
let original = std::fs::read(®_path)?;
std::fs::set_permissions(®_path, std::fs::Permissions::from_mode(0o000))?;
let reads_as_eacces = std::fs::read(®_path).is_err();
let new_key = SecretsId::new(b"room:carol".to_vec());
store.store_secret(
delegate.key(),
&new_key,
SecretScope::Local,
Zeroizing::new(b"v-new".to_vec()),
)?;
std::fs::set_permissions(®_path, std::fs::Permissions::from_mode(0o600))?;
assert_eq!(
store
.get_secret(delegate.key(), &new_key, SecretScope::Local)?
.to_vec(),
b"v-new".to_vec(),
);
if reads_as_eacces {
assert_eq!(
std::fs::read(®_path)?,
original,
"unreadable registry must be preserved, not overwritten from empty"
);
let listed = store.list_secret_keys(delegate.key(), SecretScope::Local, b"");
assert!(
listed.len() >= 2,
"original keys must survive a transient read error, got {listed:?}"
);
}
Ok(())
}
#[tokio::test]
async fn list_secret_keys_user_scope_persist_across_restart()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
std::fs::create_dir_all(&secrets_dir)?;
let delegate = Delegate::from((&vec![15].into(), &vec![].into()));
let alice = UserId::new([0xBB; 32]);
let alice_dek = user_dek(0xC2);
{
let db = create_test_db(temp_dir.path()).await;
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?;
store.store_secret(
delegate.key(),
&SecretsId::new(b"room:erin".to_vec()),
SecretScope::User {
id: &alice,
dek_secret: &alice_dek,
},
Zeroizing::new(b"v".to_vec()),
)?;
}
let db = create_test_db(temp_dir.path()).await;
let store = SecretsStore::new(secrets_dir, Default::default(), db)?;
assert_eq!(
store.list_secret_keys(
delegate.key(),
SecretScope::User {
id: &alice,
dek_secret: &alice_dek,
},
b"",
),
vec![b"room:erin".to_vec()]
);
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(_)
| SecretStoreError::QuotaExceeded { .. } => {
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_writes_do_not_snapshot_local_still_does()
-> 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,
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()),
)?;
assert!(
store
.list_snapshots(delegate.key(), &secret_id, user_scope())?
.is_empty(),
"per-user overwrite must NOT snapshot (disabled for hosted users, #4561)"
);
assert_eq!(
store
.get_secret(delegate.key(), &secret_id, user_scope())?
.to_vec(),
b"user-v2".to_vec(),
"the overwrite itself must still take effect"
);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"local-v1".to_vec()),
)?;
std::thread::sleep(Duration::from_millis(5));
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"local-v2".to_vec()),
)?;
assert_eq!(
store
.list_snapshots(delegate.key(), &secret_id, SecretScope::Local)?
.len(),
1,
"Local overwrite must still snapshot the prior version (unchanged)"
);
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.key_to_secret_part.get(delegate.key()).is_none(),
"pre-condition: the in-memory index entry was dropped (crash window)"
);
assert_eq!(
store.export_scope_entries(SecretScope::Local)?.len(),
1,
"disk-based export sees the completed on-disk secret pre-repair"
);
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
.user_key_to_secret_part
.get(&(delegate.key().clone(), *ctx.user_id()))
.is_none(),
"pre-condition: the in-memory user-index entry was dropped"
);
assert_eq!(
store.export_scope_entries(ctx.scope())?.len(),
1,
"disk-based export sees the completed on-disk user secret pre-repair"
);
let wrote = store.import_secret_by_hash(
delegate.key(),
&secret_hash,
ctx.scope(),
Zeroizing::new(b"uval".to_vec()),
false,
)?;
assert!(!wrote);
assert!(
store
.user_key_to_secret_part
.get(&(delegate.key().clone(), *ctx.user_id()))
.map(|e| e.value().contains(&secret_hash))
.unwrap_or(false),
"in-memory user index must be repaired"
);
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(())
}
fn on_disk_size(plaintext_len: usize) -> u64 {
(HEADER_LEN + plaintext_len + TAG_LEN) as u64
}
fn keys_registry_on_disk_size(keys: &[&[u8]]) -> u64 {
if keys.is_empty() {
return 0;
}
let plaintext: usize = keys.iter().map(|k| 4 + k.len()).sum();
(HEADER_LEN + plaintext + TAG_LEN) as u64
}
fn expected_footprint(value_plaintext_lens: &[usize], keys: &[&[u8]]) -> u64 {
let values: u64 = value_plaintext_lens.iter().map(|n| on_disk_size(*n)).sum();
values + keys_registry_on_disk_size(keys)
}
fn fresh_user(base_path: &Path, id_byte: u8) -> (UserId, Zeroizing<[u8; 32]>) {
let id = UserId::new([id_byte; 32]);
quota_reset_user_for_test(base_path, &id);
(id, user_dek(0xAB))
}
#[tokio::test]
async fn under_quota_user_write_succeeds() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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)?.with_user_quota(4096);
let delegate = Delegate::from((&vec![10].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0x10);
let secret_id = SecretsId::new(vec![11]);
let plaintext = b"a modest secret".to_vec();
let expected = expected_footprint(&[plaintext.len()], &[&[11]]);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(plaintext),
)?;
assert_eq!(
quota_tracked_total_for_test(&secrets_dir, &id),
Some(expected),
"tracker must reflect the full footprint: value blob + .keys registry"
);
Ok(())
}
#[tokio::test]
async fn over_quota_user_write_rejected() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let delegate = Delegate::from((&vec![20].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0x20);
let scope = SecretScope::User {
id: &id,
dek_secret: &dek,
};
let first = vec![7u8; 100];
let first_footprint = expected_footprint(&[100], &[&[1]]);
let quota = first_footprint + 50;
let mut store =
SecretsStore::new(secrets_dir.clone(), Default::default(), db)?.with_user_quota(quota);
store.store_secret(
delegate.key(),
&SecretsId::new(vec![1]),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(first),
)?;
assert_eq!(
quota_tracked_total_for_test(&secrets_dir, &id),
Some(first_footprint)
);
let second_id = SecretsId::new(vec![2]);
let second_growth = on_disk_size(100) + (4 + 1);
let result = store.store_secret(
delegate.key(),
&second_id,
scope,
Zeroizing::new(vec![9u8; 100]),
);
let err = result.expect_err("over-quota write must be rejected");
let RuntimeInnerError::SecretStoreError(SecretStoreError::QuotaExceeded {
used,
limit,
attempted,
}) = err.deref()
else {
panic!("expected QuotaExceeded, got {:?}", err.deref());
};
assert_eq!(
*used, first_footprint,
"used = current total before the write"
);
assert_eq!(*limit, quota);
assert_eq!(
*attempted, second_growth,
"attempted = net footprint growth (value blob + new .keys entry)"
);
let rejected_path = store
.scope_dir(
delegate.key(),
&SecretScope::User {
id: &id,
dek_secret: &dek,
},
)
.join(second_id.encode());
assert!(
!rejected_path.exists(),
"rejected secret must not be written to disk"
);
assert_eq!(
quota_tracked_total_for_test(&secrets_dir, &id),
Some(first_footprint),
"tracker must be unchanged after a rejected write"
);
Ok(())
}
#[tokio::test]
async fn quota_exceeded_propagates_as_storage_failure() -> Result<(), Box<dyn std::error::Error>>
{
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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)?.with_user_quota(1);
let delegate = Delegate::from((&vec![21].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0x21);
let err = store
.store_secret(
delegate.key(),
&SecretsId::new(vec![1]),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![1u8; 8]),
)
.expect_err("must reject");
assert!(
matches!(
err.deref(),
RuntimeInnerError::SecretStoreError(SecretStoreError::QuotaExceeded { .. })
),
"quota rejection must surface as a SecretStoreError storage failure"
);
Ok(())
}
#[tokio::test]
async fn remove_frees_room_for_previously_rejected_write()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let delegate = Delegate::from((&vec![30].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0x30);
let id_a = SecretsId::new(vec![1]);
let id_b = SecretsId::new(vec![2]);
let one_write = expected_footprint(&[100], &[&[1]]);
let quota = one_write;
let mut store =
SecretsStore::new(secrets_dir.clone(), Default::default(), db)?.with_user_quota(quota);
store.store_secret(
delegate.key(),
&id_a,
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![1u8; 100]),
)?;
assert_eq!(
quota_tracked_total_for_test(&secrets_dir, &id),
Some(one_write)
);
assert!(
store
.store_secret(
delegate.key(),
&id_b,
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![2u8; 100]),
)
.is_err(),
"second write must be rejected while the quota is full"
);
store.remove_secret(
delegate.key(),
&id_a,
SecretScope::User {
id: &id,
dek_secret: &dek,
},
)?;
assert_eq!(
quota_tracked_total_for_test(&secrets_dir, &id),
Some(0),
"removal must free the user's value blob AND its .keys bytes"
);
store.store_secret(
delegate.key(),
&id_b,
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![2u8; 100]),
)?;
assert_eq!(
quota_tracked_total_for_test(&secrets_dir, &id),
Some(expected_footprint(&[100], &[&[2]]))
);
Ok(())
}
#[tokio::test]
async fn lazy_disk_seed_matches_hand_built_layout() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let delegate = Delegate::from((&vec![40].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0x40);
{
let mut setup = SecretsStore::new(secrets_dir.clone(), Default::default(), db.clone())?;
for (i, len) in [(1u8, 100usize), (2u8, 50usize)] {
setup.store_secret(
delegate.key(),
&SecretsId::new(vec![i]),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![i; len]),
)?;
}
}
let expected_seed = expected_footprint(&[100, 50], &[&[1], &[2]]);
quota_reset_user_for_test(&secrets_dir, &id);
let quota = expected_seed + 10;
let mut store =
SecretsStore::new(secrets_dir.clone(), Default::default(), db)?.with_user_quota(quota);
let err = store
.store_secret(
delegate.key(),
&SecretsId::new(vec![3]),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![3u8; 1]),
)
.expect_err("seed must account for both existing blobs + .keys => over quota");
let RuntimeInnerError::SecretStoreError(SecretStoreError::QuotaExceeded { used, .. }) =
err.deref()
else {
panic!("expected QuotaExceeded, got {:?}", err.deref());
};
assert_eq!(
*used, expected_seed,
"seed must equal the summed disk bytes (value blobs + .keys registry)"
);
assert_eq!(
quota_tracked_total_for_test(&secrets_dir, &id),
Some(expected_seed)
);
Ok(())
}
#[tokio::test]
async fn two_stores_share_quota_accounting() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let delegate = Delegate::from((&vec![50].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0x50);
let one_write = expected_footprint(&[100], &[&[1]]);
let quota = one_write;
let mut store_a = SecretsStore::new(secrets_dir.clone(), Default::default(), db.clone())?
.with_user_quota(quota);
let mut store_b =
SecretsStore::new(secrets_dir.clone(), Default::default(), db)?.with_user_quota(quota);
store_a.store_secret(
delegate.key(),
&SecretsId::new(vec![1]),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![1u8; 100]),
)?;
let err = store_b
.store_secret(
delegate.key(),
&SecretsId::new(vec![2]),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![2u8; 100]),
)
.expect_err("store B must see store A's accounting via the shared tracker");
assert!(matches!(
err.deref(),
RuntimeInnerError::SecretStoreError(SecretStoreError::QuotaExceeded { .. })
));
assert_eq!(
quota_tracked_total_for_test(&secrets_dir, &id),
Some(one_write),
"the shared tracker holds only store A's write footprint"
);
Ok(())
}
#[tokio::test]
async fn local_scope_never_quota_checked() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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)?.with_user_quota(1);
let delegate = Delegate::from((&vec![60].into(), &vec![].into()));
store.store_secret(
delegate.key(),
&SecretsId::new(vec![1]),
SecretScope::Local,
Zeroizing::new(vec![0u8; 10_000]),
)?;
let got = store.get_secret(delegate.key(), &SecretsId::new(vec![1]), SecretScope::Local)?;
assert_eq!(
got.len(),
10_000,
"Local write must succeed regardless of quota"
);
Ok(())
}
#[tokio::test]
async fn quota_zero_disables_enforcement() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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)?.with_user_quota(0);
assert_eq!(store.user_quota_limit_bytes(), 0);
let delegate = Delegate::from((&vec![70].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0x70);
store.store_secret(
delegate.key(),
&SecretsId::new(vec![1]),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![0u8; 1_000_000]),
)?;
assert_eq!(
quota_tracked_total_for_test(&secrets_dir, &id),
None,
"quota=0 must skip the tracker entirely (no seed, no increment)"
);
Ok(())
}
#[tokio::test]
async fn overwrite_charges_only_delta() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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)?
.with_user_quota(100_000);
let delegate = Delegate::from((&vec![80].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0x80);
let secret_id = SecretsId::new(vec![1]);
let keys = keys_registry_on_disk_size(&[&[1]]);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![1u8; 100]),
)?;
assert_eq!(
quota_tracked_total_for_test(&secrets_dir, &id),
Some(on_disk_size(100) + keys)
);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![2u8; 300]),
)?;
assert_eq!(
quota_tracked_total_for_test(&secrets_dir, &id),
Some(on_disk_size(300) + keys),
"overwrite must charge only the value delta, not double-count"
);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![3u8; 10]),
)?;
assert_eq!(
quota_tracked_total_for_test(&secrets_dir, &id),
Some(on_disk_size(10) + keys),
"shrinking overwrite must free the freed value bytes"
);
Ok(())
}
#[tokio::test]
async fn projected_equals_limit_is_allowed() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let delegate = Delegate::from((&vec![90].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0x90);
let exact = expected_footprint(&[100], &[&[1]]);
let mut store =
SecretsStore::new(secrets_dir.clone(), Default::default(), db)?.with_user_quota(exact);
store.store_secret(
delegate.key(),
&SecretsId::new(vec![1]),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![1u8; 100]),
)?;
assert_eq!(
quota_tracked_total_for_test(&secrets_dir, &id),
Some(exact),
"a write landing exactly at the limit must be allowed"
);
assert!(
store
.store_secret(
delegate.key(),
&SecretsId::new(vec![2]),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![2u8; 1]),
)
.is_err(),
"any write past the exact limit must be rejected"
);
Ok(())
}
#[tokio::test]
async fn multi_delegate_seed_spans_two_delegates() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let delegate_a = Delegate::from((&vec![0xA0].into(), &vec![].into()));
let delegate_b = Delegate::from((&vec![0xB0].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0xA1);
{
let mut setup = SecretsStore::new(secrets_dir.clone(), Default::default(), db.clone())?;
setup.store_secret(
delegate_a.key(),
&SecretsId::new(vec![1]),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![1u8; 100]),
)?;
setup.store_secret(
delegate_b.key(),
&SecretsId::new(vec![2]),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![2u8; 200]),
)?;
}
let expected_seed = on_disk_size(100)
+ on_disk_size(200)
+ keys_registry_on_disk_size(&[&[1]])
+ keys_registry_on_disk_size(&[&[2]]);
quota_reset_user_for_test(&secrets_dir, &id);
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?
.with_user_quota(expected_seed);
let err = store
.store_secret(
delegate_a.key(),
&SecretsId::new(vec![3]),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![3u8; 1]),
)
.expect_err("seed must span both delegate dirs => already at limit");
let RuntimeInnerError::SecretStoreError(SecretStoreError::QuotaExceeded { used, .. }) =
err.deref()
else {
panic!("expected QuotaExceeded, got {:?}", err.deref());
};
assert_eq!(
*used, expected_seed,
"seed must equal the user's bytes summed across BOTH delegates"
);
Ok(())
}
#[tokio::test]
async fn user_snapshots_disabled_local_snapshots_kept() -> Result<(), Box<dyn std::error::Error>>
{
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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)?.with_user_quota(0);
let delegate = Delegate::from((&vec![0xC0].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0xC1);
let secret_id = SecretsId::new(vec![1]);
for v in [10u8, 20u8] {
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![v; 32]),
)?;
}
let user_scope_dir = store.scope_dir(
delegate.key(),
&SecretScope::User {
id: &id,
dek_secret: &dek,
},
);
assert!(
!user_scope_dir.join(SNAPSHOTS_DIR).exists(),
"per-user overwrites must NOT create a .snapshots/ directory"
);
for v in [10u8, 20u8] {
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(vec![v; 32]),
)?;
}
let local_scope_dir = store.scope_dir(delegate.key(), &SecretScope::Local);
assert!(
local_scope_dir.join(SNAPSHOTS_DIR).exists(),
"Local overwrites must still snapshot (behavior unchanged)"
);
Ok(())
}
#[tokio::test]
async fn many_keys_rejected_when_footprint_hits_limit() -> Result<(), Box<dyn std::error::Error>>
{
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let delegate = Delegate::from((&vec![0xD0].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0xD1);
let key_len = 64usize;
let value_len = 1usize;
let make_key = |n: u8| SecretsId::new(vec![n; key_len]);
let k1 = vec![1u8; key_len];
let k2 = vec![2u8; key_len];
let k3 = vec![3u8; key_len];
let three_writes = expected_footprint(
&[value_len, value_len, value_len],
&[k1.as_slice(), k2.as_slice(), k3.as_slice()],
);
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?
.with_user_quota(three_writes);
for n in 1u8..=3 {
store.store_secret(
delegate.key(),
&make_key(n),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![0u8; value_len]),
)?;
}
assert_eq!(
quota_tracked_total_for_test(&secrets_dir, &id),
Some(three_writes),
"three key writes must land exactly at the quota"
);
let err = store
.store_secret(
delegate.key(),
&make_key(4),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![0u8; value_len]),
)
.expect_err("a fourth distinct key must be rejected by the .keys footprint");
assert!(matches!(
err.deref(),
RuntimeInnerError::SecretStoreError(SecretStoreError::QuotaExceeded { .. })
));
assert_eq!(
quota_tracked_total_for_test(&secrets_dir, &id),
Some(three_writes)
);
Ok(())
}
#[tokio::test]
async fn tracker_namespaced_by_secrets_dir() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let dir_a = temp_dir.path().join("node-a");
let dir_b = temp_dir.path().join("node-b");
std::fs::create_dir_all(&dir_a)?;
std::fs::create_dir_all(&dir_b)?;
let dba_dir = temp_dir.path().join("dba");
let dbb_dir = temp_dir.path().join("dbb");
std::fs::create_dir_all(&dba_dir)?;
std::fs::create_dir_all(&dbb_dir)?;
let db_a = create_test_db(&dba_dir).await;
let db_b = create_test_db(&dbb_dir).await;
let delegate = Delegate::from((&vec![0xE0].into(), &vec![].into()));
let id = UserId::new([0xE1; 32]);
quota_reset_user_for_test(&dir_a, &id);
quota_reset_user_for_test(&dir_b, &id);
let dek = user_dek(0xAB);
let one_write = expected_footprint(&[100], &[&[1]]);
let mut store_a =
SecretsStore::new(dir_a.clone(), Default::default(), db_a)?.with_user_quota(one_write);
let mut store_b =
SecretsStore::new(dir_b.clone(), Default::default(), db_b)?.with_user_quota(one_write);
store_a.store_secret(
delegate.key(),
&SecretsId::new(vec![1]),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![1u8; 100]),
)?;
store_b.store_secret(
delegate.key(),
&SecretsId::new(vec![1]),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![1u8; 100]),
)?;
assert_eq!(
quota_tracked_total_for_test(&dir_a, &id),
Some(one_write),
"dir_a counter holds only A's write"
);
assert_eq!(
quota_tracked_total_for_test(&dir_b, &id),
Some(one_write),
"dir_b counter holds only B's write (NOT shared with A)"
);
let dba2_dir = temp_dir.path().join("dba2");
std::fs::create_dir_all(&dba2_dir)?;
let db_a2 = create_test_db(&dba2_dir).await;
let mut store_a2 =
SecretsStore::new(dir_a.clone(), Default::default(), db_a2)?.with_user_quota(one_write);
let err = store_a2
.store_secret(
delegate.key(),
&SecretsId::new(vec![2]),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![2u8; 100]),
)
.expect_err("a second store over the SAME dir must see the existing footprint");
assert!(matches!(
err.deref(),
RuntimeInnerError::SecretStoreError(SecretStoreError::QuotaExceeded { .. })
));
Ok(())
}
#[tokio::test]
async fn orphan_keys_file_counted_in_seed() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
std::fs::create_dir_all(&secrets_dir)?;
let db = create_test_db(temp_dir.path()).await;
let delegate = Delegate::from((&vec![0xF0].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0xF1);
let secret_id = SecretsId::new(vec![1]);
{
let mut setup = SecretsStore::new(secrets_dir.clone(), Default::default(), db.clone())?;
setup.store_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![1u8; 100]),
)?;
}
let scope_dir = secrets_dir
.join(delegate.key().encode())
.join("users")
.join(id.encode());
let value_path = scope_dir.join(secret_id.encode());
std::fs::remove_file(&value_path)?;
let keys_path = scope_dir.join(KEY_REGISTRY_FILE);
assert!(
keys_path.exists(),
"precondition: orphan .keys file must remain on disk"
);
let orphan_keys_size = std::fs::metadata(&keys_path)?.len();
assert!(orphan_keys_size > 0);
quota_reset_user_for_test(&secrets_dir, &id);
let mut store = SecretsStore::new(secrets_dir.clone(), Default::default(), db)?
.with_user_quota(orphan_keys_size + 10);
let err = store
.store_secret(
delegate.key(),
&SecretsId::new(vec![2]),
SecretScope::User {
id: &id,
dek_secret: &dek,
},
Zeroizing::new(vec![2u8; 1]),
)
.expect_err("seed must include the orphan .keys => over quota");
let RuntimeInnerError::SecretStoreError(SecretStoreError::QuotaExceeded { used, .. }) =
err.deref()
else {
panic!("expected QuotaExceeded, got {:?}", err.deref());
};
assert_eq!(
*used, orphan_keys_size,
"seeded total must equal exactly the orphan .keys size (no active blobs)"
);
Ok(())
}
fn store_one_user_secret(
store: &mut SecretsStore,
delegate: &DelegateKey,
id: &UserId,
dek: &Zeroizing<[u8; 32]>,
secret_id: &SecretsId,
plaintext: &[u8],
) {
store
.store_secret(
delegate,
secret_id,
SecretScope::User {
id,
dek_secret: dek,
},
Zeroizing::new(plaintext.to_vec()),
)
.expect("user secret write should succeed (quota off)");
}
fn reclaim_all(base: &Path, db: &Storage, now: u64, ttl: u64) -> usize {
reclaim_inactive_users(base, db, now, ttl, None, 0, 0).reclaimed
}
#[tokio::test]
async fn reclaim_inactive_user_clears_all_durable_state()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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.clone())?
.with_user_quota(1 << 20);
let delegate = Delegate::from((&vec![41].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0x41);
let secret_id = SecretsId::new(vec![42]);
store_one_user_secret(
&mut store,
delegate.key(),
&id,
&dek,
&secret_id,
b"keepsake",
);
let now = 1_000_000_000u64;
let ttl = DEFAULT_PER_USER_INACTIVE_TTL_SECS; let old = now - 100 * 86_400;
assert!(
stamp_user_last_seen(&secrets_dir, &id, old, 0),
"first stamp must write"
);
let user_dir = scope_user_dir(&secrets_dir, delegate.key(), &id);
assert!(
user_dir.exists(),
"user secret dir must exist before reclaim"
);
assert!(
user_activity_dir(&secrets_dir, &id)
.join(LAST_SEEN_FILE)
.exists(),
"marker must exist before reclaim"
);
assert!(
db.get_user_secrets_index(delegate.key(), id.as_bytes())?
.is_some(),
"ReDb index row must exist before reclaim"
);
assert!(
quota_tracked_total_for_test(&secrets_dir, &id).is_some(),
"quota tracker entry must exist before reclaim (seeded by the write)"
);
let reclaimed = reclaim_all(&secrets_dir, &db, now, ttl);
assert_eq!(reclaimed, 1, "the one over-TTL user must be reclaimed");
assert!(!user_dir.exists(), "user secret subtree must be removed");
assert!(
!user_activity_dir(&secrets_dir, &id).exists(),
"activity-marker tree must be removed"
);
assert!(
db.get_user_secrets_index(delegate.key(), id.as_bytes())?
.is_none(),
"ReDb index row must be removed"
);
assert!(
quota_tracked_total_for_test(&secrets_dir, &id).is_none(),
"quota-tracker entry must be removed"
);
Ok(())
}
fn scope_user_dir(base: &Path, delegate: &DelegateKey, id: &UserId) -> PathBuf {
base.join(delegate.encode())
.join(USERS_DIR)
.join(id.encode())
}
#[tokio::test]
async fn reclaim_clears_in_memory_index() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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.clone())?;
let delegate = Delegate::from((&vec![43].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0x43);
let secret_id = SecretsId::new(vec![44]);
store_one_user_secret(&mut store, delegate.key(), &id, &dek, &secret_id, b"v");
assert!(
store
.user_key_to_secret_part
.iter()
.any(|e| e.key().1 == id),
"in-memory index must list the user after a write"
);
reclaim_user(&secrets_dir, &db, &id);
assert!(
db.get_user_secrets_index(delegate.key(), id.as_bytes())?
.is_none(),
"durable ReDb row gone => a rebuilt in-memory index won't list the user"
);
Ok(())
}
#[tokio::test]
async fn active_user_is_never_reclaimed() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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.clone())?;
let delegate = Delegate::from((&vec![45].into(), &vec![].into()));
let (active, dek_a) = fresh_user(&secrets_dir, 0x45);
let (stale, dek_s) = fresh_user(&secrets_dir, 0x46);
store_one_user_secret(
&mut store,
delegate.key(),
&active,
&dek_a,
&SecretsId::new(vec![1]),
b"a",
);
store_one_user_secret(
&mut store,
delegate.key(),
&stale,
&dek_s,
&SecretsId::new(vec![2]),
b"s",
);
let now = 2_000_000_000u64;
let ttl = 30 * 86_400;
assert!(stamp_user_last_seen(&secrets_dir, &active, now - 3_600, 0));
assert!(stamp_user_last_seen(
&secrets_dir,
&stale,
now - 40 * 86_400,
0
));
let reclaimed = reclaim_all(&secrets_dir, &db, now, ttl);
assert_eq!(reclaimed, 1, "only the stale user is reclaimed");
assert!(
scope_user_dir(&secrets_dir, delegate.key(), &active).exists(),
"ACTIVE user's data MUST survive (no silent data loss)"
);
assert!(
!scope_user_dir(&secrets_dir, delegate.key(), &stale).exists(),
"stale user's data is gone"
);
Ok(())
}
#[tokio::test]
async fn user_without_marker_is_not_reclaimed() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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.clone())?;
let delegate = Delegate::from((&vec![47].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0x47);
store_one_user_secret(
&mut store,
delegate.key(),
&id,
&dek,
&SecretsId::new(vec![1]),
b"x",
);
let now = 3_000_000_000u64;
let reclaimed = reclaim_all(&secrets_dir, &db, now, 30 * 86_400);
assert_eq!(reclaimed, 0, "a user without a marker is never reclaimed");
assert!(
scope_user_dir(&secrets_dir, delegate.key(), &id).exists(),
"their data must survive"
);
Ok(())
}
#[tokio::test]
async fn reclaim_is_idempotent() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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.clone())?;
let delegate = Delegate::from((&vec![48].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0x48);
store_one_user_secret(
&mut store,
delegate.key(),
&id,
&dek,
&SecretsId::new(vec![1]),
b"y",
);
reclaim_user(&secrets_dir, &db, &id);
assert!(!scope_user_dir(&secrets_dir, delegate.key(), &id).exists());
reclaim_user(&secrets_dir, &db, &id);
assert!(
db.get_user_secrets_index(delegate.key(), id.as_bytes())?
.is_none(),
"re-run stays clean"
);
Ok(())
}
#[tokio::test]
async fn local_data_is_never_reclaimed() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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.clone())?;
let delegate = Delegate::from((&vec![49].into(), &vec![].into()));
let (cipher, nonce) = fresh_cipher();
store.register_delegate(delegate.key().clone(), cipher, nonce)?;
let secret_id = SecretsId::new(vec![50]);
store.store_secret(
delegate.key(),
&secret_id,
SecretScope::Local,
Zeroizing::new(b"local-only".to_vec()),
)?;
let local_dir = secrets_dir.join(delegate.key().encode());
assert!(local_dir.join(secret_id.encode()).exists());
let now = 4_000_000_000u64;
assert!(
enumerate_marked_users(&secrets_dir).is_empty(),
"a Local-only node has no marked users"
);
let reclaimed = reclaim_all(&secrets_dir, &db, now, 1 );
assert_eq!(reclaimed, 0, "Local data must never be reclaimed");
assert!(
local_dir.join(secret_id.encode()).exists(),
"the Local secret blob must still be on disk"
);
assert!(
store
.get_secret(delegate.key(), &secret_id, SecretScope::Local)
.is_ok(),
"Local secret must remain readable"
);
Ok(())
}
#[tokio::test]
async fn ttl_zero_disables_sweep() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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.clone())?;
let delegate = Delegate::from((&vec![51].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0x51);
store_one_user_secret(
&mut store,
delegate.key(),
&id,
&dek,
&SecretsId::new(vec![1]),
b"z",
);
assert!(stamp_user_last_seen(&secrets_dir, &id, 0, 0));
let now = 5_000_000_000u64;
let reclaimed = reclaim_all(&secrets_dir, &db, now, 0);
assert_eq!(reclaimed, 0, "ttl == 0 must reclaim nothing");
assert!(scope_user_dir(&secrets_dir, delegate.key(), &id).exists());
Ok(())
}
#[tokio::test]
async fn last_seen_marker_does_not_count_toward_quota() -> Result<(), Box<dyn std::error::Error>>
{
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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)?.with_user_quota(4096);
let (id, dek) = fresh_user(&secrets_dir, 0x52);
assert!(stamp_user_last_seen(&secrets_dir, &id, 123, 0));
let scope = SecretScope::User {
id: &id,
dek_secret: &dek,
};
let seeded = store.seeded_user_total(&scope)?;
assert_eq!(
seeded, 0,
".last_seen marker must NOT count toward the per-user quota"
);
Ok(())
}
#[tokio::test]
async fn reclaim_with_dangling_blob_does_not_panic() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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.clone())?;
let delegate = Delegate::from((&vec![53].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0x53);
let secret_id = SecretsId::new(vec![54]);
store_one_user_secret(&mut store, delegate.key(), &id, &dek, &secret_id, b"q");
let blob = scope_user_dir(&secrets_dir, delegate.key(), &id).join(secret_id.encode());
std::fs::remove_file(&blob)?;
assert!(matches!(
store.get_secret(
delegate.key(),
&secret_id,
SecretScope::User {
id: &id,
dek_secret: &dek
},
),
Err(SecretStoreError::MissingSecret(_))
));
reclaim_user(&secrets_dir, &db, &id);
assert!(
db.get_user_secrets_index(delegate.key(), id.as_bytes())?
.is_none(),
"dangling index row removed"
);
Ok(())
}
#[test]
fn stamp_last_seen_debounces() {
let temp_dir = tempfile::tempdir().unwrap();
let base = temp_dir.path();
let id = UserId::new([0x60; 32]);
let debounce = 3_600u64;
assert!(stamp_user_last_seen(base, &id, 1_000, debounce));
assert_eq!(read_user_last_seen(base, &id), Some(1_000));
assert!(!stamp_user_last_seen(base, &id, 1_600, debounce));
assert_eq!(
read_user_last_seen(base, &id),
Some(1_000),
"debounced stamp must not rewrite"
);
assert!(stamp_user_last_seen(
base,
&id,
1_000 + debounce + 1,
debounce
));
assert_eq!(read_user_last_seen(base, &id), Some(1_000 + debounce + 1));
}
#[tokio::test]
async fn reconnect_during_sweep_spares_user() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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.clone())?;
let delegate = Delegate::from((&vec![55].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0x55);
store_one_user_secret(
&mut store,
delegate.key(),
&id,
&dek,
&SecretsId::new(vec![1]),
b"r",
);
let now = 6_000_000_000u64;
assert!(stamp_user_last_seen(&secrets_dir, &id, now, 0));
let reclaimed = reclaim_all(&secrets_dir, &db, now, 30 * 86_400);
assert_eq!(
reclaimed, 0,
"a freshly-stamped user is spared by the re-check"
);
assert!(scope_user_dir(&secrets_dir, delegate.key(), &id).exists());
Ok(())
}
#[tokio::test]
async fn spawn_sweep_respects_ttl_zero() {
let temp_dir = tempfile::tempdir().unwrap();
let base = temp_dir.path().to_path_buf();
let db = create_test_db(temp_dir.path()).await;
assert!(
spawn_inactive_user_sweep(base.clone(), db.clone(), 0, 3_600).is_none(),
"ttl == 0 => no sweep task spawned"
);
let handle = spawn_inactive_user_sweep(base, db, 30 * 86_400, 3_600);
assert!(handle.is_some(), "non-zero ttl spawns a task");
handle.unwrap().abort();
}
#[tokio::test]
async fn ttl_boundary_strict_greater_than() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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.clone())?;
let delegate = Delegate::from((&vec![60].into(), &vec![].into()));
let now = 10_000_000u64;
let ttl = 30 * 86_400u64;
let (under, dek_u) = fresh_user(&secrets_dir, 0x60);
let (exact, dek_e) = fresh_user(&secrets_dir, 0x61);
let (over, dek_o) = fresh_user(&secrets_dir, 0x62);
store_one_user_secret(
&mut store,
delegate.key(),
&under,
&dek_u,
&SecretsId::new(vec![1]),
b"u",
);
store_one_user_secret(
&mut store,
delegate.key(),
&exact,
&dek_e,
&SecretsId::new(vec![2]),
b"e",
);
store_one_user_secret(
&mut store,
delegate.key(),
&over,
&dek_o,
&SecretsId::new(vec![3]),
b"o",
);
assert!(stamp_user_last_seen(
&secrets_dir,
&under,
now - (ttl - 1),
0
));
assert!(stamp_user_last_seen(&secrets_dir, &exact, now - ttl, 0));
assert!(stamp_user_last_seen(
&secrets_dir,
&over,
now - (ttl + 1),
0
));
let reclaimed = reclaim_all(&secrets_dir, &db, now, ttl);
assert_eq!(reclaimed, 1, "only age = ttl+1 is reclaimed");
assert!(
scope_user_dir(&secrets_dir, delegate.key(), &under).exists(),
"age = ttl-1 must be spared"
);
assert!(
scope_user_dir(&secrets_dir, delegate.key(), &exact).exists(),
"age == ttl must be spared (strict `>`)"
);
assert!(
!scope_user_dir(&secrets_dir, delegate.key(), &over).exists(),
"age = ttl+1 must be reclaimed"
);
Ok(())
}
#[test]
fn spawn_gate_requires_hosted_mode_and_nonzero_ttl() {
let ttl = DEFAULT_PER_USER_INACTIVE_TTL_SECS;
assert!(
should_spawn_inactive_user_sweep(true, ttl),
"hosted + ttl>0 => spawn"
);
assert!(
!should_spawn_inactive_user_sweep(false, ttl),
"NOT hosted (Local) must NEVER spawn the sweep, even with ttl>0"
);
assert!(
!should_spawn_inactive_user_sweep(true, 0),
"ttl==0 (disabled) => no spawn even in hosted mode"
);
assert!(
!should_spawn_inactive_user_sweep(false, 0),
"neither => no spawn"
);
}
#[tokio::test]
async fn per_sweep_cap_bounds_reclaims() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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.clone())?;
let delegate = Delegate::from((&vec![63].into(), &vec![].into()));
let now = 20_000_000u64;
let ttl = 30 * 86_400u64;
let cap = 3usize;
let total = 5usize;
for i in 0..total as u8 {
let (id, dek) = fresh_user(&secrets_dir, 0x70 + i);
store_one_user_secret(
&mut store,
delegate.key(),
&id,
&dek,
&SecretsId::new(vec![i]),
b"x",
);
assert!(stamp_user_last_seen(
&secrets_dir,
&id,
now - 100 * 86_400,
0
));
}
let out = reclaim_inactive_users(&secrets_dir, &db, now, ttl, None, 0, cap);
assert_eq!(out.reclaimed, cap, "first pass reclaims exactly the cap");
assert!(out.capped, "cap was hit => capped flag set (warn path)");
assert!(!out.skipped_gap);
let remaining = enumerate_marked_users(&secrets_dir).len();
assert_eq!(remaining, total - cap, "M users survive the first pass");
let out2 = reclaim_inactive_users(&secrets_dir, &db, now, ttl, None, 0, cap);
assert_eq!(
out2.reclaimed,
total - cap,
"second pass reclaims the remainder"
);
assert!(!out2.capped, "remainder is under the cap");
assert_eq!(
enumerate_marked_users(&secrets_dir).len(),
0,
"all reclaimed after two passes"
);
Ok(())
}
#[tokio::test]
async fn gap_detector_skips_pass_on_forward_jump() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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.clone())?;
let delegate = Delegate::from((&vec![64].into(), &vec![].into()));
let (id, dek) = fresh_user(&secrets_dir, 0x80);
store_one_user_secret(
&mut store,
delegate.key(),
&id,
&dek,
&SecretsId::new(vec![1]),
b"g",
);
let prev = 1_000_000u64;
assert!(stamp_user_last_seen(&secrets_dir, &id, prev - 60, 0));
let ttl = 30 * 86_400u64;
let max_gap = 8 * 3_600u64; let now = prev + 60 * 86_400;
let out = reclaim_inactive_users(&secrets_dir, &db, now, ttl, Some(prev), max_gap, 256);
assert!(
out.skipped_gap,
"implausible forward jump must skip the pass"
);
assert_eq!(out.reclaimed, 0, "no deletes on a skipped pass");
assert!(
scope_user_dir(&secrets_dir, delegate.key(), &id).exists(),
"data survives a gap-skipped pass"
);
let normal_now = prev + 3_600;
let out2 =
reclaim_inactive_users(&secrets_dir, &db, normal_now, ttl, Some(prev), max_gap, 256);
assert!(!out2.skipped_gap, "a normal interval gap does not skip");
Ok(())
}
#[tokio::test]
async fn reclaim_removes_only_target_user_redb_rows() -> Result<(), Box<dyn std::error::Error>>
{
let temp_dir = tempfile::tempdir()?;
let secrets_dir = temp_dir.path().join("s");
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.clone())?;
let delegate = Delegate::from((&vec![90].into(), &vec![].into()));
let (a, dek_a) = fresh_user(&secrets_dir, 0x90);
let (b, dek_b) = fresh_user(&secrets_dir, 0x91);
store_one_user_secret(
&mut store,
delegate.key(),
&a,
&dek_a,
&SecretsId::new(vec![1]),
b"a-secret",
);
store_one_user_secret(
&mut store,
delegate.key(),
&b,
&dek_b,
&SecretsId::new(vec![2]),
b"b-secret",
);
assert!(
db.get_user_secrets_index(delegate.key(), a.as_bytes())?
.is_some(),
"A must have a ReDb row before reclaim"
);
assert!(
db.get_user_secrets_index(delegate.key(), b.as_bytes())?
.is_some(),
"B must have a ReDb row before reclaim"
);
let now = 30_000_000u64;
let ttl = 30 * 86_400u64;
assert!(stamp_user_last_seen(&secrets_dir, &a, now - 40 * 86_400, 0));
assert!(stamp_user_last_seen(&secrets_dir, &b, now - 3_600, 0));
let reclaimed = reclaim_all(&secrets_dir, &db, now, ttl);
assert_eq!(reclaimed, 1, "exactly the idle user A is reclaimed");
assert!(
db.get_user_secrets_index(delegate.key(), a.as_bytes())?
.is_none(),
"A's ReDb index row must be removed"
);
assert!(
db.get_user_secrets_index(delegate.key(), b.as_bytes())?
.is_some(),
"B's ReDb index row must SURVIVE — reclaim must not touch the non-target user"
);
assert!(
store
.get_secret(
delegate.key(),
&SecretsId::new(vec![2]),
SecretScope::User {
id: &b,
dek_secret: &dek_b
},
)
.is_ok(),
"B's secret must remain readable after A's reclaim"
);
Ok(())
}
}