Skip to content

feat(signing): ironclaw_chain_signing — custodial multi-chain sign/broadcast (attested-signing PR6/10) - #3965

Closed
zmanian wants to merge 1 commit into
attested-signing-03-grant-ledgerfrom
attested-signing-06-chain-signing
Closed

zmanian wants to merge 1 commit into
attested-signing-03-grant-ledgerfrom
attested-signing-06-chain-signing

Conversation

@zmanian

@zmanian zmanian commented May 24, 2026 •

Copy link
Copy Markdown
Collaborator

Rebased onto current main (attested-signing cascade, 2026-07-23). The stack was 1184 commits behind (merge base 2026-05-24). Tracking issue: #6532.

Inline review threads may have re-anchored or orphaned from the force-push. Reviewers: the "Cascade changes" section states exactly what the rebase altered beyond the original work.

What this PR is

ironclaw_chain_signing — custodial multi-chain sign/broadcast behind the secrets keystore, with the KMS ship-gate that refuses custodial mainnet signing without secure custody.

Cascade changes (API adoption — please review)

This branch forked from -03 before its round-2 security fixes landed, so the port had to adopt the stronger APIs rather than merge around them. None of these weaken an invariant:

  • The signing ledger is now tenant-scoped. SigningLedger::create/state/advance take &LedgerKey { tenant, gate_ref }, not &GateRef. Rethreaded 8 production + 15 test call sites through SigningContext.tenant (which already carries it). This is the tenant-isolation fix a reviewer caught in round 2 — reverting the API to make the conflict disappear would have silently dropped it.
  • AttestedSigningGrant::seal → ::new, and ::new is now fallible (timestamp/expiry validation); 10 call sites rewrapped.
  • alloy sub-crate versions reconciled against current main; ironclaw_secrets re-export list unioned (chain_key_aad + validate_master_key_material).

Verification

56 + 16 crate tests pass; boundary test green — including chain_signing_crate_carries_chain_sdk_and_secrets, the inverse-purity assertion that this crate is the only one allowed chain SDKs + secrets; clippy clean.

@github-actions github-actions Bot added scope: dependencies Dependency updates size: XL 500+ changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels May 24, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the ironclaw_chain_signing crate, which implements custodial multi-chain signing for EVM, Solana, and NEAR within the IronClaw substrate. It features two independent enforcement points—grant claim validation and sign-time transaction hash re-checks—alongside a "ship-gate" mechanism that restricts mainnet signing to secure HSM/KMS backends. Feedback focuses on improving code idiomaticity and maintainability by replacing manual hex encoding and decoding logic with built-in functionality from the alloy-primitives and hex libraries.

Comment thread crates/ironclaw_chain_signing/src/custodial.rs Outdated
Comment thread crates/ironclaw_chain_signing/src/custodial.rs
Comment thread crates/ironclaw_chain_signing/src/custodial.rs Outdated
Comment thread crates/ironclaw_chain_signing/src/keystore.rs Outdated
Comment thread crates/ironclaw_chain_signing/src/keystore.rs Outdated
Comment thread crates/ironclaw_chain_signing/src/keystore.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fbe5abfd39

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/ironclaw_chain_signing/src/custodial.rs Outdated
Comment thread crates/ironclaw_chain_signing/src/custodial.rs Outdated
@zmanian
zmanian force-pushed the attested-signing-06-chain-signing branch from fbe5abf to 27faead Compare May 24, 2026 21:28
zmanian added a commit that referenced this pull request May 25, 2026
Replace the crate's hand-rolled hex encode/decode/nibble helpers with the
already-depended-on alloy-primitives hex utilities and Address parsing:

- custodial.rs: bound_evm_address now parses via `Address::parse`; EVM signer
  formatted with `{:#x}`; ed25519 pubkey via `hex::decode` + `try_into`; signer
  hex via `hex::encode`. Removes hex_decode_20/32, nibble, hex_lower.
- keystore.rs: encode private-key hex via `hex::encode` (kept inside Zeroizing
  to preserve key-zeroization), decode via `hex::decode`. Removes hex_encode/
  hex_decode/hex_nibble.

No behavior or security-property change: Zeroizing wrapping, exact-bytes
binding, KMS ship-gate, and fail-closed paths are all preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review (multi-agent)

Intent: Implement the ironclaw_chain_signing crate for custodial multi-chain sign/broadcast as PR6 of the 10-PR attested-signing stack
Stats: 18 findings (from 22 raw) across 10 files. Reviewers run: security, bugs, performance, tests, conventions, local-patterns, maintainability, pattern-refactor. Reviewers failed: none. Body-only: 3

Security (1)

  1. Low DecryptedSecret has a manual Clone impl that duplicates secret material in memory (crates/ironclaw_secrets/src/legacy_store.rs:58-99, confidence 50) — anchor: crates/ironclaw_secrets/src/legacy_store.rs:93 (no diff position — body only)
    DecryptedSecret wraps a SecretString and provides a manual Clone impl that creates a full copy of the underlying secret. Cloning doubles the number of live copies of decrypted secret material in memory.
    Fix: Remove the Clone derive/impl from DecryptedSecret. If cloning is needed, implement a consume() method that transfers ownership without copying.

Bugs (1)

  1. Medium KMS signature binding rejects valid sigs when explicit v is wrong (crates/ironclaw_chain_signing/src/evm/sign.rs:1603-1617, confidence 75) — anchor: crates/ironclaw_chain_signing/src/evm/sign.rs:1603
    In bind_kms_signature, when the KMS returns a 65-byte signature with an explicit v, the code tries recovery with only that v's parity. If recovery fails (e.g. buggy KMS returns wrong v), it returns SignerMismatch instead of falling back to trying both parities.
    Fix: After the explicit-v try_v fails, fall through to the both-parities fallback instead of returning SignerMismatch immediately.

Performance (2)

  1. Medium LocalKmsSigner keys HashMap grows unbounded with no eviction (crates/ironclaw_chain_signing/src/kms.rs:102-107, confidence 70) — anchor: crates/ironclaw_chain_signing/src/kms.rs:106
    The keys HashMap accepts imports via import_key but has no remove, clear, or capacity-limit mechanism.
    Fix: Add a remove_key method or enforce a max capacity at import time; document that imports are expected only at bootstrap.
    Also flagged by: security/Medium

  2. High KmsSigner::sign_digest is sync — cloud backends will block the async executor (crates/ironclaw_chain_signing/src/kms.rs:70-88, confidence 80) — anchor: crates/ironclaw_chain_signing/src/kms.rs:82
    The KmsSigner trait defines sign_digest as a synchronous fn. A production cloud KMS backend will make an HTTP/RPC call inside sign_digest, blocking the tokio worker thread for the full network round-trip.
    Fix: Change the trait to async fn sign_digest(...) and update all callers to .await.

