Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

337 changes: 266 additions & 71 deletions crates/ironclaw_attested_runtime/src/driver.rs

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions crates/ironclaw_attested_runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,9 @@ pub use binding::{
validate_binding,
};
pub use driver::{
AttestedSignerContinuationDriver, BroadcastDisposition, BroadcastOutcome, Broadcaster,
ContinuationError, CustodialSignerLike, EvmSignable, ProviderRegistry, RebuildError,
SignerContinuationOutcome,
AttestedSignerContinuationDriver, BindingOwner, BroadcastDisposition, BroadcastOutcome,
Broadcaster, ContinuationError, CustodialSignerLike, EvmSignable, ProviderRegistry,
RebuildError, SignerContinuationOutcome, VerifiedContinuation,
};
pub use port::{
InMemoryResumeGuard, ResumeGuard, RuntimeAttestedResumePort, approved_tx_hash_ref_hex,
Expand Down
53 changes: 37 additions & 16 deletions crates/ironclaw_attested_runtime/tests/threat_matrix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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]));
Expand All @@ -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(&lk(&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");
}
Expand Down Expand Up @@ -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(&lk(&gate)).await,
Err(ironclaw_attestation::LedgerError::NotFound),
"provider mismatch must not create a ledger row"
);
assert_eq!(broadcaster.count(), 0);
}
Expand Down
220 changes: 220 additions & 0 deletions crates/ironclaw_product_workflow/src/attested_continuation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
//! 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, TurnActor, 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<dyn Any + Send>,
}

impl VerifiedAttestedContinuation {
/// Wrap a composition-layer verified continuation as an opaque handle.
pub fn new<T: Any + Send>(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<T: Any + Send>(self) -> Result<Box<T>, VerifiedAttestedContinuation> {
match self.inner.downcast::<T>() {
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.)
/// `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,
) -> Result<VerifiedAttestedContinuation, AttestedContinuationRejection>;

/// 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<AttestedContinuationOutcome, AttestedContinuationRejection>;
}
5 changes: 5 additions & 0 deletions crates/ironclaw_product_workflow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading