fix: bind attribution memo nonce to challenge ID#291
Conversation
commit: |
ba33352 to
1af4502
Compare
9cc453d to
9a3238c
Compare
tempoxyz-bot
left a comment
There was a problem hiding this comment.
👁️ Cyclops Review
Note: Head drift detected — audited commit 1af4502 differs from current head 9a3238c. Findings have been re-checked against the current diff. Earlier findings about the missing transaction-path check, the custom memo regression, and the missing server fingerprint verification are resolved on the current head and omitted from inline comments.
Surviving Findings
TransferWithMemo log to satisfy challenge binding — assertChallengeBoundMemo scans all memo logs in the receipt rather than verifying the nonce on the specific log(s) that satisfied assertTransferLogs. A multi-call transaction with a same-currency dust transfer carrying a different challenge's nonce can satisfy both challenges independently if their hash stores aren't shared. See inline comment.
🛡️ [DEFENSE-IN-DEPTH] Deterministic challenge.id weakens memo-based replay protection (src/Challenge.ts:612-625, not in this diff) — challenge.id is HMAC(secret, realm|method|intent|request|expires|...) with no per-issuance randomness. If two 402 responses share the same expires (e.g., issued within the same millisecond), they produce identical IDs, and the memo nonce binding provides no replay resistance between them. Consider injecting a random value into opaque to ensure per-issuance uniqueness.
Reviewer Callouts
- ⚡ Receipt/log binding: The new defense proves some memo in the receipt is challenge-bound, but not that the matched payment log carries that memo. Evaluate whether the dust-transfer vector is practical in production.
- ⚡ Store topology: The exploit is most practical when hash stores are isolated (
Store.memory()per handler). Deployment guidance should recommend shared stores for routes accepting the same payment parameters. - ⚡ Challenge ID uniqueness: The deterministic challenge ID predates this PR. If
challenge.idis now a security nonce, adding per-issuance randomness is worth a follow-up.
Stale Findings (fixed on current head)
Transaction replay bypass via—transactionpayloadsassertChallengeBoundMemonow called in both paths.Custom memo regression—if (!memo)guard preserves custom memo support.Missing server fingerprint check—verifyServer()now called before accepting a memo log.
src/tempo/server/Charge.ts
Outdated
| }) | ||
|
|
||
| const bound = memoLogs.some((log) => { | ||
| if (!TempoAddress.isEqual(log.address, parameters.currency)) return false |
There was a problem hiding this comment.
TransferWithMemo log to satisfy challenge binding
This memoLogs.some(...) iterates over ALL TransferWithMemo logs in the receipt, accepting any log whose token matches currency and whose nonce matches challengeId. It does not verify that the accepted memo log is the same log that satisfied assertTransferLogs.
A single multi-call transaction can contain: (1) the real merchant payment for challenge A with A's nonce, and (2) a same-currency dust transfer to an arbitrary address carrying challenge B's nonce. If the two challenges' hash stores are not shared (e.g., separate Store.memory() per handler), the same tx hash satisfies both challenges.
Recommended Fix: Return the matched log(s) from assertTransferLogs and require assertChallengeBoundMemo to verify the nonce on those exact logs rather than scanning the entire receipt.
c9d8541 to
5cec129
Compare
Prevents transaction hash stealing by deriving the 7-byte memo nonce from keccak256(challengeId)[0..6] instead of random bytes. The server verifies this binding for both hash and transaction credentials, rejecting payments whose memo nonce does not match the challenge. - Attribution.encode() requires challengeId (no random fallback) - Client Charge passes challenge.id when encoding attribution memo - Server Charge verifies challenge-bound nonce for hash and transaction - Server fingerprint also verified in assertChallengeBoundMemo - Challenge binding skipped when explicit memo is set (already strict-matched) - New tests: cross-challenge theft, non-MPP memo rejection, split payments
5cec129 to
de0c99e
Compare
Summary
Fixes transaction hash stealing vulnerability in push-mode (
hashcredential) payments, with defense-in-depth fortransactioncredentials.In a very rare occurrence attackers could steal legitimate on-chain transaction hashes and submit them against a different challenge, receiving services for free while locking out the legitimate payer.
Breaking changes
Attribution.encode()now requireschallengeId— old callers without it will get a type error