diff --git a/core/archipelago/src/api/rpc/auth.rs b/core/archipelago/src/api/rpc/auth.rs index de499aab..e9b49ef3 100644 --- a/core/archipelago/src/api/rpc/auth.rs +++ b/core/archipelago/src/api/rpc/auth.rs @@ -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, ) -> Result { - // 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 { + super::onboarding_gate::ensure_user_account_exists(&self.auth_manager).await?; + self.auth_manager.complete_onboarding().await?; tracing::info!("[onboarding] onboarding marked complete"); diff --git a/core/archipelago/src/api/rpc/backup_rpc.rs b/core/archipelago/src/api/rpc/backup_rpc.rs index 1a79b0ff..f93d2167 100644 --- a/core/archipelago/src/api/rpc/backup_rpc.rs +++ b/core/archipelago/src/api/rpc/backup_rpc.rs @@ -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 { + 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"))?; diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 83b0de23..7dd8f8c5 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -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; diff --git a/core/archipelago/src/api/rpc/onboarding_gate.rs b/core/archipelago/src/api/rpc/onboarding_gate.rs new file mode 100644 index 00000000..ccc05344 --- /dev/null +++ b/core/archipelago/src/api/rpc/onboarding_gate.rs @@ -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 = + 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)" + ); + } +} diff --git a/core/archipelago/src/api/rpc/seed_rpc.rs b/core/archipelago/src/api/rpc/seed_rpc.rs index a84762e5..ea81f2f5 100644 --- a/core/archipelago/src/api/rpc/seed_rpc.rs +++ b/core/archipelago/src/api/rpc/seed_rpc.rs @@ -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 { + 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 { + // 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, @@ -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, @@ -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, ) -> Result { + 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") diff --git a/core/archipelago/src/rate_limit.rs b/core/archipelago/src/rate_limit.rs index 92cb49a1..7eedc9b2 100644 --- a/core/archipelago/src/rate_limit.rs +++ b/core/archipelago/src/rate_limit.rs @@ -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" + ); + } + } }