From e020c388353a0b420f5bd587dbde21293bc27da3 Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Sun, 17 May 2026 19:49:38 +0000 Subject: [PATCH 01/19] noise: match wasm localStorage fallback Match the wasm package by treating unreadable or malformed persisted Noise config as an empty config on read. This keeps missing, malformed, and inaccessible localStorage cases on the same compatibility path while preserving write failures as noise-config errors. Update tests for malformed JSON, invalid byte arrays, and getItem failures. --- src/internal/noise-config.ts | 13 ++++--------- test/noise-config.test.ts | 31 +++++++++++++++---------------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/internal/noise-config.ts b/src/internal/noise-config.ts index 8c65c8f..92004b1 100644 --- a/src/internal/noise-config.ts +++ b/src/internal/noise-config.ts @@ -64,10 +64,6 @@ function bytesFromArray(value: unknown, field: string): Uint8Array { return Uint8Array.from(value); } -function errorMessage(err: unknown, fallback: string): string { - return err instanceof Error && err.message ? err.message : fallback; -} - function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) { return false; @@ -141,9 +137,8 @@ interface StorageLike { /** * Browser-default config. Uses the `Storage`-shaped `localStorage` global; on a - * missing key it starts from an empty config. Malformed persisted JSON and - * storage access failures propagate to the caller so pairing state is not - * silently replaced. + * missing key, malformed persisted JSON, or storage read failure it starts from + * an empty config, matching the wasm package behavior. * @internal */ export class LocalStorageNoiseConfig implements NoiseConfig { @@ -166,8 +161,8 @@ export class LocalStorageNoiseConfig implements NoiseConfig { return emptyConfig(); } return fromJson(raw); - } catch (err) { - throw noiseConfigError(errorMessage(err, 'could not read from localstorage')); + } catch { + return emptyConfig(); } } diff --git a/test/noise-config.test.ts b/test/noise-config.test.ts index 68c9092..58602e9 100644 --- a/test/noise-config.test.ts +++ b/test/noise-config.test.ts @@ -106,26 +106,27 @@ describe('LocalStorageNoiseConfig', () => { expect(back.deviceStaticPubkeys).toEqual([]); }); - it('throws when the stored JSON is malformed', () => { + it('returns an empty config when the stored JSON is malformed', () => { const fake = new FakeStorage(); fake.setItem(LOCAL_STORAGE_CONFIG_KEY, '{ not json'); - expect(() => new LocalStorageNoiseConfig(fake).read()).toThrow( - expect.objectContaining({ code: 'noise-config' }), - ); + const back = new LocalStorageNoiseConfig(fake).read(); + expect(back.appStaticPrivkey).toBeUndefined(); + expect(back.deviceStaticPubkeys).toEqual([]); }); - it('throws when getItem throws', () => { + it('returns an empty config when getItem throws', () => { const c = new LocalStorageNoiseConfig({ getItem(): string | null { throw new Error('storage disabled'); }, setItem(): void {}, }); - expect(() => c.read()).toThrow(expect.objectContaining({ code: 'noise-config' })); - expect(() => c.read()).toThrow(/storage disabled/); + const back = c.read(); + expect(back.appStaticPrivkey).toBeUndefined(); + expect(back.deviceStaticPubkeys).toEqual([]); }); - it('throws when persisted byte arrays do not contain exactly bytes', () => { + it('returns an empty config when persisted byte arrays are malformed', () => { const fake = new FakeStorage(); fake.setItem( LOCAL_STORAGE_CONFIG_KEY, @@ -134,10 +135,9 @@ describe('LocalStorageNoiseConfig', () => { device_static_pubkeys: [], }), ); - expect(() => new LocalStorageNoiseConfig(fake).read()).toThrow( - expect.objectContaining({ code: 'noise-config' }), - ); - expect(() => new LocalStorageNoiseConfig(fake).read()).toThrow(/32-byte/); + let back = new LocalStorageNoiseConfig(fake).read(); + expect(back.appStaticPrivkey).toBeUndefined(); + expect(back.deviceStaticPubkeys).toEqual([]); fake.setItem( LOCAL_STORAGE_CONFIG_KEY, @@ -146,10 +146,9 @@ describe('LocalStorageNoiseConfig', () => { device_static_pubkeys: [Array.from({ length: 32 }, () => 256)], }), ); - expect(() => new LocalStorageNoiseConfig(fake).read()).toThrow( - expect.objectContaining({ code: 'noise-config' }), - ); - expect(() => new LocalStorageNoiseConfig(fake).read()).toThrow(/invalid byte/); + back = new LocalStorageNoiseConfig(fake).read(); + expect(back.appStaticPrivkey).toBeUndefined(); + expect(back.deviceStaticPubkeys).toEqual([]); }); it('throws noise-config when storage writes fail', () => { From 992cc0cef727a4a587da57f365e84229e5ec5e0c Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Sun, 17 May 2026 20:00:26 +0000 Subject: [PATCH 02/19] eth: reject typed message JSON strings Keep the public EIP-712 signing API aligned with the wasm package intent: callers pass the raw typed-data object, not a pre-serialized JSON string. The wasm wrapper stringifies the provided value itself before Rust parses the JSON. Accepting string input in the TypeScript parser widened the public API and let call sites work that would fail against the wasm package. Remove the JSON.parse path and cover both the parser and signing method so string input is rejected before any device query. --- src/internal/eth/eip712.ts | 20 +++---- test/eth-eip712.test.ts | 8 ++- test/eth-methods.test.ts | 8 +++ test/simulator-eth.test.ts | 104 ++++++++++++++++++------------------- 4 files changed, 73 insertions(+), 67 deletions(-) diff --git a/src/internal/eth/eip712.ts b/src/internal/eth/eip712.ts index c28158b..f985551 100644 --- a/src/internal/eth/eip712.ts +++ b/src/internal/eth/eip712.ts @@ -65,25 +65,17 @@ function parseTypes(input: unknown): Record { } export function parseEip712Message(input: unknown): Eip712Message { - let parsed: unknown = input; - if (typeof input === 'string') { - try { - parsed = JSON.parse(input); - } catch { - throw new TypedMessageError('Could not parse EIP-712 JSON message'); - } - } - if (!isRecord(parsed) || typeof parsed.primaryType !== 'string') { + if (!isRecord(input) || typeof input.primaryType !== 'string') { throw new TypedMessageError('Could not parse EIP-712 JSON message'); } - if (!isRecord(parsed.domain) || !isRecord(parsed.message)) { + if (!isRecord(input.domain) || !isRecord(input.message)) { throw new TypedMessageError('Could not parse EIP-712 JSON message'); } return { - types: parseTypes(parsed.types), - primaryType: parsed.primaryType, - domain: parsed.domain, - message: parsed.message, + types: parseTypes(input.types), + primaryType: input.primaryType, + domain: input.domain, + message: input.message, }; } diff --git a/test/eth-eip712.test.ts b/test/eth-eip712.test.ts index e43b21c..8057283 100644 --- a/test/eth-eip712.test.ts +++ b/test/eth-eip712.test.ts @@ -247,7 +247,13 @@ describe('buildStructTypes', () => { describe('parseEip712Message', () => { it('parses and validates the full message shape', () => { - expect(parseEip712Message(JSON.stringify(EIP712_MSG))).toEqual(EIP712_MSG); + expect(parseEip712Message(EIP712_MSG)).toEqual(EIP712_MSG); + }); + + it('rejects JSON strings; callers must pass the raw typed-data object', () => { + expect(() => parseEip712Message(JSON.stringify(EIP712_MSG))).toThrow( + expect.objectContaining({ code: 'eth-typed-message' }), + ); }); it('rejects malformed type members', () => { diff --git a/test/eth-methods.test.ts b/test/eth-methods.test.ts index 8cb3562..461a33e 100644 --- a/test/eth-methods.test.ts +++ b/test/eth-methods.test.ts @@ -709,6 +709,14 @@ describe('ethSignTypedMessage', () => { expect(channel.seen).toHaveLength(0); }); + it('rejects JSON strings before querying', async () => { + const channel = new ScriptedChannel([]); + await expect( + ethSignTypedMessage(channel, info('9.26.0'), 1n, [0], JSON.stringify(TYPED_MSG), true), + ).rejects.toMatchObject({ code: 'eth-typed-message' }); + expect(channel.seen).toHaveLength(0); + }); + it('rejects a string value larger than 6144 bytes', async () => { const longContents = 'x'.repeat(7000); const longMsg = { ...TYPED_MSG, message: { contents: longContents } }; diff --git a/test/simulator-eth.test.ts b/test/simulator-eth.test.ts index d110358..5d1b947 100644 --- a/test/simulator-eth.test.ts +++ b/test/simulator-eth.test.ts @@ -30,51 +30,51 @@ const RECIPIENT = new Uint8Array([ 0x19, 0x2a, 0x35, 0x28, 0x14, 0xfb, 0xe9, 0x27, 0xb8, 0x85, ]); -const EIP712_MSG = `{ - "types": { - "EIP712Domain": [ - { "name": "name", "type": "string" }, - { "name": "version", "type": "string" }, - { "name": "chainId", "type": "uint256" }, - { "name": "verifyingContract", "type": "address" } +const EIP712_MSG = { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, ], - "Attachment": [ - { "name": "contents", "type": "string" } + Attachment: [ + { name: 'contents', type: 'string' }, ], - "Person": [ - { "name": "name", "type": "string" }, - { "name": "wallet", "type": "address" }, - { "name": "age", "type": "uint8" } + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallet', type: 'address' }, + { name: 'age', type: 'uint8' }, + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person' }, + { name: 'contents', type: 'string' }, + { name: 'attachments', type: 'Attachment[]' }, ], - "Mail": [ - { "name": "from", "type": "Person" }, - { "name": "to", "type": "Person" }, - { "name": "contents", "type": "string" }, - { "name": "attachments", "type": "Attachment[]" } - ] }, - "primaryType": "Mail", - "domain": { - "name": "Ether Mail", - "version": "1", - "chainId": 1, - "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + primaryType: 'Mail', + domain: { + name: 'Ether Mail', + version: '1', + chainId: 1, + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', }, - "message": { - "from": { - "name": "Cow", - "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826", - "age": 20 + message: { + from: { + name: 'Cow', + wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + age: 20, }, - "to": { - "name": "Bob", - "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", - "age": "0x1e" + to: { + name: 'Bob', + wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + age: '0x1e', }, - "contents": "Hello, Bob!", - "attachments": [{ "contents": "attachment1" }, { "contents": "attachment2" }] - } -}`; + contents: 'Hello, Bob!', + attachments: [{ contents: 'attachment1' }, { contents: 'attachment2' }], + }, +}; function toCompactSignature({ r, s }: { r: Uint8Array; s: Uint8Array }): Uint8Array { if (r.length !== 32 || s.length !== 32) { @@ -287,23 +287,23 @@ describe.skipIf(!ENABLED)('simulator eth', () => { return; } const largeBytesHex = 'aa'.repeat(10000); - const msg = `{ - "types": { - "EIP712Domain": [ - { "name": "name", "type": "string" } + const msg = { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, ], - "Msg": [ - { "name": "data", "type": "bytes" } - ] + Msg: [ + { name: 'data', type: 'bytes' }, + ], + }, + primaryType: 'Msg', + domain: { + name: 'Test', }, - "primaryType": "Msg", - "domain": { - "name": "Test" + message: { + data: `0x${largeBytesHex}`, }, - "message": { - "data": "0x${largeBytesHex}" - } - }`; + }; const sig = await paired!.ethSignTypedMessage(1n, ETH_KEYPATH, msg, false); expectSignatureFromSimulatorAddress(eip712BytesSighash(largeBytesHex), sig); }, 60_000); From a4c7840ec71a239002c1adf69781f09f0028197b Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Sun, 17 May 2026 20:04:27 +0000 Subject: [PATCH 03/19] eth: remove signature JSON helpers Return plain Uint8Array fields for Ethereum signatures, matching the wasm package output more closely. The previous implementation attached custom toJSON methods to the signature object and its r, s, and v byte arrays. That made JSON.stringify produce array-shaped output, but it was a TypeScript-only convenience and changed observable runtime behavior. Simplify signature construction and keep tests focused on the normal Uint8Array API. --- src/internal/eth/methods.ts | 27 ++++----------------------- test/eth-methods.test.ts | 9 +++------ 2 files changed, 7 insertions(+), 29 deletions(-) diff --git a/src/internal/eth/methods.ts b/src/internal/eth/methods.ts index b172561..da27cbc 100644 --- a/src/internal/eth/methods.ts +++ b/src/internal/eth/methods.ts @@ -125,31 +125,12 @@ function shapeLegacyV(recid: number, chainId: bigint): Uint8Array { return bigUintToBytesBE(v); } -function withJsonArray(bytes: Uint8Array): Uint8Array { - Object.defineProperty(bytes, 'toJSON', { - value: () => Array.from(bytes), - enumerable: false, - configurable: true, - }); - return bytes; -} - function buildSignature(signature: Uint8Array, v: Uint8Array): EthSignature { - const out = { - r: withJsonArray(signature.slice(0, 32)), - s: withJsonArray(signature.slice(32, 64)), - v: withJsonArray(v), + return { + r: signature.slice(0, 32), + s: signature.slice(32, 64), + v, }; - Object.defineProperty(out, 'toJSON', { - value: () => ({ - r: Array.from(out.r), - s: Array.from(out.s), - v: Array.from(out.v), - }), - enumerable: false, - configurable: true, - }); - return out; } export async function ethXpub( diff --git a/test/eth-methods.test.ts b/test/eth-methods.test.ts index 461a33e..55eff55 100644 --- a/test/eth-methods.test.ts +++ b/test/eth-methods.test.ts @@ -194,12 +194,9 @@ describe('ethSignTransaction (dynamic antiklepto)', () => { expect(Array.from(sig.v)).toEqual([1 + 27 + 1 * 2 + 8]); // 38 expect(sig.r.length).toBe(32); expect(sig.s.length).toBe(32); - expect(JSON.parse(JSON.stringify(sig))).toEqual({ - r: Array.from(sig.r), - s: Array.from(sig.s), - v: Array.from(sig.v), - }); - expect(JSON.parse(JSON.stringify(sig.r))).toEqual(Array.from(sig.r)); + expect(sig.r).toBeInstanceOf(Uint8Array); + expect(sig.s).toBeInstanceOf(Uint8Array); + expect(sig.v).toBeInstanceOf(Uint8Array); }); it('legacy v shaping: chainId=17000 recid=0 → v big-endian without leading zeros', async () => { From af6d2c9d2f02e8eb727e9b218f80182c04071cb3 Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Sun, 17 May 2026 20:14:54 +0000 Subject: [PATCH 04/19] pkg: use BitBox scoped package name Publish the TypeScript package as @bitboxswiss/bitbox-api instead of the temporary unscoped bitbox-api-ts name. Update README examples, sandbox imports and aliases, and public stub error messages so consumers see and use the intended scoped npm package name. --- CONTRIBUTING.md | 2 +- README.md | 10 +++++----- package-lock.json | 4 ++-- package.json | 2 +- sandbox/index.html | 2 +- sandbox/src/App.tsx | 4 ++-- sandbox/src/Ethereum.tsx | 2 +- sandbox/tsconfig.json | 2 +- sandbox/vite.config.ts | 2 +- src/index.ts | 4 ++-- 10 files changed, 17 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e972c7..10eab45 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -124,6 +124,6 @@ hardware through WebHID or BitBoxBridge: make sandbox-dev ``` -The sandbox aliases `bitbox-api-ts` to `../src/index.ts`, so source edits +The sandbox aliases `@bitboxswiss/bitbox-api` to `../src/index.ts`, so source edits hot-reload without building `dist/`. Browsers cannot connect to the raw TCP simulator transport directly; simulator coverage belongs in `npm run test:sim`. diff --git a/README.md b/README.md index 6de676d..6cf6539 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# bitbox-api-ts +# @bitboxswiss/bitbox-api Pure TypeScript library for integrating BitBox02 hardware wallets in browser applications. -`bitbox-api-ts` is source-compatible with the current `bitbox-api` +`@bitboxswiss/bitbox-api` is source-compatible with the current `bitbox-api` Rust/WASM package for the implemented surface, but it does not ship WASM and does not require a WASM init step. @@ -20,7 +20,7 @@ does not require a WASM init step. ## Installation ```bash -npm install bitbox-api-ts +npm install @bitboxswiss/bitbox-api ``` `@noble/ciphers`, `@noble/curves`, and `@noble/hashes` are peer dependencies so @@ -43,7 +43,7 @@ For currently implemented methods, the intended migration is just the import name: ```ts -import * as bitbox from 'bitbox-api-ts'; +import * as bitbox from '@bitboxswiss/bitbox-api'; ``` There is no `init()` call and no WASM loader. Existing Webpack/Vite WASM plugin @@ -68,7 +68,7 @@ compatibility, but currently reject with typed errors as listed in ## Connecting and Pairing ```ts -import * as bitbox from 'bitbox-api-ts'; +import * as bitbox from '@bitboxswiss/bitbox-api'; async function connectBitBox(): Promise { try { diff --git a/package-lock.json b/package-lock.json index 35d24c7..9c21cc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "bitbox-api-ts", + "name": "@bitboxswiss/bitbox-api", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "bitbox-api-ts", + "name": "@bitboxswiss/bitbox-api", "version": "0.1.0", "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index e2120df..d2061bf 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "bitbox-api-ts", + "name": "@bitboxswiss/bitbox-api", "version": "0.1.0", "description": "A pure TypeScript library to interact with BitBox hardware wallets.", "license": "Apache-2.0", diff --git a/sandbox/index.html b/sandbox/index.html index 3018ac8..1a3caf0 100644 --- a/sandbox/index.html +++ b/sandbox/index.html @@ -3,7 +3,7 @@ - BitBox02 sandbox (bitbox-api-ts) + BitBox02 sandbox (@bitboxswiss/bitbox-api)
diff --git a/sandbox/src/App.tsx b/sandbox/src/App.tsx index d024f71..7e6aa11 100644 --- a/sandbox/src/App.tsx +++ b/sandbox/src/App.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { useState } from 'react'; -import * as bitbox from 'bitbox-api-ts'; +import * as bitbox from '@bitboxswiss/bitbox-api'; import './App.css'; import { Ethereum } from './Ethereum'; @@ -124,7 +124,7 @@ function App() { )}

This sandbox is backed by the in-tree{' '} - bitbox-api-ts{' '} + @bitboxswiss/bitbox-api{' '} package. It validates the current browser integration and currently wired flows; it is not intended to track full API parity.

diff --git a/sandbox/src/Ethereum.tsx b/sandbox/src/Ethereum.tsx index d77a7f5..8dc1891 100644 --- a/sandbox/src/Ethereum.tsx +++ b/sandbox/src/Ethereum.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { FormEvent, useState } from 'react'; -import * as bitbox from 'bitbox-api-ts'; +import * as bitbox from '@bitboxswiss/bitbox-api'; import { ErrorNotification } from './ErrorNotification'; diff --git a/sandbox/tsconfig.json b/sandbox/tsconfig.json index b0c2fcc..8ad26a5 100644 --- a/sandbox/tsconfig.json +++ b/sandbox/tsconfig.json @@ -15,7 +15,7 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "paths": { - "bitbox-api-ts": ["../src/index.ts"] + "@bitboxswiss/bitbox-api": ["../src/index.ts"] } }, "include": ["src", "vite.config.ts"] diff --git a/sandbox/vite.config.ts b/sandbox/vite.config.ts index 110c243..a03e422 100644 --- a/sandbox/vite.config.ts +++ b/sandbox/vite.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ alias: { // Point the sandbox at the library source so edits in ../src/ hot-reload // without needing a rebuild. - 'bitbox-api-ts': path.resolve(__dirname, '../src/index.ts'), + '@bitboxswiss/bitbox-api': path.resolve(__dirname, '../src/index.ts'), }, }, build: { diff --git a/src/index.ts b/src/index.ts index de537ec..128c199 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,14 +35,14 @@ import { function unsupportedError(method: string): Error { return { code: CODE_UNSUPPORTED, - message: `${method} is not supported in bitbox-api-ts`, + message: `${method} is not supported in @bitboxswiss/bitbox-api`, }; } function notImplementedError(method: string): Error { return { code: CODE_NOT_IMPLEMENTED, - message: `${method} is not yet implemented in bitbox-api-ts`, + message: `${method} is not yet implemented in @bitboxswiss/bitbox-api`, }; } From efb4aae76633c149a3a2ff119710c6b3ae2e6388 Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Sun, 17 May 2026 20:45:43 +0000 Subject: [PATCH 05/19] Run simulator tests across firmware matrix Run simulator suites for each manifest entry and keep version-specific expectations in the tests. --- test/simulator-eth.test.ts | 57 ++++++++---------- test/simulator-info.test.ts | 74 ++++++++++++++---------- test/simulator-util.ts | 112 +++++++++++++++++++++++++++++++++--- test/simulators.json | 28 +++++++++ 4 files changed, 197 insertions(+), 74 deletions(-) diff --git a/test/simulator-eth.test.ts b/test/simulator-eth.test.ts index 5d1b947..aaa0488 100644 --- a/test/simulator-eth.test.ts +++ b/test/simulator-eth.test.ts @@ -1,6 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -import path from 'node:path'; import { encode as rlpEncode } from '@ethereumjs/rlp'; import { secp256k1 } from '@noble/curves/secp256k1'; import { keccak_256 } from '@noble/hashes/sha3'; @@ -14,8 +13,8 @@ import { completePairing, performHandshake } from '../src/internal/pairing.js'; import { restoreFromMnemonic } from '../src/internal/restore.js'; import { SimulatorServer, - binaryToRun, - parseVersionFromFilename, + ensureSimulator, + simulatorCases, simulatorSupported, } from './simulator-util.js'; @@ -23,7 +22,7 @@ const ENABLED = simulatorSupported() && process.env.SKIP_SIMULATOR !== '1'; const SIMULATOR_ETH_ADDRESS = '0x416e88840eb6353e49252da2a2c140ea1f969d1a'; const ETH_KEYPATH = "m/44'/60'/0'/0/0"; -const ETH_ACCOUNT_KEYPATH = "m/44'/60'/0'"; +const ETH_XPUB_KEYPATH = "m/44'/60'/0'/0"; const RECIPIENT = new Uint8Array([ 0x04, 0xf2, 0x64, 0xcf, 0x34, 0x44, 0x03, 0x13, 0xb4, 0xa0, @@ -157,17 +156,18 @@ function expectSignatureFromSimulatorAddress( expect(candidates).toContain(SIMULATOR_ETH_ADDRESS); } -describe.skipIf(!ENABLED)('simulator eth', () => { +describe.skipIf(!ENABLED).sequential.each(simulatorCases())('simulator eth $name', (simulator) => { let server: SimulatorServer | undefined; let paired: PairedBitBox | undefined; - let version = ''; + const version = parseSemver(simulator.version); + const atLeast926 = atLeast(version, { major: 9, minor: 26, patch: 0 }); beforeAll(async () => { - const binary = await binaryToRun(); - version = parseVersionFromFilename(path.basename(binary)); + const binary = await ensureSimulator(simulator); server = new SimulatorServer(binary); const session = await connectSimulator(undefined, undefined, new NoiseConfigNoCache()); try { + expect(session.hww.info.version).toBe(simulator.version); const pairing = await performHandshake(session.hww, session.config); const channel = await completePairing(pairing); await restoreFromMnemonic(channel); @@ -176,17 +176,12 @@ describe.skipIf(!ENABLED)('simulator eth', () => { session.close(); throw err; } - }, 60_000); + }, 120_000); afterAll(async () => { paired?.close(); - server?.kill(); - await server?.exited; - }); - - function require926(): boolean { - return atLeast(parseSemver(version), { major: 9, minor: 26, patch: 0 }); - } + await server?.stop(); + }, 30_000); it('ethAddress returns simulator address', async () => { const address = await paired!.ethAddress(1n, ETH_KEYPATH, false); @@ -194,8 +189,8 @@ describe.skipIf(!ENABLED)('simulator eth', () => { expect(address).toBe('0x416E88840Eb6353E49252Da2a2c140eA1f969D1a'); }, 15_000); - it('ethXpub returns simulator account xpub', async () => { - const xpub = await paired!.ethXpub(ETH_ACCOUNT_KEYPATH); + it('ethXpub returns simulator xpub', async () => { + const xpub = await paired!.ethXpub(ETH_XPUB_KEYPATH); // BIP32 mainnet xpubs start with "xpub" and base58check to ~111 chars. expect(xpub).toMatch(/^xpub[1-9A-HJ-NP-Za-km-z]{106,112}$/); }, 15_000); @@ -215,10 +210,7 @@ describe.skipIf(!ENABLED)('simulator eth', () => { expectSignatureFromSimulatorAddress(legacySighash(1n, tx), sig); }, 30_000); - it('ethSignTransaction signs streaming legacy transaction', async () => { - if (!require926()) { - return; - } + it.skipIf(!atLeast926)('ethSignTransaction signs streaming legacy transaction', async () => { const tx: EthTransaction = { nonce: new Uint8Array([0x01]), gasPrice: new Uint8Array([0x01]), @@ -247,10 +239,7 @@ describe.skipIf(!ENABLED)('simulator eth', () => { expectSignatureFromSimulatorAddress(eip1559Sighash(tx), sig); }, 30_000); - it('ethSign1559Transaction signs streaming transaction', async () => { - if (!require926()) { - return; - } + it.skipIf(!atLeast926)('ethSign1559Transaction signs streaming transaction', async () => { const tx: Eth1559Transaction = { chainId: 1n, nonce: new Uint8Array([0x01]), @@ -273,19 +262,19 @@ describe.skipIf(!ENABLED)('simulator eth', () => { expect(bytesToHex(signatureBytes(sig1))).not.toBe(bytesToHex(signatureBytes(sig2))); }, 60_000); - it('ethSignTypedMessage with anti-klepto disabled is deterministic', async () => { - if (!require926()) { - return; - } + it.skipIf(!atLeast926)('ethSignTypedMessage with anti-klepto disabled is deterministic', async () => { const sig1 = await paired!.ethSignTypedMessage(1n, ETH_KEYPATH, EIP712_MSG, false); const sig2 = await paired!.ethSignTypedMessage(1n, ETH_KEYPATH, EIP712_MSG, false); expect(bytesToHex(signatureBytes(sig1))).toBe(bytesToHex(signatureBytes(sig2))); }, 60_000); - it('ethSignTypedMessage streams bytes field', async () => { - if (!require926()) { - return; - } + it.skipIf(atLeast926)('ethSignTypedMessage with anti-klepto disabled rejects before 9.26', async () => { + await expect( + paired!.ethSignTypedMessage(1n, ETH_KEYPATH, EIP712_MSG, false), + ).rejects.toMatchObject({ code: 'version' }); + }, 15_000); + + it.skipIf(!atLeast926)('ethSignTypedMessage streams bytes field', async () => { const largeBytesHex = 'aa'.repeat(10000); const msg = { types: { diff --git a/test/simulator-info.test.ts b/test/simulator-info.test.ts index 0d042fd..f5aef7d 100644 --- a/test/simulator-info.test.ts +++ b/test/simulator-info.test.ts @@ -1,37 +1,39 @@ // SPDX-License-Identifier: Apache-2.0 -import path from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { BitBox } from '../src/index.js'; import { connectSimulator, probeSimulatorInfo } from '../src/internal/connect-simulator.js'; import { atLeast, parseSemver } from '../src/internal/hww.js'; import { NoiseConfigNoCache } from '../src/internal/noise-config.js'; import { SimulatorServer, - binaryToRun, - parseVersionFromFilename, + ensureSimulator, + simulatorCases, simulatorSupported, } from './simulator-util.js'; const ENABLED = simulatorSupported() && process.env.SKIP_SIMULATOR !== '1'; -describe.skipIf(!ENABLED)('simulator info probe', () => { +describe.skipIf(!ENABLED).sequential.each(simulatorCases())('simulator info probe $name', (simulator) => { let server: SimulatorServer | undefined; - let version = ''; + let binary = ''; + const version = parseSemver(simulator.version); beforeAll(async () => { - const binary = await binaryToRun(); - version = parseVersionFromFilename(path.basename(binary)); - server = new SimulatorServer(binary); - }, 30_000); + binary = await ensureSimulator(simulator); + }, 120_000); - afterAll(async () => { - server?.kill(); - await server?.exited; + beforeEach(() => { + server = new SimulatorServer(binary); }); + afterEach(async () => { + await server?.stop(); + server = undefined; + }, 30_000); + function expectedProduct(): 'bitbox02-multi' | 'bitbox02-nova-multi' { - return atLeast(parseSemver(version), { major: 9, minor: 24, patch: 0 }) + return atLeast(version, { major: 9, minor: 24, patch: 0 }) ? 'bitbox02-nova-multi' : 'bitbox02-multi'; } @@ -39,32 +41,42 @@ describe.skipIf(!ENABLED)('simulator info probe', () => { it('HWW info reports the expected version, product, and state', async () => { let onCloseCalls = 0; const probe = await probeSimulatorInfo(undefined, () => { onCloseCalls += 1; }); - expect(probe.info.version).toBe(version); - expect(probe.info.product).toBe(expectedProduct()); - expect(probe.info.unlocked).toBe(false); - if (atLeast(parseSemver(version), { major: 9, minor: 20, patch: 0 })) { - expect(probe.info.initialized).toBe(false); - } else { - expect(probe.info.initialized).toBeUndefined(); + try { + expect(probe.info.version).toBe(simulator.version); + expect(probe.info.product).toBe(expectedProduct()); + expect(probe.info.unlocked).toBe(false); + if (atLeast(version, { major: 9, minor: 20, patch: 0 })) { + expect(probe.info.initialized).toBe(false); + } else { + expect(probe.info.initialized).toBeUndefined(); + } + } finally { + probe.close(); } - probe.close(); expect(onCloseCalls).toBe(1); }, 15_000); it('pairs over Noise and exposes paired device metadata', async () => { let onCloseCalls = 0; const session = await connectSimulator(undefined, () => { onCloseCalls += 1; }, new NoiseConfigNoCache()); - const bitbox = new BitBox(session); - const pairing = await bitbox.unlockAndPair(); - expect(pairing.getPairingCode()).toMatch(/^[A-Z2-7]{5} [A-Z2-7]{5}\n[A-Z2-7]{5} [A-Z2-7]{5}$/); - - const paired = await pairing.waitConfirm(); + try { + const bitbox = new BitBox(session); + const pairing = await bitbox.unlockAndPair(); + expect(pairing.getPairingCode()).toMatch(/^[A-Z2-7]{5} [A-Z2-7]{5}\n[A-Z2-7]{5} [A-Z2-7]{5}$/); - expect(paired.version()).toBe(version); - expect(paired.product()).toBe(expectedProduct()); - expect(paired.ethSupported()).toBe(true); + const paired = await pairing.waitConfirm(); + try { + expect(paired.version()).toBe(simulator.version); + expect(paired.product()).toBe(expectedProduct()); + expect(paired.ethSupported()).toBe(true); + } finally { + paired.close(); + } + } catch (err) { + session.close(); + throw err; + } - paired.close(); expect(onCloseCalls).toBe(1); }, 15_000); }); diff --git a/test/simulator-util.ts b/test/simulator-util.ts index 15bfd26..3ba0959 100644 --- a/test/simulator-util.ts +++ b/test/simulator-util.ts @@ -3,7 +3,7 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { createHash } from 'node:crypto'; import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; -import { createReadStream } from 'node:fs'; +import { createReadStream, readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -14,6 +14,36 @@ export interface SimulatorEntry { sha256: string; } +export interface SimulatorCase { + name: string; + version: string; + binaryPath: string; +} + +function simulatorsJsonPath(): string { + return path.join(__dirname, 'simulators.json'); +} + +function simulatorsDir(): string { + return path.join(__dirname, 'simulators'); +} + +function parseSimulatorEntries(raw: string): SimulatorEntry[] { + const entries = JSON.parse(raw) as SimulatorEntry[]; + if (!Array.isArray(entries)) { + throw new Error('test/simulators.json must contain an array'); + } + return entries; +} + +function binaryNameFromUrl(url: string): string { + return path.basename(new URL(url).pathname); +} + +function binaryPathForEntry(entry: SimulatorEntry): string { + return path.join(simulatorsDir(), binaryNameFromUrl(entry.url)); +} + async function sha256File(filePath: string): Promise { return new Promise((resolve, reject) => { const hash = createHash('sha256'); @@ -48,15 +78,14 @@ async function downloadOne(url: string, dest: string): Promise { * cached with the right hash. Returns absolute paths in list order. */ export async function downloadSimulators(): Promise { - const jsonPath = path.join(__dirname, 'simulators.json'); - const entries = JSON.parse(await readFile(jsonPath, 'utf8')) as SimulatorEntry[]; - const dir = path.join(__dirname, 'simulators'); + const entries = parseSimulatorEntries(await readFile(simulatorsJsonPath(), 'utf8')); + const dir = simulatorsDir(); await mkdir(dir, { recursive: true }); const paths: string[] = []; for (const entry of entries) { - const name = path.basename(new URL(entry.url).pathname); - const dest = path.join(dir, name); + const name = binaryNameFromUrl(entry.url); + const dest = binaryPathForEntry(entry); let cached = false; if (await fileExists(dest)) { @@ -76,6 +105,52 @@ export async function downloadSimulators(): Promise { return paths; } +let downloadedSimulators: Promise> | undefined; + +async function downloadedSimulatorPaths(): Promise> { + const paths = await downloadSimulators(); + return new Set(paths); +} + +/** + * Return the simulator matrix without downloading binaries. Each case maps one + * manifest entry to its expected on-disk path and parsed firmware version. + */ +export function simulatorCases(): SimulatorCase[] { + const override = process.env.SIMULATOR; + if (override !== undefined && override.length > 0) { + const binaryPath = path.resolve(override); + const version = parseVersionFromFilename(path.basename(binaryPath)); + return [{ name: version, version, binaryPath }]; + } + + const entries = parseSimulatorEntries(readFileSync(simulatorsJsonPath(), 'utf8')); + return entries.map((entry) => { + const binaryPath = binaryPathForEntry(entry); + const version = parseVersionFromFilename(path.basename(binaryPath)); + return { name: version, version, binaryPath }; + }); +} + +/** + * Ensure a simulator case is present and hash-verified before it is launched. + * Downloads the full manifest once per test worker so manifest/hash issues fail + * before any matrix case can silently use stale binaries. + */ +export async function ensureSimulator(simulator: SimulatorCase): Promise { + const override = process.env.SIMULATOR; + if (override !== undefined && override.length > 0) { + return simulator.binaryPath; + } + + downloadedSimulators ??= downloadedSimulatorPaths(); + const paths = await downloadedSimulators; + if (!paths.has(simulator.binaryPath)) { + throw new Error(`simulator not found in downloaded manifest: ${simulator.binaryPath}`); + } + return simulator.binaryPath; +} + /** * Spawns a simulator binary. Stdio is piped to the parent with an indent * prefix for debugging. The transport's own TCP-connect retry loop handles @@ -100,9 +175,28 @@ export class SimulatorServer { }); } - kill(): void { - if (!this.child.killed && this.child.exitCode === null) { - this.child.kill('SIGTERM'); + kill(signal: NodeJS.Signals = 'SIGTERM'): void { + if (this.child.exitCode === null) { + this.child.kill(signal); + } + } + + async stop(timeoutMs = 5_000): Promise { + this.kill(); + + let timeout: ReturnType | undefined; + const timedOut = await Promise.race([ + this.exited.then(() => false), + new Promise((resolve) => { + timeout = setTimeout(() => { resolve(true); }, timeoutMs); + }), + ]); + if (timeout !== undefined) { + clearTimeout(timeout); + } + if (timedOut && this.child.exitCode === null) { + this.kill('SIGKILL'); + await this.exited; } } } diff --git a/test/simulators.json b/test/simulators.json index 8f8fc50..80ff1b2 100644 --- a/test/simulators.json +++ b/test/simulators.json @@ -1,4 +1,32 @@ [ + { + "url": "https://github.com/BitBoxSwiss/bitbox02-firmware/releases/download/firmware%2Fv9.19.0/bitbox02-multi-v9.19.0-simulator1.0.0-linux-amd64", + "sha256": "e28be3fd6c7777624ad2574546ba125b7f134f095fa951acc8fb7295f3d33931" + }, + { + "url": "https://github.com/BitBoxSwiss/bitbox02-firmware/releases/download/firmware%2Fv9.20.0/bitbox02-multi-v9.20.0-simulator1.0.0-linux-amd64", + "sha256": "ac32c1a71bd0a3a934bc7b94268f651c655f2e3afbb954811a256e551a420b3d" + }, + { + "url": "https://github.com/BitBoxSwiss/bitbox02-firmware/releases/download/firmware%2Fv9.21.0/bitbox02-multi-v9.21.0-simulator1.0.0-linux-amd64", + "sha256": "72031b226ea344970a6a1506893838a63b075e0bad726557ab9d941b42c534f5" + }, + { + "url": "https://github.com/BitBoxSwiss/bitbox02-firmware/releases/download/firmware%2Fv9.22.0/bitbox02-multi-v9.22.0-simulator1.0.0-linux-amd64", + "sha256": "3af12697f6fd51b155bf277ef01ef3eea5290908bff99a4aae83a95cb144ced1" + }, + { + "url": "https://github.com/BitBoxSwiss/bitbox02-firmware/releases/download/firmware%2Fv9.23.0/bitbox02-multi-v9.23.0-simulator1.0.0-linux-amd64", + "sha256": "2740eb4be1abd1eb8603478c7a00874f2bff66e620c229348094a427ae8a1fde" + }, + { + "url": "https://github.com/BitBoxSwiss/bitbox02-firmware/releases/download/firmware%2Fv9.24.0/bitbox02-multi-v9.24.0-simulator1.0.0-linux-amd64", + "sha256": "a64cefb90461f479e373b5f5dee4f340f07b4619fbcb55c87784f541b4400e34" + }, + { + "url": "https://github.com/BitBoxSwiss/bitbox02-firmware/releases/download/firmware%2Fv9.25.1/bitbox02-multi-v9.25.1-simulator1.0.0-linux-amd64", + "sha256": "f4b4294a1a339b3a7d52cc0f4d93a19846099807f03adefab2b42bf9899bd5d4" + }, { "url": "https://github.com/BitBoxSwiss/bitbox02-firmware/releases/download/firmware%2Fv9.26.1/bitbox02-multi-v9.26.1-simulator1.0.0-linux-amd64", "sha256": "91ddf47eb0653ce8b3d3344a8e329fc7fef90adfa51e39c5214830cf6e21cccf" From 2508a4f752ce1a496a65fcfaa4b361edc480d736 Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Sun, 17 May 2026 20:47:33 +0000 Subject: [PATCH 06/19] noise: map crypto failures to noise errors So malformed handshake messages, AEAD failures, and other Noise primitive errors cross the public boundary as noise errors instead of unknown-js. Transport queries are intentionally left outside the wrapper so existing communication and bridge error mapping is preserved. --- src/internal/noise.ts | 48 ++++++++++++++++++++++++++++++++++++------- test/pairing.test.ts | 47 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/src/internal/noise.ts b/src/internal/noise.ts index fbcffe7..109bd60 100644 --- a/src/internal/noise.ts +++ b/src/internal/noise.ts @@ -25,12 +25,32 @@ interface KeyPair { publicKey: Uint8Array; } +/** @internal */ +export class NoiseProtocolError extends Error { + readonly code = 'noise'; + constructor(message: string) { + super(message); + } +} + +function noiseError(message: string): NoiseProtocolError { + return new NoiseProtocolError(message); +} + function keyPairFromPrivateKey(privateKey: Uint8Array): KeyPair { - return { privateKey, publicKey: x25519.getPublicKey(privateKey) }; + try { + return { privateKey, publicKey: x25519.getPublicKey(privateKey) }; + } catch { + throw noiseError('invalid Noise private key'); + } } function dh(local: KeyPair, remotePub: Uint8Array): Uint8Array { - return x25519.getSharedSecret(local.privateKey, remotePub); + try { + return x25519.getSharedSecret(local.privateKey, remotePub); + } catch { + throw noiseError('Noise DH failed'); + } } function nonce96(n: bigint): Uint8Array { @@ -68,7 +88,12 @@ export class CipherState { if (this.k === undefined) { return plaintext; } - const ct = chacha20poly1305(this.k, nonce96(this.n), ad).encrypt(plaintext); + let ct: Uint8Array; + try { + ct = chacha20poly1305(this.k, nonce96(this.n), ad).encrypt(plaintext); + } catch { + throw noiseError('noise encryption failed'); + } this.n += 1n; return ct; } @@ -77,7 +102,12 @@ export class CipherState { if (this.k === undefined) { return ciphertext; } - const pt = chacha20poly1305(this.k, nonce96(this.n), ad).decrypt(ciphertext); + let pt: Uint8Array; + try { + pt = chacha20poly1305(this.k, nonce96(this.n), ad).decrypt(ciphertext); + } catch { + throw noiseError('noise decryption failed'); + } this.n += 1n; return pt; } @@ -210,7 +240,7 @@ export class NoiseXX { for (const tok of tokens) { if (tok === 'e') { if (message.length - cursor < DHLEN) { - throw new Error('handshake message truncated at remote ephemeral key'); + throw noiseError('handshake message truncated at remote ephemeral key'); } this.re = message.slice(cursor, cursor + DHLEN); cursor += DHLEN; @@ -218,7 +248,7 @@ export class NoiseXX { } else if (tok === 's') { const len = this.symmetric.hasCipherKey() ? DHLEN + TAGLEN : DHLEN; if (message.length - cursor < len) { - throw new Error('handshake message truncated at remote static key'); + throw noiseError('handshake message truncated at remote static key'); } const slice = message.slice(cursor, cursor + len); cursor += len; @@ -275,5 +305,9 @@ export function generateStaticPrivateKey(): Uint8Array { /** Derive the public key for an x25519 private key. @internal */ export function publicKeyFromPrivateKey(privateKey: Uint8Array): Uint8Array { - return x25519.getPublicKey(privateKey); + try { + return x25519.getPublicKey(privateKey); + } catch { + throw noiseError('invalid Noise private key'); + } } diff --git a/test/pairing.test.ts b/test/pairing.test.ts index eec9280..2878b5e 100644 --- a/test/pairing.test.ts +++ b/test/pairing.test.ts @@ -3,9 +3,15 @@ import { describe, expect, it } from 'vitest'; import type { Info } from '../src/internal/hww.js'; import { InMemoryNoiseConfig, containsDeviceStaticPubkey } from '../src/internal/noise-config.js'; -import { NoiseXX, publicKeyFromPrivateKey, type HandshakeFinalState } from '../src/internal/noise.js'; +import { + CipherState, + NoiseXX, + publicKeyFromPrivateKey, + type HandshakeFinalState, +} from '../src/internal/noise.js'; import { completePairing, + makeEncryptedChannel, performHandshake, type PairingTransport, } from '../src/internal/pairing.js'; @@ -168,4 +174,43 @@ describe('pairing flow', () => { }); expect(containsDeviceStaticPubkey(config.read(), publicKeyFromPrivateKey(deviceSk))).toBe(false); }); + + it('maps malformed handshake messages to noise errors', async () => { + const config = new InMemoryNoiseConfig(); + const device: PairingTransport = { + info: INFO, + async query(msg: Uint8Array): Promise { + const opcode = msg[0]; + if (opcode === OP_UNLOCK || opcode === OP_I_CAN_HAS_HANDSHAEK) { + return success(); + } + if (opcode === OP_HER_COMEZ_TEH_HANDSHAEK) { + return success(new Uint8Array(10)); + } + throw new Error(`unexpected opcode: ${opcode}`); + }, + }; + + await expect(performHandshake(device, config)).rejects.toMatchObject({ + code: 'noise', + }); + }); + + it('maps encrypted response authentication failures to noise errors', async () => { + const device: PairingTransport = { + info: INFO, + async query(_msg: Uint8Array): Promise { + return success(new Uint8Array(16)); + }, + }; + const channel = makeEncryptedChannel( + device, + new CipherState(pinned32(70)), + new CipherState(pinned32(71)), + ); + + await expect(channel.query(bytes(0x01))).rejects.toMatchObject({ + code: 'noise', + }); + }); }); From e1d7253b3987553d859ce7b242a539c0dba72ed8 Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Sun, 17 May 2026 20:48:09 +0000 Subject: [PATCH 07/19] bridge: wait for socket close callback Match the old bridge transport timing by letting the WebSocket close event invoke the onClose callback. Calling close() now only asks the socket to close instead of firing the callback directly. Keep the existing close guard so repeated close events or repeated close() calls still notify callers at most once. --- src/internal/transport-bridge.ts | 1 - test/transport-bridge.test.ts | 21 +++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/internal/transport-bridge.ts b/src/internal/transport-bridge.ts index 3cb57f9..fd33a22 100644 --- a/src/internal/transport-bridge.ts +++ b/src/internal/transport-bridge.ts @@ -125,7 +125,6 @@ export async function openBridge( }, close(): void { socket.close(); - guard(); }, }; } diff --git a/test/transport-bridge.test.ts b/test/transport-bridge.test.ts index 34c2cf7..9291396 100644 --- a/test/transport-bridge.test.ts +++ b/test/transport-bridge.test.ts @@ -148,6 +148,27 @@ describe('openBridge', () => { FakeBridgeSocket.onConstruct = null; }); + it('close() waits for the websocket close event before invoking onCloseCb', async () => { + const fetchMock = scriptedFetch([okJson({ devices: [{ path: 'p' }] })]); + let constructed: FakeBridgeSocket | null = null; + FakeBridgeSocket.onConstruct = (s) => { + constructed = s; + s.triggerOpen(); + }; + let calls = 0; + const transport = await openBridge(() => { calls += 1; }, { fetch: fetchMock.fn, WebSocket: FakeBridgeSocket }); + constructed!.close = () => { + constructed!.closed = true; + constructed!.readyState = 2; + }; + + transport.close(); + expect(calls).toBe(0); + constructed!.triggerClose(); + expect(calls).toBe(1); + FakeBridgeSocket.onConstruct = null; + }); + it('rejects with a typed bridge error on onerror', async () => { const fetchMock = scriptedFetch([okJson({ devices: [{ path: 'p' }] })]); FakeBridgeSocket.onConstruct = (s) => { s.triggerError(); }; From d7c5d215fa8ebc8a81314548ee74c353bb13e944 Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Sun, 17 May 2026 20:48:43 +0000 Subject: [PATCH 08/19] bridge: reject oversized WS frames Match the old U2F-WS bridge framing limit by rejecting payloads that do not fit in the reference MAX_LEN buffer before writing the frame length. This avoids sending a malformed frame whose 16-bit length header no longer represents the actual payload size. --- src/internal/u2f-framing.ts | 4 ++++ test/u2f-framing.test.ts | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/internal/u2f-framing.ts b/src/internal/u2f-framing.ts index bcb6656..f545e45 100644 --- a/src/internal/u2f-framing.ts +++ b/src/internal/u2f-framing.ts @@ -12,6 +12,7 @@ export const PACKET_SIZE = 64; export const MAX_LEN = 129 * PACKET_SIZE; const MAX_PAYLOAD = PACKET_SIZE - HEADER_INIT_LEN + 128 * (PACKET_SIZE - HEADER_CONT_LEN); +const MAX_WS_PAYLOAD = MAX_LEN - HEADER_INIT_LEN; /** * Total buffer length an encoded message occupies, counting the padding that @@ -155,6 +156,9 @@ export class U2fWs implements U2fFraming { } encode(message: Uint8Array): Uint8Array { + if (message.length > MAX_WS_PAYLOAD) { + throw new TransportError('u2f-decode', 'message exceeds U2F WS max payload'); + } const buf = new Uint8Array(HEADER_INIT_LEN + message.length); const v = viewOf(buf); v.setUint32(0, this.cid, false); diff --git a/test/u2f-framing.test.ts b/test/u2f-framing.test.ts index 1fb0a60..9f054a8 100644 --- a/test/u2f-framing.test.ts +++ b/test/u2f-framing.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { + HEADER_INIT_LEN, + MAX_LEN, U2fHid, U2fWs, getEncodedLen, @@ -133,6 +135,13 @@ describe('U2fWs', () => { expect(hex(out.subarray(0, 7))).toBe(hex(header)); expect(hex(out.subarray(7))).toBe(hex(payload)); }); + + it('rejects payloads that do not fit the old bridge frame buffer', () => { + const codec = new U2fWs(TEST_CMD, TEST_CID); + const maxPayload = MAX_LEN - HEADER_INIT_LEN; + expect(codec.encode(new Uint8Array(maxPayload)).length).toBe(MAX_LEN); + expect(() => codec.encode(new Uint8Array(maxPayload + 1))).toThrow(/max payload/); + }); }); describe('parseHeader', () => { From c0e59a21723f2281cd9c04c709a2123b8367d672 Mon Sep 17 00:00:00 2001 From: Marko Bencun Date: Sun, 17 May 2026 20:58:02 +0000 Subject: [PATCH 09/19] Add device info APIs and sandbox actions Wire deviceInfo and rootFingerprint through HWW protobuf requests. Add unit, simulator, and sandbox coverage matching bitbox-api-rs. --- sandbox/src/App.tsx | 2 +- sandbox/src/General.tsx | 106 +++++++++++++++++++++++++++++++++--- src/index.ts | 24 ++++++-- src/internal/device.ts | 44 +++++++++++++++ test/device-methods.test.ts | 91 +++++++++++++++++++++++++++++++ test/lifecycle.test.ts | 4 +- test/simulator-info.test.ts | 33 ++++++++++- 7 files changed, 288 insertions(+), 16 deletions(-) create mode 100644 src/internal/device.ts create mode 100644 test/device-methods.test.ts diff --git a/sandbox/src/App.tsx b/sandbox/src/App.tsx index 7e6aa11..2cfceda 100644 --- a/sandbox/src/App.tsx +++ b/sandbox/src/App.tsx @@ -86,7 +86,7 @@ function App() { - + {bb02.ethSupported() && ( diff --git a/sandbox/src/General.tsx b/sandbox/src/General.tsx index f165fbc..ee17ef3 100644 --- a/sandbox/src/General.tsx +++ b/sandbox/src/General.tsx @@ -1,12 +1,104 @@ // SPDX-License-Identifier: Apache-2.0 -export function General() { +import { FormEvent, useState } from 'react'; +import * as bitbox from '@bitboxswiss/bitbox-api'; + +import { ErrorNotification } from './ErrorNotification'; + +type Props = { bb02: bitbox.PairedBitBox }; + +function RootFingerprint({ bb02 }: Props) { + const [rootFingerprint, setRootFingerprint] = useState(''); + const [running, setRunning] = useState(false); + const [err, setErr] = useState(); + + const submitForm = async (e: FormEvent) => { + e.preventDefault(); + setRunning(true); + setRootFingerprint(''); + setErr(undefined); + try { + setRootFingerprint(await bb02.rootFingerprint()); + } catch (e2) { + setErr(bitbox.ensureError(e2)); + } finally { + setRunning(false); + } + }; + + return ( + <> +

Root Fingerprint

+
+ + {rootFingerprint !== '' && ( +
+ +
+ )} + {err !== undefined && ( + setErr(undefined)} /> + )} + + + ); +} + +function DeviceInfo({ bb02 }: Props) { + const [deviceInfo, setDeviceInfo] = useState(); + const [running, setRunning] = useState(false); + const [err, setErr] = useState(); + + const submitForm = async (e: FormEvent) => { + e.preventDefault(); + setRunning(true); + setDeviceInfo(undefined); + setErr(undefined); + try { + setDeviceInfo(await bb02.deviceInfo()); + } catch (e2) { + setErr(bitbox.ensureError(e2)); + } finally { + setRunning(false); + } + }; + + const parsedDeviceInfo = deviceInfo ? JSON.stringify(deviceInfo, undefined, 2) : ''; + + return ( + <> +

Device Info

+
+ + {deviceInfo !== undefined && ( +
+ +