From 0989eff6d932530353fe91379542890e7e772e79 Mon Sep 17 00:00:00 2001 From: benma's agent Date: Thu, 17 Sep 2026 15:40:29 +0200 Subject: [PATCH] transport: reset sessions on connect Disconnecting after an intermediate signing response leaves the firmware waiting for a continuation while the device stays powered. On reconnect, the old workflow consumes the first unlock request and returns an encrypted error. Pairing can still succeed because the unlock response is ignored, masking the stale session. Send HWW_REQ_RESET (0x03) after version discovery and before unlock and Noise pairing on firmware v9.28.0 or newer. Keep the helper private, retry BUSY responses once per second, and require an ACK without a payload. Older firmware keeps its existing connection flow. Invalid reset replies use the internal `resetSession` code and surface as `communication error: error resetting session` through the public API. Add unit coverage for the version gate, startup order, BUSY retries, malformed reset replies and public error mapping. Add a simulator regression that disconnects with signing unfinished, reconnects to the same running process, checks the real unlock response, pairs, and reads the root fingerprint. Firmware counterpart: https://github.com/BitBoxSwiss/bitbox02-firmware/pull/2111 Related client PRs in this change: - [Go #187](https://github.com/BitBoxSwiss/bitbox02-api-go/pull/187) - [Rust #134](https://github.com/BitBoxSwiss/bitbox-api-rs/pull/134) --- CHANGELOG.md | 4 +++ src/internal/errors.ts | 2 ++ src/internal/hww.ts | 21 ++++++++++++ src/internal/read-write.ts | 1 + test/error-codes.test.ts | 1 + test/hww.test.ts | 66 +++++++++++++++++++++++++++++++++++++ test/simulator-info.test.ts | 51 ++++++++++++++++++++++++++++ 7 files changed, 146 insertions(+) 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());