From 88953cd70d8ae4b1f7a01336fb879b9680108d3e Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 23 Jul 2026 17:28:11 +0100 Subject: [PATCH 1/2] wip: -11 ingress --- Cargo.lock | 2 + .../ironclaw_attested_runtime/src/driver.rs | 242 ++++-- crates/ironclaw_attested_runtime/src/lib.rs | 2 +- .../tests/threat_matrix.rs | 53 +- .../src/attested_continuation.rs | 213 +++++ crates/ironclaw_product_workflow/src/lib.rs | 5 + .../src/reborn_services.rs | 78 +- .../src/webui_inbound.rs | 86 +- .../tests/webui_inbound_contract.rs | 161 ++++ crates/ironclaw_reborn_composition/Cargo.toml | 7 + .../src/attested.rs | 117 ++- .../src/attested_continuation.rs | 507 +++++++++++ crates/ironclaw_reborn_composition/src/lib.rs | 4 +- .../src/runtime.rs | 1 + .../src/webui/facade.rs | 13 + .../tests/attested_gate_resolve_ingress.rs | 786 ++++++++++++++++++ crates/ironclaw_wallet_external/Cargo.toml | 12 +- 17 files changed, 2203 insertions(+), 86 deletions(-) create mode 100644 crates/ironclaw_product_workflow/src/attested_continuation.rs create mode 100644 crates/ironclaw_reborn_composition/src/attested_continuation.rs create mode 100644 crates/ironclaw_reborn_composition/tests/attested_gate_resolve_ingress.rs diff --git a/Cargo.lock b/Cargo.lock index fb5c8ca2ab0..534e975094b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5244,6 +5244,7 @@ dependencies = [ "base64 0.22.1", "chrono", "deadpool-postgres", + "ed25519-dalek", "fs4", "futures", "hex", @@ -5303,6 +5304,7 @@ dependencies = [ "ironclaw_triggers", "ironclaw_trust", "ironclaw_turns", + "ironclaw_wallet_external", "ironclaw_webui", "k256", "libc", diff --git a/crates/ironclaw_attested_runtime/src/driver.rs b/crates/ironclaw_attested_runtime/src/driver.rs index 972b7c73a30..262900b2203 100644 --- a/crates/ironclaw_attested_runtime/src/driver.rs +++ b/crates/ironclaw_attested_runtime/src/driver.rs @@ -129,6 +129,36 @@ pub struct SignerContinuationOutcome { pub signer: String, } +/// The product of the verify + claim + sign half of the continuation +/// ([`AttestedSignerContinuationDriver::verify_and_sign`]). Holds everything the +/// broadcast half needs and PROVES the heavyweight crypto already ran: the +/// proof was verified, the one-shot sealed grant was claimed, and the signed +/// bytes ready to broadcast were produced. +/// +/// [`AttestedSignerContinuationDriver::broadcast_signed_continuation`] consumes +/// it to advance the ledger to `BroadcastSubmitted` and submit. The signed +/// bytes never re-trigger verification or a second grant claim: the heavyweight +/// crypto runs exactly once, in `verify_and_sign`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedContinuation { + gate_ref: GateRef, + context: SigningContext, + signed: Vec, + signer: String, +} + +impl VerifiedContinuation { + /// The gate this verified continuation belongs to. + pub fn gate_ref(&self) -> &GateRef { + &self.gate_ref + } + + /// The signer/account the eventual broadcast is attributed to (public). + pub fn signer(&self) -> &str { + &self.signer + } +} + /// Errors the signer-continuation driver can surface. Every variant is /// fail-closed: the ledger is never advanced past where the failure occurred. #[derive(Debug)] @@ -164,6 +194,14 @@ pub enum ContinuationError { /// Opaque description (never key material). reason: String, }, + /// A broadcast-idempotency ledger row already exists for this `gate_ref` + /// (a prior continuation attempt). This is the one-shot guard firing on + /// re-entry — distinct from a generic invalid ledger transition. Carries the + /// existing ledger state for diagnostics. + LedgerRowExists { + /// The state the existing row is currently in. + current: SigningLedgerState, + }, } impl std::fmt::Display for ContinuationError { @@ -184,6 +222,12 @@ impl std::fmt::Display for ContinuationError { } Self::Rebuild(e) => write!(f, "decoded-binding rebuild failed: {e}"), Self::Broadcast { reason } => write!(f, "broadcast failed: {reason}"), + Self::LedgerRowExists { current } => { + write!( + f, + "broadcast-idempotency ledger row already exists (current state: {current:?})" + ) + } } } } @@ -282,6 +326,55 @@ where gate_ref: &GateRef, proof: &SigningProof, ) -> Result + where + S: CustodialSignerLike, + { + // Legacy single-shot entrypoint, retained for the threat-matrix tests + // and any caller that drives both halves under one lock. The + // verify-before-resume facade (PR11 item B) instead calls + // [`Self::verify_and_sign`] BEFORE the turn transitions and + // [`Self::broadcast_signed_continuation`] AFTER, so the heavyweight + // verification + grant claim gate the `BlockedAttested -> + // AttestedResolved` transition. The crypto runs exactly once in either + // arrangement. + let verified = self.verify_and_sign(gate_ref, proof).await?; + self.broadcast_signed_continuation(verified).await + } + + /// Verify + claim + sign half of the continuation. Runs BEFORE the turn + /// transitions to `AttestedResolved`, so the FULL cryptographic verification + /// and the one-shot grant claim gate the transition: a malformed or forged + /// proof is rejected here, with no broadcast and no `AttestedResolved` + /// transition (the facade only calls `resume_turn` after this returns `Ok`). + /// + /// The driver NEVER accepts a caller-supplied signable transaction: the + /// custodial path reconstructs the signable *from the authoritative decoded + /// binding* and signs exactly that, so a resolver cannot pass an unapproved + /// tx (or a mainnet tx aimed past a testnet `binding.chain` ship-gate) after + /// approval (byte-drift defense, same class as PR6's `CustodialSigner`). + /// + /// 1. Read the authoritative binding for `gate_ref` (never trust the + /// caller). + /// 2. Route to the bound provider / custodial signer to verify + claim the + /// sealed grant (threat #1) and produce the signature, under the + /// broadcast-idempotency ledger guard (threats #6 / #7). + /// + /// Fail-closed retry semantics: each path creates / advances the ledger only + /// once verification is committed to, so a proof that fails verification + /// (malformed, forged, signer/hash mismatch) leaves NO blocking ledger row + /// and does NOT claim the grant — a follow-up VALID proof for the same gate + /// can still succeed. After a SUCCESSFUL verify+claim, the grant CAS and the + /// ledger row are both consumed, so a same-key retry fails closed (the + /// continuation is genuinely single-drive). + /// + /// The returned [`VerifiedContinuation`] is the only way to reach + /// [`Self::broadcast_signed_continuation`]; the broadcast half NEVER + /// re-verifies or re-claims. + pub async fn verify_and_sign( + &self, + gate_ref: &GateRef, + proof: &SigningProof, + ) -> Result where S: CustodialSignerLike, { @@ -291,47 +384,80 @@ where .await .ok_or(ContinuationError::MissingBinding)?; - // One-shot ledger create. If a row already exists for this gate_ref - // (e.g. a previous broadcast attempt), `create` fails AlreadyExists and - // we must NOT proceed to a fresh broadcast — the existing row's state - // governs (threats #6 / #7). We surface that as a ledger error. - match self.ledger.create(&LedgerKey::new(binding.context.tenant.clone(), gate_ref.clone())).await { - Ok(()) => {} - Err(ironclaw_attestation::LedgerError::AlreadyExists) => { - // A row exists. If it is already broadcast, refuse re-broadcast - // fail-closed; otherwise this is a genuine retry we still refuse - // because the deterministic continuation is one-shot. - let state = self.ledger.state(&LedgerKey::new(binding.context.tenant.clone(), gate_ref.clone())).await?; - return Err(ContinuationError::Ledger( - ironclaw_attestation::LedgerError::InvalidTransition { - from: state, - to: SigningLedgerState::Signing, - }, - )); - } - Err(other) => return Err(other.into()), - } - match binding.provider_id { - ProviderId::Custodial => self.continue_custodial(gate_ref, &binding).await, + ProviderId::Custodial => self.sign_custodial(gate_ref, &binding).await, external => { - self.continue_external_wallet(gate_ref, external, &binding, proof) + self.verify_external_wallet(gate_ref, external, &binding, proof) .await } } } - /// External-wallet continuation: the wallet already signed natively. We + /// One-shot broadcast-idempotency ledger create (threats #6 / #7): an + /// existing row for this `gate_ref` (a prior attempt) makes any re-entry fail + /// closed. Surfaced as a dedicated idempotency-guard error carrying the + /// existing state, rather than fabricating an `InvalidTransition` with a + /// synthetic `to` that never actually occurred. + async fn create_ledger_row( + &self, + tenant: &ironclaw_signing_provider::TenantId, + gate_ref: &GateRef, + ) -> Result<(), ContinuationError> { + match self + .ledger + .create(&LedgerKey::new(tenant.clone(), gate_ref.clone())) + .await + { + Ok(()) => Ok(()), + Err(ironclaw_attestation::LedgerError::AlreadyExists) => { + let current = self + .ledger + .state(&LedgerKey::new(tenant.clone(), gate_ref.clone())) + .await?; + Err(ContinuationError::LedgerRowExists { current }) + } + Err(other) => Err(other.into()), + } + } + + /// Broadcast half of the continuation. Consumes a [`VerifiedContinuation`] + /// (proof already verified + grant already claimed in + /// [`Self::verify_and_sign`]) and broadcasts the signed bytes under the + /// ledger guard. This NEVER calls `verify_resume` and NEVER re-claims the + /// grant. The broadcast-failure recovery (item C) lives in the shared + /// [`Self::broadcast_signed`] tail: a network error / ambiguous outcome moves + /// the row to the `Unknown` terminal and surfaces a fail-closed error rather + /// than reporting a false success. + pub async fn broadcast_signed_continuation( + &self, + verified: VerifiedContinuation, + ) -> Result { + let VerifiedContinuation { + gate_ref, + context, + signed, + signer, + } = verified; + self.broadcast_signed(&gate_ref, &context, &signed, signer) + .await + } + + /// External-wallet verify + claim: the wallet already signed natively. We /// verify the proof through the bound provider (signer recovery + hash - /// binding + one-shot sealed-grant CAS), then broadcast the wallet-signed - /// transaction under the ledger guard. - async fn continue_external_wallet( + /// binding + one-shot sealed-grant CAS) FIRST, so a rejected proof + /// (malformed, forged, signer/hash mismatch) never touches the ledger and + /// never claims the grant — leaving the gate cleanly retryable. Only after + /// the proof verifies + the grant is claimed do we create the + /// broadcast-idempotency ledger row and advance it `Approved -> Signing -> + /// Signed`. The wallet-signed bytes (the proof payload) become the + /// [`VerifiedContinuation`] to broadcast. + async fn verify_external_wallet( &self, gate_ref: &GateRef, provider_id: ProviderId, binding: &AttestedGateBinding, proof: &SigningProof, - ) -> Result { + ) -> Result { let provider = self .providers .get(provider_id) @@ -339,17 +465,21 @@ where debug_assert_eq!(provider.trust_model(), TrustModel::ExternalWallet); // Verify + claim the sealed one-shot grant (threat #1 lives inside - // `verify_resume`) BEFORE touching the ledger. A rejected proof must - // leave the row at `Approved`, never advance it to the in-flight - // `Signing` state: `continue_after_resolved` is one-shot per `gate_ref`, - // so a row stranded at `Signing` by a failed verify could never be - // re-entered and would be stuck permanently. Verify-before-advance keeps - // the transition atomic from the ledger's perspective — the row only - // moves once we hold a verified proof. + // `verify_resume`) BEFORE touching the ledger. A rejected proof returns + // here with no ledger row created and the grant unclaimed, so the gate + // stays cleanly retryable. Verify-before-advance keeps the transition + // atomic from the ledger's perspective — the row only moves once we hold + // a verified proof. let verified = provider .verify_resume(&binding.context, &binding.approved_tx_hash, proof) .await .map_err(ContinuationError::ProofRejected)?; + + // Proof verified + grant claimed. Now open the broadcast-idempotency + // ledger row and advance it to `Signed`. The grant CAS already made this + // single-drive; the ledger guards broadcast retry (threats #6/#7). + self.create_ledger_row(&binding.context.tenant, gate_ref) + .await?; self.ledger .advance(&LedgerKey::new(binding.context.tenant.clone(), gate_ref.clone()), SigningLedgerState::Signing) .await?; @@ -357,30 +487,27 @@ where .advance(&LedgerKey::new(binding.context.tenant.clone(), gate_ref.clone()), SigningLedgerState::Signed) .await?; - // The wallet-signed bytes are the proof payload; broadcast under the - // ledger guard. let signer = binding.context.key_or_account_id.to_string(); - self.broadcast_signed( - gate_ref, - &binding.context, - verified.proof().payload(), + Ok(VerifiedContinuation { + gate_ref: gate_ref.clone(), + context: binding.context.clone(), + signed: verified.proof().payload().to_vec(), signer, - ) - .await + }) } - /// Custodial continuation: IronClaw holds the key. The driver reconstructs + /// Custodial verify + sign: IronClaw holds the key. The driver reconstructs /// the signable *from `binding.decoded`* (never from any caller-supplied tx) /// and delegates to the [`CustodialSigner`], which runs the ship-gate, /// claims the sealed grant (threat #1), re-checks the approved hash /// (threat #3), and signs with the ecrecover binding check (threat #5). The - /// signer advances the ledger Signing->Signed itself; here we broadcast and - /// advance to BroadcastSubmitted. - async fn continue_custodial( + /// signer advances the ledger `Approved -> Signing -> Signed` itself; the + /// produced signature becomes the [`VerifiedContinuation`] to broadcast. + async fn sign_custodial( &self, gate_ref: &GateRef, binding: &AttestedGateBinding, - ) -> Result + ) -> Result where S: CustodialSignerLike, { @@ -417,6 +544,12 @@ where let signable = rebuild::rebuild_evm_signable(&binding.decoded).map_err(ContinuationError::Rebuild)?; + // Open the broadcast-idempotency ledger row (threats #6/#7) before the + // custodial signer advances it `Approved -> Signing -> Signed` itself. A + // pre-existing row (a prior attempt) fails closed here. + self.create_ledger_row(&binding.context.tenant, gate_ref) + .await?; + let req = CustodialSignRequest { context: binding.context.clone(), scope: binding.scope.clone(), @@ -432,13 +565,12 @@ where .await .map_err(ContinuationError::ChainSigning)?; - self.broadcast_signed( - gate_ref, - &binding.context, - &outcome.signature, - outcome.signer, - ) - .await + Ok(VerifiedContinuation { + gate_ref: gate_ref.clone(), + context: binding.context.clone(), + signed: outcome.signature, + signer: outcome.signer, + }) } /// Shared broadcast tail. For a broadcaster that actually submits diff --git a/crates/ironclaw_attested_runtime/src/lib.rs b/crates/ironclaw_attested_runtime/src/lib.rs index cd71cd1e64a..89677bb9321 100644 --- a/crates/ironclaw_attested_runtime/src/lib.rs +++ b/crates/ironclaw_attested_runtime/src/lib.rs @@ -60,7 +60,7 @@ pub use binding::{ pub use driver::{ AttestedSignerContinuationDriver, BroadcastDisposition, BroadcastOutcome, Broadcaster, ContinuationError, CustodialSignerLike, EvmSignable, ProviderRegistry, RebuildError, - SignerContinuationOutcome, + SignerContinuationOutcome, VerifiedContinuation, }; pub use port::{ InMemoryResumeGuard, ResumeGuard, RuntimeAttestedResumePort, approved_tx_hash_ref_hex, diff --git a/crates/ironclaw_attested_runtime/tests/threat_matrix.rs b/crates/ironclaw_attested_runtime/tests/threat_matrix.rs index 50c3c5ba59a..2584989094e 100644 --- a/crates/ironclaw_attested_runtime/tests/threat_matrix.rs +++ b/crates/ironclaw_attested_runtime/tests/threat_matrix.rs @@ -463,7 +463,10 @@ async fn threat_1_and_6_custodial_replay_and_broadcast_retry_blocked() { .await .expect_err("replay/broadcast-retry must fail closed"); assert!( - matches!(err, ContinuationError::Ledger(_)), + matches!( + err, + ContinuationError::Ledger(_) | ContinuationError::LedgerRowExists { .. } + ), "expected ledger guard rejection, got {err:?}" ); // TRUE idempotency: the replay produced ZERO additional broadcast calls. @@ -595,6 +598,14 @@ async fn driver_rechecks_inconsistent_binding() { ); } +// Note: the "approve-A / sign-B" caller-tx WYSIWYS threat is now structurally +// impossible and so has no dedicated test here: the driver NEVER accepts a +// caller-supplied signable transaction — it reconstructs the signable purely +// from the authoritative `binding.decoded` (the same decoded tx the approved +// hash was computed over). The equivalent attack surface (a tampered binding) +// is covered by `threat_3_inconsistent_binding_write_rejected` and +// `driver_rechecks_inconsistent_binding`. + // ── Threat #5: EVM `from` spoof caught via ecrecover binding ────────────── #[tokio::test] @@ -683,7 +694,10 @@ async fn threat_7_double_broadcast_blocked_by_ledger_state() { .await .expect_err("double-broadcast after recovery must fail closed"); assert!( - matches!(err, ContinuationError::Ledger(_)), + matches!( + err, + ContinuationError::Ledger(_) | ContinuationError::LedgerRowExists { .. } + ), "expected ledger guard rejection, got {err:?}" ); // Ledger never regressed out of BroadcastSubmitted. @@ -782,7 +796,10 @@ async fn retry_after_broadcast_failure_does_not_double_broadcast() { .await .expect_err("retry after a failed broadcast must fail closed"); assert!( - matches!(err, ContinuationError::Ledger(_)), + matches!( + err, + ContinuationError::Ledger(_) | ContinuationError::LedgerRowExists { .. } + ), "expected ledger guard rejection on retry, got {err:?}" ); assert_eq!( @@ -1123,13 +1140,14 @@ async fn put_external_binding( .expect("external binding insert succeeds"); } -/// Verify-before-advance: when the external-wallet provider REJECTS the proof, +/// Verify-before-resume: when the external-wallet provider REJECTS the proof, /// the ledger must NOT be stranded at `Signing`. Because the continuation is /// one-shot per gate_ref, a row left at the in-flight `Signing` state could -/// never be re-entered and would be stuck permanently. The fix verifies the -/// proof BEFORE advancing the ledger, so a rejected proof leaves the row at -/// `Approved` (the row `create`d, never advanced), and the broadcaster is never -/// called. +/// never be re-entered and would be stuck permanently. The split driver +/// verifies + claims the grant BEFORE creating the ledger row, so a rejected +/// proof leaves NO ledger row at all (cleaner than a row stranded at any +/// non-terminal state), and the broadcaster is never called. A follow-up VALID +/// proof for the same gate is therefore still drivable. #[tokio::test] async fn external_wallet_verify_failure_does_not_strand_ledger_at_signing() { let ctx = signing_context(&hex::encode([0x31u8; 20])); @@ -1149,12 +1167,13 @@ async fn external_wallet_verify_failure_does_not_strand_ledger_at_signing() { matches!(err, ContinuationError::ProofRejected(_)), "expected ProofRejected, got {err:?}" ); - // The ledger must be at `Approved` (created, never advanced to `Signing`), - // NOT stranded at the in-flight `Signing` state. + // Verify-before-resume: a rejected proof must leave NO ledger row at all + // (the row is only created after verify + grant claim succeed), so it can + // never be stranded at an in-flight state. assert_eq!( - ledger.state(&lk(&gate)).await.unwrap(), - SigningLedgerState::Approved, - "rejected proof must leave the ledger at Approved, never stranded at Signing" + ledger.state(&gate).await, + Err(ironclaw_attestation::LedgerError::NotFound), + "rejected proof must not create a ledger row at all" ); assert_eq!(broadcaster.count(), 0, "broadcaster must not be called"); } @@ -1205,10 +1224,12 @@ async fn external_wallet_unregistered_provider_is_provider_mismatch() { matches!(err, ContinuationError::ProviderMismatch { .. }), "expected ProviderMismatch, got {err:?}" ); + // Verify-before-resume: a provider mismatch is detected before any ledger + // row is created, so no row exists. assert_eq!( - ledger.state(&lk(&gate)).await.unwrap(), - SigningLedgerState::Approved, - "provider mismatch must leave the ledger at Approved" + ledger.state(&gate).await, + Err(ironclaw_attestation::LedgerError::NotFound), + "provider mismatch must not create a ledger row" ); assert_eq!(broadcaster.count(), 0); } diff --git a/crates/ironclaw_product_workflow/src/attested_continuation.rs b/crates/ironclaw_product_workflow/src/attested_continuation.rs new file mode 100644 index 00000000000..060029d95f0 --- /dev/null +++ b/crates/ironclaw_product_workflow/src/attested_continuation.rs @@ -0,0 +1,213 @@ +//! Crypto-free attested-signing continuation port for the WebUI facade +//! (attested-signing PR11). +//! +//! `ironclaw_product_workflow` is a product-facing facade crate that must stay +//! crypto-free: it never names a chain SDK, a signing provider, a sealed grant, +//! or a broadcast ledger. But the WebUI `resolve_gate` path needs to drive the +//! deterministic sign + broadcast continuation once a `BlockedAttested` gate has +//! been resolved to `AttestedResolved`. +//! +//! The bridge is this injected port. The facade (atomic verify-before-resume, +//! PR11 item B): +//! +//! 1. Translates the browser-supplied attested-proof resolution into an opaque +//! [`AttestedProofClaim`] (all fields are strings / JSON — no crypto types). +//! 2. Calls [`AttestedGateContinuationPort::verify_and_claim`] BEFORE touching +//! the turn store. This runs the FULL cryptographic verification (real +//! signature recovery / WebAuthn assertion) AND claims the one-shot sealed +//! grant. On ANY failure the turn is left `BlockedAttested` with zero +//! state-machine mutation — the facade never calls `resume_turn`. +//! 3. Only on success, builds a `ResumeTurnRequest { attestation: Some(..) }` +//! whose [`ironclaw_turns::AttestationClaimRef`] is the proof's bound-hash +//! claim, and calls `resume_turn`. The injected `AttestedResumePort` (wired +//! in the composition layer, outside `src/`) runs the synchronous binding +//! re-check + one-shot resume guard (defense in depth — it does NOT re-claim) +//! and transitions the turn to `AttestedResolved`. +//! 4. Calls [`AttestedGateContinuationPort::broadcast_resolved`] with the +//! [`VerifiedAttestedContinuation`] handle from step 2 to drive the +//! sign-output broadcast. No re-verification, no re-claim. +//! +//! The production implementation lives in `ironclaw_reborn_composition` over +//! `ironclaw_attested_runtime`'s driver; this crate declares only the +//! crypto-free contract and the opaque DTOs/handle. Mirrors how the turn store +//! already takes an injected `AttestedResumePort`. + +use std::any::Any; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use ironclaw_turns::{GateRef, TurnRunId, TurnScope}; + +/// The proof family carried on an attested gate resolution. Mirrors the legacy +/// monolith `GateResolutionPayload` variants for wire compatibility; the +/// composition-layer port maps each kind onto the matching +/// `ironclaw_signing_provider::SigningProof` it knows how to verify. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AttestedProofKind { + /// Browser injected wallet (`window.ethereum` / `window.solana`). + InjectedWallet, + /// NEAR wallet redirect callback proof. + NearRedirect, + /// WalletConnect v2 session proof. + WalletConnect, +} + +impl AttestedProofKind { + /// Sanitized, snake_case category for diagnostics and error mapping. + pub fn as_str(self) -> &'static str { + match self { + Self::InjectedWallet => "injected_wallet", + Self::NearRedirect => "near_redirect", + Self::WalletConnect => "wallet_connect", + } + } +} + +/// The opaque attested-proof claim the facade forwards to the continuation port. +/// +/// Every field is a string or JSON value: this crate confers no trust and holds +/// no crypto type. The composition-layer port re-decodes `proof_json` into the +/// concrete provider proof and verifies it against the authoritative gate +/// binding (which it persisted when the gate was raised — never trusting these +/// caller-supplied fields to *define* the binding, only to attest to it). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttestedProofClaim { + /// Which proof family this claim belongs to. + pub kind: AttestedProofKind, + /// Lowercase-hex of the approved-tx hash the wallet attests to. This becomes + /// the `AttestationClaimRef` on the resume request, so the synchronous + /// resume-port binding re-check can reject a claim that does not even name + /// the bound hash before any async verification runs. + pub approved_tx_hash_hex: String, + /// The opaque, provider-specific proof payload (signature, signer, scheme, + /// public key, scope, state echo, …). Re-decoded by the port; never + /// interpreted here. + pub proof_json: serde_json::Value, +} + +/// Sanitized outcome of a continuation. Carries no chain, signer, or ledger +/// internals beyond the public broadcast attribution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttestedContinuationOutcome { + /// Public signer/account the broadcast was attributed to. + pub signer: String, +} + +/// Sanitized rejection taxonomy for an attested continuation. Mirrors the +/// crypto-free spirit of [`ironclaw_turns::AttestedResumeRejection`]: categories +/// only, no ceremony detail. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum AttestedContinuationRejection { + /// No authoritative binding exists for the resolved gate (it was never + /// raised, or the binding store lost it). + MissingBinding, + /// The proof family or its provider did not match the bound provider. + ProviderMismatch, + /// The provider rejected the proof (signer/hash mismatch, grant-claim + /// failure, scope violation), or the custodial signer failed. + ProofRejected, + /// A broadcast-idempotency / ledger guard refused the transition (e.g. the + /// gate was already broadcast). + LedgerGuard, + /// The proof payload was malformed and could not be decoded. + MalformedProof, + /// The continuation port is not wired on this deployment. + Unavailable, +} + +impl AttestedContinuationRejection { + /// Sanitized, snake_case category for diagnostics and error mapping. + pub fn category(&self) -> &'static str { + match self { + Self::MissingBinding => "attested_missing_binding", + Self::ProviderMismatch => "attested_provider_mismatch", + Self::ProofRejected => "attested_proof_rejected", + Self::LedgerGuard => "attested_ledger_guard", + Self::MalformedProof => "attested_malformed_proof", + Self::Unavailable => "attested_unavailable", + } + } +} + +/// Opaque, crypto-free handle proving that [`AttestedGateContinuationPort::verify_and_claim`] +/// ran successfully: the proof's full signature was verified and the one-shot +/// sealed grant was claimed. The facade holds it between `verify_and_claim` and +/// [`AttestedGateContinuationPort::broadcast_resolved`] without inspecting it — +/// the composition-layer implementation downcasts it back to its concrete +/// verified-continuation type. This crate confers no trust and names no crypto +/// type; the handle is just an opaque token. +pub struct VerifiedAttestedContinuation { + inner: Box, +} + +impl VerifiedAttestedContinuation { + /// Wrap a composition-layer verified continuation as an opaque handle. + pub fn new(inner: T) -> Self { + Self { + inner: Box::new(inner), + } + } + + /// Recover the concrete verified continuation. Returns the boxed value back + /// on a type mismatch so the caller can fail closed rather than panic. + pub fn downcast(self) -> Result, VerifiedAttestedContinuation> { + match self.inner.downcast::() { + Ok(value) => Ok(value), + Err(inner) => Err(Self { inner }), + } + } +} + +impl std::fmt::Debug for VerifiedAttestedContinuation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VerifiedAttestedContinuation").finish() + } +} + +/// Injected, crypto-free continuation port for attested-signing gate resolution. +/// +/// Implementations live outside this crate (composition / reborn layer). The +/// facade (atomic verify-before-resume, PR11 item B) calls +/// [`Self::verify_and_claim`] BEFORE `resume_turn` and +/// [`Self::broadcast_resolved`] AFTER. The full cryptographic verification and +/// the one-shot sealed-grant claim run in `verify_and_claim`, so they gate the +/// `BlockedAttested -> AttestedResolved` transition; the broadcast half never +/// re-verifies or re-claims. +#[async_trait] +pub trait AttestedGateContinuationPort: Send + Sync { + /// Run the FULL cryptographic verification (real signature recovery / + /// WebAuthn assertion) AND claim the one-shot sealed grant for the resolved + /// gate — all BEFORE the turn transitions. On success returns an opaque + /// [`VerifiedAttestedContinuation`] handle the facade passes back to + /// [`Self::broadcast_resolved`] after `resume_turn`. + /// + /// On ANY failure (malformed/forged proof, signer/hash mismatch, provider + /// mismatch, grant already claimed, missing binding) it returns a sanitized + /// rejection and MUST leave the turn `BlockedAttested` with NO + /// run/mission/gate state-machine mutation: the facade never calls + /// `resume_turn` for a claim that fails here. (A failed claim may have + /// advanced the implementation's own ledger/grant fail-closed state; that is + /// internal one-shot bookkeeping, never a turn-state transition, and means a + /// retry of the same gate is refused rather than double-driven.) + async fn verify_and_claim( + &self, + scope: &TurnScope, + run_id: TurnRunId, + gate_ref: &GateRef, + claim: &AttestedProofClaim, + ) -> Result; + + /// Drive the sign-output broadcast for a gate whose proof was already + /// verified + grant-claimed in [`Self::verify_and_claim`]. Consumes the + /// opaque handle; performs NO re-verification and NO re-claim. + async fn broadcast_resolved( + &self, + scope: &TurnScope, + run_id: TurnRunId, + gate_ref: &GateRef, + verified: VerifiedAttestedContinuation, + ) -> Result; +} diff --git a/crates/ironclaw_product_workflow/src/lib.rs b/crates/ironclaw_product_workflow/src/lib.rs index a90d14d1634..6533d02efb8 100644 --- a/crates/ironclaw_product_workflow/src/lib.rs +++ b/crates/ironclaw_product_workflow/src/lib.rs @@ -31,6 +31,7 @@ mod auth_continuation; mod auth_interaction; mod auth_prompt; mod automation_thread_metadata; +mod attested_continuation; mod binding; mod binding_ref; mod command_dispatch; @@ -94,6 +95,10 @@ pub use automation_thread_metadata::{ AUTOMATION_TRIGGER_THREAD_SOURCE_TAG, automation_trigger_thread_metadata_json, thread_metadata_is_automation_trigger, }; +pub use attested_continuation::{ + AttestedContinuationOutcome, AttestedContinuationRejection, AttestedGateContinuationPort, + AttestedProofClaim, AttestedProofKind, VerifiedAttestedContinuation, +}; pub use binding::{ ConversationBindingService, ProductConversationRouteKind, ResolveBindingRequest, ResolvedBinding, route_kind_for_inbound_payload, diff --git a/crates/ironclaw_product_workflow/src/reborn_services.rs b/crates/ironclaw_product_workflow/src/reborn_services.rs index b706e3d99ee..9e26dcdb262 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services.rs @@ -39,7 +39,7 @@ use ironclaw_threads::{ ThreadMessageId, ThreadScope, }; use ironclaw_turns::{ - AcceptedMessageRef, GateRef, GetRunStateRequest, IdempotencyKey, ResumeTurnPrecondition, + AcceptedMessageRef, AttestationClaimRef, GateRef, GetRunStateRequest, IdempotencyKey, ResumeTurnPrecondition, ResumeTurnRequest, RetryTurnRequest, SanitizedCancelReason, SubmitTurnRequest, SubmitTurnResponse, TurnActor, TurnCoordinator, TurnError, TurnRunId, TurnScope, TurnStatus, }; @@ -49,6 +49,9 @@ use url::Url; use uuid::Uuid; use crate::{ + AttestedProofClaim, + AttestedGateContinuationPort, + AttestedContinuationRejection, ApprovalInteractionDecision, ApprovalInteractionService, AuthInteractionDecision, AuthInteractionRejectionKind, AuthInteractionService, LifecyclePackageRef, LifecycleProductFacade, ListPendingApprovalsRequest, ProductWorkflowError, @@ -2802,6 +2805,10 @@ pub struct RebornServices< > { product_capability_invoker: I, view_provider: V, + /// Injected, crypto-free attested-signing continuation port (PR11). When + /// wired, an `attested` gate resolution verifies + claims BEFORE the turn + /// resumes, then drives the deterministic sign + broadcast continuation. + attested_continuation: Option>, thread_service: Arc, turn_coordinator: Arc, inbound_attachments: Option>, @@ -2876,6 +2883,7 @@ where view_provider: V, ) -> Self { Self { + attested_continuation: None, product_capability_invoker, view_provider, thread_service, @@ -3086,6 +3094,16 @@ where self } + /// Wire the attested-signing continuation port (PR11). Without it, an + /// `attested` gate resolution fails closed. + pub fn with_attested_continuation( + mut self, + continuation: Arc, + ) -> Self { + self.attested_continuation = Some(continuation); + self + } + pub fn with_auth_interactions( mut self, auth_interactions: Arc, @@ -6324,6 +6342,12 @@ where WebUiGateResolution::CredentialProvided { .. } => { return Err(blocked_authentication_unavailable()); } + // An attested proof on a non-attested gate is a routing error, not a + // resolvable decision: the gate-family resolver sends `BlockedAttested` + // to `resolve_attested_gate`. Reject rather than coerce. + WebUiGateResolution::Attested { .. } => { + return Err(attested_invalid_field("resolution")); + } }; let response = self .approval_interactions @@ -6402,6 +6426,12 @@ where WebUiGateResolution::Approved { .. } => { return Err(blocked_authentication_unavailable()); } + // An attested proof on a non-attested gate is a routing error, not a + // resolvable decision: the gate-family resolver sends `BlockedAttested` + // to `resolve_attested_gate`. Reject rather than coerce. + WebUiGateResolution::Attested { .. } => { + return Err(attested_invalid_field("resolution")); + } }; let response = self .auth_interactions @@ -6471,6 +6501,10 @@ where WebUiGateResolution::CredentialProvided { .. } => { Err(blocked_authentication_unavailable()) } + // An attested proof on a non-attested gate is a routing error: the + // gate-family resolver sends `BlockedAttested` to + // `resolve_attested_gate`. Reject rather than coerce. + WebUiGateResolution::Attested { .. } => Err(attested_invalid_field("resolution")), WebUiGateResolution::Declined => { assert_generic_run_parked_on_gate( self.turn_coordinator.as_ref(), @@ -7555,3 +7589,45 @@ mod tests { ); } } + +/// A malformed attested-resolution field, mapped to the standard validation +/// error shape (400) so the client sees which field was rejected. +fn attested_invalid_field(field: &str) -> RebornServicesError { + RebornServicesError::validation(WebUiInboundValidationError { + field: field.to_string(), + code: WebUiInboundValidationCode::InvalidValue, + }) +} + +/// Map a sanitized attested-continuation rejection to the WebUI error surface. +/// The continuation runs after the resume guard already consumed the one-shot, +/// so every category here is non-retryable from the client's perspective. +fn map_attested_continuation_rejection( + rejection: AttestedContinuationRejection, +) -> RebornServicesError { + let (code, kind, status) = match rejection { + AttestedContinuationRejection::MissingBinding => ( + RebornServicesErrorCode::NotFound, + RebornServicesErrorKind::NotFound, + 404, + ), + AttestedContinuationRejection::ProviderMismatch + | AttestedContinuationRejection::ProofRejected + | AttestedContinuationRejection::MalformedProof => ( + RebornServicesErrorCode::InvalidRequest, + RebornServicesErrorKind::Validation, + 400, + ), + AttestedContinuationRejection::LedgerGuard => ( + RebornServicesErrorCode::Conflict, + RebornServicesErrorKind::Conflict, + 409, + ), + AttestedContinuationRejection::Unavailable => ( + RebornServicesErrorCode::Unavailable, + RebornServicesErrorKind::ServiceUnavailable, + 503, + ), + }; + RebornServicesError::from_status_kind(code, kind, status, false) +} diff --git a/crates/ironclaw_product_workflow/src/webui_inbound.rs b/crates/ironclaw_product_workflow/src/webui_inbound.rs index c045bb8edf0..a6849fd8075 100644 --- a/crates/ironclaw_product_workflow/src/webui_inbound.rs +++ b/crates/ironclaw_product_workflow/src/webui_inbound.rs @@ -405,7 +405,9 @@ impl From for SanitizedCancelReason { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +// `Attested` carries an opaque `serde_json::Value` proof payload, which is +// `PartialEq` but not `Eq`; the enum therefore drops the `Eq` derive. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "resolution", rename_all = "snake_case")] pub enum WebUiGateResolution { Approved { @@ -417,10 +419,21 @@ pub enum WebUiGateResolution { Declined, /// A host-stored credential reference, not a raw secret/token. CredentialProvided { credential_ref: String }, + /// An external-wallet / custodial attested-signing proof for a + /// `BlockedAttested` gate. Carries the opaque proof claim the facade + /// forwards to the injected `AttestedGateContinuationPort`. The fields are + /// validated-shape strings/JSON only — no trust is conferred here. + Attested { + kind: crate::AttestedProofKind, + approved_tx_hash_hex: String, + proof_json: serde_json::Value, + }, } /// Canonical route-independent WebUI command produced after validation. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +// `ResolveGate` carries a `WebUiGateResolution`, whose `Attested` variant holds +// a non-`Eq` proof payload, so this enum drops `Eq` as well. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "command", rename_all = "snake_case")] pub enum WebUiInboundCommand { CreateThread { @@ -733,3 +746,72 @@ fn validate_text_value( } Ok(()) } + +/// Max byte length of the caller-supplied approved-tx-hash hex ("0x" + 64). +/// Bounds an attacker-controlled field before it reaches the decoder. +const ATTESTED_HASH_HEX_MAX_BYTES: usize = 66; + +fn parse_attested_resolution( + attested_proof_kind: Option, + attested_approved_tx_hash: Option, + attested_proof: Option, +) -> Result { + let kind_text = required_text( + "attested_proof_kind", + attested_proof_kind, + 64, + TextMode::Token, + )?; + let kind = match kind_text.as_str() { + "injected_wallet" => crate::AttestedProofKind::InjectedWallet, + "near_redirect" => crate::AttestedProofKind::NearRedirect, + "wallet_connect" => crate::AttestedProofKind::WalletConnect, + _ => { + return Err(WebUiInboundValidationError::new( + "attested_proof_kind", + WebUiInboundValidationCode::InvalidValue, + )); + } + }; + let approved_tx_hash_raw = required_text( + "attested_approved_tx_hash", + attested_approved_tx_hash, + ATTESTED_HASH_HEX_MAX_BYTES, + TextMode::Token, + )?; + // Canonicalize to the port's `bound_hex` form: strip the optional `0x` + // prefix we explicitly tolerate, require exactly 64 ASCII-hex digits, and + // lowercase. Without this, a documented `0x`-prefixed (or uppercase) hash + // would be carried verbatim into the `AttestationClaimRef` and then fail the + // resume port's byte-exact comparison against the canonical bound hash — + // rejecting otherwise-valid proofs. + let approved_tx_hash_hex = parse_approved_tx_hash_hex(&approved_tx_hash_raw)?; + let proof_json = attested_proof.ok_or_else(|| { + WebUiInboundValidationError::new("attested_proof", WebUiInboundValidationCode::MissingField) + })?; + if !proof_json.is_object() { + return Err(WebUiInboundValidationError::new( + "attested_proof", + WebUiInboundValidationCode::InvalidValue, + )); + } + Ok(WebUiGateResolution::Attested { + kind, + approved_tx_hash_hex, + proof_json, + }) +} + +/// Canonicalize the attested approved-tx-hash hex to the form the resume port +/// compares against (lowercase, no `0x`, exactly 64 hex digits = 32 bytes). +/// Fail-closed on anything that is not a well-formed 32-byte hex hash. +fn parse_approved_tx_hash_hex(value: &str) -> Result { + let stripped = value.strip_prefix("0x").unwrap_or(value); + if stripped.len() != 64 || !stripped.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(WebUiInboundValidationError::new( + "attested_approved_tx_hash", + WebUiInboundValidationCode::InvalidValue, + )); + } + Ok(stripped.to_ascii_lowercase()) +} diff --git a/crates/ironclaw_product_workflow/tests/webui_inbound_contract.rs b/crates/ironclaw_product_workflow/tests/webui_inbound_contract.rs index 58f40dcae92..fd1a26b92d4 100644 --- a/crates/ironclaw_product_workflow/tests/webui_inbound_contract.rs +++ b/crates/ironclaw_product_workflow/tests/webui_inbound_contract.rs @@ -211,6 +211,166 @@ fn resolve_gate_maps_to_canonical_gate_command_without_raw_secret() { ); } +#[test] +fn resolve_gate_maps_attested_proof_to_canonical_command() { + let request: WebUiResolveGateRequest = serde_json::from_value(json!({ + "client_action_id": "gate-att-1", + "thread_id": "thread-alpha", + "run_id": run_id(), + "gate_ref": "gate-alpha", + "resolution": "attested", + "attested_proof_kind": "injected_wallet", + "attested_approved_tx_hash": "ab".repeat(32), + "attested_proof": { + "scheme": "solana", + "claimed_signer": "deadbeef", + "signature": "00".repeat(64), + "approved_tx_hash": "ab".repeat(32), + "public_key": "11".repeat(32) + } + })) + .expect("request json"); + + let command = request.into_command(caller()).expect("valid command"); + let WebUiInboundCommand::ResolveGate { resolution, .. } = command else { + panic!("expected resolve-gate command"); + }; + let WebUiGateResolution::Attested { + kind, + approved_tx_hash_hex, + proof_json, + } = resolution + else { + panic!("expected attested resolution"); + }; + assert_eq!( + kind, + ironclaw_product_workflow::AttestedProofKind::InjectedWallet + ); + assert_eq!(approved_tx_hash_hex, "ab".repeat(32)); + assert!(proof_json.is_object()); +} + +#[test] +fn attested_resolution_normalizes_0x_prefixed_uppercase_hash() { + // A documented `0x`-prefixed (and here uppercase) hash must be canonicalized + // to the lowercase, prefix-free form the resume port compares against, so an + // otherwise-valid proof is not rejected as a binding mismatch. + let request: WebUiResolveGateRequest = serde_json::from_value(json!({ + "client_action_id": "gate-att-0x", + "thread_id": "thread-alpha", + "run_id": run_id(), + "gate_ref": "gate-alpha", + "resolution": "attested", + "attested_proof_kind": "injected_wallet", + "attested_approved_tx_hash": format!("0x{}", "AB".repeat(32)), + "attested_proof": { + "scheme": "solana", + "claimed_signer": "deadbeef", + "signature": "00".repeat(64), + "approved_tx_hash": "ab".repeat(32), + "public_key": "11".repeat(32) + } + })) + .expect("request json"); + + let command = request.into_command(caller()).expect("valid command"); + let WebUiInboundCommand::ResolveGate { resolution, .. } = command else { + panic!("expected resolve-gate command"); + }; + let WebUiGateResolution::Attested { + approved_tx_hash_hex, + .. + } = resolution + else { + panic!("expected attested resolution"); + }; + assert_eq!(approved_tx_hash_hex, "ab".repeat(32)); +} + +#[test] +fn attested_resolution_rejects_wrong_length_hash() { + let request: WebUiResolveGateRequest = serde_json::from_value(json!({ + "client_action_id": "gate-att-bad", + "thread_id": "thread-alpha", + "run_id": run_id(), + "gate_ref": "gate-alpha", + "resolution": "attested", + "attested_proof_kind": "injected_wallet", + "attested_approved_tx_hash": "abcd", + "attested_proof": {"k": "v"} + })) + .expect("request json"); + + let err = request + .into_command(caller()) + .expect_err("short hash must reject"); + assert_eq!(err.field, "attested_approved_tx_hash"); + assert_eq!(err.code, WebUiInboundValidationCode::InvalidValue); +} + +#[test] +fn attested_resolution_rejects_unknown_proof_kind() { + let request: WebUiResolveGateRequest = serde_json::from_value(json!({ + "client_action_id": "gate-att-2", + "thread_id": "thread-alpha", + "run_id": run_id(), + "gate_ref": "gate-alpha", + "resolution": "attested", + "attested_proof_kind": "bitcoin", + "attested_approved_tx_hash": "ab".repeat(32), + "attested_proof": {"k": "v"} + })) + .expect("request json"); + + let err = request + .into_command(caller()) + .expect_err("unknown proof kind must reject"); + assert_eq!(err.field, "attested_proof_kind"); + assert_eq!(err.code, WebUiInboundValidationCode::InvalidValue); +} + +#[test] +fn attested_resolution_rejects_non_object_proof() { + // `attested_proof` must be a JSON object; array / string / number / bool / + // null all fail closed as an InvalidValue on `attested_proof`. + for bad in [ + json!([1, 2, 3]), + json!("not-an-object"), + json!(42), + json!(true), + json!(null), + ] { + let request: WebUiResolveGateRequest = serde_json::from_value(json!({ + "client_action_id": "gate-att-nonobj", + "thread_id": "thread-alpha", + "run_id": run_id(), + "gate_ref": "gate-alpha", + "resolution": "attested", + "attested_proof_kind": "injected_wallet", + "attested_approved_tx_hash": "ab".repeat(32), + "attested_proof": bad.clone(), + })) + .expect("request json"); + + // `null` deserializes to `None` -> MissingField; every other non-object + // shape reaches the `is_object()` guard -> InvalidValue. Both fail closed + // on the `attested_proof` field. + let err = request + .into_command(caller()) + .expect_err("non-object attested_proof must reject"); + assert_eq!(err.field, "attested_proof", "input: {bad}"); + assert!( + matches!( + err.code, + WebUiInboundValidationCode::InvalidValue | WebUiInboundValidationCode::MissingField + ), + "input {bad} produced unexpected code {:?}", + err.code + ); + } +} + #[test] fn missing_content_returns_stable_validation_error() { let request: WebUiSendMessageRequest = serde_json::from_value(json!({ @@ -392,6 +552,7 @@ fn invalid_gate_resolution_returns_stable_validation_error() { resolution: Some("not_a_resolution".to_string()), always: None, credential_ref: None, + ..Default::default() }; let err = request diff --git a/crates/ironclaw_reborn_composition/Cargo.toml b/crates/ironclaw_reborn_composition/Cargo.toml index 70e9f6579d1..646f2ec9772 100644 --- a/crates/ironclaw_reborn_composition/Cargo.toml +++ b/crates/ironclaw_reborn_composition/Cargo.toml @@ -88,6 +88,10 @@ ironclaw_attested_runtime = { path = "../ironclaw_attested_runtime" } ironclaw_attestation = { path = "../ironclaw_attestation" } ironclaw_chain_signing = { path = "../ironclaw_chain_signing" } ironclaw_signing_provider = { path = "../ironclaw_signing_provider" } +# External-wallet providers (PR7/8/9): the composition decodes the WebUI +# attested-proof payload into the concrete provider proof and registers the +# providers that verify it on the signer-continuation driver (PR11). +ironclaw_wallet_external = { path = "../ironclaw_wallet_external" } # axum + tower middleware stack used by the `webui_serve` module. Each # entry is feature-gated so non-beta builds carry no HTTP surface code. axum = { version = "0.8" } @@ -113,6 +117,9 @@ url = "2" uuid = { version = "1", features = ["v4"] } [dev-dependencies] +# Crypto-real injected-wallet proof construction for the PR11 attested +# gate/resolve end-to-end facade test (ed25519 Solana signature over the hash). +ed25519-dalek = "2" # Attested-signing composition e2e test (real RebornAttestedComposition): derive # a bound EVM signer + proof. alloy/k256 are already in-tree via chain_signing. diff --git a/crates/ironclaw_reborn_composition/src/attested.rs b/crates/ironclaw_reborn_composition/src/attested.rs index 351dea356ce..d9ff9092432 100644 --- a/crates/ironclaw_reborn_composition/src/attested.rs +++ b/crates/ironclaw_reborn_composition/src/attested.rs @@ -21,13 +21,52 @@ use std::sync::Arc; -use ironclaw_attestation::{InMemorySealedGrantStore, InMemorySigningLedger}; +use ironclaw_attestation::{ + AttestedSigningGrant, GrantError, GrantKey, InMemorySealedGrantStore, InMemorySigningLedger, + SealedGrantStore, +}; use ironclaw_attested_runtime::{ - AttestedSignerContinuationDriver, BroadcastOutcome, Broadcaster, ContinuationError, - InMemoryAttestedGateBindingStore, ProviderRegistry, + AttestedGateBinding, AttestedGateBindingStore, AttestedSignerContinuationDriver, BindingError, + BroadcastOutcome, Broadcaster, ContinuationError, InMemoryAttestedGateBindingStore, + ProviderRegistry, }; use ironclaw_chain_signing::{CustodialSigner, DenyFirstCustodyPolicy, SecretsKeyStore, ShipGate}; -use ironclaw_signing_provider::SigningContext; +use ironclaw_signing_provider::{GateRef, SigningContext}; + +/// Error from [`RebornAttestedComposition::register_attested_gate`]. Distinct +/// from [`ironclaw_attestation::GrantError`] so the gate-raise caller can tell +/// a hardening rejection (mismatched gate_ref / duplicate raise) apart from a +/// grant-store / binding-store backend failure. +#[derive(Debug)] +pub enum RegisterAttestedGateError { + /// The supplied `gate_ref` did not equal `binding.context.gate_ref`. + GateRefMismatch, + /// A binding (or sealed grant) already exists for this gate: registration is + /// insert-only and the first raise wins. + DuplicateBinding, + /// The underlying sealed-grant store failed. + Grant(GrantError), + /// The binding store rejected or could not record the binding (validation + /// failure or backend error). Fail closed — the gate is not registered. + BindingStore(BindingError), +} + +impl std::fmt::Display for RegisterAttestedGateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::GateRefMismatch => { + write!(f, "gate_ref does not match binding.context.gate_ref") + } + Self::DuplicateBinding => { + write!(f, "attested gate already registered (insert-only)") + } + Self::Grant(e) => write!(f, "sealed-grant store failed: {e}"), + Self::BindingStore(e) => write!(f, "binding store failed: {e}"), + } + } +} + +impl std::error::Error for RegisterAttestedGateError {} /// The concrete custodial signer type the local-dev composition assembles. Its /// generic parameters are pinned here so the rest of the runtime never names @@ -129,6 +168,72 @@ impl RebornAttestedComposition { } } + /// Register an attested gate: seal its one-shot grant and persist its + /// authoritative binding. This is the PR11 ingress entry point invoked when + /// a gate is raised. + /// + /// In-memory only (PR11); durable PG / libSQL backends are PR12. + /// + /// Hardening invariants enforced here: + /// - The supplied `gate_ref` MUST equal `binding.context.gate_ref`. A + /// mismatch would let the binding be filed under a key that names a + /// different gate than the one the authoritative context describes — the + /// resume port and driver both look the binding up by `gate_ref`, so a + /// mismatch is a binding-confusion vector. Fail closed. + /// - Registration is INSERT-ONLY: an existing binding for the same gate + /// (request id) is never overwritten. The first raise wins; a second raise + /// for the same gate is refused so an attacker cannot redefine the + /// authoritative `(hash, signer, decoded tx)` after the fact (threats + /// #2/#3/#4). The grant seal is likewise one-shot. + pub async fn register_attested_gate( + &self, + gate_ref: GateRef, + binding: AttestedGateBinding, + created_at_ms: i64, + expiry_ms: Option, + ) -> Result<(), RegisterAttestedGateError> { + // gate_ref must match the authoritative context's gate_ref. + if binding.context.gate_ref.as_str() != gate_ref.as_str() { + return Err(RegisterAttestedGateError::GateRefMismatch); + } + + // Seal the one-shot grant first. The seal is an atomic CAS + // (`AlreadySealed` on a second seal of the same key), so it is the gate + // that serializes concurrent raises of the same gate: at most one caller + // wins the seal and reaches the binding insert below. A duplicate seal + // means the gate was already raised; surface it as a duplicate rather + // than proceeding to (re)write the binding. + let grant_key = GrantKey::from_context(&binding.context, binding.approved_tx_hash); + match self + .grants + .seal(AttestedSigningGrant::new( + grant_key, + created_at_ms, + expiry_ms, + ).map_err(RegisterAttestedGateError::Grant)?) + .await + { + Ok(()) => {} + Err(GrantError::AlreadySealed) => { + return Err(RegisterAttestedGateError::DuplicateBinding); + } + Err(other) => return Err(RegisterAttestedGateError::Grant(other)), + } + + // Insert-only, ATOMIC + VALIDATED: the store's `put` is insert-only (the + // existence check and the insert happen under a single critical section, + // closing the check-then-act TOCTOU window) and fully validates the + // binding (`gate_ref`/hash/chain/signer self-consistency) before + // persisting. An existing binding for this gate fails closed with + // `AlreadyExists` — treat it as a duplicate, consistent with the grant + // CAS above; any other validation/backend error fails closed too. + match self.bindings.put(gate_ref, binding).await { + Ok(()) => Ok(()), + Err(BindingError::AlreadyExists) => Err(RegisterAttestedGateError::DuplicateBinding), + Err(other) => Err(RegisterAttestedGateError::BindingStore(other)), + } + } + /// The authoritative gate-binding store. The PR11 ingress persists a /// binding here when it raises an attested gate, and the driver reads it /// back on continuation. @@ -318,7 +423,9 @@ mod tests { assert!( matches!( err, - ContinuationError::Ledger(_) | ContinuationError::ChainSigning(_) + ContinuationError::Ledger(_) + | ContinuationError::LedgerRowExists { .. } + | ContinuationError::ChainSigning(_) ), "expected fail-closed replay, got {err:?}" ); diff --git a/crates/ironclaw_reborn_composition/src/attested_continuation.rs b/crates/ironclaw_reborn_composition/src/attested_continuation.rs new file mode 100644 index 00000000000..5a22639f3fd --- /dev/null +++ b/crates/ironclaw_reborn_composition/src/attested_continuation.rs @@ -0,0 +1,507 @@ +//! Composition-layer implementation of the crypto-free +//! [`AttestedGateContinuationPort`] (attested-signing PR11). +//! +//! This is the bridge between the crypto-free WebUI facade +//! ([`ironclaw_product_workflow`]) and the attested-signing signer-continuation +//! driver assembled in [`crate::attested`] over [`ironclaw_attested_runtime`]. +//! +//! Atomic verify-before-resume (PR11 item B): this port runs the heavyweight +//! cryptographic half in two phases, straddling the turn transition. +//! +//! 1. [`AttestedGateContinuationPort::verify_and_claim`] (BEFORE the turn +//! transitions): decode the opaque [`AttestedProofClaim`] into the concrete +//! [`ironclaw_signing_provider::SigningProof`] for its proof family (mirrors +//! the legacy monolith decode in +//! `src/channels/web/features/chat/attested.rs`), then call +//! [`AttestedSignerContinuationDriver::verify_and_sign`], which reads the +//! authoritative binding, claims the sealed one-shot grant, and verifies the +//! proof through the bound provider. On any failure the turn is left +//! `BlockedAttested` (the facade never resumes). On success it returns an +//! opaque verified handle. +//! 2. [`AttestedGateContinuationPort::broadcast_resolved`] (AFTER `resume_turn` +//! drove the turn to `AttestedResolved`): consume the verified handle and +//! call [`AttestedSignerContinuationDriver::broadcast_signed_continuation`] +//! to perform the ledger-guarded broadcast. No re-verification, no re-claim. +//! +//! All verification (signer/hash binding, sealed-grant CAS, ledger idempotency) +//! lives in `ironclaw_attested_runtime` / the providers — this module is decode +//! + dispatch only. + +use std::sync::Arc; + +use async_trait::async_trait; + +use ironclaw_attested_runtime::{ContinuationError, VerifiedContinuation}; +use ironclaw_product_workflow::{ + AttestedContinuationOutcome, AttestedContinuationRejection, AttestedGateContinuationPort, + AttestedProofClaim, AttestedProofKind, VerifiedAttestedContinuation, +}; +use ironclaw_signing_provider::{ + ApprovedTxHash, GateRef as SigningGateRef, SigningProof, SigningProviderError, +}; +use ironclaw_turns::{GateRef, TurnRunId, TurnScope}; +use ironclaw_wallet_external::{ + InjectedProofPayload, InjectedScheme, NearAccessKeyScope, NearRedirectProofPayload, + WalletConnectProofPayload, encode_injected_proof, encode_near_redirect_proof, + encode_walletconnect_proof, +}; +use serde::Deserialize; + +use crate::attested::{LocalDevContinuationDriver, RebornAttestedComposition}; + +/// Composition-layer [`AttestedGateContinuationPort`]. +/// +/// Holds the assembled signer-continuation driver shared with the reborn +/// runtime (the same driver + binding store + ledger the resume port reads). +pub struct RebornAttestedContinuation { + driver: Arc, +} + +impl RebornAttestedContinuation { + /// Build the port over the runtime's attested-signing composition. + pub fn new(composition: &RebornAttestedComposition) -> Self { + Self { + driver: Arc::clone(composition.driver()), + } + } +} + +#[async_trait] +impl AttestedGateContinuationPort for RebornAttestedContinuation { + async fn verify_and_claim( + &self, + _scope: &TurnScope, + _run_id: TurnRunId, + gate_ref: &GateRef, + claim: &AttestedProofClaim, + ) -> Result { + // FULL verification + one-shot grant claim, run BEFORE the facade + // transitions the turn. A malformed proof fails closed here at decode; a + // forged signature / signer mismatch / already-claimed grant fails closed + // inside the driver's `verify_and_sign` (provider `verify_resume` + the + // sealed-grant CAS) — all before any `AttestedResolved` transition. The + // driver reads the authoritative binding itself and re-checks the bound + // hash against the proof, so the caller can only attest to the bound hash + // (threat #3), never redefine it. + let proof = decode_proof(claim)?; + let signing_gate_ref = SigningGateRef::new(gate_ref.as_str()); + + // External-wallet path only: the wallet already signed, so no custodial + // EVM transaction is supplied. The custodial path is selected purely by + // the authoritative binding's `provider_id` (never by the caller). + let verified = self + .driver + .verify_and_sign(&signing_gate_ref, &proof) + .await + .map_err(map_continuation_error)?; + + Ok(VerifiedAttestedContinuation::new(verified)) + } + + async fn broadcast_resolved( + &self, + _scope: &TurnScope, + _run_id: TurnRunId, + _gate_ref: &GateRef, + verified: VerifiedAttestedContinuation, + ) -> Result { + // Recover the concrete verified continuation produced by + // `verify_and_claim`. A type mismatch (only possible if a different port + // implementation produced the handle) fails closed rather than panicking. + let verified = *verified + .downcast::() + .map_err(|_| AttestedContinuationRejection::ProofRejected)?; + + // Broadcast only — the proof is already verified and the grant already + // claimed. No re-verification, no re-claim. + let outcome = self + .driver + .broadcast_signed_continuation(verified) + .await + .map_err(map_continuation_error)?; + + Ok(AttestedContinuationOutcome { + signer: outcome.signer, + }) + } +} + +/// Decode the opaque WebUI proof claim into the concrete provider proof for its +/// family. Mirrors the legacy monolith wire contract +/// (`src/channels/web/features/chat/attested.rs`): every byte field arrives as +/// lowercase-hex (optionally `0x`-prefixed) and the hash as hex, so we parse the +/// JSON via explicit input structs rather than the payload types directly (the +/// payload's `ApprovedTxHash` serde is a raw byte array, not the hex wire form). +/// A malformed payload fails closed as `MalformedProof`. +fn decode_proof(claim: &AttestedProofClaim) -> Result { + match claim.kind { + AttestedProofKind::InjectedWallet => { + let input: InjectedWalletProofInput = parse_input(&claim.proof_json)?; + let scheme = match input.scheme.as_str() { + "evm" => InjectedScheme::Evm, + "solana" => InjectedScheme::Solana, + _ => return Err(AttestedContinuationRejection::MalformedProof), + }; + let payload = InjectedProofPayload { + scheme, + approved_tx_hash: parse_hash(&input.approved_tx_hash)?, + claimed_signer: input.claimed_signer, + signature: parse_hex(&input.signature)?, + public_key: input.public_key.as_deref().map(parse_hex).transpose()?, + }; + Ok(SigningProof::InjectedProof( + encode_injected_proof(&payload) + .map_err(|_| AttestedContinuationRejection::MalformedProof)?, + )) + } + AttestedProofKind::NearRedirect => { + let input: NearRedirectProofInput = parse_input(&claim.proof_json)?; + let access_key_scope = match input.access_key_scope { + NearAccessKeyScopeInput::FullAccess => NearAccessKeyScope::FullAccess, + NearAccessKeyScopeInput::FunctionCall { + receiver_id, + method_names, + } => NearAccessKeyScope::FunctionCall { + receiver_id, + method_names, + }, + }; + let payload = NearRedirectProofPayload { + approved_tx_hash: parse_hash(&input.approved_tx_hash)?, + account_id: input.account_id, + public_key: parse_hex(&input.public_key)?, + signature: parse_hex(&input.signature)?, + access_key_scope, + state: input.state, + }; + Ok(SigningProof::NearRedirectProof( + encode_near_redirect_proof(&payload) + .map_err(|_| AttestedContinuationRejection::MalformedProof)?, + )) + } + AttestedProofKind::WalletConnect => { + let input: WalletConnectProofInput = parse_input(&claim.proof_json)?; + let payload = WalletConnectProofPayload { + session_topic: input.session_topic, + approved_tx_hash: parse_hash(&input.approved_tx_hash)?, + claimed_signer: input.claimed_signer, + nonce: parse_hex(&input.nonce)?, + signed_payload: parse_hex(&input.signed_payload)?, + signature: parse_hex(&input.signature)?, + public_key: input.public_key.as_deref().map(parse_hex).transpose()?, + }; + Ok(SigningProof::WalletConnectProof( + encode_walletconnect_proof(&payload) + .map_err(|_| AttestedContinuationRejection::MalformedProof)?, + )) + } + } +} + +fn parse_input Deserialize<'de>>( + value: &serde_json::Value, +) -> Result { + serde_json::from_value(value.clone()).map_err(|_| AttestedContinuationRejection::MalformedProof) +} + +/// Parse a 32-byte hex (optionally `0x`-prefixed) approved-tx hash. +fn parse_hash(s: &str) -> Result { + let bytes = parse_hex(s)?; + let arr: [u8; 32] = bytes + .try_into() + .map_err(|_| AttestedContinuationRejection::MalformedProof)?; + Ok(ApprovedTxHash::from_bytes(arr)) +} + +/// Decode a hex string (optionally `0x`-prefixed) to bytes. +/// +/// Operates over raw bytes after validating the input is pure ASCII-hex, so a +/// multibyte-Unicode JSON value can never trigger a non-char-boundary slice +/// panic — a malformed (non-ASCII-hex or odd-length) input fails closed as +/// [`AttestedContinuationRejection::MalformedProof`]. +fn parse_hex(s: &str) -> Result, AttestedContinuationRejection> { + let s = s.strip_prefix("0x").unwrap_or(s); + let bytes = s.as_bytes(); + if !bytes.len().is_multiple_of(2) { + return Err(AttestedContinuationRejection::MalformedProof); + } + bytes + .chunks_exact(2) + .map(|pair| { + let hi = hex_nibble(pair[0])?; + let lo = hex_nibble(pair[1])?; + Ok((hi << 4) | lo) + }) + .collect() +} + +/// Decode a single ASCII-hex digit to its nibble value, fail-closed. +fn hex_nibble(byte: u8) -> Result { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + b'A'..=b'F' => Ok(byte - b'A' + 10), + _ => Err(AttestedContinuationRejection::MalformedProof), + } +} + +/// Wire input for an injected-wallet proof (lowercase-hex fields). Mirrors the +/// legacy `InjectedWalletProofInput`. +#[derive(Debug, Deserialize)] +struct InjectedWalletProofInput { + scheme: String, + claimed_signer: String, + signature: String, + approved_tx_hash: String, + #[serde(default)] + public_key: Option, +} + +/// Wire input for a NEAR redirect proof. Mirrors the legacy +/// `NearRedirectProofInput`. +#[derive(Debug, Deserialize)] +struct NearRedirectProofInput { + account_id: String, + public_key: String, + signature: String, + approved_tx_hash: String, + access_key_scope: NearAccessKeyScopeInput, + state: String, +} + +/// Wire form of the NEAR access-key scope. Mirrors the legacy +/// `NearAccessKeyScopeInput`. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind")] +enum NearAccessKeyScopeInput { + FullAccess, + FunctionCall { + receiver_id: String, + #[serde(default)] + method_names: Vec, + }, +} + +/// Wire input for a WalletConnect v2 proof. +#[derive(Debug, Deserialize)] +struct WalletConnectProofInput { + session_topic: String, + claimed_signer: String, + nonce: String, + signature: String, + approved_tx_hash: String, + /// The exact bytes the wallet's chain signature covers (the EVM sighash / + /// Solana message), as lowercase hex. Bound to the recorded expectation by + /// the provider before any signature work (WYSIWYS, #1). + signed_payload: String, + #[serde(default)] + public_key: Option, +} + +/// Map the driver's [`ContinuationError`] to the sanitized facade rejection. +/// Categories only — no chain, signer, or ledger internals cross this boundary. +fn map_continuation_error(error: ContinuationError) -> AttestedContinuationRejection { + match error { + ContinuationError::MissingBinding => AttestedContinuationRejection::MissingBinding, + ContinuationError::ProviderMismatch { .. } => { + AttestedContinuationRejection::ProviderMismatch + } + ContinuationError::ProofRejected(SigningProviderError::GrantClaimFailed) => { + // A replayed proof for an already-claimed grant is an idempotency + // guard outcome, surfaced as a conflict to the client. + AttestedContinuationRejection::LedgerGuard + } + // A tampered/inconsistent authoritative binding (sign-time hash re-check + // mismatch, the binding's chain not matching its own decoded tx, or a + // decoded tx that cannot be rebuilt into a signable) all fail closed + // BEFORE any signing. None are retryable as-is; surface them as a proof + // rejection rather than a recoverable infra failure. + ContinuationError::ProofRejected(_) + | ContinuationError::ApprovedHashMismatch + | ContinuationError::BindingChainMismatch + | ContinuationError::Rebuild(_) => AttestedContinuationRejection::ProofRejected, + ContinuationError::Ledger(_) | ContinuationError::LedgerRowExists { .. } => { + AttestedContinuationRejection::LedgerGuard + } + ContinuationError::ChainSigning(_) => AttestedContinuationRejection::ProofRejected, + // A broadcast failure is a POST-verification, server-side (recoverable) + // infrastructure failure: the proof was already verified and the grant + // claimed. Surfacing it as ProofRejected (400) would wrongly imply the + // client's proof was bad; map it to Unavailable (503) so the client can + // retry the broadcast tail instead. + ContinuationError::Broadcast { .. } => AttestedContinuationRejection::Unavailable, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_hex_rejects_multibyte_unicode_without_panicking() { + // A multibyte-Unicode string whose byte length is even: the old + // byte-offset `&s[i..i+2]` slice would panic on a non-char-boundary. + // It must fail closed as MalformedProof instead. + let result = parse_hex("déadbeef"); + assert!(matches!( + result, + Err(AttestedContinuationRejection::MalformedProof) + )); + + // Other Unicode shapes (odd byte length, emoji) must also fail closed. + assert!(matches!( + parse_hex("é"), + Err(AttestedContinuationRejection::MalformedProof) + )); + assert!(matches!( + parse_hex("🦀🦀"), + Err(AttestedContinuationRejection::MalformedProof) + )); + } + + #[test] + fn parse_hex_accepts_valid_hex_with_optional_prefix() { + assert_eq!(parse_hex("00ff").unwrap(), vec![0x00, 0xff]); + assert_eq!(parse_hex("0xDEAD").unwrap(), vec![0xde, 0xad]); + assert_eq!(parse_hex("").unwrap(), Vec::::new()); + } + + fn hash_hex_64() -> String { + "11".repeat(32) + } + + #[test] + fn decode_injected_wallet_proof() { + let claim = AttestedProofClaim { + kind: AttestedProofKind::InjectedWallet, + approved_tx_hash_hex: hash_hex_64(), + proof_json: serde_json::json!({ + "scheme": "evm", + "claimed_signer": "0xabc", + "signature": "deadbeef", + "approved_tx_hash": hash_hex_64(), + }), + }; + assert!(matches!( + decode_proof(&claim), + Ok(SigningProof::InjectedProof(_)) + )); + } + + #[test] + fn decode_near_redirect_proof() { + let claim = AttestedProofClaim { + kind: AttestedProofKind::NearRedirect, + approved_tx_hash_hex: hash_hex_64(), + proof_json: serde_json::json!({ + "account_id": "alice.near", + "public_key": "aa", + "signature": "bbcc", + "approved_tx_hash": hash_hex_64(), + "access_key_scope": { "kind": "full_access" }, + "state": "opaque-state", + }), + }; + assert!(matches!( + decode_proof(&claim), + Ok(SigningProof::NearRedirectProof(_)) + )); + + // FunctionCall scope variant also decodes. + let claim_fc = AttestedProofClaim { + kind: AttestedProofKind::NearRedirect, + approved_tx_hash_hex: hash_hex_64(), + proof_json: serde_json::json!({ + "account_id": "alice.near", + "public_key": "aa", + "signature": "bbcc", + "approved_tx_hash": hash_hex_64(), + "access_key_scope": { + "kind": "function_call", + "receiver_id": "contract.near", + "method_names": ["do_thing"], + }, + "state": "opaque-state", + }), + }; + assert!(matches!( + decode_proof(&claim_fc), + Ok(SigningProof::NearRedirectProof(_)) + )); + } + + #[test] + fn decode_walletconnect_proof() { + let claim = AttestedProofClaim { + kind: AttestedProofKind::WalletConnect, + approved_tx_hash_hex: hash_hex_64(), + proof_json: serde_json::json!({ + "session_topic": "topic-123", + "claimed_signer": "0xabc", + "nonce": "0011", + "signed_payload": "cafe", + "signature": "deadbeef", + "approved_tx_hash": hash_hex_64(), + }), + }; + assert!(matches!( + decode_proof(&claim), + Ok(SigningProof::WalletConnectProof(_)) + )); + } + + #[test] + fn decode_proof_rejects_malformed_payload() { + // Missing required fields for the family fails closed. + let claim = AttestedProofClaim { + kind: AttestedProofKind::WalletConnect, + approved_tx_hash_hex: hash_hex_64(), + proof_json: serde_json::json!({ "session_topic": "only-this" }), + }; + assert!(matches!( + decode_proof(&claim), + Err(AttestedContinuationRejection::MalformedProof) + )); + } + + #[test] + fn broadcast_failure_maps_to_unavailable_not_proof_rejected() { + // A broadcast / RPC failure is a post-verification, server-side + // (recoverable) infrastructure failure — it must NOT be surfaced as + // ProofRejected (which implies the proof was bad and maps to 400). It + // maps to Unavailable (503) so clients can retry. + let rejection = map_continuation_error(ContinuationError::Broadcast { + reason: "rpc timeout".to_string(), + }); + assert!(matches!( + rejection, + AttestedContinuationRejection::Unavailable + )); + } + + #[test] + fn custodial_signing_failure_still_maps_to_proof_rejected() { + // A custodial signer failure happens during verification/signing (before + // broadcast) and remains a client-facing rejection. + let rejection = map_continuation_error(ContinuationError::ChainSigning( + ironclaw_chain_signing::ChainSigningError::SignerMismatch, + )); + assert!(matches!( + rejection, + AttestedContinuationRejection::ProofRejected + )); + } + + #[test] + fn parse_hash_rejects_unicode_and_wrong_length() { + assert!(matches!( + parse_hash("déadbeef"), + Err(AttestedContinuationRejection::MalformedProof) + )); + // Valid hex but not 32 bytes. + assert!(matches!( + parse_hash("00ff"), + Err(AttestedContinuationRejection::MalformedProof) + )); + } +} diff --git a/crates/ironclaw_reborn_composition/src/lib.rs b/crates/ironclaw_reborn_composition/src/lib.rs index 0f88d513570..8ee1964d7fd 100644 --- a/crates/ironclaw_reborn_composition/src/lib.rs +++ b/crates/ironclaw_reborn_composition/src/lib.rs @@ -30,6 +30,7 @@ mod blocked_auth_resume; mod builtin_capability_policy; pub mod deployment; mod attested; +mod attested_continuation; mod error; mod extension_host; mod factory; @@ -61,7 +62,8 @@ mod webui; pub use admin_token::AdminApiTokenMinter; pub use automation::facade::RebornAutomationProductFacade; pub use automation::trigger_poller::PostSubmitDeliveryHook; -pub use attested::{NoopBroadcaster, RebornAttestedComposition}; +pub use attested::{NoopBroadcaster, RebornAttestedComposition, RegisterAttestedGateError}; +pub use attested_continuation::RebornAttestedContinuation; pub use error::RebornBuildError; pub use extension_host::channel_host::{ChannelHostIdentity, GenericChannelHostAssembly}; pub use extension_host::channel_identity::{ diff --git a/crates/ironclaw_reborn_composition/src/runtime.rs b/crates/ironclaw_reborn_composition/src/runtime.rs index 4515c330130..ed82334b813 100644 --- a/crates/ironclaw_reborn_composition/src/runtime.rs +++ b/crates/ironclaw_reborn_composition/src/runtime.rs @@ -89,6 +89,7 @@ use ironclaw_turns::{ events::EventCursor, run_profile::{LoopHostMilestoneSink, LoopRunContext}, }; +use ironclaw_wallet_external::InjectedSigningProvider; use ironclaw_host_runtime::MemoryBackedUserProfileSource; #[cfg(any(test, feature = "test-support"))] diff --git a/crates/ironclaw_reborn_composition/src/webui/facade.rs b/crates/ironclaw_reborn_composition/src/webui/facade.rs index 0bb1855b858..1214e90e2eb 100644 --- a/crates/ironclaw_reborn_composition/src/webui/facade.rs +++ b/crates/ironclaw_reborn_composition/src/webui/facade.rs @@ -259,6 +259,19 @@ pub(crate) fn build_webui_services_with_channel_connection( ) .with_approval_interactions(runtime.webui_approval_interaction_service()) .with_auth_interactions(runtime.webui_auth_interaction_service()); + + // Attested-signing continuation port (PR11): wire the WebUI `resolve_gate` + // attested path to the runtime's signer-continuation driver + shared gate + // binding store. The facade stays crypto-free; this port (composition layer, + // over `ironclaw_attested_runtime`) does the decode + driver dispatch, and + // reuses the SAME driver/binding/ledger the resume port reads. Absent an + // attested composition (production until the durable stores land) the port + // stays unset and attested resolutions fail closed. + if let Some(attested) = runtime.attested_signing() { + api = api.with_attested_continuation(Arc::new( + crate::RebornAttestedContinuation::new(attested), + )); + } // Admin user-management surface: wired only when the identity directory, // the admin secret provisioner, and a token minter are all available. // Otherwise the fail-closed RejectingAdminUserService default stands and diff --git a/crates/ironclaw_reborn_composition/tests/attested_gate_resolve_ingress.rs b/crates/ironclaw_reborn_composition/tests/attested_gate_resolve_ingress.rs new file mode 100644 index 00000000000..9499ce15384 --- /dev/null +++ b/crates/ironclaw_reborn_composition/tests/attested_gate_resolve_ingress.rs @@ -0,0 +1,786 @@ +//! End-to-end test for the PR11 reborn WebUI attested gate/resolve ingress. +//! +//! Drives the CALLER (`RebornServices::resolve_gate`, the same facade the +//! `ironclaw_webui_v2` `resolve_gate` HTTP handler calls) through the full +//! attested-signing lifecycle, per CLAUDE.md "Test Through the Caller": +//! +//! raise (persist authoritative binding + seal one-shot grant) +//! -> block the turn `BlockedAttested` +//! -> POST an attested injected-wallet proof through `resolve_gate` +//! -> `RuntimeAttestedResumePort` re-checks the binding + claims the resume +//! guard -> turn transitions to `AttestedResolved` +//! -> `AttestedSignerContinuationDriver` verifies the proof through the bound +//! provider, claims the sealed grant, and broadcasts under the ledger. +//! +//! It also asserts the security envelope PR11 must preserve: a replayed resolve +//! fails closed (one-shot resume guard + sealed grant), and an attested resolve +//! with no continuation port wired fails closed. + +use std::sync::Arc; + +use chrono::{TimeZone, Utc}; +use ed25519_dalek::{Signer as _, SigningKey as EdSigningKey}; + +use ironclaw_attestation::{ + Bytes32, DecodedTransaction, RenderingSchemaVersion, SolanaCompiledInstruction, + SolanaMessageHeader, SolanaMessageVersion, SolanaTransaction, +}; +use ironclaw_attested_runtime::{ + AttestedGateBinding, InMemoryAttestedGateBindingStore, InMemoryResumeGuard, ResumeGuard, + RuntimeAttestedResumePort, approved_tx_hash_ref_hex, +}; +use ironclaw_chain_signing::{ChainKeyId, SecretsKeyStore, recompute_approved_hash}; +use ironclaw_host_api::{ + AgentId, InvocationId, ProjectId, ResourceScope, TenantId, ThreadId, UserId, +}; +use ironclaw_product_workflow::{ + AttestedContinuationOutcome, AttestedContinuationRejection, AttestedGateContinuationPort, + AttestedProofClaim, RebornServices, RebornServicesApi, WebUiAuthenticatedCaller, + WebUiResolveGateRequest, +}; +use ironclaw_reborn_composition::{ + RebornAttestedComposition, RebornAttestedContinuation, RegisterAttestedGateError, +}; +use ironclaw_secrets::SecretsCrypto; +use ironclaw_signing_provider::{ + ActorId, ApprovedTxHash, ChainId, GateRef as SigningGateRef, KeyOrAccountId, ProviderId, RunId, + ScopeId, SigningContext, TenantId as SigningTenantId, UserId as SigningUserId, +}; +use ironclaw_threads::{ + EnsureThreadRequest, InMemorySessionThreadService, SessionThreadService, ThreadScope, +}; +use ironclaw_turns::{ + AcceptedMessageRef, ApprovedTxHashRef, AttestedResumePort, BlockedReason, + DefaultTurnCoordinator, GateRef, IdempotencyKey, InMemoryTurnStateStore, + LoopCheckpointStateRef, ReplyTargetBindingRef, RunProfileRequest, SourceBindingRef, + SubmitTurnRequest, SubmitTurnResponse, TurnActor, TurnCheckpointId, TurnCoordinator, + TurnLeaseToken, TurnRunId, TurnRunnerId, TurnScope, + runner::{BlockRunRequest, ClaimRunRequest, TurnRunTransitionPort}, +}; + +use secrecy::SecretString; +use serde_json::json; + +const GATE: &str = "gate:pr11-attested-ingress"; +const TENANT: &str = "tenant1"; +const AGENT: &str = "agent1"; +const PROJECT: &str = "project1"; +const USER: &str = "user1"; +const THREAD: &str = "thread-pr11"; + +/// The authoritative decoded transaction the binding is approved over. A Solana +/// (`solana:mainnet`) message so its `chain_network()` matches the binding's +/// `chain` / context `chain_id` and the injected-wallet (ed25519/Solana) proof +/// the resolve requests carry. +fn placeholder_decoded() -> DecodedTransaction { + DecodedTransaction::Solana(SolanaTransaction { + cluster: "mainnet".to_string(), + version: SolanaMessageVersion::Legacy, + header: SolanaMessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 1, + }, + static_account_keys: vec![Bytes32([0x44; 32]), Bytes32([0x55; 32])], + recent_blockhash: Bytes32([0x66; 32]), + instructions: vec![SolanaCompiledInstruction { + program_id_index: 1, + account_indices: vec![0], + data: vec![1, 2, 3], + }], + address_table_lookups: vec![], + }) +} + +/// The 32-byte approved-tx hash the wallet attests to, recomputed from the +/// authoritative decoded tx folded with the GATE-BOUND signer (`account_hex`), +/// so a validating insert-only binding store accepts it (WYSIWYS +/// self-consistency). +fn bound_hash(account_hex: &str) -> ApprovedTxHash { + recompute_approved_hash( + &placeholder_decoded(), + account_hex, + RenderingSchemaVersion::CURRENT, + ) + .expect("recompute approved hash in test") +} + +fn signing_ctx(account_hex: &str) -> SigningContext { + SigningContext { + tenant: SigningTenantId::new(TENANT), + user: SigningUserId::new(USER), + scope: ScopeId::new("scope"), + actor: ActorId::new("actor"), + run_id: RunId::new("run"), + gate_ref: SigningGateRef::new(GATE), + chain_id: ChainId::new("solana:mainnet"), + key_or_account_id: KeyOrAccountId::new(account_hex), + } +} + +fn turn_scope() -> TurnScope { + TurnScope::new( + TenantId::new(TENANT).unwrap(), + Some(AgentId::new(AGENT).unwrap()), + Some(ProjectId::new(PROJECT).unwrap()), + ThreadId::new(THREAD).unwrap(), + ) +} + +fn caller() -> WebUiAuthenticatedCaller { + WebUiAuthenticatedCaller::new( + TenantId::new(TENANT).unwrap(), + UserId::new(USER).unwrap(), + Some(AgentId::new(AGENT).unwrap()), + Some(ProjectId::new(PROJECT).unwrap()), + ) +} + +fn lower_hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push(char::from_digit((b >> 4) as u32, 16).unwrap()); + out.push(char::from_digit((b & 0x0f) as u32, 16).unwrap()); + } + out +} + +/// Build the local-dev attested composition (the same wiring the reborn runtime +/// assembles), exposed here so the test can register a gate and read the driver. +fn build_composition(bindings: Arc) -> RebornAttestedComposition { + use ironclaw_attestation::InMemorySealedGrantStore; + use ironclaw_attested_runtime::{CustodialMainnetShipGate, ProviderRegistry}; + use ironclaw_wallet_external::InjectedSigningProvider; + + let crypto = SecretsCrypto::new(SecretString::from( + "0123456789abcdef0123456789ABCDEF".to_string(), + )) + .expect("valid local-dev master key"); + let keystore = Arc::new(SecretsKeyStore::new(crypto)); + let ship_gate = CustodialMainnetShipGate::from_env().build_chain_ship_gate(None); + let grants = Arc::new(InMemorySealedGrantStore::new()); + RebornAttestedComposition::new(bindings, keystore, ship_gate, grants, |grants| { + ProviderRegistry::new() + .with_provider(Arc::new(InjectedSigningProvider::new( + Arc::clone(grants) as Arc + ))) + }) +} + +/// Submit a turn and block it `BlockedAttested` on `GATE`. +async fn block_attested( + store: &Arc, + expected_tx_hash_ref: &str, +) -> TurnRunId { + let scope = turn_scope(); + let coordinator = DefaultTurnCoordinator::new(store.clone()); + let SubmitTurnResponse::Accepted { run_id, .. } = coordinator + .submit_turn(SubmitTurnRequest { + scope: scope.clone(), + actor: TurnActor::new(UserId::new(USER).unwrap()), + accepted_message_ref: AcceptedMessageRef::new("msg-pr11").unwrap(), + source_binding_ref: SourceBindingRef::new("source-web").unwrap(), + reply_target_binding_ref: ReplyTargetBindingRef::new("reply-web").unwrap(), + requested_run_profile: Some(RunProfileRequest::new("default").unwrap()), + idempotency_key: IdempotencyKey::new("idem-pr11").unwrap(), + received_at: Utc.with_ymd_and_hms(2026, 5, 24, 12, 0, 0).unwrap(), + }) + .await + .unwrap(); + let runner_id = TurnRunnerId::new(); + let lease_token = TurnLeaseToken::new(); + store + .claim_next_run(ClaimRunRequest { + runner_id, + lease_token, + scope_filter: Some(scope.clone()), + }) + .await + .unwrap() + .unwrap(); + store + .block_run(BlockRunRequest { + run_id, + runner_id, + lease_token, + checkpoint_id: TurnCheckpointId::new(), + state_ref: LoopCheckpointStateRef::new("checkpoint:block").unwrap(), + reason: BlockedReason::Attested { + gate_ref: GateRef::new(GATE).unwrap(), + expected_tx_hash: ApprovedTxHashRef::new(expected_tx_hash_ref).unwrap(), + }, + }) + .await + .unwrap(); + run_id +} + +async fn ensure_thread(thread_service: &Arc) { + thread_service + .ensure_thread(EnsureThreadRequest { + scope: ThreadScope { + tenant_id: TenantId::new(TENANT).unwrap(), + agent_id: AgentId::new(AGENT).unwrap(), + project_id: Some(ProjectId::new(PROJECT).unwrap()), + owner_user_id: Some(UserId::new(USER).unwrap()), + mission_id: None, + }, + thread_id: Some(ThreadId::new(THREAD).unwrap()), + created_by_actor_id: USER.to_string(), + title: None, + metadata_json: None, + }) + .await + .expect("ensure thread"); +} + +/// Build an `attested` injected-wallet (Solana) resolve request whose proof +/// signs the bound hash with `key`. +fn attested_request( + run_id: TurnRunId, + key: &EdSigningKey, + hash: &ApprovedTxHash, + account_hex: &str, + client_action_id: &str, +) -> WebUiResolveGateRequest { + let signature = key.sign(hash.as_bytes()); + WebUiResolveGateRequest { + client_action_id: Some(client_action_id.to_string()), + thread_id: Some(THREAD.to_string()), + run_id: Some(run_id.to_string()), + gate_ref: Some(GATE.to_string()), + resolution: Some("attested".to_string()), + always: None, + credential_ref: None, + attested_proof_kind: Some("injected_wallet".to_string()), + attested_approved_tx_hash: Some(approved_tx_hash_ref_hex(hash.as_bytes())), + attested_proof: Some(json!({ + "scheme": "solana", + "approved_tx_hash": lower_hex(hash.as_bytes()), + "claimed_signer": account_hex, + "signature": lower_hex(&signature.to_bytes()), + "public_key": account_hex, + })), + } +} + +fn binding(account_hex: &str, hash: ApprovedTxHash) -> AttestedGateBinding { + AttestedGateBinding { + provider_id: ProviderId::Injected, + context: signing_ctx(account_hex), + approved_tx_hash: hash, + decoded: placeholder_decoded(), + chain: ChainKeyId::new("solana:mainnet").expect("valid chain id in test"), + scope: ResourceScope { + tenant_id: TenantId::new(TENANT).unwrap(), + user_id: UserId::new(USER).unwrap(), + agent_id: Some(AgentId::new(AGENT).unwrap()), + project_id: Some(ProjectId::new(PROJECT).unwrap()), + mission_id: None, + thread_id: None, + invocation_id: InvocationId::new(), + }, + schema_version: RenderingSchemaVersion::CURRENT, + } +} + +#[tokio::test] +async fn resolve_gate_attested_drives_resume_and_continuation() { + let key = EdSigningKey::from_bytes(&[0x22u8; 32]); + let account_hex = lower_hex(&key.verifying_key().to_bytes()); + + let hash = bound_hash(&account_hex); + let hash_ref = approved_tx_hash_ref_hex(hash.as_bytes()); + + // Wire the resume port over the shared binding store exactly as the reborn + // factory does, then build the turn store with it injected. + let bindings = Arc::new(InMemoryAttestedGateBindingStore::new()); + let resume_guard: Arc = Arc::new(InMemoryResumeGuard::new()); + let port: Arc = Arc::new(RuntimeAttestedResumePort::new( + Arc::clone(&bindings), + Arc::clone(&resume_guard), + )); + let store = Arc::new(InMemoryTurnStateStore::default().with_attested_resume_port(port)); + + // Build the attested composition over the SAME binding store, and wire the + // continuation port into the facade. + let composition = build_composition(Arc::clone(&bindings)); + + // Raise side (PR11): persist the authoritative binding + seal the one-shot + // grant. + composition + .register_attested_gate( + SigningGateRef::new(GATE), + binding(&account_hex, hash), + 0, + None, + ) + .await + .expect("register attested gate"); + + let thread_service = Arc::new(InMemorySessionThreadService::default()); + ensure_thread(&thread_service).await; + + let coordinator: Arc = + Arc::new(DefaultTurnCoordinator::new(store.clone())); + let services = RebornServices::new(thread_service.clone(), coordinator) + .with_attested_continuation(Arc::new(RebornAttestedContinuation::new(&composition))); + + let run_id = block_attested(&store, &hash_ref).await; + + // POST the attested proof through the facade (the resolve_gate caller). + let response = services + .resolve_gate( + caller(), + attested_request(run_id, &key, &hash, &account_hex, "action-1"), + ) + .await + .expect("attested resolve succeeds end-to-end"); + + match response { + ironclaw_product_workflow::RebornResolveGateResponse::Resumed(resumed) => { + assert_eq!( + resumed.status, + ironclaw_turns::TurnStatus::AttestedResolved, + "resume must transition the turn to AttestedResolved" + ); + } + other => panic!("expected Resumed, got {other:?}"), + } + + // Replay: a second resolve of the same gate fails closed. The turn is no + // longer BlockedAttested and the one-shot resume guard / sealed grant + // refuse it. + let replay = services + .resolve_gate( + caller(), + attested_request(run_id, &key, &hash, &account_hex, "action-2"), + ) + .await; + assert!( + replay.is_err(), + "replayed attested resolve must fail closed" + ); +} + +#[tokio::test] +async fn resolve_gate_attested_without_continuation_port_fails_closed() { + let key = EdSigningKey::from_bytes(&[0x33u8; 32]); + let account_hex = lower_hex(&key.verifying_key().to_bytes()); + let hash = bound_hash(&account_hex); + let hash_ref = approved_tx_hash_ref_hex(hash.as_bytes()); + + let bindings = Arc::new(InMemoryAttestedGateBindingStore::new()); + let resume_guard: Arc = Arc::new(InMemoryResumeGuard::new()); + let port: Arc = Arc::new(RuntimeAttestedResumePort::new( + Arc::clone(&bindings), + Arc::clone(&resume_guard), + )); + let store = Arc::new(InMemoryTurnStateStore::default().with_attested_resume_port(port)); + let composition = build_composition(Arc::clone(&bindings)); + composition + .register_attested_gate( + SigningGateRef::new(GATE), + binding(&account_hex, hash), + 0, + None, + ) + .await + .expect("register attested gate"); + + let thread_service = Arc::new(InMemorySessionThreadService::default()); + ensure_thread(&thread_service).await; + let coordinator: Arc = + Arc::new(DefaultTurnCoordinator::new(store.clone())); + // No `.with_attested_continuation(...)`. + let services = RebornServices::new(thread_service.clone(), coordinator); + + let run_id = block_attested(&store, &hash_ref).await; + let result = services + .resolve_gate( + caller(), + attested_request(run_id, &key, &hash, &account_hex, "action-1"), + ) + .await; + assert!( + result.is_err(), + "attested resolve with no continuation port wired must fail closed" + ); +} + +/// A continuation port wrapper that counts how many times the full verify+claim +/// (`verify_and_claim`) runs and how many times the broadcast half +/// (`broadcast_resolved`) is driven, delegating both to the real composition +/// port. Lets the tests assert the verify-before-resume ordering and the +/// single-drive invariant (PR11 item B). +struct CountingContinuation { + inner: RebornAttestedContinuation, + verify_calls: Arc, + drive_calls: Arc, +} + +#[async_trait::async_trait] +impl AttestedGateContinuationPort for CountingContinuation { + async fn verify_and_claim( + &self, + scope: &TurnScope, + run_id: TurnRunId, + gate_ref: &GateRef, + claim: &AttestedProofClaim, + ) -> Result< + ironclaw_product_workflow::VerifiedAttestedContinuation, + AttestedContinuationRejection, + > { + self.verify_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.inner + .verify_and_claim(scope, run_id, gate_ref, claim) + .await + } + + async fn broadcast_resolved( + &self, + scope: &TurnScope, + run_id: TurnRunId, + gate_ref: &GateRef, + verified: ironclaw_product_workflow::VerifiedAttestedContinuation, + ) -> Result { + self.drive_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.inner + .broadcast_resolved(scope, run_id, gate_ref, verified) + .await + } +} + +/// Build a fully-wired services + turn store + composition over a shared binding +/// store, with a `CountingContinuation` so a test can observe drive/verify counts. +async fn wired_services_with_counting( + bytes_seed: u8, +) -> ( + RebornServices, + Arc, + String, + EdSigningKey, + ApprovedTxHash, + String, + Arc, + Arc, +) { + let key = EdSigningKey::from_bytes(&[bytes_seed; 32]); + let account_hex = lower_hex(&key.verifying_key().to_bytes()); + let hash = bound_hash(&account_hex); + let hash_ref = approved_tx_hash_ref_hex(hash.as_bytes()); + + let bindings = Arc::new(InMemoryAttestedGateBindingStore::new()); + let resume_guard: Arc = Arc::new(InMemoryResumeGuard::new()); + let port: Arc = Arc::new(RuntimeAttestedResumePort::new( + Arc::clone(&bindings), + Arc::clone(&resume_guard), + )); + let store = Arc::new(InMemoryTurnStateStore::default().with_attested_resume_port(port)); + let composition = build_composition(Arc::clone(&bindings)); + composition + .register_attested_gate( + SigningGateRef::new(GATE), + binding(&account_hex, hash), + 0, + None, + ) + .await + .expect("register attested gate"); + + let thread_service = Arc::new(InMemorySessionThreadService::default()); + ensure_thread(&thread_service).await; + let coordinator: Arc = + Arc::new(DefaultTurnCoordinator::new(store.clone())); + let verify_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let drive_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counting = CountingContinuation { + inner: RebornAttestedContinuation::new(&composition), + verify_calls: Arc::clone(&verify_calls), + drive_calls: Arc::clone(&drive_calls), + }; + let services = RebornServices::new(thread_service, coordinator) + .with_attested_continuation(Arc::new(counting)); + ( + services, + store, + hash_ref, + key, + hash, + account_hex, + verify_calls, + drive_calls, + ) +} + +/// A same-key retry is single-drive via the grant/ledger CAS: the first resolve +/// claims the grant + broadcasts once; the retry's `verify_and_claim` re-enters +/// the driver, the one-shot ledger CAS rejects the second claim (clean replay +/// error), and the broadcast half is never reached again — no double +/// sign/broadcast. +#[tokio::test] +async fn resolve_gate_attested_retry_is_single_drive_via_grant_cas() { + let (services, store, hash_ref, key, hash, account_hex, verify, drive) = + wired_services_with_counting(0x44).await; + let run_id = block_attested(&store, &hash_ref).await; + + let req = || attested_request(run_id, &key, &hash, &account_hex, "action-retry"); + + // First (fresh) resolve: verify+claim runs once, broadcast drives once. + services + .resolve_gate(caller(), req()) + .await + .expect("fresh attested resolve succeeds"); + assert_eq!( + verify.load(std::sync::atomic::Ordering::SeqCst), + 1, + "fresh resolve runs verify+claim exactly once" + ); + assert_eq!( + drive.load(std::sync::atomic::Ordering::SeqCst), + 1, + "fresh resolve drives the broadcast exactly once" + ); + + // Retry: verify+claim re-enters the driver, the one-shot grant/ledger CAS + // rejects the second claim. The retry fails closed (clean replay error) and + // the broadcast half is NOT reached again. + let replay = services.resolve_gate(caller(), req()).await; + assert!( + replay.is_err(), + "same-key retry must fail closed via the grant/ledger CAS" + ); + assert_eq!( + verify.load(std::sync::atomic::Ordering::SeqCst), + 2, + "retry re-enters verify+claim (where the CAS rejects it)" + ); + assert_eq!( + drive.load(std::sync::atomic::Ordering::SeqCst), + 1, + "retry must NOT double-drive the broadcast" + ); +} + +/// A malformed proof must be rejected by `verify_and_claim` BEFORE `resume_turn` +/// (it fails at decode, before any grant claim or ledger advance), leaving the +/// turn `BlockedAttested` and driving NO broadcast — so a follow-up VALID resolve +/// still succeeds. +#[tokio::test] +async fn resolve_gate_attested_malformed_proof_fails_before_resume() { + let (services, store, hash_ref, key, hash, account_hex, verify, drive) = + wired_services_with_counting(0x55).await; + let run_id = block_attested(&store, &hash_ref).await; + + // Corrupt the proof so decode fails: a multibyte-Unicode signature field + // (also exercises the panic-free hex path). + let mut req = attested_request(run_id, &key, &hash, &account_hex, "action-bad"); + req.attested_proof = Some(json!({ + "scheme": "solana", + "approved_tx_hash": lower_hex(hash.as_bytes()), + "claimed_signer": account_hex, + "signature": "déadbeef", + "public_key": account_hex, + })); + + let result = services.resolve_gate(caller(), req).await; + assert!(result.is_err(), "malformed proof must fail closed"); + assert_eq!( + verify.load(std::sync::atomic::Ordering::SeqCst), + 1, + "verify+claim ran (and failed at decode)" + ); + assert_eq!( + drive.load(std::sync::atomic::Ordering::SeqCst), + 0, + "broadcast must NOT be driven for a malformed proof" + ); + + // The turn must remain BlockedAttested (no state mutated): a follow-up VALID + // resolve with a fresh client_action_id still succeeds and drives once. + let ok = services + .resolve_gate( + caller(), + attested_request(run_id, &key, &hash, &account_hex, "action-good"), + ) + .await; + assert!( + ok.is_ok(), + "turn stayed BlockedAttested after the malformed-proof rejection: {ok:?}" + ); + assert_eq!( + drive.load(std::sync::atomic::Ordering::SeqCst), + 1, + "the valid follow-up drives the continuation exactly once" + ); +} + +/// When NO authoritative binding was ever registered for the blocked gate, the +/// resolve must fail closed: the resume port cannot validate the attested claim +/// against an absent binding, so the turn stays `BlockedAttested` and the +/// continuation is never driven. Exercises the missing-binding path that every +/// other e2e test bypasses by registering a binding first. +#[tokio::test] +async fn resolve_gate_attested_with_no_binding_fails_closed() { + let key = EdSigningKey::from_bytes(&[0x99u8; 32]); + let account_hex = lower_hex(&key.verifying_key().to_bytes()); + let hash = bound_hash(&account_hex); + let hash_ref = approved_tx_hash_ref_hex(hash.as_bytes()); + + // Wire the resume port + continuation over a binding store, but DO NOT + // register any binding for the gate. + let bindings = Arc::new(InMemoryAttestedGateBindingStore::new()); + let resume_guard: Arc = Arc::new(InMemoryResumeGuard::new()); + let port: Arc = Arc::new(RuntimeAttestedResumePort::new( + Arc::clone(&bindings), + Arc::clone(&resume_guard), + )); + let store = Arc::new(InMemoryTurnStateStore::default().with_attested_resume_port(port)); + let composition = build_composition(Arc::clone(&bindings)); + + let thread_service = Arc::new(InMemorySessionThreadService::default()); + ensure_thread(&thread_service).await; + let coordinator: Arc = + Arc::new(DefaultTurnCoordinator::new(store.clone())); + let verify_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let drive_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counting = CountingContinuation { + inner: RebornAttestedContinuation::new(&composition), + verify_calls: Arc::clone(&verify_calls), + drive_calls: Arc::clone(&drive_calls), + }; + let services = RebornServices::new(thread_service, coordinator) + .with_attested_continuation(Arc::new(counting)); + + let run_id = block_attested(&store, &hash_ref).await; + + let result = services + .resolve_gate( + caller(), + attested_request(run_id, &key, &hash, &account_hex, "action-no-binding"), + ) + .await; + assert!( + result.is_err(), + "resolve with no registered binding must fail closed, got {result:?}" + ); + assert_eq!( + drive_calls.load(std::sync::atomic::Ordering::SeqCst), + 0, + "continuation must NOT be driven when the binding is absent" + ); +} + +/// A FORGED proof (well-formed, but signed by the WRONG key) must be rejected by +/// the FULL cryptographic verification inside `verify_and_claim` BEFORE +/// `resume_turn`: the turn stays `BlockedAttested`, no broadcast is driven, and +/// the sealed grant is NOT consumed — so a follow-up VALID resolve still +/// succeeds. This is the core item-B guarantee: signature verification gates the +/// transition. +#[tokio::test] +async fn resolve_gate_attested_forged_signature_fails_before_resume() { + let (services, store, hash_ref, key, hash, account_hex, verify, drive) = + wired_services_with_counting(0x77).await; + let run_id = block_attested(&store, &hash_ref).await; + + // Forge: keep the bound signer/account, but sign with a DIFFERENT key so the + // recovered signer will not match. The proof is structurally valid (decodes + // fine) but cryptographically wrong. + let wrong_key = EdSigningKey::from_bytes(&[0x88u8; 32]); + let forged_signature = wrong_key.sign(hash.as_bytes()); + let mut req = attested_request(run_id, &key, &hash, &account_hex, "action-forged"); + req.attested_proof = Some(json!({ + "scheme": "solana", + "approved_tx_hash": lower_hex(hash.as_bytes()), + "claimed_signer": account_hex, + "signature": lower_hex(&forged_signature.to_bytes()), + "public_key": account_hex, + })); + + let result = services.resolve_gate(caller(), req).await; + assert!( + result.is_err(), + "forged signature must fail closed before resume" + ); + assert_eq!( + verify.load(std::sync::atomic::Ordering::SeqCst), + 1, + "verify+claim ran (and the signature check rejected the forgery)" + ); + assert_eq!( + drive.load(std::sync::atomic::Ordering::SeqCst), + 0, + "broadcast must NOT be driven for a forged proof" + ); + + // The turn stayed BlockedAttested and the grant was NOT consumed: a valid + // follow-up still succeeds and drives the broadcast exactly once. + let ok = services + .resolve_gate( + caller(), + attested_request(run_id, &key, &hash, &account_hex, "action-good"), + ) + .await; + assert!( + ok.is_ok(), + "turn stayed BlockedAttested + grant unclaimed after forgery: {ok:?}" + ); + assert_eq!( + drive.load(std::sync::atomic::Ordering::SeqCst), + 1, + "the valid follow-up drives the broadcast exactly once" + ); +} + +/// `register_attested_gate` rejects a gate_ref that mismatches +/// `binding.context.gate_ref` and refuses to overwrite an existing binding. +#[tokio::test] +async fn register_attested_gate_rejects_mismatch_and_is_insert_only() { + let key = EdSigningKey::from_bytes(&[0x66u8; 32]); + let account_hex = lower_hex(&key.verifying_key().to_bytes()); + let hash = bound_hash(&account_hex); + let bindings = Arc::new(InMemoryAttestedGateBindingStore::new()); + let composition = build_composition(Arc::clone(&bindings)); + + // gate_ref mismatch: binding.context.gate_ref is GATE, register under a + // different gate_ref => GateRefMismatch. + let mismatch = composition + .register_attested_gate( + SigningGateRef::new("gate:other"), + binding(&account_hex, hash), + 0, + None, + ) + .await; + assert!( + matches!(mismatch, Err(RegisterAttestedGateError::GateRefMismatch)), + "gate_ref/binding mismatch must be rejected, got {mismatch:?}" + ); + + // First valid raise succeeds. + composition + .register_attested_gate( + SigningGateRef::new(GATE), + binding(&account_hex, hash), + 0, + None, + ) + .await + .expect("first raise succeeds"); + + // Second raise for the same gate is refused (insert-only). + let dup = composition + .register_attested_gate( + SigningGateRef::new(GATE), + binding(&account_hex, hash), + 0, + None, + ) + .await; + assert!( + matches!(dup, Err(RegisterAttestedGateError::DuplicateBinding)), + "a second raise for the same gate must be refused, got {dup:?}" + ); +} diff --git a/crates/ironclaw_wallet_external/Cargo.toml b/crates/ironclaw_wallet_external/Cargo.toml index 72d0de0fc0c..466c3ac9620 100644 --- a/crates/ironclaw_wallet_external/Cargo.toml +++ b/crates/ironclaw_wallet_external/Cargo.toml @@ -1,6 +1,10 @@ [package] name = "ironclaw_wallet_external" version = "0.1.0" +# Internal workspace crate (path-deps on sibling substrate crates) — not +# published; satisfies cargo-deny wildcards=deny (allow-wildcard-paths only +# exempts publish=false crates). +publish = false edition = "2024" rust-version = "1.92" description = "External-wallet signing providers (browser injected window.ethereum / window.solana) for the IronClaw attested-signing substrate" @@ -8,10 +12,6 @@ authors = ["NEAR AI "] license = "MIT OR Apache-2.0" homepage = "https://github.com/nearai/ironclaw" repository = "https://github.com/nearai/ironclaw" -# Internal workspace crate — not published to crates.io. cargo-deny's -# allow-wildcard-paths exemption is skipped for publishable crates (crates.io -# disallows path deps), so this crate's path deps would otherwise be flagged. -publish = false [package.metadata.dist] dist = false @@ -43,10 +43,12 @@ sha2 = "0.10" base64 = "0.22" # WalletConnect v2 relay transport (PR9). The fork at -# tracecommons/walletconnect-rs @ c6b528e defaults to rustls (openssl-free); we +# tracecommons/walletconnect-rs @ 7078fd3 defaults to rustls (openssl-free); we # take DEFAULT features for relay_client and DO NOT enable relay_rpc's `cacao` # feature (it pulls alloy 0.3.6 -> reqwest default-tls -> openssl). The # architecture boundary test `workspace_graph_is_openssl_free` enforces this. +# Rev 7078fd3 moves relay_rpc to jsonwebtoken 9 (ring 0.17), dropping the yanked +# ring 0.16.20 that cargo-deny rejects as unlicensed — matches the clean stack tip. relay_client = { git = "https://github.com/tracecommons/walletconnect-rs", rev = "7078fd303cc521cceeed919e3d452f45eeb115e8", package = "relay_client" } relay_rpc = { git = "https://github.com/tracecommons/walletconnect-rs", rev = "7078fd303cc521cceeed919e3d452f45eeb115e8", package = "relay_rpc" } From cf7053d65940645174c983dddda44e00959a92ca Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 23 Jul 2026 17:54:54 +0100 Subject: [PATCH 2/2] feat(signing): reborn webui attested gate/resolve ingress + cross-user IDOR fix (#3995, ported) --- .../ironclaw_attested_runtime/src/driver.rs | 95 ++++++++-- crates/ironclaw_attested_runtime/src/lib.rs | 6 +- .../tests/threat_matrix.rs | 4 +- .../src/attested_continuation.rs | 9 +- .../src/reborn_services.rs | 152 ++++++++++++++++ .../src/webui_inbound.rs | 30 ++- .../src/attested.rs | 11 ++ .../src/attested_continuation.rs | 106 ++++++++++- .../tests/attested_gate_resolve_ingress.rs | 172 +++++++++++++++++- 9 files changed, 547 insertions(+), 38 deletions(-) diff --git a/crates/ironclaw_attested_runtime/src/driver.rs b/crates/ironclaw_attested_runtime/src/driver.rs index 262900b2203..a4c16a06eff 100644 --- a/crates/ironclaw_attested_runtime/src/driver.rs +++ b/crates/ironclaw_attested_runtime/src/driver.rs @@ -165,6 +165,15 @@ impl VerifiedContinuation { pub enum ContinuationError { /// No authoritative binding exists for the resolved `gate_ref`. MissingBinding, + /// The calling identity does not own the binding addressed by `gate_ref` + /// (cross-user / cross-tenant IDOR). The binding's authoritative + /// `tenant`/`user` (recorded when the gate was raised by the original + /// raiser) does not match the caller resolving the gate. Fail-closed + /// BEFORE any provider verify / custodial sign / grant claim, so a member + /// who merely learns another user's `gate_ref` can never drive that user's + /// signing continuation. Surfaced to clients indistinguishably from + /// `MissingBinding` (a 404) so it is not an existence oracle. + OwnerMismatch, /// The carried proof's provider does not match the bound provider, or no /// provider is registered for it. ProviderMismatch { @@ -208,6 +217,12 @@ impl std::fmt::Display for ContinuationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::MissingBinding => write!(f, "no authoritative binding for the resolved gate"), + Self::OwnerMismatch => { + write!( + f, + "calling identity does not own the addressed gate binding" + ) + } Self::ProviderMismatch { bound } => { write!(f, "provider mismatch: bound provider is {bound:?}") } @@ -276,6 +291,19 @@ pub struct AttestedSignerContinuationDriver { broadcaster: Arc, } +/// The calling identity asserting ownership of an attested-gate binding on +/// resume. Compared against the binding's authoritative `tenant`/`user` +/// (recorded by the original raiser) before any verify/sign/claim, so a member +/// who merely learns another user's `gate_ref` cannot drive that user's signing +/// continuation (IDOR defense). Both axes must match. +#[derive(Debug, Clone, Copy)] +pub struct BindingOwner<'a> { + /// The caller's tenant boundary. + pub tenant_id: &'a str, + /// The caller's user identity. + pub user_id: &'a str, +} + impl AttestedSignerContinuationDriver where B: Broadcaster, @@ -393,6 +421,40 @@ where } } + /// Assert the caller owns the binding addressed by `gate_ref` BEFORE any + /// verify / custodial sign / grant claim (IDOR defense). + /// + /// The binding's authoritative `SigningContext` carries the original + /// raiser's `tenant`/`user`. A second tenant member who learns another + /// user's `gate_ref` (returned in the gate-raise response) must not be able + /// to drive that user's signing continuation — most acutely on the + /// custodial path, where the proof payload is ignored and the driver signs + /// from the authoritative binding regardless of who presents the gate_ref. + /// + /// Fail-closed `OwnerMismatch` on either a missing binding *or* an identity + /// divergence, so the result is indistinguishable from a non-existent gate + /// (no existence oracle). Both tenant and user axes must match. The binding + /// is immutable once written, so re-reading it here (separately from + /// [`Self::verify_and_sign`]) is race-free; the sealed-grant CAS remains the + /// authoritative one-shot guard. + pub async fn assert_binding_owner( + &self, + gate_ref: &GateRef, + caller: BindingOwner<'_>, + ) -> Result<(), ContinuationError> { + let binding = self + .bindings + .get(gate_ref) + .await + .ok_or(ContinuationError::MissingBinding)?; + if binding.context.tenant.as_str() != caller.tenant_id + || binding.context.user.as_str() != caller.user_id + { + return Err(ContinuationError::OwnerMismatch); + } + Ok(()) + } + /// One-shot broadcast-idempotency ledger create (threats #6 / #7): an /// existing row for this `gate_ref` (a prior attempt) makes any re-entry fail /// closed. Surfaced as a dedicated idempotency-guard error carrying the @@ -690,24 +752,25 @@ where // An `InvalidTransition` is expected and benign here: the row is // already at or past a terminal. Any OTHER ledger error (e.g. a // backend failure) means we could NOT confirm the row is safely - // terminal — surface it at `warn!` so it is visible in release - // builds, not silently swallowed by a `debug_assert!` that compiles - // out. The original broadcast error stays authoritative. - if matches!( + // terminal. This recovery runs on the background broadcast-failure + // path, so per CLAUDE.md ("Background tasks must NEVER use `info!` + // — it breaks the interactive display"; the same applies to + // `warn!`) we log at `debug!` with a structured `recoverable` field + // rather than corrupting the REPL/TUI stream. The original + // broadcast error stays authoritative; a ledger row that could not + // be confirmed terminal is flagged here for operator triage via a + // structured field, not a user-facing warning. + let recoverable = matches!( e, ironclaw_attestation::LedgerError::InvalidTransition { .. } - ) { - tracing::debug!( - gate_ref = %gate_ref.as_str(), - "recover_unknown: row already at/past a terminal state ({e:?})" - ); - } else { - tracing::warn!( - gate_ref = %gate_ref.as_str(), - "recover_unknown: failed to move row to Unknown terminal after a \ - broadcast failure ({e:?}); the row may be left non-terminal" - ); - } + ); + tracing::debug!( + gate_ref = %gate_ref.as_str(), + recoverable, + "recover_unknown: ledger advance to Unknown terminal returned an error \ + ({e:?}); recoverable=true means the row was already terminal (benign), \ + recoverable=false means the row may be left non-terminal" + ); } } diff --git a/crates/ironclaw_attested_runtime/src/lib.rs b/crates/ironclaw_attested_runtime/src/lib.rs index 89677bb9321..b30a15e2b2b 100644 --- a/crates/ironclaw_attested_runtime/src/lib.rs +++ b/crates/ironclaw_attested_runtime/src/lib.rs @@ -58,9 +58,9 @@ pub use binding::{ validate_binding, }; pub use driver::{ - AttestedSignerContinuationDriver, BroadcastDisposition, BroadcastOutcome, Broadcaster, - ContinuationError, CustodialSignerLike, EvmSignable, ProviderRegistry, RebuildError, - SignerContinuationOutcome, VerifiedContinuation, + AttestedSignerContinuationDriver, BindingOwner, BroadcastDisposition, BroadcastOutcome, + Broadcaster, ContinuationError, CustodialSignerLike, EvmSignable, ProviderRegistry, + RebuildError, SignerContinuationOutcome, VerifiedContinuation, }; pub use port::{ InMemoryResumeGuard, ResumeGuard, RuntimeAttestedResumePort, approved_tx_hash_ref_hex, diff --git a/crates/ironclaw_attested_runtime/tests/threat_matrix.rs b/crates/ironclaw_attested_runtime/tests/threat_matrix.rs index 2584989094e..476e59ca9f4 100644 --- a/crates/ironclaw_attested_runtime/tests/threat_matrix.rs +++ b/crates/ironclaw_attested_runtime/tests/threat_matrix.rs @@ -1171,7 +1171,7 @@ async fn external_wallet_verify_failure_does_not_strand_ledger_at_signing() { // (the row is only created after verify + grant claim succeed), so it can // never be stranded at an in-flight state. assert_eq!( - ledger.state(&gate).await, + ledger.state(&lk(&gate)).await, Err(ironclaw_attestation::LedgerError::NotFound), "rejected proof must not create a ledger row at all" ); @@ -1227,7 +1227,7 @@ async fn external_wallet_unregistered_provider_is_provider_mismatch() { // Verify-before-resume: a provider mismatch is detected before any ledger // row is created, so no row exists. assert_eq!( - ledger.state(&gate).await, + ledger.state(&lk(&gate)).await, Err(ironclaw_attestation::LedgerError::NotFound), "provider mismatch must not create a ledger row" ); diff --git a/crates/ironclaw_product_workflow/src/attested_continuation.rs b/crates/ironclaw_product_workflow/src/attested_continuation.rs index 060029d95f0..54ba6d9288a 100644 --- a/crates/ironclaw_product_workflow/src/attested_continuation.rs +++ b/crates/ironclaw_product_workflow/src/attested_continuation.rs @@ -37,7 +37,7 @@ use std::any::Any; use async_trait::async_trait; use serde::{Deserialize, Serialize}; -use ironclaw_turns::{GateRef, TurnRunId, TurnScope}; +use ironclaw_turns::{GateRef, TurnActor, TurnRunId, TurnScope}; /// The proof family carried on an attested gate resolution. Mirrors the legacy /// monolith `GateResolutionPayload` variants for wire compatibility; the @@ -192,9 +192,16 @@ pub trait AttestedGateContinuationPort: Send + Sync { /// advanced the implementation's own ledger/grant fail-closed state; that is /// internal one-shot bookkeeping, never a turn-state transition, and means a /// retry of the same gate is refused rather than double-driven.) + /// `actor` carries the calling user identity; the implementation MUST + /// verify the addressed gate binding is owned by `(scope.tenant_id, + /// actor.user_id)` before any cryptographic work, failing closed + /// indistinguishably from a missing binding so a member who merely learns + /// another user's `gate_ref` cannot drive that user's signing continuation + /// (IDOR defense). async fn verify_and_claim( &self, scope: &TurnScope, + actor: &TurnActor, run_id: TurnRunId, gate_ref: &GateRef, claim: &AttestedProofClaim, diff --git a/crates/ironclaw_product_workflow/src/reborn_services.rs b/crates/ironclaw_product_workflow/src/reborn_services.rs index 9e26dcdb262..cacdaa9dd5e 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services.rs @@ -890,6 +890,10 @@ impl AutomationProductFacade for UnsupportedAutomationProductFacade { enum GateResolutionRoute { Approval, Auth, + /// Attested-signing ceremony. Folded into this single resolver (rather than + /// a parallel dispatch branch) per the #4029 gate-family agreement, so every + /// gate family is classified in exactly one place. + Attested, Generic, } @@ -917,6 +921,14 @@ impl GateResolutionRoute { )?; Ok(Self::Auth) } + TurnStatus::BlockedAttested => { + validate_current_gate_ref( + parked_gate_ref, + requested_gate_ref, + RebornServicesErrorKind::Conflict, + )?; + Ok(Self::Attested) + } status if status.is_terminal() => Err(RebornServicesError::from_status_kind( RebornServicesErrorCode::Conflict, RebornServicesErrorKind::Conflict, @@ -928,6 +940,15 @@ impl GateResolutionRoute { } fn from_gate_shape(gate_ref: &GateRef, resolution: &WebUiGateResolution) -> Self { + // An attested resolution routes to the attested resolver even when the + // run could not be read (e.g. cross-user `ScopeNotFound`): the attested + // resolver performs its own owner-scoped verify-and-claim and surfaces a + // 404 (no existence oracle), whereas falling through to `Generic` would + // reject the resolution shape with a 400 and leak that the request was + // even attested-shaped. This is the #3995 cross-user IDOR contract. + if matches!(resolution, WebUiGateResolution::Attested { .. }) { + return Self::Attested; + } match ( is_approval_gate_ref(gate_ref.as_str()), is_auth_gate_ref(gate_ref.as_str()), @@ -1936,6 +1957,8 @@ pub trait RebornServicesApi: Send + Sync { request: WebUiResolveGateRequest, ) -> Result; + + async fn retry_run( &self, caller: WebUiAuthenticatedCaller, @@ -4686,6 +4709,29 @@ where ) .await } + GateResolutionRoute::Attested => { + let WebUiGateResolution::Attested { + kind, + approved_tx_hash_hex, + proof_json, + } = resolution + else { + return Err(attested_invalid_field("resolution")); + }; + self.resolve_attested_gate( + access.scope, + access.run_actor, + run_id, + gate_ref, + client_action_id, + AttestedProofClaim { + kind, + approved_tx_hash_hex, + proof_json, + }, + ) + .await + } GateResolutionRoute::Generic => { self.resolve_generic_gate( access.scope, @@ -6455,6 +6501,112 @@ where } } + /// Resolve a `BlockedAttested` gate with an external-wallet / custodial + /// attested-signing proof — atomic verify-before-resume (PR11 item B). + /// + /// Flow (v2 path — `gate_ref` carries the `request_id` binding): + /// 1. `verify_and_claim`: the FULL cryptographic signature verification AND + /// the one-shot sealed-grant claim run BEFORE any turn-state mutation, + /// through the injected `AttestedGateContinuationPort` (composition layer + /// over `ironclaw_attested_runtime`). On ANY failure (malformed/forged + /// proof, signer/hash mismatch, grant already claimed, missing binding) + /// we return a clean error and the turn stays `BlockedAttested` with no + /// state-machine mutation — `resume_turn` is never called. + /// 2. Only on a successful verify+claim, `resume_turn` transitions + /// `BlockedAttested -> AttestedResolved`. The injected `AttestedResumePort` + /// (composition-layer `RuntimeAttestedResumePort`) runs a synchronous + /// authoritative-binding re-check + one-shot resume guard as defense in + /// depth — it does NOT re-claim the grant. + /// 3. `broadcast_resolved`: drive the sign-output broadcast through the same + /// port, consuming the verified handle from step 1. No re-verification, + /// no re-claim. + /// + /// Single-drive guarantee: a same-`client_action_id` retry re-enters + /// `verify_and_claim`, where the grant/ledger one-shot CAS rejects the second + /// claim (a clean replay error) before any resume or broadcast — so the + /// continuation can never sign/broadcast twice. The facade therefore needs no + /// separate idempotency fingerprint. + /// + /// This facade stays crypto-free: all signature decode + provider verify + /// live behind the port in `ironclaw_reborn_composition` / + /// `ironclaw_attested_runtime`. + async fn resolve_attested_gate( + &self, + scope: TurnScope, + actor: TurnActor, + run_id: TurnRunId, + gate_ref: GateRef, + client_action_id: IdempotencyKey, + claim: AttestedProofClaim, + ) -> Result { + let Some(continuation) = self.attested_continuation.clone() else { + // Attested signing not wired on this deployment: fail closed rather + // than resume a gate we cannot complete. + return Err(RebornServicesError::from_status_kind( + RebornServicesErrorCode::Unavailable, + RebornServicesErrorKind::BlockedApproval, + 503, + false, + )); + }; + + // The untrusted attestation claim is the proof's bound-hash hex. The + // resume port re-checks it against the authoritative persisted binding; + // a claim that does not even name the bound hash fails closed there. + let attestation = AttestationClaimRef::new(claim.approved_tx_hash_hex.clone()) + .map_err(|_| attested_invalid_field("attested_approved_tx_hash"))?; + + // ATOMIC VERIFY-BEFORE-RESUME. Run the FULL cryptographic verification + + // one-shot grant claim BEFORE `resume_turn`. `resume_turn` transitions + // `BlockedAttested -> AttestedResolved`, clears the gate_ref, and consumes + // the one-shot resume guard; we must not commit that transition for a + // proof that fails verification. On any failure here the turn stays + // `BlockedAttested` and NO run/mission/gate state-machine transition + // occurs. + let verified = continuation + .verify_and_claim(&scope, &actor, run_id, &gate_ref, &claim) + .await + .map_err(map_attested_continuation_rejection)?; + + let binding_id = webui_gate_binding_id(&scope, &gate_ref_string(&gate_ref)); + let resume = self + .turn_coordinator + .resume_turn(ResumeTurnRequest { + scope: scope.clone(), + actor, + run_id, + gate_resolution_ref: gate_ref.clone(), + source_binding_ref: webui_source_binding_ref_from_raw( + "webui-gate-src", + &binding_id, + )?, + reply_target_binding_ref: webui_reply_target_binding_ref_from_raw( + "webui-gate-reply", + &binding_id, + )?, + // The attested gate has its own precondition so a resume cannot + // be redirected onto a different blocked family. + precondition: ResumeTurnPrecondition::BlockedAttestedGate, + resume_disposition: None, + idempotency_key: client_action_id, + attestation: Some(attestation), + }) + .await + .map_err(map_turn_error)?; + + // The turn is `AttestedResolved` and the grant is already claimed. Drive + // the sign-output broadcast exactly once with the verified handle. A + // broadcast failure does NOT roll the turn back (the resume guard already + // consumed the one-shot); it surfaces as a sanitized error so the client + // can observe the failure category. + continuation + .broadcast_resolved(&scope, run_id, &gate_ref, verified) + .await + .map_err(map_attested_continuation_rejection)?; + + Ok(RebornResolveGateResponse::Resumed(resume.into())) + } + async fn resolve_generic_gate( &self, scope: TurnScope, diff --git a/crates/ironclaw_product_workflow/src/webui_inbound.rs b/crates/ironclaw_product_workflow/src/webui_inbound.rs index a6849fd8075..365bf179f04 100644 --- a/crates/ironclaw_product_workflow/src/webui_inbound.rs +++ b/crates/ironclaw_product_workflow/src/webui_inbound.rs @@ -381,6 +381,19 @@ pub struct WebUiResolveGateRequest { pub always: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub credential_ref: Option, + /// Attested-signing proof family for `resolution = "attested"`: one of + /// `injected_wallet`, `near_redirect`, `wallet_connect`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attested_proof_kind: Option, + /// Lowercase-hex of the approved-tx hash the wallet attests to. Carried as + /// the untrusted `AttestationClaimRef`; the authoritative binding persisted + /// on gate raise is what the proof is verified against. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attested_approved_tx_hash: Option, + /// Opaque, provider-specific proof payload. Re-decoded by the composition + /// continuation port; never interpreted by this facade. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attested_proof: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -565,7 +578,14 @@ impl WebUiResolveGateRequest { let thread_id = parse_thread_id(self.thread_id)?; let run_id = parse_run_id(self.run_id)?; let gate_ref = parse_gate_ref(self.gate_ref)?; - let resolution = parse_gate_resolution(self.resolution, self.always, self.credential_ref)?; + let resolution = parse_gate_resolution( + self.resolution, + self.always, + self.credential_ref, + self.attested_proof_kind, + self.attested_approved_tx_hash, + self.attested_proof, + )?; Ok(WebUiInboundCommand::ResolveGate { scope: caller.turn_scope(thread_id), @@ -675,6 +695,9 @@ fn parse_gate_resolution( resolution: Option, always: Option, credential_ref: Option, + attested_proof_kind: Option, + attested_approved_tx_hash: Option, + attested_proof: Option, ) -> Result { let resolution = required_text("resolution", resolution, 64, TextMode::Token)?; match resolution.as_str() { @@ -682,6 +705,11 @@ fn parse_gate_resolution( always: always.unwrap_or(false), }), "declined" => Ok(WebUiGateResolution::Declined), + "attested" => parse_attested_resolution( + attested_proof_kind, + attested_approved_tx_hash, + attested_proof, + ), "credential_provided" => Ok(WebUiGateResolution::CredentialProvided { credential_ref: required_text( "credential_ref", diff --git a/crates/ironclaw_reborn_composition/src/attested.rs b/crates/ironclaw_reborn_composition/src/attested.rs index d9ff9092432..f003a637f3b 100644 --- a/crates/ironclaw_reborn_composition/src/attested.rs +++ b/crates/ironclaw_reborn_composition/src/attested.rs @@ -90,6 +90,17 @@ pub(crate) type LocalDevContinuationDriver = AttestedSignerContinuationDriver< /// the local-dev path can never be mislabeled as a real broadcast. A real /// per-chain broadcaster (PR12 / production) reports `submits() == true` and /// returns [`BroadcastOutcome::Submitted`]. +/// +/// # PRODUCTION WARNING +/// +/// This broadcaster intentionally NEVER submits. It exists for local-dev / +/// test wiring ONLY. Do NOT wire it into a production composition: a real +/// deployment MUST inject a per-chain broadcaster whose `submits()` returns +/// `true`. The `submits() -> false` contract is the compile-independent +/// guard — the driver leaves the ledger at `Signed` and reports `NotBroadcast` +/// rather than a false success — but a silent mis-wire here would mean +/// transactions are signed and never broadcast. PR13/PR14 production wiring +/// must select the real broadcaster, not this one. #[derive(Debug, Default)] pub struct NoopBroadcaster; diff --git a/crates/ironclaw_reborn_composition/src/attested_continuation.rs b/crates/ironclaw_reborn_composition/src/attested_continuation.rs index 5a22639f3fd..77302e9729b 100644 --- a/crates/ironclaw_reborn_composition/src/attested_continuation.rs +++ b/crates/ironclaw_reborn_composition/src/attested_continuation.rs @@ -31,7 +31,7 @@ use std::sync::Arc; use async_trait::async_trait; -use ironclaw_attested_runtime::{ContinuationError, VerifiedContinuation}; +use ironclaw_attested_runtime::{BindingOwner, ContinuationError, VerifiedContinuation}; use ironclaw_product_workflow::{ AttestedContinuationOutcome, AttestedContinuationRejection, AttestedGateContinuationPort, AttestedProofClaim, AttestedProofKind, VerifiedAttestedContinuation, @@ -39,7 +39,7 @@ use ironclaw_product_workflow::{ use ironclaw_signing_provider::{ ApprovedTxHash, GateRef as SigningGateRef, SigningProof, SigningProviderError, }; -use ironclaw_turns::{GateRef, TurnRunId, TurnScope}; +use ironclaw_turns::{GateRef, TurnActor, TurnRunId, TurnScope}; use ironclaw_wallet_external::{ InjectedProofPayload, InjectedScheme, NearAccessKeyScope, NearRedirectProofPayload, WalletConnectProofPayload, encode_injected_proof, encode_near_redirect_proof, @@ -70,11 +70,34 @@ impl RebornAttestedContinuation { impl AttestedGateContinuationPort for RebornAttestedContinuation { async fn verify_and_claim( &self, - _scope: &TurnScope, + scope: &TurnScope, + actor: &TurnActor, _run_id: TurnRunId, gate_ref: &GateRef, claim: &AttestedProofClaim, ) -> Result { + let signing_gate_ref = SigningGateRef::new(gate_ref.as_str()); + + // IDOR DEFENSE (threat #2): assert the calling identity owns the + // authoritative binding BEFORE any decode / provider verify / custodial + // sign / grant claim. The driver reconstructs and signs the custodial + // path from the authoritative binding regardless of who presents the + // `gate_ref`, so without this check a second tenant member who learns + // another user's `gate_ref` could drive that user's signing + // continuation. The thread-ownership probe upstream only proves the + // caller owns *their own* thread, not the gate. Fail closed + // indistinguishably from a missing binding (no existence oracle). + self.driver + .assert_binding_owner( + &signing_gate_ref, + BindingOwner { + tenant_id: scope.tenant_id.as_str(), + user_id: actor.user_id.as_str(), + }, + ) + .await + .map_err(map_continuation_error)?; + // FULL verification + one-shot grant claim, run BEFORE the facade // transitions the turn. A malformed proof fails closed here at decode; a // forged signature / signer mismatch / already-claimed grant fails closed @@ -84,7 +107,6 @@ impl AttestedGateContinuationPort for RebornAttestedContinuation { // hash against the proof, so the caller can only attest to the bound hash // (threat #3), never redefine it. let proof = decode_proof(claim)?; - let signing_gate_ref = SigningGateRef::new(gate_ref.as_str()); // External-wallet path only: the wallet already signed, so no custodial // EVM transaction is supplied. The custodial path is selected purely by @@ -126,6 +148,20 @@ impl AttestedGateContinuationPort for RebornAttestedContinuation { } } +/// Upper bound on the serialized size of a single attested-proof blob. The +/// `proof_json` arrives as an opaque `serde_json::Value` from the browser and +/// is NOT subject to the `USER_MESSAGE_TEXT_MAX_BYTES` message limit, so an +/// explicit ceiling keeps a syntactically-valid but pathologically large proof +/// (and the `parse_input` clone it forces) bounded. Every real proof family +/// (injected / NEAR-redirect / WalletConnect) is a small fixed struct of +/// hex/string fields; 16 KiB is generous headroom. +const ATTESTED_PROOF_MAX_BYTES: usize = 16 * 1024; + +/// Upper bound on a WalletConnect `session_topic`. WalletConnect topic ids are +/// 32-byte hex (64 chars); 256 is generous headroom while bounding an +/// untrusted, persisted browser-supplied string (finding #5). +const WALLETCONNECT_SESSION_TOPIC_MAX_LEN: usize = 256; + /// Decode the opaque WebUI proof claim into the concrete provider proof for its /// family. Mirrors the legacy monolith wire contract /// (`src/channels/web/features/chat/attested.rs`): every byte field arrives as @@ -134,6 +170,13 @@ impl AttestedGateContinuationPort for RebornAttestedContinuation { /// payload's `ApprovedTxHash` serde is a raw byte array, not the hex wire form). /// A malformed payload fails closed as `MalformedProof`. fn decode_proof(claim: &AttestedProofClaim) -> Result { + // Bound the untrusted proof blob before any clone/parse work. + let serialized_len = serde_json::to_vec(&claim.proof_json) + .map(|v| v.len()) + .map_err(|_| AttestedContinuationRejection::MalformedProof)?; + if serialized_len > ATTESTED_PROOF_MAX_BYTES { + return Err(AttestedContinuationRejection::MalformedProof); + } match claim.kind { AttestedProofKind::InjectedWallet => { let input: InjectedWalletProofInput = parse_input(&claim.proof_json)?; @@ -181,6 +224,9 @@ fn decode_proof(claim: &AttestedProofClaim) -> Result { let input: WalletConnectProofInput = parse_input(&claim.proof_json)?; + if input.session_topic.len() > WALLETCONNECT_SESSION_TOPIC_MAX_LEN { + return Err(AttestedContinuationRejection::MalformedProof); + } let payload = WalletConnectProofPayload { session_topic: input.session_topic, approved_tx_hash: parse_hash(&input.approved_tx_hash)?, @@ -302,7 +348,12 @@ struct WalletConnectProofInput { /// Categories only — no chain, signer, or ledger internals cross this boundary. fn map_continuation_error(error: ContinuationError) -> AttestedContinuationRejection { match error { - ContinuationError::MissingBinding => AttestedContinuationRejection::MissingBinding, + // A cross-user/cross-tenant gate_ref is surfaced identically to a + // non-existent binding (404) so it is not an existence oracle (IDOR + // defense, threat #2). + ContinuationError::MissingBinding | ContinuationError::OwnerMismatch => { + AttestedContinuationRejection::MissingBinding + } ContinuationError::ProviderMismatch { .. } => { AttestedContinuationRejection::ProviderMismatch } @@ -492,6 +543,51 @@ mod tests { )); } + #[test] + fn decode_proof_rejects_oversized_blob() { + // A syntactically valid but pathologically large proof blob must be + // rejected before any clone/parse work (finding #3). + let big = "a".repeat(ATTESTED_PROOF_MAX_BYTES + 1); + let claim = AttestedProofClaim { + kind: AttestedProofKind::InjectedWallet, + approved_tx_hash_hex: hash_hex_64(), + proof_json: serde_json::json!({ + "scheme": "evm", + "claimed_signer": "0xabc", + "signature": "deadbeef", + "approved_tx_hash": hash_hex_64(), + "public_key": big, + }), + }; + assert!(matches!( + decode_proof(&claim), + Err(AttestedContinuationRejection::MalformedProof) + )); + } + + #[test] + fn decode_walletconnect_rejects_oversized_session_topic() { + // An over-long session_topic must fail closed (finding #5) while a + // bounded one still decodes. + let long_topic = "t".repeat(WALLETCONNECT_SESSION_TOPIC_MAX_LEN + 1); + let claim = AttestedProofClaim { + kind: AttestedProofKind::WalletConnect, + approved_tx_hash_hex: hash_hex_64(), + proof_json: serde_json::json!({ + "session_topic": long_topic, + "claimed_signer": "0xabc", + "nonce": "0011", + "signed_payload": "cafe", + "signature": "deadbeef", + "approved_tx_hash": hash_hex_64(), + }), + }; + assert!(matches!( + decode_proof(&claim), + Err(AttestedContinuationRejection::MalformedProof) + )); + } + #[test] fn parse_hash_rejects_unicode_and_wrong_length() { assert!(matches!( diff --git a/crates/ironclaw_reborn_composition/tests/attested_gate_resolve_ingress.rs b/crates/ironclaw_reborn_composition/tests/attested_gate_resolve_ingress.rs index 9499ce15384..accbf738453 100644 --- a/crates/ironclaw_reborn_composition/tests/attested_gate_resolve_ingress.rs +++ b/crates/ironclaw_reborn_composition/tests/attested_gate_resolve_ingress.rs @@ -49,9 +49,11 @@ use ironclaw_signing_provider::{ use ironclaw_threads::{ EnsureThreadRequest, InMemorySessionThreadService, SessionThreadService, ThreadScope, }; +use ironclaw_filesystem::InMemoryBackend; +use ironclaw_turns::test_support::in_memory_turn_state_store; use ironclaw_turns::{ AcceptedMessageRef, ApprovedTxHashRef, AttestedResumePort, BlockedReason, - DefaultTurnCoordinator, GateRef, IdempotencyKey, InMemoryTurnStateStore, + DefaultTurnCoordinator, FilesystemTurnStateRowStore, GateRef, IdempotencyKey, LoopCheckpointStateRef, ReplyTargetBindingRef, RunProfileRequest, SourceBindingRef, SubmitTurnRequest, SubmitTurnResponse, TurnActor, TurnCheckpointId, TurnCoordinator, TurnLeaseToken, TurnRunId, TurnRunnerId, TurnScope, @@ -67,6 +69,10 @@ const AGENT: &str = "agent1"; const PROJECT: &str = "project1"; const USER: &str = "user1"; const THREAD: &str = "thread-pr11"; +/// A second user in the SAME tenant, used for the cross-user IDOR test +/// (threat #2). They own their own thread but NOT user1's attested gate. +const USER_B: &str = "user2"; +const THREAD_B: &str = "thread-pr11-b"; /// The authoritative decoded transaction the binding is approved over. A Solana /// (`solana:mainnet`) message so its `chain_network()` matches the binding's @@ -119,11 +125,15 @@ fn signing_ctx(account_hex: &str) -> SigningContext { } fn turn_scope() -> TurnScope { - TurnScope::new( + // Must match `caller().turn_scope(THREAD)` exactly — the row store keys on + // the FULL scope including `thread_owner`, so a run submitted under a + // different owner is `ScopeNotFound` at resolve time. + TurnScope::new_with_owner( TenantId::new(TENANT).unwrap(), Some(AgentId::new(AGENT).unwrap()), Some(ProjectId::new(PROJECT).unwrap()), ThreadId::new(THREAD).unwrap(), + Some(UserId::new(USER).unwrap()), ) } @@ -169,7 +179,7 @@ fn build_composition(bindings: Arc) -> RebornA /// Submit a turn and block it `BlockedAttested` on `GATE`. async fn block_attested( - store: &Arc, + store: &Arc>, expected_tx_hash_ref: &str, ) -> TurnRunId { let scope = turn_scope(); @@ -184,6 +194,12 @@ async fn block_attested( requested_run_profile: Some(RunProfileRequest::new("default").unwrap()), idempotency_key: IdempotencyKey::new("idem-pr11").unwrap(), received_at: Utc.with_ymd_and_hms(2026, 5, 24, 12, 0, 0).unwrap(), + requested_model: None, + requested_run_id: None, + parent_run_id: None, + subagent_depth: 0, + spawn_tree_root_run_id: None, + product_context: None, }) .await .unwrap(); @@ -207,7 +223,7 @@ async fn block_attested( state_ref: LoopCheckpointStateRef::new("checkpoint:block").unwrap(), reason: BlockedReason::Attested { gate_ref: GateRef::new(GATE).unwrap(), - expected_tx_hash: ApprovedTxHashRef::new(expected_tx_hash_ref).unwrap(), + expected_tx_hash: Some(ApprovedTxHashRef::new(expected_tx_hash_ref).unwrap()), }, }) .await @@ -300,7 +316,7 @@ async fn resolve_gate_attested_drives_resume_and_continuation() { Arc::clone(&bindings), Arc::clone(&resume_guard), )); - let store = Arc::new(InMemoryTurnStateStore::default().with_attested_resume_port(port)); + let store = Arc::new(in_memory_turn_state_store().with_attested_resume_port(port)); // Build the attested composition over the SAME binding store, and wire the // continuation port into the facade. @@ -376,7 +392,7 @@ async fn resolve_gate_attested_without_continuation_port_fails_closed() { Arc::clone(&bindings), Arc::clone(&resume_guard), )); - let store = Arc::new(InMemoryTurnStateStore::default().with_attested_resume_port(port)); + let store = Arc::new(in_memory_turn_state_store().with_attested_resume_port(port)); let composition = build_composition(Arc::clone(&bindings)); composition .register_attested_gate( @@ -424,6 +440,7 @@ impl AttestedGateContinuationPort for CountingContinuation { async fn verify_and_claim( &self, scope: &TurnScope, + actor: &TurnActor, run_id: TurnRunId, gate_ref: &GateRef, claim: &AttestedProofClaim, @@ -434,7 +451,7 @@ impl AttestedGateContinuationPort for CountingContinuation { self.verify_calls .fetch_add(1, std::sync::atomic::Ordering::SeqCst); self.inner - .verify_and_claim(scope, run_id, gate_ref, claim) + .verify_and_claim(scope, actor, run_id, gate_ref, claim) .await } @@ -459,7 +476,7 @@ async fn wired_services_with_counting( bytes_seed: u8, ) -> ( RebornServices, - Arc, + Arc>, String, EdSigningKey, ApprovedTxHash, @@ -478,7 +495,7 @@ async fn wired_services_with_counting( Arc::clone(&bindings), Arc::clone(&resume_guard), )); - let store = Arc::new(InMemoryTurnStateStore::default().with_attested_resume_port(port)); + let store = Arc::new(in_memory_turn_state_store().with_attested_resume_port(port)); let composition = build_composition(Arc::clone(&bindings)); composition .register_attested_gate( @@ -637,7 +654,7 @@ async fn resolve_gate_attested_with_no_binding_fails_closed() { Arc::clone(&bindings), Arc::clone(&resume_guard), )); - let store = Arc::new(InMemoryTurnStateStore::default().with_attested_resume_port(port)); + let store = Arc::new(in_memory_turn_state_store().with_attested_resume_port(port)); let composition = build_composition(Arc::clone(&bindings)); let thread_service = Arc::new(InMemorySessionThreadService::default()); @@ -673,6 +690,141 @@ async fn resolve_gate_attested_with_no_binding_fails_closed() { ); } +/// Threat #2 (cross-user IDOR): a second tenant member who learns another +/// user's `gate_ref` (returned in the gate-raise response) must NOT be able to +/// drive that user's attested-signing continuation. The attacker owns their own +/// thread (so the thread-ownership probe passes), then submits a resolve for +/// user1's `gate_ref` with their own `thread_id`. `verify_and_claim` must +/// fail closed on the binding-owner check (surfaced indistinguishably from a +/// missing binding — a 404, no existence oracle) BEFORE any verify / custodial +/// sign / grant claim, so the broadcast half is never driven. +#[tokio::test] +async fn resolve_gate_attested_cross_user_fails_closed() { + let key = EdSigningKey::from_bytes(&[0x77u8; 32]); + let account_hex = lower_hex(&key.verifying_key().to_bytes()); + let hash = bound_hash(&account_hex); + let hash_ref = approved_tx_hash_ref_hex(hash.as_bytes()); + + let bindings = Arc::new(InMemoryAttestedGateBindingStore::new()); + let resume_guard: Arc = Arc::new(InMemoryResumeGuard::new()); + let port: Arc = Arc::new(RuntimeAttestedResumePort::new( + Arc::clone(&bindings), + Arc::clone(&resume_guard), + )); + let store = Arc::new(in_memory_turn_state_store().with_attested_resume_port(port)); + let composition = build_composition(Arc::clone(&bindings)); + + // Raise side: the gate binding is owned by user1 (TENANT/USER). + composition + .register_attested_gate( + SigningGateRef::new(GATE), + binding(&account_hex, hash), + 0, + None, + ) + .await + .expect("register attested gate"); + + // Both users own a thread in the same tenant. user1 (the gate owner) and + // user2 (the attacker) each pass their OWN thread-ownership probe. + let thread_service = Arc::new(InMemorySessionThreadService::default()); + ensure_thread(&thread_service).await; + thread_service + .ensure_thread(EnsureThreadRequest { + scope: ThreadScope { + tenant_id: TenantId::new(TENANT).unwrap(), + agent_id: AgentId::new(AGENT).unwrap(), + project_id: Some(ProjectId::new(PROJECT).unwrap()), + owner_user_id: Some(UserId::new(USER_B).unwrap()), + mission_id: None, + }, + thread_id: Some(ThreadId::new(THREAD_B).unwrap()), + created_by_actor_id: USER_B.to_string(), + title: None, + metadata_json: None, + }) + .await + .expect("ensure attacker thread"); + + let coordinator: Arc = + Arc::new(DefaultTurnCoordinator::new(store.clone())); + let verify_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let drive_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counting = CountingContinuation { + inner: RebornAttestedContinuation::new(&composition), + verify_calls: Arc::clone(&verify_calls), + drive_calls: Arc::clone(&drive_calls), + }; + let services = RebornServices::new(thread_service, coordinator) + .with_attested_continuation(Arc::new(counting)); + + // user1's run is blocked on the gate. + let run_id = block_attested(&store, &hash_ref).await; + + // The attacker (user2) crafts a resolve for user1's GATE, but names THEIR + // OWN thread so the ownership probe passes. Same tenant; valid proof shape. + let caller_b = WebUiAuthenticatedCaller::new( + TenantId::new(TENANT).unwrap(), + UserId::new(USER_B).unwrap(), + Some(AgentId::new(AGENT).unwrap()), + Some(ProjectId::new(PROJECT).unwrap()), + ); + let signature = key.sign(hash.as_bytes()); + let attacker_request = WebUiResolveGateRequest { + client_action_id: Some("action-idor".to_string()), + thread_id: Some(THREAD_B.to_string()), + run_id: Some(run_id.to_string()), + gate_ref: Some(GATE.to_string()), + resolution: Some("attested".to_string()), + always: None, + credential_ref: None, + attested_proof_kind: Some("injected_wallet".to_string()), + attested_approved_tx_hash: Some(approved_tx_hash_ref_hex(hash.as_bytes())), + attested_proof: Some(json!({ + "scheme": "solana", + "approved_tx_hash": lower_hex(hash.as_bytes()), + "claimed_signer": account_hex, + "signature": lower_hex(&signature.to_bytes()), + "public_key": account_hex, + })), + }; + + let result = services.resolve_gate(caller_b, attacker_request).await; + assert!( + result.is_err(), + "cross-user attested resolve must fail closed (IDOR), got {result:?}" + ); + // Fail-closed indistinguishably from a missing binding (404), not a 400/409 + // that could act as an existence/ownership oracle. + let err = result.unwrap_err(); + assert_eq!( + err.status_code, 404, + "cross-user resolve must surface as NotFound (no existence oracle), got {err:?}" + ); + assert_eq!( + drive_calls.load(std::sync::atomic::Ordering::SeqCst), + 0, + "broadcast must NEVER be driven for a cross-user resolve" + ); + + // The gate is untouched: user1 (the real owner) can still resolve it. + let owner_ok = services + .resolve_gate( + caller(), + attested_request(run_id, &key, &hash, &account_hex, "action-owner"), + ) + .await; + assert!( + owner_ok.is_ok(), + "the real owner can still resolve after the rejected IDOR attempt: {owner_ok:?}" + ); + assert_eq!( + drive_calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "the legitimate owner's resolve drives the broadcast exactly once" + ); +} + /// A FORGED proof (well-formed, but signed by the WRONG key) must be rejected by /// the FULL cryptographic verification inside `verify_and_claim` BEFORE /// `resume_turn`: the turn stays `BlockedAttested`, no broadcast is driven, and