Skip to content

feat(wasm): per-request credential signing for HMAC, EIP-712, NEP-413#3240

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

feat(wasm): per-request credential signing for HMAC, EIP-712, NEP-413#3240
neo-sky wants to merge 3 commits into
nearai:stagingfrom
neo-sky:feat/request-signer-pipeline

Conversation

@neo-sky
Copy link
Copy Markdown

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

Summary

Adds three new CredentialLocation variants (HmacSignedHeader, Eip712SignedHeader, Nep413SignedHeader) 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 without any 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) 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, or NEP-413: Polymarket L1 (ClobAuth) + L2 (HMAC), Hyperliquid (EIP-712 with body-output signature), Trezu (NEP-413), NEAR Intents (NEP-413).

Architecture

Three 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). 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 rather than in headers. The new output_body_fields: Vec<BodyJsonOutput> field on Eip712SignedHeader declares JSON paths and value sources; the runtime parses the body once, walks the paths, sets the values, re-serializes.

EIP-712 schemas in this PR are constrained to a single struct: only the primary type, no nested type dependencies. All four target venues (Polymarket L1 ClobAuth, Hyperliquid Agent, plus the NEP-413 use cases that don't go through EIP-712) 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 (no breaking change to the manifest format or the runtime API).

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, so host-injected secrets cannot self-trigger leak detection.
  • Sandbox proxy skip-list extended for the new variants alongside the existing AuthorizationBasic/UrlPath complex variants. Container proxy delegates to the orchestrator credentials endpoint as before.
  • ResolvedHostCredential::Debug redacts secret_value and shows only header NAMES (no values) for the signing field.
  • Signing context (timestamp, random nonce) is generated once per request and shared across multiple matching signers, so two signers on the same request cannot drift on POLY_TIMESTAMP or similar shared-name headers.

Tests

  • 23 new tests covering all three signers, body mutation, schema deserialization, end-to-end pipeline through a real capabilities manifest, and uint256 encoding edge cases.
  • 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).
  • 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.
  • 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: 5613 passed, 0 failed, 7 ignored.
  • cargo clippy --lib --tests: zero warnings.

Rollback

Additive only. New variants on an existing enum, no DB migration, no schema changes, no breaking API changes. Tools that do not declare the new variants are unaffected. Revert is git revert of the three commits.

Spec references

Reviewer note

Three commits on the branch (initial feature, review cleanup, automated-review fixes). Happy to split into a 3-PR series (HMAC + closed vocabulary; EIP-712 + NEP-413 header outputs; body mutation + Hyperliquid msgpack) if that is the preferred review shape, let me know.

neo-sky added 2 commits May 3, 2026 20:40
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.
@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 4, 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 implements support for HMAC-SHA256, EIP-712, and NEP-413 request signing within the WASM tool environment. It introduces new credential locations and the corresponding cryptographic logic to handle secure API interactions for Ethereum and NEAR protocols. Feedback identifies a high-severity issue where uint256 values are incorrectly parsed into u128, potentially leading to runtime errors. Additionally, improvements are suggested to avoid redundant JSON parsing of the request body and to address the current limitation that restricts EIP-712 signing to single-struct schemas.

Comment thread src/tools/wasm/wrapper.rs Outdated
Comment on lines +773 to +779
"uint256" => {
let num: u128 = value
.parse()
.map_err(|e| SignerError::InvalidField(format!("uint256 parse: {e}")))?;
let mut padded = [0u8; 32];
padded[16..].copy_from_slice(&num.to_be_bytes());
Ok(padded)
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.

high

Solidity's uint256 type represents a 256-bit unsigned integer. Parsing it into a u128 will cause a runtime error for any value exceeding $2^{128}-1$. Since crypto-bigint is already a dependency in this PR, you should use its U256 type or a similar big-integer library to correctly handle the full range of uint256 values.

Comment thread src/tools/wasm/wrapper.rs Outdated
Comment on lines +1016 to +1036
fn assemble_bytes_for_keccak(
parts: &[crate::secrets::BytesPart],
body: Option<&[u8]>,
) -> Result<Vec<u8>, SignerError> {
use crate::secrets::BytesPart;
let needs_body = parts
.iter()
.any(|p| !matches!(p, BytesPart::LiteralHex { .. }));
let body_json = if needs_body {
let bytes = body.ok_or_else(|| {
SignerError::UnsupportedSource(
"body-derived bytes_part declared but no request body provided".into(),
)
})?;
Some(
serde_json::from_slice::<serde_json::Value>(bytes)
.map_err(|e| SignerError::InvalidField(format!("body not json: {e}")))?,
)
} else {
None
};
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

This function parses the request body as JSON every time it is called. Since it is invoked within resolve_field_source, which itself is called in a loop for every field in an EIP-712 struct, a large request body will be parsed multiple times redundantly. To improve efficiency, the body should be parsed once at the start of apply_eip712_signing (if any field source requires it) and the resulting serde_json::Value should be passed down.

References
  1. To improve performance in WASM, avoid unnecessary heap allocations. For instance, when processing string parts, use iterators directly instead of collecting them into a Vec.

Comment thread src/tools/wasm/wrapper.rs
Comment on lines +485 to +489
if spec.structs.len() != 1 {
return Err(SignerError::InvalidSchema(
"only single-struct eip712 schemas are supported in this build".into(),
));
}
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 current implementation restricts EIP-712 signing to schemas with exactly one struct. While this covers simple cases, EIP-712 often involves nested structs where the primary type references other defined types. The encode_eip712_field_value function also lacks support for nested struct types. This limitation should be documented or the implementation expanded to support the full EIP-712 specification if complex venues are to be supported.

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.
@neo-sky
Copy link
Copy Markdown
Author

neo-sky commented May 5, 2026

Addressed all three review comments from gemini-code-assist in 223e263 (the body) plus a PR-body update (the docs).

  • uint256 overflow (high): replaced u128::parse() with a hand-rolled decimal-to-32-byte big-endian parser that handles the full 0..(2^256 - 1) range and rejects overflow past 2^256 - 1 with a clear error. Regression test covers zero, a value just above 2^128, the 2^256 - 1 max, the 2^256 overflow case, and non-numeric / negative / empty input rejection.
  • Redundant body JSON parsing (medium): hoisted into a new parse_body_json_if_needed helper called once per signing call. Both apply_eip712_signing and apply_nep413_signing now parse the request body to serde_json::Value once at the top and thread it through resolve_field_source and assemble_bytes_for_keccak, so multiple Bytes32Keccak256OfBytes fields no longer re-parse.
  • Single-struct EIP-712 limitation (medium): documented in the PR body's Architecture section. Manifest format already accepts Vec<Eip712StructDef>, so nested-struct dependency resolution is an additive follow-up rather than a breaking change to either the manifest or the runtime API.

Full lib suite still green (5613 passed, 0 failed, 7 ignored). Clippy clean.

@neo-sky neo-sky closed this May 5, 2026
neo-sky added a commit to neo-sky/ironclaw that referenced this pull request May 18, 2026
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.
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: dependencies Dependency updates scope: sandbox Docker sandbox scope: secrets Secrets management scope: tool/wasm WASM tool sandbox size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant