feat(wasm): credential signers for HMAC, EIP-712, NEP-413, Solana#3256
feat(wasm): credential signers for HMAC, EIP-712, NEP-413, Solana#3256neo-sky wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for several new cryptographic signing credential locations, including HMAC-SHA256, EIP-712 (Ethereum), NEP-413 (NEAR), and Solana transaction signing. The changes include adding necessary cryptographic dependencies to Cargo.toml, defining structured data types for signing payloads in types.rs, updating the capabilities schema, and providing comprehensive unit tests for the new configurations. Feedback was provided regarding the empty match arm for these new variants in the credential injection logic, which may lead to these credentials not being properly processed as they are currently no-ops.
| CredentialLocation::HmacSignedHeader { .. } | ||
| | CredentialLocation::Eip712SignedHeader { .. } | ||
| | CredentialLocation::Nep413SignedHeader { .. } | ||
| | CredentialLocation::SolanaSignedTransaction { .. } => {} |
There was a problem hiding this comment.
The match arm for the new credential variants is empty, meaning these credentials will not be injected. Note that all credentials declared in a tool's capabilities are mandatory. If these variants are intended to be handled elsewhere (e.g., via body mutation), please add a comment explaining that, or implement the injection logic if it is missing.
References
- All credentials declared in a tool's capabilities are mandatory. Tools that can function with a subset of credentials should only declare the non-optional ones in their capabilities.
There was a problem hiding this comment.
Intentional, documented inline in 309ac857. Signing variants aren't injected here; signatures are computed host-side in the signer path after the body is assembled, so the secret never enters static header/query/url placement and the WASM tool never sees key material.
zmanian
left a comment
There was a problem hiding this comment.
Review
Summary: Adds four new CredentialLocation variants letting WASM tools declare a closed-vocabulary signing schema in capabilities.json; the host performs all crypto with secrets that never cross the WASM boundary. Big PR (+3482/-28), well-architected.
Crypto implementation is clean
- EIP-712 domain is manifest-bound, not WASM-supplied.
Eip712Domain { name, version, chain_id, verifying_contract }is declared incapabilities.jsonas aCredentialLocationSchema::Eip712SignedHeaderliteral. The WASM tool cannot mutatechainIdorverifyingContractat runtime — they're baked into the install-reviewed manifest. Trust anchor moves to manifest-review quality. But only as strong as the install-time UX (see below). - NEP-413 prefix correctly enforced host-side.
apply_nep413_signinghardcodesprefix: u32 = 2_147_484_061(= 2^31 + 413) and borsh-serializes it before the payload. Signing issha256(prefix || borsh(payload)), ed25519 over the digest. WASM has no bypass path. Correct. - Solana key material never crosses WASM boundary.
apply_solana_signingruns host-side; secret resolution viaresolve_host_credentials(host scope only). WIT exposes onlysecret_exists, neversecret_get. - Input validation tight. uint256 overflow rejected; Solana 1232-byte packet limit enforced; cross-signer body-value confusion rejected (
SolanaSignedTransactionBase64outside Solana signer →InvalidSchema); secp256k1 v=27/28 convention preserved. - HMAC key isolation relies on existing
SecretsStore+CredentialMapping.tool_idscoping — not weakened.
Architectural gaps — blocking for wallet-draining surfaces
-
No per-request user consent. Once a tool is installed with a
SolanaSignedTransactionorEip712SignedHeadercapability, every subsequent invocation signs silently. The closedFieldSource/BytesPartvocabulary constrains the shape of the signed payload, not its value — a malicious/compromised extension can construct a Solana transaction draining the wallet within the allowed shape. The host signs rawmessage_bytesfromBodyFieldStringwithout parsing instructions.This is the largest unmitigated risk. Recommend a per-call user approval for high-value signers (Solana
sendTransaction, EIP-712 OrderTyped) or at minimum a rate-limit + UI-surfaced audit log. -
Signing capability bundled with HTTP allowlist at install time. No separate
sign:eip712/sign:solanascope a user can revoke independently of the tool. Combined with (1), signing authority + network authority are bundled. A user can't say "I trust this for reading prices but not for signing trades."
Verify separately
-
Install-time EIP-712 domain rendering. The whole #1 trust-anchor argument depends on
ironclaw extension installsurfacing the EIP-712 domain (chain, contract, version) to the user in human-readable form before approval. Not visible in this PR — confirm separately. Without it, "manifest-bound" reduces to "extension-author-bound." -
No host-side replay protection.
request_random_nonce_b64/request_timestamp_secsgenerated fresh per request but the host doesn't track used nonces. If a venue's protocol expects monotonic or one-shot nonces, enforcement is implicit in the venue API, not in IronClaw. Acceptable but document explicitly.
Verdict
Crypto: ship. Architecture: not yet — the per-tool blank-cheque signer is a wallet-draining vector even with the closed vocabulary. Recommend opening tracking issues for (a) install-time EIP-712 domain rendering, (b) per-call approval for high-value signers, (c) splitting sign:* from HTTP allowlist as separate capability scopes, then re-reviewing. Given the blast radius, a human security pass before merge is also warranted.
|
Filed follow-up security issue #3564 capturing the architectural concern from my review:
The crypto in this PR is sound and useful for the lower-stakes paths (HMAC auth, NEP-413 message attestations, EIP-712 off-chain venue auth). The follow-up issue contests only the wallet-equivalent signing-authority use cases — where host compromise translates directly to unbounded fund loss — and proposes:
The manifest-bound EIP-712 domain stays a useful UX/ergonomics feature in that world; it just stops being a load-bearing security control. Not a request to revert this PR — the in-scope crypto can land. The follow-up gates what these capabilities are advertised for and adds the missing channel for the value-bearing use cases. |
Addresses the three blocker asks on PR nearai#3256. Renders the EIP-712 domain and primary type at install time with an explicit typed consent phrase. Adds TerminalSignerGate that prompts on TTY before any High-classification signer runs and fails closed on non-TTY. Splits sign:* schemes onto a dedicated signing.schemes capability axis with auto-promotion and a deprecation warning for legacy http.credentials signers.
Adds three CredentialLocation variants (HmacSignedHeader, Eip712SignedHeader,
Nep413SignedHeader) following the existing primitive pattern. Tools declare
venue-specific signing schemas in capabilities.json via a closed source
vocabulary; the runtime stays generic.
All signing happens host-side. WASM tools never see the HMAC key, secp256k1
private key, or ed25519 seed. Body mutation (Hyperliquid signature.{r,s,v})
runs after WASM hands over the request body, before reqwest dispatch.
Roundtrip-verified for each auth type:
- HMAC: recomputed via the hmac crate against captured timestamp
- EIP-712: VerifyingKey::recover_from_prehash recovers the signer address
- NEP-413: ed25519_dalek::Verifier on sha256(prefix || borsh-payload)
…tion tests Cleanup pass on the per-request credential signing pipeline: - Hoist timestamp and random nonce above the credential dispatch loop so multiple matching signers on the same request share one signing context. Previously regenerated per credential, which silently produced mismatched timestamps when L1 and L2 mappings both matched the same path. - resolve_field_source / resolve_output_source: signer_address is now Option<&str>. NEP-413 passes None and any reference to the signer_address source errors with a clear message instead of silently emitting an empty string. - assemble_bytes_for_keccak: parse the request body JSON once and pass the Value to each evaluate_bytes_part. Hyperliquid's connectionId construction was parsing the same body three times. - Dedupe HostCredentialsResolution doc comment. - json_path_set: validate empty path and empty path segments before the split. The previous parts.is_empty() check was unreachable. Adds two integration tests that load the polymarket-clob capabilities manifest end-to-end through the resolver and dispatcher: - L2 GET /data/orders assembles all five HMAC headers and the signature matches independent recomputation against the hmac crate. - L1 POST /auth/api-key assembles the four EIP-712 headers and the signature recovers to the dummy wallet address derived from the test private key.
Address nearai#3240 review: - uint256 hand-rolled decimal-to-32-byte parser replaces u128::parse, rejects overflow past 2^256 - 1. - parse_body_json_if_needed hoists parsing out of assemble_bytes_for_keccak; apply_eip712 and apply_nep413 thread the parsed Value down. - Regression test for uint256 covers 0, above 2^128, max, overflow, non-numeric input.
Additive variant on CredentialLocation alongside HMAC, EIP-712, NEP-413. Tool supplies the unsigned message bytes via the new BodyFieldString source; runtime signs per RFC 8032 and assembles the wire-format signed transaction ([compact-u16 sig_count][64-byte sig][message]) base64- encoded for sendTransaction. Total tx size capped at the 1232-byte Solana packet limit. json_path_set now walks array indices so output can land at the JSON-RPC params slot. evaluate_body_value gains a Result return and explicit error arm for SolanaSignedTransactionBase64 outside the solana signer; solana writes its own evaluator that rejects ECDSA r/s/v outputs. 7 new tests including end-to-end pipeline with signature verification against the signer pubkey.
Addresses the three blocker asks on PR nearai#3256. Renders the EIP-712 domain and primary type at install time with an explicit typed consent phrase. Adds TerminalSignerGate that prompts on TTY before any High-classification signer runs and fails closed on non-TTY. Splits sign:* schemes onto a dedicated signing.schemes capability axis with auto-promotion and a deprecation warning for legacy http.credentials signers.
When two signing schemas match a request and write the same header, the higher-classification signer's value now wins deterministically and equal-classification collisions refuse to sign rather than silently picking one, replacing the previous last-writer-wins behavior. Also documents the per-request replay posture at the nonce site and why the credential_injector signing arm is intentionally empty.
a8bb22a to
309ac85
Compare
|
@zmanian thanks for the thorough review, that was genuinely useful. Pushed the changes (rebased on current staging, fmt/clippy/tests green): Install-time EIP-712 rendering is in. The domain (name, version, chain in human-readable form, verifying contract) is shown and the user has to consent before approval, on both Per-call approval for the high-value signers is wired end to end (app to ToolRegistry to the WASM wrappers). Solana is classified High, EIP-712 is graded by its struct shape. The gate fails closed when there's no TTY, so on a hosted or non-interactive agent a high-classification signer can't go through silently. Signing is now its own capability section in capabilities.json, separate from the HTTP allowlist and with its own revoke path, so network access and signing authority aren't bundled together anymore. On replay, I wrote the posture down explicitly at the nonce site rather than leaving it implied: fresh nonce and timestamp per request, the host does not track or enforce uniqueness, replay resistance is delegated to the venue API. One thing worth calling out. While wiring the approval gate I found a real bug in the same area: when two signing schemas matched a request and wrote the same header it was silent last-writer-wins, so a low-classification HMAC signer was overwriting a higher-classification EIP-712 signature. It now resolves by classification precedence (higher wins, and the override is logged), and an equal-classification collision fails closed instead of silently picking one. Added regression tests, and a previously-failing end-to-end test now passes for the right reason. The Gemini note about the empty match arm in |
Summary
Adds four new
CredentialLocationvariants (HmacSignedHeader,Eip712SignedHeader,Nep413SignedHeader,SolanaSignedTransaction) alongside the existing five static primitives. Tools declare venue-specific signing schemas incapabilities.jsonvia a closed source vocabulary; the runtime stays generic. Unblocks Polymarket L1+L2, Hyperliquid, Trezu, NEAR Intents, and Solana RPCsendTransactionwithout per-venue runtime code.Motivation
The five existing
CredentialLocationvariants only support static placement of secrets into headers, query params, or URL placeholders. Real signing recipes need request data (method, path, body, timestamp, message bytes) that is only known at request time, plus crypto the WASM sandbox cannot safely run. This blocks every venue using HMAC, EIP-712 typed data, NEP-413, or Solana ed25519: Polymarket L1 (ClobAuth) + L2 (HMAC), Hyperliquid (EIP-712 with body-output signature), Trezu (NEP-413), NEAR Intents (NEP-413), Solana RPC (sendTransactionwith a fully signed transaction inparams[0]).Architecture
Four new variants on the existing
CredentialLocationenum, same dispatcher loop (inject_host_credentials), same resolver (resolve_host_credentials), same schema layer. No architectural change.Closed source vocabulary (
FieldSource,OutputSource,BodyValue,BytesPart) bounds what tools can declare to be signed: every value is either user-reviewed at install time (manifest literal) or derived from the request the tool was already authorized to send (timestamp, nonce, body field, msgpack of body field, base64 message bytes). No "sign these arbitrary bytes" escape hatch exists.Body mutation runs after the WASM tool hands over the request body but before reqwest dispatch, needed for Hyperliquid (which puts
signature.{r,s,v}in the body) and Solana (which puts the full base64 signed transaction atparams.0of the JSON-RPC envelope). The newoutput_body_fields: Vec<BodyJsonOutput>field declares JSON paths and value sources; the runtime parses the body once, walks the paths, sets the values, re-serializes.json_path_setnow walks array indices so output can land at JSON-RPC params slots.EIP-712 schemas in this PR are constrained to a single struct: only the primary type, no nested type dependencies. All four EVM target venues use single-struct schemas, so this is sufficient for the unblocking work. Multi-struct support per the full EIP-712 spec is a follow-up; the manifest format already accepts
Vec<Eip712StructDef>, so adding nested-struct dependency resolution later is additive.Solana signing follows the same closed-vocabulary pattern: the tool builds the unsigned wire-format message bytes itself (account ordering, recent_blockhash, instruction encoding) and places them base64-encoded in the request body via the new
FieldSource::BodyFieldString { path }source. The runtime decodes, ed25519-signs the raw message bytes per RFC 8032, prepends the compact-u16 signature count and the 64-byte signature, base64-encodes, and writes the result viaoutput_body_fields(typically intoparams.0). Total transaction size is capped at the Solana 1232-byte packet limit.Security
secret_exists(name)is exposed to WASM, neversecret_get.LeakDetectorstill scans WASM-provided URL/headers/body before any host injection.AuthorizationBasic/UrlPathcomplex variants.ResolvedHostCredential::Debugredactssecret_valueand shows only header NAMES (no values).SolanaSignedTransactionBase64outside the Solana signer. Cross-signer output type confusion fails fast.Tests
hmaccrate and asserted equal.VerifyingKey::recover_from_prehashagainst the recomputed final hash (validates domain separator, type hash, struct hash).ed25519_dalek::Verifieroversha256(prefix || borsh-payload)(validates prefix bytes `[0x9D, 0x01, 0x00, 0x80]` matches `2^31 + 413`, borsh field order, hash).ed25519_dalek::Verifierdirectly against the raw message bytes (validates RFC 8032 application-level no-prehash, wire-format envelope assembly, base64 encoding).resolve_host_credentials, and verify both L1 (`/auth/api-key` EIP-712) and L2 (`/data/orders` HMAC) paths assemble all expected headers correctly.Rollback
Additive only. New variants on existing enums, no DB migration, no schema changes, no breaking API changes. Tools that do not declare the new variants are unaffected.
Spec references