From d2ae91bd3abbc9b369684dae00be2366ce6cde73 Mon Sep 17 00:00:00 2001 From: zy0n Date: Fri, 24 Jul 2026 18:13:14 +0000 Subject: [PATCH 1/3] Make the 7702 derivation path fully customizable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The EOA path is [account(W0), chainId(W1), ephemeralIndex(W2)] — chainId is part of the derivation, so each chain yields a distinct EOA and a wallet can operate on multiple chains at once without reusing a 7702 address. All three words are already caller-settable through prepareEthereumSigner, the engine signer provider, and the controller; the one gap was get7702Signer, which hard-coded the account. Expose it via an optional railgunAccountIndex on Railgun7702SignerRequest (defaulting to the signer's account). Lock the chain-scoped, per-word-customizable behavior with a multi-chain unit test. --- src/core/signers/railgun-signer.ts | 2 +- src/sdk/engine/railgun-7702-hooked-signer.ts | 4 ++++ test/unit/apdu-embedded-eth.test.ts | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/core/signers/railgun-signer.ts b/src/core/signers/railgun-signer.ts index 87aba8b..5b16aa0 100644 --- a/src/core/signers/railgun-signer.ts +++ b/src/core/signers/railgun-signer.ts @@ -297,7 +297,7 @@ export class RailgunSigner { this, { railgunWalletID: 'railgun-signer', - railgunAccountIndex: this.account, + railgunAccountIndex: request.railgunAccountIndex ?? this.account, chainId: BigInt(request.chainId), ephemeralIndex: request.ephemeralIndex, }, diff --git a/src/sdk/engine/railgun-7702-hooked-signer.ts b/src/sdk/engine/railgun-7702-hooked-signer.ts index fe8556e..bbfd20c 100644 --- a/src/sdk/engine/railgun-7702-hooked-signer.ts +++ b/src/sdk/engine/railgun-7702-hooked-signer.ts @@ -60,8 +60,12 @@ export type RailgunRelayAdapt7702HookedSigner = { export type Railgun7702Signer = RailgunRelayAdapt7702HookedSigner; export type Railgun7702SignerRequest = { + /** Path word W1 — chain-scopes the EOA (a distinct address per chain). */ readonly chainId: number | bigint | string; + /** Path word W2 — the ephemeral/rotating index within an (account, chain). */ readonly ephemeralIndex: number; + /** Path word W0 — the account index. Defaults to the signer's account. */ + readonly railgunAccountIndex?: number; }; export type RailgunRelayAdapt7702SignerRequest = { diff --git a/test/unit/apdu-embedded-eth.test.ts b/test/unit/apdu-embedded-eth.test.ts index 6173aca..97af626 100644 --- a/test/unit/apdu-embedded-eth.test.ts +++ b/test/unit/apdu-embedded-eth.test.ts @@ -7,6 +7,7 @@ import { buildSignEip7702Authorization, buildSignEthereumTxHash, encodeBip32Path, + encodeRailgunEthereumPathSuffix, parseEthereumSignatureResponse, } from '../../src/core/transport/apdu.js'; @@ -49,6 +50,19 @@ describe('embedded Ethereum APDUs', () => { ); }); + it('derives a distinct chain-scoped 7702 path per chain (multi-chain, all words customizable)', () => { + // chainId is path word W1, so each chain yields a different EOA slot — a wallet + // can run on many chains at once without reusing the same 7702 address. + const eth = hex(encodeRailgunEthereumPathSuffix({ railgunAccountIndex: 0, chainId: 1n, ephemeralIndex: 0 })); + const arb = hex(encodeRailgunEthereumPathSuffix({ railgunAccountIndex: 0, chainId: 42161n, ephemeralIndex: 0 })); + expect(eth).toBe('00000000' + '00000001' + '00000000'); // W0=account 0, W1=chainId 1, W2=index 0 + expect(arb).toBe('00000000' + '0000a4b1' + '00000000'); // W1=chainId 42161 + expect(eth).not.toBe(arb); + // account (W0) and ephemeralIndex (W2) are independent, caller-customizable axes: + expect(hex(encodeRailgunEthereumPathSuffix({ railgunAccountIndex: 2, chainId: 1n, ephemeralIndex: 5 }))) + .toBe('00000002' + '00000001' + '00000005'); + }); + it('rejects path indexes that would collide with hardened components', () => { expect(() => buildRailgunEthereumBip32Path({ railgunAccountIndex: 0x8000_0000, From 73e35777f93804591d8444a9319b2e3e5093f0fd Mon Sep 17 00:00:00 2001 From: zy0n Date: Fri, 24 Jul 2026 18:23:18 +0000 Subject: [PATCH 2/3] Lock the 7702 account override and document the chainId path-word range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review of the customizable-path change: - Add a get7702Signer test that a custom railgunAccountIndex overrides the signer's own account (the branch the change exists to enable) — a revert to the hard-coded account would now fail the suite, not pass silently. - Document that each path word (account/chainId/ephemeralIndex) is a hardened BIP-32 index and must fit in 31 bits, so EVM chains with chainId >= 2**31 are rejected fail-closed (never collapsed onto another chain's slot). --- src/core/transport/apdu.ts | 10 +++++-- src/sdk/engine/railgun-7702-hooked-signer.ts | 2 +- test/unit/railgun-signer.test.ts | 28 ++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/core/transport/apdu.ts b/src/core/transport/apdu.ts index 409b564..b02c91e 100644 --- a/src/core/transport/apdu.ts +++ b/src/core/transport/apdu.ts @@ -53,10 +53,16 @@ export const RAILGUN_EIP7702_BIP32_PATH = [ ] as const; export type RailgunEthereumPathRequest = { + /** Account index — path word W0 (must fit in 31 bits). */ readonly railgunAccountIndex: number; - /** Chain/domain path suffix used by the firmware for the 7702 EOA. */ + /** + * Chain id — path word W1; chain-scopes the derived 7702 EOA (a distinct + * address per chain). Each path word is a hardened BIP-32 index, so this must + * fit in 31 bits: EVM chains with `chainId >= 2**31` are not supported and are + * rejected (fail-closed — never silently collapsed onto another chain's slot). + */ readonly chainId: number | bigint; - /** Ephemeral path suffix used by the firmware for the 7702 EOA. */ + /** Ephemeral/rotating index within an (account, chain) — path word W2 (must fit in 31 bits). */ readonly ephemeralIndex: number; }; diff --git a/src/sdk/engine/railgun-7702-hooked-signer.ts b/src/sdk/engine/railgun-7702-hooked-signer.ts index bbfd20c..7bd117b 100644 --- a/src/sdk/engine/railgun-7702-hooked-signer.ts +++ b/src/sdk/engine/railgun-7702-hooked-signer.ts @@ -60,7 +60,7 @@ export type RailgunRelayAdapt7702HookedSigner = { export type Railgun7702Signer = RailgunRelayAdapt7702HookedSigner; export type Railgun7702SignerRequest = { - /** Path word W1 — chain-scopes the EOA (a distinct address per chain). */ + /** Path word W1 — chain-scopes the EOA (a distinct address per chain). Must fit in 31 bits (chains >= 2**31 unsupported). */ readonly chainId: number | bigint | string; /** Path word W2 — the ephemeral/rotating index within an (account, chain). */ readonly ephemeralIndex: number; diff --git a/test/unit/railgun-signer.test.ts b/test/unit/railgun-signer.test.ts index 89f3ffe..e5b1274 100644 --- a/test/unit/railgun-signer.test.ts +++ b/test/unit/railgun-signer.test.ts @@ -243,6 +243,34 @@ describe('RailgunSigner', () => { expect(Buffer.from(transport.sentCommands[1]?.data?.slice(0, 12) ?? new Uint8Array()).toString('hex')).toBe('000000020000a4b100000007'); }); + it('honors a custom railgunAccountIndex in get7702Signer (override wins over this.account)', async () => { + // Signer account is 2, but the request overrides W0 to 5 — the derivation + // and the authorization must both use account 5, not the signer's 2. + signer = new RailgunSigner({ transport, account: 2 }); + const publicKey = new Uint8Array(Buffer.from( + '0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798' + + '483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8', + 'hex', + )); + transport.enqueueResponse(successResponse(publicKey)); + transport.enqueueResponse(ethereumSignatureResponse(1)); + + const railgun7702Signer = await signer.get7702Signer({ + chainId: '42161', + ephemeralIndex: 7, + railgunAccountIndex: 5, + }); + await railgun7702Signer.authorize({ + address: '0x1111111111111111111111111111111111111111', + chainId: 42161n, + nonce: '9', + }); + + // W0 must be the override (5), NOT the signer's account (2). + expect(Buffer.from(transport.sentCommands[0]?.data ?? new Uint8Array()).toString('hex')).toBe('000000050000a4b100000007'); + expect(Buffer.from(transport.sentCommands[1]?.data?.slice(0, 12) ?? new Uint8Array()).toString('hex')).toBe('000000050000a4b100000007'); + }); + it('rejects EIP-7702 authorization when the prepared session chain differs', async () => { await expect(signer.signEip7702Authorization({ session: { From 1298129c3fd83c1d877beaf9af7b570c29c845ee Mon Sep 17 00:00:00 2001 From: zy0n Date: Fri, 24 Jul 2026 18:30:30 +0000 Subject: [PATCH 3/3] Reject an explicit 7702 path whose chainId word disagrees with the auth chainId Defense-in-depth for chain-scoping: when a caller passes an explicit derivation path to signEip7702Authorization, assert its chainId word (W1) equals the authorization's target chainId, so a path can't derive one chain's EOA while the authorization targets another. Session and fallback paths are built from the chainId and are unaffected. --- src/core/signers/railgun-signer.ts | 24 ++++++++++++++++++++++++ test/unit/railgun-signer.test.ts | 14 ++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/core/signers/railgun-signer.ts b/src/core/signers/railgun-signer.ts index 5b16aa0..6944c7f 100644 --- a/src/core/signers/railgun-signer.ts +++ b/src/core/signers/railgun-signer.ts @@ -24,6 +24,7 @@ import { buildGetRailgunAddress, buildRailgunEip7702Bip32Path, buildRailgunEthereumBip32Path, + encodeRailgunEthereumPathSuffixFromBip32Path, buildGetEthereumPublicKey, buildSignEip7702Authorization, buildSignEthereumTxHash, @@ -314,12 +315,35 @@ export class RailgunSigner { } } + /** + * Chain-scoping guard for an explicitly-supplied path: the EOA is derived from + * the path (whose word W1 is the chainId), and the authorization is signed + * against the same chainId in the 8-byte field. Reject an explicit `path` whose + * W1 disagrees with the authorization chainId — otherwise a caller could derive + * one chain's EOA but authorize on another, defeating chain-scoping. Session and + * fallback paths are built from the chainId, so they always match; only an + * explicit `path` can diverge, so that is the only case guarded here. + */ + private assertPathChainId(path: readonly number[], chainId: bigint): void { + const suffix = encodeRailgunEthereumPathSuffixFromBip32Path(path); + const pathChainId = BigInt(new DataView(suffix.buffer, suffix.byteOffset, suffix.byteLength).getUint32(4, false)); + if (pathChainId !== chainId) { + throw new HWError( + HWErrorCode.VALIDATION_DERIVATION_INDEX, + `7702 derivation path chainId (W1=${String(pathChainId)}) does not match the authorization chainId (${String(chainId)}).`, + ); + } + } + async signEip7702Authorization(request: Eip7702AuthorizationRequest): Promise { this.requireCapability( (capabilities) => capabilities.eip7702Authorization, 'RAILGUN app does not advertise EIP-7702 authorization signing support.', ); this.assertSessionChainId(request.session, request.chainId); + if (request.path !== undefined) { + this.assertPathChainId(request.path, request.chainId); + } const response = await this.transport.send(buildSignEip7702Authorization({ ...request, path: request.path ?? request.session?.path ?? buildRailgunEthereumBip32Path({ diff --git a/test/unit/railgun-signer.test.ts b/test/unit/railgun-signer.test.ts index e5b1274..92fe2ba 100644 --- a/test/unit/railgun-signer.test.ts +++ b/test/unit/railgun-signer.test.ts @@ -291,6 +291,20 @@ describe('RailgunSigner', () => { expect(transport.sentCommands).toHaveLength(0); }); + it('rejects EIP-7702 authorization when an explicit path chainId (W1) differs from the auth chainId', async () => { + // Explicit path derives the chain-1 EOA (W1=1) but the authorization targets chain 137 — + // chain-scoping guard must reject rather than derive one chain and authorize another. + await expect(signer.signEip7702Authorization({ + path: [0x8000_1e16, 0x8000_07c0, 0x8000_0000, 1, 0], + chainId: 137n, + contractAddress: new Uint8Array(20).fill(0x11), + nonce: 7n, + })).rejects.toMatchObject({ + code: HWErrorCode.VALIDATION_DERIVATION_INDEX, + }); + expect(transport.sentCommands).toHaveLength(0); + }); + it('signs Ethereum tx hashes in gated blind-signing mode', async () => { signer = new RailgunSigner({ transport, account: 7 }); transport.enqueueResponse(ethereumSignatureResponse(0));