feat(wasm): per-request credential signing for HMAC, EIP-712, NEP-413#3240
feat(wasm): per-request credential signing for HMAC, EIP-712, NEP-413#3240neo-sky wants to merge 3 commits into
Conversation
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.
There was a problem hiding this comment.
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.
| "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) |
There was a problem hiding this comment.
Solidity's uint256 type represents a 256-bit unsigned integer. Parsing it into a u128 will cause a runtime error for any value exceeding 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.
| 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 | ||
| }; |
There was a problem hiding this comment.
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
- 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.
| if spec.structs.len() != 1 { | ||
| return Err(SignerError::InvalidSchema( | ||
| "only single-struct eip712 schemas are supported in this build".into(), | ||
| )); | ||
| } |
There was a problem hiding this comment.
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.
|
Addressed all three review comments from gemini-code-assist in 223e263 (the body) plus a PR-body update (the docs).
Full lib suite still green (5613 passed, 0 failed, 7 ignored). Clippy clean. |
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.
Summary
Adds three new
CredentialLocationvariants (HmacSignedHeader,Eip712SignedHeader,Nep413SignedHeader) 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 without any 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) 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
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). 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 newoutput_body_fields: Vec<BodyJsonOutput>field onEip712SignedHeaderdeclares 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
secret_exists(name)is exposed to WASM, neversecret_get.LeakDetectorstill scans WASM-provided URL/headers/body before any host injection, so host-injected secrets cannot self-trigger leak detection.AuthorizationBasic/UrlPathcomplex variants. Container proxy delegates to the orchestrator credentials endpoint as before.ResolvedHostCredential::Debugredactssecret_valueand shows only header NAMES (no values) for the signing field.POLY_TIMESTAMPor similar shared-name headers.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]matches2^31 + 413, borsh field order, hash).resolve_host_credentials, and verify both L1 (/auth/api-keyEIP-712) and L2 (/data/ordersHMAC) paths assemble all expected headers correctly.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 revertof 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.