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))
|
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(
|
pub(super) async fn handle_auth_setup(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<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?;
|
let is_setup = self.auth_manager.is_setup().await?;
|
||||||
if is_setup {
|
if is_setup {
|
||||||
tracing::warn!("[onboarding] setup rejected — already set up");
|
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 params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let password = params
|
let password = params
|
||||||
.get("password")
|
.get("password")
|
||||||
@@ -247,7 +262,32 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::json!(true))
|
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> {
|
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?;
|
self.auth_manager.complete_onboarding().await?;
|
||||||
tracing::info!("[onboarding] onboarding marked complete");
|
tracing::info!("[onboarding] onboarding marked complete");
|
||||||
|
|
||||||
|
|||||||
@@ -406,10 +406,20 @@ impl RpcHandler {
|
|||||||
|
|
||||||
/// Restore identity from an encrypted DID backup JSON.
|
/// Restore identity from an encrypted DID backup JSON.
|
||||||
/// Params: { backup: { version, blob, ... }, passphrase }
|
/// 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(
|
pub(super) async fn handle_backup_restore_identity(
|
||||||
&self,
|
&self,
|
||||||
params: &serde_json::Value,
|
params: &serde_json::Value,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
|
super::onboarding_gate::ensure_onboarding_open(&self.config.data_dir, &self.auth_manager)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let backup = params
|
let backup = params
|
||||||
.get("backup")
|
.get("backup")
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'backup' parameter"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'backup' parameter"))?;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ mod names;
|
|||||||
mod network;
|
mod network;
|
||||||
mod node;
|
mod node;
|
||||||
mod nostr;
|
mod nostr;
|
||||||
|
mod onboarding_gate;
|
||||||
mod openwrt;
|
mod openwrt;
|
||||||
mod package;
|
mod package;
|
||||||
pub(crate) use package::wyoming_satellite_keeper;
|
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 {
|
impl RpcHandler {
|
||||||
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
|
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
|
||||||
/// Returns the words for the user to write down.
|
/// Returns the words for the user to write down.
|
||||||
pub(in crate::api::rpc) async fn handle_seed_generate(&self) -> Result<serde_json::Value> {
|
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
|
// Serialize concurrent / retried generate calls. The web client aborts
|
||||||
// at 15s and retries internally (up to 3x), and the onboarding view
|
// 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
|
// 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.
|
/// Verify the user wrote down their seed correctly.
|
||||||
/// Also confirms the mnemonic by re-deriving and returning DID + npub.
|
/// 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(
|
pub(in crate::api::rpc) async fn handle_seed_verify(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
@@ -223,6 +333,10 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Restore node identity from a 24-word seed phrase.
|
/// 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(
|
pub(in crate::api::rpc) async fn handle_seed_restore(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
@@ -236,80 +350,24 @@ impl RpcHandler {
|
|||||||
)
|
)
|
||||||
.context("Invalid words array")?;
|
.context("Invalid words array")?;
|
||||||
|
|
||||||
let phrase = words.join(" ");
|
restore_node_identity_from_words(&self.config.data_dir, &self.auth_manager, &words).await
|
||||||
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,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encrypt and save the mnemonic to disk for convenience backup.
|
/// 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(
|
pub(in crate::api::rpc) async fn handle_seed_save_encrypted(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<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 params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let passphrase = params
|
let passphrase = params
|
||||||
.get("passphrase")
|
.get("passphrase")
|
||||||
|
|||||||
@@ -101,6 +101,37 @@ impl EndpointRateLimiter {
|
|||||||
// DID rotation: sensitive identity operation
|
// DID rotation: sensitive identity operation
|
||||||
limits.insert("node.rotate-did".to_string(), (3, 600));
|
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 {
|
Self {
|
||||||
requests: Arc::new(RwLock::new(HashMap::new())),
|
requests: Arc::new(RwLock::new(HashMap::new())),
|
||||||
limits: Arc::new(limits),
|
limits: Arc::new(limits),
|
||||||
@@ -195,4 +226,66 @@ mod tests {
|
|||||||
// ip2 should still be allowed
|
// ip2 should still be allowed
|
||||||
assert!(limiter.check(ip2).await);
|
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