diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index b433b534f8..4f232ea345 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -110,8 +110,13 @@ pub async fn sign_event( created_at: Option, tags: Vec>, state: State<'_, AppState>, -) -> Result { +) -> 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). + let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?; tauri::async_runtime::spawn_blocking(move || { let nostr_tags = tags @@ -128,7 +133,10 @@ pub async fn sign_event( .sign_with_keys(&keys) .map_err(|error| format!("sign failed: {error}"))?; - Ok(event.as_json()) + // Stamp the issuing lease's generation, then wrap the value; the + // frontend threads {value, artifact} opaque — validated in C6/C7. + let artifact = crate::owner_identity_egress::register_owner_artifact(&lease); + Ok(artifact.stamp_value(event.as_json())) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? @@ -138,8 +146,11 @@ pub async fn sign_event( pub async fn decrypt_observer_event( event_json: String, state: State<'_, AppState>, -) -> Result { +) -> 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`. + let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?; tauri::async_runtime::spawn_blocking(move || { let event = @@ -153,20 +164,26 @@ pub async fn decrypt_observer_event( return Err("observer event has invalid signature".into()); } - buzz_core_pkg::observer::decrypt_observer_payload(&keys, &event) - .map_err(|error| format!("decrypt observer event failed: {error}")) + let payload = buzz_core_pkg::observer::decrypt_observer_payload(&keys, &event) + .map_err(|error| format!("decrypt observer event failed: {error}"))?; + // Stamp the decrypted payload; frontend validates in C6/C7. + let artifact = crate::owner_identity_egress::register_owner_artifact(&lease); + Ok(artifact.stamp_value(payload)) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn build_observer_control_event( +pub async fn build_observer_control_event( agent_pubkey: String, payload: serde_json::Value, state: State<'_, AppState>, -) -> Result { +) -> 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`. + let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?; 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(); @@ -183,7 +200,9 @@ pub fn build_observer_control_event( let event = builder .sign_with_keys(&keys) .map_err(|error| format!("sign observer control failed: {error}"))?; - Ok(event.as_json()) + // Stamp the issuing lease's generation; frontend validates in C6/C7. + let artifact = crate::owner_identity_egress::register_owner_artifact(&lease); + Ok(artifact.stamp_value(event.as_json())) } #[tauri::command] @@ -825,7 +844,7 @@ pub async fn sign_nostr_identity_binding( origin: String, expires_at: String, state: State<'_, AppState>, -) -> Result { +) -> Result, String> { nostr_bind::validate_signing_request( &challenge_id, &nonce, @@ -835,6 +854,9 @@ pub async fn sign_nostr_identity_binding( )?; 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`. + let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?; tauri::async_runtime::spawn_blocking(move || { let event = build_nostr_identity_binding_event( @@ -846,7 +868,8 @@ pub async fn sign_nostr_identity_binding( &expires_at, )?; - Ok(event.as_json()) + let artifact = crate::owner_identity_egress::register_owner_artifact(&lease); + Ok(artifact.stamp_value(event.as_json())) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? @@ -857,15 +880,16 @@ pub async fn create_auth_event( challenge: String, relay_url: String, state: State<'_, AppState>, -) -> Result { +) -> 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 // frontend session's authority is gated the same as every other owner - // sign). Frontend session REGISTRATION (capability + native-WS teardown - // handle) defers to C6/C7 with the frontend identity store; this lands the - // spec-mandated admission gate now. + // sign). The signed auth event is an owner-signed relay-event ARTIFACT + // (stamped and threaded to the frontend, validated in C6/C7); frontend + // 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?; tauri::async_runtime::spawn_blocking(move || { @@ -880,9 +904,10 @@ pub async fn create_auth_event( .tags(tags) .sign_with_keys(&keys) .map_err(|error| format!("sign failed: {error}"))?; - // Sign complete: the lease has served its sign→return window. - drop(lease); - Ok(event.as_json()) + // Stamp the issuing lease's generation, consuming the lease as the + // sign→return window closes; frontend validates in C6/C7. + let artifact = crate::owner_identity_egress::register_owner_artifact(&lease); + Ok(artifact.stamp_value(event.as_json())) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? @@ -892,17 +917,22 @@ pub async fn create_auth_event( pub async fn nip44_encrypt_to_self( plaintext: String, state: State<'_, AppState>, -) -> Result { +) -> 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`. + let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?; tauri::async_runtime::spawn_blocking(move || { - nip44::encrypt( + let ciphertext = nip44::encrypt( keys.secret_key(), &keys.public_key(), &plaintext, nip44::Version::V2, ) - .map_err(|e| format!("nip44 encrypt failed: {e}")) + .map_err(|e| format!("nip44 encrypt failed: {e}"))?; + let artifact = crate::owner_identity_egress::register_owner_artifact(&lease); + Ok(artifact.stamp_value(ciphertext)) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? @@ -912,12 +942,17 @@ pub async fn nip44_encrypt_to_self( pub async fn nip44_decrypt_from_self( ciphertext: String, state: State<'_, AppState>, -) -> Result { +) -> 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`. + let lease = crate::owner_identity_egress::try_admit_owner_identity_egress().await?; tauri::async_runtime::spawn_blocking(move || { - nip44::decrypt(keys.secret_key(), &keys.public_key(), &ciphertext) - .map_err(|e| format!("nip44 decrypt failed: {e}")) + let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &ciphertext) + .map_err(|e| format!("nip44 decrypt failed: {e}"))?; + let artifact = crate::owner_identity_egress::register_owner_artifact(&lease); + Ok(artifact.stamp_value(plaintext)) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? diff --git a/desktop/src-tauri/src/owner_identity_egress/durable.rs b/desktop/src-tauri/src/owner_identity_egress/durable.rs index 049e41c715..f5faaa938a 100644 --- a/desktop/src-tauri/src/owner_identity_egress/durable.rs +++ b/desktop/src-tauri/src/owner_identity_egress/durable.rs @@ -11,6 +11,7 @@ use std::collections::HashMap; use std::sync::atomic::Ordering; use std::sync::{LazyLock, Mutex}; +use serde::Serialize; use tokio_util::sync::CancellationToken; use super::{lock_inner, IdentityPersistenceState, OwnerIdentityEgressLease, REGISTRY}; @@ -86,17 +87,45 @@ pub struct SessionPolicy; #[derive(Debug)] pub struct BearerPolicy; +/// A generation-stamped owner-key-derived VALUE (owner-signed relay event JSON, +/// identity-binding response, encrypt-to-self ciphertext, or the plaintext of +/// an owner-key decryption) that leaves its producing lease and is exercised — +/// exported, published, signed around, or applied to identity-indexed state — +/// LATER. Unlike a session or bearer, an artifact owns NO side-effecting +/// teardown: a stamped `String`/JSON value has nothing to cancel or remove. Its +/// only revocation mechanism is the exercise-time generation compare, which the +/// generation bump at [`begin_egress_drain`] triggers by construction — so the +/// registry keeps NO artifact entry for the barrier to invalidate (shape (ii), +/// acked by Paul 2026-08-18). The registered revocation handle is therefore +/// [`RevocationHandle::Artifact`], a marker carrying no teardown authority; the +/// capability's `id` is correlation/diagnostic only, never a revocation target. +/// +/// Rust-consumed / collapsed-transaction artifacts (project git-workflow +/// sign+submit, archive-ingest decrypts, snapshot-import, engram-listing +/// decrypt) exercise synchronously inside their producing lease and validate +/// via [`OwnerIdentityCapability::admit_exercise`] before their local +/// application — identical to a bounded lease. Boundary-crossing artifacts (the +/// frontend-consumed producers) serialize their stamp across the Tauri boundary +/// as [`ArtifactStamp`]; the frontend threads it opaque until C6/C7 adds the +/// generation-compare at each application site. +#[derive(Debug)] +pub struct ArtifactPolicy; + impl CapabilityPolicy for SessionPolicy { const KIND: &'static str = "session"; } impl CapabilityPolicy for BearerPolicy { const KIND: &'static str = "bearer"; } +impl CapabilityPolicy for ArtifactPolicy { + const KIND: &'static str = "artifact"; +} mod private { pub trait Sealed {} impl Sealed for super::SessionPolicy {} impl Sealed for super::BearerPolicy {} + impl Sealed for super::ArtifactPolicy {} } /// The revocation authority for one registered durable capability, invoked by @@ -109,6 +138,16 @@ enum RevocationHandle { /// invalidation, so no side-effecting handle is needed. The variant exists /// so the registry records the capability's kind for the per-kind barrier. Bearer, + /// Artifact invalidation is BY GENERATION BUMP, not by entry: a stamped + /// value owns no side-effecting teardown, and its only revocation is the + /// exercise-time / application-site generation compare failing once the + /// bump advances the current generation (shape (ii)). The registry keeps NO + /// artifact entry for the barrier to invalidate — this marker exists only + /// so `register_owner_artifact` shares the one registration path; the entry + /// is removed immediately on the handle's Drop (serialization time for a + /// boundary-crossing artifact, scope end for a collapsed one), so the id is + /// correlation/diagnostic only, never a revocation target. + Artifact, } /// Registry of live durable capabilities, keyed by capability id, under its @@ -234,6 +273,85 @@ pub fn register_owner_bearer( register_durable(lease.generation(), RevocationHandle::Bearer) } +/// The wire form of an artifact's generation stamp, serialized across the Tauri +/// boundary alongside the owner-key-derived value as `{ value, artifact }`. +/// +/// The frontend threads this pair OPAQUE (never unwrapping and discarding it at +/// the adapter — the witness must survive to the application site); C6/C7 adds +/// the generation-compare at each application boundary using `generation`. The +/// `id` is correlation/diagnostic only (log correlation, completeness count), +/// never a revocation target — see [`ArtifactPolicy`] for the shape (ii) +/// rationale. +#[derive(Debug, Clone, Serialize)] +pub struct ArtifactStamp { + /// Correlation id — monotonic, diagnostic only, never a revocation target. + pub id: u64, + /// The identity-persistence generation this artifact was issued under; the + /// application-site compare (C6/C7) refuses once the current generation + /// advances past it. + pub generation: u64, +} + +/// The serialized `{ value, artifact }` pair a boundary-crossing producer +/// returns across the Tauri boundary. The frontend threads this OPAQUE (the +/// witness must survive to the application site — never unwrap-and-discard at +/// the adapter); C6/C7 adds the generation-compare against `artifact.generation` +/// immediately before each irreversible application effect. +#[derive(Debug, Serialize)] +pub struct StampedArtifact { + /// The owner-key-derived value (signed event JSON, ciphertext, decrypted + /// payload). Consumers destructure `.value` at their current use point. + pub value: T, + /// The generation stamp carried alongside the value. + pub artifact: ArtifactStamp, +} + +impl OwnerIdentityCapability { + /// The wire stamp for a boundary-crossing artifact, serialized alongside + /// the value as `{ value, artifact: ArtifactStamp }`. Producing the stamp + /// does not consume the capability; the caller drops the capability once + /// the pair is serialized (the entry is correlation-only — see + /// [`ArtifactPolicy`]). + pub fn stamp(&self) -> ArtifactStamp { + ArtifactStamp { + id: self.id, + generation: self.generation, + } + } + + /// Wrap a boundary-crossing `value` with this capability's stamp, consuming + /// the capability. The returned [`StampedArtifact`] serializes as + /// `{ value, artifact }`; the capability's registry entry deregisters as it + /// drops at the end of this call (the entry is correlation-only under shape + /// (ii), so early deregistration loses no revocation authority). + pub fn stamp_value(self, value: T) -> StampedArtifact { + StampedArtifact { + value, + artifact: self.stamp(), + } + } +} + +/// Issue a durable owner-identity artifact capability over an owner-key-derived +/// value, stamped with the issuing lease's generation. +/// +/// Stamps the issuing [`OwnerIdentityEgressLease`]'s generation, not a re-read +/// one — see [`register_owner_session`] for why re-reading is a barrier bypass. +/// The F1 lesson applies identically: the signing/decryption that derives the +/// value runs under the lease, and the stamp must be that lease's generation so +/// a value derived across a bump fails closed at its application site. +/// +/// Call this AFTER the owner-key operation completes under the lease (admit +/// before sign/decrypt, stamp after). A Rust-consumed / collapsed artifact +/// validates via [`OwnerIdentityCapability::admit_exercise`] before its local +/// application; a boundary-crossing artifact serializes [`ArtifactStamp`] via +/// [`OwnerIdentityCapability::stamp`] and the frontend validates in C6/C7. +pub fn register_owner_artifact( + lease: &OwnerIdentityEgressLease, +) -> OwnerIdentityCapability { + register_durable(lease.generation(), RevocationHandle::Artifact) +} + /// Register a durable capability stamped with `generation` and carrying /// `handle` as its revocation authority. Shared by both constructors so the /// id allocation and registry insert live in one place. @@ -258,6 +376,22 @@ fn register_durable( /// later attach fails [`OwnerIdentityCapability::admit_exercise`]). Returns the /// number of capabilities revoked. /// +/// ARTIFACTS ARE NOT REVOKED HERE, and this is correct, not a gap (shape (ii), +/// acked by Paul 2026-08-18). The spec text reads "invalidates every +/// old-generation bearer AND artifact"; a reviewer diffing against it will look +/// for artifact handling in this function and find none. An artifact owns no +/// side-effecting teardown — it is a stamped value, not a socket or a bearer +/// entry — so its ONLY revocation mechanism is the exercise-time / +/// application-site generation compare, which the generation bump at +/// [`begin_egress_drain`] triggers by construction. The bump IS the artifact +/// invalidation, and it precedes this call (and the journal write) in the +/// coordinator sequence, so every old-generation artifact is already +/// invalidated before the durable boundary without a per-entry action. Per-entry +/// work here is reserved for authorities with side-effecting teardown (session +/// cancel-tokens, bearer entries). Any artifact entry that happens to be live +/// at barrier time (a producer between lease-drop and value-serialization) +/// self-deregisters on its imminent Drop and is skipped either way. +/// /// The C5 coordinator barrier calls this after /// [`begin_egress_drain`]/[`await_egress_drain`] and BEFORE the journal write + /// durable B dispatch, so no old-generation session or bearer can transmit @@ -269,7 +403,11 @@ pub fn revoke_durable_capabilities_before(winning_generation: u64) -> usize { let stale: Vec = durable .entries .iter() - .filter(|(_, (gen, _))| *gen < winning_generation) + .filter(|(_, (gen, handle))| { + // Artifacts are generation-invalidated (shape (ii)); the barrier + // acts only on side-effecting authorities. + *gen < winning_generation && !matches!(handle, RevocationHandle::Artifact) + }) .map(|(id, _)| *id) .collect(); for id in &stale { @@ -491,4 +629,136 @@ mod tests { "with no transition the session still transmits" ); } + + // §7 artifact schedules (P31-C1) — three schedules proving shape (ii): the + // generation bump reaches artifacts WITHOUT a barrier registry entry, so a + // stamped value's application is refused post-bump by the generation + // compare alone. `admit_exercise` on an ArtifactPolicy capability stands in + // for both the collapsed-artifact local check and the boundary-crossing + // artifact's simulated application-site compare (C6/C7). + + // P31-C1 (a): a Rust-consumed / collapsed artifact stamped under A refuses + // its local application after the generation advances to B. + #[test] + fn stale_artifact_application_refuses_after_a_generation_bump() { + let _g = guard(); + let artifact = register_owner_artifact(&lease()); + begin_egress_drain().unwrap(); + resume_egress_live(); + assert!( + artifact.admit_exercise().is_err(), + "an artifact stamped under A must refuse application after B wins" + ); + } + + // P31-C1 (b): the barrier does NOT revoke artifacts by entry — the bump is + // the invalidation. `revoke_durable_capabilities_before` reports zero + // side-effecting revocations for a registry holding only artifacts, and the + // stamped artifact still refuses application by generation compare. This is + // the test-level proof that the bump reaches artifacts without registry + // entries (Paul's condition 2). + #[test] + fn barrier_leaves_artifacts_to_generation_compare_not_entry_revocation() { + let _g = guard(); + let artifact = register_owner_artifact(&lease()); + assert_eq!(live_durable_capability_count(), 1); + let winning = begin_egress_drain().unwrap(); + resume_egress_live(); + assert_eq!( + revoke_durable_capabilities_before(winning), + 0, + "the barrier performs no per-entry revocation for artifacts (shape (ii))" + ); + assert!( + artifact.admit_exercise().is_err(), + "the bump alone invalidates the artifact — the generation compare refuses" + ); + } + + // P31-C1 (c): a boundary-crossing artifact's wire stamp carries the issuing + // generation, and once the generation advances the stamp is stale relative + // to the current generation — the invariant C6/C7's application-site + // compare enforces against `ArtifactStamp::generation`. + #[test] + fn boundary_crossing_artifact_stamp_is_stale_after_a_bump() { + let _g = guard(); + let artifact = register_owner_artifact(&lease()); + let stamp = artifact.stamp(); + assert_eq!(stamp.generation, current_identity_persistence_generation()); + let winning = begin_egress_drain().unwrap(); + resume_egress_live(); + assert_ne!( + stamp.generation, winning, + "the wire stamp is stale relative to the winning generation; the C6/C7 \ + application-site compare refuses it" + ); + assert!( + artifact.admit_exercise().is_err(), + "and the capability itself refuses local application" + ); + } + + // §7 decryption-artifact schedules (P32-C1) — two schedules for the + // decryption outputs (nip44_decrypt_from_self / decrypt_observer_event): + // the decrypted plaintext stamped under A refuses application after B, and + // a no-transition control keeps it applicable. + + // P32-C1 (a): a decryption-output artifact stamped under A refuses + // application after the generation advances to B. + #[test] + fn stale_decryption_artifact_application_refuses_after_a_bump() { + let _g = guard(); + let decrypted = register_owner_artifact(&lease()); + begin_egress_drain().unwrap(); + resume_egress_live(); + assert!( + decrypted.admit_exercise().is_err(), + "a decryption output stamped under A must refuse application after B wins" + ); + } + + // P32-C1 (b): no-transition control — a decryption-output artifact with no + // intervening bump stays applicable and the barrier revokes nothing. + #[test] + fn no_transition_leaves_decryption_artifact_applicable() { + let _g = guard(); + let decrypted = register_owner_artifact(&lease()); + let current = current_identity_persistence_generation(); + assert_eq!( + revoke_durable_capabilities_before(current), + 0, + "no side-effecting revocation with no transition" + ); + assert!( + decrypted.admit_exercise().is_ok(), + "with no transition the decryption output stays applicable" + ); + } + + // Wire-shape LOCK (Paul's condition 2): the boundary payload serializes as + // exactly `{ value, artifact: { id, generation } }`. C6/C7 inherits this + // contract — the frontend `StampedArtifact` mirror and every + // application-site compare read `artifact.generation`, so a silent drift + // here (renamed/reshaped field) would break C6/C7 undetectably. Pinning it + // now makes any drift a test failure, not a C6/C7 rediscovery. + #[test] + fn stamped_artifact_serializes_to_the_locked_wire_shape() { + let stamped = StampedArtifact { + value: "signed-event-json", + artifact: ArtifactStamp { + id: 7, + generation: 3, + }, + }; + let json = serde_json::to_value(&stamped).expect("serializes"); + assert_eq!( + json, + serde_json::json!({ + "value": "signed-event-json", + "artifact": { "id": 7, "generation": 3 }, + }), + "the boundary payload must be {{value, artifact:{{id, generation}}}} \ + — the contract C6/C7 threads to application sites" + ); + } } diff --git a/desktop/src-tauri/src/owner_identity_egress/mod.rs b/desktop/src-tauri/src/owner_identity_egress/mod.rs index a890e458ce..d4502a402a 100644 --- a/desktop/src-tauri/src/owner_identity_egress/mod.rs +++ b/desktop/src-tauri/src/owner_identity_egress/mod.rs @@ -70,8 +70,25 @@ //! `commands::channels` (×3), and the huddle STT task (per-send inline //! owner lease). //! - `commands::identity::create_auth_event` — the relay-WS reconnection -//! NIP-42 handshake (per-send lease; frontend session registration defers to -//! C6/C7 with the frontend identity store). +//! NIP-42 handshake (per-send lease). The signed auth event is an +//! owner-signed relay-event ARTIFACT: stamped and threaded to the frontend +//! as `{value, artifact}`, validated in C6/C7. Frontend session REGISTRATION +//! (native-WS teardown handle) defers to C6/C7 with the frontend identity +//! store. +//! - **Owner-identity artifacts** — the seven `commands::identity` producers +//! `sign_event`, `create_auth_event`, `build_observer_control_event`, +//! `sign_nostr_identity_binding`, `nip44_encrypt_to_self`, +//! `nip44_decrypt_from_self`, and `decrypt_observer_event` admit a bounded +//! lease BEFORE the owner-key sign/encrypt/decrypt, register an +//! [`OwnerIdentityCapability`], and return the owner-key +//! value wrapped as [`StampedArtifact`] (`{value, artifact:{id,generation}}`). +//! Artifacts carry NO side-effecting teardown: their only revocation is the +//! application-site generation compare, triggered by the transition bump +//! (shape (ii)). The frontend threads the pair opaque; C6/C7 adds the +//! compare at each application boundary. Rust-consumed / collapsed-transaction +//! artifacts (`project_git_workflow` sign+submit, archive-ingest decrypts, +//! snapshot-import, engram-listing decrypt) exercise synchronously inside +//! their lease and keep the collapsed shape — inventoried for C5/C6. //! - **Durable bearers** — `commands::media::mint_media_get_auth` (Blossom //! `t=get`) and the `do_upload` `t=upload` mint sign under a bounded lease //! and register an [`OwnerIdentityCapability`]; the four @@ -475,14 +492,20 @@ fn admit_managed_agent_after_wait() -> Result { /// file-size discipline; it shares this module's one registry. mod durable; pub use durable::{ - register_owner_bearer, register_owner_session, BearerPolicy, OwnerIdentityCapability, - SessionPolicy, + register_owner_artifact, register_owner_bearer, register_owner_session, BearerPolicy, + OwnerIdentityCapability, SessionPolicy, StampedArtifact, }; // The C5 coordinator barrier and its registration-completeness reader; no // production caller until C5, so the re-exports are unused today. Consumed by // C5 (P25/P28); remove allow when C5 lands. #[allow(unused_imports)] pub use durable::revoke_durable_capabilities_before; +// The artifact policy marker and its wire stamp are named at the frontend +// application sites in C6/C7 (generation-compare) and by the §7 artifact +// schedules; no production Rust caller names them today. Remove allow when +// C6/C7 lands. +#[allow(unused_imports)] +pub use durable::{ArtifactPolicy, ArtifactStamp}; /// Process-global mutex serializing tests that mutate the registry's /// process-global state. Any test that admits a lease, drives a drain, or diff --git a/desktop/src/features/agents/ui/useLoadArchivedObserverEvents.test.mjs b/desktop/src/features/agents/ui/useLoadArchivedObserverEvents.test.mjs index c3ca1f678b..27fd22c4eb 100644 --- a/desktop/src/features/agents/ui/useLoadArchivedObserverEvents.test.mjs +++ b/desktop/src/features/agents/ui/useLoadArchivedObserverEvents.test.mjs @@ -193,10 +193,30 @@ installDOMShim(); /** @type {Map Promise>} */ const ipcHandlers = new Map(); +// Owner-identity artifact producers (NIP-AP) return `{ value, artifact }` across +// the Tauri boundary; the adapters unwrap `.value`. Mocks return the bare value, +// so wrap it in the stamped shape for these commands (inert stamp in C3 — the +// generation-compare lands in C6/C7). Keep this set in sync with the producers. +const STAMPED_ARTIFACT_COMMANDS = new Set([ + "sign_event", + "create_auth_event", + "build_observer_control_event", + "sign_nostr_identity_binding", + "nip44_encrypt_to_self", + "nip44_decrypt_from_self", + "decrypt_observer_event", +]); +function wrapV(cmd, value) { + return STAMPED_ARTIFACT_COMMANDS.has(cmd) + ? { value, artifact: { id: 0, generation: 0 } } + : value; +} + globalThis.__TAURI_INTERNALS__ = { invoke: (cmd, args) => { const handler = ipcHandlers.get(cmd); - if (handler) return handler(args); + if (handler) + return Promise.resolve(handler(args)).then((v) => wrapV(cmd, v)); return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); }, transformCallback: (_cb) => { diff --git a/desktop/src/features/profile/lib/nostrIdentityBinding.ts b/desktop/src/features/profile/lib/nostrIdentityBinding.ts index 352580b1ff..4296972695 100644 --- a/desktop/src/features/profile/lib/nostrIdentityBinding.ts +++ b/desktop/src/features/profile/lib/nostrIdentityBinding.ts @@ -1,4 +1,4 @@ -import { invokeTauri } from "@/shared/api/tauri"; +import { invokeTauri, type StampedArtifact } from "@/shared/api/tauri"; export type NostrIdentityBindingInput = { challengeId: string; @@ -8,8 +8,13 @@ export type NostrIdentityBindingInput = { expiresAt: string; }; -export function signNostrIdentityBinding( +export async function signNostrIdentityBinding( input: NostrIdentityBindingInput, ): Promise { - return invokeTauri("sign_nostr_identity_binding", input); + // C6/C7: thread {value, artifact} to application sites — unwrap is temporary. + const { value } = await invokeTauri>( + "sign_nostr_identity_binding", + input, + ); + return value; } diff --git a/desktop/src/shared/api/identityArtifacts.ts b/desktop/src/shared/api/identityArtifacts.ts new file mode 100644 index 0000000000..cfc4e06b49 --- /dev/null +++ b/desktop/src/shared/api/identityArtifacts.ts @@ -0,0 +1,56 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { invokeTauri } from "./tauri"; +import type { StampedArtifact } from "./stampedArtifact"; + +// Owner-identity artifact producers (NIP-AP). Each Rust command returns the +// owner-key-derived value wrapped as `{ value, artifact }`; C6/C7 threads the +// pair to each identity-sensitive application site and adds the +// generation-compare. Until then these adapters unwrap `.value` at the boundary +// — the stamp is inert but the wire shape is pinned (see `StampedArtifact`). + +export async function signRelayEvent(input: { + kind: number; + content: string; + createdAt?: number; + tags: string[][]; +}): Promise { + // C6/C7: thread {value, artifact} to application sites — unwrap is temporary. + const r = await invokeTauri>("sign_event", input); + return JSON.parse(r.value) as RelayEvent; +} + +export async function createAuthEvent(input: { + challenge: string; + relayUrl: string; +}): Promise { + // C6/C7: thread {value, artifact} to application sites — unwrap is temporary. + const r = await invokeTauri>( + "create_auth_event", + input, + ); + return JSON.parse(r.value) as RelayEvent; +} + +export async function nip44EncryptToSelf(plaintext: string): Promise { + // C6/C7: thread {value, artifact} to application sites — unwrap is temporary. + const r = await invokeTauri>( + "nip44_encrypt_to_self", + { + plaintext, + }, + ); + return r.value; +} + +export async function nip44DecryptFromSelf( + ciphertext: string, +): Promise { + // C6/C7: thread {value, artifact} to application sites — unwrap is temporary. + const r = await invokeTauri>( + "nip44_decrypt_from_self", + { + ciphertext, + }, + ); + return r.value; +} diff --git a/desktop/src/shared/api/invites.test.mjs b/desktop/src/shared/api/invites.test.mjs index eb3754435e..3440f10f60 100644 --- a/desktop/src/shared/api/invites.test.mjs +++ b/desktop/src/shared/api/invites.test.mjs @@ -105,7 +105,13 @@ function setupTauriStubs( invoke: async (command, args) => { calls.invokeArgs.push({ command, args }); if (command === "get_relay_http_url") return httpBase; - if (command === "sign_event") return JSON.stringify(authEvent); + // sign_event returns the NIP-AP stamped artifact `{ value, artifact }`; + // the adapter unwraps `.value` (generation-compare lands in C6/C7). + if (command === "sign_event") + return { + value: JSON.stringify(authEvent), + artifact: { id: 0, generation: 0 }, + }; throw new Error(`Unexpected Tauri command: ${command}`); }, }; diff --git a/desktop/src/shared/api/stampedArtifact.ts b/desktop/src/shared/api/stampedArtifact.ts new file mode 100644 index 0000000000..93e9623960 --- /dev/null +++ b/desktop/src/shared/api/stampedArtifact.ts @@ -0,0 +1,18 @@ +/** + * The `{ value, artifact }` pair the owner-identity artifact producers return + * across the Tauri boundary. `artifact` is the generation stamp + * (`OwnerIdentityCapability`, NIP-AP): `id` is + * correlation/diagnostic only, `generation` is the identity-persistence + * generation the value was signed/decrypted under. + * + * The wire contract is locked here and on the Rust side (`StampedArtifact`): + * C6/C7 threads this pair to each identity-sensitive application site and adds + * the generation-compare (refuse to apply once the current generation advances + * past `artifact.generation`). Until then the adapters unwrap `.value` at the + * boundary — the stamp is inert but the shape is pinned so C6/C7 inherits a + * stable contract, not a rediscovery. + */ +export interface StampedArtifact { + value: T; + artifact: { id: number; generation: number }; +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 108e646d59..d02f6160b1 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -118,6 +118,13 @@ type RawRelayAgent = { }; import type { RestartDiffEntry as RawRestartDiffEntry } from "./restartDiff"; +export type { StampedArtifact } from "./stampedArtifact"; +export { + signRelayEvent, + createAuthEvent, + nip44EncryptToSelf, + nip44DecryptFromSelf, +} from "./identityArtifacts"; export type RawManagedAgent = { pubkey: string; name: string; @@ -655,24 +662,6 @@ export async function removeReaction( await invokeTauri("remove_reaction", { eventId, emoji }); } -export async function signRelayEvent(input: { - kind: number; - content: string; - createdAt?: number; - tags: string[][]; -}): Promise { - const eventJson = await invokeTauri("sign_event", input); - return JSON.parse(eventJson) as RelayEvent; -} - -export async function createAuthEvent(input: { - challenge: string; - relayUrl: string; -}): Promise { - const eventJson = await invokeTauri("create_auth_event", input); - return JSON.parse(eventJson) as RelayEvent; -} - function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent { return { pubkey: agent.pubkey, @@ -1116,16 +1105,6 @@ export async function probeBackendProvider( // ── NIP-44 encrypt-to-self ─────────────────────────────────────────────────── -export async function nip44EncryptToSelf(plaintext: string): Promise { - return invokeTauri("nip44_encrypt_to_self", { plaintext }); -} - -export async function nip44DecryptFromSelf( - ciphertext: string, -): Promise { - return invokeTauri("nip44_decrypt_from_self", { ciphertext }); -} - export async function startPairing(): Promise { return invokeTauri("start_pairing"); } diff --git a/desktop/src/shared/api/tauriObserver.ts b/desktop/src/shared/api/tauriObserver.ts index f0d3ba7f91..080f9f9ce9 100644 --- a/desktop/src/shared/api/tauriObserver.ts +++ b/desktop/src/shared/api/tauriObserver.ts @@ -1,21 +1,25 @@ import type { RelayEvent } from "@/shared/api/types"; -import { invokeTauri } from "./tauri"; +import { invokeTauri, type StampedArtifact } from "./tauri"; export async function decryptObserverEvent( event: RelayEvent, ): Promise { - return invokeTauri("decrypt_observer_event", { - eventJson: JSON.stringify(event), - }); + // C6/C7: thread {value, artifact} to application sites — unwrap is temporary. + const { value } = await invokeTauri>( + "decrypt_observer_event", + { eventJson: JSON.stringify(event) }, + ); + return value; } export async function buildObserverControlEvent(input: { agentPubkey: string; payload: unknown; }): Promise { - const eventJson = await invokeTauri("build_observer_control_event", { - agentPubkey: input.agentPubkey, - payload: input.payload, - }); - return JSON.parse(eventJson) as RelayEvent; + // C6/C7: thread {value, artifact} to application sites — unwrap is temporary. + const { value } = await invokeTauri>( + "build_observer_control_event", + { agentPubkey: input.agentPubkey, payload: input.payload }, + ); + return JSON.parse(value) as RelayEvent; } diff --git a/desktop/src/shared/theme/communityThemeSync.test.mjs b/desktop/src/shared/theme/communityThemeSync.test.mjs index 567319d58d..49a9c4eb35 100644 --- a/desktop/src/shared/theme/communityThemeSync.test.mjs +++ b/desktop/src/shared/theme/communityThemeSync.test.mjs @@ -14,6 +14,13 @@ const preference = { followSystem: false, }; +// Owner-identity artifact producers (NIP-AP) return `{ value, artifact }` across +// the Tauri boundary; the adapters unwrap `.value`. Wrap a mock's bare value in +// the stamped shape (inert stamp in C3 — the generation-compare lands in C6/C7). +function stampedArtifact(value) { + return { value, artifact: { id: 0, generation: 0 } }; +} + function installFakeTimer() { globalThis.window ??= {}; let callback = null; @@ -147,15 +154,18 @@ test("new remote invalidates no-op suppression for A to B to A", async () => { let signedEventId = "published-z"; globalThis.window.__TAURI_INTERNALS__ = { invoke(command, args) { - if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher"); + if (command === "nip44_encrypt_to_self") + return Promise.resolve(stampedArtifact("cipher")); if (command === "sign_event") { return Promise.resolve( - JSON.stringify( - relayEvent({ - id: signedEventId, - content: args.content, - created_at: args.createdAt, - }), + stampedArtifact( + JSON.stringify( + relayEvent({ + id: signedEventId, + content: args.content, + created_at: args.createdAt, + }), + ), ), ); } @@ -201,16 +211,19 @@ test("serializes an in-flight publish before sending the latest edit", async () let signed = 0; globalThis.window.__TAURI_INTERNALS__ = { invoke(command, args) { - if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher"); + if (command === "nip44_encrypt_to_self") + return Promise.resolve(stampedArtifact("cipher")); if (command === "sign_event") { signed += 1; return Promise.resolve( - JSON.stringify( - relayEvent({ - id: `event-${signed}`, - content: args.content, - created_at: args.createdAt, - }), + stampedArtifact( + JSON.stringify( + relayEvent({ + id: `event-${signed}`, + content: args.content, + created_at: args.createdAt, + }), + ), ), ); } @@ -256,16 +269,19 @@ test("republishes above a newer remote observed while publish is in flight", asy let signed = 0; globalThis.window.__TAURI_INTERNALS__ = { invoke(command, args) { - if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher"); + if (command === "nip44_encrypt_to_self") + return Promise.resolve(stampedArtifact("cipher")); if (command === "sign_event") { signed += 1; return Promise.resolve( - JSON.stringify( - relayEvent({ - id: `event-${signed}`, - content: args.content, - created_at: args.createdAt, - }), + stampedArtifact( + JSON.stringify( + relayEvent({ + id: `event-${signed}`, + content: args.content, + created_at: args.createdAt, + }), + ), ), ); } @@ -316,17 +332,21 @@ test("delayed live decryption fences publish acknowledgement and preserves local let signed = 0; globalThis.window.__TAURI_INTERNALS__ = { invoke(command, args) { - if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher"); - if (command === "nip44_decrypt_from_self") return remotePlaintext.promise; + if (command === "nip44_encrypt_to_self") + return Promise.resolve(stampedArtifact("cipher")); + if (command === "nip44_decrypt_from_self") + return remotePlaintext.promise.then(stampedArtifact); if (command === "sign_event") { signed += 1; return Promise.resolve( - JSON.stringify( - relayEvent({ - id: `event-${signed}`, - content: args.content, - created_at: args.createdAt, - }), + stampedArtifact( + JSON.stringify( + relayEvent({ + id: `event-${signed}`, + content: args.content, + created_at: args.createdAt, + }), + ), ), ); } @@ -388,15 +408,18 @@ test("transient publish failure retries and acknowledges exact event", async () let attempts = 0; globalThis.window.__TAURI_INTERNALS__ = { invoke(command, args) { - if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher"); + if (command === "nip44_encrypt_to_self") + return Promise.resolve(stampedArtifact("cipher")); if (command === "sign_event") { return Promise.resolve( - JSON.stringify( - relayEvent({ - id: "published-event", - content: args.content, - created_at: args.createdAt, - }), + stampedArtifact( + JSON.stringify( + relayEvent({ + id: "published-event", + content: args.content, + created_at: args.createdAt, + }), + ), ), ); }