From 3ba7f2aeea25d6461b4ef9c37796aaf937760e38 Mon Sep 17 00:00:00 2001 From: zy0n Date: Fri, 24 Jul 2026 14:54:48 +0000 Subject: [PATCH 1/2] feat(apdu): add viewing-public-key and RAILGUN-address device accessors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RAILGUN firmware 1.6.1 adds two display-and-confirm read commands. Expose them as additive, capability-gated accessors: - VIEWING_PUBLIC_KEY (INS 0x10, P1=0x01): returns the 32-byte compressed Ed25519 viewing *public* key. A display/verify accessor that does not export the viewing secret; wallet loading still uses the viewing private key (INS 0x13). - RAILGUN_ADDRESS (INS 0x14, P1=0x01): returns the 127-byte ASCII 0zk1… address for on-device cross-check of the host-derived address. Extends the RAILGUN profile with the two command definitions plus a railgunClearSign capability flag (reserved for the forthcoming CLEAR_SIGN protocol), marks CLEAR_SIGN experimental in the capability map, and adds builders, length-checked response parsers, and RailgunSigner methods. Locked with golden-vector unit tests from the firmware spec. --- src/core/capabilities.ts | 2 + src/core/signers/railgun-signer.ts | 39 ++++++++++++ src/core/transport/apdu-profile.ts | 21 +++++++ src/core/transport/apdu.ts | 58 ++++++++++++++++++ src/index.ts | 6 ++ src/validation/apdu-response.ts | 43 +++++++++++++ test/unit/apdu-embedded-eth.test.ts | 3 + test/unit/capabilities.test.ts | 4 ++ test/unit/railgun-address.test.ts | 94 +++++++++++++++++++++++++++++ test/unit/viewing-pubkey.test.ts | 83 +++++++++++++++++++++++++ 10 files changed, 353 insertions(+) create mode 100644 test/unit/railgun-address.test.ts create mode 100644 test/unit/viewing-pubkey.test.ts diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 47fec56..7feade9 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -27,6 +27,8 @@ export const CAPABILITY_STATUS = { keyAttestation: 'experimental', /** RAILGUN signing (BabyJubjub EdDSA) via the custom app. */ railgunSigning: 'experimental', + /** CLEAR_SIGN transact review protocol (INS 0x11) — new in firmware 1.6.1. */ + clearSign: 'experimental', /** Ethereum tx / message / EIP-712 signing. */ ethereumSigning: 'experimental', /** FROST / MPC threshold signing — NOT implemented on current firmware. */ diff --git a/src/core/signers/railgun-signer.ts b/src/core/signers/railgun-signer.ts index 5d08e48..87aba8b 100644 --- a/src/core/signers/railgun-signer.ts +++ b/src/core/signers/railgun-signer.ts @@ -20,6 +20,8 @@ import { buildGetPublicKey, buildSignHash, buildGetViewingKey, + buildGetViewingPublicKey, + buildGetRailgunAddress, buildRailgunEip7702Bip32Path, buildRailgunEthereumBip32Path, buildGetEthereumPublicKey, @@ -35,6 +37,8 @@ import { parseSignResponse, parsePublicKeyResponse, parseViewingKeyResponse, + parseViewingPublicKeyResponse, + parseRailgunAddressResponse, extractEchoedHash, } from '../../validation/apdu-response.js'; import { validateSignature } from '../../validation/signature.js'; @@ -89,6 +93,9 @@ function defaultCapabilities(): RailgunAppCapabilities { eip7702Authorization: false, ethereumTxHash: false, ethereumSigning: [], + viewingPublicKey: false, + railgunAddress: false, + railgunClearSign: false, }; } @@ -199,6 +206,38 @@ export class RailgunSigner { return deriveRailgunWalletArtifacts(spendingPublicKey, viewingPrivateKey); } + /** + * Get the compressed Ed25519 viewing *public* key from the device (INS 0x10). + * Display/verify accessor — requires an on-device confirmation and does NOT + * export the viewing secret. Wallet loading still uses `getWalletArtifacts()`. + * @returns 32 raw bytes (compressed Ed25519 point). + */ + async getViewingPublicKey(): Promise { + this.requireCapability( + (capabilities) => capabilities.viewingPublicKey, + 'RAILGUN app does not advertise viewing-public-key retrieval support.', + ); + const response = await this.transport.send(buildGetViewingPublicKey(this.account, this.profile)); + validateApduResponse(response); + return parseViewingPublicKeyResponse(response.data); + } + + /** + * Derive and display the canonical `0zk1…` address on the device (INS 0x14). + * Device-confirmed cross-check of the host-derived address — requires an + * on-device confirmation. + * @returns the 127-character `0zk1…` address string. + */ + async getRailgunAddress(): Promise { + this.requireCapability( + (capabilities) => capabilities.railgunAddress, + 'RAILGUN app does not advertise RAILGUN-address derivation support.', + ); + const response = await this.transport.send(buildGetRailgunAddress(this.account, this.profile)); + validateApduResponse(response); + return parseRailgunAddressResponse(response.data); + } + private async getEthereumPublicKeyAtPath(request: RailgunEthereumPreloadRequest, display: boolean): Promise { this.requireCapability( (capabilities) => capabilities.ethereumAddress, diff --git a/src/core/transport/apdu-profile.ts b/src/core/transport/apdu-profile.ts index 11506fe..263a401 100644 --- a/src/core/transport/apdu-profile.ts +++ b/src/core/transport/apdu-profile.ts @@ -39,6 +39,12 @@ export type RailgunAppCapabilities = { readonly eip7702Authorization: boolean; readonly ethereumTxHash: boolean; readonly ethereumSigning: readonly EthereumSignCapability[]; + /** Device can return the compressed Ed25519 viewing *public* key (INS 0x10). */ + readonly viewingPublicKey: boolean; + /** Device can derive + display the canonical `0zk1…` address (INS 0x14). */ + readonly railgunAddress: boolean; + /** Device supports the stateful CLEAR_SIGN transact review protocol (INS 0x11). */ + readonly railgunClearSign: boolean; }; /** @@ -64,6 +70,15 @@ export type ApduProfile = { readonly sign: ApduSignDef; /** Get viewing private key. Response: privkey(32B). Optional — not all apps support this. */ readonly getViewingKey?: ApduCommandDef; + /** Get compressed Ed25519 viewing public key (INS 0x10). Response: pubkey(32B). */ + readonly getViewingPublicKey?: ApduCommandDef; + /** Derive + display the canonical `0zk1…` address (INS 0x14). Response: 127 ASCII bytes. */ + readonly getRailgunAddress?: ApduCommandDef; + /** + * CLEAR_SIGN transact review protocol (INS 0x11). Stateful: P1 selects the + * sub-command and the response length varies per sub-command; the quoted + * `responseLength` is the single-tx FINALIZE length (129B). */ + readonly clearSign?: ApduCommandDef; /** Get secp256k1 Ethereum public key. Response: uncompressed pubkey(65B). */ readonly getEthereumPublicKey?: ApduCommandDef; /** Sign an EIP-7702 authorization. Response: yParity(1B) + r(32B) + s(32B). */ @@ -88,11 +103,17 @@ export const RAILGUN_PROFILE: ApduProfile = { eip7702Authorization: true, ethereumTxHash: true, ethereumSigning: ['blind', 'clear'], + viewingPublicKey: true, + railgunAddress: true, + railgunClearSign: true, }, commands: { getPublicKey: { ins: 0x01, responseLength: 64 }, sign: { ins: 0x12, responseLength: 129, hasPrefix: true, echoesHash: true }, getViewingKey: { ins: 0x13, responseLength: 32 }, + getViewingPublicKey: { ins: 0x10, responseLength: 32 }, + getRailgunAddress: { ins: 0x14, responseLength: 127 }, + clearSign: { ins: 0x11, responseLength: 129 }, getEthereumPublicKey: { ins: 0x07, responseLength: 65 }, signEip7702Authorization: { ins: 0x08, responseLength: 65 }, signEthereumTxHash: { ins: 0x09, responseLength: 65 }, diff --git a/src/core/transport/apdu.ts b/src/core/transport/apdu.ts index 7e42bc6..cd355c1 100644 --- a/src/core/transport/apdu.ts +++ b/src/core/transport/apdu.ts @@ -135,6 +135,12 @@ export const RailgunAppINS = { SIGN_HASH: 0x12, /** Get viewing private key. Data: account(4B BE). Response: privkey(32B). */ GET_VIEWING_KEY: 0x13, + /** Get compressed Ed25519 viewing public key. Data: account(4B BE). Response: pubkey(32B). P1=0x01. */ + GET_VIEWING_PUBLIC_KEY: 0x10, + /** CLEAR_SIGN transact review protocol. Stateful; P1 selects the sub-command, P2=0x00. */ + CLEAR_SIGN: 0x11, + /** Derive + display the canonical `0zk1…` address. Data: account(4B BE). Response: 127 ASCII. P1=0x01. */ + GET_RAILGUN_ADDRESS: 0x14, // ─── Ethereum / EIP-7702 — matched to current embedded app demo ────── // EIP-7702 (SIGN_EIP7702_AUTHORIZATION) is UNDER DEVELOPMENT — see CAPABILITY_STATUS. @@ -285,6 +291,56 @@ export function buildGetViewingKey( }; } +/** + * Build GET_VIEWING_PUBLIC_KEY APDU (VIEWING_PUBKEY, INS 0x10). + * Returns the compressed Ed25519 viewing *public* key — 32 bytes. + * + * P1 is `0x01` (display + confirm): the device shows the account index and + * pubkey hex and returns the key only on Approve (Reject → `0x6985`). This is a + * display/verify accessor — it does NOT export the viewing secret. Wallet-artifact + * derivation still uses `buildGetViewingKey` (the private key, INS 0x13). + * @param account - Account index (default 0). + * @param profile - APDU profile (default RAILGUN_PROFILE). + */ +export function buildGetViewingPublicKey( + account = 0, + profile: ApduProfile = RAILGUN_PROFILE, +): ApduCommand { + const command = requiredCommand(profile.commands.getViewingPublicKey, profile, 'getViewingPublicKey'); + return { + cla: profile.cla, + ins: command.ins, + p1: 0x01, + p2: 0, + data: encodeAccountIndex(account), + }; +} + +/** + * Build GET_RAILGUN_ADDRESS APDU (RAILGUN_ADDRESS, INS 0x14). + * Derives and displays the canonical `0zk1…` address — 127 ASCII bytes. + * + * P1 is `0x01` (display + confirm; always required in prod): the device shows the + * same `0zk1…` string for out-of-band comparison and returns it only on Approve + * (Reject → `0x6985`). This is a device-confirmed cross-check of the address the + * host already derives in `wallet-artifacts.ts`; it does not replace it. + * @param account - Account index (default 0). + * @param profile - APDU profile (default RAILGUN_PROFILE). + */ +export function buildGetRailgunAddress( + account = 0, + profile: ApduProfile = RAILGUN_PROFILE, +): ApduCommand { + const command = requiredCommand(profile.commands.getRailgunAddress, profile, 'getRailgunAddress'); + return { + cla: profile.cla, + ins: command.ins, + p1: 0x01, + p2: 0, + data: encodeAccountIndex(account), + }; +} + export function buildGetEthereumPublicKey( request: number | RailgunEthereumPathRequest = 0, display = false, @@ -523,4 +579,6 @@ export function buildMpcReset(): ApduCommand { export const SIGN_RESPONSE_LENGTH = RAILGUN_PROFILE.commands.sign.responseLength; export const PUBLIC_KEY_RESPONSE_LENGTH = RAILGUN_PROFILE.commands.getPublicKey.responseLength; export const VIEWING_KEY_RESPONSE_LENGTH = RAILGUN_PROFILE.commands.getViewingKey!.responseLength; +export const VIEWING_PUBLIC_KEY_RESPONSE_LENGTH = RAILGUN_PROFILE.commands.getViewingPublicKey!.responseLength; +export const RAILGUN_ADDRESS_RESPONSE_LENGTH = RAILGUN_PROFILE.commands.getRailgunAddress!.responseLength; export const COMMITMENTS_RESPONSE_LENGTH = 128; // hiding.x(32) + hiding.y(32) + binding.x(32) + binding.y(32) diff --git a/src/index.ts b/src/index.ts index bd6a602..f8400d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -143,6 +143,8 @@ export { buildGetPublicKey, buildSignHash, buildGetViewingKey, + buildGetViewingPublicKey, + buildGetRailgunAddress, buildGetEthereumPublicKey, buildSignEip7702Authorization, buildSignEthereumTxHash, @@ -156,6 +158,8 @@ export { SIGN_RESPONSE_LENGTH, PUBLIC_KEY_RESPONSE_LENGTH, VIEWING_KEY_RESPONSE_LENGTH, + VIEWING_PUBLIC_KEY_RESPONSE_LENGTH, + RAILGUN_ADDRESS_RESPONSE_LENGTH, COMMITMENTS_RESPONSE_LENGTH, } from './core/transport/apdu.js'; export type { RailgunEthereumPathRequest, EthereumSignatureParts } from './core/transport/apdu.js'; @@ -182,6 +186,8 @@ export { parseSignResponse, parsePublicKeyResponse, parseViewingKeyResponse, + parseViewingPublicKeyResponse, + parseRailgunAddressResponse, extractEchoedHash, } from './validation/apdu-response.js'; diff --git a/src/validation/apdu-response.ts b/src/validation/apdu-response.ts index 57bf7d2..2bda0c0 100644 --- a/src/validation/apdu-response.ts +++ b/src/validation/apdu-response.ts @@ -13,6 +13,8 @@ import type { Signature } from '../core/connector/types.js'; import { PUBLIC_KEY_RESPONSE_LENGTH, VIEWING_KEY_RESPONSE_LENGTH, + VIEWING_PUBLIC_KEY_RESPONSE_LENGTH, + RAILGUN_ADDRESS_RESPONSE_LENGTH, } from '../core/transport/apdu.js'; /** @@ -107,6 +109,47 @@ export function parseViewingKeyResponse(data: Uint8Array): Uint8Array { return data.slice(); } +/** + * Parse a GET_VIEWING_PUBLIC_KEY response (INS 0x10). + * Expected: compressed Ed25519 viewing public key (32B). Returns a copy. + */ +export function parseViewingPublicKeyResponse(data: Uint8Array): Uint8Array { + if (data.length !== VIEWING_PUBLIC_KEY_RESPONSE_LENGTH) { + throw new HWError( + HWErrorCode.APDU_INVALID_RESPONSE, + `Expected ${String(VIEWING_PUBLIC_KEY_RESPONSE_LENGTH)} bytes for viewing public key, got ${String(data.length)}`, + ); + } + return data.slice(); +} + +/** + * Parse a GET_RAILGUN_ADDRESS response (INS 0x14). + * Expected: exactly 127 ASCII bytes (a `0zk1…` string, not NUL-terminated). + * The device fixes the width, so a non-127 length is a protocol error. + */ +export function parseRailgunAddressResponse(data: Uint8Array): string { + if (data.length !== RAILGUN_ADDRESS_RESPONSE_LENGTH) { + throw new HWError( + HWErrorCode.APDU_INVALID_RESPONSE, + `Expected ${String(RAILGUN_ADDRESS_RESPONSE_LENGTH)} bytes for RAILGUN address, got ${String(data.length)}`, + ); + } + for (const byte of data) { + if (byte < 0x20 || byte > 0x7e) { + throw new HWError( + HWErrorCode.APDU_INVALID_RESPONSE, + 'RAILGUN address response contains a non-printable-ASCII byte', + ); + } + } + let address = ''; + for (const byte of data) { + address += String.fromCharCode(byte); + } + return address; +} + /** * Convert a big-endian Uint8Array to bigint. */ diff --git a/test/unit/apdu-embedded-eth.test.ts b/test/unit/apdu-embedded-eth.test.ts index d7168eb..6173aca 100644 --- a/test/unit/apdu-embedded-eth.test.ts +++ b/test/unit/apdu-embedded-eth.test.ts @@ -21,6 +21,9 @@ describe('embedded Ethereum APDUs', () => { eip7702Authorization: true, ethereumTxHash: true, ethereumSigning: ['blind', 'clear'], + viewingPublicKey: true, + railgunAddress: true, + railgunClearSign: true, }); }); diff --git a/test/unit/capabilities.test.ts b/test/unit/capabilities.test.ts index a9ebb22..8792020 100644 --- a/test/unit/capabilities.test.ts +++ b/test/unit/capabilities.test.ts @@ -20,4 +20,8 @@ describe('CAPABILITY_STATUS', () => { expect(CAPABILITY_STATUS.installer).toBe('experimental'); expect(CAPABILITY_STATUS.keyAttestation).toBe('experimental'); }); + + it('marks CLEAR_SIGN experimental', () => { + expect(CAPABILITY_STATUS.clearSign).toBe('experimental'); + }); }); diff --git a/test/unit/railgun-address.test.ts b/test/unit/railgun-address.test.ts new file mode 100644 index 0000000..41cb8cb --- /dev/null +++ b/test/unit/railgun-address.test.ts @@ -0,0 +1,94 @@ +/** + * RAILGUN_ADDRESS (INS 0x14) — builder, parser, and signer accessor. + * + * Golden vector from RAILGUN-HW firmware 1.6.1 spec (js/README.md): + * e01401000400000000 = CLA e0 | INS 14 | P1 01 | P2 00 | Lc 04 | account 00000000 + * P1=0x01 is display+confirm; the device returns 127 ASCII bytes (a `0zk1…` + * string, not NUL-terminated) on Approve. + */ + +import { describe, expect, it } from 'vitest'; +import { + RAILGUN_CLA, + RailgunAppINS, + buildGetRailgunAddress, +} from '../../src/core/transport/apdu.js'; +import { parseRailgunAddressResponse } from '../../src/validation/apdu-response.js'; +import { serializeApdu } from '../../src/core/transport/apdu-wire.js'; +import { RailgunSigner } from '../../src/core/signers/railgun-signer.js'; +import { HWError, HWErrorCode } from '../../src/core/errors.js'; +import { StatusWord } from '../../src/core/transport/types.js'; +import { MockTransport } from '../integration/mock-transport.js'; + +function hex(bytes: Uint8Array): string { + return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +/** A 127-char `0zk1…` placeholder address as raw ASCII bytes. */ +function address127(): { bytes: Uint8Array; text: string } { + const text = `0zk1${'q'.repeat(123)}`; + const bytes = new Uint8Array(127); + for (let i = 0; i < 127; i++) bytes[i] = text.charCodeAt(i); + return { bytes, text }; +} + +describe('RAILGUN_ADDRESS (0x14)', () => { + describe('buildGetRailgunAddress', () => { + it('matches the spec golden vector for account 0', () => { + const cmd = buildGetRailgunAddress(); + expect(cmd.cla).toBe(RAILGUN_CLA); + expect(cmd.ins).toBe(RailgunAppINS.GET_RAILGUN_ADDRESS); + expect(cmd.p1).toBe(0x01); + expect(cmd.p2).toBe(0x00); + expect(hex(serializeApdu(cmd))).toBe('e01401000400000000'); + }); + + it('encodes the account index big-endian', () => { + expect(hex(serializeApdu(buildGetRailgunAddress(1)))).toBe('e01401000400000001'); + }); + }); + + describe('parseRailgunAddressResponse', () => { + it('decodes exactly 127 ASCII bytes into a string', () => { + const { bytes, text } = address127(); + expect(parseRailgunAddressResponse(bytes)).toBe(text); + }); + + it('rejects a wrong length (126 / 128)', () => { + expect(() => parseRailgunAddressResponse(new Uint8Array(126).fill(0x71))).toThrow(HWError); + expect(() => parseRailgunAddressResponse(new Uint8Array(128).fill(0x71))).toThrow(HWError); + }); + + it('rejects a non-printable byte', () => { + const bytes = new Uint8Array(127).fill(0x71); + bytes[10] = 0x00; + expect(() => parseRailgunAddressResponse(bytes)).toThrow(HWError); + }); + }); + + describe('RailgunSigner.getRailgunAddress', () => { + it('sends the 0x14 command and returns the address string', async () => { + const transport = new MockTransport(); + await transport.connect(); + const { bytes, text } = address127(); + transport.enqueueResponse({ data: bytes, statusWord: StatusWord.SUCCESS }); + + const signer = new RailgunSigner({ transport }); + const result = await signer.getRailgunAddress(); + + expect(result).toBe(text); + expect(transport.sentCommands[0]).toMatchObject({ ins: 0x14, p1: 0x01, p2: 0x00 }); + }); + + it('maps an on-device reject (0x6985) to APDU_REJECTED', async () => { + const transport = new MockTransport(); + await transport.connect(); + transport.enqueueResponse({ data: new Uint8Array(0), statusWord: StatusWord.USER_REJECTED }); + + const signer = new RailgunSigner({ transport }); + await expect(signer.getRailgunAddress()).rejects.toMatchObject({ + code: HWErrorCode.APDU_REJECTED, + }); + }); + }); +}); diff --git a/test/unit/viewing-pubkey.test.ts b/test/unit/viewing-pubkey.test.ts new file mode 100644 index 0000000..7512f33 --- /dev/null +++ b/test/unit/viewing-pubkey.test.ts @@ -0,0 +1,83 @@ +/** + * VIEWING_PUBKEY (INS 0x10) — builder, parser, and signer accessor. + * + * Golden vector from RAILGUN-HW firmware 1.6.1 spec (js/README.md): + * e01001000400000000 = CLA e0 | INS 10 | P1 01 | P2 00 | Lc 04 | account 00000000 + * P1=0x01 is display+confirm; the device returns 32B compressed Ed25519 on Approve. + */ + +import { describe, expect, it } from 'vitest'; +import { + RAILGUN_CLA, + RailgunAppINS, + buildGetViewingPublicKey, +} from '../../src/core/transport/apdu.js'; +import { parseViewingPublicKeyResponse } from '../../src/validation/apdu-response.js'; +import { serializeApdu } from '../../src/core/transport/apdu-wire.js'; +import { RailgunSigner } from '../../src/core/signers/railgun-signer.js'; +import { HWError, HWErrorCode } from '../../src/core/errors.js'; +import { StatusWord } from '../../src/core/transport/types.js'; +import { MockTransport } from '../integration/mock-transport.js'; + +function hex(bytes: Uint8Array): string { + return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +describe('VIEWING_PUBKEY (0x10)', () => { + describe('buildGetViewingPublicKey', () => { + it('matches the spec golden vector for account 0', () => { + const cmd = buildGetViewingPublicKey(); + expect(cmd.cla).toBe(RAILGUN_CLA); + expect(cmd.ins).toBe(RailgunAppINS.GET_VIEWING_PUBLIC_KEY); + expect(cmd.p1).toBe(0x01); + expect(cmd.p2).toBe(0x00); + expect(cmd.data).toEqual(new Uint8Array([0, 0, 0, 0])); + expect(hex(serializeApdu(cmd))).toBe('e01001000400000000'); + }); + + it('encodes the account index big-endian', () => { + expect(hex(serializeApdu(buildGetViewingPublicKey(1)))).toBe('e01001000400000001'); + }); + }); + + describe('parseViewingPublicKeyResponse', () => { + it('accepts exactly 32 bytes and returns a copy', () => { + const key = new Uint8Array(32).fill(0xcd); + const parsed = parseViewingPublicKeyResponse(key); + expect(parsed).toEqual(key); + expect(parsed).not.toBe(key); + }); + + it('rejects a wrong length', () => { + expect(() => parseViewingPublicKeyResponse(new Uint8Array(31))).toThrow(HWError); + expect(() => parseViewingPublicKeyResponse(new Uint8Array(33))).toThrow(HWError); + }); + }); + + describe('RailgunSigner.getViewingPublicKey', () => { + it('sends the 0x10 command and returns the 32-byte key', async () => { + const transport = new MockTransport(); + await transport.connect(); + const key = new Uint8Array(32).fill(0xab); + transport.enqueueResponse({ data: key, statusWord: StatusWord.SUCCESS }); + + const signer = new RailgunSigner({ transport }); + const result = await signer.getViewingPublicKey(); + + expect(result).toEqual(key); + expect(transport.sentCommands).toHaveLength(1); + expect(transport.sentCommands[0]).toMatchObject({ ins: 0x10, p1: 0x01, p2: 0x00 }); + }); + + it('maps an on-device reject (0x6985) to APDU_REJECTED', async () => { + const transport = new MockTransport(); + await transport.connect(); + transport.enqueueResponse({ data: new Uint8Array(0), statusWord: StatusWord.USER_REJECTED }); + + const signer = new RailgunSigner({ transport }); + await expect(signer.getViewingPublicKey()).rejects.toMatchObject({ + code: HWErrorCode.APDU_REJECTED, + }); + }); + }); +}); From 80d3adf68e0871a1b76dcf44b8ca4a93ea19c5be Mon Sep 17 00:00:00 2001 From: zy0n Date: Fri, 24 Jul 2026 15:01:42 +0000 Subject: [PATCH 2/2] feat(apdu): add CLEAR_SIGN transact protocol builders (INS 0x11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firmware 1.6.1 clear-signs a RAILGUN transact through a stateful, multi-step session where P1 selects the sub-command. Add pure APDU builders for the full single-tx flow — CS_INIT, NULLIFIER, BP_FIELDS, OUT_BROADCASTER, OUT_CHANGE, OUT_TRANSFER, OUT_UNSHIELD, FINALIZE — plus: - validateClearSignShape: enforces the device limits (n,m in [1,3], n+m <= 5). - encodeErc20TokenHash: 20-byte address -> 32-byte tokenHash. - parseClearSignFinalize: parses the 129-byte FINALIZE response (0x60 prefix + R8x/R8y/S + echoed msgHash) into an EdDSA signature + message hash. Builders are pure (request -> ApduCommand); session orchestration and engine wiring are intentionally not included yet. Range-checks minGasPrice (uint48), memo length (<= 32), and field widths. Locked with golden-vector tests that reproduce the firmware spec's worked 1x1 unshield example byte-for-byte, plus the documented multi-output layouts. --- src/core/transport/clear-sign-apdu.ts | 322 ++++++++++++++++++++++++++ src/index.ts | 29 +++ src/validation/apdu-response.ts | 36 +++ test/unit/clear-sign-apdus.test.ts | 226 ++++++++++++++++++ 4 files changed, 613 insertions(+) create mode 100644 src/core/transport/clear-sign-apdu.ts create mode 100644 test/unit/clear-sign-apdus.test.ts diff --git a/src/core/transport/clear-sign-apdu.ts b/src/core/transport/clear-sign-apdu.ts new file mode 100644 index 0000000..285863c --- /dev/null +++ b/src/core/transport/clear-sign-apdu.ts @@ -0,0 +1,322 @@ +/** + * CLEAR_SIGN transact protocol (INS 0x11) — pure APDU builders + shape validator. + * + * Firmware 1.6.1 clear-signs a RAILGUN transact through a stateful, multi-step + * session where P1 selects the sub-command and P2 is always 0x00. The host + * streams the transact shape (init), one nullifier per input, the bound-params + * fields, then one APDU per output, and finally FINALIZE — at which point the + * device shows the review and EdDSA-Poseidon signs on Approve. + * + * Canonical single-tx ordering: + * CS_INIT → NULLIFIER×n → BP_FIELDS → [OUT_BROADCASTER] → [OUT_CHANGE] + * → (OUT_TRANSFER | OUT_UNSHIELD) → FINALIZE + * + * These builders are pure (request → ApduCommand). Byte layouts and the worked + * golden vectors are from RAILGUN-HW/js/README.md and js/clear-sign-apdus.js. + * The session orchestration (sending them in order, collecting responses) and + * the FINALIZE-response parsing live elsewhere — see parseClearSignFinalize in + * validation/apdu-response.ts. + */ + +import type { ApduCommand } from './types.js'; +import type { ApduProfile } from './apdu-profile.js'; +import { RAILGUN_PROFILE } from './apdu-profile.js'; +import { encodeAccountIndex } from './apdu.js'; + +/** P1 sub-command selector for the CLEAR_SIGN (INS 0x11) session. */ +export const ClearSignP1 = { + INIT: 0x00, + BP_FIELDS: 0x10, + NULLIFIER: 0x20, + OUT_BROADCASTER: 0x30, + OUT_CHANGE: 0x31, + OUT_TRANSFER: 0x32, + OUT_UNSHIELD: 0x33, + FINALIZE: 0x40, +} as const; + +/** Memo cap — matches the device `CS_MAX_MEMO_LEN`. */ +export const CLEAR_SIGN_MAX_MEMO_LEN = 32; + +/** minGasPrice is a uint48 on the wire (narrowed post-audit S13). */ +export const CLEAR_SIGN_MIN_GAS_PRICE_MAX = 1n << 48n; + +/** Transfer output type — 0 = Transfer. */ +export const CLEAR_SIGN_OUTPUT_TYPE_TRANSFER = 0; + +// ─── Encoding helpers ───────────────────────────────────────────────────────── + +function encodeUintBE(value: bigint, byteLength: number, label: string): Uint8Array { + if (value < 0n || value >= 1n << BigInt(byteLength * 8)) { + throw new Error(`${label} must fit in an unsigned ${String(byteLength * 8)}-bit integer, got ${String(value)}`); + } + const out = new Uint8Array(byteLength); + let v = value; + for (let i = byteLength - 1; i >= 0; i--) { + out[i] = Number(v & 0xffn); + v >>= 8n; + } + return out; +} + +function assertBytes(value: Uint8Array, length: number, label: string): void { + if (value.length !== length) { + throw new Error(`${label} must be exactly ${String(length)} bytes, got ${String(value.length)}`); + } +} + +function clearSignIns(profile: ApduProfile): number { + const command = profile.commands.clearSign; + if (command === undefined) { + throw new Error(`Profile "${profile.name}" does not support clearSign`); + } + return command.ins; +} + +function concatBytes(...chunks: readonly Uint8Array[]): Uint8Array { + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +/** + * Encode an ERC-20 token address (20 bytes) as the 32-byte tokenHash the + * protocol uses: 12 zero bytes ‖ address. + */ +export function encodeErc20TokenHash(address: Uint8Array): Uint8Array { + assertBytes(address, 20, 'ERC-20 token address'); + const out = new Uint8Array(32); + out.set(address, 12); + return out; +} + +// ─── Shape validation ───────────────────────────────────────────────────────── + +/** + * Validate the transact (n inputs, m outputs) shape against the device limits: + * n,m ∈ [1,3] and n+m ≤ 5 (the device Poseidon msgHash arity is 2+n+m ≤ 7). + */ +export function validateClearSignShape(nIn: number, nOut: number): void { + for (const [label, value] of [['nIn', nIn], ['nOut', nOut]] as const) { + if (!Number.isInteger(value) || value < 1 || value > 3) { + throw new Error(`CLEAR_SIGN ${label} must be an integer in [1, 3], got ${String(value)}`); + } + } + if (nIn + nOut > 5) { + throw new Error(`CLEAR_SIGN shape n+m must be ≤ 5 (device Poseidon arity cap), got n=${String(nIn)} m=${String(nOut)}`); + } +} + +// ─── Request types ──────────────────────────────────────────────────────────── + +export type ClearSignInitRequest = { + readonly account?: number; + /** 32-byte merkle root. */ + readonly merkleRoot: Uint8Array; + /** Number of inputs (nullifiers), 1..3. */ + readonly nIn: number; + /** Number of device outputs, 1..3. */ + readonly nOut: number; +}; + +export type ClearSignBpFieldsRequest = { + readonly treeNumber: number; + /** uint48 — values ≥ 2^48 are rejected. */ + readonly minGasPrice: bigint; + readonly unshield: boolean; + readonly chainId: bigint; + /** 20-byte RelayAdapt contract; defaults to all-zero (non-adapt). */ + readonly adaptContract?: Uint8Array; + /** 32-byte adapt params; defaults to all-zero (non-adapt). */ + readonly adaptParams?: Uint8Array; +}; + +export type ClearSignBroadcasterOutput = { + /** 32-byte recipient master public key. */ + readonly recipientMasterPublicKey: Uint8Array; + /** 32-byte recipient viewing public key. */ + readonly recipientViewingPublicKey: Uint8Array; + /** 32-byte tokenHash (see encodeErc20TokenHash). */ + readonly tokenHash: Uint8Array; + readonly value: bigint; +}; + +export type ClearSignChangeOutput = { + readonly tokenHash: Uint8Array; + readonly value: bigint; +}; + +export type ClearSignTransferOutput = { + /** 127-character 0zk1… recipient address. */ + readonly recipient0zk: string; + readonly tokenHash: Uint8Array; + readonly value: bigint; + /** Output type byte (default 0 = Transfer). */ + readonly outputType?: number; + /** Optional memo, ≤ 32 bytes. The device generates the annotation itself. */ + readonly memo?: Uint8Array; +}; + +export type ClearSignUnshieldOutput = { + /** 20-byte on-chain recipient address. */ + readonly recipientAddress: Uint8Array; + readonly tokenHash: Uint8Array; + readonly value: bigint; +}; + +// ─── Builders ───────────────────────────────────────────────────────────────── + +/** CS_INIT (P1 0x00): account(4) ‖ merkleRoot(32) ‖ nIn(1) ‖ nOut(1). */ +export function buildClearSignInit( + request: ClearSignInitRequest, + profile: ApduProfile = RAILGUN_PROFILE, +): ApduCommand { + assertBytes(request.merkleRoot, 32, 'CLEAR_SIGN merkleRoot'); + validateClearSignShape(request.nIn, request.nOut); + const data = concatBytes( + encodeAccountIndex(request.account ?? 0), + request.merkleRoot, + new Uint8Array([request.nIn, request.nOut]), + ); + return { cla: profile.cla, ins: clearSignIns(profile), p1: ClearSignP1.INIT, p2: 0, data }; +} + +/** NULLIFIER (P1 0x20): one 32-byte nullifier per input. */ +export function buildClearSignNullifier( + nullifier: Uint8Array, + profile: ApduProfile = RAILGUN_PROFILE, +): ApduCommand { + assertBytes(nullifier, 32, 'CLEAR_SIGN nullifier'); + return { cla: profile.cla, ins: clearSignIns(profile), p1: ClearSignP1.NULLIFIER, p2: 0, data: nullifier.slice() }; +} + +/** + * BP_FIELDS (P1 0x10): treeNumber(2) ‖ minGasPrice(6) ‖ unshield(1) ‖ + * chainID(8) ‖ adaptContract(20) ‖ adaptParams(32) = 69 bytes. + */ +export function buildClearSignBpFields( + request: ClearSignBpFieldsRequest, + profile: ApduProfile = RAILGUN_PROFILE, +): ApduCommand { + if (request.minGasPrice < 0n || request.minGasPrice >= CLEAR_SIGN_MIN_GAS_PRICE_MAX) { + throw new Error(`CLEAR_SIGN minGasPrice must be a uint48 (< 2^48), got ${String(request.minGasPrice)}`); + } + const adaptContract = request.adaptContract ?? new Uint8Array(20); + const adaptParams = request.adaptParams ?? new Uint8Array(32); + assertBytes(adaptContract, 20, 'CLEAR_SIGN adaptContract'); + assertBytes(adaptParams, 32, 'CLEAR_SIGN adaptParams'); + const data = concatBytes( + encodeUintBE(BigInt(request.treeNumber), 2, 'treeNumber'), + encodeUintBE(request.minGasPrice, 6, 'minGasPrice'), + new Uint8Array([request.unshield ? 1 : 0]), + encodeUintBE(request.chainId, 8, 'chainId'), + adaptContract, + adaptParams, + ); + return { cla: profile.cla, ins: clearSignIns(profile), p1: ClearSignP1.BP_FIELDS, p2: 0, data }; +} + +/** + * OUT_BROADCASTER (P1 0x30): MPK(32) ‖ VKpub(32) ‖ tokenHash(32) ‖ value(32) ‖ + * ann_len(2)=0 ‖ memo_len(2)=0. The device generates the annotation/memo, so + * both length prefixes are sent empty (matches the reference). + */ +export function buildClearSignOutBroadcaster( + output: ClearSignBroadcasterOutput, + profile: ApduProfile = RAILGUN_PROFILE, +): ApduCommand { + assertBytes(output.recipientMasterPublicKey, 32, 'broadcaster recipientMasterPublicKey'); + assertBytes(output.recipientViewingPublicKey, 32, 'broadcaster recipientViewingPublicKey'); + assertBytes(output.tokenHash, 32, 'broadcaster tokenHash'); + const data = concatBytes( + output.recipientMasterPublicKey, + output.recipientViewingPublicKey, + output.tokenHash, + encodeUintBE(output.value, 32, 'broadcaster value'), + new Uint8Array([0, 0, 0, 0]), + ); + return { cla: profile.cla, ins: clearSignIns(profile), p1: ClearSignP1.OUT_BROADCASTER, p2: 0, data }; +} + +/** OUT_CHANGE (P1 0x31): tokenHash(32) ‖ value(32) ‖ ann_len(2)=0 ‖ memo_len(2)=0. */ +export function buildClearSignOutChange( + output: ClearSignChangeOutput, + profile: ApduProfile = RAILGUN_PROFILE, +): ApduCommand { + assertBytes(output.tokenHash, 32, 'change tokenHash'); + const data = concatBytes( + output.tokenHash, + encodeUintBE(output.value, 32, 'change value'), + new Uint8Array([0, 0, 0, 0]), + ); + return { cla: profile.cla, ins: clearSignIns(profile), p1: ClearSignP1.OUT_CHANGE, p2: 0, data }; +} + +/** + * OUT_TRANSFER (P1 0x32): recipient0zk(127 ASCII) ‖ tokenHash(32) ‖ value(32) ‖ + * outputType(1) ‖ memo_len(2 BE) ‖ memo(0..32). The annotation is device-generated. + */ +export function buildClearSignOutTransfer( + output: ClearSignTransferOutput, + profile: ApduProfile = RAILGUN_PROFILE, +): ApduCommand { + const recipient = encodeAscii127(output.recipient0zk); + assertBytes(output.tokenHash, 32, 'transfer tokenHash'); + const memo = output.memo ?? new Uint8Array(0); + if (memo.length > CLEAR_SIGN_MAX_MEMO_LEN) { + throw new Error(`CLEAR_SIGN memo must be ≤ ${String(CLEAR_SIGN_MAX_MEMO_LEN)} bytes, got ${String(memo.length)}`); + } + const outputType = output.outputType ?? CLEAR_SIGN_OUTPUT_TYPE_TRANSFER; + if (!Number.isInteger(outputType) || outputType < 0 || outputType > 0xff) { + throw new Error(`CLEAR_SIGN outputType must be a byte, got ${String(outputType)}`); + } + const data = concatBytes( + recipient, + output.tokenHash, + encodeUintBE(output.value, 32, 'transfer value'), + new Uint8Array([outputType]), + encodeUintBE(BigInt(memo.length), 2, 'memo_len'), + memo, + ); + return { cla: profile.cla, ins: clearSignIns(profile), p1: ClearSignP1.OUT_TRANSFER, p2: 0, data }; +} + +/** OUT_UNSHIELD (P1 0x33): recipientAddr(20) ‖ tokenHash(32) ‖ value(32) = 84 bytes. */ +export function buildClearSignOutUnshield( + output: ClearSignUnshieldOutput, + profile: ApduProfile = RAILGUN_PROFILE, +): ApduCommand { + assertBytes(output.recipientAddress, 20, 'unshield recipientAddress'); + assertBytes(output.tokenHash, 32, 'unshield tokenHash'); + const data = concatBytes( + output.recipientAddress, + output.tokenHash, + encodeUintBE(output.value, 32, 'unshield value'), + ); + return { cla: profile.cla, ins: clearSignIns(profile), p1: ClearSignP1.OUT_UNSHIELD, p2: 0, data }; +} + +/** FINALIZE (P1 0x40): one dummy filler byte (the dispatcher rejects Lc=0). */ +export function buildClearSignFinalize(profile: ApduProfile = RAILGUN_PROFILE): ApduCommand { + return { cla: profile.cla, ins: clearSignIns(profile), p1: ClearSignP1.FINALIZE, p2: 0, data: new Uint8Array([0]) }; +} + +function encodeAscii127(value: string): Uint8Array { + const out = new Uint8Array(127); + if (value.length !== 127) { + throw new Error(`0zk recipient must be exactly 127 characters, got ${String(value.length)}`); + } + for (let i = 0; i < 127; i++) { + const code = value.charCodeAt(i); + if (code < 0x20 || code > 0x7e) { + throw new Error(`0zk recipient contains a non-printable-ASCII character at index ${String(i)}`); + } + out[i] = code; + } + return out; +} diff --git a/src/index.ts b/src/index.ts index f8400d6..e0db5c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -164,6 +164,34 @@ export { } from './core/transport/apdu.js'; export type { RailgunEthereumPathRequest, EthereumSignatureParts } from './core/transport/apdu.js'; +// ─── CLEAR_SIGN transact protocol (INS 0x11) — EXPERIMENTAL (firmware 1.6.1) ── +// Pure APDU builders + shape validator for the stateful clear-sign session. +// Session orchestration and engine wiring are not yet wired up; see CAPABILITY_STATUS.clearSign. +export { + ClearSignP1, + CLEAR_SIGN_MAX_MEMO_LEN, + CLEAR_SIGN_MIN_GAS_PRICE_MAX, + CLEAR_SIGN_OUTPUT_TYPE_TRANSFER, + encodeErc20TokenHash, + validateClearSignShape, + buildClearSignInit, + buildClearSignNullifier, + buildClearSignBpFields, + buildClearSignOutBroadcaster, + buildClearSignOutChange, + buildClearSignOutTransfer, + buildClearSignOutUnshield, + buildClearSignFinalize, +} from './core/transport/clear-sign-apdu.js'; +export type { + ClearSignInitRequest, + ClearSignBpFieldsRequest, + ClearSignBroadcasterOutput, + ClearSignChangeOutput, + ClearSignTransferOutput, + ClearSignUnshieldOutput, +} from './core/transport/clear-sign-apdu.js'; + // ─── Device registry ───────────────────────────────────────────────────────── export { RAILGUN_APP, @@ -188,6 +216,7 @@ export { parseViewingKeyResponse, parseViewingPublicKeyResponse, parseRailgunAddressResponse, + parseClearSignFinalize, extractEchoedHash, } from './validation/apdu-response.js'; diff --git a/src/validation/apdu-response.ts b/src/validation/apdu-response.ts index 2bda0c0..fd5885c 100644 --- a/src/validation/apdu-response.ts +++ b/src/validation/apdu-response.ts @@ -150,6 +150,42 @@ export function parseRailgunAddressResponse(data: Uint8Array): string { return address; } +/** + * Parse a CLEAR_SIGN single-tx FINALIZE response (INS 0x11, P1 0x40). + * + * Layout (129 bytes): sig_len(1)=0x60 ‖ R8.x(32) ‖ R8.y(32) ‖ S(32) ‖ msgHash(32). + * This is byte-identical to the prefixed SIGN_HASH response, so the signature + * and echoed message hash are parsed with the shared helpers; the only extra + * check is that the length prefix is 0x60 (3×32). + * + * @returns the parsed EdDSA signature and the 32-byte message hash the device signed. + */ +export function parseClearSignFinalize( + data: Uint8Array, +): { readonly signature: Signature; readonly msgHash: Uint8Array } { + if (data.length !== 129) { + throw new HWError( + HWErrorCode.SIGN_INVALID_RESPONSE, + `Expected 129 bytes for CLEAR_SIGN FINALIZE response, got ${String(data.length)}`, + ); + } + if (data[0] !== 0x60) { + throw new HWError( + HWErrorCode.SIGN_INVALID_RESPONSE, + `CLEAR_SIGN FINALIZE signature-length prefix must be 0x60, got 0x${(data[0] ?? 0).toString(16)}`, + ); + } + const signature = parseSignResponse(data, true); + const msgHash = extractEchoedHash(data, true); + if (msgHash === null) { + throw new HWError( + HWErrorCode.SIGN_INVALID_RESPONSE, + 'CLEAR_SIGN FINALIZE response is missing the echoed message hash', + ); + } + return { signature, msgHash }; +} + /** * Convert a big-endian Uint8Array to bigint. */ diff --git a/test/unit/clear-sign-apdus.test.ts b/test/unit/clear-sign-apdus.test.ts new file mode 100644 index 0000000..bdf7bcc --- /dev/null +++ b/test/unit/clear-sign-apdus.test.ts @@ -0,0 +1,226 @@ +/** + * CLEAR_SIGN (INS 0x11) pure builders + shape validator + FINALIZE parser. + * + * The single-tx vectors are the worked 1×1 unshield example from the RAILGUN-HW + * firmware 1.6.1 spec (js/README.md) — every builder's serialized APDU must + * reproduce the spec hex byte-for-byte. Multi-output builders (broadcaster / + * change / transfer) are checked against their documented byte layout. + */ + +import { describe, expect, it } from 'vitest'; +import { + ClearSignP1, + encodeErc20TokenHash, + validateClearSignShape, + buildClearSignInit, + buildClearSignNullifier, + buildClearSignBpFields, + buildClearSignOutBroadcaster, + buildClearSignOutChange, + buildClearSignOutTransfer, + buildClearSignOutUnshield, + buildClearSignFinalize, +} from '../../src/core/transport/clear-sign-apdu.js'; +import { parseClearSignFinalize } from '../../src/validation/apdu-response.js'; +import { serializeApdu } from '../../src/core/transport/apdu-wire.js'; +import { HWError } from '../../src/core/errors.js'; + +function hex(bytes: Uint8Array): string { + return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} +function bytes(hexStr: string): Uint8Array { + const clean = hexStr.replace(/\s/g, ''); + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); + return out; +} +function apduHex(cmd: ReturnType): string { + return hex(serializeApdu(cmd)); +} + +const MERKLE_ROOT = new Uint8Array(32).fill(0x11); +const NULLIFIER = new Uint8Array(32).fill(0x22); +const VITALIK = bytes('d8da6bf26964af9d7eed9e03e53415d37aa96045'); // 20-byte address +const DAI = bytes('6b175474e89094c44da98b954eedeac495271d0f'); // 20-byte token address +const DAI_HASH = encodeErc20TokenHash(DAI); // 12 zeros ‖ DAI + +describe('CLEAR_SIGN (0x11) builders — README 1×1 unshield golden vectors', () => { + it('CS_INIT reproduces the spec hex', () => { + const cmd = buildClearSignInit({ account: 0, merkleRoot: MERKLE_ROOT, nIn: 1, nOut: 1 }); + expect(cmd.p1).toBe(ClearSignP1.INIT); + expect(apduHex(cmd)).toBe( + 'e011000026' + '00000000' + '11'.repeat(32) + '01' + '01', + ); + }); + + it('NULLIFIER reproduces the spec hex', () => { + const cmd = buildClearSignNullifier(NULLIFIER); + expect(cmd.p1).toBe(ClearSignP1.NULLIFIER); + expect(apduHex(cmd)).toBe('e011200020' + '22'.repeat(32)); + }); + + it('BP_FIELDS reproduces the spec hex (unshield, chainID=1, minGasPrice=1, non-adapt)', () => { + const cmd = buildClearSignBpFields({ + treeNumber: 0, + minGasPrice: 1n, + unshield: true, + chainId: 1n, + }); + expect(cmd.p1).toBe(ClearSignP1.BP_FIELDS); + expect(apduHex(cmd)).toBe( + 'e011100045' + + '0000' // treeNumber (2) + + '000000000001' // minGasPrice (6) + + '01' // unshield (1) + + '0000000000000001' // chainID (8) + + '00'.repeat(20) // adaptContract + + '00'.repeat(32), // adaptParams + ); + }); + + it('OUT_UNSHIELD reproduces the spec hex (vitalik.eth, 0x40000 DAI)', () => { + const cmd = buildClearSignOutUnshield({ + recipientAddress: VITALIK, + tokenHash: DAI_HASH, + value: 0x40000n, + }); + expect(cmd.p1).toBe(ClearSignP1.OUT_UNSHIELD); + expect(apduHex(cmd)).toBe( + 'e011330054' + + 'd8da6bf26964af9d7eed9e03e53415d37aa96045' + + '000000000000000000000000' + '6b175474e89094c44da98b954eedeac495271d0f' + + '0'.repeat(58) + '040000', + ); + }); + + it('FINALIZE reproduces the spec hex', () => { + const cmd = buildClearSignFinalize(); + expect(cmd.p1).toBe(ClearSignP1.FINALIZE); + expect(apduHex(cmd)).toBe('e01140000100'); + }); +}); + +describe('CLEAR_SIGN (0x11) multi-output builders — documented layout', () => { + it('OUT_BROADCASTER = MPK(32) VK(32) tokenHash(32) value(32) ann_len(0) memo_len(0)', () => { + const cmd = buildClearSignOutBroadcaster({ + recipientMasterPublicKey: new Uint8Array(32).fill(0xaa), + recipientViewingPublicKey: new Uint8Array(32).fill(0xbb), + tokenHash: DAI_HASH, + value: 5n, + }); + expect(cmd.p1).toBe(ClearSignP1.OUT_BROADCASTER); + expect(cmd.data).toBeDefined(); + expect(cmd.data!.length).toBe(132); + expect(hex(cmd.data!)).toBe( + 'aa'.repeat(32) + 'bb'.repeat(32) + hex(DAI_HASH) + + '0'.repeat(63) + '5' + '0000' + '0000', + ); + }); + + it('OUT_CHANGE = tokenHash(32) value(32) ann_len(0) memo_len(0)', () => { + const cmd = buildClearSignOutChange({ tokenHash: DAI_HASH, value: 9n }); + expect(cmd.p1).toBe(ClearSignP1.OUT_CHANGE); + expect(cmd.data!.length).toBe(68); + expect(hex(cmd.data!)).toBe(hex(DAI_HASH) + '0'.repeat(63) + '9' + '0000' + '0000'); + }); + + it('OUT_TRANSFER = 0zk(127) tokenHash(32) value(32) outputType(1) memo_len(2) memo', () => { + const recipient = `0zk1${'q'.repeat(123)}`; + const cmd = buildClearSignOutTransfer({ + recipient0zk: recipient, + tokenHash: DAI_HASH, + value: 7n, + }); + expect(cmd.p1).toBe(ClearSignP1.OUT_TRANSFER); + // 127 + 32 + 32 + 1 + 2 + 0 = 194 bytes, empty memo + expect(cmd.data!.length).toBe(194); + expect(cmd.data!.subarray(0, 127)).toEqual(new TextEncoder().encode(recipient)); + expect(cmd.data![191]).toBe(0x00); // outputType = Transfer + expect(hex(cmd.data!.subarray(192, 194))).toBe('0000'); // memo_len = 0 + }); + + it('OUT_TRANSFER appends a memo with its length prefix', () => { + const recipient = `0zk1${'q'.repeat(123)}`; + const memo = new TextEncoder().encode('gm'); + const cmd = buildClearSignOutTransfer({ recipient0zk: recipient, tokenHash: DAI_HASH, value: 1n, memo }); + expect(cmd.data!.length).toBe(196); + expect(hex(cmd.data!.subarray(192, 194))).toBe('0002'); + expect(cmd.data!.subarray(194)).toEqual(memo); + }); +}); + +describe('validateClearSignShape', () => { + it('accepts legal shapes', () => { + expect(() => validateClearSignShape(1, 1)).not.toThrow(); + expect(() => validateClearSignShape(2, 3)).not.toThrow(); + expect(() => validateClearSignShape(3, 2)).not.toThrow(); + }); + + it('rejects n+m > 5', () => { + expect(() => validateClearSignShape(3, 3)).toThrow(/n\+m/); + }); + + it('rejects out-of-range n or m', () => { + expect(() => validateClearSignShape(4, 1)).toThrow(/\[1, 3\]/); + expect(() => validateClearSignShape(0, 1)).toThrow(/\[1, 3\]/); + expect(() => validateClearSignShape(1, 0)).toThrow(/\[1, 3\]/); + }); +}); + +describe('builder guards', () => { + it('rejects minGasPrice ≥ 2^48', () => { + expect(() => buildClearSignBpFields({ treeNumber: 0, minGasPrice: 1n << 48n, unshield: false, chainId: 1n })) + .toThrow(/uint48/); + }); + + it('rejects a memo over 32 bytes', () => { + expect(() => buildClearSignOutTransfer({ + recipient0zk: `0zk1${'q'.repeat(123)}`, + tokenHash: DAI_HASH, + value: 1n, + memo: new Uint8Array(33), + })).toThrow(/memo/); + }); + + it('rejects a 0zk recipient that is not 127 chars', () => { + expect(() => buildClearSignOutTransfer({ recipient0zk: '0zk1short', tokenHash: DAI_HASH, value: 1n })) + .toThrow(/127/); + }); + + it('rejects a wrong-length nullifier / merkleRoot', () => { + expect(() => buildClearSignNullifier(new Uint8Array(31))).toThrow(/nullifier/); + expect(() => buildClearSignInit({ merkleRoot: new Uint8Array(31), nIn: 1, nOut: 1 })).toThrow(/merkleRoot/); + }); +}); + +describe('parseClearSignFinalize', () => { + function finalize129(): Uint8Array { + const out = new Uint8Array(129); + out[0] = 0x60; + out.set(new Uint8Array(32).fill(0x00), 1); // R8x = 0 + out[64] = 0x01; // R8y last byte = 1 (point (0,1)) + out.set(new Uint8Array(32).fill(0x00), 65); + out[96] = 0x07; // S = 7 + out.set(new Uint8Array(32).fill(0xcd), 97); // msgHash + return out; + } + + it('parses a 129-byte FINALIZE into signature + msgHash', () => { + const { signature, msgHash } = parseClearSignFinalize(finalize129()); + expect(signature.R8[0]).toBe(0n); + expect(signature.R8[1]).toBe(1n); + expect(signature.S).toBe(7n); + expect(msgHash).toEqual(new Uint8Array(32).fill(0xcd)); + }); + + it('rejects a wrong length', () => { + expect(() => parseClearSignFinalize(new Uint8Array(128))).toThrow(HWError); + expect(() => parseClearSignFinalize(new Uint8Array(256))).toThrow(HWError); + }); + + it('rejects a bad length prefix', () => { + const bad = finalize129(); + bad[0] = 0x40; + expect(() => parseClearSignFinalize(bad)).toThrow(/0x60/); + }); +});