diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 2ad5b70759..03debda18e 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -111,12 +111,14 @@ pub async fn sign_event( tags: Vec>, state: State<'_, AppState>, ) -> Result, String> { - let keys = state.signing_keys()?; - // Admit BEFORE signing (the F1 lesson): the sign that derives this artifact - // is a leased owner-identity operation, and the artifact is stamped with - // the lease's generation so a signature produced across a transition fails - // closed at its frontend application site (C6/C7). + // Admit BEFORE reading the owner key (the F1 lesson): the admitted lease + // pins the identity generation, and `begin_egress_drain` cannot proceed + // while it is in flight — so the key clone below cannot observe a + // mid-transition swap, and the artifact is stamped with the lease's + // generation so a signature produced across a transition fails closed at + // its frontend application site (C6/C7). let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?; + let keys = state.signing_keys()?; tauri::async_runtime::spawn_blocking(move || { let nostr_tags = tags @@ -147,10 +149,11 @@ pub async fn decrypt_observer_event( event_json: String, state: State<'_, AppState>, ) -> Result, String> { - let keys = state.signing_keys()?; - // Admit BEFORE decrypting: the owner-key decryption is a leased operation - // and the decrypted payload is a stamped artifact — see `sign_event`. + // Admit BEFORE reading the owner key: the admitted lease pins the identity + // generation for the key clone below, and the decrypted payload is a + // stamped artifact — see `sign_event`. let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?; + let keys = state.signing_keys()?; tauri::async_runtime::spawn_blocking(move || { let event = @@ -180,10 +183,11 @@ pub async fn build_observer_control_event( payload: serde_json::Value, state: State<'_, AppState>, ) -> Result, String> { - let keys = state.signing_keys()?; - // Admit BEFORE encrypt+sign: the owner-signed observer control frame is a - // leased artifact stamped with the lease's generation — see `sign_event`. + // Admit BEFORE reading the owner key: the admitted lease pins the identity + // generation for the key clone below, and the owner-signed observer control + // frame is a stamped artifact — see `sign_event`. let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?; + let keys = state.signing_keys()?; let agent_pubkey = PublicKey::from_hex(agent_pubkey.trim()) .map_err(|error| format!("invalid agent pubkey: {error}"))?; let agent_pubkey_hex = agent_pubkey.to_hex(); @@ -209,13 +213,14 @@ pub async fn build_observer_control_event( pub async fn get_nsec( state: State<'_, AppState>, ) -> Result, String> { - let keys = state.signing_keys()?; - // Admit BEFORE reading the secret key (the F1 lesson): the raw-identity - // export is a leased owner-identity operation and its value is a P33 - // identity-export artifact stamped with the issuing lease's generation, so - // an nsec revealed across a transition fails closed at its frontend - // reveal/copy boundary (C6/C7). + // Admit BEFORE reading the secret key (the F1 lesson): the admitted lease + // pins the identity generation, and `begin_egress_drain` cannot proceed + // while it is in flight — so the secret-key read below cannot observe a + // mid-transition swap. The value is a P33 identity-export artifact stamped + // with the issuing lease's generation, so an nsec revealed across a + // transition fails closed at its frontend reveal/copy boundary (C6/C7). let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?; + let keys = state.signing_keys()?; let nsec = keys .secret_key() .to_bech32() @@ -637,10 +642,10 @@ pub async fn sign_nostr_identity_binding( &expires_at, )?; - let keys = state.signing_keys()?; - // Admit BEFORE signing: the identity-binding response is a leased artifact - // stamped with the lease's generation — see `sign_event`. + // Admission precedes the key clone: the lease pins the identity generation + // for the signed artifact — see `sign_event`. let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?; + let keys = state.signing_keys()?; tauri::async_runtime::spawn_blocking(move || { let event = build_nostr_identity_binding_event( @@ -665,7 +670,6 @@ pub async fn create_auth_event( relay_url: String, state: State<'_, AppState>, ) -> Result, String> { - let keys = state.signing_keys()?; // The relay-WS reconnection handshake signs a NIP-42 auth event under a // per-send bounded egress lease: reconnection cannot re-authenticate // mid-transition because the drain refuses new admission (spec — the @@ -675,6 +679,7 @@ pub async fn create_auth_event( // session REGISTRATION (native-WS teardown handle) defers to C6/C7 with the // frontend identity store. let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?; + let keys = state.signing_keys()?; tauri::async_runtime::spawn_blocking(move || { let tags = vec![ @@ -702,10 +707,10 @@ pub async fn nip44_encrypt_to_self( plaintext: String, state: State<'_, AppState>, ) -> Result, String> { - let keys = state.signing_keys()?; - // Admit BEFORE encrypting: the encrypt-to-self ciphertext is a leased - // artifact stamped with the lease's generation — see `sign_event`. + // Admission precedes the key clone: the lease pins the identity generation + // for the ciphertext artifact — see `sign_event`. let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?; + let keys = state.signing_keys()?; tauri::async_runtime::spawn_blocking(move || { let ciphertext = nip44::encrypt( @@ -727,10 +732,10 @@ pub async fn nip44_decrypt_from_self( ciphertext: String, state: State<'_, AppState>, ) -> Result, String> { - let keys = state.signing_keys()?; - // Admit BEFORE decrypting: the decrypted plaintext is a leased artifact - // stamped with the lease's generation — see `sign_event`. + // Admission precedes the key clone: the lease pins the identity generation + // for the plaintext artifact — see `sign_event`. let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?; + let keys = state.signing_keys()?; tauri::async_runtime::spawn_blocking(move || { let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &ciphertext) @@ -749,3 +754,7 @@ mod nostr_identity_binding_tests; #[cfg(test)] #[path = "identity_key_backup_tests.rs"] mod identity_key_backup_tests; + +#[cfg(test)] +#[path = "identity_egress_ordering_tests.rs"] +mod identity_egress_ordering_tests; diff --git a/desktop/src-tauri/src/commands/identity_egress_ordering_tests.rs b/desktop/src-tauri/src/commands/identity_egress_ordering_tests.rs new file mode 100644 index 0000000000..5cdb36ecb3 --- /dev/null +++ b/desktop/src-tauri/src/commands/identity_egress_ordering_tests.rs @@ -0,0 +1,170 @@ +// C2 structural tripwire: every direct owner-artifact command that reads +// `state.signing_keys()` must admit before its owner-key read. +// +// The commands take `State`, which a `tauri::test` mock cannot swap +// mid-call, so the "clone A's key → pause → transition commits B → admit at +// B" race has no hermetic runtime seam at the command boundary. It is instead +// pinned STRUCTURALLY: `try_admit_owner_identity_egress()` holds a lease that +// `begin_egress_drain` must await before B commits, so a key read that happens +// AFTER admission cannot observe a mid-transition swap. This scan asserts that +// ordering for every command in the C2 set — a future edit that reads +// `signing_keys()` before admitting fails to compile-in the bug because this +// test rejects it. The lease-blocks-the-drain half of the invariant is driven +// at the registry seam in +// `owner_identity_egress::tests::key_read_under_lease_blocks_commit_b`. + +/// The owner-key read accessor guarded by admission. `state.signing_keys()` is +/// the sole owner-key clone in the leased commands. +fn key_read_needle() -> String { + ["signing_", "keys()"].concat() +} + +/// The admission call that must precede every key read. +fn admit_needle() -> String { + ["try_admit_owner_", "identity_egress"].concat() +} + +/// The direct owner-artifact commands whose artifacts can be stamped as a +/// post-transition identity if they clone the owner key before admission. +const C2_COMMANDS: &[&str] = &[ + // The C2 artifact-command universe is closed by the owner-artifact inventory + // in `owner_identity_egress`: every direct producer that reads + // `state.signing_keys()` must admit before that read. The NIP-49 backup is + // structurally different — its helper reads under its caller-held + // `identity_mutation` + admitted lease, which the C3 schedule and helper + // precondition test pin separately. + "pub async fn sign_event(", + "pub async fn decrypt_observer_event(", + "pub async fn build_observer_control_event(", + "pub async fn get_nsec(", + "pub async fn sign_nostr_identity_binding(", + "pub async fn create_auth_event(", + "pub async fn nip44_encrypt_to_self(", + "pub async fn nip44_decrypt_from_self(", +]; + +/// Split the module source into `(fn signature line, body)` segments at +/// top-level `fn` boundaries (column-0 `fn`, `pub fn`, `pub async fn`, +/// `pub(crate) fn`). Every leased command is a top-level fn, so this bounds +/// each body without a brace parser. +fn top_level_fns(content: &str) -> Vec<(String, String)> { + let is_fn_decl = |line: &str| { + line.starts_with("fn ") + || line.starts_with("pub fn ") + || line.starts_with("pub async fn ") + || line.starts_with("pub(crate) fn ") + || line.starts_with("async fn ") + }; + let mut segments = Vec::new(); + let mut current: Option<(String, Vec<&str>)> = None; + for line in content.lines() { + if is_fn_decl(line) { + if let Some((sig, body)) = current.take() { + segments.push((sig, body.join("\n"))); + } + current = Some((line.to_string(), Vec::new())); + } else if let Some((_, body)) = current.as_mut() { + body.push(line); + } + } + if let Some((sig, body)) = current.take() { + segments.push((sig, body.join("\n"))); + } + segments +} + +/// First non-comment line index of `needle` in a body, or `None`. +fn first_call(body: &str, needle: &str) -> Option { + body.lines().enumerate().find_map(|(i, line)| { + let trimmed = line.trim_start(); + (!trimmed.starts_with("//") && trimmed.contains(needle)).then_some(i) + }) +} + +/// Violations: a command in the C2 set either omits admission or reads the +/// owner key before it admits. +fn admission_ordering_violations(content: &str) -> Vec { + let key = key_read_needle(); + let admit = admit_needle(); + let mut violations = Vec::new(); + for (sig, body) in top_level_fns(content) { + if !C2_COMMANDS.iter().any(|command| sig.contains(command)) { + continue; + } + let Some(admit_at) = first_call(&body, &admit) else { + violations.push(format!( + "{}: C2 command must admit before reading {key}", + sig.trim() + )); + continue; + }; + let Some(key_at) = first_call(&body, &key) else { + violations.push(format!( + "{}: C2 command must read {key} under its admission lease", + sig.trim() + )); + continue; + }; + if key_at < admit_at { + violations.push(format!( + "{}: reads {key} at body line {} but admits the lease at body line {} — \ + admission MUST precede every owner-key read (C2)", + sig.trim(), + key_at + 1, + admit_at + 1, + )); + } + } + violations +} + +fn module_source() -> String { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/commands/identity.rs"); + std::fs::read_to_string(path).unwrap() +} + +/// Every leased owner-identity command admits before it reads the owner key. +#[test] +fn leased_commands_admit_before_reading_the_owner_key() { + let violations = admission_ordering_violations(&module_source()); + assert!( + violations.is_empty(), + "owner-key read precedes admission — a captured pre-transition key can be \ + stamped as the post-transition generation (C2). Admit first:\n{}", + violations.join("\n") + ); +} + +/// Mutation proof: reordering a command back to read-then-admit trips the scan. +#[test] +fn scan_catches_a_read_before_admit() { + let content = format!( + "pub async fn sign_event(state: State) {{\n \ + let keys = state.{}?;\n \ + let lease = crate::owner_identity_egress::{}().await?;\n}}\n", + key_read_needle(), + admit_needle(), + ); + let violations = admission_ordering_violations(&content); + assert!( + violations.iter().any(|v| v.contains("sign_event")), + "a read-before-admit command must trip the scan: {violations:?}" + ); +} + +/// The helper's caller holds `identity_mutation` plus the admitted lease; +/// its internal key read is not itself a command-admission boundary and must +/// not register as a C2 violation. +#[test] +fn scan_ignores_non_leased_key_readers() { + let content = format!( + "pub(crate) fn create_backup_with_log_n(state: &AppState) {{\n \ + let _guard = state.identity_mutation.blocking_lock();\n \ + let keys = state.{}?;\n}}\n", + key_read_needle(), + ); + assert!( + admission_ordering_violations(&content).is_empty(), + "a key reader that never admits a lease is out of C2 scope" + ); +} diff --git a/desktop/src-tauri/src/owner_identity_egress/mod.rs b/desktop/src-tauri/src/owner_identity_egress/mod.rs index 0437a5e3e5..ea0aa55795 100644 --- a/desktop/src-tauri/src/owner_identity_egress/mod.rs +++ b/desktop/src-tauri/src/owner_identity_egress/mod.rs @@ -886,4 +886,55 @@ mod tests { "the refusal must name the indeterminate recovery state: {err}" ); } + + /// C2 schedule at the registry seam: once a command admits (the ordering + /// the `identity.rs` scan enforces), the owner-key read that follows runs + /// UNDER the held lease, and a transition's `begin_egress_drain` + + /// `wait_egress_drain_blocking` cannot complete — so commit-B cannot + /// proceed — until that lease drops. This closes the "clone A's key, pause, + /// let B commit, admit at B" race: the admitted lease pins generation A + /// across the whole read, and the drain that would bump to B is forced to + /// wait it out. (The command boundary itself takes `State`, which + /// a mock cannot swap mid-call, so the property is pinned here at the + /// hermetically drivable seam and structurally in the `identity.rs` + /// ordering scan.) + #[test] + fn key_read_under_lease_blocks_commit_b() { + let _g = guard(); + + // The command admits first; the key read happens under this lease. + let lease = admit_owner_identity_after_wait().unwrap(); + let read_generation = lease.generation(); + assert_eq!( + read_generation, + current_identity_persistence_generation(), + "the key read observes generation A under the held lease" + ); + + // A transition begins draining toward B. It bumps the generation and + // refuses NEW admission, but must await this in-flight lease before it + // can commit B. + let bumped = begin_egress_drain().unwrap(); + assert!(bumped > read_generation, "the drain bumps toward B"); + assert_eq!( + lock_inner().in_flight, + 1, + "the pre-drain lease is in flight" + ); + + // Drop the lease from another thread AFTER the blocking wait parks — + // the drain (and thus commit-B) can only make progress once the + // A-generation read has finished and released its lease. + let handle = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(50)); + drop(lease); + }); + wait_egress_drain_blocking(); + assert_eq!( + lock_inner().in_flight, + 0, + "commit-B waited out the A-generation key read" + ); + handle.join().unwrap(); + } }