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:
archipelago
2026-08-02 13:05:35 -04:00
co-authored by Claude Opus 5
parent 49345b67ed
commit 879de59ecc
6 changed files with 683 additions and 68 deletions
+41 -1
View File
@@ -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"))?;
+1
View File
@@ -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)"
);
}
}
+125 -67
View File
@@ -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")
+93
View File
@@ -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"
);
}
}
}