ChainSafe / ChainSafe/canton-middleware
Harden write-endpoint EIP-191 auth (action/body/domain binding + replay protection)
- Dominant language
- Go
- Stars
- 1
- Forks
- 1
- Avg merge
- 40m
- Merged PRs (30d)
- 1
Description
### 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 `:`. 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 `:` 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:::`; the server recomputes `keccak256(body)` and checks the domain/action prefix. This closes #1, #2 (attacker can no longer choose the body), and #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 #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
Contributor guide
No contributing guide indexed for this repository
Research direction
Start by reading authenticateEVM in pkg/transfer/http.go:404-424 and ValidateTimedMessage in pkg/auth/evm.go:80-102, then trace the write endpoints that use them. Compare the existing client message construction and tests, if present, with the acceptance checklist. Done means action, body hash, and domain are enforced, fund-moving writes have atomic message-hash replay protection, future timestamps are rejected, and clients use the documented breaking format.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go, postgresql
- Domain
- authentication, backend-api-design, security
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100