Tests (6)

  1. Medium CustodialSigner::finalize with non-terminal state not tested (crates/ironclaw_chain_signing/src/custodial.rs:431-448, confidence 75) — anchor: crates/ironclaw_chain_signing/src/custodial.rs:436
    The finalize method rejects non-terminal states with InvalidTransition, but no test exercises this error path.
    Fix: tests::custodial_signing::finalize_rejects_non_terminal_state

  2. Medium bound_evm_address with invalid hex not tested (crates/ironclaw_chain_signing/src/custodial.rs:471-480, confidence 75) — anchor: crates/ironclaw_chain_signing/src/custodial.rs:471
    The bound_evm_address helper returns KeyStore error when the binding's public_address_hex cannot be parsed. No test exercises this malformed-hex error path.
    Fix: tests::custodial_signing::bound_evm_address_rejects_invalid_hex

  3. Medium ed25519_pubkey_from_binding with wrong-length hex not tested (crates/ironclaw_chain_signing/src/custodial.rs:485-494, confidence 75) — anchor: crates/ironclaw_chain_signing/src/custodial.rs:485
    The ed25519_pubkey_from_binding helper returns KeyStore error when the decoded hex is not exactly 32 bytes. No test exercises this.
    Fix: tests::custodial_signing::ed25519_pubkey_from_binding_rejects_wrong_length

  4. Medium ChainFamily::of_transaction and ChainKeyId::family have no direct tests (crates/ironclaw_chain_signing/src/chain.rs:64-73, confidence 75) — anchor: crates/ironclaw_chain_signing/src/chain.rs:66
    The public ChainFamily::of_transaction function and ChainKeyId::family method have no unit tests. These are used in the authorize chain-binding check.
    Fix: tests::chain::of_transaction_maps_variants_and_unknown_prefix

  5. Medium rebuild_signable with unsupported tx type not tested (crates/ironclaw_chain_signing/src/evm/decode.rs:256-257, confidence 75) — anchor: crates/ironclaw_chain_signing/src/evm/decode.rs:256
    rebuild_signable returns a Decode error for unknown tx types. This fail-closed path has no test coverage.
    Fix: tests::evm_decode::rebuild_signable_rejects_unsupported_tx_type

  6. Low SecretsKeyStore::binding returning NotFound not tested (crates/ironclaw_chain_signing/src/keystore.rs:281-290, confidence 75) — anchor: crates/ironclaw_chain_signing/src/keystore.rs:289
    The binding method returns NotFound when no key exists for the scope/chain pair. The binding-specific NotFound path is untested.
    Fix: tests::keystore::binding_missing_is_not_found

Conventions (1)

  1. Medium ChainKeyId uses #[serde(transparent)] instead of canonical try_from newtype template (crates/ironclaw_chain_signing/src/chain.rs:14-16, confidence 75) — anchor: .claude/rules/types.md:66-110
    ChainKeyId is a newly added identity newtype that uses #[serde(transparent)] for serde deserialization, bypassing the canonical newtype template mandated by .claude/rules/types.md.
    Fix: Adopt the canonical newtype template from types.md: add validate(&str), switch to #[serde(try_from = "String")], implement TryFrom<String>, AsRef<str>, and From<ChainKeyId> for String.

Local Patterns (2)

  1. Medium EVM decode module visibility and re-export pattern diverges from Solana/NEAR siblings (crates/ironclaw_chain_signing/src/evm/mod.rs:10-23, confidence 100) — anchor: crates/ironclaw_chain_signing/src/solana/mod.rs:14 vs crates/ironclaw_chain_signing/src/evm/mod.rs:10
    The EVM vertical declares pub(crate) mod decode and re-exports its functions, while Solana and NEAR both use pub mod decode without re-exports.
    Fix: Make EVM decode pub mod decode (matching Solana/NEAR) and remove the pub use decode::{...} re-export, OR add matching re-exports to Solana and NEAR mod.rs files.

  2. Low Broadcast trait method names differ across chains for the same conceptual operation (crates/ironclaw_chain_signing/src/evm/broadcast.rs:33-37, confidence 75) — anchor: crates/ironclaw_chain_signing/src/evm/broadcast.rs:36 vs solana/broadcast.rs:27 vs near/broadcast.rs:27
    The three broadcaster traits name their submit method differently: send_raw, send_transaction, broadcast_tx.
    Fix: Unify to a single method name across all three broadcaster traits, e.g. submit_signed or broadcast.

Maintainability (5)

  1. Low authorize_chain has unreachable Err(CustodyDecision::Allow) dead branch (crates/ironclaw_chain_signing/src/kms.rs:341-352, confidence 50) — anchor: crates/ironclaw_chain_signing/src/kms.rs:348
    ShipGate::authorize only ever returns Err(CustodyDecision::Deny).
    Fix: Change authorize to return Result<SigningPath, String>.

  2. Medium Three near-identical sign_* methods could collapse into one generic flow (crates/ironclaw_chain_signing/src/custodial.rs:236-391, confidence 75) — anchor: crates/ironclaw_chain_signing/src/custodial.rs:236
    sign_evm, sign_solana, and sign_near follow the exact same orchestration. Only the digest computation, signing primitive, and outcome formatting differ.
    Fix: Define a ChainSignerOps trait with extract_signer(), compute_digest(), sign_hot(), sign_kms(), format_outcome().
    Also flagged by: pattern-refactor/Medium
    Also flagged by: security/Medium

  3. Low new() and with_kms() constructors differ only in one Optional field (crates/ironclaw_chain_signing/src/custodial.rs:99-134, confidence 75) — anchor: crates/ironclaw_chain_signing/src/custodial.rs:99
    CustodialSigner::new and with_kms are identical except one sets kms: None.
    Fix: Replace both constructors with a single builder or one constructor that takes Option<Arc<dyn KmsSigner>>.

  4. Medium KeyStoreKey duplicates ScopeKey scope-to-string mapping from ironclaw_secrets (crates/ironclaw_chain_signing/src/keystore.rs:174-199, confidence 50) — anchor: crates/ironclaw_chain_signing/src/keystore.rs:174
    KeyStoreKey reconstructs the same tenant/user/agent/project-to-string mapping.
    Fix: Move ScopeKey to ironclaw_host_api or make it pub in ironclaw_secrets.

  5. Medium Solana and NEAR sign modules are copy-paste with only chain tags differing (crates/ironclaw_chain_signing/src/solana/sign.rs:40-118, confidence 100) — anchor: crates/ironclaw_chain_signing/src/solana/sign.rs:40
    solana/sign.rs and near/sign.rs contain structurally identical functions.
    Fix: Extract a shared ed25519_signing module.

Comment thread crates/ironclaw_chain_signing/src/kms.rs
Comment thread crates/ironclaw_chain_signing/src/evm/mod.rs
Comment thread crates/ironclaw_chain_signing/src/solana/sign.rs
Comment thread crates/ironclaw_chain_signing/src/custodial.rs
Comment thread crates/ironclaw_chain_signing/src/custodial.rs
Comment thread crates/ironclaw_chain_signing/src/evm/decode.rs
Comment thread crates/ironclaw_chain_signing/src/kms.rs
Comment thread crates/ironclaw_chain_signing/src/keystore.rs
Comment thread crates/ironclaw_chain_signing/src/custodial.rs
Comment thread crates/ironclaw_chain_signing/src/keystore.rs
zmanian added a commit that referenced this pull request May 26, 2026
Headline (High/perf): make KmsSigner::sign_digest async via #[async_trait]
so a production cloud KMS/HSM round-trip never blocks a tokio worker. The
in-tree LocalKmsSigner stays CPU-only and returns a ready future; all three
custodial sign_* call sites now .await. Ship-gate semantics (mainnet
fail-closed without KMS; curve-capability check) and key zeroization are
unchanged.

Also addressed:
- kms: add LocalKmsSigner::remove_key for explicit eviction (+ test);
  documents bootstrap-only import expectation (unbounded-HashMap finding).
- chain: adopt canonical newtype template for ChainKeyId (validate(),
  #[serde(try_from)], TryFrom<String>, AsRef<str>, From<ChainKeyId> for
  String, into_inner); fail-closed on empty (conventions finding).
- tests: add coverage for ChainFamily::family/of_transaction, ChainKeyId
  validation, bound_evm_address invalid hex, ed25519_pubkey_from_binding
  wrong length, rebuild_signable unsupported tx type, and keystore binding
  NotFound.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@zmanian

zmanian commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed henrypark133 review (commit ded8dfd)

Worked off the force-pushed b52b7b2b5 tip. Verified each finding against current code first.

Fixed

Finding Disposition
High/perf KmsSigner::sign_digest sync blocks executor Fixed — sign_digest is now async via #[async_trait] (matching this crate's broadcaster traits). LocalKmsSigner stays CPU-only (ready future); all three custodial sign_* call sites .await. Ship-gate semantics and key zeroization unchanged.
Perf LocalKmsSigner keys HashMap unbounded Fixed — added remove_key (+ test) and documented bootstrap-only import expectation.
Conventions ChainKeyId uses #[serde(transparent)] Fixed — adopted canonical newtype template (validate, #[serde(try_from)], TryFrom<String>, AsRef<str>, From<ChainKeyId> for String, into_inner); fail-closed on empty.
Tests finalize / bound_evm_address / ed25519_pubkey / chain family+of_transaction / rebuild_signable / keystore binding NotFound Added direct unit tests for all the pure helpers: bound_evm_address_rejects_invalid_hex, ed25519_pubkey_from_binding_rejects_wrong_length, family_maps_prefixes_and_unknown_fails_closed, of_transaction_maps_near_variant, rebuild_signable_rejects_unsupported_tx_type, binding_missing_is_not_found, local_kms_remove_key_evicts_and_allows_reimport, plus a ChainKeyId wire+construction validation test.

Already fixed / stale

  • Bug KMS signature binding rejects valid sigs when explicit v is wrong: already correct in current evm/sign.rs — the explicit-v try only early-returns on success, then falls through to the both-parities fallback (try_v(false).or_else(try_v(true))); SignerMismatch only when neither parity binds. The review anchored at line 1603 but the file is now 216 lines after the 27faead refactor.

