Skip to content

feat(wasm): credential signers for HMAC, EIP-712, NEP-413, Solana#3256

Open
neo-sky wants to merge 6 commits into
nearai:stagingfrom
neo-sky:feat/request-signer-pipeline
Open

feat(wasm): credential signers for HMAC, EIP-712, NEP-413, Solana#3256
neo-sky wants to merge 6 commits into
nearai:stagingfrom
neo-sky:feat/request-signer-pipeline

Conversation

@neo-sky
Copy link
Copy Markdown

@neo-sky neo-sky commented May 5, 2026

Summary

Adds four new CredentialLocation variants (HmacSignedHeader, Eip712SignedHeader, Nep413SignedHeader, SolanaSignedTransaction) alongside the existing five static primitives. Tools declare venue-specific signing schemas in capabilities.json via a closed source vocabulary; the runtime stays generic. Unblocks Polymarket L1+L2, Hyperliquid, Trezu, NEAR Intents, and Solana RPC sendTransaction without per-venue runtime code.

Motivation

The five existing CredentialLocation variants 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 (sendTransaction with a fully signed transaction in params[0]).

Architecture

Four new variants on the existing CredentialLocation enum, 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 at params.0 of the JSON-RPC envelope). The new output_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_set now 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 via output_body_fields (typically into params.0). Total transaction size is capped at the Solana 1232-byte packet limit.

Security

  • All signing happens host-side. WASM tools never see HMAC keys, secp256k1 private keys, or ed25519 seeds.
  • WIT contract unchanged: only secret_exists(name) is exposed to WASM, never secret_get.
  • LeakDetector still scans WASM-provided URL/headers/body before any host injection.
  • Sandbox proxy skip-list extended for the four signing variants alongside the existing AuthorizationBasic/UrlPath complex variants.
  • ResolvedHostCredential::Debug redacts secret_value and shows only header NAMES (no values).
  • Signing context (timestamp, random nonce) is generated once per request and shared across multiple matching signers.
  • Solana signer rejects ECDSA r/s/v body outputs at runtime; the EIP-712 evaluator rejects SolanaSignedTransactionBase64 outside the Solana signer. Cross-signer output type confusion fails fast.
  • uint256 EIP-712 encoding uses a hand-rolled big-endian decimal parser supporting the full 2^256 - 1 range with overflow rejection.

Tests

  • 30 new tests covering all four signers, body mutation, schema deserialization, end-to-end pipeline through real capabilities manifests, uint256 encoding edge cases, and cross-signer output type confusion guards.
  • Cryptographic round-trips:
    • HMAC: signature recomputed via the hmac crate and asserted equal.
    • EIP-712: signature recovered to the signer address via VerifyingKey::recover_from_prehash against the recomputed final hash (validates domain separator, type hash, struct hash).
    • NEP-413: signature verified via ed25519_dalek::Verifier over sha256(prefix || borsh-payload) (validates prefix bytes `[0x9D, 0x01, 0x00, 0x80]` matches `2^31 + 413`, borsh field order, hash).
    • Solana: signature verified via ed25519_dalek::Verifier directly against the raw message bytes (validates RFC 8032 application-level no-prehash, wire-format envelope assembly, base64 encoding).
  • Polymarket-clob integration tests load the actual manifest content, resolve dummy secrets through resolve_host_credentials, and verify both L1 (`/auth/api-key` EIP-712) and L2 (`/data/orders` HMAC) paths assemble all expected headers correctly.
  • Solana integration test loads a JSON-RPC sendTransaction body with the unsigned message at `_signing.message_b64` and verifies the assembled body has a valid signed transaction at `params.0` that ed25519-verifies against the signer pubkey.
  • uint256 encoding regression test covers zero, value just above 2^128, the 2^256 - 1 maximum, the 2^256 overflow case, and non-numeric / negative / empty input rejection.
  • Full lib suite: 5620 passed, 0 failed, 7 ignored.
  • `cargo clippy --lib --tests`: zero warnings.

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

