You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
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.
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)
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.
authenticateEVMonly checks thatX-Messageends in:<unix_timestamp>. Thetransfer: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/custodialor/withdraw/custodialwith 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 topersonal_signan 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.
ValidateTimedMessagetakes 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 symmetricmaxAge.5. Minor.
s./profileaccepts a signed login message for 24h (loginMessageMaxAge) — long-lived bearer credential, read-only impact.Proposed fix
canton-middleware:<action>:<keccak256(body)>:<timestamp>; the server recomputeskeccak256(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.keccak256(message), TTL = validity window, atomic check-and-insert). In-memory map is acceptable for a single api-server replica; use a Postgresseen_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.Scope / acceptance