Deferred (with reasons in inline replies)

  • finalize non-terminal test: guard is pure !terminal.is_terminal() and short-circuits before the ledger; a direct test needs a mock CustodialSigner<K,G,L> harness this crate doesn't have yet. Happy to add in a follow-up.
  • Refactors (collapse sign_* into ChainSignerOps; extract shared ed25519 module; unify EVM/Solana/NEAR decode visibility; unify broadcaster method names; share ScopeKey cross-crate; merge new/with_kms): declined for this PR. In a custody-sensitive crate I'd rather keep each chain's enforcement path independently auditable and the hot-key-vs-KMS constructor intent explicit than trade that for de-duplication. Open to revisiting once the chain set stabilizes.
  • DecryptedSecret manual Clone (ironclaw_secrets, confidence 50, sibling crate not in this PR's added diff): the Clone impl is currently unused dead code and SecretString already zeroizes on drop. Best removed as a deliberate change in that crate rather than folded into this signing PR.

Verification

cargo test -p ironclaw_chain_signing (54 lib + 15 integration green), cargo fmt --check, cargo clippy -p ironclaw_chain_signing --all-features --tests (zero warnings).

@zmanian
zmanian force-pushed the attested-signing-03-grant-ledger branch from 8ae1abd to 4045354 Compare May 26, 2026 06:12
@zmanian
zmanian force-pushed the attested-signing-06-chain-signing branch 3 times, most recently from ded8dfd to 19136c8 Compare May 26, 2026 12:04
@zmanian
zmanian requested a review from henrypark133 May 26, 2026 14:26
Comment thread crates/ironclaw_chain_signing/src/kms.rs
Comment thread crates/ironclaw_chain_signing/src/keystore.rs Outdated
Comment thread crates/ironclaw_chain_signing/src/custodial.rs Outdated
let authorized = self.authorize(req, ChainFamily::Solana).await?;

let DecodedTransaction::Solana(sol) = &req.decoded else {
return Err(ChainSigningError::ChainMismatch {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — Solana/NEAR sign over sha256(canonical_signing_bytes), not the network wire format; produced signatures are not directly broadcastable, and the approved hash is schema-version-coupled.

The custodial signer for Solana and NEAR signs a 32-byte sha256(canonical_signing_bytes(decoded, schema_version)). This is explicitly acknowledged as a deferred "next slice". The immediate security concern is not the gap itself (the broadcast path is also deferred), but two structural risks that should be addressed before the wire decoder lands:

  1. Schema-version coupling of approved hash: ApprovedTxHash is sealed against (decoded_tx, schema_version). If canonical_signing_bytes changes between schema versions (e.g. a new field is added), a grant approved under SCHEMA_V1 cannot be re-verified under SCHEMA_V2. The current codebase has a single CURRENT version, but the version is threaded all the way through the signing path, implying future versions are expected. There is no test that signs under SCHEMA_V1 and verifies the hash still matches after a hypothetical schema bump.

  2. Signature domain mismatch: When the Solana/NEAR wire decoder arrives (using solana-sdk/near-primitives borsh), the canonical bytes it produces MUST be byte-identical to canonical_signing_bytes for the same transaction — otherwise the approved hash from the current schema and the broadcastable signature from the new decoder would disagree. The spec should make this equivalence a hard contract and add a cross-schema round-trip test before that PR lands.

Fix: In this PR, add a #[doc(hidden)] marker or // INVARIANT: comment at canonical_signing_bytes spelling out that any new schema version MUST preserve the property that the wire-format bytes are a deterministic function of the canonical bytes (or the approved hash must be recomputed). Add a future-proofing test: assert_eq!(recompute_approved_hash(&tx, signer, SchemaV1), recompute_approved_hash(&tx, signer, SchemaV1)) as a regression pin.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 5711af0. Added an explicit INVARIANT block on recompute_approved_hash documenting (1) the approved hash is sealed against (decoded_tx, signer, schema_version) so a schema bump fails closed by design, and (2) the future wire decoder must keep canonical bytes a deterministic function of the same pair. Added regression pin recompute_approved_hash_is_stable_for_a_fixed_schema covering same-schema determinism plus the signer-binding (WYSIWYS) property. The canonical encoder itself lives in ironclaw_attestation (PR2 scope); this PR pins the contract at its chain_signing consumption point.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still accurate — leaving open, and it is a documented limitation rather than an oversight.

Confirmed in the code: solana/sign.rs signs the 32-byte sha256 commitment of the canonical signing bytes (sign.rs:66-82), not the network wire message. The same holds for NEAR. So the signatures these paths produce are not broadcastable as-is — they attest to IronClaw's canonical commitment, not to a serialized Solana/NEAR transaction.

Why it is this way: ironclaw_attestation deliberately carries no chain SDKs (pinned by the attested_signing_boundaries architecture test), so the SDK-level wire encoders for Solana VersionedMessage/ALT and NEAR borsh Transaction were flagged as a follow-up rather than vendored into the canonical layer. EVM is the complete vertical (rebuild_signable produces a real signable via alloy).

The practical consequence — custodial Solana/NEAR cannot broadcast yet — is contained by the ship gate, and the external-wallet path doesn't have this problem because the wallet serializes and signs natively. Tracked in #6532 as the Solana/NEAR wire-decoder item; leaving open until those land.

Comment thread crates/ironclaw_chain_signing/src/evm/sign.rs
Comment thread crates/ironclaw_secrets/src/crypto.rs
@henrypark133

Copy link
Copy Markdown
Collaborator

Architectural comment — signing_key_from_bytes in LocalKmsSigner (kms.rs) constructs signing key inside the lock

Low — LocalKmsSigner::sign_digest holds the Mutex lock (line ~246 in kms.rs) across the SigningKey::from_slice construction and the entire sign_prehash_recoverable call. The ed25519 and secp256k1 operations are synchronous and fast (microseconds), so there is no realistic contention problem today. However:

  1. If the lock is held across any async boundary in the future (the method is async), this would stall a tokio worker thread.
  2. Constructing the signing key (Secp256k1SigningKey::from_slice) from raw bytes could theoretically involve a zero-copy that keeps the slice reference valid only within the lock scope — which is fine — but makes the code harder to refactor if the lock scope is changed.

Recommendation: Clone/copy the secret bytes under the lock into a short-lived Zeroizing<[u8; 32]> stack buffer, release the lock, then construct the signing key and sign from the buffer. This decouples the crypto work from the lock and is the safer pattern for when the lock semantics evolve. Not a required change before merge given the synchronous nature, but worth tracking.


Architectural comment — chain_key_aad uses agent_id and project_id from ResourceScope

Nit — ScopeKey::from_account_scope includes agent_id and project_id in the chain-key AAD. This means a key bound for scope (tenant, user, agent=Some("a"), project=Some("p")) will not decrypt under scope (tenant, user, agent=None, project=None) even though both represent the same human user. The docs say "owner scope", but agent and project sub-scope might not be stable across deployments (e.g. if the agent_id changes after re-provisioning). Confirm the key lifecycle matches the scope granularity, or consider using only (tenant_id, user_id) as the stable owner identity for custodial keys, matching how the credential-account AAD works.

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Paranoid-architect review — PR6/10 attested-signing: ironclaw_chain_signing

Summary: The two-enforcement-point architecture (grant claim + sign-time hash re-check), WYSIWYS property, ecrecover/ed25519 binding checks, broadcast idempotency, ship-gate, AAD-bound chain-key secrets, and test coverage are all strong and well-structured. This is a high-quality security-critical crate. Two issues need resolution before merge.

Blocking (High)

H1 — LocalKmsSigner::is_secure_custody() = true bypasses the mainnet ship-gate (kms.rs:193): The backend stores keys in process heap memory — the exact attack surface threat #18 guards against. Returning true means any operator who wires LocalKmsSigner (or a derived subclass) with the mainnet opt-in can bypass the KMS requirement. Fix: return false, provide a test-only wrapper that overrides it, and add a prominent doc/compile warning.

Non-Blocking (Medium, Low, Nit)

M2 — Unzeroized hex-decode buffer in SecretsKeyStore::consume() (keystore.rs:331): Raw private-key bytes from hex::decode not wrapped in Zeroizing before moving into ConsumedChainKey::new. Fix: wrap in Zeroizing::new(bytes) before the move, consistent with the bind() path.

M3 — Grant/ledger atomicity gap (custodial.rs:198): authorize() claims the grant one-shot, then returns. Pre-flight failures between claim and ledger.advance(Signing) leave the grant consumed but the ledger at Approved, permanently blocking this gate_ref. Fix: move grants.claim() to immediately before ledger.advance(Signing) after all pre-flight checks pass.

M4 — Schema-version coupling of Solana/NEAR approved hash (custodial.rs): canonical_signing_bytes schema-versioning is threaded through but no contract exists that future schema versions preserve the approved-hash equivalence. Add an invariant comment and regression test pin.

L5 — EIP-7702 v-parity normalization undocumented assumption (evm/sign.rs:167)

Nit — Shared HKDF label across all secret domains (crypto.rs)

See inline comments for details.

zmanian added a commit that referenced this pull request May 27, 2026
H1: LocalKmsSigner::is_secure_custody() no longer hard-codes true — it now
reflects a constructor-time flag defaulting to false, so the in-process
software backend can NEVER satisfy the mainnet ship-gate in a normal build
(threat #18). Tests opt in via the explicit, doc-hidden
new_modeling_secure_custody(). Adds a regression test proving the default
LocalKmsSigner is refused for mainnet even with the opt-in flag.

M2: SecretsKeyStore::consume() wraps the hex-decoded private-key bytes in
Zeroizing before ConsumedChainKey::new takes ownership, so the decode buffer
is wiped even if into_boxed_slice reallocates (matching the bind() path).

M3: Grant/ledger atomicity. The one-shot grant claim moved out of authorize()
to the last step before the Approved->Signing ledger transition in each
sign_* method, after all chain-specific pre-flight (digest rebuild, address
parsing, KMS key_ref resolution) succeeds. A post-authorize pre-flight failure
now leaves the grant unclaimed and the gate_ref retryable. Adds a regression
test.

M4: Documents the schema-version coupling invariant on recompute_approved_hash
(approved hash is a deterministic function of (decoded_tx, signer, schema) and
a future schema bump fails closed) and pins same-schema determinism + signer
binding with a regression test.

L5: Documents that the explicit-v parity normalization arm is EIP-155 only and
that EIP-7702 (type 4) is rejected upstream by rebuild_signable.

Nit: Documents that all secret domains intentionally share the single HKDF
label and that AAD + per-secret salt are the cross-domain separators.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@zmanian

zmanian commented May 27, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed henrypark133 paranoid-architect review (tip 5711af0e5)

Finding Disposition
H1 LocalKmsSigner::is_secure_custody == true bypasses mainnet ship-gate Fixed. Now a constructor-time flag defaulting to false; new() is insecure, tests opt in via #[doc(hidden)] new_modeling_secure_custody(). Regression test added.
M2 Unzeroized hex-decode buffer in consume() Fixed. Wrapped in Zeroizing<Vec<u8>> before ConsumedChainKey::new, matching the bind() path.
M3 Grant/ledger atomicity gap Fixed (preferred option). Grant claim moved to the last step before ledger.advance(Signing), after all pre-flight (incl. KMS key_ref resolution). Regression test added.
M4 Schema-version coupling of approved hash Addressed. INVARIANT doc on recompute_approved_hash + same-schema determinism / signer-binding regression pin.
L5 EIP-7702 v-parity assumption undocumented Addressed. Comment added: EIP-155-only; type 4 rejected upstream by rebuild_signable.
Nit Shared HKDF label Addressed (accepted as intentional). Clarifying comment: AAD prefix + per-secret salt are the cross-domain separators.

Preserved invariants: mainnet fail-closed without KMS, KmsSigner curve-capability fail-closed, async KMS sign, ApprovedTxHash WYSIWYS binding via gate-bound signer, Solana broadcast maxRetries:0 idempotency, key zeroization (Zeroizing/SecretBox kept), openssl-free.

Verification (IRONCLAW_DISABLE_OS_KEYCHAIN=1): cargo test -p ironclaw_chain_signing --all-features — 56 unit + 16 integration tests pass; cargo fmt + cargo clippy --all-features --tests clean on both touched crates.

@zmanian
zmanian requested a review from henrypark133 May 27, 2026 00:39
@zmanian
zmanian force-pushed the attested-signing-03-grant-ledger branch from 3889b3b to cddd527 Compare July 23, 2026 14:00
zmanian added a commit that referenced this pull request Jul 23, 2026
@zmanian
zmanian force-pushed the attested-signing-06-chain-signing branch from 5711af0 to 29f1cb7 Compare July 23, 2026 14:00
@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • staging
  • reborn-integration

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4a58fbe3-4da4-4b28-9cf5-9df330e8b630

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@zmanian
zmanian force-pushed the attested-signing-03-grant-ledger branch from cddd527 to 883675c Compare July 23, 2026 15:37
@zmanian
zmanian force-pushed the attested-signing-06-chain-signing branch from 29f1cb7 to 685b835 Compare July 23, 2026 15:37
@zmanian

zmanian commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #6749, part of consolidating the 20-PR attested-signing-* stack into 8 PRs against current main.

The content of this PR is carried forward in full, re-based on current main rather than on the old stack. That re-basing also fixed the CI failures that had been red here — including the missing [package.metadata.ironclaw] layer declarations, which were blocking the bottom of the stack and therefore every PR above it.

Closing to keep the queue honest. The review history stays on this PR and remains readable; reopen if the consolidation is rejected.

@zmanian zmanian closed this Jul 28, 2026
zmanian added a commit that referenced this pull request Jul 28, 2026
zmanian added a commit that referenced this pull request Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: dependencies Dependency updates size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants