Skip to content

Harden write-endpoint EIP-191 auth (action/body/domain binding + replay protection) #349

Description

@sadiq1971

Summary

The write endpoints authenticate with an EIP-191 signature over a message whose only validated property is a trailing Unix timestamp (authenticateEVM, pkg/transfer/http.go:404-424; ValidateTimedMessage, pkg/auth/evm.go:80-102). The signature math is correct, but the message is bound to nothing else — no action, no request body, no domain, and no single-use nonce. This creates several gaps, the most serious of which allows theft of custodial-user funds from a captured or phished signature.

Companion to #341 (read-endpoint auth). This issue covers the write path only; per the #341 constraint, writes stay on the signature path — this is about hardening that path, not replacing it.

Gaps (by severity)

1. One signature authorizes every write action — no action/body binding.
authenticateEVM only checks that X-Message ends in :<unix_timestamp>. The transfer: prefix in the comment is illustrative and is never enforced. A signature captured for one endpoint (e.g. /transfer/prepare) is accepted verbatim on any other write endpoint within the window.

2. For custodial users, that credential moves funds.
In non-custodial flows the EIP-191 header only establishes identity — the fund-moving authorization is the Canton signature in the request body, which an attacker cannot forge. In custodial flows the server signs on the user's behalf, so the EIP-191 header is the entire authorization. Holding one valid (X-Message, X-Signature) pair for a custodial user lets an attacker call /transfer/custodial or /withdraw/custodial with an attacker-chosen recipient and amount (the body is unsigned).

3. No domain separation — cross-application signature phishing.
Nothing in the message identifies this API; any string ending in :<timestamp> verifies. A malicious dapp that gets a user to personal_sign an innocuous-looking string has minted a valid credential for this API — combined with #1/#2, a fund-theft credential for custodial users — with no interaction with our system.

4. Replay window is timestamp-only, no nonce, and effectively ±5 min.
No server-side nonce or seen-message tracking, so every capture is replayable (repeatedly) within the window. ValidateTimedMessage takes the absolute value of age (evm.go:92-96), so timestamps up to 5 min in the future are also accepted — roughly a 10-minute total window. Skew tolerance should be a small separate allowance, not symmetric maxAge.

5. Minor.

  • ECDSA signature malleability (low-s) is not enforced — harmless today, but a future replay cache must key on the message hash, not the signature bytes, or it is bypassable by flipping s.
  • /profile accepts a signed login message for 24h (loginMessageMaxAge) — long-lived bearer credential, read-only impact.

Proposed fix

  1. Bind the message to action + payload + domain. Signed message becomes e.g. canton-middleware:<action>:<keccak256(body)>:<timestamp>; the server recomputes keccak256(body) and checks the domain/action prefix. This closes Bump github.com/consensys/gnark-crypto from 0.18.0 to 0.18.1 #1, Bump golang.org/x/crypto from 0.41.0 to 0.45.0 #2 (attacker can no longer choose the body), and Fix/proto v2 migration and relayer fixes #3 in one change. Breaking change for existing clients.
  2. Add single-use replay protection for fund-moving endpoints. A server-side seen-message set (key = keccak256(message), TTL = validity window, atomic check-and-insert). In-memory map is acceptable for a single api-server replica; use a Postgres seen_messages(hash PK, expires_at) table if there is more than one replica (survives restart, shared across instances). No endpoint required — this is dedupe, distinct from the SIWE login nonce in Put read APIs behind authentication #341, though both can share one store. See discussion.
  3. Fix the timestamp check to reject future timestamps beyond a small skew allowance; consider shrinking the 5-min window to 1-2 min once a nonce exists.
  4. Later / optional: for custodial fund-moving actions, move to EIP-712 typed data so the wallet renders recipient/amount instead of a blind hash (UX + anti-phishing upgrade, not a prerequisite).

Scope / acceptance

  • Signed message bound to action, body hash, and domain; server enforces all three
  • Replay protection on fund-moving write endpoints (atomic, message-hash-keyed)
  • Timestamp validation rejects out-of-skew future timestamps
  • Client(s) updated to new message format; migration noted as breaking

Metadata

Metadata

Assignees

No one assigned

    Labels

    Priority: P1Added to issues and PRs relating to a high severity bugs.Type: BugAdded to issues and PRs if they are addressing a buggoPull requests that update go code

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions