fix(10-01): gate identity-mutating onboarding RPCs on provisioned nodes (F-01)
Closes F-01 (Critical) of docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md. seed.generate/seed.restore/seed.save-encrypted/backup.restore-identity/ auth.setup are all in UNAUTHENTICATED_METHODS, and several reach NodeIdentity::from_seed or restore_encrypted_backup, which overwrite identity/node_key unconditionally. One unauthenticated POST from the LAN or from any FIPS mesh peer hijacked a live node's Ed25519 identity, Nostr node key and FIPS transport key. - new api::rpc::onboarding_gate::ensure_onboarding_open: refuses once ANY of is_setup() / is_onboarding_complete() / seed_exists() says provisioned, failing safe on I/O errors. NodeIdentity::key_exists is deliberately NOT a signal — server.rs:63-71 writes a temporary key on every boot, so a gate keyed on it would refuse seed.generate on a never-onboarded node. Pinned by allows_on_fresh_temp_dir_even_though_node_key_exists. - ensure_user_account_exists: the inverse guard for auth.onboardingComplete, which is unauthenticated and sets the flag the gate reads — without it, one call locks a fresh node out of its own onboarding. - seed.restore body extracted to restore_node_identity_from_words so the regression suite drives the real path; seed.verify left open with a written verdict (non-mutating). - refusal text begins "Not supported:" so it survives sanitize_error_message and names the authenticated system.factory-reset recovery path. - per-method rate limits for the four onboarding mutators, sized ~6x the measured client retry budget so a 429 cannot reintroduce the error at the DID-creation screen. First-boot onboarding is untouched: all three signals are false throughout the seed steps, and auth.setup runs last (Login.vue:405-425). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
49345b67ed
commit
879de59ecc
@@ -196,11 +196,23 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!(is_setup))
|
||||
}
|
||||
|
||||
/// Create the node's user account. Unauthenticated by necessity: no account
|
||||
/// exists yet when the onboarding wizard reaches the password screen.
|
||||
///
|
||||
/// D-04 verdict: **gated, in addition to the pre-existing `is_setup()`
|
||||
/// rejection.** The `is_setup()` check alone fails open in a drift case: on
|
||||
/// a provisioned node whose `user.json` is missing or was deleted it would
|
||||
/// still run — and it does more than create an account, it also rewrites the
|
||||
/// OS login password via `crate::auth::change_ssh_password` (below), which
|
||||
/// is an unauthenticated privilege escalation on a live node (T-10-08). The
|
||||
/// gate closes that case using the seed/onboarding signals, which survive a
|
||||
/// deleted `user.json`.
|
||||
pub(super) async fn handle_auth_setup(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
// Prevent re-setup if already set up
|
||||
// Prevent re-setup if already set up. Kept ahead of the gate so the
|
||||
// existing, more specific message survives for this common case.
|
||||
let is_setup = self.auth_manager.is_setup().await?;
|
||||
if is_setup {
|
||||
tracing::warn!("[onboarding] setup rejected — already set up");
|
||||
@@ -209,6 +221,9 @@ impl RpcHandler {
|
||||
));
|
||||
}
|
||||
|
||||
super::onboarding_gate::ensure_onboarding_open(&self.config.data_dir, &self.auth_manager)
|
||||
.await?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let password = params
|
||||
.get("password")
|
||||
@@ -247,7 +262,32 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!(true))
|
||||
}
|
||||
|
||||
/// Mark onboarding complete.
|
||||
///
|
||||
/// This one takes the OPPOSITE guard to the rest of the D-04 sweep, and it
|
||||
/// is the most important addition in it. The method is unauthenticated
|
||||
/// (`middleware.rs:12`) and it SETS the very flag
|
||||
/// `onboarding_gate::ensure_onboarding_open` reads. Without a guard, one
|
||||
/// unauthenticated call against a fresh node marks it onboarded and
|
||||
/// permanently locks it out of its own onboarding — a denial of service
|
||||
/// created BY the gate (T-10-04). So: refuse until a user account exists.
|
||||
///
|
||||
/// Verified safe against the real wizard before shipping:
|
||||
/// * The live flow never calls this before `auth.setup`. It is
|
||||
/// `/onboarding/intro → path → seed → seed-verify → identity → done →
|
||||
/// /login`, and `views/Login.vue:405-425` posts `auth.setup` from that
|
||||
/// last screen. The onboarding flag is then set by
|
||||
/// `auth.rs:203-217`'s auto-heal inference, not by this RPC.
|
||||
/// * The only caller of this method is `OnboardingVerify.vue:157`, on the
|
||||
/// `/onboarding/verify` route — reachable only from
|
||||
/// `/onboarding/backup`, which nothing in the app navigates to any more.
|
||||
/// * Even on that dead path the refusal is invisible: `completeOnboarding`
|
||||
/// wraps the call in `callWithRetry` (`useOnboarding.ts:64-68`), which
|
||||
/// returns `null` on a non-retryable error instead of throwing, and
|
||||
/// `proceed()` catches anyway.
|
||||
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
|
||||
super::onboarding_gate::ensure_user_account_exists(&self.auth_manager).await?;
|
||||
|
||||
self.auth_manager.complete_onboarding().await?;
|
||||
tracing::info!("[onboarding] onboarding marked complete");
|
||||
|
||||
|
||||
@@ -406,10 +406,20 @@ impl RpcHandler {
|
||||
|
||||
/// Restore identity from an encrypted DID backup JSON.
|
||||
/// Params: { backup: { version, blob, ... }, passphrase }
|
||||
///
|
||||
/// D-04 verdict: **gated.** This is unauthenticated
|
||||
/// (`middleware.rs:30`) and reaches
|
||||
/// `backup::identity::restore_encrypted_backup`, which writes
|
||||
/// `identity/node_key` unconditionally at `backup/identity.rs:113-117` —
|
||||
/// the same overwrite primitive F-01 names, behind a different door.
|
||||
/// Fixing `seed.restore` alone would have moved the door, not closed it.
|
||||
pub(super) async fn handle_backup_restore_identity(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value> {
|
||||
super::onboarding_gate::ensure_onboarding_open(&self.config.data_dir, &self.auth_manager)
|
||||
.await?;
|
||||
|
||||
let backup = params
|
||||
.get("backup")
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'backup' parameter"))?;
|
||||
|
||||
@@ -24,6 +24,7 @@ mod names;
|
||||
mod network;
|
||||
mod node;
|
||||
mod nostr;
|
||||
mod onboarding_gate;
|
||||
mod openwrt;
|
||||
mod package;
|
||||
pub(crate) use package::wyoming_satellite_keeper;
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
//! Onboarding-posture gate for the unauthenticated, identity-mutating RPCs.
|
||||
//!
|
||||
//! Closes F-01 (Critical) of `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`:
|
||||
//! `seed.generate` / `seed.restore` / `seed.save-encrypted` /
|
||||
//! `backup.restore-identity` / `auth.setup` all sit in
|
||||
//! `middleware::UNAUTHENTICATED_METHODS`, and several of them reach
|
||||
//! `NodeIdentity::from_seed` (`identity.rs:79-114`) or
|
||||
//! `backup::identity::restore_encrypted_backup` (`backup/identity.rs:112-117`),
|
||||
//! both of which overwrite `identity/node_key` unconditionally. Before this
|
||||
//! gate, a single unauthenticated JSON-RPC POST from anywhere on the LAN — or
|
||||
//! from any FIPS mesh peer — replaced a live node's Ed25519 identity, Nostr
|
||||
//! node key and FIPS transport key.
|
||||
//!
|
||||
//! Those endpoints cannot simply be removed from the unauthenticated list:
|
||||
//! they are *legitimately* pre-auth, because no user account exists until
|
||||
//! `auth.setup` runs at the very end of the onboarding wizard. So instead of
|
||||
//! authenticating the caller, this gate asks a different question — "is this
|
||||
//! node still un-provisioned?" — and refuses once the answer is no.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// The D-04 sweep set: every method in `UNAUTHENTICATED_METHODS` that can
|
||||
/// mutate node identity or credentials. Each of these either calls
|
||||
/// [`ensure_onboarding_open`] or carries a written, evidence-backed verdict for
|
||||
/// why it does not (`auth.onboardingComplete` takes the *opposite* guard — see
|
||||
/// `api/rpc/auth.rs::handle_auth_onboarding_complete`).
|
||||
///
|
||||
/// This constant is the anti-drift anchor for `gate_calls_are_present`, the
|
||||
/// source-guard test at the bottom of this file. It does not itself dispatch
|
||||
/// anything, so it is dead in a non-test build by design — it exists to make
|
||||
/// the sweep set reviewable in one place and to fail a test when a sixth door
|
||||
/// is added without a gate.
|
||||
#[allow(dead_code)]
|
||||
pub(in crate::api::rpc) const IDENTITY_MUTATING_ONBOARDING_METHODS: &[&str] = &[
|
||||
"seed.generate",
|
||||
"seed.restore",
|
||||
"seed.save-encrypted",
|
||||
"backup.restore-identity",
|
||||
"auth.setup",
|
||||
"auth.onboardingComplete",
|
||||
];
|
||||
|
||||
/// The refusal text. MUST begin with `Not supported:` — `sanitize_error_message`
|
||||
/// (`middleware.rs:47-71`) only lets an error through to the caller when it
|
||||
/// starts with a known prefix, and `Not supported` is already on that list.
|
||||
/// Anything else would reach the operator as "Operation failed. Check server
|
||||
/// logs for details.", which is a dead end rather than a refusal.
|
||||
///
|
||||
/// Kept under the sanitizer's 200-character truncation limit so the recovery
|
||||
/// path (D-02) survives intact.
|
||||
const REFUSAL: &str = "Not supported: this node is already provisioned. Re-keying requires the \
|
||||
authenticated system.factory-reset, after which the normal onboarding \
|
||||
restore flow works.";
|
||||
|
||||
/// Return `Ok(())` only while the node is still un-provisioned; otherwise
|
||||
/// refuse with [`REFUSAL`].
|
||||
///
|
||||
/// # Signals (D-03 / D-03a)
|
||||
///
|
||||
/// Three independent signals, OR-ed. ANY one of them saying "provisioned"
|
||||
/// refuses — the gate never trusts a single signal alone to say "open", which
|
||||
/// is what makes it fail safe when the signals drift apart (a real state:
|
||||
/// `auth.rs:193-217` carries auto-heal logic for exactly that drift).
|
||||
///
|
||||
/// | Signal | Source | Fresh node | Mid-onboarding | Provisioned |
|
||||
/// |---|---|---|---|---|
|
||||
/// | `AuthManager::is_setup()` (`auth.rs:116-119`, `user.json` exists) | disk | false | false | true |
|
||||
/// | `AuthManager::is_onboarding_complete()` (`auth.rs:182-219`) | disk + flag | false | false | true |
|
||||
/// | `crate::seed::seed_exists()` (`seed.rs:384-386`, `identity/master_seed.enc`) | disk | false | false | true (legacy nodes: false — covered by the other two) |
|
||||
///
|
||||
/// # Why `NodeIdentity::key_exists` is NOT one of them
|
||||
///
|
||||
/// The audit's suggested remediation, and the phase's own D-03, both named
|
||||
/// `NodeIdentity::key_exists` (`identity.rs:117`) as the on-disk "this node is
|
||||
/// onboarded" signal. **It is unusable, and a gate keyed on it would brick
|
||||
/// first boot on every new node.** `Server::new` (`server.rs:63-71`) calls
|
||||
/// `NodeIdentity::load_or_create` on *both* branches of its fresh-vs-existing
|
||||
/// check, and `load_or_create` (`identity.rs:47-67`) generates and writes a
|
||||
/// random temporary node key when none exists — its own comment says "Fresh
|
||||
/// install — create a temporary identity. Onboarding will overwrite this with
|
||||
/// seed-derived keys." So `key_exists` is `true` on every node that has booted
|
||||
/// even once, onboarded or not, and refusing on it would refuse
|
||||
/// `seed.generate` on a node that has never been onboarded.
|
||||
///
|
||||
/// `identity::fips_key_exists` was rejected for a related reason: the FIPS key
|
||||
/// is written by `NodeIdentity::from_seed` (`identity.rs:108`), i.e. by the
|
||||
/// *first* seed step, so it is already true midway through the wizard. Gating
|
||||
/// on it would break a generate-then-restore switchback inside onboarding.
|
||||
///
|
||||
/// This correction is pinned by the test
|
||||
/// `allows_on_fresh_temp_dir_even_though_node_key_exists`, not by this comment.
|
||||
///
|
||||
/// # Failure handling
|
||||
///
|
||||
/// An I/O error from any signal is treated as **provisioned** (fail safe), not
|
||||
/// as open. A gate that opens when it cannot read the disk is not a gate.
|
||||
///
|
||||
/// # Disclosure
|
||||
///
|
||||
/// The refusal deliberately does not say *which* signal fired. A one-bit
|
||||
/// "provisioned" answer discloses nothing beyond what `auth.isOnboardingComplete`
|
||||
/// already discloses — that method is itself in `UNAUTHENTICATED_METHODS`
|
||||
/// (`middleware.rs:9`) — but a per-signal breakdown would disclose more.
|
||||
pub(in crate::api::rpc) async fn ensure_onboarding_open(
|
||||
data_dir: &Path,
|
||||
auth: &crate::auth::AuthManager,
|
||||
) -> anyhow::Result<()> {
|
||||
// `unwrap_or(true)` is the fail-safe: an unreadable user.json or
|
||||
// onboarding.json means we cannot prove the node is fresh, so we refuse.
|
||||
let user_account_exists = auth.is_setup().await.unwrap_or(true);
|
||||
let onboarding_marked_complete = auth.is_onboarding_complete().await.unwrap_or(true);
|
||||
let encrypted_seed_on_disk = crate::seed::seed_exists(data_dir);
|
||||
|
||||
if user_account_exists || onboarding_marked_complete || encrypted_seed_on_disk {
|
||||
// Log the deciding signals for the operator; the caller gets one bit.
|
||||
tracing::warn!(
|
||||
user_account_exists,
|
||||
onboarding_marked_complete,
|
||||
encrypted_seed_on_disk,
|
||||
"[onboarding-gate] refused an identity-mutating onboarding RPC on a provisioned node"
|
||||
);
|
||||
anyhow::bail!(REFUSAL);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The OPPOSITE guard, for `auth.onboardingComplete` only.
|
||||
///
|
||||
/// That method is unauthenticated and SETS the flag [`ensure_onboarding_open`]
|
||||
/// reads, so without this an attacker could call it once against a fresh node
|
||||
/// and permanently lock it out of onboarding — a denial of service created by
|
||||
/// the gate itself (T-10-04). Onboarding cannot legitimately be "complete"
|
||||
/// before a user account exists, so refuse until it does.
|
||||
///
|
||||
/// Failure handling is the mirror image of the main gate: an unreadable
|
||||
/// `user.json` means we cannot prove an account exists, so we refuse
|
||||
/// (`unwrap_or(false)`). Refusing here is safe — the flag is also inferred by
|
||||
/// `AuthManager::is_onboarding_complete`'s auto-heal path (`auth.rs:203-217`)
|
||||
/// once the account is set up, so nothing depends on this RPC succeeding.
|
||||
pub(in crate::api::rpc) async fn ensure_user_account_exists(
|
||||
auth: &crate::auth::AuthManager,
|
||||
) -> anyhow::Result<()> {
|
||||
if !auth.is_setup().await.unwrap_or(false) {
|
||||
tracing::warn!("[onboarding-gate] refused auth.onboardingComplete — no user account yet");
|
||||
anyhow::bail!(
|
||||
"Not supported: onboarding cannot be completed before a user account exists. \
|
||||
Set a password first."
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::AuthManager;
|
||||
|
||||
/// A temp data dir plus its AuthManager, in the state a genuinely fresh
|
||||
/// node is in: nothing written yet.
|
||||
fn fresh() -> (tempfile::TempDir, AuthManager) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let auth = AuthManager::new(dir.path().to_path_buf());
|
||||
(dir, auth)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allows_on_fresh_node() {
|
||||
let (dir, auth) = fresh();
|
||||
assert!(ensure_onboarding_open(dir.path(), &auth).await.is_ok());
|
||||
}
|
||||
|
||||
/// Pins the D-03a scoping correction as a test rather than a comment:
|
||||
/// every booted node has a `node_key` on disk (`server.rs:63-71` ->
|
||||
/// `identity.rs:47-67`), so a gate keyed on `NodeIdentity::key_exists`
|
||||
/// would refuse onboarding on a node that has never been onboarded.
|
||||
#[tokio::test]
|
||||
async fn allows_on_fresh_temp_dir_even_though_node_key_exists() {
|
||||
let (dir, auth) = fresh();
|
||||
let identity_dir = dir.path().join("identity");
|
||||
std::fs::create_dir_all(&identity_dir).unwrap();
|
||||
std::fs::write(identity_dir.join("node_key"), [7u8; 32]).unwrap();
|
||||
|
||||
assert!(
|
||||
crate::identity::NodeIdentity::key_exists(&identity_dir),
|
||||
"precondition: the boot-time node key is on disk"
|
||||
);
|
||||
assert!(
|
||||
ensure_onboarding_open(dir.path(), &auth).await.is_ok(),
|
||||
"a boot-time node_key must NOT be read as 'onboarded' — that would \
|
||||
brick first boot on every fresh node"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refuses_when_user_json_exists() {
|
||||
let (dir, auth) = fresh();
|
||||
auth.setup_user("password123").await.unwrap();
|
||||
|
||||
let err = ensure_onboarding_open(dir.path(), &auth).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().starts_with("Not supported:"),
|
||||
"refusal must survive sanitize_error_message: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refuses_when_onboarding_flag_set() {
|
||||
let (dir, auth) = fresh();
|
||||
// Writes onboarding.json even with no user account — the drift case.
|
||||
auth.complete_onboarding().await.unwrap();
|
||||
assert!(!auth.is_setup().await.unwrap(), "no user.json in this case");
|
||||
|
||||
assert!(ensure_onboarding_open(dir.path(), &auth).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refuses_when_encrypted_seed_on_disk() {
|
||||
let (dir, auth) = fresh();
|
||||
let identity_dir = dir.path().join("identity");
|
||||
std::fs::create_dir_all(&identity_dir).unwrap();
|
||||
std::fs::write(identity_dir.join("master_seed.enc"), b"ciphertext").unwrap();
|
||||
assert!(crate::seed::seed_exists(dir.path()));
|
||||
|
||||
assert!(ensure_onboarding_open(dir.path(), &auth).await.is_err());
|
||||
}
|
||||
|
||||
/// The refusal must reach the caller intact rather than being collapsed
|
||||
/// into "Operation failed. Check server logs for details.", and it must
|
||||
/// name the D-02 recovery path.
|
||||
#[tokio::test]
|
||||
async fn refusal_survives_the_error_sanitizer_and_names_the_recovery_path() {
|
||||
let sanitized = crate::api::rpc::middleware::sanitize_error_message(REFUSAL);
|
||||
assert_ne!(sanitized, "Operation failed. Check server logs for details.");
|
||||
assert!(
|
||||
sanitized.contains("system.factory-reset"),
|
||||
"the refusal must not be a dead end: {sanitized}"
|
||||
);
|
||||
}
|
||||
|
||||
/// T-10-04: `auth.onboardingComplete` must not be usable to lock a fresh
|
||||
/// node out of its own onboarding.
|
||||
#[tokio::test]
|
||||
async fn onboarding_complete_guard_requires_a_user_account() {
|
||||
let (_dir, auth) = fresh();
|
||||
|
||||
let err = ensure_user_account_exists(&auth).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().starts_with("Not supported:"),
|
||||
"refusal must survive sanitize_error_message: {err}"
|
||||
);
|
||||
|
||||
auth.setup_user("password123").await.unwrap();
|
||||
assert!(
|
||||
ensure_user_account_exists(&auth).await.is_ok(),
|
||||
"once the account exists, completing onboarding is legitimate"
|
||||
);
|
||||
}
|
||||
|
||||
/// Anti-drift source guard: every method in
|
||||
/// [`IDENTITY_MUTATING_ONBOARDING_METHODS`] must still carry its guard in
|
||||
/// the handler body that serves it. Deleting any single
|
||||
/// `ensure_onboarding_open` call fails this test instead of shipping.
|
||||
///
|
||||
/// Matching is done on source text rather than behaviour because the
|
||||
/// handlers are `RpcHandler` methods, and constructing an `RpcHandler`
|
||||
/// needs an orchestrator, port allocator, session store and metrics store.
|
||||
#[test]
|
||||
fn every_identity_mutating_method_still_carries_its_guard() {
|
||||
const SEED_RPC: &str = include_str!("seed_rpc.rs");
|
||||
const BACKUP_RPC: &str = include_str!("backup_rpc.rs");
|
||||
const AUTH_RPC: &str = include_str!("auth.rs");
|
||||
|
||||
// method -> (source file, the fn whose body serves it, guard call)
|
||||
let coverage: &[(&str, &str, &str, &str)] = &[
|
||||
(
|
||||
"seed.generate",
|
||||
SEED_RPC,
|
||||
"async fn handle_seed_generate",
|
||||
"ensure_onboarding_open",
|
||||
),
|
||||
(
|
||||
"seed.restore",
|
||||
SEED_RPC,
|
||||
"async fn restore_node_identity_from_words",
|
||||
"ensure_onboarding_open",
|
||||
),
|
||||
(
|
||||
"seed.save-encrypted",
|
||||
SEED_RPC,
|
||||
"async fn handle_seed_save_encrypted",
|
||||
"ensure_onboarding_open",
|
||||
),
|
||||
(
|
||||
"backup.restore-identity",
|
||||
BACKUP_RPC,
|
||||
"async fn handle_backup_restore_identity",
|
||||
"ensure_onboarding_open",
|
||||
),
|
||||
(
|
||||
"auth.setup",
|
||||
AUTH_RPC,
|
||||
"async fn handle_auth_setup",
|
||||
"ensure_onboarding_open",
|
||||
),
|
||||
// The opposite guard — see `ensure_user_account_exists`.
|
||||
(
|
||||
"auth.onboardingComplete",
|
||||
AUTH_RPC,
|
||||
"async fn handle_auth_onboarding_complete",
|
||||
"ensure_user_account_exists",
|
||||
),
|
||||
];
|
||||
|
||||
for method in IDENTITY_MUTATING_ONBOARDING_METHODS {
|
||||
assert!(
|
||||
coverage.iter().any(|(m, ..)| m == method),
|
||||
"{method} is in the sweep set but no source guard covers it"
|
||||
);
|
||||
}
|
||||
|
||||
for (method, source, signature, guard) in coverage {
|
||||
let start = source
|
||||
.find(signature)
|
||||
.unwrap_or_else(|| panic!("{signature} not found — did {method} get renamed?"));
|
||||
let body = fn_body(&source[start..]);
|
||||
|
||||
assert!(
|
||||
body.contains(guard),
|
||||
"{method}: {signature} no longer calls {guard} — the F-01 gate was removed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `{ .. }` block of the function `src` starts with, by brace matching.
|
||||
/// Deliberately exact: a looser "up to the next fn" slice would let a
|
||||
/// neighbouring handler's guard call satisfy the assertion for a handler
|
||||
/// whose own guard had been deleted.
|
||||
fn fn_body(src: &str) -> &str {
|
||||
let open = src.find('{').expect("function has a body");
|
||||
let mut depth = 0usize;
|
||||
for (i, c) in src[open..].char_indices() {
|
||||
match c {
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return &src[open..open + i + 1];
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
panic!("unbalanced braces while scanning a function body");
|
||||
}
|
||||
|
||||
/// The headline F-01 regression: an already-provisioned node refuses
|
||||
/// `seed.restore` with attacker-chosen words, and its identity is
|
||||
/// byte-identical afterwards. This test cannot pass without the gate.
|
||||
#[tokio::test]
|
||||
async fn provisioned_node_refuses_restore_and_identity_bytes_are_unchanged() {
|
||||
let (dir, auth) = fresh();
|
||||
let data_dir = dir.path();
|
||||
let identity_dir = data_dir.join("identity");
|
||||
|
||||
// 1) The node's real identity, derived from seed A.
|
||||
let (_mnemonic_a, seed_a) = crate::seed::MasterSeed::generate().unwrap();
|
||||
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed_a)
|
||||
.await
|
||||
.unwrap();
|
||||
let nostr_a = crate::seed::derive_node_nostr_key(&seed_a).unwrap();
|
||||
std::fs::write(
|
||||
identity_dir.join("nostr_secret"),
|
||||
nostr_a.secret_key().display_secret().to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 2) The node is provisioned.
|
||||
auth.complete_onboarding().await.unwrap();
|
||||
|
||||
// 3) Snapshot the key material an attacker would be trying to replace.
|
||||
let node_key_before = std::fs::read(identity_dir.join("node_key")).unwrap();
|
||||
let nostr_secret_before = std::fs::read(identity_dir.join("nostr_secret")).unwrap();
|
||||
|
||||
// 4) The attack: a valid but attacker-chosen 24-word mnemonic, posted
|
||||
// unauthenticated at seed.restore.
|
||||
let (attacker_mnemonic, _seed_b) = crate::seed::MasterSeed::generate().unwrap();
|
||||
let attacker_words: Vec<String> =
|
||||
attacker_mnemonic.words().map(str::to_string).collect();
|
||||
assert_eq!(attacker_words.len(), 24);
|
||||
|
||||
let result = super::super::seed_rpc::restore_node_identity_from_words(
|
||||
data_dir,
|
||||
&auth,
|
||||
&attacker_words,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a provisioned node must refuse seed.restore"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(identity_dir.join("node_key")).unwrap(),
|
||||
node_key_before,
|
||||
"identity/node_key was overwritten by an unauthenticated caller (F-01)"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(identity_dir.join("nostr_secret")).unwrap(),
|
||||
nostr_secret_before,
|
||||
"identity/nostr_secret was overwritten by an unauthenticated caller (F-01)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -87,10 +87,113 @@ fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Restore the node's identity from a 24-word BIP-39 mnemonic.
|
||||
///
|
||||
/// This is the production body of `seed.restore`, extracted out of
|
||||
/// `RpcHandler` so the F-01 regression suite can drive the real path against a
|
||||
/// temp data dir without constructing an `RpcHandler` (which needs an
|
||||
/// orchestrator, port allocator, session store and metrics store).
|
||||
///
|
||||
/// **The gate is the first statement and must stay there.** `seed.restore` is
|
||||
/// in `UNAUTHENTICATED_METHODS`, and everything below this line overwrites
|
||||
/// `identity/node_key`, `identity/nostr_secret` and the FIPS transport key
|
||||
/// unconditionally (`identity.rs:79-114`). Without the gate, one unauthenticated
|
||||
/// POST with an attacker-chosen mnemonic hijacks a live node — F-01, Critical.
|
||||
pub(in crate::api::rpc) async fn restore_node_identity_from_words(
|
||||
data_dir: &std::path::Path,
|
||||
auth: &crate::auth::AuthManager,
|
||||
words: &[String],
|
||||
) -> Result<serde_json::Value> {
|
||||
super::onboarding_gate::ensure_onboarding_open(data_dir, auth).await?;
|
||||
|
||||
let phrase = words.join(" ");
|
||||
let (_mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&phrase)?;
|
||||
|
||||
// Stash the restored words like seed.generate does, so auth.setup can
|
||||
// persist the encrypted backup once the user's password exists and
|
||||
// "Reveal recovery phrase" works on restored nodes too.
|
||||
{
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
*state = Some(OnboardingMnemonicState {
|
||||
words: phrase.clone(),
|
||||
created_at: std::time::Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Derive and write node Ed25519 key.
|
||||
let identity_dir = data_dir.join("identity");
|
||||
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?;
|
||||
|
||||
// Derive and write node-level Nostr key.
|
||||
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
|
||||
let secret_hex = nostr_keys.secret_key().display_secret().to_string();
|
||||
let pubkey_hex_nostr = nostr_keys.public_key().to_hex();
|
||||
tokio::fs::write(identity_dir.join("nostr_secret"), secret_hex.as_bytes()).await?;
|
||||
tokio::fs::write(
|
||||
identity_dir.join("nostr_pubkey"),
|
||||
pubkey_hex_nostr.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
tokio::fs::set_permissions(
|
||||
identity_dir.join("nostr_secret"),
|
||||
std::fs::Permissions::from_mode(0o600),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Initialize identity index.
|
||||
crate::seed::save_identity_index(data_dir, 0).await?;
|
||||
|
||||
// Create default identity from seed.
|
||||
let manager = crate::identity_manager::IdentityManager::new(data_dir).await?;
|
||||
manager
|
||||
.create_from_seed(
|
||||
"Personal".to_string(),
|
||||
crate::identity_manager::IdentityPurpose::Personal,
|
||||
&seed,
|
||||
data_dir,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Get DID and npub for the response.
|
||||
let node_key = crate::seed::derive_node_ed25519(&seed)?;
|
||||
let pubkey_hex = hex::encode(node_key.verifying_key().as_bytes());
|
||||
let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?;
|
||||
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
|
||||
|
||||
// Same as seed.generate: the key is materialised, kick the FIPS
|
||||
// service up without user interaction.
|
||||
spawn_post_onboarding_fips_activate(data_dir.to_path_buf());
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"did": did,
|
||||
"nostr_npub": nostr_npub,
|
||||
"restored": true,
|
||||
}))
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
|
||||
/// Returns the words for the user to write down.
|
||||
pub(in crate::api::rpc) async fn handle_seed_generate(&self) -> Result<serde_json::Value> {
|
||||
// Gate BEFORE the lock and before the idempotent fast path. Ordering is
|
||||
// load-bearing in both directions:
|
||||
// * Gate-first is REQUIRED because the fast path below returns the 24
|
||||
// words to an unauthenticated caller. On a provisioned node whose
|
||||
// in-memory mnemonic happened to survive (the encrypted save inside
|
||||
// auth.setup is best-effort and can fail), the fast path is itself a
|
||||
// disclosure — T-10-07.
|
||||
// * Gate-first is SAFE for onboarding because all three gate signals
|
||||
// are false throughout the seed steps: `auth.setup` runs at the very
|
||||
// END of the wizard (router order: onboarding/seed →
|
||||
// onboarding/seed-verify → onboarding/identity → onboarding/done →
|
||||
// /login, where views/Login.vue:405-425 posts auth.setup).
|
||||
super::onboarding_gate::ensure_onboarding_open(&self.config.data_dir, &self.auth_manager)
|
||||
.await?;
|
||||
|
||||
// Serialize concurrent / retried generate calls. The web client aborts
|
||||
// at 15s and retries internally (up to 3x), and the onboarding view
|
||||
// re-fires every 4s while the server is still booting on slow first-boot
|
||||
@@ -160,6 +263,13 @@ impl RpcHandler {
|
||||
|
||||
/// Verify the user wrote down their seed correctly.
|
||||
/// Also confirms the mnemonic by re-deriving and returning DID + npub.
|
||||
///
|
||||
/// D-04 verdict: **deliberately NOT gated.** It compares the submitted
|
||||
/// words against the in-memory copy and re-derives a DID and npub for
|
||||
/// display — it writes no file and mutates no identity (contrast
|
||||
/// `handle_seed_restore`, which calls `NodeIdentity::from_seed`). Leaving
|
||||
/// it open costs nothing, and gating it would break a legitimate retry:
|
||||
/// the view re-submits after a 15s client abort.
|
||||
pub(in crate::api::rpc) async fn handle_seed_verify(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -223,6 +333,10 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// Restore node identity from a 24-word seed phrase.
|
||||
///
|
||||
/// Thin wrapper: parses `params.words` and delegates to
|
||||
/// [`restore_node_identity_from_words`], which carries the onboarding gate
|
||||
/// and the whole restore body.
|
||||
pub(in crate::api::rpc) async fn handle_seed_restore(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -236,80 +350,24 @@ impl RpcHandler {
|
||||
)
|
||||
.context("Invalid words array")?;
|
||||
|
||||
let phrase = words.join(" ");
|
||||
let (_mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&phrase)?;
|
||||
|
||||
// Stash the restored words like seed.generate does, so auth.setup can
|
||||
// persist the encrypted backup once the user's password exists and
|
||||
// "Reveal recovery phrase" works on restored nodes too.
|
||||
{
|
||||
let mut state = ONBOARDING_MNEMONIC.lock().await;
|
||||
*state = Some(OnboardingMnemonicState {
|
||||
words: phrase.clone(),
|
||||
created_at: std::time::Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Derive and write node Ed25519 key.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?;
|
||||
|
||||
// Derive and write node-level Nostr key.
|
||||
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
|
||||
let secret_hex = nostr_keys.secret_key().display_secret().to_string();
|
||||
let pubkey_hex_nostr = nostr_keys.public_key().to_hex();
|
||||
tokio::fs::write(identity_dir.join("nostr_secret"), secret_hex.as_bytes()).await?;
|
||||
tokio::fs::write(
|
||||
identity_dir.join("nostr_pubkey"),
|
||||
pubkey_hex_nostr.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
tokio::fs::set_permissions(
|
||||
identity_dir.join("nostr_secret"),
|
||||
std::fs::Permissions::from_mode(0o600),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Initialize identity index.
|
||||
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
||||
|
||||
// Create default identity from seed.
|
||||
let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?;
|
||||
manager
|
||||
.create_from_seed(
|
||||
"Personal".to_string(),
|
||||
crate::identity_manager::IdentityPurpose::Personal,
|
||||
&seed,
|
||||
&self.config.data_dir,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Get DID and npub for the response.
|
||||
let node_key = crate::seed::derive_node_ed25519(&seed)?;
|
||||
let pubkey_hex = hex::encode(node_key.verifying_key().as_bytes());
|
||||
let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?;
|
||||
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
|
||||
|
||||
// Same as seed.generate: the key is materialised, kick the FIPS
|
||||
// service up without user interaction.
|
||||
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"did": did,
|
||||
"nostr_npub": nostr_npub,
|
||||
"restored": true,
|
||||
}))
|
||||
restore_node_identity_from_words(&self.config.data_dir, &self.auth_manager, &words).await
|
||||
}
|
||||
|
||||
/// Encrypt and save the mnemonic to disk for convenience backup.
|
||||
///
|
||||
/// D-04 note: this method has no UI caller today —
|
||||
/// `neode-ui/src/api/rpc-client.ts:334` exposes it, but no view calls it.
|
||||
/// The encrypted save that actually happens during onboarding is
|
||||
/// `save_pending_seed_encrypted`, called from INSIDE `auth.setup`
|
||||
/// (`api/rpc/auth.rs:239`) once a passphrase exists; that call is behind
|
||||
/// `auth.setup`'s own gate and is therefore not gated again here.
|
||||
pub(in crate::api::rpc) async fn handle_seed_save_encrypted(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
super::onboarding_gate::ensure_onboarding_open(&self.config.data_dir, &self.auth_manager)
|
||||
.await?;
|
||||
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let passphrase = params
|
||||
.get("passphrase")
|
||||
|
||||
@@ -101,6 +101,37 @@ impl EndpointRateLimiter {
|
||||
// DID rotation: sensitive identity operation
|
||||
limits.insert("node.rotate-did".to_string(), (3, 600));
|
||||
|
||||
// ── Unauthenticated onboarding mutators (F-01 / KEY-01) ─────────────
|
||||
//
|
||||
// These are in UNAUTHENTICATED_METHODS and can write node key
|
||||
// material, so they are rate-limited as defence in depth behind
|
||||
// `api::rpc::onboarding_gate`. The numbers are deliberately GENEROUS
|
||||
// rather than minimal, because a 429 here is a hard, user-visible
|
||||
// failure at the DID-creation screen — exactly the failure the
|
||||
// in-memory generate lock (`seed_rpc.rs:97-116`) was written to
|
||||
// prevent. A 429 comes back as `{"error":{"code":429,...}}` with
|
||||
// "Rate limit exceeded. Try again later." (`api/rpc/mod.rs:506-519`)
|
||||
// over HTTP 429, and neither the onboarding view's transient-error
|
||||
// regex (`OnboardingSeedGenerate.vue:243`) nor `rpc-client.ts`'s
|
||||
// retryable check (502/503 only) matches it — so a too-tight limit
|
||||
// surfaces to the user as "onboarding is broken".
|
||||
//
|
||||
// seed.generate — derivation: the view's 4s silent retry loop
|
||||
// (`OnboardingSeedGenerate.vue:265-268`) only fires on transient /
|
||||
// network errors, i.e. when the daemon is not answering at all, so the
|
||||
// limiter never sees those. What DOES reach the limiter is the
|
||||
// 30s-timeout aborts plus rpc-client's internal retries — roughly one
|
||||
// user-visible attempt per 30s, i.e. ~10 per 300s worst case. 20/300s
|
||||
// is ~6x the realistic budget and ~2x the pathological one.
|
||||
limits.insert("seed.generate".to_string(), (20, 300));
|
||||
// seed.restore — the audit suggests matching auth.changePassword at
|
||||
// 3/300s. REJECTED with cause: `rpc-client.ts:196-215` retries a single
|
||||
// call up to 3 times, so 3/300s would burn a user's entire budget on
|
||||
// one submit of a mistyped seed phrase and lock them out of the retry.
|
||||
limits.insert("seed.restore".to_string(), (10, 300));
|
||||
limits.insert("seed.save-encrypted".to_string(), (10, 300));
|
||||
limits.insert("backup.restore-identity".to_string(), (10, 300));
|
||||
|
||||
Self {
|
||||
requests: Arc::new(RwLock::new(HashMap::new())),
|
||||
limits: Arc::new(limits),
|
||||
@@ -195,4 +226,66 @@ mod tests {
|
||||
// ip2 should still be allowed
|
||||
assert!(limiter.check(ip2).await);
|
||||
}
|
||||
|
||||
fn test_ip() -> IpAddr {
|
||||
IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
|
||||
}
|
||||
|
||||
/// seed.generate is rate-limited, but not below the real client retry
|
||||
/// budget: 20 attempts in the window are allowed, the 21st is refused.
|
||||
#[tokio::test]
|
||||
async fn seed_generate_allows_twenty_then_limits() {
|
||||
let limiter = EndpointRateLimiter::new();
|
||||
let ip = test_ip();
|
||||
|
||||
for i in 0..20 {
|
||||
assert!(
|
||||
limiter.check("seed.generate", ip).await,
|
||||
"attempt {i} must be allowed — a 429 here is a hard failure at \
|
||||
the DID-creation screen"
|
||||
);
|
||||
limiter.record("seed.generate", ip).await;
|
||||
}
|
||||
|
||||
assert!(
|
||||
!limiter.check("seed.generate", ip).await,
|
||||
"the 21st attempt in the window must be refused"
|
||||
);
|
||||
}
|
||||
|
||||
/// One user submit of a seed phrase costs up to 1 + 3 internal retries
|
||||
/// (`rpc-client.ts:196-215`). The limit must clear that comfortably, which
|
||||
/// is why the audit's suggested 3/300s was rejected.
|
||||
#[tokio::test]
|
||||
async fn seed_restore_allows_a_full_submit_with_its_retries() {
|
||||
let limiter = EndpointRateLimiter::new();
|
||||
let ip = test_ip();
|
||||
|
||||
for i in 0..4 {
|
||||
assert!(
|
||||
limiter.check("seed.restore", ip).await,
|
||||
"call {i} of one user submit + its internal retries must be allowed"
|
||||
);
|
||||
limiter.record("seed.restore", ip).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// All four onboarding mutators are actually registered — a typo'd key
|
||||
/// silently means "not rate-limited at all" (`check` returns true for
|
||||
/// unknown methods).
|
||||
#[tokio::test]
|
||||
async fn onboarding_mutators_are_registered() {
|
||||
let limiter = EndpointRateLimiter::new();
|
||||
for method in [
|
||||
"seed.generate",
|
||||
"seed.restore",
|
||||
"seed.save-encrypted",
|
||||
"backup.restore-identity",
|
||||
] {
|
||||
assert!(
|
||||
limiter.limits.contains_key(method),
|
||||
"{method} has no rate limit entry"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user