Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/internal/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
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('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.
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
1 change: 1 addition & 0 deletions src/internal/read-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type TransportErrorCode =
| 'read'
| 'u2f-decode'
| 'info'
| 'resetSession'
| 'version'
| 'bridge'
| 'simulator'
Expand Down
1 change: 1 addition & 0 deletions test/error-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions test/hww.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)];
Expand All @@ -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', () => {
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