Skip to content

Commit 7d99a1d

Browse files
committed
Session enhanced replay protection
1 parent 7bef40e commit 7d99a1d

File tree

4 files changed

+93
-29
lines changed

4 files changed

+93
-29
lines changed

packages/wallet/core/src/signers/session/explicit.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,10 +263,13 @@ export class Explicit implements ExplicitSessionSigner {
263263
}
264264

265265
// Sign it
266-
const useDeprecatedHash =
267-
Address.isEqual(sessionManagerAddress, Extensions.Dev1.sessions) ||
268-
Address.isEqual(sessionManagerAddress, Extensions.Dev2.sessions)
269-
const callHash = SessionSignature.hashCallWithReplayProtection(payload, callIdx, chainId, useDeprecatedHash)
266+
const callHash = SessionSignature.hashCallWithReplayProtection(
267+
wallet,
268+
payload,
269+
callIdx,
270+
chainId,
271+
sessionManagerAddress,
272+
)
270273
const sessionSignature = await this._privateKey.signDigest(Bytes.fromHex(callHash))
271274
return {
272275
permissionIndex: BigInt(permissionIndex),

packages/wallet/core/src/signers/session/implicit.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,13 @@ export class Implicit implements ImplicitSessionSigner {
115115
if (!isSupported) {
116116
throw new Error('Unsupported call')
117117
}
118-
const useDeprecatedHash =
119-
Address.isEqual(sessionManagerAddress, Extensions.Dev1.sessions) ||
120-
Address.isEqual(sessionManagerAddress, Extensions.Dev2.sessions)
121-
const callHash = SessionSignature.hashCallWithReplayProtection(payload, callIdx, chainId, useDeprecatedHash)
118+
const callHash = SessionSignature.hashCallWithReplayProtection(
119+
wallet,
120+
payload,
121+
callIdx,
122+
chainId,
123+
sessionManagerAddress,
124+
)
122125
const sessionSignature = await this._privateKey.signDigest(Bytes.fromHex(callHash))
123126
return {
124127
attestation: this._attestation,

packages/wallet/primitives/src/session-signature.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Address, Bytes, Hash, Hex } from 'ox'
2+
import { Attestation, Extensions, Payload } from './index.js'
23
import { MAX_PERMISSIONS_COUNT } from './permission.js'
34
import {
45
decodeSessionsTopology,
@@ -10,7 +11,6 @@ import {
1011
} from './session-config.js'
1112
import { RSY } from './signature.js'
1213
import { minBytesFor, packRSY, unpackRSY } from './utils.js'
13-
import { Attestation, Payload } from './index.js'
1414

1515
export type ImplicitSessionCallSignature = {
1616
attestation: Attestation.Attestation
@@ -273,20 +273,37 @@ export function decodeSessionSignature(encodedSignatures: Bytes.Bytes): {
273273

274274
// Call encoding
275275

276+
/**
277+
* Hashes a call with replay protection parameters.
278+
* @param payload The payload to hash.
279+
* @param callIdx The index of the call to hash.
280+
* @param chainId The chain ID. Use 0 when noChainId enabled.
281+
* @param sessionManagerAddress The session manager address to compile the hash for. Only required to support deprecated hash encodings for Dev1, Dev2 and Rc3.
282+
* @returns The hash of the call with replay protection parameters for sessions.
283+
*/
276284
export function hashCallWithReplayProtection(
285+
wallet: Address.Address,
277286
payload: Payload.Calls,
278287
callIdx: number,
279288
chainId: number,
280-
skipCallIdx: boolean = false, // Deprecated. Dev1 and Dev2 support
289+
sessionManagerAddress?: Address.Address,
281290
): Hex.Hex {
282291
const call = payload.calls[callIdx]!
292+
// Support deprecated hashes for Dev1, Dev2 and Rc3
293+
const ignoreCallIdx =
294+
sessionManagerAddress &&
295+
(Address.isEqual(sessionManagerAddress, Extensions.Dev1.sessions) ||
296+
Address.isEqual(sessionManagerAddress, Extensions.Dev2.sessions))
297+
const ignoreWallet =
298+
ignoreCallIdx || (sessionManagerAddress && Address.isEqual(sessionManagerAddress, Extensions.Rc3.sessions))
283299
return Hex.fromBytes(
284300
Hash.keccak256(
285301
Bytes.concat(
302+
ignoreWallet ? Bytes.from([]) : Bytes.fromHex(wallet),
286303
Bytes.fromNumber(chainId, { size: 32 }),
287304
Bytes.fromNumber(payload.space, { size: 32 }),
288305
Bytes.fromNumber(payload.nonce, { size: 32 }),
289-
skipCallIdx ? Bytes.from([]) : Bytes.fromNumber(callIdx, { size: 32 }),
306+
ignoreCallIdx ? Bytes.from([]) : Bytes.fromNumber(callIdx, { size: 32 }),
290307
Bytes.fromHex(Payload.hashCall(call)),
291308
),
292309
),

packages/wallet/primitives/test/session-signature.test.ts

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
sessionCallSignatureToJson,
2222
} from '../src/session-signature.js'
2323
import { RSY } from '../src/signature.js'
24+
import { Extensions } from '../src/index.js'
2425

2526
describe('Session Signature', () => {
2627
// Test data
@@ -446,22 +447,23 @@ describe('Session Signature', () => {
446447
describe('Helper Functions', () => {
447448
describe('hashCallWithReplayProtection', () => {
448449
it('should hash call with replay protection parameters', () => {
449-
const result = hashCallWithReplayProtection(samplePayload, 0, testChainId)
450+
const result = hashCallWithReplayProtection(testAddress1, samplePayload, 0, testChainId)
450451

451452
expect(result).toMatch(/^0x[0-9a-f]{64}$/) // 32-byte hex string
452453
expect(Hex.size(result)).toBe(32)
453454
})
454455

455456
it('should produce different hashes for different chain IDs', () => {
456-
const hash1 = hashCallWithReplayProtection(samplePayload, 0, ChainId.MAINNET)
457-
const hash2 = hashCallWithReplayProtection(samplePayload, 0, ChainId.POLYGON)
457+
const hash1 = hashCallWithReplayProtection(testAddress1, samplePayload, 0, ChainId.MAINNET)
458+
const hash2 = hashCallWithReplayProtection(testAddress1, samplePayload, 0, ChainId.POLYGON)
458459

459460
expect(hash1).not.toBe(hash2)
460461
})
461462

462463
it('should produce different hashes for different spaces', () => {
463-
const hash1 = hashCallWithReplayProtection(samplePayload, 0, testChainId)
464+
const hash1 = hashCallWithReplayProtection(testAddress1, samplePayload, 0, testChainId)
464465
const hash2 = hashCallWithReplayProtection(
466+
testAddress1,
465467
{ ...samplePayload, space: samplePayload.space + 1n },
466468
0,
467469
testChainId,
@@ -471,8 +473,9 @@ describe('Session Signature', () => {
471473
})
472474

473475
it('should produce different hashes for different nonces', () => {
474-
const hash1 = hashCallWithReplayProtection(samplePayload, 0, testChainId)
476+
const hash1 = hashCallWithReplayProtection(testAddress1, samplePayload, 0, testChainId)
475477
const hash2 = hashCallWithReplayProtection(
478+
testAddress1,
476479
{ ...samplePayload, nonce: samplePayload.nonce + 1n },
477480
0,
478481
testChainId,
@@ -488,17 +491,51 @@ describe('Session Signature', () => {
488491
}
489492
const payload2 = { ...samplePayload, calls: [call2] }
490493

491-
const hash1 = hashCallWithReplayProtection(samplePayload, 0, testChainId)
492-
const hash2 = hashCallWithReplayProtection(payload2, 0, testChainId)
494+
const hash1 = hashCallWithReplayProtection(testAddress1, samplePayload, 0, testChainId)
495+
const hash2 = hashCallWithReplayProtection(testAddress1, payload2, 0, testChainId)
493496

494497
expect(hash1).not.toBe(hash2)
495498
})
496499

500+
it('should produce different hashes for different wallets', () => {
501+
const payload = { ...samplePayload, calls: [sampleCall, sampleCall] }
502+
503+
const hash1 = hashCallWithReplayProtection(testAddress1, payload, 0, testChainId)
504+
const hash2 = hashCallWithReplayProtection(testAddress2, payload, 0, testChainId)
505+
506+
expect(hash1).not.toBe(hash2)
507+
})
508+
509+
it('should NOT produce different hashes for different wallets when using deprecated hash encoding for Dev1 and Dev2', () => {
510+
// This is ONLY for backward compatibility with Dev1 and Dev2
511+
// This is exploitable and should not be used in practice
512+
const payload = { ...samplePayload, calls: [sampleCall, sampleCall] }
513+
514+
const hash1 = hashCallWithReplayProtection(testAddress1, payload, 0, testChainId, Extensions.Dev1.sessions)
515+
const hash2 = hashCallWithReplayProtection(testAddress2, payload, 0, testChainId, Extensions.Dev2.sessions)
516+
517+
expect(hash1).toBe(hash2)
518+
})
519+
520+
it('should produce different hashes for different wallets when using deprecated hash encoding for Dev1/2, Rc3 and latest', () => {
521+
// This is ONLY for backward compatibility with Rc3
522+
// This is exploitable and should not be used in practice
523+
const payload = { ...samplePayload, calls: [sampleCall, sampleCall] }
524+
525+
const hash1 = hashCallWithReplayProtection(testAddress1, payload, 0, testChainId, Extensions.Dev1.sessions)
526+
const hash2 = hashCallWithReplayProtection(testAddress2, payload, 0, testChainId, Extensions.Rc3.sessions)
527+
const hash3 = hashCallWithReplayProtection(testAddress2, payload, 0, testChainId)
528+
529+
expect(hash1).not.toBe(hash2)
530+
expect(hash1).not.toBe(hash3)
531+
expect(hash2).not.toBe(hash3)
532+
})
533+
497534
it('should produce different hashes for same call at different index', () => {
498535
const payload = { ...samplePayload, calls: [sampleCall, sampleCall] }
499536

500-
const hash1 = hashCallWithReplayProtection(payload, 0, testChainId)
501-
const hash2 = hashCallWithReplayProtection(payload, 1, testChainId)
537+
const hash1 = hashCallWithReplayProtection(testAddress1, payload, 0, testChainId)
538+
const hash2 = hashCallWithReplayProtection(testAddress1, payload, 1, testChainId)
502539

503540
expect(hash1).not.toBe(hash2)
504541
})
@@ -508,15 +545,15 @@ describe('Session Signature', () => {
508545
// This is exploitable and should not be used in practice
509546
const payload = { ...samplePayload, calls: [sampleCall, sampleCall] }
510547

511-
const hash1 = hashCallWithReplayProtection(payload, 0, testChainId, true)
512-
const hash2 = hashCallWithReplayProtection(payload, 1, testChainId, true)
548+
const hash1 = hashCallWithReplayProtection(testAddress1, payload, 0, testChainId, Extensions.Dev1.sessions)
549+
const hash2 = hashCallWithReplayProtection(testAddress1, payload, 1, testChainId, Extensions.Dev1.sessions)
513550

514551
expect(hash1).toBe(hash2)
515552
})
516553

517554
it('should be deterministic', () => {
518-
const hash1 = hashCallWithReplayProtection(samplePayload, 0, testChainId)
519-
const hash2 = hashCallWithReplayProtection(samplePayload, 0, testChainId)
555+
const hash1 = hashCallWithReplayProtection(testAddress1, samplePayload, 0, testChainId)
556+
const hash2 = hashCallWithReplayProtection(testAddress1, samplePayload, 0, testChainId)
520557

521558
expect(hash1).toBe(hash2)
522559
})
@@ -527,6 +564,7 @@ describe('Session Signature', () => {
527564
const largeNonce = 2n ** 24n
528565

529566
const result = hashCallWithReplayProtection(
567+
testAddress1,
530568
{ ...samplePayload, space: largeSpace, nonce: largeNonce },
531569
0,
532570
largeChainId,
@@ -535,7 +573,7 @@ describe('Session Signature', () => {
535573
})
536574

537575
it('should handle zero values', () => {
538-
const result = hashCallWithReplayProtection({ ...samplePayload, space: 0n, nonce: 0n }, 0, 0)
576+
const result = hashCallWithReplayProtection(testAddress1, { ...samplePayload, space: 0n, nonce: 0n }, 0, 0)
539577
expect(result).toMatch(/^0x[0-9a-f]{64}$/)
540578
})
541579

@@ -546,7 +584,7 @@ describe('Session Signature', () => {
546584
}
547585
const payload = { ...samplePayload, calls: [callWithEmptyData] }
548586

549-
const result = hashCallWithReplayProtection(payload, 0, testChainId)
587+
const result = hashCallWithReplayProtection(testAddress1, payload, 0, testChainId)
550588
expect(result).toMatch(/^0x[0-9a-f]{64}$/)
551589
})
552590

@@ -557,8 +595,8 @@ describe('Session Signature', () => {
557595
}
558596
const payload = { ...samplePayload, calls: [delegateCall] }
559597

560-
const hash1 = hashCallWithReplayProtection(samplePayload, 0, testChainId)
561-
const hash2 = hashCallWithReplayProtection(payload, 0, testChainId)
598+
const hash1 = hashCallWithReplayProtection(testAddress1, samplePayload, 0, testChainId)
599+
const hash2 = hashCallWithReplayProtection(testAddress1, payload, 0, testChainId)
562600

563601
expect(hash1).not.toBe(hash2)
564602
})
@@ -735,12 +773,15 @@ describe('Session Signature', () => {
735773
const calls: Payload.Call[] = [
736774
sampleCall,
737775
{ ...sampleCall, to: testAddress2 },
776+
{ ...sampleCall, to: testAddress2 }, // Repeat call
738777
{ ...sampleCall, value: 500000000000000000n },
739778
]
740779
const payload = { ...samplePayload, calls: calls }
741780

742781
// Generate hashes for each call
743-
const hashes = calls.map((call) => hashCallWithReplayProtection(payload, calls.indexOf(call), testChainId))
782+
const hashes = calls.map((call) =>
783+
hashCallWithReplayProtection(testAddress1, payload, calls.indexOf(call), testChainId),
784+
)
744785

745786
// All hashes should be valid and different
746787
for (let i = 0; i < hashes.length; i++) {

0 commit comments

Comments
 (0)