use std::fs;
use std::path::PathBuf;
use anyhow::{Context, Result, anyhow, bail};
use chacha20poly1305::aead::{OsRng, rand_core::RngCore};
use chrono::{DateTime, Utc};
use freenet::dev_tool::{
BundleKeyMaterial, ExportError, KEK_SIZE, KekBackendKind, RestoreError, RetentionPolicy,
SecretScope, Secrets, SecretsStore, Storage, TargetScope, UserSecretContext, build_backend_for,
export_bundle, import_bundle, list_snapshots, load_from_backend, read_backend_marker,
replace_backend_marker, restore_snapshot_file, snapshot_dir_for_encoded, thin_snapshots,
write_backend_marker, write_bundle_file,
};
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
#[derive(clap::Parser, Clone, Debug)]
pub struct SecretsCliConfig {
#[clap(subcommand)]
pub command: SecretsCommand,
}
#[derive(clap::Subcommand, Clone, Debug)]
pub enum SecretsCommand {
KekStatus(KekStatusArgs),
KekInit(KekInitArgs),
KekRotate(KekRotateArgs),
KekMigrate(KekMigrateArgs),
SnapshotList(SnapshotListArgs),
SnapshotRestore(SnapshotRestoreArgs),
Export(ExportArgs),
Import(ImportArgs),
}
#[derive(clap::Parser, Clone, Debug)]
pub struct KekStatusArgs {
#[clap(long, value_parser)]
pub secrets_dir: PathBuf,
}
#[derive(clap::Parser, Clone, Debug)]
pub struct KekInitArgs {
#[clap(long, value_parser)]
pub secrets_dir: PathBuf,
#[clap(long)]
pub backend: String,
#[clap(long)]
pub yes: bool,
}
#[derive(clap::Parser, Clone, Debug)]
pub struct KekRotateArgs {
#[clap(long, value_parser)]
pub secrets_dir: PathBuf,
#[clap(long)]
pub yes: bool,
}
#[derive(clap::Parser, Clone, Debug)]
pub struct KekMigrateArgs {
#[clap(long, value_parser)]
pub secrets_dir: PathBuf,
#[clap(long)]
pub to: String,
#[clap(long)]
pub yes: bool,
}
#[derive(clap::Parser, Clone, Debug)]
pub struct SnapshotListArgs {
#[clap(long, value_parser)]
pub secrets_dir: PathBuf,
#[clap(long)]
pub delegate: Option<String>,
#[clap(long, requires = "delegate")]
pub secret: Option<String>,
}
#[derive(clap::Parser, Clone, Debug)]
pub struct SnapshotRestoreArgs {
#[clap(long, value_parser)]
pub secrets_dir: PathBuf,
#[clap(long)]
pub delegate: String,
#[clap(long)]
pub secret: String,
#[clap(long)]
pub timestamp_ms: u64,
#[clap(long)]
pub suffix: Option<u32>,
#[clap(long)]
pub yes: bool,
}
#[derive(clap::Parser, Clone, Debug)]
pub struct ExportArgs {
#[clap(long, value_parser)]
pub secrets_dir: PathBuf,
#[clap(long, value_parser)]
pub db_dir: PathBuf,
#[clap(long, conflicts_with = "user_token")]
pub local: bool,
#[clap(long, num_args = 0..=1, default_missing_value = "")]
pub user_token: Option<String>,
#[clap(long, num_args = 0..=1, default_missing_value = "", conflicts_with = "use_token_key")]
pub passphrase: Option<String>,
#[clap(long, requires = "user_token")]
pub use_token_key: bool,
#[clap(long, value_parser)]
pub out: PathBuf,
}
#[derive(clap::Parser, Clone, Debug)]
pub struct ImportArgs {
#[clap(value_parser)]
pub bundle: PathBuf,
#[clap(long, value_parser)]
pub secrets_dir: PathBuf,
#[clap(long, value_parser)]
pub db_dir: PathBuf,
#[clap(long, num_args = 0..=1, default_missing_value = "", conflicts_with = "use_token_key")]
pub passphrase: Option<String>,
#[clap(long)]
pub use_token_key: bool,
#[clap(long, num_args = 0..=1, default_missing_value = "", conflicts_with = "passphrase")]
pub token: Option<String>,
#[clap(long, conflicts_with = "into_user")]
pub local: bool,
#[clap(long, num_args = 0..=1, default_missing_value = "")]
pub into_user: Option<String>,
#[clap(long)]
pub overwrite: bool,
}
pub async fn run(config: SecretsCliConfig) -> Result<()> {
match config.command {
SecretsCommand::KekStatus(args) => kek_status(args).await,
SecretsCommand::KekInit(args) => kek_init(args).await,
SecretsCommand::KekRotate(args) => kek_rotate(args).await,
SecretsCommand::KekMigrate(args) => kek_migrate(args).await,
SecretsCommand::SnapshotList(args) => snapshot_list(args).await,
SecretsCommand::SnapshotRestore(args) => snapshot_restore(args).await,
SecretsCommand::Export(args) => secrets_export(args).await,
SecretsCommand::Import(args) => secrets_import(args).await,
}
}
fn parse_backend_kind(s: &str) -> Result<KekBackendKind> {
match s {
"keyring" => Ok(KekBackendKind::Keyring),
"systemd" => Ok(KekBackendKind::Systemd),
"file" => Ok(KekBackendKind::File),
other => Err(anyhow!(
"unrecognized backend `{other}`; expected keyring|systemd|file"
)),
}
}
async fn kek_init(args: KekInitArgs) -> Result<()> {
let target = parse_backend_kind(&args.backend)?;
if let Some(existing) =
read_backend_marker(&args.secrets_dir).context("failed to read kek_backend marker")?
{
bail!(
"KEK already provisioned on `{existing}` backend in {}. Use \
`freenet secrets kek-migrate --to {target}` to switch backends.",
args.secrets_dir.display()
);
}
if !args.yes {
eprintln!(
"About to opt in to the `{target}` backend for the node KEK at {}.\n\
- `keyring`: writes a freshly generated KEK to the OS secret store.\n\
* macOS: binds to current binary signature; ad-hoc / dev builds re-prompt\n\
every start; signed builds re-prompt after each upgrade.\n\
* Windows: silent write to Credential Manager.\n\
* Linux: NOT supported in this build (workspace ships `keyring` without\n\
Linux-native features to avoid the libdbus build dep).\n\
- `systemd`: marker-only opt-in; the credential MUST be provisioned out-of-band\n\
via `LoadCredentialEncrypted=freenet-kek:/path` on the unit.\n\
The node will fail to start if the credential is not present.\n\
- `file`: writes `node_kek` (0o600) to the secrets dir. Weakest option;\n\
operator must protect the directory.\n\
Re-run with --yes to proceed.",
args.secrets_dir.display()
);
bail!("aborted (no --yes)");
}
std::fs::create_dir_all(&args.secrets_dir).with_context(|| {
format!(
"failed to create secrets dir {}",
args.secrets_dir.display()
)
})?;
match target {
KekBackendKind::Systemd => {
write_backend_marker(&args.secrets_dir, target).context(
"failed to write kek_backend marker for systemd opt-in (no KEK was provisioned)",
)?;
println!(
"Opted in to `systemd` backend by writing kek_backend marker. \
The KEK itself MUST be provisioned by the service manager via \
`LoadCredentialEncrypted=freenet-kek:/path` on the freenet unit. \
The node will fail to start if the credential is absent."
);
Ok(())
}
KekBackendKind::Keyring | KekBackendKind::File => {
let backend = build_backend_for(target, &args.secrets_dir).with_context(|| {
format!(
"failed to construct `{target}` backend handle (is it available on this \
platform?)"
)
})?;
let mut kek_bytes = Zeroizing::new([0u8; KEK_SIZE]);
OsRng.fill_bytes(kek_bytes.as_mut_slice());
backend.store(&kek_bytes).with_context(|| {
format!(
"failed to store KEK in `{target}` backend (existing entry? delete first \
via OS tools)"
)
})?;
if let Err(marker_err) = write_backend_marker(&args.secrets_dir, target) {
let scrub = backend.delete();
let scrub_msg = match scrub {
Ok(()) => format!(
"Rolled back the orphaned KEK in `{target}` backend; safe to re-run."
),
Err(e) => format!(
"Marker write failed AND rollback of the stored KEK in `{target}` \
backend also failed ({e}); scrub the entry manually before re-running."
),
};
return Err(anyhow!(
"failed to write kek_backend marker after provisioning: {marker_err}. {scrub_msg}"
));
}
let mut hasher = Sha256::new();
hasher.update(kek_bytes.as_slice());
let fp = hasher.finalize();
let fp_hex: String = fp[..8].iter().map(|b| format!("{b:02x}")).collect();
println!("Provisioned KEK into `{target}` backend.");
println!("KEK fingerprint (SHA-256[..8]): {fp_hex}");
Ok(())
}
}
}
async fn kek_status(args: KekStatusArgs) -> Result<()> {
let kind = match read_backend_marker(&args.secrets_dir)
.context("failed to read kek_backend marker")?
{
Some(k) => k,
None => {
println!("KEK backend: <not provisioned yet — first node start will resolve>");
return Ok(());
}
};
println!("KEK backend: {kind}");
let kek = load_from_backend(kind, &args.secrets_dir)
.with_context(|| format!("failed to load KEK from `{kind}` backend"))?;
let mut hasher = Sha256::new();
hasher.update(kek.as_slice());
let fp = hasher.finalize();
let fp_hex: String = fp[..8].iter().map(|b| format!("{b:02x}")).collect();
println!("KEK fingerprint (SHA-256[..8]): {fp_hex}");
Ok(())
}
async fn kek_rotate(_args: KekRotateArgs) -> Result<()> {
bail!(
"kek-rotate on-disk walk not yet implemented in this build. \
Operators wanting to rotate today should: stop the node, run \
`freenet secrets kek-migrate --to file --yes` (or to a new backend), \
move the existing secrets dir aside, start the node fresh, and \
re-upload delegate secrets via clients. Tracked under #4137."
);
}
async fn kek_migrate(args: KekMigrateArgs) -> Result<()> {
let target = parse_backend_kind(&args.to)?;
let current = read_backend_marker(&args.secrets_dir)
.context("failed to read kek_backend marker")?
.ok_or_else(|| {
anyhow!(
"no KEK provisioned yet (kek_backend marker missing); \
start the node once to provision before migrating"
)
})?;
if current == target {
println!("KEK is already on the `{target}` backend; nothing to do.");
return Ok(());
}
if !args.yes {
eprintln!(
"About to migrate KEK from `{current}` to `{target}` in {}.",
args.secrets_dir.display()
);
eprintln!(
"Stop the freenet node before running this command. \
Re-run with --yes to proceed."
);
bail!("aborted (no --yes)");
}
let src_backend =
build_backend_for(current, &args.secrets_dir).context("failed to open source backend")?;
let kek = src_backend
.load()
.context("failed to load KEK from source backend")?
.ok_or_else(|| anyhow!("source backend reports no KEK present"))?;
let dst_backend =
build_backend_for(target, &args.secrets_dir).context("failed to open target backend")?;
dst_backend
.store(&kek)
.context("failed to store KEK in target backend (existing entry? delete first)")?;
replace_backend_marker(&args.secrets_dir, target)
.context("failed to atomically rewrite kek_backend marker to point at target")?;
if let Err(e) = src_backend.delete() {
eprintln!(
"WARNING: failed to delete KEK from `{current}` backend after migration: {e}\n\
The migration succeeded (target has the KEK + marker points there), but the \
stale source entry should be removed manually."
);
}
println!("Migrated KEK from `{current}` to `{target}`.");
Ok(())
}
fn format_ts(timestamp_ms: u64) -> String {
DateTime::<Utc>::from_timestamp_millis(timestamp_ms as i64)
.map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.unwrap_or_else(|| format!("{timestamp_ms}ms"))
}
fn validate_component(label: &str, value: &str) -> Result<()> {
use std::path::Component;
let mut comps = std::path::Path::new(value).components();
match (comps.next(), comps.next()) {
(Some(Component::Normal(c)), None) if c == std::ffi::OsStr::new(value) => Ok(()),
_ => bail!(
"invalid {label} `{value}`: must be a single path component with no `/`, `\\`, or \
`..` — copy the exact value shown by `freenet secrets snapshot-list`"
),
}
}
fn subdir_names(dir: &std::path::Path) -> Result<Vec<String>> {
let mut out = Vec::new();
for entry in fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? {
let entry = entry?;
if entry.file_type().is_ok_and(|ft| ft.is_dir())
&& let Some(name) = entry.file_name().to_str()
{
out.push(name.to_string());
}
}
out.sort();
Ok(out)
}
async fn snapshot_list(args: SnapshotListArgs) -> Result<()> {
if !args.secrets_dir.exists() {
bail!("secrets dir {} does not exist", args.secrets_dir.display());
}
if let Some(d) = &args.delegate {
validate_component("delegate", d)?;
}
if let Some(s) = &args.secret {
validate_component("secret", s)?;
}
if let Some(secret) = &args.secret {
let delegate = args
.delegate
.as_ref()
.expect("clap enforces --secret requires --delegate");
let delegate_dir = args.secrets_dir.join(delegate);
if !delegate_dir.is_dir() {
bail!(
"no delegate directory `{delegate}` under {}",
args.secrets_dir.display()
);
}
let snap_dir = snapshot_dir_for_encoded(&delegate_dir, secret);
let snaps = list_snapshots(&snap_dir)
.with_context(|| format!("failed to list snapshots in {}", snap_dir.display()))?;
println!("Delegate {delegate}");
println!("Secret {secret}");
if snaps.is_empty() {
println!(" (no snapshot history)");
return Ok(());
}
println!(
" {:>16} {:<20} {:>9} suffix",
"timestamp_ms", "utc", "bytes"
);
for s in &snaps {
let suffix = s
.suffix
.map(|x| x.to_string())
.unwrap_or_else(|| "-".to_string());
println!(
" {:>16} {:<20} {:>9} {suffix}",
s.timestamp_ms,
format_ts(s.timestamp_ms),
s.size_bytes,
);
}
println!(" {} snapshot(s)", snaps.len());
return Ok(());
}
let delegates = match &args.delegate {
Some(d) => {
if !args.secrets_dir.join(d).is_dir() {
bail!(
"no delegate directory `{d}` under {}",
args.secrets_dir.display()
);
}
vec![d.clone()]
}
None => subdir_names(&args.secrets_dir)?,
};
if delegates.is_empty() {
println!(
"No delegate directories found under {}",
args.secrets_dir.display()
);
return Ok(());
}
println!("Secrets dir: {}", args.secrets_dir.display());
for delegate in &delegates {
let delegate_dir = args.secrets_dir.join(delegate);
let snapshots_root = delegate_dir.join(".snapshots");
println!("Delegate {delegate}");
let secrets = if snapshots_root.is_dir() {
subdir_names(&snapshots_root)?
} else {
Vec::new()
};
let mut any = false;
for secret in &secrets {
let snap_dir = snapshot_dir_for_encoded(&delegate_dir, secret);
let snaps = list_snapshots(&snap_dir)
.with_context(|| format!("failed to list snapshots in {}", snap_dir.display()))?;
if snaps.is_empty() {
continue;
}
any = true;
let latest = snaps
.last()
.map(|s| format_ts(s.timestamp_ms))
.unwrap_or_default();
println!(
" Secret {secret}: {} snapshot(s), latest {latest}",
snaps.len()
);
}
if !any {
println!(" (no snapshot history)");
}
}
Ok(())
}
async fn snapshot_restore(args: SnapshotRestoreArgs) -> Result<()> {
validate_component("delegate", &args.delegate)?;
validate_component("secret", &args.secret)?;
let delegate_dir = args.secrets_dir.join(&args.delegate);
let snap_dir = snapshot_dir_for_encoded(&delegate_dir, &args.secret);
if !snap_dir.is_dir() {
bail!(
"no snapshot history for secret `{}` of delegate `{}` under {} \
(run `freenet secrets snapshot-list` to see what's available)",
args.secret,
args.delegate,
args.secrets_dir.display()
);
}
let selector = match args.suffix {
Some(s) => format!("timestamp_ms={} suffix={s}", args.timestamp_ms),
None => format!("timestamp_ms={}", args.timestamp_ms),
};
if !args.yes {
eprintln!(
"About to restore secret `{}` of delegate `{}` to the snapshot at \
{selector} in {}.\n\
- The current value is snapshotted first, so this is reversible.\n\
- The freenet node MUST be stopped (a running node may concurrently \
write this secret).\n\
Re-run with --yes to proceed.",
args.secret,
args.delegate,
args.secrets_dir.display()
);
bail!("aborted (no --yes)");
}
match restore_snapshot_file(
&delegate_dir,
&args.secret,
args.timestamp_ms,
args.suffix,
true,
) {
Ok(()) => {
let snap_dir = snapshot_dir_for_encoded(&delegate_dir, &args.secret);
thin_snapshots(
&snap_dir,
&RetentionPolicy::default(),
std::time::SystemTime::now(),
);
println!(
"Restored secret `{}` of delegate `{}` to snapshot {selector}.",
args.secret, args.delegate
);
Ok(())
}
Err(RestoreError::NotFound(_)) => bail!(
"no snapshot at {selector} for secret `{}` of delegate `{}`. Run \
`freenet secrets snapshot-list --secrets-dir {} --delegate {} --secret {}` to see \
available timestamps (and suffixes).",
args.secret,
args.delegate,
args.secrets_dir.display(),
args.delegate,
args.secret
),
Err(RestoreError::Io(e)) => Err(anyhow::Error::new(e).context("snapshot restore failed")),
}
}
async fn open_store(
secrets_dir: &std::path::Path,
db_dir: &std::path::Path,
) -> Result<SecretsStore> {
if !secrets_dir.exists() {
bail!("secrets dir {} does not exist", secrets_dir.display());
}
let db = Storage::new(db_dir)
.await
.with_context(|| format!("failed to open secrets index db under {}", db_dir.display()))?;
let secrets = Secrets::load_for_secrets_dir(secrets_dir)
.with_context(|| format!("failed to load node secrets from {}", secrets_dir.display()))?;
SecretsStore::new(secrets_dir.to_path_buf(), secrets, db)
.map_err(|e| anyhow!("failed to open secrets store: {e}"))
}
fn map_export_err(e: ExportError) -> anyhow::Error {
if matches!(e, ExportError::AuthFailed) {
return anyhow!(
"bundle authentication failed: wrong passphrase/token, mismatched key method \
(passphrase vs token), or a corrupt bundle. No secrets were written."
);
}
anyhow::Error::new(e)
}
fn resolve_secret(
flag: Option<&str>,
env_var: Option<&str>,
label: &str,
hidden: bool,
) -> Result<Zeroizing<String>> {
use std::io::IsTerminal;
if let Some(env_var) = env_var
&& let Some(v) = std::env::var_os(env_var)
&& !v.is_empty()
{
let s = v
.into_string()
.map_err(|_| anyhow!("{env_var} is not valid UTF-8"))?;
return Ok(Zeroizing::new(s));
}
if let Some(flag) = flag
&& !flag.is_empty()
{
return Ok(Zeroizing::new(flag.to_string()));
}
if std::io::stdin().is_terminal() {
let prompt = format!("Enter {label}: ");
let value = if hidden {
rpassword::prompt_password(&prompt)
.with_context(|| format!("failed to read {label} from the terminal"))?
} else {
use std::io::Write as _;
eprint!("{prompt}");
std::io::stderr().flush().ok();
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.with_context(|| format!("failed to read {label} from the terminal"))?;
line.trim_end_matches(['\r', '\n']).to_string()
};
if value.is_empty() {
bail!("empty {label}; aborting");
}
return Ok(Zeroizing::new(value));
}
let env_hint = match env_var {
Some(e) => format!("set {e}, "),
None => String::new(),
};
bail!(
"no {label} provided: {env_hint}pass the corresponding flag, or run interactively \
(stdin is not a TTY, so there is nothing to prompt)"
)
}
const ENV_PASSPHRASE: &str = "FREENET_SECRET_PASSPHRASE";
const ENV_USER_TOKEN: &str = "FREENET_USER_TOKEN";
async fn secrets_export(args: ExportArgs) -> Result<()> {
if !args.local && args.user_token.is_none() {
bail!("specify the source scope: --local (all single-user secrets) or --user-token <tok>");
}
let db_file = args.db_dir.join("db");
if !db_file.exists() {
bail!(
"no node database found at {} — is --db-dir correct and is the node initialized? \
(export refuses to run against a missing/empty database to avoid emitting an empty bundle)",
db_file.display()
);
}
let store = open_store(&args.secrets_dir, &args.db_dir).await?;
let user_token: Option<Zeroizing<String>> = if args.local {
None
} else {
Some(resolve_secret(
args.user_token.as_deref(),
Some(ENV_USER_TOKEN),
"user token",
false,
)?)
};
let user_ctx = user_token
.as_ref()
.map(|t| UserSecretContext::from_token(t.as_bytes()));
let scope = if args.local {
SecretScope::Local
} else {
user_ctx
.as_ref()
.expect("user scope selected => context built")
.scope()
};
let passphrase: Option<Zeroizing<String>> = if args.use_token_key {
None
} else {
Some(resolve_secret(
args.passphrase.as_deref(),
Some(ENV_PASSPHRASE),
"passphrase",
true,
)?)
};
let material = if let Some(pass) = passphrase.as_ref() {
BundleKeyMaterial::Passphrase(pass.as_bytes())
} else {
BundleKeyMaterial::Token(
user_token
.as_ref()
.expect("--use-token-key requires --user-token")
.as_bytes(),
)
};
eprintln!(
"Note: building the bundle decrypts every secret in this node's memory. \
If this node is operated by someone other than you (hosted mode), they \
can observe the plaintext during this export. The bundle FILE is always \
encrypted at rest."
);
let bundle = export_bundle(&store, scope, &material).map_err(map_export_err)?;
write_bundle_file(&args.out, &bundle).map_err(|e| {
if let ExportError::Io(io) = &e
&& io.kind() == std::io::ErrorKind::AlreadyExists
{
return anyhow!(
"refusing to overwrite existing file {}: choose a fresh --out path",
args.out.display()
);
}
anyhow::Error::new(e).context("failed to write bundle file")
})?;
println!(
"Wrote encrypted secrets bundle to {} ({} bytes).",
args.out.display(),
bundle.len()
);
Ok(())
}
async fn secrets_import(args: ImportArgs) -> Result<()> {
let bundle = fs::read(&args.bundle)
.with_context(|| format!("failed to read bundle file {}", args.bundle.display()))?;
let mut store = open_store(&args.secrets_dir, &args.db_dir).await?;
let secret = if args.use_token_key {
resolve_secret(
args.token.as_deref(),
Some(ENV_USER_TOKEN),
"user token",
false,
)?
} else {
resolve_secret(
args.passphrase.as_deref(),
Some(ENV_PASSPHRASE),
"passphrase",
true,
)?
};
let material = if args.use_token_key {
BundleKeyMaterial::Token(secret.as_bytes())
} else {
BundleKeyMaterial::Passphrase(secret.as_bytes())
};
let into_user_token: Option<Zeroizing<String>> = if args.into_user.is_some() {
Some(resolve_secret(
args.into_user.as_deref(),
None,
"target user token (--into-user)",
false,
)?)
} else {
None
};
let target = match into_user_token.as_ref() {
Some(tok) => TargetScope::user_from_token(tok.as_bytes()),
None => TargetScope::Local,
};
let report = import_bundle(&mut store, &bundle, &material, &target, args.overwrite)
.map_err(map_export_err)?;
println!("Imported {} secret(s).", report.imported);
if !report.skipped.is_empty() {
println!(
"Skipped {} secret(s) that already existed (re-run with --overwrite to replace):",
report.skipped.len()
);
for (delegate, secret) in &report.skipped {
println!(" delegate {delegate} secret {secret}");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn args(dir: &std::path::Path, backend: &str, yes: bool) -> KekInitArgs {
KekInitArgs {
secrets_dir: dir.to_path_buf(),
backend: backend.to_string(),
yes,
}
}
#[tokio::test]
async fn parse_backend_kind_accepts_valid_and_rejects_garbage() {
assert!(matches!(
parse_backend_kind("keyring").unwrap(),
KekBackendKind::Keyring
));
assert!(matches!(
parse_backend_kind("systemd").unwrap(),
KekBackendKind::Systemd
));
assert!(matches!(
parse_backend_kind("file").unwrap(),
KekBackendKind::File
));
let err = parse_backend_kind("garbage").unwrap_err().to_string();
assert!(
err.contains("unrecognized backend"),
"expected unrecognized-backend error, got: {err}"
);
}
#[tokio::test]
async fn kek_init_refuses_when_marker_already_exists() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path()).unwrap();
write_backend_marker(dir.path(), KekBackendKind::File).expect("seed marker");
let err = kek_init(args(dir.path(), "file", true))
.await
.expect_err("must refuse when marker exists");
let msg = err.to_string();
assert!(
msg.contains("already provisioned"),
"expected already-provisioned error, got: {msg}"
);
}
#[tokio::test]
async fn kek_init_without_yes_aborts() {
let dir = tempfile::tempdir().expect("tempdir");
let err = kek_init(args(dir.path(), "file", false))
.await
.expect_err("must abort without --yes");
assert!(err.to_string().contains("aborted"));
assert!(read_backend_marker(dir.path()).unwrap().is_none());
assert!(!dir.path().join("node_kek").exists());
}
#[tokio::test]
async fn kek_init_file_backend_writes_kek_and_marker() {
let dir = tempfile::tempdir().expect("tempdir");
kek_init(args(dir.path(), "file", true))
.await
.expect("file kek-init must succeed");
assert_eq!(
read_backend_marker(dir.path()).unwrap(),
Some(KekBackendKind::File)
);
let kek_path = dir.path().join("node_kek");
let bytes = std::fs::read(&kek_path).expect("read node_kek");
assert_eq!(bytes.len(), KEK_SIZE);
let err = kek_init(args(dir.path(), "file", true))
.await
.expect_err("second kek-init must refuse");
assert!(err.to_string().contains("already provisioned"));
}
#[tokio::test]
async fn kek_init_systemd_writes_marker_only_without_storing_kek() {
let dir = tempfile::tempdir().expect("tempdir");
kek_init(args(dir.path(), "systemd", true))
.await
.expect("systemd marker-only opt-in must succeed");
assert_eq!(
read_backend_marker(dir.path()).unwrap(),
Some(KekBackendKind::Systemd)
);
assert!(
!dir.path().join("node_kek").exists(),
"systemd opt-in must not write a file-backend KEK"
);
}
fn migrate_args(dir: &std::path::Path, to: &str, yes: bool) -> KekMigrateArgs {
KekMigrateArgs {
secrets_dir: dir.to_path_buf(),
to: to.to_string(),
yes,
}
}
#[tokio::test]
async fn kek_migrate_refuses_when_marker_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let err = kek_migrate(migrate_args(dir.path(), "file", true))
.await
.expect_err("must refuse when marker is absent");
let msg = err.to_string();
assert!(
msg.contains("no KEK provisioned"),
"expected no-KEK-provisioned error, got: {msg}"
);
}
#[tokio::test]
async fn kek_migrate_short_circuits_when_current_equals_target() {
let dir = tempfile::tempdir().expect("tempdir");
write_backend_marker(dir.path(), KekBackendKind::File).expect("seed marker");
kek_migrate(migrate_args(dir.path(), "file", true))
.await
.expect("idempotent migrate must succeed");
assert_eq!(
read_backend_marker(dir.path()).unwrap(),
Some(KekBackendKind::File)
);
assert!(
!dir.path().join("node_kek").exists(),
"idempotent migrate must not touch backends"
);
}
#[tokio::test]
async fn kek_migrate_without_yes_aborts() {
let dir = tempfile::tempdir().expect("tempdir");
kek_init(args(dir.path(), "file", true))
.await
.expect("seed kek-init");
let err = kek_migrate(migrate_args(dir.path(), "systemd", false))
.await
.expect_err("must abort without --yes");
assert!(err.to_string().contains("aborted"));
assert_eq!(
read_backend_marker(dir.path()).unwrap(),
Some(KekBackendKind::File)
);
assert!(dir.path().join("node_kek").exists());
}
#[tokio::test]
async fn kek_migrate_rejects_unknown_target() {
let dir = tempfile::tempdir().expect("tempdir");
write_backend_marker(dir.path(), KekBackendKind::File).expect("seed marker");
let err = kek_migrate(migrate_args(dir.path(), "garbage", true))
.await
.expect_err("must reject unknown backend");
assert!(
err.to_string().contains("unrecognized backend"),
"expected unrecognized-backend error, got: {err}"
);
}
fn status_args(dir: &std::path::Path) -> KekStatusArgs {
KekStatusArgs {
secrets_dir: dir.to_path_buf(),
}
}
fn rotate_args(dir: &std::path::Path, yes: bool) -> KekRotateArgs {
KekRotateArgs {
secrets_dir: dir.to_path_buf(),
yes,
}
}
#[tokio::test]
async fn kek_status_succeeds_when_marker_absent() {
let dir = tempfile::tempdir().expect("tempdir");
kek_status(status_args(dir.path()))
.await
.expect("kek-status with no marker must succeed");
}
#[tokio::test]
async fn kek_status_succeeds_after_kek_init() {
let dir = tempfile::tempdir().expect("tempdir");
kek_init(args(dir.path(), "file", true))
.await
.expect("seed kek-init");
kek_status(status_args(dir.path()))
.await
.expect("kek-status after init must succeed");
}
#[tokio::test]
async fn kek_status_fails_when_marker_points_at_missing_kek() {
let dir = tempfile::tempdir().expect("tempdir");
write_backend_marker(dir.path(), KekBackendKind::File).expect("seed marker");
let err = kek_status(status_args(dir.path()))
.await
.expect_err("must error when marker points at empty backend");
let msg = format!("{err:#}");
assert!(
msg.contains("failed to load KEK"),
"expected load-failure error, got: {msg}"
);
}
#[tokio::test]
async fn kek_rotate_always_bails_with_not_implemented() {
let dir = tempfile::tempdir().expect("tempdir");
let err = kek_rotate(rotate_args(dir.path(), true))
.await
.expect_err("kek-rotate must always error");
assert!(
err.to_string().contains("not yet implemented"),
"expected not-implemented bail, got: {err}"
);
}
#[tokio::test]
async fn kek_migrate_fails_when_source_backend_empty() {
let dir = tempfile::tempdir().expect("tempdir");
write_backend_marker(dir.path(), KekBackendKind::File).expect("seed marker");
let err = kek_migrate(migrate_args(dir.path(), "systemd", true))
.await
.expect_err("must fail when source backend has no KEK");
let msg = format!("{err:#}");
assert!(
msg.contains("source backend reports no KEK present")
|| msg.contains("failed to load KEK from source backend"),
"expected source-empty error, got: {msg}"
);
assert_eq!(
read_backend_marker(dir.path()).unwrap(),
Some(KekBackendKind::File)
);
}
fn seed_snapshot(
secrets_dir: &std::path::Path,
delegate: &str,
secret: &str,
active: &[u8],
stamp: u64,
snapshot: &[u8],
) {
let delegate_dir = secrets_dir.join(delegate);
std::fs::create_dir_all(&delegate_dir).unwrap();
std::fs::write(delegate_dir.join(secret), active).unwrap();
let snap_dir = delegate_dir.join(".snapshots").join(secret);
std::fs::create_dir_all(&snap_dir).unwrap();
std::fs::write(snap_dir.join(format!("{stamp:020}")), snapshot).unwrap();
}
fn list_args(
dir: &std::path::Path,
delegate: Option<&str>,
secret: Option<&str>,
) -> SnapshotListArgs {
SnapshotListArgs {
secrets_dir: dir.to_path_buf(),
delegate: delegate.map(str::to_string),
secret: secret.map(str::to_string),
}
}
fn restore_args(
dir: &std::path::Path,
delegate: &str,
secret: &str,
timestamp_ms: u64,
yes: bool,
) -> SnapshotRestoreArgs {
SnapshotRestoreArgs {
secrets_dir: dir.to_path_buf(),
delegate: delegate.to_string(),
secret: secret.to_string(),
timestamp_ms,
suffix: None,
yes,
}
}
#[tokio::test]
async fn snapshot_list_empty_dir_succeeds() {
let dir = tempfile::tempdir().expect("tempdir");
snapshot_list(list_args(dir.path(), None, None))
.await
.expect("list on empty dir must succeed");
}
#[tokio::test]
async fn snapshot_list_missing_dir_errors() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("nope");
let err = snapshot_list(list_args(&missing, None, None))
.await
.expect_err("missing secrets dir must error");
assert!(err.to_string().contains("does not exist"));
}
#[tokio::test]
async fn snapshot_list_summary_and_detail_views_succeed() {
let dir = tempfile::tempdir().expect("tempdir");
seed_snapshot(dir.path(), "delegateA", "secretX", b"cur", 7, b"old");
snapshot_list(list_args(dir.path(), None, None))
.await
.expect("summary list ok");
snapshot_list(list_args(dir.path(), Some("delegateA"), None))
.await
.expect("single-delegate list ok");
snapshot_list(list_args(dir.path(), Some("delegateA"), Some("secretX")))
.await
.expect("detail list ok");
}
#[tokio::test]
async fn snapshot_list_unknown_delegate_errors() {
let dir = tempfile::tempdir().expect("tempdir");
let err = snapshot_list(list_args(dir.path(), Some("nope"), None))
.await
.expect_err("unknown delegate must error");
assert!(err.to_string().contains("no delegate directory"));
}
#[tokio::test]
async fn snapshot_restore_without_yes_aborts_and_preserves_active() {
let dir = tempfile::tempdir().expect("tempdir");
seed_snapshot(dir.path(), "D", "S", b"current", 42, b"old");
let err = snapshot_restore(restore_args(dir.path(), "D", "S", 42, false))
.await
.expect_err("must abort without --yes");
assert!(err.to_string().contains("aborted"));
assert_eq!(
std::fs::read(dir.path().join("D").join("S")).unwrap(),
b"current"
);
}
#[tokio::test]
async fn snapshot_restore_unknown_secret_errors() {
let dir = tempfile::tempdir().expect("tempdir");
let err = snapshot_restore(restore_args(dir.path(), "D", "missing", 1, true))
.await
.expect_err("must error when no snapshot history");
assert!(err.to_string().contains("no snapshot history"));
}
#[tokio::test]
async fn snapshot_restore_round_trip_restores_prior_value() {
let dir = tempfile::tempdir().expect("tempdir");
let stamp = (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64)
- 60_000;
seed_snapshot(dir.path(), "D", "S", b"current", stamp, b"old-value");
snapshot_restore(restore_args(dir.path(), "D", "S", stamp, true))
.await
.expect("restore must succeed");
assert_eq!(
std::fs::read(dir.path().join("D").join("S")).unwrap(),
b"old-value"
);
let snap_dir = dir.path().join("D").join(".snapshots").join("S");
let count = std::fs::read_dir(&snap_dir).unwrap().count();
assert!(count >= 2, "expected reversibility snapshot, got {count}");
}
#[tokio::test]
async fn snapshot_restore_unknown_timestamp_errors() {
let dir = tempfile::tempdir().expect("tempdir");
seed_snapshot(dir.path(), "D", "S", b"current", 100, b"old");
let err = snapshot_restore(restore_args(dir.path(), "D", "S", 999, true))
.await
.expect_err("must error on unknown timestamp");
assert!(err.to_string().contains("no snapshot at timestamp_ms=999"));
assert_eq!(
std::fs::read(dir.path().join("D").join("S")).unwrap(),
b"current"
);
}
#[tokio::test]
async fn snapshot_restore_rejects_path_traversal() {
let dir = tempfile::tempdir().expect("tempdir");
let err = snapshot_restore(restore_args(dir.path(), "../../etc", "S", 1, true))
.await
.expect_err("traversal in --delegate must be rejected");
assert!(err.to_string().contains("invalid delegate"));
let err = snapshot_restore(restore_args(dir.path(), "D", "../../etc/passwd", 1, true))
.await
.expect_err("traversal in --secret must be rejected");
assert!(err.to_string().contains("invalid secret"));
}
#[tokio::test]
async fn snapshot_list_rejects_path_traversal() {
let dir = tempfile::tempdir().expect("tempdir");
let err = snapshot_list(list_args(dir.path(), Some("../x"), None))
.await
.expect_err("traversal in --delegate must be rejected");
assert!(err.to_string().contains("invalid delegate"));
let err = snapshot_list(list_args(dir.path(), Some("D"), Some("a/b")))
.await
.expect_err("separator in --secret must be rejected");
assert!(err.to_string().contains("invalid secret"));
}
#[tokio::test]
async fn snapshot_list_detail_empty_history_succeeds() {
let dir = tempfile::tempdir().expect("tempdir");
let delegate_dir = dir.path().join("D");
std::fs::create_dir_all(&delegate_dir).unwrap();
std::fs::write(delegate_dir.join("S"), b"v").unwrap();
snapshot_list(list_args(dir.path(), Some("D"), Some("S")))
.await
.expect("detail view with no history must succeed");
}
#[test]
fn snapshot_list_secret_requires_delegate_at_parse() {
use clap::Parser;
let res = SnapshotListArgs::try_parse_from([
"snapshot-list",
"--secrets-dir",
"/tmp/x",
"--secret",
"y",
]);
assert!(
res.is_err(),
"--secret without --delegate must fail to parse"
);
}
#[tokio::test]
async fn snapshot_restore_suffix_targets_collision_entry() {
let dir = tempfile::tempdir().expect("tempdir");
let secrets_dir = dir.path();
let snap_dir = secrets_dir.join("D").join(".snapshots").join("S");
std::fs::create_dir_all(&snap_dir).unwrap();
let base = format!("{:020}", 1000u64);
std::fs::write(snap_dir.join(&base), b"unsuffixed").unwrap();
std::fs::write(snap_dir.join(format!("{base}.0")), b"suffix-zero").unwrap();
std::fs::write(snap_dir.join(format!("{base}.1")), b"suffix-one").unwrap();
std::fs::create_dir_all(secrets_dir.join("D")).unwrap();
std::fs::write(secrets_dir.join("D").join("S"), b"current").unwrap();
let mut args = restore_args(secrets_dir, "D", "S", 1000, true);
args.suffix = Some(9);
let err = snapshot_restore(args)
.await
.expect_err("missing suffix must error");
assert!(err.to_string().contains("suffix=9"));
assert_eq!(
std::fs::read(secrets_dir.join("D").join("S")).unwrap(),
b"current"
);
let mut args = restore_args(secrets_dir, "D", "S", 1000, true);
args.suffix = Some(1);
snapshot_restore(args)
.await
.expect("restore .1 must succeed");
assert_eq!(
std::fs::read(secrets_dir.join("D").join("S")).unwrap(),
b"suffix-one"
);
}
use freenet::dev_tool::SecretsStore as TestStore;
use freenet_stdlib::prelude::{Delegate, SecretsId};
use zeroize::Zeroizing as Zng;
async fn seed_local_secret(
root: &std::path::Path,
delegate_code: u8,
secret_id: &[u8],
plaintext: &[u8],
) -> (PathBuf, PathBuf) {
let secrets_dir = root.join("secrets");
let db_dir = root.join("data");
std::fs::create_dir_all(&secrets_dir).unwrap();
std::fs::create_dir_all(&db_dir).unwrap();
{
let db = Storage::new(&db_dir).await.unwrap();
let secrets = Secrets::load_for_secrets_dir(&secrets_dir).unwrap();
let mut store = TestStore::new(secrets_dir.clone(), secrets, db).unwrap();
let d = Delegate::from((&vec![delegate_code].into(), &vec![].into()));
let s = SecretsId::new(secret_id.to_vec());
store
.store_secret(
d.key(),
&s,
SecretScope::Local,
Zng::new(plaintext.to_vec()),
)
.unwrap();
}
(secrets_dir, db_dir)
}
fn export_args(
secrets_dir: &std::path::Path,
db_dir: &std::path::Path,
out: &std::path::Path,
passphrase: &str,
) -> ExportArgs {
ExportArgs {
secrets_dir: secrets_dir.to_path_buf(),
db_dir: db_dir.to_path_buf(),
local: true,
user_token: None,
passphrase: Some(passphrase.to_string()),
use_token_key: false,
out: out.to_path_buf(),
}
}
fn import_args(
bundle: &std::path::Path,
secrets_dir: &std::path::Path,
db_dir: &std::path::Path,
passphrase: &str,
) -> ImportArgs {
ImportArgs {
bundle: bundle.to_path_buf(),
secrets_dir: secrets_dir.to_path_buf(),
db_dir: db_dir.to_path_buf(),
passphrase: Some(passphrase.to_string()),
use_token_key: false,
token: None,
local: true,
into_user: None,
overwrite: false,
}
}
#[tokio::test]
async fn cli_export_then_import_round_trip() {
let src = tempfile::tempdir().unwrap();
let (secrets_dir, db_dir) =
seed_local_secret(src.path(), 1, b"cli-secret", b"cli-plaintext").await;
let bundle_path = src.path().join("bundle.bin");
secrets_export(export_args(&secrets_dir, &db_dir, &bundle_path, "pw"))
.await
.expect("export must succeed");
assert!(bundle_path.exists(), "bundle file written");
let bytes = std::fs::read(&bundle_path).unwrap();
assert!(
!bytes
.windows(b"cli-plaintext".len())
.any(|w| w == b"cli-plaintext"),
"bundle must not contain plaintext at rest"
);
let dst = tempfile::tempdir().unwrap();
let dst_secrets = dst.path().join("secrets");
let dst_db = dst.path().join("data");
std::fs::create_dir_all(&dst_secrets).unwrap();
std::fs::create_dir_all(&dst_db).unwrap();
secrets_import(import_args(&bundle_path, &dst_secrets, &dst_db, "pw"))
.await
.expect("import must succeed");
let db = Storage::new(&dst_db).await.unwrap();
let secrets = Secrets::load_for_secrets_dir(&dst_secrets).unwrap();
let store = TestStore::new(dst_secrets.clone(), secrets, db).unwrap();
let d = Delegate::from((&vec![1u8].into(), &vec![].into()));
let s = SecretsId::new(b"cli-secret".to_vec());
let got = store
.get_secret(d.key(), &s, SecretScope::Local)
.expect("imported secret present");
assert_eq!(got.to_vec(), b"cli-plaintext");
}
#[tokio::test]
async fn cli_export_refuses_to_overwrite_out() {
let src = tempfile::tempdir().unwrap();
let (secrets_dir, db_dir) = seed_local_secret(src.path(), 2, b"s", b"p").await;
let out = src.path().join("exists.bin");
std::fs::write(&out, b"prior").unwrap();
let err = secrets_export(export_args(&secrets_dir, &db_dir, &out, "pw"))
.await
.expect_err("must refuse to overwrite an existing --out");
assert!(
err.to_string().contains("refusing to overwrite"),
"got: {err}"
);
assert_eq!(std::fs::read(&out).unwrap(), b"prior");
}
#[tokio::test]
async fn cli_import_wrong_passphrase_fails_no_write() {
let src = tempfile::tempdir().unwrap();
let (secrets_dir, db_dir) = seed_local_secret(src.path(), 3, b"s", b"p").await;
let bundle_path = src.path().join("b.bin");
secrets_export(export_args(&secrets_dir, &db_dir, &bundle_path, "right"))
.await
.expect("export");
let dst = tempfile::tempdir().unwrap();
let dst_secrets = dst.path().join("secrets");
let dst_db = dst.path().join("data");
std::fs::create_dir_all(&dst_secrets).unwrap();
std::fs::create_dir_all(&dst_db).unwrap();
let err = secrets_import(import_args(&bundle_path, &dst_secrets, &dst_db, "wrong"))
.await
.expect_err("wrong passphrase must fail");
assert!(
err.to_string().contains("authentication failed"),
"got: {err}"
);
let db = Storage::new(&dst_db).await.unwrap();
let secrets = Secrets::load_for_secrets_dir(&dst_secrets).unwrap();
let store = TestStore::new(dst_secrets.clone(), secrets, db).unwrap();
assert!(
store
.export_scope_entries(SecretScope::Local)
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn cli_export_requires_scope_and_key() {
let src = tempfile::tempdir().unwrap();
let (secrets_dir, db_dir) = seed_local_secret(src.path(), 4, b"s", b"p").await;
let out = src.path().join("o.bin");
let mut a = export_args(&secrets_dir, &db_dir, &out, "pw");
a.local = false;
let err = secrets_export(a).await.expect_err("no scope must error");
assert!(err.to_string().contains("source scope"), "got: {err}");
unsafe {
std::env::remove_var(ENV_PASSPHRASE);
}
let mut a = export_args(&secrets_dir, &db_dir, &out, "pw");
a.passphrase = None;
let err = secrets_export(a)
.await
.expect_err("no passphrase source must error");
assert!(
err.to_string().contains("no passphrase provided"),
"got: {err}"
);
}
#[tokio::test]
async fn cli_export_refuses_missing_db() {
let src = tempfile::tempdir().unwrap();
let secrets_dir = src.path().join("secrets");
let db_dir = src.path().join("data"); std::fs::create_dir_all(&secrets_dir).unwrap();
std::fs::create_dir_all(&db_dir).unwrap();
let out = src.path().join("o.bin");
let err = secrets_export(export_args(&secrets_dir, &db_dir, &out, "pw"))
.await
.expect_err("missing db must error");
assert!(
err.to_string().contains("no node database found"),
"got: {err}"
);
assert!(
!out.exists(),
"no bundle should be written on the error path"
);
}
#[test]
fn resolve_secret_prefers_env_over_flag() {
let env_name = "FREENET_TEST_RESOLVE_PREF";
unsafe {
std::env::set_var(env_name, "from-env");
}
let got = resolve_secret(Some("from-flag"), Some(env_name), "thing", false)
.expect("env source must resolve");
assert_eq!(&*got, "from-env", "env must win over the flag");
unsafe {
std::env::remove_var(env_name);
}
}
#[test]
fn resolve_secret_falls_back_to_flag() {
let env_name = "FREENET_TEST_RESOLVE_FLAG";
unsafe {
std::env::remove_var(env_name);
}
let got = resolve_secret(Some("flag-value"), Some(env_name), "thing", false)
.expect("flag source must resolve");
assert_eq!(&*got, "flag-value");
}
#[test]
fn resolve_secret_errors_when_no_source_in_non_interactive() {
let env_name = "FREENET_TEST_RESOLVE_MISSING";
unsafe {
std::env::remove_var(env_name);
}
let err = resolve_secret(None, Some(env_name), "passphrase", true)
.expect_err("no source in non-interactive mode must error");
let msg = err.to_string();
assert!(
msg.contains("no passphrase provided") && msg.contains(env_name),
"error should name the secret and the env var, got: {msg}"
);
}
}