Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/core/signers/railgun-signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
buildGetRailgunAddress,
buildRailgunEip7702Bip32Path,
buildRailgunEthereumBip32Path,
encodeRailgunEthereumPathSuffixFromBip32Path,
buildGetEthereumPublicKey,
buildSignEip7702Authorization,
buildSignEthereumTxHash,
Expand Down Expand Up @@ -297,7 +298,7 @@ export class RailgunSigner {
this,
{
railgunWalletID: 'railgun-signer',
railgunAccountIndex: this.account,
railgunAccountIndex: request.railgunAccountIndex ?? this.account,
chainId: BigInt(request.chainId),
ephemeralIndex: request.ephemeralIndex,
},
Expand All @@ -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<EthereumSignatureParts> {
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({
Expand Down
10 changes: 8 additions & 2 deletions src/core/transport/apdu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
4 changes: 4 additions & 0 deletions src/sdk/engine/railgun-7702-hooked-signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). 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;
/** Path word W0 — the account index. Defaults to the signer's account. */
readonly railgunAccountIndex?: number;
};

export type RailgunRelayAdapt7702SignerRequest = {
Expand Down
14 changes: 14 additions & 0 deletions test/unit/apdu-embedded-eth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
buildSignEip7702Authorization,
buildSignEthereumTxHash,
encodeBip32Path,
encodeRailgunEthereumPathSuffix,
parseEthereumSignatureResponse,
} from '../../src/core/transport/apdu.js';

Expand Down Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions test/unit/railgun-signer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -263,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));
Expand Down
Loading