Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions crates/anodize-hsm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,20 @@ pub trait HsmBackend: Send {
fn list_all_slots(&self) -> Result<Vec<SlotTokenInfo>> {
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.
Expand Down
114 changes: 96 additions & 18 deletions crates/anodize-hsm/src/yubihsm_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand All @@ -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,
Expand All @@ -227,31 +300,36 @@ 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!(
"YubiHSM bootstrap: fixed audit for {} commands",
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.
Expand Down
2 changes: 1 addition & 1 deletion crates/anodize-shuttle/src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
Loading