diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 61ad4c8..92c4fa7 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -38,7 +38,12 @@ jobs: cache: true cache-dependency-path: go/go.sum - run: go vet ./... - - run: go test -race -timeout 60s ./... + # -count=1 disables the test cache. The source assertions in + # ios_bluetooth_regression_test.go read Swift and Kotlin files, which live + # outside the module root, so cmd/go does not hash them into the cache key + # — a PR that only touches those files would otherwise be served a stale + # "ok" and the guards would never run. + - run: go test -race -timeout 60s -count=1 ./... yaml-lint: name: Workflow YAML lint diff --git a/CHANGELOG.md b/CHANGELOG.md index c3024b2..2796a15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,39 @@ +## 0.0.11 + +* Expose `BitboxManager.getFirmwareVersion()`, returning the connected device's + main firmware version `v`-prefixed (e.g. `v9.26.4`). It is readable once the + pairing has been established, on both transports, and costs no device + round-trip afterwards. Null means the version is not known — before + `initBitBox`, after a pairing the device declined (which `initBitBox()` still + reports as true, see below), after `close`, or when the device reported a + version that could not be parsed — and never "old firmware", so a host gating + on a minimum version must treat the two apart and refuse rather than pass + when it is absent. +* Disconnecting now also releases the device inside the native binding — on an + explicit `close()`, on a peripheral that drops on its own, and when connecting + to another device without closing first. Previously the binding kept the + previous device, so `getDeviceStatus()` (and the new `getFirmwareVersion()`) + could answer for a device that was no longer attached. +* `supportsETH()` / `supportsERC20()` are derived from the firmware version, so + they now report no support whenever that version is unknown: before the + pairing is established, after one the device declined, and when the device + reported a version string the binding could not parse (where it previously + answered from an internal placeholder). Both track `getFirmwareVersion()` + returning null. +* Over Bluetooth the version is known before pairing, but `getFirmwareVersion()` + withholds it until the pairing is established — a declined or failed pairing + must not vouch for a channel that was never established. (The native binding + also withholds it when the host rejects the code; `BitboxManager` never does, + since `channelHashVerify()` always affirms.) Note the SDK reports a decline by + leaving the channel hash unverified rather than by returning an error, so + `initBitBox()` still resolves true there; only the version and the + capabilities derived from it are withheld. +* Testkit: `SimulatedBitboxPlatform.supportsLTC()`, `supportsETH()` and + `supportsERC20()` now report false until the simulated pairing is established + — `initBitBox()` returning true is not enough, see the new `pairingVerified` + knob. A consumer test that asserted any of the three straight after + `connect()` needs an `initBitBox()` first. + ## 0.0.10 * Expose `BitboxManager.getDeviceStatus()`, returning the SDK's cached firmware diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 854b688..f8cedab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,15 +14,15 @@ CI runs three jobs on every PR and every push to `develop` / `main` (`.github/wo | Job | What it does | |---|---| -| `Flutter analyze + test` | `dart format --set-exit-if-changed`, `flutter analyze --no-fatal-infos`, `flutter test` | -| `Go unit tests` | `go vet ./...`, `go test -race -timeout 60s ./...` against `go/api` and `go/u2fhid` | +| `Flutter analyze + test` | `dart format --set-exit-if-changed`, `flutter analyze --fatal-infos`, `flutter test` | +| `Go unit tests` | `go vet ./...`, `go test -race -timeout 60s -count=1 ./...` against `go/api` and `go/u2fhid` | | `Workflow YAML lint` | `yaml.safe_load` on every `.github/**/*.y*ml` | Run the same gate locally — see [TESTING.md → Fast PR gate](TESTING.md#fast-pr-gate). Lint failures upstream are wasted CI minutes; catch them locally. ## Adding a new platform method -1. **Dart side**: declare the abstract method on `BitboxUsbPlatform` (`lib/usb/bitbox_usb_platform_interface.dart`) and implement it on `BitboxUsbMethodChannel` (`lib/usb/bitbox_usb_method_channel.dart`). +1. **Dart side**: declare the abstract method on `BitboxUsbPlatform` (`lib/usb/bitbox_usb_platform_interface.dart`) and implement it on `MethodChannelBitboxUsb` (`lib/usb/bitbox_usb_method_channel.dart`). 2. **Go side** (gomobile-exported): add the corresponding function in `go/api/*.go`, wrapped with `defer recoverPanic("")` so a Go-side crash returns a zero value instead of taking the engine down. 3. **Native bridges**: wire the new method through `android/src/main/kotlin/.../MethodCallRegistry.kt` and the iOS handler in `ios/Classes/BitboxFlutterPlugin.swift`. 4. **Testkit**: implement the method on `SimulatedBitboxPlatform` in `lib/testing/bitbox_testkit.dart` so consumer apps can exercise it without hardware. Add a method-name constant to `SimulatedBitboxMethod`. diff --git a/TESTING.md b/TESTING.md index 89b9c97..2bac51a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -14,7 +14,7 @@ flutter test # Go (from go/) cd go go vet ./... -go test -race -timeout 60s ./... +go test -race -timeout 60s -count=1 ./... ``` The Go API tests include a generic fake BitBox device. It is not @@ -89,6 +89,48 @@ The tests explicitly guard against these hardware-wallet regressions: - iOS BLE read timeout regressing from 60 seconds to 10 seconds - U2FHID assumptions drifting away from the iOS BLE bridge contract - Pairing/channel-hash behavior not being simulatable without hardware +- An unknown firmware version being conflated with an old one. The SDK panics + when `Version()` is read before the device reports one; the pairing gate + already answers `""` in that window, and `recoverPanic` backstops the panic + should it ever be reached — the Go test drives that path directly. The Dart + side maps `""` to null, and the testkit withholds the version until the + simulated pairing is established — `initBitBox()` returning true is not + enough, see its `pairingVerified` knob — so a consumer's version gate cannot + pass its tests and then read null on hardware. +- A version answering for a pairing that never completed. Bluetooth knows the + version before `initBitBox`, so the binding tracks whether the pairing was + actually established and withholds the version — and the capabilities derived + from it — until then. The signal is the channel hash being device-verified and + not since repudiated by the host, NOT `Init()` returning without error: the + SDK returns nil on a decline, having already dropped both ciphers. Covered on + the Go side for a decline, a host affirming a decline, a host-rejected code + and its re-affirm, a failed init, a failed re-init, and an init that lands + after the device was replaced. +- A device that is gone still answering. On iOS both `handleDisconnect` and + `connect(to:)` call `ReleaseDevice`, since nothing rebinds the Go side until + `initBitBox` — so a peripheral that drops on its own, and one that is replaced + without closing first, both clear the binding. Android releases in + `CloseOperation` and, before the rebind, in `ConnectBitBoxOperation`. All four + are pinned from CI by source assertions on `Bluetooth.swift`, + `BitboxFlutterPlugin.swift` and the two Kotlin operations, the same way the + 60s read timeout is, plus Go and testkit coverage. Those assertions scope to + the enclosing function, ignore commented-out calls, check the + release-before-rebind ordering on the connect path, and fail loudly if they + can no longer find the function rather than degrading into a file-wide search. +- The placeholder version `GetDeviceWithInfo` substitutes when the device's own + version string does not parse escaping as if it were the device's. It is + withheld from `FirmwareVersion` *and* from `SupportsETH` / `SupportsERC20`, + which the SDK derives from the version — so unparseable reads as unknown + everywhere rather than as a specific wrong number a gate would act on. The + testkit applies the same rule to its configured version, so a consumer's + capability gate does not pass against the simulator and read false on + hardware. A behaviour installed with `when` moves only the method it targets. +- `bitbox`, its synthetic-version flag and its initialised flag being written + without synchronisation. They are replaced as a set under `deviceMu`, and + every export takes a single snapshot, because close/disconnect runs on a + different thread than an in-flight signature or pairing handshake. A dedicated + test drives readers and writers concurrently, so `go test -race` fails if the + lock is removed. - ETH/BTC success, error, and panic flows not being simulatable without hardware - App-level Flutter flows not being testable with deterministic BitBox delays and aborts diff --git a/android/libs/api-sources.jar b/android/libs/api-sources.jar index 64cfb40..b9957e7 100644 Binary files a/android/libs/api-sources.jar and b/android/libs/api-sources.jar differ diff --git a/android/libs/api.aar b/android/libs/api.aar index ebc6578..7b72ea6 100644 Binary files a/android/libs/api.aar and b/android/libs/api.aar differ diff --git a/android/src/main/kotlin/com/cakewallet/bitbox_flutter/BitboxFlutterPlugin.kt b/android/src/main/kotlin/com/cakewallet/bitbox_flutter/BitboxFlutterPlugin.kt index dadd984..c35da70 100644 --- a/android/src/main/kotlin/com/cakewallet/bitbox_flutter/BitboxFlutterPlugin.kt +++ b/android/src/main/kotlin/com/cakewallet/bitbox_flutter/BitboxFlutterPlugin.kt @@ -14,6 +14,7 @@ import com.cakewallet.bitbox_flutter.operations.ETHSignTransactionOperation import com.cakewallet.bitbox_flutter.operations.ETHSignTypedMessageOperation import com.cakewallet.bitbox_flutter.operations.GetChannelHashOperation import com.cakewallet.bitbox_flutter.operations.GetDeviceStatusOperation +import com.cakewallet.bitbox_flutter.operations.GetFirmwareVersionOperation import com.cakewallet.bitbox_flutter.operations.GetDevicesOperation import com.cakewallet.bitbox_flutter.operations.ETHGetAddressOperation import com.cakewallet.bitbox_flutter.operations.ETHSignRLPTransactionOperation @@ -55,6 +56,7 @@ class BitboxFlutterPlugin : FlutterPlugin, MethodCallHandler { registry.registerMethodCall("getChannelHash", GetChannelHashOperation(bitboxManager)) registry.registerMethodCall("channelHashVerify", ChannelHashVerifyOperation(bitboxManager)) registry.registerMethodCall("getDeviceStatus", GetDeviceStatusOperation(bitboxManager)) + registry.registerMethodCall("getFirmwareVersion", GetFirmwareVersionOperation(bitboxManager)) registry.registerMethodCall("getMasterFingerprint", GetMasterFingerprintOperation(bitboxManager)) registry.registerMethodCall("supportsETH", SupportsETHOperation(bitboxManager)) registry.registerMethodCall("supportsERC20", SupportsERC20Operation(bitboxManager)) diff --git a/android/src/main/kotlin/com/cakewallet/bitbox_flutter/operations/CloseOperation.kt b/android/src/main/kotlin/com/cakewallet/bitbox_flutter/operations/CloseOperation.kt index 1e20341..9e535f5 100644 --- a/android/src/main/kotlin/com/cakewallet/bitbox_flutter/operations/CloseOperation.kt +++ b/android/src/main/kotlin/com/cakewallet/bitbox_flutter/operations/CloseOperation.kt @@ -1,6 +1,7 @@ package com.cakewallet.bitbox_flutter.operations import android.content.Context +import api.Api import com.cakewallet.bitbox_flutter.BitboxManager import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel @@ -18,6 +19,11 @@ class CloseOperation(private val manager: BitboxManager) : manager.gracefullyReset() } + // Drop the Go-side device too, so the next connection is not answered + // with this one's cached status and firmware version. Runs after the + // reset path as well, which is exactly when the stale state matters. + Api.releaseDevice() + result.success(true) } } diff --git a/android/src/main/kotlin/com/cakewallet/bitbox_flutter/operations/ConnectBitBoxOperation.kt b/android/src/main/kotlin/com/cakewallet/bitbox_flutter/operations/ConnectBitBoxOperation.kt index 6ab076b..d7a7221 100644 --- a/android/src/main/kotlin/com/cakewallet/bitbox_flutter/operations/ConnectBitBoxOperation.kt +++ b/android/src/main/kotlin/com/cakewallet/bitbox_flutter/operations/ConnectBitBoxOperation.kt @@ -1,6 +1,7 @@ package com.cakewallet.bitbox_flutter.operations import android.content.Context +import api.Api import com.cakewallet.bitbox_flutter.BitBoxException import com.cakewallet.bitbox_flutter.BitboxManager import io.flutter.plugin.common.MethodCall @@ -13,6 +14,11 @@ class ConnectBitBoxOperation(private val manager: BitboxManager) : methodCall: MethodCall, result: MethodChannel.Result ) { + // Opening starts a new device. Release first so a failed open cannot + // leave the previous one answering: the success path rebinds + // immediately via Api.getDevice, but the error path never would. + Api.releaseDevice() + val identifier: String? = methodCall.argument("identifier") try { this.manager.connectBitBox(identifier!!) diff --git a/android/src/main/kotlin/com/cakewallet/bitbox_flutter/operations/GetFirmwareVersionOperation.kt b/android/src/main/kotlin/com/cakewallet/bitbox_flutter/operations/GetFirmwareVersionOperation.kt new file mode 100644 index 0000000..4904a95 --- /dev/null +++ b/android/src/main/kotlin/com/cakewallet/bitbox_flutter/operations/GetFirmwareVersionOperation.kt @@ -0,0 +1,22 @@ +package com.cakewallet.bitbox_flutter.operations + +import android.content.Context +import api.Api +import com.cakewallet.bitbox_flutter.BitboxManager +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +class GetFirmwareVersionOperation(manager: BitboxManager) : UsbMethodCallOperation(manager.usbManager) { + override fun onMethodCall( + context: Context, + methodCall: MethodCall, + result: MethodChannel.Result + ) { + // Api.firmwareVersion() reads the version the SDK already holds — no + // device round-trip — so it is safe on the serial queue like + // getDeviceStatus. It stays empty until the pairing is established — + // initBitBox returning true is not enough. + val version = Api.firmwareVersion() + result.success(version) + } +} diff --git a/go/api/api.go b/go/api/api.go index e9aeb5b..1ee0432 100644 --- a/go/api/api.go +++ b/go/api/api.go @@ -106,7 +106,21 @@ func GetDevice(device GoReadWriteCloserInterface) { const bitboxCMD = 0x80 + 0x40 + 0x01 comm := u2fhid.NewCommunication(readWriteCloser{device}, bitboxCMD) - bitbox = firmware.NewDevice(nil, nil, &mocks.Config{}, comm, &mocks.Logger{}) + // The SDK infers the real version from OP_INFO during Init, so nothing is + // synthesised on this path. + setDevice(firmware.NewDevice(nil, nil, &mocks.Config{}, comm, &mocks.Logger{}), false) +} + +// ReleaseDevice drops the reference to the connected device. The bridges call +// it when closing so the next connection cannot be answered with the previous +// device's cached state — a stale firmware version would otherwise let a host +// clear a device it never inspected. +// +//export ReleaseDevice +func ReleaseDevice() { + defer recoverPanic("ReleaseDevice") + + setDevice(nil, false) } // GetDeviceWithInfo is like GetDevice but accepts version and product info for Bluetooth connections. @@ -123,6 +137,7 @@ func GetDeviceWithInfo(device GoReadWriteCloserInterface, versionStr string, pro // default rather than panicking, so a malformed version from the device // does not crash the host engine. version, err := semver.NewSemVerFromString(strings.TrimPrefix(versionStr, "v")) + versionSynthetic := err != nil if err != nil { fmt.Printf("[GetDeviceWithInfo] invalid version %q, falling back: %v\n", versionStr, err) version = semver.NewSemVer(9, 25, 0) @@ -149,17 +164,21 @@ func GetDeviceWithInfo(device GoReadWriteCloserInterface, versionStr string, pro product = common.ProductBitBox02PlusMulti } - bitbox = firmware.NewDevice(version, &product, &mocks.Config{}, comm, &mocks.Logger{}) + setDevice( + firmware.NewDevice(version, &product, &mocks.Config{}, comm, &mocks.Logger{}), + versionSynthetic, + ) } //export GetChannelHash func GetChannelHash() (hash string) { defer recoverPanic("GetChannelHash") - if bitbox == nil { + device, _, _ := currentDevice() + if device == nil { return "" } - hash, _ = bitbox.ChannelHash() + hash, _ = device.ChannelHash() return hash } @@ -167,25 +186,43 @@ func GetChannelHash() (hash string) { func ChannelHashVerify(ok bool) { defer recoverPanic("ChannelHashVerify") - if bitbox == nil { + device, _, _ := currentDevice() + if device == nil { return } - bitbox.ChannelHashVerify(ok) + device.ChannelHashVerify(ok) + // The host rejecting the code repudiates the channel from this side, so it + // must stop answering a version gate — the SDK marks the status but leaves + // its own device-verified flag set. Re-affirming restores it, since the SDK + // leaves the channel usable and a full re-init should not be required. + _, deviceVerified := device.ChannelHash() + setInitialised(device, ok && deviceVerified) } //export InitDevice func InitDevice() (success bool) { defer recoverPanic("InitDevice") - if bitbox == nil { + device, _, _ := currentDevice() + if device == nil { fmt.Println("[InitDevice] device pointer is nil") return false } - err := bitbox.Init() + // Init discards the existing channel before it builds a new one, so an + // earlier success stops counting the moment this attempt starts. + setInitialised(device, false) + + err := device.Init() if err != nil { fmt.Println("[InitDevice] error:", err) return false } + // Init returns nil even when the user declined on the device — it reports + // that by leaving the channel hash unverified, having already dropped both + // ciphers. Only a channel the device actually confirmed may answer a + // version gate, so the pairing flag decides this, not the error. + _, deviceVerified := device.ChannelHash() + setInitialised(device, deviceVerified) return true } @@ -200,60 +237,108 @@ func InitDevice() (success bool) { func DeviceStatus() (status string) { defer recoverPanic("DeviceStatus") - if bitbox == nil { + device, _, _ := currentDevice() + if device == nil { + return "" + } + return string(device.Status()) +} + +// FirmwareVersion returns the main firmware version of the connected device, +// `v`-prefixed (e.g. "v9.26.4"). It becomes available once the pairing has +// been established — InitDevice returning true is not sufficient, see below. +// Over Bluetooth the version reaches the SDK with GetDeviceWithInfo, over USB +// the SDK infers it from OP_INFO while initialising. Reading it afterwards +// costs no device round-trip. +// +// An empty string means the version is not known — no device, a device whose +// pairing was not established (a decline included, which InitDevice itself +// still reports as success), or one whose reported version could not be +// parsed. It never means "old firmware": callers gating on a minimum version +// must treat the two apart. This is the main firmware version, NOT the +// separately versioned Bluetooth firmware. +// +//export FirmwareVersion +func FirmwareVersion() (version string) { + defer recoverPanic("FirmwareVersion") + + device, versionSynthetic, initialised := currentDevice() + if device == nil || !initialised { + // Bluetooth knows a version before init, but a pairing that was + // declined or failed must not answer a gate — the channel it would + // vouch for was never established. + return "" + } + if versionSynthetic { + // GetDeviceWithInfo substituted a placeholder it invented, which would + // otherwise be reported as the device's own version and could clear a + // device a gate never actually identified. return "" } - return string(bitbox.Status()) + // Version() panics when the version is not known yet. The pairing gate above + // already answered "" for every state the SDK can reach with an unknown + // version, so this is a backstop should that ever widen. + return "v" + device.Version().String() } //export SupportsETH func SupportsETH(chainId int) (supported bool) { defer recoverPanic("SupportsETH") - if bitbox == nil { + device, versionSynthetic, initialised := currentDevice() + if device == nil || !initialised || versionSynthetic { + // The SDK answers this by comparing the firmware version, so a version + // we invented — or one from a device whose pairing was never + // established — would decide it. Report no support rather than a + // capability derived from a number the device never stood behind. return false } - return bitbox.SupportsETH(uint64(chainId)) + return device.SupportsETH(uint64(chainId)) } //export SupportsLTC func SupportsLTC() (supported bool) { defer recoverPanic("SupportsLTC") - if bitbox == nil { + device, _, _ := currentDevice() + if device == nil { return false } - return bitbox.SupportsLTC() + return device.SupportsLTC() } //export SupportsBluetooth func SupportsBluetooth() (supported bool) { defer recoverPanic("SupportsBluetooth") - if bitbox == nil { + device, _, _ := currentDevice() + if device == nil { return false } - return bitbox.SupportsBluetooth() + return device.SupportsBluetooth() } //export SupportsERC20 func SupportsERC20(contractAddress string) (supported bool) { defer recoverPanic("SupportsERC20") - if bitbox == nil { + device, versionSynthetic, initialised := currentDevice() + if device == nil || !initialised || versionSynthetic { + // Version-derived like SupportsETH — see there. return false } - return bitbox.SupportsERC20(contractAddress) + return device.SupportsERC20(contractAddress) } //export DeviceInfo func DeviceInfo() (out firmware.DeviceInfo) { defer recoverPanic("DeviceInfo") - if bitbox == nil { + device, _, _ := currentDevice() + if device == nil { return firmware.DeviceInfo{} } - info, err := bitbox.DeviceInfo() + info, err := device.DeviceInfo() if err != nil || info == nil { fmt.Printf("[DeviceInfo] error: %v\n", err) return firmware.DeviceInfo{} diff --git a/go/api/bitbox_device.go b/go/api/bitbox_device.go index 44ad11a..84e064e 100644 --- a/go/api/bitbox_device.go +++ b/go/api/bitbox_device.go @@ -2,9 +2,11 @@ package api import ( "math/big" + "sync" "github.com/BitBoxSwiss/bitbox02-api-go/api/firmware" "github.com/BitBoxSwiss/bitbox02-api-go/api/firmware/messages" + "github.com/BitBoxSwiss/bitbox02-api-go/util/semver" "github.com/btcsuite/btcd/btcutil/psbt" ) @@ -13,6 +15,7 @@ type bitboxDevice interface { ChannelHash() (string, bool) ChannelHashVerify(ok bool) Status() firmware.Status + Version() *semver.SemVer DeviceInfo() (*firmware.DeviceInfo, error) RootFingerprint() ([]byte, error) SupportsETH(chainID uint64) bool @@ -67,4 +70,51 @@ type bitboxDevice interface { ) (*firmware.BTCSignMessageResult, error) } -var bitbox bitboxDevice +// deviceMu guards bitbox, versionIsSynthetic and deviceInitialised, which +// belong together: they describe one connected device and are replaced as a +// set. The bridges write them from the thread that handles close/disconnect +// while a signature or the pairing handshake is still in flight on another, so +// an unsynchronised interface write could be observed half-applied — a fault +// the recoverPanic boundary could not catch. +var ( + deviceMu sync.RWMutex + bitbox bitboxDevice + versionIsSynthetic bool + deviceInitialised bool +) + +// currentDevice returns the connected device together with whether its version +// was invented rather than reported, and whether its pairing is established — +// device-verified and not since repudiated by the host. Read under one lock, so +// no caller can see a half-replaced device. +func currentDevice() (device bitboxDevice, versionSynthetic bool, initialised bool) { + deviceMu.RLock() + defer deviceMu.RUnlock() + + return bitbox, versionIsSynthetic, deviceInitialised +} + +// setDevice replaces the connected device. Pass nil to release it. The device +// always starts with no established pairing: a version is only trustworthy once +// the device has confirmed the channel, on Bluetooth as much as on USB. +func setDevice(device bitboxDevice, versionSynthetic bool) { + deviceMu.Lock() + defer deviceMu.Unlock() + + bitbox = device + versionIsSynthetic = versionSynthetic + deviceInitialised = false +} + +// setInitialised records whether the pairing is established — an init outcome +// or a host repudiation — but only while device is still the connected one, so +// a disconnect during the pairing wait is not undone by the call already in +// flight. +func setInitialised(device bitboxDevice, initialised bool) { + deviceMu.Lock() + defer deviceMu.Unlock() + + if bitbox == device { + deviceInitialised = initialised + } +} diff --git a/go/api/bitcoin.go b/go/api/bitcoin.go index f269e1e..ce6a692 100644 --- a/go/api/bitcoin.go +++ b/go/api/bitcoin.go @@ -13,13 +13,15 @@ import ( func BTCXPub(coinType int, keypath string, addressType int, display bool) (xpub string) { defer recoverPanic("BTCXPub") + device, _, _ := currentDevice() + keypathData, err := hexToUint32Slice(keypath) if err != nil { fmt.Printf("[BTCXPub] keypath decode error: %v\n", err) return "" } - pub, err := bitbox.BTCXPub(messages.BTCCoin(coinType), keypathData, messages.BTCPubRequest_XPubType(addressType), display) + pub, err := device.BTCXPub(messages.BTCCoin(coinType), keypathData, messages.BTCPubRequest_XPubType(addressType), display) if err != nil { fmt.Printf("[BTCXPub] device error: %v\n", err) return "" @@ -31,13 +33,15 @@ func BTCXPub(coinType int, keypath string, addressType int, display bool) (xpub func BTCSignPSBT(coinType int, psbtStr string) (signed string) { defer recoverPanic("BTCSignPSBT") + device, _, _ := currentDevice() + psbt_, err := psbt.NewFromRawBytes(strings.NewReader(psbtStr), true) if err != nil { fmt.Printf("[BTCSignPSBT] PSBT parse error: %v\n", err) return "" } - if err := bitbox.BTCSignPSBT(messages.BTCCoin(coinType), psbt_, nil); err != nil { + if err := device.BTCSignPSBT(messages.BTCCoin(coinType), psbt_, nil); err != nil { fmt.Printf("[BTCSignPSBT] device error: %v\n", err) return "" } @@ -55,6 +59,8 @@ func BTCSignPSBT(coinType int, psbtStr string) (signed string) { func BTCSignMessage(coinType int, keypath string, msg []byte) (signature []byte) { defer recoverPanic("BTCSignMessage") + device, _, _ := currentDevice() + keypathData, err := hexToUint32Slice(keypath) if err != nil { fmt.Printf("[BTCSignMessage] keypath decode error: %v\n", err) @@ -66,7 +72,7 @@ func BTCSignMessage(coinType int, keypath string, msg []byte) (signature []byte) Keypath: keypathData, } - result, err := bitbox.BTCSignMessage(messages.BTCCoin(coinType), scriptConfig, msg) + result, err := device.BTCSignMessage(messages.BTCCoin(coinType), scriptConfig, msg) if err != nil { fmt.Printf("[BTCSignMessage] device error: %v\n", err) return nil @@ -78,7 +84,9 @@ func BTCSignMessage(coinType int, keypath string, msg []byte) (signature []byte) func GetMasterFingerprint() (fingerprint []byte) { defer recoverPanic("GetMasterFingerprint") - fingerprint, err := bitbox.RootFingerprint() + device, _, _ := currentDevice() + + fingerprint, err := device.RootFingerprint() if err != nil { return make([]byte, 0) } diff --git a/go/api/ethereum.go b/go/api/ethereum.go index 688f188..70b631f 100644 --- a/go/api/ethereum.go +++ b/go/api/ethereum.go @@ -15,13 +15,15 @@ import ( func ETHGetAddress(chainId int, keypath string, outputType int, display bool, contractAddress []byte) (address string) { defer recoverPanic("ETHGetAddress") + device, _, _ := currentDevice() + keypathData, err := hexToUint32Slice(keypath) if err != nil { fmt.Printf("[ETHGetAddress] keypath decode error: %v\n", err) return "" } - pub, err := bitbox.ETHPub(uint64(chainId), keypathData, messages.ETHPubRequest_OutputType(outputType), display, contractAddress) + pub, err := device.ETHPub(uint64(chainId), keypathData, messages.ETHPubRequest_OutputType(outputType), display, contractAddress) if err != nil { fmt.Printf("[ETHGetAddress] device error: %v\n", err) return "" @@ -60,6 +62,8 @@ type legacyTxPayload struct { func ETHSignRPLTx(chainId int, keypath string, encodedTx string, isEIP1559 bool) (signature []byte) { defer recoverPanic("ETHSignRPLTx") + device, _, _ := currentDevice() + keypathData, err := hexToUint32Slice(keypath) if err != nil { fmt.Printf("[ETHSignRPLTx] keypath decode error: %v\n", err) @@ -79,7 +83,7 @@ func ETHSignRPLTx(chainId int, keypath string, encodedTx string, isEIP1559 bool) return nil } - signature, err = bitbox.ETHSignEIP1559( + signature, err = device.ETHSignEIP1559( uint64(chainId), keypathData, tx.Nonce, @@ -97,7 +101,7 @@ func ETHSignRPLTx(chainId int, keypath string, encodedTx string, isEIP1559 bool) fmt.Printf("[ETHSignRPLTx] legacy decode error: %v\n", err) return nil } - signature, err = bitbox.ETHSign( + signature, err = device.ETHSign( uint64(chainId), keypathData, tx.Nonce, @@ -122,6 +126,8 @@ func ETHSignRPLTx(chainId int, keypath string, encodedTx string, isEIP1559 bool) func ETHSignTransaction(chainId int, keypath string, nonce int, gasPrice string, gasLimit int, recipient []byte, value string, data []byte, recipientAddressCase int) (signature []byte) { defer recoverPanic("ETHSignTransaction") + device, _, _ := currentDevice() + keypathData, err := hexToUint32Slice(keypath) if err != nil { fmt.Printf("[ETHSignTransaction] keypath decode error: %v\n", err) @@ -134,7 +140,7 @@ func ETHSignTransaction(chainId int, keypath string, nonce int, gasPrice string, valueBI := new(big.Int) valueBI, _ = valueBI.SetString(value, 16) - signature, err = bitbox.ETHSign(uint64(chainId), keypathData, uint64(nonce), gasPriceBI, uint64(gasLimit), [20]byte(recipient), valueBI, data, messages.ETHAddressCase(recipientAddressCase)) + signature, err = device.ETHSign(uint64(chainId), keypathData, uint64(nonce), gasPriceBI, uint64(gasLimit), [20]byte(recipient), valueBI, data, messages.ETHAddressCase(recipientAddressCase)) if err != nil { fmt.Printf("[ETHSignTransaction] device error: %v\n", err) return nil @@ -146,6 +152,8 @@ func ETHSignTransaction(chainId int, keypath string, nonce int, gasPrice string, func ETHSignEIP1559(chainId int, keypath string, nonce int, maxPriorityFeePerGas string, maxFeePerGas string, gasLimit int, recipient []byte, value string, data []byte, recipientAddressCase int) (signature []byte) { defer recoverPanic("ETHSignEIP1559") + device, _, _ := currentDevice() + keypathData, err := hexToUint32Slice(keypath) if err != nil { fmt.Printf("[ETHSignEIP1559] keypath decode error: %v\n", err) @@ -161,7 +169,7 @@ func ETHSignEIP1559(chainId int, keypath string, nonce int, maxPriorityFeePerGas valueBI := new(big.Int) valueBI, _ = valueBI.SetString(value, 16) - signature, err = bitbox.ETHSignEIP1559(uint64(chainId), keypathData, uint64(nonce), maxPriorityFeePerGasBI, maxFeePerGasBI, uint64(gasLimit), [20]byte(recipient), valueBI, data, messages.ETHAddressCase(recipientAddressCase)) + signature, err = device.ETHSignEIP1559(uint64(chainId), keypathData, uint64(nonce), maxPriorityFeePerGasBI, maxFeePerGasBI, uint64(gasLimit), [20]byte(recipient), valueBI, data, messages.ETHAddressCase(recipientAddressCase)) if err != nil { fmt.Printf("[ETHSignEIP1559] device error: %v\n", err) return nil @@ -174,13 +182,15 @@ func ETHSignEIP1559(chainId int, keypath string, nonce int, maxPriorityFeePerGas func ETHSignMessage(chainId int, keypath string, msg []byte) (signature []byte) { defer recoverPanic("ETHSignMessage") + device, _, _ := currentDevice() + keypathData, err := hexToUint32Slice(keypath) if err != nil { fmt.Printf("[ETHSignMessage] keypath decode error: %v\n", err) return nil } - signature, err = bitbox.ETHSignMessage(uint64(chainId), keypathData, msg) + signature, err = device.ETHSignMessage(uint64(chainId), keypathData, msg) if err != nil { fmt.Printf("[ETHSignMessage] device error: %v\n", err) return nil @@ -192,13 +202,15 @@ func ETHSignMessage(chainId int, keypath string, msg []byte) (signature []byte) func ETHSignTypedMessage(chainId int, keypath string, jsonMsg []byte) (signature []byte) { defer recoverPanic("ETHSignTypedMessage") + device, _, _ := currentDevice() + keypathData, err := hexToUint32Slice(keypath) if err != nil { fmt.Printf("[ETHSignTypedMessage] keypath decode error: %v\n", err) return nil } - signature, err = bitbox.ETHSignTypedMessage(uint64(chainId), keypathData, jsonMsg) + signature, err = device.ETHSignTypedMessage(uint64(chainId), keypathData, jsonMsg) if err != nil { fmt.Printf("[ETHSignTypedMessage] device error: %v\n", err) return nil diff --git a/go/api/fake_bitbox_test.go b/go/api/fake_bitbox_test.go index 0c1fe84..0c120eb 100644 --- a/go/api/fake_bitbox_test.go +++ b/go/api/fake_bitbox_test.go @@ -5,10 +5,15 @@ import ( "errors" "math/big" "reflect" + "runtime" + "slices" + "sync" + "sync/atomic" "testing" "github.com/BitBoxSwiss/bitbox02-api-go/api/firmware" "github.com/BitBoxSwiss/bitbox02-api-go/api/firmware/messages" + "github.com/BitBoxSwiss/bitbox02-api-go/util/semver" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/rlp" @@ -23,6 +28,10 @@ type fakeBitboxDevice struct { status firmware.Status + // version is nil when the device has not reported one yet, which makes + // Version() panic exactly like the SDK does before Init. + version *semver.SemVer + deviceInfo *firmware.DeviceInfo deviceInfoErr error rootFingerprint []byte @@ -74,6 +83,14 @@ func (f *fakeBitboxDevice) Status() firmware.Status { return f.status } +func (f *fakeBitboxDevice) Version() *semver.SemVer { + f.calls = append(f.calls, "Version") + if f.version == nil { + panic("version not set; Init() must be called first") + } + return f.version +} + func (f *fakeBitboxDevice) DeviceInfo() (*firmware.DeviceInfo, error) { f.calls = append(f.calls, "DeviceInfo") return f.deviceInfo, f.deviceInfoErr @@ -147,13 +164,28 @@ func (f *fakeBitboxDevice) BTCSignMessage(messages.BTCCoin, *messages.BTCScriptC return &firmware.BTCSignMessageResult{Signature: f.btcSignMessageSig}, f.btcSignMessageErr } +// snapshotDevice captures the whole device state and returns a func that puts +// it back. The device, its synthetic-version flag and its initialised flag are +// one piece of state: restoring only part of it would leak into later tests, +// silently emptying FirmwareVersion for the rest of the package run. +func snapshotDevice() func() { + device, versionSynthetic, initialised := currentDevice() + return func() { + setDevice(device, versionSynthetic) + if initialised { + setInitialised(device, true) + } + } +} + +// withFakeBitbox installs a fake for the duration of the test. The fake starts +// uninitialised, like a freshly connected device — call InitDevice to open the +// version-derived answers. func withFakeBitbox(t *testing.T, fake *fakeBitboxDevice) { t.Helper() - previous := bitbox - bitbox = fake - t.Cleanup(func() { - bitbox = previous - }) + restore := snapshotDevice() + setDevice(fake, false) + t.Cleanup(restore) } func TestFakeBitboxHarnessSimulatesPairingAndCapabilities(t *testing.T) { @@ -163,6 +195,7 @@ func TestFakeBitboxHarnessSimulatesPairingAndCapabilities(t *testing.T) { channelHashOk: true, channelHashVerified: &verified, status: firmware.StatusInitialized, + version: semver.NewSemVer(9, 26, 4), deviceInfo: &firmware.DeviceInfo{Name: "Simulated BitBox"}, rootFingerprint: []byte{0x01, 0x02, 0x03, 0x04}, supportsETH: true, @@ -191,6 +224,10 @@ func TestFakeBitboxHarnessSimulatesPairingAndCapabilities(t *testing.T) { if got := DeviceStatus(); got != string(firmware.StatusInitialized) { t.Fatalf("expected simulated device status, got %q", got) } + // The `v` prefix is added by FirmwareVersion; semver.String() omits it. + if got := FirmwareVersion(); got != "v9.26.4" { + t.Fatalf("expected simulated firmware version, got %q", got) + } if got := GetMasterFingerprint(); !reflect.DeepEqual(got, []byte{0x01, 0x02, 0x03, 0x04}) { t.Fatalf("expected simulated root fingerprint, got %x", got) } @@ -253,6 +290,384 @@ func ptr[T any](value T) *T { return &value } +// The SDK panics rather than returning nil when the version is unknown. The +// pairing gate answers "" before that can happen in any state the SDK reaches, +// so this fake reports a device-verified channel while Version() still panics — +// a combination the SDK cannot produce — to keep the recoverPanic backstop +// pinned. An empty string means "not known", and a caller gating on a minimum +// version must not read that as old firmware. +func TestFirmwareVersionReturnsEmptyWhenTheVersionIsUnknown(t *testing.T) { + fake := &fakeBitboxDevice{ + status: firmware.StatusInitialized, + channelHashOk: true, + } + withFakeBitbox(t, fake) + + if !InitDevice() { + t.Fatal("expected simulated init to succeed") + } + if got := FirmwareVersion(); got != "" { + t.Fatalf("expected the panic to be absorbed into an empty version, got %q", got) + } + if !slices.Contains(fake.calls, "Version") { + t.Fatal("expected FirmwareVersion to consult the device") + } + // The panic must not have poisoned the binding: the next call still works. + if got := DeviceStatus(); got != string(firmware.StatusInitialized) { + t.Fatalf("expected the binding to survive the panic, got %q", got) + } +} + +// Closing must drop the device, or the next connection inherits this one's +// cached state. A stale firmware version is the dangerous case: a host gate +// would clear a device it never inspected. +func TestReleaseDeviceClearsTheDeviceSoStaleStateIsNotReported(t *testing.T) { + fake := &fakeBitboxDevice{ + status: firmware.StatusInitialized, + channelHashOk: true, + version: semver.NewSemVer(9, 26, 4), + } + withFakeBitbox(t, fake) + + if !InitDevice() { + t.Fatal("expected simulated init to succeed") + } + if got := FirmwareVersion(); got != "v9.26.4" { + t.Fatalf("expected the connected device's version, got %q", got) + } + + ReleaseDevice() + + if got := FirmwareVersion(); got != "" { + t.Fatalf("expected no version after release, got %q", got) + } + if got := DeviceStatus(); got != "" { + t.Fatalf("expected no status after release, got %q", got) + } +} + +// nullTransport satisfies GoReadWriteCloserInterface without a device. +// GetDeviceWithInfo only stores it — u2fhid.NewCommunication and +// firmware.NewDevice do no I/O — so the version-parsing branch is reachable in +// a unit test. +type nullTransport struct{} + +func (nullTransport) Read(n int) ([]byte, error) { return nil, errors.New("no transport") } +func (nullTransport) Write(p []byte) (int, error) { + return 0, errors.New("no transport") +} +func (nullTransport) Close() error { return nil } + +// A version the device reported but GetDeviceWithInfo could not parse is +// replaced by an invented placeholder. That placeholder must never leave the +// binding — neither as the device's own version, nor through the capability +// answers the SDK derives from the version. +func TestGetDeviceWithInfoWithholdsAnUnparseableVersion(t *testing.T) { + restore := snapshotDevice() + t.Cleanup(restore) + + // A real Init needs a transport, so mark the bind directly; the subject + // here is the version parsing, not the handshake. + initialiseCurrentDevice := func() { + device, _, _ := currentDevice() + setInitialised(device, true) + } + + GetDeviceWithInfo(nullTransport{}, "v9.26.4", "bb02p-multi") + initialiseCurrentDevice() + if got := FirmwareVersion(); got != "v9.26.4" { + t.Fatalf("expected the reported version, got %q", got) + } + if !SupportsETH(1) { + t.Fatal("expected ETH support for a device on v9.26.4") + } + + GetDeviceWithInfo(nullTransport{}, "not-a-version", "bb02p-multi") + initialiseCurrentDevice() + if got := FirmwareVersion(); got != "" { + t.Fatalf("expected the invented version to be withheld, got %q", got) + } + if SupportsETH(1) { + t.Fatal("expected no ETH support while the version is unknown") + } + if SupportsERC20("0xdAC17F958D2ee523a2206206994597C13D831ec7") { + t.Fatal("expected no ERC20 support while the version is unknown") + } + + // A later good connection must clear the flag again, not inherit it. + GetDeviceWithInfo(nullTransport{}, "v9.26.4", "bb02p-multi") + initialiseCurrentDevice() + if got := FirmwareVersion(); got != "v9.26.4" { + t.Fatalf("expected the flag to be cleared on reconnect, got %q", got) + } + + ReleaseDevice() + if got := FirmwareVersion(); got != "" { + t.Fatalf("expected no version after release, got %q", got) + } +} + +// A device whose init did not succeed -- the transport dropped, the handshake +// failed -- must not answer a version gate. Over Bluetooth the version is known +// from the product characteristic before init even starts, so without the +// initialised flag the binding would vouch for a channel never established. +func TestFirmwareVersionStaysUnknownWhenInitFails(t *testing.T) { + fake := &fakeBitboxDevice{ + initErr: errors.New("transport dropped"), + channelHashOk: true, + status: firmware.StatusInitialized, + version: semver.NewSemVer(9, 26, 4), + supportsETH: true, + supportedERC20: map[string]bool{"0xToken": true}, + } + withFakeBitbox(t, fake) + + if InitDevice() { + t.Fatal("expected simulated init to fail") + } + + if got := FirmwareVersion(); got != "" { + t.Fatalf("expected no version after a failed init, got %q", got) + } + if SupportsETH(1) || SupportsERC20("0xToken") { + t.Fatal("expected no version-derived capabilities after a failed init") + } +} + +// The SDK returns no error when the user declines the pairing on the device: +// it drops both ciphers, sets StatusPairingFailed and leaves the channel hash +// unverified, then returns nil. Init succeeding is therefore not the same as +// pairing succeeding, and only the latter may answer a version gate. +func TestFirmwareVersionStaysUnknownWhenThePairingIsDeclined(t *testing.T) { + fake := &fakeBitboxDevice{ + channelHash: "PAIR-CODE", + channelHashOk: false, // declined on the device + status: firmware.StatusPairingFailed, + version: semver.NewSemVer(9, 26, 4), + supportsETH: true, + supportedERC20: map[string]bool{"0xToken": true}, + } + withFakeBitbox(t, fake) + + // Init itself reports success — that is the trap. + if !InitDevice() { + t.Fatal("expected the simulated init call itself to succeed") + } + + if got := FirmwareVersion(); got != "" { + t.Fatalf("expected no version after a declined pairing, got %q", got) + } + if SupportsETH(1) || SupportsERC20("0xToken") { + t.Fatal("expected no version-derived capabilities after a declined pairing") + } +} + +// The host rejecting the pairing code repudiates the channel from this side. +// The SDK marks the status but leaves its own device-verified flag set, so the +// binding has to clear its own. +func TestHostRejectingThePairingClearsTheVersion(t *testing.T) { + fake := &fakeBitboxDevice{ + channelHash: "PAIR-CODE", + channelHashOk: true, + version: semver.NewSemVer(9, 26, 4), + supportsETH: true, + } + withFakeBitbox(t, fake) + + if !InitDevice() { + t.Fatal("expected simulated init to succeed") + } + if got := FirmwareVersion(); got != "v9.26.4" { + t.Fatalf("expected the version after the device confirmed, got %q", got) + } + + ChannelHashVerify(false) + + if got := FirmwareVersion(); got != "" { + t.Fatalf("expected no version after the host rejected the code, got %q", got) + } + if SupportsETH(1) { + t.Fatal("expected no ETH support after the host rejected the code") + } + + // Re-affirming leaves the SDK's channel usable, so the version comes back + // without forcing a full re-init. + ChannelHashVerify(true) + + if got := FirmwareVersion(); got != "v9.26.4" { + t.Fatalf("expected the version back after the host re-affirmed, got %q", got) + } + if !SupportsETH(1) { + t.Fatal("expected ETH support back after the host re-affirmed") + } +} + +// Both bridges hard-code channelHashVerify(true), and a device decline still +// resolves initBitBox to true — so an app that shows the pairing code and then +// confirms reaches this exact sequence whenever the user declines on the +// device. Affirming must not resurrect a channel the device refused. +func TestHostAffirmingCannotResurrectADeclinedPairing(t *testing.T) { + fake := &fakeBitboxDevice{ + channelHash: "PAIR-CODE", + channelHashOk: false, // declined on the device + version: semver.NewSemVer(9, 26, 4), + supportsETH: true, + supportedERC20: map[string]bool{"0xToken": true}, + } + withFakeBitbox(t, fake) + + if !InitDevice() { + t.Fatal("expected the simulated init call itself to succeed") + } + + ChannelHashVerify(true) + + if got := FirmwareVersion(); got != "" { + t.Fatalf("expected no version after affirming a declined pairing, got %q", got) + } + if SupportsETH(1) || SupportsERC20("0xToken") { + t.Fatal("expected no capabilities after affirming a declined pairing") + } +} + +// A second init that fails must not leave the first one's success answering. +// Android re-inits on the bound device without reopening, so nothing else +// would clear it. +func TestFailedReinitClearsThePreviousSuccess(t *testing.T) { + fake := &fakeBitboxDevice{ + channelHashOk: true, + status: firmware.StatusInitialized, + version: semver.NewSemVer(9, 26, 4), + supportsETH: true, + } + withFakeBitbox(t, fake) + + if !InitDevice() { + t.Fatal("expected the first init to succeed") + } + if got := FirmwareVersion(); got != "v9.26.4" { + t.Fatalf("expected the version after a good init, got %q", got) + } + + fake.initErr = errors.New("transport dropped") + if InitDevice() { + t.Fatal("expected the second init to fail") + } + + if got := FirmwareVersion(); got != "" { + t.Fatalf("expected no version after a failed re-init, got %q", got) + } + if SupportsETH(1) { + t.Fatal("expected no ETH support after a failed re-init") + } +} + +// An init still in flight when the device is replaced must not mark the new +// one — its result describes a device that is no longer connected. +func TestInitCannotMarkADeviceThatWasReplaced(t *testing.T) { + restore := snapshotDevice() + t.Cleanup(restore) + + previous := &fakeBitboxDevice{channelHashOk: true} + replacement := &fakeBitboxDevice{channelHashOk: true} + + setDevice(replacement, false) + setInitialised(previous, true) + + if _, _, initialised := currentDevice(); initialised { + t.Fatal("expected a replaced device's init result to be discarded") + } +} + +// concurrentFake answers without recording the call, so the harness's own call +// log cannot race and confuse the subject of the test below, which is deviceMu. +type concurrentFake struct { + *fakeBitboxDevice + version *semver.SemVer +} + +func (c concurrentFake) Version() *semver.SemVer { return c.version } +func (c concurrentFake) Status() firmware.Status { return firmware.StatusInitialized } + +// The bridges release the device from the thread handling close/disconnect +// while a signature or the pairing handshake is still running on another, so +// the device and its flag must be replaced as a pair under the lock. Without +// deviceMu this fails under -race; the assertions matter less than the +// concurrent access itself. +func TestDeviceStateIsSafeUnderConcurrentReplacement(t *testing.T) { + restore := snapshotDevice() + t.Cleanup(restore) + + fake := concurrentFake{ + fakeBitboxDevice: &fakeBitboxDevice{}, + version: semver.NewSemVer(9, 26, 4), + } + + var ( + wg sync.WaitGroup + versionsSeen atomic.Int64 + ) + for i := 0; i < 8; i++ { + wg.Add(2) + go func() { + defer wg.Done() + for j := 0; j < 200; j++ { + setDevice(fake, false) + // Mark it, or the readers below can only ever see the + // uninitialised answer and the assertion goes vacuous. + setInitialised(fake, true) + // Uncontended mutexes never park the goroutine, so with a + // single P a writer would otherwise run all its iterations + // before any reader starts and always be observed released. + runtime.Gosched() + ReleaseDevice() + } + }() + go func() { + defer wg.Done() + for j := 0; j < 200; j++ { + // Either answer is legitimate; the point is that no reader can + // observe a half-replaced device. + switch got := FirmwareVersion(); got { + case "v9.26.4": + versionsSeen.Add(1) + case "": + default: + t.Errorf("observed a torn firmware version: %q", got) + } + DeviceStatus() + } + }() + } + wg.Wait() + + // Without this the test would still pass if the writer stopped marking the + // device initialised, leaving the readers unable to see anything but "". + if versionsSeen.Load() == 0 { + t.Fatal("no reader ever observed the connected device's version") + } +} + +// Attaching a USB device after a Bluetooth device whose version did not parse +// must not inherit the flag. The two exports cannot tell the states apart -- +// both answer "" before Init -- so assert the flag itself, which is what the +// version the SDK infers during Init is later filtered through. +func TestGetDeviceClearsTheSyntheticVersionFlag(t *testing.T) { + restore := snapshotDevice() + t.Cleanup(restore) + + GetDeviceWithInfo(nullTransport{}, "not-a-version", "bb02p-multi") + if _, synthetic, _ := currentDevice(); !synthetic { + t.Fatal("expected the unparseable version to be flagged as synthetic") + } + + GetDevice(nullTransport{}) + + if _, synthetic, _ := currentDevice(); synthetic { + t.Fatal("expected a USB device to start with no synthetic version flag") + } +} + func TestFakeBitboxHarnessSimulatesErrorsAndPanicsWithoutCrashing(t *testing.T) { fake := &fakeBitboxDevice{ initErr: errors.New("init failed"), @@ -288,11 +703,9 @@ func TestFakeBitboxHarnessSimulatesErrorsAndPanicsWithoutCrashing(t *testing.T) } func TestExportedAPIsReturnZeroValuesWithoutDeviceInsteadOfCrashing(t *testing.T) { - previous := bitbox - bitbox = nil - t.Cleanup(func() { - bitbox = previous - }) + restore := snapshotDevice() + setDevice(nil, false) + t.Cleanup(restore) keypath := "0000002c0000003c000000000000000000000000" if InitDevice() { @@ -311,6 +724,9 @@ func TestExportedAPIsReturnZeroValuesWithoutDeviceInsteadOfCrashing(t *testing.T if got := DeviceStatus(); got != "" { t.Fatalf("expected empty device status without device, got %q", got) } + if got := FirmwareVersion(); got != "" { + t.Fatalf("expected empty firmware version without device, got %q", got) + } if got := ETHGetAddress(1, keypath, int(messages.ETHPubRequest_ADDRESS), false, nil); got != "" { t.Fatalf("expected empty ETH address without device, got %q", got) } diff --git a/go/api/ios_bluetooth_regression_test.go b/go/api/ios_bluetooth_regression_test.go index 9f41c0b..2373752 100644 --- a/go/api/ios_bluetooth_regression_test.go +++ b/go/api/ios_bluetooth_regression_test.go @@ -1,6 +1,7 @@ package api import ( + "errors" "os" "strings" "testing" @@ -23,3 +24,136 @@ func TestIOSBluetoothKeeps60sReadTimeout(t *testing.T) { t.Fatal("Bluetooth.swift must not regress to the old 10s BLE read timeout") } } + +// The Go binding only learns a device went away when Swift tells it. Both +// teardown and re-connection must release it: the peripheral can drop on its +// own, and connecting to a second one does not rebind until initBitBox. Either +// gap leaves the binding reporting the previous device's firmware version and +// status to a host that is gating on them. +func TestIOSBluetoothReleasesTheDeviceOnDisconnectAndConnect(t *testing.T) { + for _, function := range []string{ + "func handleDisconnect() {", + "func connect(to peripheralID: UUID) {", + } { + body, err := swiftFunctionBody(t, "../../ios/Classes/Bluetooth.swift", function) + if err != nil { + t.Fatalf("%s: %v", function, err) + } + if !containsCall(body, "ApiReleaseDevice()") { + t.Fatalf("%s must call ApiReleaseDevice(), or a device that is gone keeps answering", function) + } + } +} + +// close() must keep routing through handleDisconnect, which is what performs +// the release. +func TestIOSPluginCloseTearsDownThroughHandleDisconnect(t *testing.T) { + body, err := swiftFunctionBody( + t, + "../../ios/Classes/BitboxFlutterPlugin.swift", + "private func close(result: @escaping FlutterResult) {", + ) + if err != nil { + t.Fatal(err) + } + if !containsCall(body, "bluetoothManager.handleDisconnect()") { + t.Fatal("close must call handleDisconnect, which is what releases the Go-side device") + } +} + +// Android's half of the same invariant. Closing must release, and opening must +// release BEFORE it rebinds — connectBitBox calls Api.getDevice on the success +// path, so a release after it would drop the device that was just bound. +func TestAndroidReleasesTheDeviceOnCloseAndConnect(t *testing.T) { + const dir = "../../android/src/main/kotlin/com/cakewallet/bitbox_flutter/operations/" + + for _, file := range []string{"CloseOperation.kt", "ConnectBitBoxOperation.kt"} { + body, err := kotlinMethodBody(t, dir+file, "override fun onMethodCall(") + if err != nil { + t.Fatalf("%s: %v", file, err) + } + if !containsCall(body, "Api.releaseDevice()") { + t.Fatalf("%s must call Api.releaseDevice() in onMethodCall, or a device that is gone keeps answering", file) + } + } + + body, err := kotlinMethodBody(t, dir+"ConnectBitBoxOperation.kt", "override fun onMethodCall(") + if err != nil { + t.Fatal(err) + } + release, connect := callIndex(body, "Api.releaseDevice()"), callIndex(body, "connectBitBox(") + if connect < 0 { + t.Fatal("connectBitBox( not found in onMethodCall — this source assertion needs updating") + } + if release < 0 || release > connect { + t.Fatal("ConnectBitBoxOperation must release before connectBitBox rebinds, or it drops the device it just bound") + } +} + +// kotlinMethodBody returns the source between a method's signature and the +// first closing brace at the enclosing indentation, failing loudly rather than +// degrading into a file-wide search. +func kotlinMethodBody(t *testing.T, path, signature string) (string, error) { + t.Helper() + + contentBytes, err := os.ReadFile(path) + if err != nil { + return "", err + } + + _, after, found := strings.Cut(string(contentBytes), signature) + if !found { + return "", errors.New("method not found — this source assertion needs updating") + } + body, _, closed := strings.Cut(after, "\n }") + if !closed { + return "", errors.New("could not find the end of the method — this source assertion needs updating") + } + return body, nil +} + +// swiftFunctionBody returns the source between a function's opening brace and +// the first closing brace at the enclosing indentation. It fails rather than +// returning a best guess, so a reformat degrades these guards loudly instead of +// silently widening them to the whole file. +func swiftFunctionBody(t *testing.T, path, signature string) (string, error) { + t.Helper() + + contentBytes, err := os.ReadFile(path) + if err != nil { + return "", err + } + + _, after, found := strings.Cut(string(contentBytes), signature) + if !found { + return "", errors.New("function not found — this source assertion needs updating") + } + body, _, closed := strings.Cut(after, "\n }") + if !closed { + return "", errors.New("could not find the end of the function — this source assertion needs updating") + } + return body, nil +} + +// containsCall reports whether the body actually calls target. Line comments +// are stripped first, so neither commenting the call out nor merely mentioning +// it in a trailing comment satisfies the guard — both are likelier than the +// call simply vanishing. +func containsCall(body, target string) bool { + return callIndex(body, target) >= 0 +} + +// callIndex is containsCall with a position: the offset of the first real call +// to target, or -1. Ordering checks must use this rather than strings.Index, or +// a commented-out decoy earlier in the body would satisfy them. +func callIndex(body, target string) int { + offset := 0 + for _, line := range strings.Split(body, "\n") { + code, _, _ := strings.Cut(line, "//") + if i := strings.Index(code, target); i >= 0 { + return offset + i + } + offset += len(line) + 1 + } + return -1 +} diff --git a/ios/Api.xcframework/Info.plist b/ios/Api.xcframework/Info.plist index 1a9d8ff..9d3fc23 100644 --- a/ios/Api.xcframework/Info.plist +++ b/ios/Api.xcframework/Info.plist @@ -8,32 +8,32 @@ BinaryPath Api.framework/Api LibraryIdentifier - ios-arm64_x86_64-simulator + ios-arm64 LibraryPath Api.framework SupportedArchitectures arm64 - x86_64 SupportedPlatform ios - SupportedPlatformVariant - simulator BinaryPath Api.framework/Api LibraryIdentifier - ios-arm64 + ios-arm64_x86_64-simulator LibraryPath Api.framework SupportedArchitectures arm64 + x86_64 SupportedPlatform ios + SupportedPlatformVariant + simulator CFBundlePackageType diff --git a/ios/Api.xcframework/ios-arm64/Api.framework/Api b/ios/Api.xcframework/ios-arm64/Api.framework/Api index 4b4f4d0..c2b445c 100644 Binary files a/ios/Api.xcframework/ios-arm64/Api.framework/Api and b/ios/Api.xcframework/ios-arm64/Api.framework/Api differ diff --git a/ios/Api.xcframework/ios-arm64/Api.framework/Headers/Api.objc.h b/ios/Api.xcframework/ios-arm64/Api.framework/Headers/Api.objc.h index 093e3aa..f8af034 100644 --- a/ios/Api.xcframework/ios-arm64/Api.framework/Headers/Api.objc.h +++ b/ios/Api.xcframework/ios-arm64/Api.framework/Headers/Api.objc.h @@ -67,6 +67,23 @@ FOUNDATION_EXPORT NSData* _Nullable ApiETHSignTransaction(long chainId, NSString FOUNDATION_EXPORT NSData* _Nullable ApiETHSignTypedMessage(long chainId, NSString* _Nullable keypath, NSData* _Nullable jsonMsg); +/** + * FirmwareVersion returns the main firmware version of the connected device, +`v`-prefixed (e.g. "v9.26.4"). It becomes available once the pairing has +been established — InitDevice returning true is not sufficient, see below. +Over Bluetooth the version reaches the SDK with GetDeviceWithInfo, over USB +the SDK infers it from OP_INFO while initialising. Reading it afterwards +costs no device round-trip. + +An empty string means the version is not known — no device, a device whose +pairing was not established (a decline included, which InitDevice itself +still reports as success), or one whose reported version could not be +parsed. It never means "old firmware": callers gating on a minimum version +must treat the two apart. This is the main firmware version, NOT the +separately versioned Bluetooth firmware. + */ +FOUNDATION_EXPORT NSString* _Nonnull ApiFirmwareVersion(void); + FOUNDATION_EXPORT NSString* _Nonnull ApiGetChannelHash(void); FOUNDATION_EXPORT void ApiGetDevice(id _Nullable device); @@ -81,6 +98,14 @@ FOUNDATION_EXPORT NSData* _Nullable ApiGetMasterFingerprint(void); FOUNDATION_EXPORT BOOL ApiInitDevice(void); +/** + * ReleaseDevice drops the reference to the connected device. The bridges call +it when closing so the next connection cannot be answered with the previous +device's cached state — a stale firmware version would otherwise let a host +clear a device it never inspected. + */ +FOUNDATION_EXPORT void ApiReleaseDevice(void); + FOUNDATION_EXPORT BOOL ApiSupportsBluetooth(void); FOUNDATION_EXPORT BOOL ApiSupportsERC20(NSString* _Nullable contractAddress); diff --git a/ios/Api.xcframework/ios-arm64/Api.framework/Info.plist b/ios/Api.xcframework/ios-arm64/Api.framework/Info.plist index 08aa4e4..617a171 100644 --- a/ios/Api.xcframework/ios-arm64/Api.framework/Info.plist +++ b/ios/Api.xcframework/ios-arm64/Api.framework/Info.plist @@ -9,9 +9,9 @@ MinimumOSVersion 100.0 CFBundleShortVersionString - 0.0.1781025390 + 0.0.1785441807 CFBundleVersion - 0.0.1781025390 + 0.0.1785441807 CFBundlePackageType FMWK diff --git a/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Api b/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Api index 04dddb2..819501e 100644 Binary files a/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Api and b/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Api differ diff --git a/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Headers/Api.objc.h b/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Headers/Api.objc.h index 093e3aa..f8af034 100644 --- a/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Headers/Api.objc.h +++ b/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Headers/Api.objc.h @@ -67,6 +67,23 @@ FOUNDATION_EXPORT NSData* _Nullable ApiETHSignTransaction(long chainId, NSString FOUNDATION_EXPORT NSData* _Nullable ApiETHSignTypedMessage(long chainId, NSString* _Nullable keypath, NSData* _Nullable jsonMsg); +/** + * FirmwareVersion returns the main firmware version of the connected device, +`v`-prefixed (e.g. "v9.26.4"). It becomes available once the pairing has +been established — InitDevice returning true is not sufficient, see below. +Over Bluetooth the version reaches the SDK with GetDeviceWithInfo, over USB +the SDK infers it from OP_INFO while initialising. Reading it afterwards +costs no device round-trip. + +An empty string means the version is not known — no device, a device whose +pairing was not established (a decline included, which InitDevice itself +still reports as success), or one whose reported version could not be +parsed. It never means "old firmware": callers gating on a minimum version +must treat the two apart. This is the main firmware version, NOT the +separately versioned Bluetooth firmware. + */ +FOUNDATION_EXPORT NSString* _Nonnull ApiFirmwareVersion(void); + FOUNDATION_EXPORT NSString* _Nonnull ApiGetChannelHash(void); FOUNDATION_EXPORT void ApiGetDevice(id _Nullable device); @@ -81,6 +98,14 @@ FOUNDATION_EXPORT NSData* _Nullable ApiGetMasterFingerprint(void); FOUNDATION_EXPORT BOOL ApiInitDevice(void); +/** + * ReleaseDevice drops the reference to the connected device. The bridges call +it when closing so the next connection cannot be answered with the previous +device's cached state — a stale firmware version would otherwise let a host +clear a device it never inspected. + */ +FOUNDATION_EXPORT void ApiReleaseDevice(void); + FOUNDATION_EXPORT BOOL ApiSupportsBluetooth(void); FOUNDATION_EXPORT BOOL ApiSupportsERC20(NSString* _Nullable contractAddress); diff --git a/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Info.plist b/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Info.plist index 08aa4e4..617a171 100644 --- a/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Info.plist +++ b/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Info.plist @@ -9,9 +9,9 @@ MinimumOSVersion 100.0 CFBundleShortVersionString - 0.0.1781025390 + 0.0.1785441807 CFBundleVersion - 0.0.1781025390 + 0.0.1785441807 CFBundlePackageType FMWK diff --git a/ios/Classes/BitboxFlutterPlugin.swift b/ios/Classes/BitboxFlutterPlugin.swift index 2234c3c..4c04379 100644 --- a/ios/Classes/BitboxFlutterPlugin.swift +++ b/ios/Classes/BitboxFlutterPlugin.swift @@ -34,6 +34,8 @@ public class BitboxFlutterPlugin: NSObject, FlutterPlugin { channelHashVerify(result: result) case "getDeviceStatus": getDeviceStatus(result: result) + case "getFirmwareVersion": + getFirmwareVersion(result: result) case "supportsETH": supportsETH(call: call, result: result) case "supportsERC20": @@ -122,6 +124,8 @@ public class BitboxFlutterPlugin: NSObject, FlutterPlugin { } private func close(result: @escaping FlutterResult) { + // handleDisconnect releases the Go-side device, so the next peripheral + // is not answered with this one's cached status and firmware version. bluetoothManager.handleDisconnect() result(true) } @@ -196,6 +200,14 @@ public class BitboxFlutterPlugin: NSObject, FlutterPlugin { result(ApiDeviceStatus()) } + private func getFirmwareVersion(result: @escaping FlutterResult) { + // ApiFirmwareVersion() reads the version the SDK already holds — no + // device round-trip — so it returns immediately without a background + // dispatch. Empty until the pairing is established — initBitBox returning + // true is not enough; the Dart side maps that to null. + result(ApiFirmwareVersion()) + } + // MARK: - Feature Support private func supportsETH(call: FlutterMethodCall, result: @escaping FlutterResult) { diff --git a/ios/Classes/Bluetooth.swift b/ios/Classes/Bluetooth.swift index 83179c5..1a39c87 100644 --- a/ios/Classes/Bluetooth.swift +++ b/ios/Classes/Bluetooth.swift @@ -121,6 +121,13 @@ class BluetoothManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB guard var metadata = state.discoveredPeripherals[peripheralID] else { return } centralManager.stopScan() + // Connecting starts a new device even if the previous one was never + // closed, and nothing rebinds the Go side until initBitBox. Without + // this, the peripheral we are leaving keeps answering for the one we + // are joining. ConnectBitBoxOperation does the same on Android, where + // Api.getDevice only rebinds on the success path. + ApiReleaseDevice() + // Reset characteristics for fresh connection pWriter = nil pReader = nil @@ -374,6 +381,13 @@ class BluetoothManager: NSObject, ObservableObject, CBCentralManagerDelegate, CB } func handleDisconnect() { + // Release the Go-side device on EVERY disconnect, not just an explicit + // close: the peripheral can drop on its own and Bluetooth can be turned + // off, and open() does not rebind. Otherwise the binding keeps + // answering getFirmwareVersion/getDeviceStatus for a device that is no + // longer there. + ApiReleaseDevice() + connectedPeripheral = nil pReader = nil pWriter = nil diff --git a/lib/bitbox_manager.dart b/lib/bitbox_manager.dart index abdff55..e757ceb 100644 --- a/lib/bitbox_manager.dart +++ b/lib/bitbox_manager.dart @@ -39,6 +39,14 @@ class BitboxManager { Future getDeviceStatus() => BitboxUsbPlatform.instance.getDeviceStatus(); + /// The connected device's main firmware version, e.g. `"v9.26.4"`, available + /// once the pairing has been established, or null when it is not known. + /// [initBitBox] returning true is not sufficient — it still resolves true + /// when the user declines the pairing on the device, and this stays null. + /// Null is not "old firmware" — see [BitboxUsbPlatform.getFirmwareVersion]. + Future getFirmwareVersion() => + BitboxUsbPlatform.instance.getFirmwareVersion(); + Future supportsLTC() => BitboxUsbPlatform.instance.supportsLTC(); Future supportsETH(int chainId) => diff --git a/lib/testing/bitbox_testkit.dart b/lib/testing/bitbox_testkit.dart index 20b2824..4343c9b 100644 --- a/lib/testing/bitbox_testkit.dart +++ b/lib/testing/bitbox_testkit.dart @@ -18,6 +18,7 @@ final class SimulatedBitboxMethod { static const getChannelHash = 'getChannelHash'; static const channelHashVerify = 'channelHashVerify'; static const getDeviceStatus = 'getDeviceStatus'; + static const getFirmwareVersion = 'getFirmwareVersion'; static const supportsETH = 'supportsETH'; static const supportsERC20 = 'supportsERC20'; static const supportsLTC = 'supportsLTC'; @@ -66,8 +67,10 @@ class SimulatedBitboxPlatform extends BitboxUsbPlatform { this.permissionResult = true, this.openResult = true, this.initResult = true, + this.pairingVerified = true, this.channelHashVerifyResult = true, this.deviceStatus = 'initialized', + this.firmwareVersion = 'v9.26.4', this.supportsETHResult = true, this.supportsERC20Result = true, this.supportsLTCResult = true, @@ -157,8 +160,17 @@ class SimulatedBitboxPlatform extends BitboxUsbPlatform { final bool permissionResult; final bool openResult; final bool initResult; + + /// Whether the device confirms the pairing. Set to false to model a user + /// declining on the device: [initBitBox] still resolves true, exactly as the + /// plugin does, but the version and the capabilities derived from it stay + /// withheld. + final bool pairingVerified; final bool channelHashVerifyResult; final String deviceStatus; + + /// Set to null to simulate a device that has not reported a version yet. + final String? firmwareVersion; final bool supportsETHResult; final bool supportsERC20Result; final bool supportsLTCResult; @@ -181,10 +193,16 @@ class SimulatedBitboxPlatform extends BitboxUsbPlatform { final List _devices; bool _isOpen = false; + bool _isInitialised = false; bool _channelHashVerified; bool get isOpen => _isOpen; + /// Whether the currently open device's pairing is established. The firmware + /// version is only readable from that point on. Note this is not the same as + /// [initBitBox] returning true — see [pairingVerified]. + bool get isInitialised => _isInitialised; + bool get channelHashVerified => _channelHashVerified; List get devices => List.unmodifiable(_devices); @@ -237,6 +255,10 @@ class SimulatedBitboxPlatform extends BitboxUsbPlatform { @override Future open(BitboxDevice usbDevice) async { + // Opening starts a new device, whether or not the previous one was closed + // first. Anything learned about the previous one is stale from here on. + _isInitialised = false; + final result = await _run( SimulatedBitboxMethod.open, {'device': usbDevice}, @@ -248,11 +270,19 @@ class SimulatedBitboxPlatform extends BitboxUsbPlatform { } @override - Future initBitBox() => _run( - SimulatedBitboxMethod.initBitBox, - const {}, - initResult, - ); + Future initBitBox() async { + // Cleared up front so a rejected or failed init cannot leave the previous + // device's state readable. + _isInitialised = false; + + final result = await _run( + SimulatedBitboxMethod.initBitBox, + const {}, + initResult, + ); + _isInitialised = result && pairingVerified; + return result; + } @override Future getMasterFingerprint() => _run( @@ -286,26 +316,72 @@ class SimulatedBitboxPlatform extends BitboxUsbPlatform { deviceStatus, ); + // On hardware the version is read from the device initBitBox bound, and only + // once that device has confirmed the pairing, so it stays absent until then + // and goes absent again after close. The simulator models that rather than + // the looser open-only gate, so a consumer's version gate cannot pass here + // and read null in the field. @override - Future supportsETH(int chainId) => _run( - SimulatedBitboxMethod.supportsETH, - {'chainId': chainId}, - supportsETHResult, - ); + Future getFirmwareVersion() async { + final version = await _run( + SimulatedBitboxMethod.getFirmwareVersion, + const {}, + firmwareVersion, + ); + + return _isInitialised ? version : null; + } + // The SDK answers these two by comparing the firmware version, so the plugin + // reports no support whenever the version is unknown. Mirror that here, or a + // consumer's capability gate passes against the simulator and reads false on + // hardware. @override - Future supportsERC20(String contractAddress) => _run( - SimulatedBitboxMethod.supportsERC20, - {'contractAddress': contractAddress}, - supportsERC20Result, - ); + Future supportsETH(int chainId) async { + final supported = await _run( + SimulatedBitboxMethod.supportsETH, + {'chainId': chainId}, + supportsETHResult, + ); + + return _hasKnownVersion && supported; + } @override - Future supportsLTC() => _run( - SimulatedBitboxMethod.supportsLTC, - const {}, - supportsLTCResult, - ); + Future supportsERC20(String contractAddress) async { + final supported = await _run( + SimulatedBitboxMethod.supportsERC20, + {'contractAddress': contractAddress}, + supportsERC20Result, + ); + + return _hasKnownVersion && supported; + } + + /// Reads the configured [firmwareVersion], not the result of + /// [getFirmwareVersion]: consulting that would log a second call and run any + /// behaviour installed with [when] twice. The capabilities therefore follow + /// the configured version — a [when] override that supplies one does not + /// enable them, so set [firmwareVersion] itself to drive both. + bool get _hasKnownVersion => _isInitialised && firmwareVersion != null; + + // Product-derived rather than version-derived, so the plugin can still answer + // it once the device is bound — including after a pairing the device + // declined. The simulator is deliberately stricter and waits for an + // established pairing: before the device is bound the plugin answers false + // anyway (on USB the product is not known yet, on Bluetooth nothing is + // bound), and a consumer is better served by one gate that opens when the + // pairing does. + @override + Future supportsLTC() async { + final supported = await _run( + SimulatedBitboxMethod.supportsLTC, + const {}, + supportsLTCResult, + ); + + return _isInitialised && supported; + } @override Future getBTCXPub( @@ -482,7 +558,10 @@ class SimulatedBitboxPlatform extends BitboxUsbPlatform { true, needsOpen: false, ); - if (result) _isOpen = false; + if (result) { + _isOpen = false; + _isInitialised = false; + } return result; } @@ -531,8 +610,10 @@ SimulatedBitboxPlatform installSimulatedBitboxPlatform({ bool permissionResult = true, bool openResult = true, bool initResult = true, + bool pairingVerified = true, bool channelHashVerifyResult = true, String deviceStatus = 'initialized', + String? firmwareVersion = 'v9.26.4', bool supportsETHResult = true, bool supportsERC20Result = true, bool supportsLTCResult = true, @@ -560,8 +641,10 @@ SimulatedBitboxPlatform installSimulatedBitboxPlatform({ permissionResult: permissionResult, openResult: openResult, initResult: initResult, + pairingVerified: pairingVerified, channelHashVerifyResult: channelHashVerifyResult, deviceStatus: deviceStatus, + firmwareVersion: firmwareVersion, supportsETHResult: supportsETHResult, supportsERC20Result: supportsERC20Result, supportsLTCResult: supportsLTCResult, diff --git a/lib/usb/bitbox_usb_method_channel.dart b/lib/usb/bitbox_usb_method_channel.dart index dd5a8a9..2254c83 100644 --- a/lib/usb/bitbox_usb_method_channel.dart +++ b/lib/usb/bitbox_usb_method_channel.dart @@ -82,6 +82,17 @@ class MethodChannelBitboxUsb extends BitboxUsbPlatform { return result ?? ''; } + @override + Future getFirmwareVersion() async { + final result = + await methodChannel.invokeMethod('getFirmwareVersion'); + + // Both bridges return the Go layer's empty string when the version is not + // known yet. Collapse it to null so callers have one absent value to + // check, not two — see the interface doc. + return (result == null || result.isEmpty) ? null : result; + } + @override Future getMasterFingerprint() async { final result = diff --git a/lib/usb/bitbox_usb_platform_interface.dart b/lib/usb/bitbox_usb_platform_interface.dart index 80f7bf9..af00478 100644 --- a/lib/usb/bitbox_usb_platform_interface.dart +++ b/lib/usb/bitbox_usb_platform_interface.dart @@ -45,6 +45,25 @@ abstract class BitboxUsbPlatform extends PlatformInterface { Future getDeviceStatus(); + /// The main firmware version of the connected device, e.g. `"v9.26.4"`. + /// + /// Available once the pairing has been established — on BOTH transports, + /// not just USB. [open] alone is not enough: it establishes the link, while + /// [initBitBox] is what binds the device the version is read from. Nor is + /// [initBitBox] returning true sufficient: it still resolves true when the + /// user declines on the device. Reading it afterwards needs no round-trip. + /// + /// Null means the version is not known — before [initBitBox] or after one + /// whose pairing was not established (a decline included, which [initBitBox] + /// itself still reports as true), after [close], or when the device reported + /// a version that could not be parsed. It never + /// means "old firmware": callers gating on a minimum version must treat the + /// two apart, and refuse rather than pass when the version is absent. + /// + /// This is the main firmware version, NOT the separately versioned Bluetooth + /// firmware. + Future getFirmwareVersion(); + Future supportsETH(int chainId); Future supportsERC20(String contractAddress); diff --git a/test/bitbox_firmware_version_test.dart b/test/bitbox_firmware_version_test.dart new file mode 100644 index 0000000..baf640f --- /dev/null +++ b/test/bitbox_firmware_version_test.dart @@ -0,0 +1,39 @@ +import 'package:bitbox_flutter/usb/bitbox_usb_method_channel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final platform = MethodChannelBitboxUsb(); + final channel = platform.methodChannel; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + test('returns the version the platform reports', () async { + final methods = []; + messenger.setMockMethodCallHandler(channel, (call) async { + methods.add(call.method); + return 'v9.26.4'; + }); + + expect(await platform.getFirmwareVersion(), 'v9.26.4'); + expect(methods, ['getFirmwareVersion']); + }); + + test('maps the empty string to null', () async { + // Both bridges hand through the Go layer's zero value, which is what the + // plugin reports until the pairing is established — initBitBox returning + // true is not enough. Callers get one absent value to check, not two. + messenger.setMockMethodCallHandler(channel, (call) async => ''); + + expect(await platform.getFirmwareVersion(), isNull); + }); + + test('returns null when the platform reports no version', () async { + messenger.setMockMethodCallHandler(channel, (call) async => null); + + expect(await platform.getFirmwareVersion(), isNull); + }); +} diff --git a/test/bitbox_testkit_test.dart b/test/bitbox_testkit_test.dart index 7f6c47e..df33a66 100644 --- a/test/bitbox_testkit_test.dart +++ b/test/bitbox_testkit_test.dart @@ -59,6 +59,9 @@ void main() { ); final manager = BitboxManager(); await manager.connect((await manager.devices).single); + // ETH/ERC20 support is version-derived, and the version is only known once + // the device is initialised — as on hardware. + await manager.initBitBox(); expect(await manager.supportsETH(1), isTrue); expect(await manager.supportsERC20('0xToken'), isTrue); @@ -183,6 +186,10 @@ void main() { ); final manager = BitboxManager(); await manager.connect((await manager.devices).single); + // Capabilities are only meaningful on an initialised device, so initialise + // first — otherwise these assertions would pass on the lifecycle gate and + // never exercise the configured results. + await manager.initBitBox(); expect(await manager.channelHashVerify(), isFalse); expect(platform.channelHashVerified, isFalse); @@ -246,4 +253,165 @@ void main() { expect(await manager.getDeviceStatus(), 'uninitialized'); expect(platform.count(SimulatedBitboxMethod.getDeviceStatus), 1); }); + + test('reports the firmware version once initBitBox has run', () async { + final platform = installSimulatedBitboxPlatform( + firmwareVersion: 'v9.26.4', + ); + final manager = BitboxManager(); + await manager.connect((await manager.devices).single); + await manager.initBitBox(); + + expect(await manager.getFirmwareVersion(), 'v9.26.4'); + expect(platform.count(SimulatedBitboxMethod.getFirmwareVersion), 1); + }); + + test('reports a null firmware version when the device has not told yet', + () async { + // The device is initialised but reported no version. Null must be + // distinguishable from an old version by the caller, never conflated. + installSimulatedBitboxPlatform(firmwareVersion: null); + final manager = BitboxManager(); + await manager.connect((await manager.devices).single); + await manager.initBitBox(); + + expect(await manager.getFirmwareVersion(), isNull); + }); + + test('reports no firmware version before initBitBox has run', () async { + // open() only establishes the link; initBitBox is what binds the device + // the version is read from. The simulator must not be more permissive than + // hardware, or a consumer's gate passes its tests and reads null in the + // field. + installSimulatedBitboxPlatform(firmwareVersion: 'v9.26.4'); + final manager = BitboxManager(); + await manager.connect((await manager.devices).single); + + expect(await manager.getFirmwareVersion(), isNull); + }); + + test('does not carry a firmware version across a reconnect', () async { + // The dangerous case: close, attach a different device, and read the + // version without initialising it. The previous device's version must not + // answer, or a gate clears a device it never inspected. + installSimulatedBitboxPlatform(firmwareVersion: 'v9.26.4'); + final manager = BitboxManager(); + await manager.connect((await manager.devices).single); + await manager.initBitBox(); + expect(await manager.getFirmwareVersion(), 'v9.26.4'); + + await manager.disconnect(); + await manager.connect((await manager.devices).single); + + expect(await manager.getFirmwareVersion(), isNull); + }); + + test('forgets the firmware version after close', () async { + // requireOpen: false so the close path itself is what withdraws the + // version, rather than the open-channel guard answering first. + installSimulatedBitboxPlatform( + requireOpen: false, + firmwareVersion: 'v9.26.4', + ); + final manager = BitboxManager(); + await manager.connect((await manager.devices).single); + await manager.initBitBox(); + expect(await manager.getFirmwareVersion(), 'v9.26.4'); + + await manager.disconnect(); + + expect(await manager.getFirmwareVersion(), isNull); + }); + + test('does not carry a firmware version into a reopen without disconnect', + () async { + // Reconnecting without closing first is the same hazard: hardware rebinds + // an uninitialised device, so the simulator must forget too. + installSimulatedBitboxPlatform(firmwareVersion: 'v9.26.4'); + final manager = BitboxManager(); + await manager.connect((await manager.devices).single); + await manager.initBitBox(); + expect(await manager.getFirmwareVersion(), 'v9.26.4'); + + await manager.connect((await manager.devices).single); + + expect(await manager.getFirmwareVersion(), isNull); + }); + + test('forgets the firmware version when initBitBox fails', () async { + final platform = installSimulatedBitboxPlatform( + firmwareVersion: 'v9.26.4', + ); + final manager = BitboxManager(); + await manager.connect((await manager.devices).single); + await manager.initBitBox(); + + platform.throwOn( + SimulatedBitboxMethod.initBitBox, + StateError('pairing rejected'), + ); + + await expectLater(manager.initBitBox(), throwsA(isA())); + expect(await manager.getFirmwareVersion(), isNull); + }); + + test('reports no ETH support while the version is unknown', () async { + // The plugin answers these from the firmware version, so an unknown + // version must not clear a capability gate. LTC is product-derived and + // stays available. + installSimulatedBitboxPlatform(firmwareVersion: null); + final manager = BitboxManager(); + await manager.connect((await manager.devices).single); + await manager.initBitBox(); + + expect(await manager.getFirmwareVersion(), isNull); + expect(await manager.supportsETH(1), isFalse); + expect(await manager.supportsERC20('0xToken'), isFalse); + // LTC is product-derived, so an unknown version does not withdraw it. + expect(await manager.supportsLTC(), isTrue); + }); + + test('reports no firmware version when the device declines the pairing', + () async { + // The plugin's trap, modelled: the SDK reports a decline by leaving the + // channel hash unverified, not by failing, so initBitBox still resolves + // true. A gate keyed on that boolean would proceed on a channel the device + // refused. + installSimulatedBitboxPlatform(pairingVerified: false); + final manager = BitboxManager(); + await manager.connect((await manager.devices).single); + + expect(await manager.initBitBox(), isTrue); + + expect(await manager.getFirmwareVersion(), isNull); + expect(await manager.supportsETH(1), isFalse); + expect(await manager.supportsERC20('0xToken'), isFalse); + }); + + test('reports no capabilities before initBitBox has run', () async { + // open() alone tells the plugin nothing about the device, so every + // capability answer is false until init binds it — as on hardware. + installSimulatedBitboxPlatform(); + final manager = BitboxManager(); + await manager.connect((await manager.devices).single); + + expect(await manager.supportsETH(1), isFalse); + expect(await manager.supportsERC20('0xToken'), isFalse); + expect(await manager.supportsLTC(), isFalse); + + await manager.initBitBox(); + + expect(await manager.supportsETH(1), isTrue); + expect(await manager.supportsLTC(), isTrue); + }); + + test('requires an open channel to read the firmware version', () async { + installSimulatedBitboxPlatform(firmwareVersion: 'v9.26.4'); + final manager = BitboxManager(); + + expect( + () => manager.getFirmwareVersion(), + throwsA(isA()), + ); + }); }