feat(10-04): let a deployed node report — and fix — fleet-shared host keys
10-03 closed the build half of F-03: the ISO no longer bakes SSH host keys or
a TLS keypair into the shared rootfs, and first-boot regeneration fails closed.
Nodes already in the field receive none of that — the first-boot script is
installed by the installer, not shipped by OTA — so a node that hit the old
fail-open path is still running key material that every downloader of its ISO
also holds, and its completion marker guarantees it will never try again.
scripts/security/host-secrets-audit.sh decides, from the node's own disk alone,
which of those it is. Four signals in a fixed precedence: missing material can
never be shared material; the fail-open fingerprint (marker present plus the
literal `WARNING: TLS regeneration failed` / `WARNING: ssh-keygen -A failed`
lines the old script emitted) is direct evidence and outranks timestamps and
also names WHICH class survived; then key mtime against a first-boot anchor
(.secrets-regenerated, falling back to the installer's LUKS key then
machine-id). Verdicts are per-node / shared / fail-closed-missing / unknown,
and every one of them carries the evidence strings that produced it, each
naming the file it was read from.
per-node is never claimed from an absent signal. No anchor means `unknown`, and
a standing first-boot-secrets.failed record also means `unknown` — a clean
mtime is not evidence that generation succeeded. That is T-10-37: a false
per-node verdict leaves an exposed node looking clean, which is worse than no
verdict at all.
Rotation (D-06: detect-report-then-apply, recorded in
docs/security/KEY-02-FLEET-ROTATION.md):
- --detect is the default and is read-only; it always exits 0, because
detection is informational and must never fail a boot.
- --apply without --yes writes nothing at all, not even its own verdict file.
"Touches nothing" is worth being able to say without a footnote.
- --apply --yes refuses unless the verdict is `shared`, so the wrong node
cannot be rotated even deliberately.
- It stages the full replacement TLS pair AND host-key set before touching
anything live and aborts if either fails; records the OLD fingerprints
before the swap; does TLS first (a dead web UI is recoverable over SSH, the
converse is not); replaces host keys by mv-onto-the-existing-path rather
than rm-then-mv, so the directory is never momentarily empty; and RELOADS
sshd, never restarts it, so the operator's own session survives its own
rotation.
bootstrap.rs ships the boot unit through the existing run_runtime_assets
promotion and enables it --now, so the verdict lands with the OTA rather than
at the next reboot. handle_system_stats gains a host_secrets object read from
the on-disk verdict — cheap, never an error however malformed the file, and
deliberately carrying no fingerprints, because a payload polled every few
seconds does not need digests an operator on the node can already read.
tests/first-boot-secrets/rotation-tests.sh: 8 cases against temp roots through
the HOST_SECRETS_ROOT seam. Negative controls run and reverted, each reddening
exactly one case: dry run writing its verdict file (STATE-DIR-CHANGED); the
old fingerprints recorded after the swap instead of before (caught by an
ordering observation, not a content comparison — the systemctl stub records
whether the file existed at the moment of the first reload); a tolerated
generation failure leaving a half-rotated node; and `per-node` claimed with no
anchor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
78b3ec879b
commit
0ed9334f15
@@ -193,7 +193,14 @@ impl RpcHandler {
|
||||
};
|
||||
let (disk_used, disk_total) = read_disk_usage_path(df_target).await.unwrap_or((0, 0));
|
||||
|
||||
// F-03 verdict. Surfacing it here rather than only in a log file is the
|
||||
// point: an operator must be able to see that a node is running
|
||||
// fleet-shared host keys without having to SSH into it — and SSH into
|
||||
// it is exactly the thing a shared host key compromises.
|
||||
let host_secrets = host_secrets_status(Path::new(HOST_SECRETS_STATE_DIR)).await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"host_secrets": host_secrets,
|
||||
"uptime_secs": uptime as u64,
|
||||
"load_avg_1": load.0,
|
||||
"load_avg_5": load.1,
|
||||
@@ -499,6 +506,74 @@ const RM_BIN: &str = "/usr/bin/rm";
|
||||
const TLS_KEY_FALLBACK_MODE: &str = "600";
|
||||
const TLS_CRT_FALLBACK_MODE: &str = "644";
|
||||
|
||||
/// Where `scripts/security/host-secrets-audit.sh` leaves its verdict. Hardcoded
|
||||
/// rather than derived from `config.data_dir` because the script is a systemd
|
||||
/// unit with no view of the daemon's configuration — this is the path both
|
||||
/// sides agree on.
|
||||
const HOST_SECRETS_STATE_DIR: &str = "/var/lib/archipelago";
|
||||
const HOST_SECRETS_AUDIT_FILE: &str = "host-secrets-audit.json";
|
||||
const HOST_KEY_ROTATION_FILE: &str = "host-key-rotation.json";
|
||||
|
||||
/// The `host_secrets` object carried by `system.stats`: has this node been
|
||||
/// judged to be running the fleet-shared, image-baked SSH host keys and TLS
|
||||
/// private key (audit F-03), and has it been rotated since?
|
||||
///
|
||||
/// Three properties this must hold, because `system.stats` is in
|
||||
/// `CACHEABLE_METHODS` (`api/rpc/middleware.rs:41`) and the dashboard polls it:
|
||||
///
|
||||
/// 1. **It never errors.** A missing, truncated or unparseable verdict file
|
||||
/// yields `{"verdict":"unknown"}`. A node that has not run the audit yet —
|
||||
/// every node, until the OTA carrying the unit lands — must not turn its own
|
||||
/// stats call into a failure.
|
||||
/// 2. **It is cheap.** Two small file reads, no process spawn, no key parsing.
|
||||
/// The expensive work (ssh-keygen, openssl) happens in the boot unit, once.
|
||||
/// 3. **It carries no fingerprints.** Those live in the on-disk record only.
|
||||
/// They are public data, but there is no reason to put them in a payload
|
||||
/// that is polled every few seconds — the dashboard needs the verdict, and
|
||||
/// an operator who needs the digests is already on the node.
|
||||
async fn host_secrets_status(dir: &Path) -> serde_json::Value {
|
||||
let unknown = || serde_json::json!({ "verdict": "unknown" });
|
||||
|
||||
let audit: serde_json::Value = match tokio::fs::read_to_string(dir.join(HOST_SECRETS_AUDIT_FILE))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
{
|
||||
Some(v) => v,
|
||||
None => return unknown(),
|
||||
};
|
||||
|
||||
let verdict = audit
|
||||
.get("verdict")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let mut out = serde_json::json!({ "verdict": verdict });
|
||||
|
||||
if let Some(checked_at) = audit.get("checked_at").and_then(|v| v.as_str()) {
|
||||
out["checked_at"] = serde_json::json!(checked_at);
|
||||
}
|
||||
if let Some(evidence) = audit.get("evidence").and_then(|v| v.as_array()) {
|
||||
out["evidence"] = serde_json::json!(evidence);
|
||||
}
|
||||
|
||||
// A rotation record only exists on a node an operator has actually
|
||||
// rotated, so its absence is the normal case and is not reported.
|
||||
if let Some(rotated_at) = tokio::fs::read_to_string(dir.join(HOST_KEY_ROTATION_FILE))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.and_then(|v| {
|
||||
v.get("rotated_at")
|
||||
.and_then(|r| r.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
{
|
||||
out["rotated_at"] = serde_json::json!(rotated_at);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Where this node's TLS material lives, and how we are allowed to touch it.
|
||||
///
|
||||
/// This type exists for two reasons. The first is that every step of the
|
||||
@@ -1306,3 +1381,88 @@ exec {OPENSSL_BIN} "$@"
|
||||
assert_eq!(private_key_mode(Some("garbage")), "600");
|
||||
}
|
||||
}
|
||||
|
||||
/// The `host_secrets` object in `system.stats` (phase 10 KEY-02 / audit F-03).
|
||||
///
|
||||
/// These pin the contract that matters for a *cacheable, polled* method: the
|
||||
/// absent and malformed cases must be indistinguishable from a plain "not
|
||||
/// judged yet", never an error and never a panic. Every fleet node is in the
|
||||
/// absent case until the OTA carrying the audit unit reaches it, so the absent
|
||||
/// case is the common one, not the edge one.
|
||||
#[cfg(test)]
|
||||
mod host_secrets_tests {
|
||||
use super::*;
|
||||
|
||||
fn write(dir: &Path, name: &str, body: &str) {
|
||||
std::fs::write(dir.join(name), body).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verdict_is_unknown_when_the_audit_file_is_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let v = host_secrets_status(dir.path()).await;
|
||||
assert_eq!(v["verdict"], "unknown");
|
||||
assert!(v.get("checked_at").is_none());
|
||||
assert!(v.get("rotated_at").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verdict_is_unknown_when_the_audit_file_is_unparseable() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// A half-written file: the script writes to .tmp and renames, so this
|
||||
// should not happen — which is exactly why the daemon must survive it
|
||||
// if it ever does.
|
||||
write(dir.path(), HOST_SECRETS_AUDIT_FILE, "{\"verdict\": \"shar");
|
||||
let v = host_secrets_status(dir.path()).await;
|
||||
assert_eq!(v["verdict"], "unknown");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recorded_verdict_and_evidence_are_surfaced() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(
|
||||
dir.path(),
|
||||
HOST_SECRETS_AUDIT_FILE,
|
||||
r#"{
|
||||
"verdict": "shared",
|
||||
"checked_at": "2026-08-02T12:00:00Z",
|
||||
"evidence": ["shared: /etc/ssh/ssh_host_rsa_key mtime is older than the anchor"],
|
||||
"ssh_host_key_fingerprints": ["/etc/ssh/ssh_host_rsa_key.pub: 3072 SHA256:abc"],
|
||||
"tls_cert_sha256": "AA:BB:CC"
|
||||
}"#,
|
||||
);
|
||||
let v = host_secrets_status(dir.path()).await;
|
||||
assert_eq!(v["verdict"], "shared");
|
||||
assert_eq!(v["checked_at"], "2026-08-02T12:00:00Z");
|
||||
assert_eq!(v["evidence"].as_array().unwrap().len(), 1);
|
||||
|
||||
// The digests stay on disk. A payload polled every few seconds does not
|
||||
// carry them, and a future edit that "helpfully" forwards the whole
|
||||
// file should fail here.
|
||||
assert!(v.get("ssh_host_key_fingerprints").is_none());
|
||||
assert!(v.get("tls_cert_sha256").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rotated_at_is_surfaced_only_when_a_rotation_was_recorded() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(
|
||||
dir.path(),
|
||||
HOST_SECRETS_AUDIT_FILE,
|
||||
r#"{"verdict":"per-node","checked_at":"2026-08-02T12:05:00Z","evidence":[]}"#,
|
||||
);
|
||||
assert!(host_secrets_status(dir.path())
|
||||
.await
|
||||
.get("rotated_at")
|
||||
.is_none());
|
||||
|
||||
write(
|
||||
dir.path(),
|
||||
HOST_KEY_ROTATION_FILE,
|
||||
r#"{"rotated_at":"2026-08-02T12:04:00Z","old_ssh_fingerprints":[],"old_tls_sha256":"AA"}"#,
|
||||
);
|
||||
let v = host_secrets_status(dir.path()).await;
|
||||
assert_eq!(v["verdict"], "per-node");
|
||||
assert_eq!(v["rotated_at"], "2026-08-02T12:04:00Z");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user