@github-actions github-actions Bot added scope: tool/wasm WASM tool sandbox scope: secrets Secrets management scope: sandbox Docker sandbox scope: dependencies Dependency updates size: XL 500+ changed lines risk: high Safety, secrets, auth, or critical infrastructure contributor: new First-time contributor labels May 5, 2026
Copy link
Copy Markdown
Contributor

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

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 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.

Comment on lines +430 to +433
CredentialLocation::HmacSignedHeader { .. }
| CredentialLocation::Eip712SignedHeader { .. }
| CredentialLocation::Nep413SignedHeader { .. }
| CredentialLocation::SolanaSignedTransaction { .. } => {}
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.

medium

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
  1. 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator

@zmanian zmanian left a comment

Choose a reason for hiding this comment

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

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 in capabilities.json as a CredentialLocationSchema::Eip712SignedHeader literal. The WASM tool cannot mutate chainId or verifyingContract at 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_signing hardcodes prefix: u32 = 2_147_484_061 (= 2^31 + 413) and borsh-serializes it before the payload. Signing is sha256(prefix || borsh(payload)), ed25519 over the digest. WASM has no bypass path. Correct.
  • Solana key material never crosses WASM boundary. apply_solana_signing runs host-side; secret resolution via resolve_host_credentials (host scope only). WIT exposes only secret_exists, never secret_get.
  • Input validation tight. uint256 overflow rejected; Solana 1232-byte packet limit enforced; cross-signer body-value confusion rejected (SolanaSignedTransactionBase64 outside Solana signer → InvalidSchema); secp256k1 v=27/28 convention preserved.
  • HMAC key isolation relies on existing SecretsStore + CredentialMapping.tool_id scoping — not weakened.

Architectural gaps — blocking for wallet-draining surfaces

  1. No per-request user consent. Once a tool is installed with a SolanaSignedTransaction or Eip712SignedHeader capability, every subsequent invocation signs silently. The closed FieldSource/BytesPart vocabulary 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 raw message_bytes from BodyFieldString without 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.

  2. Signing capability bundled with HTTP allowlist at install time. No separate sign:eip712 / sign:solana scope 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

  1. Install-time EIP-712 domain rendering. The whole #1 trust-anchor argument depends on ironclaw extension install surfacing 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."

  2. No host-side replay protection. request_random_nonce_b64 / request_timestamp_secs generated 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.

@zmanian
Copy link
Copy Markdown
Collaborator

zmanian commented May 13, 2026

Filed follow-up security issue #3564 capturing the architectural concern from my review:

[Security] Wallet signing requires an unforgeable user-authorization channel, not host-resident keys
#3564

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:

  1. Interactive sessions → EIP-1193 / WalletConnect bridge (IronClaw composes, user's wallet signs).
  2. Autonomous missions → session keys with on-chain caps (Permit2 / ERC-4337 / Solana PDA authorities). Drain blast radius ≤ delegation cap.
  3. Optional hardening → TEE-resident signer + WebAuthn/passkey assertion for cap extension.
  4. Architectural rule → no high-value chain-signing key lives in the IronClaw host process address space.

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.

neo-sky added a commit to neo-sky/ironclaw that referenced this pull request May 14, 2026
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.
@github-actions github-actions Bot added scope: channel/cli TUI / CLI channel scope: tool Tool infrastructure labels May 14, 2026
neo-sky added 6 commits May 17, 2026 18:42
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.
@neo-sky
Copy link
Copy Markdown
Author

neo-sky commented May 18, 2026

@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 ironclaw registry install and ironclaw tool install. Known vs unknown domains get flagged.

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 credential_injector is answered: it's intentional, signing happens host-side after the body is assembled, and there's now an inline comment saying so.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: new First-time contributor risk: high Safety, secrets, auth, or critical infrastructure scope: channel/cli TUI / CLI channel scope: dependencies Dependency updates scope: sandbox Docker sandbox scope: secrets Secrets management scope: tool/wasm WASM tool sandbox scope: tool Tool infrastructure size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants