diff --git a/CHANGELOG.md b/CHANGELOG.md index c5981a4..d348a85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased +- Reset unfinished sessions when connecting to firmware v9.28.0 or newer, allowing host + reconnects while the device remains powered on. + ## 0.4.0 - Implement `showMnemonic()`, `changePassword()`, and `bip85AppBip39()` with the Rust/WASM firmware requirements diff --git a/src/internal/errors.ts b/src/internal/errors.ts index fe44508..27c8e7b 100644 --- a/src/internal/errors.ts +++ b/src/internal/errors.ts @@ -269,6 +269,8 @@ export function toPublicError(err: unknown): BitBoxError { return communicationError('u2f framing decoding error'); case 'info': return communicationError('error querying device info'); + case 'resetSession': + return communicationError('error resetting session'); case 'noise-pairing-rejected': return makeError(CODE_PAIRING_REJECTED, 'pairing code rejected by user'); } diff --git a/src/internal/hww.ts b/src/internal/hww.ts index 6aaa49d..916863d 100644 --- a/src/internal/hww.ts +++ b/src/internal/hww.ts @@ -10,6 +10,8 @@ export const HWW_REQ_NEW = 0x00; /** @internal */ export const HWW_REQ_RETRY = 0x01; /** @internal */ +export const HWW_REQ_RESET = 0x03; +/** @internal */ export const HWW_INFO = 0x69; /** @internal */ @@ -222,6 +224,24 @@ export async function getInfo(rw: ReadWrite): Promise { }; } +async function resetSession(comm: ReadWrite, version: string, sleeper: Sleeper): Promise { + if (!atLeast(parseSemver(version), { major: 9, minor: 28, patch: 0 })) { + return; + } + // Send at the framing layer so an unfinished workflow cannot consume the request. + for (;;) { + const response = await query(comm, new Uint8Array([HWW_REQ_RESET])); + if (response.length === 1 && response[0] === HWW_RSP_ACK) { + return; + } + if (response.length === 1 && response[0] === HWW_RSP_BUSY) { + await sleeper.sleep(BUSY_SLEEP_MS); + continue; + } + throw new TransportError('resetSession', 'unexpected session reset response'); + } +} + /** * Adds the HWW request/response framing opcode layer plus BUSY/NOTREADY * retry logic on top of the U2F-framed communication. @@ -243,6 +263,7 @@ export class HwwCommunication { if (!atLeast(parseSemver(info.version), { major: 7, minor: 0, patch: 0 })) { throw new TransportError('version', 'firmware version >=7.0.0 required'); } + await resetSession(comm, info.version, sleeper); return new HwwCommunication(comm, info, sleeper); } diff --git a/src/internal/read-write.ts b/src/internal/read-write.ts index 1ef72c2..a94b77a 100644 --- a/src/internal/read-write.ts +++ b/src/internal/read-write.ts @@ -17,6 +17,7 @@ export type TransportErrorCode = | 'read' | 'u2f-decode' | 'info' + | 'resetSession' | 'version' | 'bridge' | 'simulator' diff --git a/test/error-codes.test.ts b/test/error-codes.test.ts index b026d6f..e0b9792 100644 --- a/test/error-codes.test.ts +++ b/test/error-codes.test.ts @@ -273,6 +273,7 @@ describe('toPublicError', () => { ['read', 'communication error: read error'], ['u2f-decode', 'communication error: u2f framing decoding error'], ['info', 'communication error: error querying device info'], + ['resetSession', 'communication error: error resetting session'], ] as const)('maps %s to communication', (code, message) => { expect(publicShape(toPublicError({ code, message: 'internal detail' }))).toEqual({ code: CODE_COMMUNICATION, diff --git a/test/hww.test.ts b/test/hww.test.ts index 7cf96b8..59964b0 100644 --- a/test/hww.test.ts +++ b/test/hww.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'; import { + HWW_INFO, HWW_REQ_NEW, + HWW_REQ_RESET, HWW_REQ_RETRY, HWW_RSP_ACK, HWW_RSP_BUSY, @@ -122,6 +124,11 @@ describe('getInfo', () => { }); describe('HwwCommunication.create', () => { + function infoResponse(version = '9.28.0'): Uint8Array { + const encoded = new TextEncoder().encode(`v${version}`); + return bytes(encoded.length, ...encoded, 0x00, 0x00, 0x01, 0x01); + } + it('rejects devices running firmware <7.0.0', async () => { // length=5 "v6.9.9", platform=0, edition=0, unlocked=1 const script = [bytes(0x06, 0x76, 0x36, 0x2e, 0x39, 0x2e, 0x39, 0x00, 0x00, 0x01)]; @@ -136,6 +143,65 @@ describe('HwwCommunication.create', () => { expect(hww.info.version).toBe('9.18.0'); expect(hww.info.product).toBe('bitbox02-multi'); }); + + it.each(['7.0.0', '9.27.2'])('skips session reset on firmware %s', async (version) => { + const t = new ScriptedTransport([infoResponse(version)]); + await HwwCommunication.create(t); + expect(t.writes).toEqual([bytes(HWW_INFO)]); + }); + + it.each(['9.28.0', '9.29.0', '10.0.0'])( + 'resets firmware %s after INFO and before unlock', async (version) => { + const t = new ScriptedTransport([ + infoResponse(version), + bytes(HWW_RSP_ACK), + bytes(HWW_RSP_ACK, 0x00), + ]); + const hww = await HwwCommunication.create(t); + await expect(hww.query(bytes(0x75))).resolves.toEqual(bytes(0x00)); + expect(t.writes).toEqual([ + bytes(HWW_INFO), + bytes(HWW_REQ_RESET), + bytes(HWW_REQ_NEW, 0x75), + ]); + }, + ); + + it('retries session reset every second while firmware is BUSY', async () => { + const t = new ScriptedTransport([ + infoResponse(), + bytes(HWW_RSP_BUSY), + bytes(HWW_RSP_BUSY), + bytes(HWW_RSP_ACK), + ]); + const sleeper = fakeSleeper(); + await HwwCommunication.create(t, sleeper); + expect(sleeper.calls).toEqual([1000, 1000]); + expect(t.writes).toEqual([ + bytes(HWW_INFO), + bytes(HWW_REQ_RESET), + bytes(HWW_REQ_RESET), + bytes(HWW_REQ_RESET), + ]); + }); + + it.each([ + ['empty', bytes()], + ['NACK', bytes(HWW_RSP_NACK)], + ['NOTREADY', bytes(HWW_RSP_NOTREADY)], + ['unknown opcode', bytes(0xff)], + ['ACK with payload', bytes(HWW_RSP_ACK, 0x00)], + ['BUSY with payload', bytes(HWW_RSP_BUSY, 0x00)], + ] as const)('rejects reset response %s with resetSession', async (_name, response) => { + const t = new ScriptedTransport([infoResponse(), response]); + const sleeper = fakeSleeper(); + await expect(HwwCommunication.create(t, sleeper)).rejects.toMatchObject({ + code: 'resetSession', + message: 'unexpected session reset response', + }); + expect(t.writes).toEqual([bytes(HWW_INFO), bytes(HWW_REQ_RESET)]); + expect(sleeper.calls).toEqual([]); + }); }); describe('HwwCommunication.query', () => { diff --git a/test/simulator-info.test.ts b/test/simulator-info.test.ts index 73635cd..a322e91 100644 --- a/test/simulator-info.test.ts +++ b/test/simulator-info.test.ts @@ -1,12 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { create } from '@bufbuild/protobuf'; import { BitBox, PairedBitBox } 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 { completePairing, performHandshake } from '../src/internal/pairing.js'; import { restoreFromMnemonic } from '../src/internal/restore.js'; +import { query } from '../src/internal/proto-query.js'; +import { RequestSchema } from '../src/proto/gen/hww_pb.js'; +import { BTCCoin, BTCScriptConfig_SimpleType, BTCSignNextResponse_Type } from '../src/proto/gen/btc_pb.js'; import { SimulatorServer, ensureSimulator, @@ -111,6 +115,53 @@ describe.skipIf(!ENABLED).sequential.each(simulatorCases())('simulator info prob expect(onCloseCalls).toBe(1); }, 30_000); + // Resetting an unfinished workflow requires firmware v9.28.0 or newer. + it.skipIf(!atLeast(version, { major: 9, minor: 28, patch: 0 }))( + 'reconnects after disconnecting with an unfinished signing request', async () => { + const session = await connectSimulator(); + try { + const pairing = await performHandshake(session.hww, session.config); + const channel = await completePairing(pairing); + await restoreFromMnemonic(channel); + // Leave the signing workflow waiting for its next request when the host disconnects. + const response = await query(channel, create(RequestSchema, { + request: { + case: 'btcSignInit', + value: { + coin: BTCCoin.BTC, + scriptConfigs: [{ + scriptConfig: { config: { case: 'simpleType', value: BTCScriptConfig_SimpleType.P2WPKH } }, + keypath: [84 + 0x80000000, 0x80000000, 0x80000000], + }], + version: 2, + numInputs: 1, + numOutputs: 1, + }, + }, + })); + expect(response.response.case).toBe('btcSignNext'); + if (response.response.case !== 'btcSignNext') { + throw new Error('expected the firmware to request the first transaction input'); + } + expect(response.response.value.type).toBe(BTCSignNextResponse_Type.INPUT); + } finally { + session.close(); + } + + // Reconnect to the same running simulator, preserving the firmware session state. + const reconnected = await connectSimulator(); + try { + // Check the first setup response: unlockAndPair currently ignores unlock errors. + await expect(reconnected.hww.query(new Uint8Array([0x75]))) + .resolves.toEqual(new Uint8Array([0x00])); + const pairing = await new BitBox(reconnected).unlockAndPair(); + const paired = await pairing.waitConfirm(); + await expect(paired.rootFingerprint()).resolves.toBe('4c00739d'); + } finally { + reconnected.close(); + } + }, 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());