fix(identity): admit artifacts before reading owner keys

Bind every direct owner-key artifact read to its admitted egress generation so
an identity transition cannot stamp an A-derived artifact as B-current.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-19 18:31:17 -04:00
parent 557c05645f
commit 249c4943df
3 changed files with 257 additions and 27 deletions

View File

@@ -111,12 +111,14 @@ pub async fn sign_event(
tags: Vec<Vec<String>>,
state: State<'_, AppState>,
) -> Result<crate::owner_identity_egress::StampedArtifact<String>, 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<crate::owner_identity_egress::StampedArtifact<serde_json::Value>, 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<crate::owner_identity_egress::StampedArtifact<String>, 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<crate::owner_identity_egress::StampedArtifact<String>, 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<crate::owner_identity_egress::StampedArtifact<String>, 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<crate::owner_identity_egress::StampedArtifact<String>, 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<crate::owner_identity_egress::StampedArtifact<String>, 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;

View File

@@ -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<AppState>`, 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<usize> {
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<String> {
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"
);
}

View File

@@ -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<AppState>`, 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();
}
}