From 04e7f80f1ce7ddfcc454d6d818a560889190402e Mon Sep 17 00:00:00 2001 From: benma's agent Date: Thu, 17 Sep 2026 15:40:29 +0200 Subject: [PATCH] api/firmware: 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 attestation request and returns an encrypted error, causing the attestation check to fail. Send HWW_REQ_RESET (0x03) after version discovery and before attestation, 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. Add a simulator regression that disconnects with signing unfinished, reconnects to the same running process, checks the real attestation response, pairs, and reads the root fingerprint. Older simulator versions exercise ordinary reconnects. The regression uses the locally built v9.28.0 C simulator and fails if the reset call is disabled. Firmware counterpart: https://github.com/BitBoxSwiss/bitbox02-firmware/pull/2111 --- api/firmware/device.go | 4 +++ api/firmware/device_test.go | 64 +++++++++++++++++++++++++++++++++++++ api/firmware/query.go | 23 +++++++++++++ 3 files changed, 91 insertions(+) diff --git a/api/firmware/device.go b/api/firmware/device.go index 2408507..8131c89 100644 --- a/api/firmware/device.go +++ b/api/firmware/device.go @@ -276,6 +276,10 @@ func (device *Device) Init() error { return nil } + if err := device.resetSession(); err != nil { + return err + } + attestation, err := device.performAttestation() if err != nil { return err diff --git a/api/firmware/device_test.go b/api/firmware/device_test.go index 945a225..8f828b2 100644 --- a/api/firmware/device_test.go +++ b/api/firmware/device_test.go @@ -209,6 +209,9 @@ func runSimulator(filename string) (func() error, *Device, *simulatorStdout, err ) return func() error { connErr := conn.Close() + if errors.Is(connErr, net.ErrClosed) { + connErr = nil + } killErr := cmd.Process.Kill() <-scannerDone _ = cmd.Wait() @@ -377,6 +380,67 @@ func testInitializedSimulators(t *testing.T, run func(*testing.T, *Device, *simu }) } +// Records real simulator responses without replacing the transport or firmware behavior. +type recordingCommunication struct { + Communication + + attestationResponse []byte +} + +func (communication *recordingCommunication) Query(request []byte) ([]byte, error) { + response, err := communication.Communication.Query(request) + if bytes.HasPrefix(request, []byte(hwwReqNew+opAttestation)) { + communication.attestationResponse = response + } + return response, err +} + +func TestSimulatorReconnect(t *testing.T) { + testInitializedSimulators(t, func(t *testing.T, device *Device, stdOut *simulatorStdout) { + t.Helper() + if device.version.AtLeast(semver.NewSemVer(9, 28, 0)) { + // Leave the signing workflow waiting for its next request when the host disconnects. + response, err := device.query(&messages.Request{ + Request: &messages.Request_BtcSignInit{ + BtcSignInit: &messages.BTCSignInitRequest{ + Coin: messages.BTCCoin_BTC, + ScriptConfigs: []*messages.BTCScriptConfigWithKeypath{{ + ScriptConfig: NewBTCScriptConfigSimple(messages.BTCScriptConfig_P2WPKH), + Keypath: []uint32{84 + 0x80000000, 0x80000000, 0x80000000}, + }}, + Version: 2, + NumInputs: 1, + NumOutputs: 1, + }, + }, + }) + require.NoError(t, err) + next, ok := response.Response.(*messages.Response_BtcSignNext) + require.True(t, ok) + require.Equal(t, messages.BTCSignNextResponse_INPUT, next.BtcSignNext.Type) + } + device.Close() + + // Reconnect to the same running simulator, preserving the firmware session state. + conn, err := net.DialTimeout("tcp", "localhost:15423", time.Second) + require.NoError(t, err) + require.NoError(t, conn.SetDeadline(time.Now().Add(15*time.Second))) + communication := &recordingCommunication{ + Communication: u2fhid.NewCommunication(conn, 0xc1), + } + reconnected := NewDevice(nil, nil, device.config, communication, device.log) + defer reconnected.Close() + require.NoError(t, reconnected.Init()) + // The simulator has no attestation certificate, so attestation returns FAILURE. + // An unfinished workflow would instead return its encrypted error, which Init ignores. + require.Equal(t, []byte{0x00, 0x01}, communication.attestationResponse) + reconnected.ChannelHashVerify(true) + fp, err := reconnected.RootFingerprint() + require.NoError(t, err) + require.Equal(t, "4c00739d", hex.EncodeToString(fp)) + }) +} + func TestSimulatorRootFingerprint(t *testing.T) { testInitializedSimulators(t, func(t *testing.T, device *Device, stdOut *simulatorStdout) { t.Helper() diff --git a/api/firmware/query.go b/api/firmware/query.go index bb21fb2..1ee8e19 100644 --- a/api/firmware/query.go +++ b/api/firmware/query.go @@ -25,6 +25,8 @@ const ( hwwReqRetry = "\x01" // Cancel any outstanding request. // hwwReqCancel = "\x02" + // Reset the previous session before starting a new one (since v9.28.0). + hwwReqReset = "\x03" // INFO api call (used to be OP_INFO api call), graduated to the toplevel framing so it works // the same way for all firmware versions. hwwInfo = "i" @@ -41,6 +43,27 @@ const ( hwwRspNack = "\x03" ) +func (device *Device) resetSession() error { + if !device.version.AtLeast(semver.NewSemVer(9, 28, 0)) { + return nil + } + // Send at the framing layer so an unfinished workflow cannot consume the request. + for { + response, err := device.communication.Query([]byte(hwwReqReset)) + if err != nil { + return err + } + switch string(response) { + case hwwRspAck: + return nil + case hwwRspBusy: + time.Sleep(time.Second) + default: + return errp.New("unexpected session reset response") + } + } +} + func (device *Device) rawQueryV7(msg []byte) ([]byte, error) { var status string var payload []byte