mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
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:
@@ -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;
|
||||
|
||||
170
desktop/src-tauri/src/commands/identity_egress_ordering_tests.rs
Normal file
170
desktop/src-tauri/src/commands/identity_egress_ordering_tests.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user