From ed6e492bafb402b01a6a90efc180b68cfaaeb800 Mon Sep 17 00:00:00 2001 From: benma's agent Date: Sat, 5 Sep 2026 21:35:07 +0200 Subject: [PATCH] Implement recovery, password, and BIP85 workflows Implement showMnemonic(), changePassword(), and bip85AppBip39() using the Rust/WASM requests, firmware requirements, response validation, and existing device call queue. Port the General sandbox actions and password-change simulator coverage, and add protocol, version, cancellation, and lifecycle tests. Update documentation and release version 0.4.0. --- CHANGELOG.md | 3 + CONTRIBUTING.md | 4 +- README.md | 23 ++++++-- package-lock.json | 4 +- package.json | 2 +- sandbox/src/General.tsx | 99 +++++++++++++++++++++++++++++++++ src/index.ts | 34 +++--------- src/internal/device.ts | 53 +++++++++++++++++- test/device-methods.test.ts | 106 ++++++++++++++++++++++++++++++++++-- test/lifecycle.test.ts | 7 ++- test/simulator-info.test.ts | 26 +++++++++ 11 files changed, 321 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e1ce9c..c5981a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 0.4.0 +- Implement `showMnemonic()`, `changePassword()`, and `bip85AppBip39()` with the Rust/WASM firmware requirements + ## 0.3.0 - Add Bitcoin APIs, sandbox actions, and simulator transaction-vector coverage - Validate Bitcoin and Ethereum ECDSA signatures in Anti-Klepto and direct signing flows diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb9cae5..b637b91 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,7 +94,9 @@ The package is intended as a drop-in TypeScript replacement for the current taxonomy unless there is an explicit reason to extend it. - Bitcoin xpub, address, script config, PSBT, and message-signing support is implemented; keep it aligned with the Rust/WASM reference and simulator - vectors. BIP85 methods remain compatibility stubs. + vectors. +- Recovery-word display, password changes, and BIP85-BIP39 derivation are + implemented. - Cardano xpub, address, and transaction-signing support is implemented; keep it aligned with the Rust/WASM reference behavior and simulator vectors. diff --git a/README.md b/README.md index 0e7afc2..b7eab50 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,11 @@ applications. ## Status - **Implemented:** WebHID and BitBoxBridge transports, Noise XX pairing, - device metadata helpers, Bitcoin xpub/address/PSBT/message signing methods, + device metadata helpers, recovery-word display, password changes, BIP85-BIP39 + mnemonic derivation, Bitcoin xpub/address/PSBT/message signing methods, script config registration, Ethereum xpub/address/signing methods, antiklepto, transaction data streaming, EIP-712 typed messages, `ethIdentifyCase()`, and Cardano xpub/address/signing methods. -- **Stubbed with `code: 'unsupported'`:** BIP85 methods. -- **Stubbed with `code: 'not-implemented'`:** `showMnemonic()` and - `changePassword()`. ## Installation @@ -93,6 +91,19 @@ closed. Reconnect before retrying. Call `bb02.close()` when your app is done with the device. `close()` is idempotent and invokes the `onClose` callback supplied to the connect function. +## Device Workflows + +These workflows run on the device and resolve when complete: + +```ts +await bb02.showMnemonic(); // Display the recovery words on the device. +await bb02.changePassword(); // Change the password (firmware >=9.25.0). +await bb02.bip85AppBip39(); // Derive and display a BIP39 mnemonic (firmware >=9.17.0). +``` + +For BIP85, the user selects the word count (12, 18, or 24) and derivation index +on the device. Recovery words and derived mnemonics are not returned to the host. + ## Bitcoin Usage Bitcoin-family methods accept coin, keypath, script config, and xpub type @@ -318,7 +329,6 @@ Common client-facing codes include: - `communication`, `noise`, `noise-config`, `pairing-rejected`: transport, pairing, or encrypted-channel failures. - `version`: the connected firmware is too old for the requested method. -- `unsupported` / `not-implemented`: methods that are not currently available. ## Sandbox and Development @@ -332,6 +342,9 @@ make sandbox-dev Open the printed Vite URL, usually `http://localhost:5173`. +The General accordion covers device info, the root fingerprint, recovery-word +display, password changes, and BIP85-BIP39 derivation. + The Bitcoin accordion covers xpubs, addresses, script config registration, PSBT signing, and message signing. Bitcoin unit tests are part of `npm run build && npm test`; the firmware transaction-vector suite runs against diff --git a/package-lock.json b/package-lock.json index 7f5b5fc..250b347 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitboxswiss/bitbox-api", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitboxswiss/bitbox-api", - "version": "0.3.0", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "2.11.0", diff --git a/package.json b/package.json index 4a710fb..00a5b6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitboxswiss/bitbox-api", - "version": "0.3.0", + "version": "0.4.0", "description": "A pure TypeScript library to interact with BitBox hardware wallets.", "license": "Apache-2.0", "type": "module", diff --git a/sandbox/src/General.tsx b/sandbox/src/General.tsx index ee17ef3..3aa19d6 100644 --- a/sandbox/src/General.tsx +++ b/sandbox/src/General.tsx @@ -90,6 +90,96 @@ function DeviceInfo({ bb02 }: Props) { ); } +function ShowMnemonic({ bb02 }: Props) { + const [running, setRunning] = useState(false); + const [err, setErr] = useState(); + + const actionShowMnemonic = async (e: FormEvent) => { + e.preventDefault(); + setRunning(true); + setErr(undefined); + try { + await bb02.showMnemonic(); + } catch (err) { + setErr(bitbox.ensureError(err)); + } finally { + setRunning(false); + } + }; + + return ( + <> +

