Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
21 changes: 21 additions & 0 deletions src/internal/hww.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -222,6 +224,24 @@ export async function getInfo(rw: ReadWrite): Promise<Info> {
};
}

async function resetSession(comm: ReadWrite, version: string, sleeper: Sleeper): Promise<void> {
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('info', 'unexpected session reset response');
}
}

/**
* Adds the HWW request/response framing opcode layer plus BUSY/NOTREADY
* retry logic on top of the U2F-framed communication.
Expand All @@ -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);
}

Expand Down
51 changes: 51 additions & 0 deletions test/simulator-info.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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());
Expand Down
Loading