From aa194d2171af2c6d060c9cab28cbdd5f6be41086 Mon Sep 17 00:00:00 2001 From: Cris Lingad Date: Fri, 29 May 2026 16:33:58 -0700 Subject: [PATCH] fix(hsm): idempotent bootstrap with audit log pre-drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate HSM-layer improvements from pterohacktyl/retry-disc-logic, adapted to current main after the TUI refactor. YubiHSM bootstrap idempotency: - Drain the audit log immediately after connecting with factory creds, before any auditable commands. A prior partial bootstrap may have left Force Audit=Fix with the log full, blocking PutAuthenticationKey and all other auditable operations. GetLogEntries/SetLogIndex are exempt from Force Audit blocking, so the drain always succeeds. - Delete any existing anodize auth key (ID 2) before re-creating it, making bootstrap safe to retry after a partial failure. - Tolerate InvalidData from set_force_audit_option and set_command_audit_option — once set to Fix, the YubiHSM firmware rejects even same-value SetOption calls. New trait method: - HsmBackend::query_bootstrap_audit_log() returns (used, capacity) for the device's audit log using factory-default credentials. YubiHSM implements it; SoftHSM returns None. Allows the TUI to detect a full log pre-bootstrap and prompt the operator. Build improvements: - Add connect-timeout (15s) and stalled-download-timeout (60s) to Docker-based nix builds to prevent hangs on flaky networks. Doc fix: - Update anodize-shuttle init.rs to reference `make list-usb` instead of the removed `anodize-shuttle lint --list-usb`. Original-branch: pterohacktyl/retry-disc-logic Co-Authored-By: Cris Lingad --- Makefile | 4 + crates/anodize-hsm/src/lib.rs | 14 +++ crates/anodize-hsm/src/yubihsm_backend.rs | 114 ++++++++++++++++++---- crates/anodize-shuttle/src/init.rs | 2 +- 4 files changed, 115 insertions(+), 19 deletions(-) diff --git a/Makefile b/Makefile index 21039a6..8909895 100644 --- a/Makefile +++ b/Makefile @@ -101,6 +101,8 @@ define nix-iso-build nix --extra-experimental-features "nix-command flakes" \ --option build-users-group "" \ --option sandbox false \ + --option connect-timeout 15 \ + --option stalled-download-timeout 60 \ $(NIX_SANDBOX_FLAG) build .#$(1) && \ rm -f /src/$(2) && cp -L result/iso/*.iso /src/$(2)' @echo "ISO ready: $(2)" @@ -585,6 +587,8 @@ define nix-build-bin nix --extra-experimental-features "nix-command flakes" \ --option build-users-group "" \ --option sandbox false \ + --option connect-timeout 15 \ + --option stalled-download-timeout 60 \ $(NIX_SANDBOX_FLAG) build .#$(1) && \ rm -f /src/$(2) && cp -L result/bin/anodize-ceremony /src/$(2)' endef diff --git a/crates/anodize-hsm/src/lib.rs b/crates/anodize-hsm/src/lib.rs index 5f7eb5e..42c83a5 100644 --- a/crates/anodize-hsm/src/lib.rs +++ b/crates/anodize-hsm/src/lib.rs @@ -163,6 +163,20 @@ pub trait HsmBackend: Send { fn list_all_slots(&self) -> Result> { self.list_tokens() } + + /// Query the HSM audit log state for the device at `slot_id`, using + /// factory-default credentials, without modifying any state. + /// + /// Returns `Some((used, capacity))` for backends that support it + /// (YubiHSM). Returns `None` for backends that have no audit log + /// (SoftHSM) or when the device cannot be reached with factory credentials + /// (already bootstrapped or unavailable). + /// + /// Called before `bootstrap()` so the TUI can detect a full log and + /// require explicit operator confirmation before draining it. + fn query_bootstrap_audit_log(&self, _slot_id: u64) -> Option<(u8, u8)> { + None + } } /// Instantiate the appropriate backend for the given model. diff --git a/crates/anodize-hsm/src/yubihsm_backend.rs b/crates/anodize-hsm/src/yubihsm_backend.rs index 38a8a01..7b610aa 100644 --- a/crates/anodize-hsm/src/yubihsm_backend.rs +++ b/crates/anodize-hsm/src/yubihsm_backend.rs @@ -152,6 +152,23 @@ impl HsmBackend for YubiHsmBackend { Ok(Box::new(YubiHsmSession { client })) } + fn query_bootstrap_audit_log(&self, slot_id: u64) -> Option<(u8, u8)> { + let serials = yubihsm::connector::usb::Devices::serial_numbers().ok()?; + let serial = serials.get(slot_id as usize)?; + let cfg = yubihsm::UsbConfig { + serial: Some(*serial), + ..Default::default() + }; + let connector = yubihsm::Connector::usb(&cfg); + let creds = yubihsm::Credentials::from_password( + DEFAULT_AUTH_KEY_ID, + DEFAULT_AUTH_PASSWORD.as_bytes(), + ); + let client = yubihsm::Client::open(connector, creds, false).ok()?; + let di = client.device_info().ok()?; + Some((di.log_store_used, di.log_store_capacity)) + } + fn bootstrap( &self, slot_id: u64, @@ -184,6 +201,50 @@ impl HsmBackend for YubiHsmBackend { HsmError::BackendError(format!("YubiHSM bootstrap connect ({serial}): {e}")) })?; + // 1a. Drain the audit log as the very first operation after opening a session. + // + // GetLogEntries and SetLogIndex are always allowed by the YubiHSM — they + // are explicitly exempt from Force Audit blocking even when the log is full. + // All other auditable commands (including PutAuthenticationKey below) are + // blocked when Force Audit = Fix AND the log is full. A prior failed + // bootstrap may have left the device in exactly that state, so we drain + // unconditionally here before touching anything else. + let pre_log = client + .get_log_entries() + .map_err(|e| HsmError::BackendError(format!("get_log_entries (pre-drain): {e}")))?; + if let Some(last) = pre_log.entries.last() { + client + .set_log_index(last.item) + .map_err(|e| HsmError::BackendError(format!("set_log_index (pre-drain): {e}")))?; + } + tracing::info!( + "YubiHSM bootstrap: pre-drained {} log entries", + pre_log.entries.len() + ); + + // 1b. Remove the anodize auth key if it already exists from a prior + // partial bootstrap. After the drain the log has room, so + // DeleteObject is unblocked even if Force Audit = Fix. + // Ignore ObjectNotFound — that is the expected case on a fresh device. + if let Err(e) = client.delete_object( + ANODIZE_AUTH_KEY_ID, + yubihsm::object::Type::AuthenticationKey, + ) { + let msg = e.to_string(); + if !msg.contains("ObjectNotFound") && !msg.contains("object not found") { + return Err(HsmError::BackendError(format!( + "delete existing anodize auth key: {e}" + ))); + } + tracing::debug!( + "YubiHSM bootstrap: anodize auth key {ANODIZE_AUTH_KEY_ID} not present (fresh device)" + ); + } else { + tracing::info!( + "YubiHSM bootstrap: removed stale anodize auth key {ANODIZE_AUTH_KEY_ID} from partial bootstrap" + ); + } + // 2. Create a new auth key (ID 2) derived from the SSS user_pin. // This replaces the factory default for future sessions. let auth_key = @@ -204,13 +265,25 @@ impl HsmBackend for YubiHsmBackend { tracing::info!("YubiHSM bootstrap: created auth key {ANODIZE_AUTH_KEY_ID}"); // 2a. Enable Force Audit permanently — HSM blocks ops when log is full. - client - .set_force_audit_option(yubihsm::audit::AuditOption::Fix) - .map_err(|e| HsmError::BackendError(format!("set_force_audit_option: {e}")))?; - - tracing::info!("YubiHSM bootstrap: force audit set to Fix"); + // The log has room now (drained above), so this call cannot be blocked. + // Once set to Fix the firmware rejects any further SetOption for this + // flag (including setting it to Fix again) with InvalidData. Treat + // InvalidData as "already Fix" and continue. + match client.set_force_audit_option(yubihsm::audit::AuditOption::Fix) { + Ok(()) => tracing::info!("YubiHSM bootstrap: force audit set to Fix"), + Err(ref e) if e.device_error() == Some(yubihsm::device::ErrorKind::InvalidData) => { + tracing::info!("YubiHSM bootstrap: force audit already Fix — skipping"); + } + Err(e) => { + return Err(HsmError::BackendError(format!( + "set_force_audit_option: {e}" + ))) + } + } // 2b. Fix per-command audit for all security-critical commands. + // Same InvalidData guard: once a command's audit is set to Fix it + // cannot be changed, so a repeat bootstrap attempt would get rejected. const FIXED_AUDIT_COMMANDS: &[yubihsm::command::Code] = &[ yubihsm::command::Code::SignEcdsa, yubihsm::command::Code::GenerateAsymmetricKey, @@ -227,11 +300,17 @@ impl HsmBackend for YubiHsmBackend { ]; for &cmd in FIXED_AUDIT_COMMANDS { - client - .set_command_audit_option(cmd, yubihsm::audit::AuditOption::Fix) - .map_err(|e| { - HsmError::BackendError(format!("set_command_audit_option({cmd:?}): {e}")) - })?; + match client.set_command_audit_option(cmd, yubihsm::audit::AuditOption::Fix) { + Ok(()) => {} + Err(ref e) if e.device_error() == Some(yubihsm::device::ErrorKind::InvalidData) => { + tracing::debug!("YubiHSM bootstrap: {cmd:?} audit already Fix — skipping"); + } + Err(e) => { + return Err(HsmError::BackendError(format!( + "set_command_audit_option({cmd:?}): {e}" + ))) + } + } } tracing::info!( @@ -239,19 +318,18 @@ impl HsmBackend for YubiHsmBackend { FIXED_AUDIT_COMMANDS.len() ); - // 2c. Drain the initial log so the validator has a clean baseline. - let log = client + // 2c. Drain the bootstrap audit entries to leave a clean baseline. + let post_log = client .get_log_entries() - .map_err(|e| HsmError::BackendError(format!("get_log_entries: {e}")))?; - if let Some(last) = log.entries.last() { + .map_err(|e| HsmError::BackendError(format!("get_log_entries (post-drain): {e}")))?; + if let Some(last) = post_log.entries.last() { client .set_log_index(last.item) - .map_err(|e| HsmError::BackendError(format!("set_log_index: {e}")))?; + .map_err(|e| HsmError::BackendError(format!("set_log_index (post-drain): {e}")))?; } - tracing::info!( - "YubiHSM bootstrap: drained {} initial log entries", - log.entries.len() + "YubiHSM bootstrap: post-drained {} bootstrap log entries", + post_log.entries.len() ); // 3. Delete the factory default auth key to lock down the device. diff --git a/crates/anodize-shuttle/src/init.rs b/crates/anodize-shuttle/src/init.rs index 3730f07..4281364 100644 --- a/crates/anodize-shuttle/src/init.rs +++ b/crates/anodize-shuttle/src/init.rs @@ -20,7 +20,7 @@ pub struct InitArgs { mode: HsmMode, /// USB device path (e.g. /dev/disk4 on macOS, /dev/sdb on Linux). - /// Use `anodize-shuttle lint --list-usb` or `diskutil list` to find it. + /// Use `make list-usb` or `diskutil list` to find it. #[arg(long)] device: String,