Recovery Words

+
+ + {err !== undefined && ( + setErr(undefined)} /> + )} + + + ); +} + +function ChangePassword({ bb02 }: Props) { + const [running, setRunning] = useState(false); + const [err, setErr] = useState(); + + const actionChangePassword = async (e: FormEvent) => { + e.preventDefault(); + setRunning(true); + setErr(undefined); + try { + await bb02.changePassword(); + } catch (err) { + setErr(bitbox.ensureError(err)); + } finally { + setRunning(false); + } + }; + + return ( + <> +

Change Password

+
+ + {err !== undefined && ( + setErr(undefined)} /> + )} + + + ); +} + +function Bip85AppBip39({ bb02 }: Props) { + const [running, setRunning] = useState(false); + const [err, setErr] = useState(); + + const actionBip85 = async (e: FormEvent) => { + e.preventDefault(); + setRunning(true); + setErr(undefined); + try { + await bb02.bip85AppBip39(); + } catch (err) { + setErr(bitbox.ensureError(err)); + } finally { + setRunning(false); + } + }; + + return ( + <> +

BIP-85

+
+ + {err !== undefined && ( + setErr(undefined)} /> + )} + + + ); +} + export function General({ bb02 }: Props) { return ( <> @@ -99,6 +189,15 @@ export function General({ bb02 }: Props) {
+
+ +
+
+ +
+
+ +
); } diff --git a/src/index.ts b/src/index.ts index c8d5cd1..6cf2aa1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,16 +8,17 @@ import { } from './internal/connect.js'; import { CODE_INVALID_STATE, - CODE_NOT_IMPLEMENTED, - CODE_UNSUPPORTED, CODE_USER_ABORT, CODE_BITBOX_USER_ABORT, ensureTyped, toPublicError, } from './internal/errors.js'; import { + bip85AppBip39 as bip85AppBip39Impl, + changePassword as changePasswordImpl, deviceInfo as deviceInfoImpl, rootFingerprint as rootFingerprintImpl, + showMnemonic as showMnemonicImpl, } from './internal/device.js'; import { cardanoAddress as cardanoAddressImpl, @@ -50,20 +51,6 @@ import { type PairingState, } from './internal/pairing.js'; -function unsupportedError(method: string): Error { - return { - code: CODE_UNSUPPORTED, - 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 @bitboxswiss/bitbox-api`, - }; -} - function invalidStateError(method: string): Error { return { code: CODE_INVALID_STATE, @@ -594,16 +581,14 @@ export class PairedBitBox { return this.#runExclusive('rootFingerprint', open => rootFingerprintImpl(open.channel)); } - /** Not implemented in this TypeScript iteration. */ + /** Show recovery words on the BitBox. */ async showMnemonic(): Promise { - this.#requireOpen('showMnemonic'); - throw notImplementedError('showMnemonic'); + return this.#runExclusive('showMnemonic', open => showMnemonicImpl(open.channel)); } - /** Not implemented in this TypeScript iteration. */ + /** Invokes the password change workflow on the device. Requires firmware >=9.25.0. */ async changePassword(): Promise { - this.#requireOpen('changePassword'); - throw notImplementedError('changePassword'); + return this.#runExclusive('changePassword', open => changePasswordImpl(open.channel, open.info)); } /** Retrieves a Bitcoin-family account xpub. */ @@ -851,11 +836,10 @@ export class PairedBitBox { * Invokes the BIP85-BIP39 workflow on the device, letting the user select the number of words * (12, 18, 24) and an index and display a derived BIP-39 mnemonic. * - * Compatibility stub: BIP85 support is not implemented in this TypeScript iteration. + * Requires firmware >=9.17.0. */ async bip85AppBip39(): Promise { - this.#requireOpen('bip85AppBip39'); - throw unsupportedError('bip85AppBip39'); + return this.#runExclusive('bip85AppBip39', open => bip85AppBip39Impl(open.channel, open.info)); } } diff --git a/src/internal/device.ts b/src/internal/device.ts index de7fd9d..7b35165 100644 --- a/src/internal/device.ts +++ b/src/internal/device.ts @@ -3,11 +3,18 @@ import { create } from '@bufbuild/protobuf'; import { bytesToHex } from '@noble/hashes/utils'; import type { DeviceInfo } from '../index.js'; -import { DeviceInfoRequestSchema } from '../proto/gen/bitbox02_system_pb.js'; +import { + ChangePasswordRequestSchema, + DeviceInfoRequestSchema, +} from '../proto/gen/bitbox02_system_pb.js'; import { RootFingerprintRequestSchema } from '../proto/gen/common_pb.js'; import { RequestSchema } from '../proto/gen/hww_pb.js'; +import { BIP85RequestSchema } from '../proto/gen/keystore_pb.js'; +import { ShowMnemonicRequestSchema } from '../proto/gen/mnemonic_pb.js'; +import type { Info } from './hww.js'; import type { EncryptedChannel } from './pairing.js'; import { query, unexpectedResponse } from './proto-query.js'; +import { requireVersion } from './version.js'; export async function deviceInfo(channel: EncryptedChannel): Promise { const response = await query(channel, create(RequestSchema, { @@ -42,3 +49,47 @@ export async function rootFingerprint(channel: EncryptedChannel): Promise { + const response = await query(channel, create(RequestSchema, { + request: { + case: 'showMnemonic', + value: create(ShowMnemonicRequestSchema), + }, + })); + if (response.response.case !== 'success') { + throw unexpectedResponse(); + } +} + +/** Invokes the password change workflow on the device. Requires firmware >=9.25.0. */ +export async function changePassword(channel: EncryptedChannel, info: Info): Promise { + requireVersion(info, { major: 9, minor: 25, patch: 0 }); + const response = await query(channel, create(RequestSchema, { + request: { + case: 'changePassword', + value: create(ChangePasswordRequestSchema), + }, + })); + if (response.response.case !== 'success') { + throw unexpectedResponse(); + } +} + +/** + * Invokes the BIP85-BIP39 workflow on the device, letting the user select the number of words + * (12, 18, 24) and an index and display a derived BIP-39 mnemonic. + */ +export async function bip85AppBip39(channel: EncryptedChannel, info: Info): Promise { + requireVersion(info, { major: 9, minor: 17, patch: 0 }); + const response = await query(channel, create(RequestSchema, { + request: { + case: 'bip85', + value: create(BIP85RequestSchema, { app: { case: 'bip39', value: {} } }), + }, + })); + if (response.response.case !== 'bip85' || response.response.value.app.case !== 'bip39') { + throw unexpectedResponse(); + } +} diff --git a/test/device-methods.test.ts b/test/device-methods.test.ts index 636935a..af20f65 100644 --- a/test/device-methods.test.ts +++ b/test/device-methods.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { PairedBitBox } from '../src/index.js'; import { DeviceInfoResponseSchema } from '../src/proto/gen/bitbox02_system_pb.js'; import { RootFingerprintResponseSchema } from '../src/proto/gen/common_pb.js'; @@ -50,6 +50,18 @@ function responseFor(request: Request): Uint8Array { }, }); break; + case 'showMnemonic': + case 'changePassword': + response = create(ResponseSchema, { response: { case: 'success', value: {} } }); + break; + case 'bip85': + response = create(ResponseSchema, { + response: { + case: 'bip85', + value: { app: { case: 'bip39', value: {} } }, + }, + }); + break; default: throw new Error(`unexpected request: ${request.request.case}`); } @@ -67,10 +79,14 @@ function deferred(): { promise: Promise; resolve: () => void } { class FakeDeviceChannel implements EncryptedChannel { readonly requests: Request['request']['case'][] = []; + constructor(private readonly response?: Response) {} + async query(plaintext: Uint8Array): Promise { const request = fromBinary(RequestSchema, plaintext); this.requests.push(request.request.case); - return responseFor(request); + return this.response === undefined + ? responseFor(request) + : toBinary(ResponseSchema, this.response); } } @@ -142,6 +158,78 @@ describe('device methods', () => { expect(channel.requests).toEqual(['fingerprint']); }); + it.each([ + // Rust sends empty ShowMnemonic/ChangePassword requests and expects Success. + ['showMnemonic', '7.0.0', [0x3a, 0x00], [0x0a, 0x00]], + ['changePassword', '9.25.0', [0xf2, 0x01, 0x00], [0x0a, 0x00]], + // BIP85 requires the nested BIP39 app in both the request and response. + ['bip85AppBip39', '9.17.0', [0xe2, 0x01, 0x02, 0x0a, 0x00], [0x82, 0x01, 0x02, 0x0a, 0x00]], + ] as const)('%s uses the Rust protocol at its minimum firmware version', async ( + method, version, requestBytes, responseBytes, + ) => { + const query = vi.fn(async () => new Uint8Array(responseBytes)); + const paired = new PairedBitBox({ + channel: { query }, + info: { ...INFO, version }, + close(): void {}, + }); + + await expect(paired[method]()).resolves.toBeUndefined(); + expect(query).toHaveBeenCalledExactlyOnceWith(new Uint8Array(requestBytes)); + }); + + it.each([ + ['changePassword', '9.24.0', '>=9.25.0'], + ['bip85AppBip39', '9.16.0', '>=9.17.0'], + ] as const)('%s rejects older firmware before querying the device', async ( + method, version, required, + ) => { + const channel = new FakeDeviceChannel(); + const paired = new PairedBitBox({ channel, info: { ...INFO, version }, close(): void {} }); + + await expect(paired[method]()).rejects.toMatchObject({ + code: 'version', + message: `firmware version ${required} required`, + }); + expect(channel.requests).toEqual([]); + }); + + describe.each(['showMnemonic', 'changePassword', 'bip85AppBip39'] as const)('%s', (method) => { + it('rejects an unexpected response', async () => { + const channel = new FakeDeviceChannel(create(ResponseSchema, { + response: { case: 'fingerprint', value: {} }, + })); + const paired = new PairedBitBox({ channel, info: INFO, close(): void {} }); + + await expect(paired[method]()).rejects.toMatchObject({ code: 'unexpected-response' }); + }); + + it('propagates device cancellation', async () => { + const channel = new FakeDeviceChannel(create(ResponseSchema, { + response: { case: 'error', value: { code: 104 } }, + })); + const paired = new PairedBitBox({ channel, info: INFO, close(): void {} }); + + await expect(paired[method]()).rejects.toMatchObject({ + code: 'bitbox-user-abort', + message: 'bitbox error: aborted by the user', + }); + }); + }); + + it.each([ + create(ResponseSchema, { response: { case: 'success', value: {} } }), + create(ResponseSchema, { response: { case: 'bip85', value: {} } }), + create(ResponseSchema, { + response: { case: 'bip85', value: { app: { case: 'ln', value: new Uint8Array(32) } } }, + }), + ])('bip85AppBip39 requires a BIP39 app response (%#)', async (response) => { + const channel = new FakeDeviceChannel(response); + const paired = new PairedBitBox({ channel, info: INFO, close(): void {} }); + + await expect(paired.bip85AppBip39()).rejects.toMatchObject({ code: 'unexpected-response' }); + }); + it('maps a missing top-level response oneof to protobuf-decode', async () => { const channel = new EmptyResponseChannel(); const paired = new PairedBitBox({ channel, info: INFO, close(): void {} }); @@ -160,6 +248,9 @@ describe('device methods', () => { await channel.firstQueryStarted.promise; const rootFingerprint = paired.rootFingerprint(); + const showMnemonic = paired.showMnemonic(); + const changePassword = paired.changePassword(); + const bip85AppBip39 = paired.bip85AppBip39(); await Promise.resolve(); await Promise.resolve(); @@ -167,7 +258,9 @@ describe('device methods', () => { expect(channel.maxActiveQueries).toBe(1); channel.releaseFirstQuery.resolve(); - await expect(Promise.all([deviceInfo, rootFingerprint])).resolves.toEqual([ + await expect(Promise.all([ + deviceInfo, rootFingerprint, showMnemonic, changePassword, bip85AppBip39, + ])).resolves.toEqual([ { name: 'My BitBox', initialized: true, @@ -177,8 +270,13 @@ describe('device methods', () => { monotonicIncrementsRemaining: 42, }, '4c00739d', + undefined, + undefined, + undefined, + ]); + expect(channel.requests).toEqual([ + 'deviceInfo', 'fingerprint', 'showMnemonic', 'changePassword', 'bip85', ]); - expect(channel.requests).toEqual(['deviceInfo', 'fingerprint']); expect(channel.maxActiveQueries).toBe(1); }); diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 5c3c961..c1021bd 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -280,6 +280,8 @@ describe('PairedBitBox lifecycle', () => { const p = new PairedBitBox(); await expect(p.deviceInfo()).rejects.toMatchObject({ code: 'invalid-state' }); await expect(p.rootFingerprint()).rejects.toMatchObject({ code: 'invalid-state' }); + await expect(p.showMnemonic()).rejects.toMatchObject({ code: 'invalid-state' }); + await expect(p.changePassword()).rejects.toMatchObject({ code: 'invalid-state' }); await expect(p.btcXpub('btc', [0], 'xpub', false)).rejects.toMatchObject({ code: 'invalid-state' }); await expect(p.ethXpub("m/44'/60'/0'/0/0")).rejects.toMatchObject({ code: 'invalid-state' }); await expect(p.ethAddress(1n, "m/44'/60'/0'/0/0", false)).rejects.toMatchObject({ code: 'invalid-state' }); @@ -301,7 +303,7 @@ describe('PairedBitBox lifecycle', () => { const paired = await pairing.waitConfirm(); expect(paired.product()).toBe('bitbox02-multi'); - await expect(paired.changePassword()).rejects.toMatchObject({ code: 'not-implemented' }); + await expect(paired.changePassword()).rejects.toMatchObject({ code: 'version' }); await expect(paired.btcXpub('btc', [0], 'xpub', false)).rejects.toMatchObject({ code: 'unexpected-response', }); @@ -319,6 +321,9 @@ describe('PairedBitBox lifecycle', () => { try { paired.product(); } catch (err) { expect(asBitboxError(err).code).toBe('invalid-state'); } await expect(paired.deviceInfo()).rejects.toMatchObject({ code: 'invalid-state' }); await expect(paired.rootFingerprint()).rejects.toMatchObject({ code: 'invalid-state' }); + await expect(paired.showMnemonic()).rejects.toMatchObject({ code: 'invalid-state' }); + await expect(paired.changePassword()).rejects.toMatchObject({ code: 'invalid-state' }); + await expect(paired.bip85AppBip39()).rejects.toMatchObject({ code: 'invalid-state' }); await expect(paired.btcXpub('btc', [0], 'xpub', false)).rejects.toMatchObject({ code: 'invalid-state' }); await expect(paired.ethXpub("m/44'/60'/0'/0/0")).rejects.toMatchObject({ code: 'invalid-state' }); }); diff --git a/test/simulator-info.test.ts b/test/simulator-info.test.ts index fd3a135..73635cd 100644 --- a/test/simulator-info.test.ts +++ b/test/simulator-info.test.ts @@ -110,4 +110,30 @@ describe.skipIf(!ENABLED).sequential.each(simulatorCases())('simulator info prob expect(onCloseCalls).toBe(1); }, 30_000); + + // Ported from bitbox-api-rs/tests/test_device.rs::test_change_password. + it('changePassword succeeds on supported firmware and rejects older versions', async () => { + const session = await connectSimulator(undefined, undefined, new NoiseConfigNoCache()); + try { + const pairing = await performHandshake(session.hww, session.config); + const channel = await completePairing(pairing); + await restoreFromMnemonic(channel); + const paired = new PairedBitBox({ channel, info: session.hww.info, close: session.close }); + try { + if (atLeast(version, { major: 9, minor: 25, patch: 0 })) { + await expect(paired.changePassword()).resolves.toBeUndefined(); + } else { + await expect(paired.changePassword()).rejects.toMatchObject({ + code: 'version', + message: 'firmware version >=9.25.0 required', + }); + } + } finally { + paired.close(); + } + } catch (err) { + session.close(); + throw err; + } + }, 30_000); });