Skip to content

Commit de0c99e

Browse files
committed
fix: bind attribution memo nonce to challenge ID
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
1 parent 9323531 commit de0c99e

7 files changed

Lines changed: 451 additions & 42 deletions

File tree

.changeset/challenge-bound-memo.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'mppx': patch
3+
---
4+
5+
Bind attribution memo nonce to challenge ID. The 7-byte nonce field (bytes 25–31) is now derived from `keccak256(challengeId)[0..6]` instead of random bytes, preventing transaction hash stealing in push mode. `Attribution.encode()` now requires `challengeId`. The server verifies challenge binding and server fingerprint for `hash` (push) credentials. Pull-mode `transaction` credentials are not affected — the server controls broadcast, so there is no hash-stealing risk.
6+
7+
**Breaking:** `Attribution.encode()` now requires `challengeId` — callers must pass the challenge ID to generate a memo. Old push-mode clients that generate random attribution nonces or plain transfers without memos are rejected by the server. Pull-mode clients are unaffected.

AGENTS.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,22 @@ id = base64url(HMAC-SHA256(server_secret, input))
142142

143143
**Verification:** Server recomputes HMAC from echoed challenge parameters and compares to `id`. If mismatch, reject credential.
144144

145+
## Changesets
146+
147+
**Never manually edit `CHANGELOG.md`.** Use [changesets](https://github.com/changesets/changesets) instead:
148+
149+
Create a `.changeset/<name>.md` file with frontmatter specifying the package and bump type:
150+
151+
```md
152+
---
153+
'mppx': patch
154+
---
155+
156+
Description of the change.
157+
```
158+
159+
The changelog is auto-generated from changesets during `changeset version`.
160+
145161
## Commands
146162

147163
```bash

src/tempo/Attribution.test.ts

Lines changed: 129 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,38 @@ describe('Attribution', () => {
1616

1717
describe('encode', () => {
1818
test('returns a 32-byte hex string', () => {
19-
const memo = Attribution.encode({ serverId: 'api.example.com' })
19+
const memo = Attribution.encode({
20+
challengeId: 'test-challenge-1',
21+
serverId: 'api.example.com',
22+
})
2023
// 0x prefix + 64 hex chars = 32 bytes
2124
expect(memo).toMatch(/^0x[0-9a-f]{64}$/i)
2225
})
2326

2427
test('starts with TAG + version byte', () => {
25-
const memo = Attribution.encode({ serverId: 'api.example.com' })
28+
const memo = Attribution.encode({
29+
challengeId: 'test-challenge-1',
30+
serverId: 'api.example.com',
31+
})
2632
const tag = memo.slice(0, 10) // 0x + 8 hex chars
2733
expect(tag.toLowerCase()).toBe(Attribution.tag.toLowerCase())
2834
const version = memo.slice(10, 12)
2935
expect(version).toBe('01')
3036
})
3137

32-
test('generates unique memos (random nonce)', () => {
33-
const a = Attribution.encode({ serverId: 'api.example.com' })
34-
const b = Attribution.encode({ serverId: 'api.example.com' })
38+
test('produces deterministic memos (challenge-bound nonce)', () => {
39+
const a = Attribution.encode({ challengeId: 'challenge-a', serverId: 'api.example.com' })
40+
const b = Attribution.encode({ challengeId: 'challenge-b', serverId: 'api.example.com' })
3541
expect(a).not.toBe(b)
42+
const c = Attribution.encode({ challengeId: 'challenge-a', serverId: 'api.example.com' })
43+
expect(a).toBe(c)
3644
})
3745

3846
test('encodes server fingerprint from serverId', () => {
39-
const memo = Attribution.encode({ serverId: 'api.example.com' })
47+
const memo = Attribution.encode({
48+
challengeId: 'test-challenge-1',
49+
serverId: 'api.example.com',
50+
})
4051
const expectedFingerprint = Hex.slice(
4152
Hash.keccak256(Bytes.fromString('api.example.com'), { as: 'Hex' }),
4253
0,
@@ -47,7 +58,11 @@ describe('Attribution', () => {
4758
})
4859

4960
test('encodes client fingerprint from clientId', () => {
50-
const memo = Attribution.encode({ serverId: 'api.example.com', clientId: 'my-app' })
61+
const memo = Attribution.encode({
62+
challengeId: 'test-challenge-1',
63+
clientId: 'my-app',
64+
serverId: 'api.example.com',
65+
})
5166
const expectedFingerprint = Hex.slice(
5267
Hash.keccak256(Bytes.fromString('my-app'), { as: 'Hex' }),
5368
0,
@@ -58,13 +73,20 @@ describe('Attribution', () => {
5873
})
5974

6075
test('encodes zero client bytes when no clientId', () => {
61-
const memo = Attribution.encode({ serverId: 'api.example.com' })
76+
const memo = Attribution.encode({
77+
challengeId: 'test-challenge-1',
78+
serverId: 'api.example.com',
79+
})
6280
const clientHex = `0x${memo.slice(32, 52)}` as `0x${string}`
6381
expect(clientHex).toBe(Attribution.anonymous)
6482
})
6583

6684
test('treats empty string clientId as anonymous', () => {
67-
const memo = Attribution.encode({ serverId: 'api.example.com', clientId: '' })
85+
const memo = Attribution.encode({
86+
challengeId: 'test-challenge-1',
87+
clientId: '',
88+
serverId: 'api.example.com',
89+
})
6890
const clientHex = `0x${memo.slice(32, 52)}` as `0x${string}`
6991
expect(clientHex).toBe(Attribution.anonymous)
7092
const decoded = Attribution.decode(memo)
@@ -75,12 +97,19 @@ describe('Attribution', () => {
7597

7698
describe('isMppMemo', () => {
7799
test('returns true for encoded memos', () => {
78-
const memo = Attribution.encode({ serverId: 'api.example.com' })
100+
const memo = Attribution.encode({
101+
challengeId: 'test-challenge-1',
102+
serverId: 'api.example.com',
103+
})
79104
expect(Attribution.isMppMemo(memo)).toBe(true)
80105
})
81106

82107
test('returns true for encoded memos with clientId', () => {
83-
const memo = Attribution.encode({ serverId: 'api.example.com', clientId: 'my-app' })
108+
const memo = Attribution.encode({
109+
challengeId: 'test-challenge-1',
110+
clientId: 'my-app',
111+
serverId: 'api.example.com',
112+
})
84113
expect(Attribution.isMppMemo(memo)).toBe(true)
85114
})
86115

@@ -101,13 +130,19 @@ describe('Attribution', () => {
101130
})
102131

103132
test('returns false for wrong version', () => {
104-
const memo = Attribution.encode({ serverId: 'api.example.com' })
133+
const memo = Attribution.encode({
134+
challengeId: 'test-challenge-1',
135+
serverId: 'api.example.com',
136+
})
105137
const wrongVersion = `${memo.slice(0, 10)}ff${memo.slice(12)}` as `0x${string}`
106138
expect(Attribution.isMppMemo(wrongVersion)).toBe(false)
107139
})
108140

109141
test('handles mixed case hex', () => {
110-
const memo = Attribution.encode({ serverId: 'api.example.com' })
142+
const memo = Attribution.encode({
143+
challengeId: 'test-challenge-1',
144+
serverId: 'api.example.com',
145+
})
111146
const tagUpper = memo.slice(0, 10).toUpperCase()
112147
const mixed = `0x${tagUpper.slice(2)}${memo.slice(10)}` as `0x${string}`
113148
expect(Attribution.isMppMemo(mixed)).toBe(true)
@@ -116,17 +151,27 @@ describe('Attribution', () => {
116151

117152
describe('verifyServer', () => {
118153
test('returns true for matching serverId', () => {
119-
const memo = Attribution.encode({ serverId: 'api.example.com' })
154+
const memo = Attribution.encode({
155+
challengeId: 'test-challenge-1',
156+
serverId: 'api.example.com',
157+
})
120158
expect(Attribution.verifyServer(memo, 'api.example.com')).toBe(true)
121159
})
122160

123161
test('returns true for matching serverId with clientId', () => {
124-
const memo = Attribution.encode({ serverId: 'api.example.com', clientId: 'my-app' })
162+
const memo = Attribution.encode({
163+
challengeId: 'test-challenge-1',
164+
clientId: 'my-app',
165+
serverId: 'api.example.com',
166+
})
125167
expect(Attribution.verifyServer(memo, 'api.example.com')).toBe(true)
126168
})
127169

128170
test('returns false for wrong serverId', () => {
129-
const memo = Attribution.encode({ serverId: 'api.example.com' })
171+
const memo = Attribution.encode({
172+
challengeId: 'test-challenge-1',
173+
serverId: 'api.example.com',
174+
})
130175
expect(Attribution.verifyServer(memo, 'other.example.com')).toBe(false)
131176
})
132177

@@ -139,7 +184,11 @@ describe('Attribution', () => {
139184

140185
describe('decode', () => {
141186
test('decodes an encoded memo with serverId and clientId', () => {
142-
const memo = Attribution.encode({ serverId: 'api.example.com', clientId: 'my-app' })
187+
const memo = Attribution.encode({
188+
challengeId: 'test-challenge-1',
189+
clientId: 'my-app',
190+
serverId: 'api.example.com',
191+
})
143192
const result = Attribution.decode(memo)
144193
expect(result).not.toBeNull()
145194
expect(result!.version).toBe(1)
@@ -150,7 +199,10 @@ describe('Attribution', () => {
150199
})
151200

152201
test('decodes anonymous client as null', () => {
153-
const memo = Attribution.encode({ serverId: 'api.example.com' })
202+
const memo = Attribution.encode({
203+
challengeId: 'test-challenge-1',
204+
serverId: 'api.example.com',
205+
})
154206
const result = Attribution.decode(memo)
155207
expect(result).not.toBeNull()
156208
expect(result!.clientFingerprint).toBeNull()
@@ -162,14 +214,22 @@ describe('Attribution', () => {
162214
expect(Attribution.decode(arbitrary)).toBeNull()
163215
})
164216

165-
test('different encodes produce different nonces', () => {
166-
const a = Attribution.decode(Attribution.encode({ serverId: 'api.example.com' }))
167-
const b = Attribution.decode(Attribution.encode({ serverId: 'api.example.com' }))
217+
test('different challengeIds produce different nonces', () => {
218+
const a = Attribution.decode(
219+
Attribution.encode({ challengeId: 'challenge-a', serverId: 'api.example.com' }),
220+
)
221+
const b = Attribution.decode(
222+
Attribution.encode({ challengeId: 'challenge-b', serverId: 'api.example.com' }),
223+
)
168224
expect(a!.nonce).not.toBe(b!.nonce)
169225
})
170226

171227
test('serverId fingerprint matches expected keccak hash', () => {
172-
const memo = Attribution.encode({ serverId: 'api.example.com', clientId: 'my-app' })
228+
const memo = Attribution.encode({
229+
challengeId: 'test-challenge-1',
230+
clientId: 'my-app',
231+
serverId: 'api.example.com',
232+
})
173233
const result = Attribution.decode(memo)!
174234
const expectedServer = Hex.slice(
175235
Hash.keccak256(Bytes.fromString('api.example.com'), { as: 'Hex' }),
@@ -180,9 +240,55 @@ describe('Attribution', () => {
180240
})
181241

182242
test('returns null for wrong version via decode', () => {
183-
const memo = Attribution.encode({ serverId: 'api.example.com' })
243+
const memo = Attribution.encode({
244+
challengeId: 'test-challenge-1',
245+
serverId: 'api.example.com',
246+
})
184247
const corrupted = `${memo.slice(0, 10)}ff${memo.slice(12)}` as `0x${string}`
185248
expect(Attribution.decode(corrupted)).toBeNull()
186249
})
187250
})
251+
252+
describe('challengeNonce', () => {
253+
test('returns 7 bytes', () => {
254+
const nonce = Attribution.challengeNonce('challenge-123')
255+
expect(nonce.length).toBe(7)
256+
})
257+
258+
test('is deterministic', () => {
259+
const a = Attribution.challengeNonce('challenge-123')
260+
const b = Attribution.challengeNonce('challenge-123')
261+
expect(Hex.fromBytes(a)).toBe(Hex.fromBytes(b))
262+
})
263+
264+
test('differs for different challengeIds', () => {
265+
const a = Attribution.challengeNonce('challenge-123')
266+
const b = Attribution.challengeNonce('challenge-456')
267+
expect(Hex.fromBytes(a)).not.toBe(Hex.fromBytes(b))
268+
})
269+
})
270+
271+
describe('verifyChallengeBinding', () => {
272+
test('returns true for matching challengeId', () => {
273+
const memo = Attribution.encode({
274+
challengeId: 'challenge-123',
275+
serverId: 'api.example.com',
276+
})
277+
expect(Attribution.verifyChallengeBinding(memo, 'challenge-123')).toBe(true)
278+
})
279+
280+
test('returns false for wrong challengeId', () => {
281+
const memo = Attribution.encode({
282+
challengeId: 'challenge-123',
283+
serverId: 'api.example.com',
284+
})
285+
expect(Attribution.verifyChallengeBinding(memo, 'challenge-456')).toBe(false)
286+
})
287+
288+
test('returns false for non-MPP memo', () => {
289+
const arbitrary =
290+
'0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' as `0x${string}`
291+
expect(Attribution.verifyChallengeBinding(arbitrary, 'challenge-123')).toBe(false)
292+
})
293+
})
188294
})

src/tempo/Attribution.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { Bytes, Hash, Hex } from 'ox'
1414
* | 4 | 1 | version (0x01) |
1515
* | 5..14 | 10 | serverId = keccak256(serverId)[0..9] |
1616
* | 15..24 | 10 | clientId = keccak256(clientId)[0..9] or 0s |
17-
* | 25..31 | 7 | nonce (random bytes) |
17+
* | 25..31 | 7 | nonce = keccak256(challengeId)[0..6] |
1818
*
1919
* The TAG prefix makes MPP transactions trivially distinguishable
2020
* from arbitrary memos via `TransferWithMemo` event topic filtering.
@@ -50,30 +50,34 @@ function fingerprint(value: string): Uint8Array {
5050
* ```ts
5151
* import * as Attribution from './Attribution.js'
5252
*
53-
* const memo = Attribution.encode({ serverId: 'api.example.com', clientId: 'my-app' })
53+
* const memo = Attribution.encode({ challengeId: 'challenge-123', clientId: 'my-app', serverId: 'api.example.com' })
5454
* ```
5555
*/
5656
export function encode(parameters: encode.Parameters) {
57-
const { serverId, clientId } = parameters
57+
const { serverId, clientId, challengeId } = parameters
5858
const buf = new Uint8Array(32)
5959

6060
buf.set(Hex.toBytes(tag), 0)
6161
buf[4] = version
6262
buf.set(fingerprint(serverId), 5)
6363
if (clientId) buf.set(fingerprint(clientId), 15)
6464

65-
const nonce = crypto.getRandomValues(new Uint8Array(7))
66-
buf.set(nonce, 25)
65+
// Derive the nonce from keccak256(challengeId)[0..6] to bind the memo
66+
// to the challenge and prevent transaction hash stealing.
67+
// TODO: expand to full memo verification once the server tracks the complete attribution payload.
68+
buf.set(challengeNonce(challengeId), 25)
6769

6870
return Hex.fromBytes(buf)
6971
}
7072

7173
export declare namespace encode {
7274
type Parameters = {
73-
/** Server identity used to derive the server fingerprint. */
74-
serverId: string
75+
/** Challenge ID used to derive a deterministic nonce, binding the memo to the challenge. */
76+
challengeId: string
7577
/** Optional client identity used to derive the client fingerprint. */
7678
clientId?: string | undefined
79+
/** Server identity used to derive the server fingerprint. */
80+
serverId: string
7781
}
7882
}
7983

@@ -87,7 +91,7 @@ export declare namespace encode {
8791
* ```ts
8892
* import * as Attribution from './Attribution.js'
8993
*
90-
* Attribution.isMppMemo(Attribution.encode({ serverId: 'api.example.com' })) // true
94+
* Attribution.isMppMemo(Attribution.encode({ challengeId: 'challenge-123', serverId: 'api.example.com' })) // true
9195
* Attribution.isMppMemo('0x0000...0000') // false
9296
* ```
9397
*/
@@ -124,7 +128,7 @@ export function verifyServer(memo: `0x${string}`, serverId: string): boolean {
124128
* ```ts
125129
* import * as Attribution from './Attribution.js'
126130
*
127-
* const memo = Attribution.encode({ serverId: 'api.example.com', clientId: 'my-app' })
131+
* const memo = Attribution.encode({ challengeId: 'challenge-123', clientId: 'my-app', serverId: 'api.example.com' })
128132
* const decoded = Attribution.decode(memo)
129133
* // { version: 1, serverFingerprint: '0x...', clientFingerprint: '0x...', nonce: '0x...' }
130134
* ```
@@ -150,7 +154,32 @@ export declare namespace decode {
150154
serverFingerprint: `0x${string}`
151155
/** 10-byte client fingerprint hex, or `null` if anonymous. */
152156
clientFingerprint: `0x${string}` | null
153-
/** 7-byte random nonce hex. */
157+
/** 7-byte challenge-bound nonce hex (keccak256(challengeId)[0..6]). */
154158
nonce: `0x${string}`
155159
}
156160
}
161+
162+
/**
163+
* Computes the 7-byte challenge-bound nonce: keccak256(challengeId)[0..6].
164+
* @internal
165+
*/
166+
export function challengeNonce(challengeId: string): Uint8Array {
167+
const hash = Hash.keccak256(Bytes.fromString(challengeId), { as: 'Hex' })
168+
return Hex.toBytes(Hex.slice(hash, 0, 7))
169+
}
170+
171+
/**
172+
* Verifies that a memo's nonce is bound to the given challengeId.
173+
*
174+
* Checks TAG, version byte, and that bytes 25–31 equal keccak256(challengeId)[0..6].
175+
*
176+
* @param memo - A `0x`-prefixed hex string (bytes32).
177+
* @param challengeId - The expected challenge ID.
178+
* @returns `true` if the memo has a valid MPP tag and matching challenge nonce.
179+
*/
180+
export function verifyChallengeBinding(memo: `0x${string}`, challengeId: string): boolean {
181+
const decoded = decode(memo)
182+
if (!decoded) return false
183+
const expectedNonce = Hex.fromBytes(challengeNonce(challengeId))
184+
return decoded.nonce.toLowerCase() === expectedNonce.toLowerCase()
185+
}

0 commit comments

Comments
 (0)