server/cli/src/secure_update.rs
//! Secure Update Gateway offline operations: verified bundle import (with
//! anti-rollback), integrity scrub, backup/restore, and audit-segment verify.
//!
//! All of these reuse the network-free `holger-update-bundle` crate. Import and
//! scrub key off the target RON config + a raw repository name (whose
//! `ron_data_dir` is the served bundle store); backup packages that store into a
//! single verifiable znippy archive.
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::{Args, Subcommand};
use update_bundle::container::{load_bundle, pack_bundle};
use update_bundle::digest::sha256_hex;
use update_bundle::signer::sign_pkcs8;
use update_bundle::verify::{
verify_detached_by_trusted_signer, verify_member_map_with_time, SignerIdentity, TargetSpec,
};
use update_bundle::{MemberMap, TrustPolicy};
// ───────────────────────── the estate's clock ─────────────────────────
/// **The bundle verifier's time source: the ESTATE's clock** — the one the
/// import door uses too (`server_lib::import_door::EstateTime`), so the CLI and
/// the box answer from one clock with one set of rules.
pub use server_lib::import_door::EstateTime;
/// **Ask the clock before a verdict**: this CLI is its own process, so the
/// running server's view is not here. The source is the medium's / the site's
/// `ESTATE_CLOCK` (+ `ESTATE_CLOCK_KEY`) — on an airgapped downstream a
/// site-local `estate-clock run` — and, on a paired box (feature `paired`), the
/// kept feed (`<root>/pairing/joined.toml`, the front's `clock_peer` +
/// `clock_key`). One measuring sync — the machine's clock is never moved from a
/// CLI — and its line on stderr. Nothing configured: nothing asked.
pub(crate) fn establish_time() {
#[cfg(feature = "paired")]
{
// The kept feed is SEALED at rest (lane G5): read through the box's own reader.
if let Some(reply) = crate::paired::kept_reply(&crate::paired::appliance_root()) {
server_lib::clock::set_feed(&reply.feed);
}
}
let config = server_lib::clock::config_now();
if matches!(config, Ok(server_lib::clock::Config::Sources { .. })) {
let r = server_lib::clock::measure_once(&config, server_lib::clock::Trigger::Timer);
eprintln!("clock: {}", r.summary());
}
}
/// The downstream's expired-metadata posture, or a refusal to start. The owner
/// has not decided between ReadOnly and Refuse (2026-09-20, open); the policy
/// must say which, and nothing here picks one.
fn require_expired_posture(policy: &TrustPolicy) -> Result<update_bundle::policy::ExpiredPosture> {
policy
.expired_posture()
.map_err(|e| anyhow::anyhow!("[{}] {e}", e.code().code))
}
// ───────────────────────── CLI arg types ─────────────────────────
#[derive(Args)]
pub struct BundleArgs {
#[command(subcommand)]
pub op: BundleOp,
}
#[derive(Subcommand)]
pub enum BundleOp {
/// Verify + import a signed update bundle into the protected data root
/// (anti-rollback enforced; atomic; audited). There is no --force bypass.
Import(ImportArgs),
/// ADMIN, on the secure side (lane HOL-CDS, NCSC gap 2): authorize the FIRST import of
/// one vendor's product at one sequence, so that bundle may establish the absent
/// anti-rollback floor. Audited; used once by the import it names (the CLI, the
/// network doors or the `bundle.inbox` drop). Refused when a floor already stands.
AllowFirstImport(AllowFirstImportArgs),
}
#[derive(Args)]
pub struct AllowFirstImportArgs {
/// The Holger RON config (its data root and audit sink).
#[arg(long)]
pub config: PathBuf,
/// The bundle's vendor (its `supplier`).
#[arg(long, required_unless_present = "from_bundle")]
pub vendor: Option<String>,
/// The bundle's product.
#[arg(long, required_unless_present = "from_bundle")]
pub product: Option<String>,
/// The bundle's sequence: the one that establishes the floor.
#[arg(long, required_unless_present = "from_bundle")]
pub sequence: Option<u64>,
/// Take vendor, product and sequence from this bundle, verified first under `--policy`
/// (or the config's `promotion.intake.trust_policy`, then `intel.trust_policy`).
#[arg(long, conflicts_with_all = ["vendor", "product", "sequence"])]
pub from_bundle: Option<PathBuf>,
#[arg(long)]
pub policy: Option<PathBuf>,
}
#[derive(Args)]
pub struct ImportArgs {
/// Target Holger RON config (for the audit sink + the store location).
#[arg(long)]
pub config: PathBuf,
/// REMOVED (lane W4): this wrote the bundle straight into a raw repository's
/// data directory, past the intake chain. Naming it is refused by name
/// (`holger-import-1`); every bundle now lands through intake.
#[arg(long, hide = true)]
pub repo: Option<String>,
/// The `.holger.znippy` bundle to import.
#[arg(long)]
pub bundle: PathBuf,
/// Trust policy (RON). Default: `promotion.intake.trust_policy` from the config.
#[arg(long)]
pub policy: Option<PathBuf>,
#[arg(long)]
pub target_product: Option<String>,
#[arg(long)]
pub target_model: Option<String>,
#[arg(long)]
pub target_revision: Option<String>,
/// H4: when the bundle carries a sync manifest and it is applied, copy the
/// signed ack here (the stick). It is always kept in the sync outbox too.
#[arg(long)]
pub ack_out: Option<PathBuf>,
}
#[derive(Args)]
pub struct ScrubArgs {
#[arg(long)]
pub config: PathBuf,
#[arg(long)]
pub repo: String,
/// Trust policy to re-verify each installed bundle against.
#[arg(long)]
pub policy: PathBuf,
}
#[derive(Args)]
pub struct BackupArgs {
#[command(subcommand)]
pub op: BackupOp,
}
#[derive(Subcommand)]
pub enum BackupOp {
/// Package the protected store (bundles + anti-rollback state + receipts) into
/// a single verifiable `.znippy` backup. Private keys and inline secrets are
/// never included.
Create(BackupCreateArgs),
/// Verify a backup archive offline (detects a single changed byte).
Verify(BackupVerifyArgs),
/// Restore a backup into an EMPTY data root (verify-first, atomic activate).
Restore(BackupRestoreArgs),
}
#[derive(Args)]
pub struct BackupCreateArgs {
#[arg(long)]
pub config: PathBuf,
#[arg(long)]
pub repo: String,
#[arg(long)]
pub output: PathBuf,
/// X.509 signer certificate (DER) whose key signs the backup manifest.
#[arg(long)]
pub signer_cert: Option<PathBuf>,
/// ed25519 private key (PKCS#8 DER) for `--signer-cert`. Never written into
/// the backup and never printed.
#[arg(long)]
pub signer_key: Option<PathBuf>,
/// Produce a backup with NO authenticity layer: anyone who can write the file
/// can rewrite its contents and its hashes together, and `restore` cannot tell.
#[arg(long)]
pub allow_unauthenticated_backup: bool,
}
#[derive(Args)]
pub struct BackupVerifyArgs {
#[arg(long)]
pub input: PathBuf,
/// Trust policy (RON) the backup manifest's signer is authorized against.
#[arg(long)]
pub policy: Option<PathBuf>,
/// Accept a backup carrying no signature at all. The manifest then proves only
/// that the archive is internally consistent — NOT that it is the archive
/// holger wrote.
#[arg(long)]
pub allow_unauthenticated_backup: bool,
}
#[derive(Args)]
pub struct BackupRestoreArgs {
#[arg(long)]
pub input: PathBuf,
/// Empty destination store root to restore into.
#[arg(long)]
pub data_root: PathBuf,
/// Trust policy (RON) the backup manifest's signer is authorized against.
#[arg(long)]
pub policy: Option<PathBuf>,
/// Restore content whose authenticity is unproven into the data root.
#[arg(long)]
pub allow_unauthenticated_backup: bool,
}
// ───────────────────────── store layout ─────────────────────────
struct Store {
/// Where bundle files live (the raw repo's data dir; served over HTTPS).
bundles: PathBuf,
/// Under the sibling of `bundles` (everything not served).
state: PathBuf,
receipts: PathBuf,
}
fn resolve_store(config: &Path, repo: &str) -> Result<Store> {
let holger = server_lib::read_ron_config(config)
.with_context(|| format!("load config {}", config.display()))?;
let r = holger
.repositories
.iter()
.find(|r| r.ron_name == repo)
.ok_or_else(|| anyhow::anyhow!("repository {repo:?} not found in config"))?;
let bundles = r.ron_data_dir.as_ref().map(PathBuf::from).ok_or_else(|| {
anyhow::anyhow!("repository {repo:?} has no ron_data_dir (must be a raw store)")
})?;
let root = bundles
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| bundles.clone());
Ok(Store {
state: root.join("holger-update-state"),
receipts: root.join("holger-import-receipts"),
bundles,
})
}
// ───────────────────────── bundle import ─────────────────────────
pub fn run_bundle(a: BundleArgs) -> Result<()> {
match a.op {
BundleOp::Import(args) => run_import(args),
BundleOp::AllowFirstImport(args) => run_allow_first_import(args),
}
}
/// `bundle allow-first-import`: the admin's authorization, through the import door.
fn run_allow_first_import(a: AllowFirstImportArgs) -> Result<()> {
let mut holger = server_lib::read_ron_config(&a.config)
.with_context(|| format!("load config {}", a.config.display()))?;
for line in holger.banner() {
println!("{line}");
}
#[cfg(feature = "downstream")]
holger.enter_downstream_mode()?;
if holger.resolved_network() == server_lib::egress::NetworkMode::Offline {
server_lib::egress::set_offline();
}
holger.instantiate_backends()?;
let door = server_lib::import_door::ImportDoor::of(&holger)?;
let (vendor, product, sequence) = match &a.from_bundle {
None => (
a.vendor.clone().unwrap_or_default(),
a.product.clone().unwrap_or_default(),
a.sequence.unwrap_or(0),
),
Some(bundle) => {
// The bundle is verified before a field of it is believed: under `--policy`, else
// under the first of the config's policies (intake, then intel) that verifies it.
let candidates: Vec<PathBuf> = match &a.policy {
Some(p) => vec![p.clone()],
None => [
holger
.promotion
.as_ref()
.and_then(|p| p.intake.as_ref())
.and_then(|i| i.trust_policy.clone()),
holger.intel.as_ref().and_then(|i| i.trust_policy.clone()),
]
.into_iter()
.flatten()
.map(PathBuf::from)
.collect(),
};
if candidates.is_empty() {
bail!("--from-bundle verifies the bundle first: pass --policy, or set promotion.intake.trust_policy / intel.trust_policy");
}
let mut why = Vec::new();
// Verified in a verify-bundle job (lane HOL-SANDBOX-TRAIT): this process never opens it.
let mut core = None;
for policy_path in &candidates {
let policy = TrustPolicy::from_ron(
&std::fs::read_to_string(policy_path)
.with_context(|| format!("read {}", policy_path.display()))?,
)?;
match door.verified_core(bundle, &policy, "cli", "local:cli") {
Ok(c) => {
core = Some(c);
break;
}
Err(e) => why.push(format!("{}: {}", policy_path.display(), e.tagged())),
}
}
let core = core.ok_or_else(|| {
anyhow::anyhow!("{} does not verify: {}", bundle.display(), why.join("; "))
})?;
println!(
"bundle {} (verified): vendor {} product {} sequence {}",
core.bundle_id, core.supplier, core.product, core.sequence
);
(core.supplier, core.product, core.sequence)
}
};
let g = door
.allow_first_import(&vendor, &product, sequence, &os_user(), "local")
.map_err(|e| anyhow::anyhow!("{}", e.tagged()))?;
println!(
"FIRST IMPORT AUTHORIZED vendor {} product {} sequence {} by {} (used once, by the import of exactly that bundle)",
g.vendor, g.product, g.sequence, g.by
);
Ok(())
}
fn os_user() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.unwrap_or_else(|_| "local".into())
}
/// `bundle import`: the box's import door (`server_lib::import_door`), the SAME
/// code `AdminService.ImportBundle`, `POST /-/bundle/import` and the
/// `bundle.inbox` job run. Every artifact lands AVAILABLE through the intake
/// chain; the raw-directory write this verb used to do is refused by name.
pub fn run_import(a: ImportArgs) -> Result<()> {
if let Some(repo) = &a.repo {
bail!(
"{}",
server_lib::import_door::raw_bypass_refusal(repo).tagged()
);
}
let mut holger = server_lib::read_ron_config(&a.config)
.with_context(|| format!("load config {}", a.config.display()))?;
for line in holger.banner() {
println!("{line}");
}
// The secure side of an airgap: `network: Offline` switches the egress guard
// on for this process too, before the clock or anything else could dial.
// A downstream (H3) pins this process to its one upstream (or to nothing) first.
#[cfg(feature = "downstream")]
holger.enter_downstream_mode()?;
if holger.resolved_network() == server_lib::egress::NetworkMode::Offline {
server_lib::egress::set_offline();
}
holger.instantiate_backends()?;
let door = server_lib::import_door::ImportDoor::of(&holger)?;
std::fs::create_dir_all(door.data_root()).ok();
// Exclusive store lock (no concurrent import from another process).
let _guard = LockGuard::acquire(
&door.data_root().join(".holger-import.lock"),
"bundle import",
)?;
let policy = match &a.policy {
Some(p) => {
let policy = TrustPolicy::from_ron(&std::fs::read_to_string(p)?)?;
require_expired_posture(&policy)?;
Some(policy)
}
None => None,
};
let target = TargetSpec {
product: a.target_product.clone(),
model: a.target_model.clone(),
revision: a.target_revision.clone(),
};
establish_time();
let out = door
.import_bundle(&a.bundle, policy.as_ref(), &target, &os_user(), "local")
.map_err(|e| anyhow::anyhow!("{}", e.tagged()))?;
println!(
"IMPORTED ({}) {} seq {}",
out.outcome, out.bundle_id, out.sequence
);
for art in &out.artifacts {
if art.outcome == "landed" {
println!(
" LANDED {} {} in '{}' sha256 {}",
art.artifact, art.state, art.repository, art.sha256
);
} else {
println!(" ALREADY {} AVAILABLE with the same bytes", art.artifact);
}
}
for c in out.checks.iter().filter(|c| c.starts_with("scanner-sigs:")) {
println!(" {c}");
}
println!(" receipt: {}", out.receipt);
for c in &out.sync_held {
println!(" SYNC HELD {c}");
}
if let Some(ack) = &out.sync_ack {
println!(" sync ack: {ack}");
if let Some(dir) = &a.ack_out {
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
let name = std::path::Path::new(ack)
.file_name()
.ok_or_else(|| anyhow::anyhow!("the ack path {ack} names no file"))?;
let to = dir.join(name);
std::fs::copy(ack, &to).with_context(|| format!("copy the ack to {}", to.display()))?;
println!(" ACK {}", to.display());
}
} else if a.ack_out.is_some() {
println!(
" no sync ack: the bundle carries no sync manifest, or this Holger trusts no upstream"
);
}
if let Some(time) = out.checks.first() {
println!(" {time}");
}
Ok(())
}
// ───────────────────────── the import lock ─────────────────────────
//
// `Drop` removes the lock on every ordinary exit — and on none of the ones that
// matter. SIGKILL runs no destructor, so the OOM kill this store is most likely to
// suffer (a large import on a small appliance) leaves the marker behind forever.
// The old refusal could not tell the operator which case they were in: it said
// "another import is running or a previous one crashed" and left them to guess,
// which is the same as telling them to delete it.
//
// So the lock now carries its holder's IDENTITY, and the refusal states a verdict.
/// The identity written into the lock file when it is acquired.
///
/// `pid` alone is not enough: pids are recycled, and a recycled pid reads as a
/// live holder. `boot_id` settles it across a reboot (every pid from the previous
/// boot is gone, whatever `/proc` says now) and `start_ticks` — the process's own
/// start time from `/proc/<pid>/stat` — settles it within one boot, because a
/// recycled pid cannot have the original's start time.
fn lock_identity(operation: &str) -> serde_json::Value {
let pid = std::process::id();
serde_json::json!({
"pid": pid,
"boot_id": current_boot_id(),
"start_ticks": process_start_ticks(pid),
"acquired_at": chrono::Utc::now().to_rfc3339(),
"operation": operation,
"host": hostname(),
})
}
fn current_boot_id() -> Option<String> {
std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn hostname() -> Option<String> {
std::fs::read_to_string("/proc/sys/kernel/hostname")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// Field 22 (`starttime`) of `/proc/<pid>/stat`, in clock ticks since boot.
///
/// Parsed from after the LAST `)`, because field 2 is the executable name and may
/// itself contain spaces and parentheses — splitting the line from the left is the
/// classic way to read this wrong.
fn process_start_ticks(pid: u32) -> Option<u64> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let after = &stat[stat.rfind(')')? + 1..];
after.split_whitespace().nth(19)?.parse().ok()
}
/// What can be established about the process named in a lock file.
enum Holder {
/// The holder is running. Waiting is the only correct action.
Alive(String),
/// The holder is PROVABLY gone — a reboot, or no such process, or a pid that
/// has been recycled into a different process.
Gone(String),
/// The lock file is unreadable, malformed, or the liveness of its holder
/// cannot be established. **Fails closed**: treated exactly like `Alive`.
Indeterminate(String),
}
fn describe_holder(path: &Path) -> Holder {
let raw = match std::fs::read(path) {
Ok(r) => r,
Err(e) => return Holder::Indeterminate(format!("the lock file cannot be read ({e})")),
};
let doc: serde_json::Value = match serde_json::from_slice(&raw) {
Ok(d) => d,
Err(_) => {
return Holder::Indeterminate(
"the lock file is not valid holger lock JSON (an empty or truncated lock is \
what a crash BETWEEN creating and stamping it looks like)"
.into(),
)
}
};
let pid = match doc.get("pid").and_then(|v| v.as_u64()) {
Some(p) => p as u32,
None => return Holder::Indeterminate("the lock file names no pid".into()),
};
let since = doc
.get("acquired_at")
.and_then(|v| v.as_str())
.unwrap_or("an unrecorded time")
.to_string();
let operation = doc
.get("operation")
.and_then(|v| v.as_str())
.unwrap_or("an import")
.to_string();
let held = format!("pid {pid} ({operation}, held since {since})");
// A reboot retires every pid from the previous boot, whatever /proc says now.
let lock_boot = doc.get("boot_id").and_then(|v| v.as_str());
match (lock_boot, current_boot_id()) {
(Some(l), Some(n)) if l != n => {
return Holder::Gone(format!(
"{held} belongs to a PREVIOUS boot of this machine, so it cannot be running"
))
}
(Some(_), Some(_)) => {}
// Without both boot ids the reboot question is open; fall through to /proc,
// which can still prove absence, but never claim staleness on this basis.
_ => {}
}
if !Path::new(&format!("/proc/{pid}")).exists() {
// Absence of /proc entirely (a non-Linux host) must not read as "no such
// process" — that would turn every lock stale on the wrong platform.
if !Path::new("/proc/self").exists() {
return Holder::Indeterminate(format!(
"{held} cannot be checked: this system exposes no /proc"
));
}
return Holder::Gone(format!("{held} is not running — there is no /proc/{pid}"));
}
match (
doc.get("start_ticks").and_then(|v| v.as_u64()),
process_start_ticks(pid),
) {
(Some(recorded), Some(now)) if recorded != now => Holder::Gone(format!(
"{held} is gone — pid {pid} now belongs to a DIFFERENT process (it started at a \
different time), so the pid has been recycled"
)),
(Some(_), Some(_)) => Holder::Alive(held),
// The pid exists but its start time cannot be compared. It may well be the
// holder; refuse rather than guess.
_ => Holder::Indeterminate(format!(
"{held}: a process with that pid exists, but its start time could not be compared, \
so it cannot be told apart from a recycled pid"
)),
}
}
/// Exclusive advisory lock via `create_new`, stamped with its holder's identity.
#[derive(Debug)]
struct LockGuard(PathBuf);
impl LockGuard {
fn acquire(path: &Path, operation: &str) -> Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
{
Ok(mut f) => {
use std::io::Write;
let identity = serde_json::to_vec_pretty(&lock_identity(operation))?;
// A lock we cannot stamp is worse than no lock: it would be
// unreadable, and an unreadable lock fails closed FOREVER. Remove
// it and fail here, where the operator can still see why.
if let Err(e) = f.write_all(&identity).and_then(|()| f.sync_all()) {
drop(f);
let _ = std::fs::remove_file(path);
bail!("could not stamp the import lock {}: {e}", path.display());
}
Ok(LockGuard(path.to_path_buf()))
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
// WHY REFUSE RATHER THAN BREAK THE LOCK AUTOMATICALLY:
//
// Breaking it would be this process — the one that wants the lock —
// deciding that the holder of the lock is dead. That is the wrong
// party to hold that vote, and the evidence it votes on is only as
// good as a /proc read: a holder inside a different PID namespace,
// or on a host whose /proc this process does not share, is invisible
// here and would be declared gone. The cost of being wrong is two
// concurrent imports writing the protected store.
//
// The operator's actual problem was never "the lock is still there",
// it was "I cannot tell a live holder from a corpse". A definite
// verdict plus the exact command solves that completely, and costs
// one paste. So: state the verdict, name the command, change nothing.
match describe_holder(path) {
Holder::Alive(held) => bail!(
"store is locked by {held} — that process is RUNNING right now. \
Wait for it to finish; do not remove {}.",
path.display()
),
Holder::Gone(why) => bail!(
"store is locked by a holder that is PROVABLY GONE: {why}.\n\
No import is running. This lock is a corpse left by a crash or an \
OOM kill — `Drop` does not run on SIGKILL.\n\
Clear it with:\n rm {}",
path.display()
),
Holder::Indeterminate(why) => bail!(
"store is locked and holger CANNOT DETERMINE whether the holder is \
alive: {why}.\n\
Refusing: a lock whose holder cannot be established is treated as HELD, \
never as stale.\n\
Confirm by hand that no holger import is running, then remove {}.",
path.display()
),
}
}
Err(e) => Err(e.into()),
}
}
}
impl Drop for LockGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
// ───────────────────────── scrub ─────────────────────────
pub fn run_scrub(a: ScrubArgs) -> Result<()> {
let store = resolve_store(&a.config, &a.repo)?;
let policy = TrustPolicy::from_ron(&std::fs::read_to_string(&a.policy)?)?;
require_expired_posture(&policy)?;
establish_time();
let mut checked = 0usize;
let mut bad = 0usize;
let bundles = collect_bundles(&store.bundles)?;
for path in &bundles {
checked += 1;
match scrub_one(path, &policy) {
Ok(()) => println!("ok {}", path.display()),
Err(e) => {
bad += 1;
println!("FAIL {} — {e}", path.display());
}
}
}
println!("scrub: {checked} bundle(s) checked, {bad} failed");
if bad > 0 {
bail!("integrity scrub found {bad} corrupt bundle(s)");
}
Ok(())
}
fn scrub_one(path: &Path, policy: &TrustPolicy) -> Result<()> {
let members = load_bundle(path).map_err(|e| anyhow::anyhow!("{e}"))?;
verify_member_map_with_time(&members, policy, &TargetSpec::default(), None, &EstateTime)
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(())
}
fn collect_bundles(dir: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
if !dir.exists() {
return Ok(out);
}
walk(dir, &mut out)?;
out.sort();
Ok(out)
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
for de in std::fs::read_dir(dir)? {
let de = de?;
let ft = de.file_type()?;
if ft.is_symlink() {
bail!("unexpected symlink in store: {}", de.path().display());
}
if ft.is_dir() {
walk(&de.path(), out)?;
} else if de.path().to_string_lossy().ends_with(".holger.znippy") {
out.push(de.path());
}
}
Ok(())
}
// ───────────────────────── backup ─────────────────────────
pub fn run_backup(a: BackupArgs) -> Result<()> {
match a.op {
BackupOp::Create(args) => backup_create(args),
BackupOp::Verify(args) => backup_verify(args),
BackupOp::Restore(args) => backup_restore(args),
}
}
const BACKUP_MANIFEST: &str = "backup-manifest.json";
/// Detached ed25519 signature over the EXACT `backup-manifest.json` bytes.
const BACKUP_MANIFEST_SIG: &str = "backup-manifest.sig";
/// The X.509 signer certificate (DER) for [`BACKUP_MANIFEST_SIG`].
const BACKUP_MANIFEST_CRT: &str = "backup-manifest.crt";
/// The policy role a backup signer must hold (a `trusted_release_signer` is also
/// accepted, so an operator need not invent a second key to get a signed backup).
const BACKUP_SIGNER_ROLE: &str = "backup-signer";
/// The three members that carry the backup's own trust decision. They are not
/// listed in the manifest — the manifest cannot cover itself, and the signature
/// and certificate are what prove the manifest instead.
fn is_backup_control(path: &str) -> bool {
path == BACKUP_MANIFEST || path == BACKUP_MANIFEST_SIG || path == BACKUP_MANIFEST_CRT
}
/// Authenticate a backup's manifest before ANY of its member hashes are believed.
///
/// The finding this answers: the manifest lists a sha256 per member and lives
/// INSIDE the archive it describes, so anyone who can write the backup file
/// rewrites a member and its hash together and both `verify` and `restore` report
/// success. Hashes detect corruption; only a signature over those hashes, made by
/// a key the attacker does not have, detects tampering.
///
/// Returns the signer's identity, or `None` for a backup that carries no signature
/// AND whose operator has explicitly accepted that.
fn authenticate_backup_manifest(
members: &MemberMap,
policy: Option<&TrustPolicy>,
allow_unauthenticated: bool,
) -> Result<Option<SignerIdentity>> {
let manifest = members
.get(BACKUP_MANIFEST)
.ok_or_else(|| anyhow::anyhow!("not a holger backup (no {BACKUP_MANIFEST})"))?;
let sig = members.get(BACKUP_MANIFEST_SIG);
let crt = members.get(BACKUP_MANIFEST_CRT);
match (sig, crt) {
(Some(sig), Some(crt)) => {
// A signature is PRESENT: it gets checked. Without a policy there is
// nothing to authorize it against, and skipping it because the operator
// forgot a flag is precisely the silent pass this fix exists to remove.
let policy = policy.ok_or_else(|| {
anyhow::anyhow!(
"this backup is signed but no --policy was given — refusing to \
ignore a signature. Pass --policy <trust-policy.ron>."
)
})?;
let who =
verify_detached_by_trusted_signer(manifest, sig, crt, policy, BACKUP_SIGNER_ROLE)
.map_err(|e| anyhow::anyhow!("backup manifest signature: {}", e.tagged()))?;
Ok(Some(who))
}
(None, None) => {
if !allow_unauthenticated {
bail!(
"this backup carries no signature ({BACKUP_MANIFEST_SIG} / \
{BACKUP_MANIFEST_CRT} are absent), so its manifest proves only that the \
archive agrees with itself — anyone who could write the file could have \
rewritten a member and its hash together.\n\
Re-create it with --signer-cert/--signer-key, or, if you accept that this \
content's origin is unproven, pass --allow-unauthenticated-backup."
)
}
eprintln!(
"WARNING: --allow-unauthenticated-backup — this backup's contents are NOT \
authenticated. Its hashes prove only internal consistency; a tampered archive \
whose member and hash were changed together passes every check below. Do not \
restore it into a protected data root unless you trust the whole path the file \
travelled."
);
Ok(None)
}
_ => bail!(
"malformed backup: exactly one of {BACKUP_MANIFEST_SIG} / {BACKUP_MANIFEST_CRT} is \
present. A half-signed backup is refused."
),
}
}
/// Refuse any member the manifest does not list.
///
/// `restore` used to write EVERY member except the manifest into the data root, so
/// a member simply added to the archive was never hash-checked and landed in the
/// store regardless. Signing the manifest does not fix that on its own: the
/// signature covers what the manifest names, and a stowaway is by definition not
/// named.
fn refuse_unlisted_members(members: &MemberMap, listed: &[(String, String)]) -> Result<()> {
for path in members.keys() {
if is_backup_control(path) {
continue;
}
if !listed.iter().any(|(p, _)| p == path) {
bail!("backup contains {path} which its manifest does not list — refusing");
}
}
Ok(())
}
fn backup_create(a: BackupCreateArgs) -> Result<()> {
let store = resolve_store(&a.config, &a.repo)?;
let mut members = MemberMap::new();
// Collect bundles, state, and receipts under stable relative prefixes.
read_tree(&store.bundles, "bundles", &mut members)?;
read_tree(&store.state, "state", &mut members)?;
read_tree(&store.receipts, "receipts", &mut members)?;
// Manifest of hashes for every included file (never a private key — the store
// holds none; config with inline secrets is not included).
let mut manifest = serde_json::Map::new();
for (path, bytes) in &members {
manifest.insert(
path.clone(),
serde_json::json!({ "sha256": sha256_hex(bytes) }),
);
}
let manifest_bytes = serde_json::to_vec_pretty(&serde_json::json!({
"schema": "holger.backup/v1",
"files": manifest,
}))?;
let file_count = members.len();
members.insert(BACKUP_MANIFEST.to_string(), manifest_bytes.clone());
// Sign the manifest, so that the hash list is worth something against an
// attacker rather than only against a failing disk.
let signed_by = match (&a.signer_cert, &a.signer_key) {
(Some(cert_path), Some(key_path)) => {
let cert_der = std::fs::read(cert_path)
.with_context(|| format!("read signer certificate {}", cert_path.display()))?;
let key_pkcs8 = server_lib::keyfile::read_private(
std::path::Path::new(key_path),
"the signing key",
)
.with_context(|| format!("read signer key {}", key_path.display()))?;
let sig = sign_pkcs8(&key_pkcs8, &manifest_bytes)
.map_err(|e| anyhow::anyhow!("sign backup manifest: {}", e.tagged()))?;
// The private key stays in this scope and is never a member.
drop(key_pkcs8);
members.insert(BACKUP_MANIFEST_SIG.to_string(), sig);
members.insert(BACKUP_MANIFEST_CRT.to_string(), cert_der);
Some(cert_path.display().to_string())
}
(None, None) => {
if !a.allow_unauthenticated_backup {
bail!(
"refusing to write a backup with no authenticity layer. Its manifest would \
list a hash per file and live inside the archive it describes, so anyone \
who can write the file could rewrite a member and its hash together and \
`backup verify` would still say ok.\n\
Pass --signer-cert <cert.der> --signer-key <key.pk8>, or \
--allow-unauthenticated-backup to accept that."
)
}
eprintln!(
"WARNING: --allow-unauthenticated-backup — writing an UNSIGNED backup. Anyone \
who can write {} can alter its contents undetectably, and `backup restore` \
will write whatever it then contains into a data root.",
a.output.display()
);
None
}
_ => bail!("--signer-cert and --signer-key must be given together"),
};
pack_bundle(&members, &a.output).map_err(|e| anyhow::anyhow!("{e}"))?;
println!(
"backup created: {} ({file_count} files)",
a.output.display()
);
match signed_by {
Some(c) => println!(" manifest signed with: {c}"),
None => println!(" manifest: UNSIGNED (authenticity not provable)"),
}
Ok(())
}
fn backup_verify(a: BackupVerifyArgs) -> Result<()> {
let members = load_bundle(&a.input).map_err(|e| anyhow::anyhow!("{e}"))?;
let policy = load_optional_policy(a.policy.as_deref())?;
// AUTHENTICITY FIRST: the member hashes below are only worth checking once the
// document listing them is known to be the one holger signed.
let signer =
authenticate_backup_manifest(&members, policy.as_ref(), a.allow_unauthenticated_backup)?;
let (_, files) = read_backup_manifest(&members)?;
refuse_unlisted_members(&members, &files)?;
let mut bad = 0usize;
for (path, expect_sha) in &files {
match members.get(path) {
Some(bytes) if &sha256_hex(bytes) == expect_sha => {}
Some(_) => {
bad += 1;
println!("FAIL {path} — digest mismatch");
}
None => {
bad += 1;
println!("FAIL {path} — missing from backup");
}
}
}
println!("backup verify: {} file(s), {bad} failed", files.len());
match &signer {
Some(w) => println!(" manifest signed by: {} [{}]", w.subject, w.fingerprint),
None => println!(" manifest: UNSIGNED — authenticity NOT checked"),
}
if bad > 0 {
bail!("backup verification failed for {bad} file(s)");
}
Ok(())
}
/// Read a trust policy if one was given.
fn load_optional_policy(path: Option<&Path>) -> Result<Option<TrustPolicy>> {
match path {
Some(p) => {
let text = std::fs::read_to_string(p)
.with_context(|| format!("read trust policy {}", p.display()))?;
Ok(Some(TrustPolicy::from_ron(&text)?))
}
None => Ok(None),
}
}
fn backup_restore(a: BackupRestoreArgs) -> Result<()> {
// Verify before writing anything.
let members = load_bundle(&a.input).map_err(|e| anyhow::anyhow!("{e}"))?;
let policy = load_optional_policy(a.policy.as_deref())?;
// AUTHENTICATE FIRST — this command writes into the protected data root, so it
// is the one that must not act on a hash list an attacker could have rewritten.
let signer =
authenticate_backup_manifest(&members, policy.as_ref(), a.allow_unauthenticated_backup)?;
let (_, files) = read_backup_manifest(&members)?;
refuse_unlisted_members(&members, &files)?;
for (path, expect_sha) in &files {
let bytes = members
.get(path)
.ok_or_else(|| anyhow::anyhow!("backup missing {path}"))?;
if &sha256_hex(bytes) != expect_sha {
bail!("backup digest mismatch for {path} — refusing to restore");
}
}
// Restore only into an empty target (verify-first, atomic activate). The three
// control members are the backup's own trust wrapper, not store content, so
// they are not written into the data root.
let mut to_write = members.clone();
to_write.retain(|path, _| !is_backup_control(path));
update_bundle::api::write_members_to_empty_dir(&to_write, &a.data_root)
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!(
"restored {} file(s) into {}",
files.len(),
a.data_root.display()
);
match &signer {
Some(w) => println!(" manifest signed by: {} [{}]", w.subject, w.fingerprint),
None => println!(" manifest: UNSIGNED — restored content's origin is unproven"),
}
println!("NOTE: private signing/client keys and inline config secrets are NOT in the backup — restore them separately.");
Ok(())
}
fn read_backup_manifest(members: &MemberMap) -> Result<(String, Vec<(String, String)>)> {
let raw = members
.get(BACKUP_MANIFEST)
.ok_or_else(|| anyhow::anyhow!("not a holger backup (no {BACKUP_MANIFEST})"))?;
let doc: serde_json::Value = serde_json::from_slice(raw)?;
let schema = doc
.get("schema")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let mut out = Vec::new();
if let Some(files) = doc.get("files").and_then(|v| v.as_object()) {
for (path, meta) in files {
let sha = meta
.get("sha256")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("backup manifest entry {path} missing sha256"))?;
out.push((path.clone(), sha.to_string()));
}
}
Ok((schema, out))
}
/// Read a directory tree into `members` under `prefix`, rejecting symlinks.
fn read_tree(dir: &Path, prefix: &str, members: &mut MemberMap) -> Result<()> {
if !dir.exists() {
return Ok(());
}
let mut paths = Vec::new();
walk_all(dir, &mut paths)?;
for p in paths {
let rel = p
.strip_prefix(dir)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
let key = format!("{prefix}/{rel}");
members.insert(key, std::fs::read(&p)?);
}
Ok(())
}
fn walk_all(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
for de in std::fs::read_dir(dir)? {
let de = de?;
let ft = de.file_type()?;
if ft.is_symlink() {
bail!("refusing to back up a symlink: {}", de.path().display());
}
if ft.is_dir() {
walk_all(&de.path(), out)?;
} else if ft.is_file() {
out.push(de.path());
}
}
Ok(())
}
// ───────────────────────── tests ─────────────────────────
#[cfg(test)]
mod tests {
//! Two security properties the Secure Update Gateway's OFFLINE side owes an
//! operator, neither of which the code held before:
//!
//! * a lock that outlives its holder must say so, and a lock whose holder
//! cannot be established must never be called stale;
//! * a backup archive must prove WHO wrote it, not merely that it agrees with
//! itself.
//!
//! The crypto half of the second property — a real certificate, a real
//! signature, a real untrusted signer — is proven in `holger-update-bundle`'s
//! own suite, where `rcgen` is available to mint throwaway keys in-process.
//! What is provable here without a PKI is the POLICY around it, which is where
//! the silent pass lived.
use super::*;
// ── finding 2: the import lock ───────────────────────────────────────────
fn lock_path(dir: &tempfile::TempDir) -> PathBuf {
dir.path().join(".holger-import.lock")
}
#[test]
fn acquiring_the_import_lock_stamps_the_holders_identity_into_it() {
let dir = tempfile::tempdir().unwrap();
let path = lock_path(&dir);
let _guard = LockGuard::acquire(&path, "bundle import").expect("a free lock is acquirable");
let doc: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).expect("the lock must be JSON");
assert_eq!(
doc["pid"].as_u64().unwrap() as u32,
std::process::id(),
"the lock must name its holder"
);
assert!(
doc["acquired_at"].as_str().is_some_and(|s| s.contains('T')),
"the lock must carry an ISO-8601 acquisition time: {doc}"
);
assert_eq!(doc["operation"].as_str(), Some("bundle import"));
// pid alone cannot survive recycling; one of these must pin it further.
assert!(
!doc["boot_id"].is_null() || !doc["start_ticks"].is_null(),
"a pid with neither a boot id nor a start time cannot be told from a recycled \
pid: {doc}"
);
}
#[test]
fn an_import_lock_whose_holder_is_gone_names_the_command_that_clears_it() {
let dir = tempfile::tempdir().unwrap();
let path = lock_path(&dir);
// A holder from a PREVIOUS BOOT: whatever /proc says now, every pid from
// that boot is retired. This is exactly the corpse an OOM kill leaves.
let stale = serde_json::json!({
"pid": 1u32,
"boot_id": "00000000-0000-0000-0000-000000000000",
"start_ticks": 1u64,
"acquired_at": "2026-09-10T04:00:00+00:00",
"operation": "bundle import",
});
std::fs::write(&path, serde_json::to_vec_pretty(&stale).unwrap()).unwrap();
let err = LockGuard::acquire(&path, "bundle import")
.expect_err("a lock must never be taken while the file is there");
let msg = format!("{err}");
assert!(
msg.contains("PROVABLY GONE"),
"the operator must be told the holder is dead, not asked to guess: {msg}"
);
assert!(
msg.contains(&format!("rm {}", path.display())),
"the refusal must name the exact command that clears it: {msg}"
);
// Refusing is the choice; it must not have broken the lock behind our back.
assert!(path.exists(), "refusing must leave the lock in place");
}
#[test]
fn an_import_lock_whose_holder_is_this_running_process_is_refused_as_live() {
let dir = tempfile::tempdir().unwrap();
let path = lock_path(&dir);
// This very process: pid, boot id and start time all agree, because they
// are ours. No liveness check may call this a corpse.
std::fs::write(
&path,
serde_json::to_vec_pretty(&lock_identity("bundle import")).unwrap(),
)
.unwrap();
let err = LockGuard::acquire(&path, "bundle import").expect_err("a held lock is refused");
let msg = format!("{err}");
assert!(
msg.contains("RUNNING"),
"a live holder must be reported as live: {msg}"
);
assert!(
!msg.contains("PROVABLY GONE"),
"a running holder must never be reported as gone: {msg}"
);
assert!(
!msg.contains("rm "),
"holger must not invite an operator to delete a LIVE holder's lock: {msg}"
);
}
#[test]
fn a_malformed_import_lock_is_never_treated_as_stale() {
// Every shape a crash or a hostile writer can leave behind. None of them
// may resolve to "stale" — that direction fails closed.
for (name, body) in [
("empty", b"".to_vec()),
("truncated json", b"{\"pid\": 12".to_vec()),
(
"not json at all",
b"a previous holger wrote nothing useful".to_vec(),
),
(
"json without a pid",
br#"{"operation":"bundle import"}"#.to_vec(),
),
] {
let dir = tempfile::tempdir().unwrap();
let path = lock_path(&dir);
std::fs::write(&path, &body).unwrap();
let err = match LockGuard::acquire(&path, "bundle import") {
Ok(_) => panic!("[{name}] a lock file that exists is never taken"),
Err(e) => e,
};
let msg = format!("{err}");
assert!(
msg.contains("CANNOT DETERMINE"),
"[{name}] an unreadable lock must say so: {msg}"
);
assert!(
!msg.contains("PROVABLY GONE"),
"[{name}] an unreadable lock must NOT be called stale: {msg}"
);
assert!(path.exists(), "[{name}] refusing must leave the lock alone");
}
}
// ── finding 3: backup authenticity ───────────────────────────────────────
/// A backup member map with a well-formed manifest over its own files.
fn backup_members(files: &[(&str, &[u8])]) -> MemberMap {
let mut members = MemberMap::new();
let mut manifest = serde_json::Map::new();
for (path, bytes) in files {
members.insert((*path).to_string(), bytes.to_vec());
manifest.insert(
(*path).to_string(),
serde_json::json!({ "sha256": sha256_hex(bytes) }),
);
}
let manifest_bytes = serde_json::to_vec_pretty(&serde_json::json!({
"schema": "holger.backup/v1",
"files": manifest,
}))
.unwrap();
members.insert(BACKUP_MANIFEST.to_string(), manifest_bytes);
members
}
#[test]
fn an_unsigned_backup_is_refused_unless_the_operator_says_what_that_gives_up() {
let members = backup_members(&[("bundles/a.holger.znippy", b"content")]);
let err = authenticate_backup_manifest(&members, None, false)
.expect_err("an unsigned backup must not silently pass");
let msg = format!("{err}");
assert!(
msg.contains("carries no signature"),
"the refusal must say what is missing: {msg}"
);
assert!(
msg.contains("--allow-unauthenticated-backup"),
"the refusal must name the opt-out flag: {msg}"
);
// With the opt-out it proceeds — and reports no signer, so nothing
// downstream can mistake it for an authenticated backup.
let who = authenticate_backup_manifest(&members, None, true)
.expect("the explicit opt-out is honoured");
assert!(who.is_none(), "an unsigned backup has no signer to report");
}
#[test]
fn a_signed_backup_is_never_accepted_without_a_policy_to_check_it_against() {
let mut members = backup_members(&[("bundles/a.holger.znippy", b"content")]);
members.insert(BACKUP_MANIFEST_SIG.to_string(), vec![0u8; 64]);
members.insert(BACKUP_MANIFEST_CRT.to_string(), b"a certificate".to_vec());
// No policy: the signature cannot be authorized, so it must not be skipped.
let err = authenticate_backup_manifest(&members, None, false)
.expect_err("a present signature must be checked, not ignored");
assert!(
format!("{err}").contains("--policy"),
"the refusal must name what is needed: {err}"
);
// And the opt-out must NOT become a way to skip a signature that is there.
let err = authenticate_backup_manifest(&members, None, true)
.expect_err("--allow-unauthenticated-backup must not bypass a present signature");
assert!(
format!("{err}").contains("--policy"),
"the opt-out is for backups with NO signature, not for skipping one: {err}"
);
}
#[test]
fn a_half_signed_backup_is_refused() {
let mut members = backup_members(&[("bundles/a.holger.znippy", b"content")]);
members.insert(BACKUP_MANIFEST_SIG.to_string(), vec![0u8; 64]);
// Signature present, certificate absent: an attacker stripping the cert
// must not land in the "unsigned, warn and continue" path.
let err = authenticate_backup_manifest(&members, None, true)
.expect_err("a half-signed backup must be refused");
assert!(
format!("{err}").contains("half-signed"),
"the refusal must name the shape: {err}"
);
}
#[test]
fn a_backup_member_the_manifest_does_not_list_is_refused() {
let mut members = backup_members(&[("bundles/a.holger.znippy", b"content")]);
// A stowaway: never hash-checked, and `restore` used to write it straight
// into the data root.
members.insert("bundles/evil.holger.znippy".into(), b"payload".to_vec());
let (_, files) = read_backup_manifest(&members).unwrap();
let err = refuse_unlisted_members(&members, &files)
.expect_err("a member outside the manifest must be refused");
assert!(
format!("{err}").contains("bundles/evil.holger.znippy"),
"the refusal must name the stowaway: {err}"
);
}
}
#[cfg(test)]
mod estate_time_tests {
//! The bundle verifier's time source is the ESTATE's clock: named
//! `estate-clock`, UNTRUSTED until the front's signed time answered, and
//! reading that time once it has — the instant `core.expires_at`, the policy's
//! maximum bundle age and the signer certificates' windows are checked against.
use super::EstateTime;
use update_bundle::time::TrustedTime as _;
#[test]
fn the_bundle_verifier_reads_the_estate_clock_and_says_whether_it_is_verified() {
use server_lib::clock::{self, Outcome, Report, SourceReport, Trigger};
clock::reset();
assert_eq!(EstateTime.source_name(), "estate-clock");
assert!(
!EstateTime.is_trusted(),
"nothing answered: the verdict says UNTRUSTED"
);
let system = chrono::Utc::now();
assert!((EstateTime.now().unwrap() - system).num_seconds().abs() < 2);
// The front's verified answer: this box is two hours behind it.
let mut r = Report::without_sources(Trigger::Timer, Outcome::ApplyFailed, "measured");
r.offset_ms = 7_200_000;
r.sources = vec![SourceReport {
source: "peer://10.13.8.1:50123".into(),
offset_ms: Some(7_200_000),
error: None,
}];
clock::settle(&r);
assert!(EstateTime.is_trusted());
let ahead = (EstateTime.now().unwrap() - chrono::Utc::now()).num_seconds();
assert!(
(ahead - 7_200).abs() < 2,
"the verifier's now is the front's time: {ahead} s"
);
clock::reset();
}
}