From a2d233a3ba3aac83925887d4f64d2c0bad48127e Mon Sep 17 00:00:00 2001 From: benma's agent Date: Sat, 19 Sep 2026 15:09:05 +0200 Subject: [PATCH] api/firmware: support host passphrase entry Unlock through the paired Noise channel on firmware 9.28.0 and later, keeping the legacy unlock command for older firmware. The new workflow starts device entry when the optional passphrase feature is enabled and lets the host request device consent to enter the passphrase on the host. Uninitialized and already unlocked devices finish immediately. Expose two callbacks through PassphraseConfig. The availability callback receives a callable that queues a host-entry request and wakes the polling loop without doing transport I/O. Notify once per entry phase, withdraw when consent starts or device entry finishes, and offer a fresh callable on retry. Separate buffered channels keep delayed or duplicate clicks from affecting later phases. Without an availability callback, request host entry once automatically and fall back to device entry on rejection or cancellation. Call the blocking input callback only after device approval. A string, including the empty string, submits input; nil cancels and resumes device entry. The device confirms the actual passphrase. Keep protocol handling and device-entry polling inside the library, holding the API lock for the entire unlock so other queries cannot interrupt its continuations. Notify clients when device passphrase entry finishes, before confirmation starts. This lets the app show the confirmation prompt even when device entry wins a simultaneous host-entry request. Use the device lifetime context to release host input on Close, and make Close idempotent because disconnect handling and unlock cancellation can both close the transport. Ignore repeated pairing approvals to avoid starting duplicate unlocks. Clear temporary protobuf plaintext buffers after each query. On local failure, withdraw availability, attempt RESET and close the connection; reconnect uses the preceding session-reset commit. Keep resetSession's explicit firmware version check independent of host-passphrase support. Add simulator coverage for initialization, device passphrase entry, disabled passphrases, simultaneous host requests, stale clicks, repeated unlocks and repeated closes. --- api/firmware/device.go | 20 +- api/firmware/event.go | 4 + api/firmware/messages/hww.pb.go | 163 ++++++++++---- api/firmware/messages/hww.proto | 4 + api/firmware/messages/keystore.pb.go | 326 ++++++++++++++++++++++++--- api/firmware/messages/keystore.proto | 32 +++ api/firmware/options.go | 26 +++ api/firmware/pairing.go | 14 +- api/firmware/query.go | 2 + api/firmware/status.go | 6 +- api/firmware/unlock.go | 109 +++++++++ api/firmware/unlock_test.go | 119 ++++++++++ 12 files changed, 743 insertions(+), 82 deletions(-) create mode 100644 api/firmware/unlock.go create mode 100644 api/firmware/unlock_test.go diff --git a/api/firmware/device.go b/api/firmware/device.go index 8131c89..98d3810 100644 --- a/api/firmware/device.go +++ b/api/firmware/device.go @@ -4,6 +4,7 @@ package firmware import ( + "context" "fmt" "sync" @@ -60,6 +61,10 @@ const ( // Device provides the API to communicate with the BitBox02. type Device struct { communication Communication + // This context represents the device lifetime, so Close can cancel a pending host-input dialog. + ctx context.Context //nolint:containedctx + cancel context.CancelFunc + closeOnce sync.Once // firmware version. version *semver.SemVer product *common.Product @@ -149,8 +154,11 @@ func NewDevice( for _, opt := range opts { opt(options) } + ctx, cancel := context.WithCancel(context.Background()) return &Device{ communication: communication, + ctx: ctx, + cancel: cancel, version: version, product: product, config: config, @@ -290,7 +298,7 @@ func (device *Device) Init() error { // Before 2.0.0, unlock was invoked automatically by the device before USB communication // started. - if device.version.AtLeast(semver.NewSemVer(2, 0, 0)) { + if device.version.AtLeast(semver.NewSemVer(2, 0, 0)) && !device.supportsPairedUnlock() { _, err := device.rawQuery([]byte(opUnlock)) if err != nil { // Most likely the device has been unplugged. @@ -307,19 +315,27 @@ func (device *Device) Init() error { } func (device *Device) changeStatus(status Status) { + device.mu.Lock() device.status = status + device.mu.Unlock() device.fireEvent(EventStatusChanged) } // Status returns the device state. See the Status* constants. func (device *Device) Status() Status { + device.mu.RLock() + defer device.mu.RUnlock() device.log.Debug(fmt.Sprintf("Device status: %v", device.status)) return device.status } // Close implements device.Device. func (device *Device) Close() { - device.communication.Close() + // Disconnect handling and an interrupted unlock can both close the same device. + device.closeOnce.Do(func() { + device.cancel() + device.communication.Close() + }) } // RootFingerprint returns the keystore's root fingerprint, which is the first 32 bits of the diff --git a/api/firmware/event.go b/api/firmware/event.go index 8f63555..e84bef2 100644 --- a/api/firmware/event.go +++ b/api/firmware/event.go @@ -17,6 +17,10 @@ const ( // EventAttestationCheckDone is fired when the attestation signature check is completed. In // case of failure, the user should be alerted, before they enter the password. EventAttestationCheckDone Event = "attestationCheckDone" + + // EventPassphraseEntered is fired when entry on the device finishes, before confirmation. + // Requires firmware 9.28.0 or later. + EventPassphraseEntered Event = "passphraseEntered" ) // SetOnEvent installs the callback which will be called with various events. diff --git a/api/firmware/messages/hww.pb.go b/api/firmware/messages/hww.pb.go index 8b931d2..8d2b74e 100644 --- a/api/firmware/messages/hww.pb.go +++ b/api/firmware/messages/hww.pb.go @@ -144,6 +144,9 @@ type Request struct { // *Request_Bluetooth // *Request_ChangePassword // *Request_BitboxSync + // *Request_Unlock + // *Request_UnlockContinue + // *Request_UnlockHostInfo Request isRequest_Request `protobuf_oneof:"request"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -447,6 +450,33 @@ func (x *Request) GetBitboxSync() *BitBoxSyncRequest { return nil } +func (x *Request) GetUnlock() *UnlockRequest { + if x != nil { + if x, ok := x.Request.(*Request_Unlock); ok { + return x.Unlock + } + } + return nil +} + +func (x *Request) GetUnlockContinue() *UnlockContinueRequest { + if x != nil { + if x, ok := x.Request.(*Request_UnlockContinue); ok { + return x.UnlockContinue + } + } + return nil +} + +func (x *Request) GetUnlockHostInfo() *UnlockHostInfoRequest { + if x != nil { + if x, ok := x.Request.(*Request_UnlockHostInfo); ok { + return x.UnlockHostInfo + } + } + return nil +} + type isRequest_Request interface { isRequest_Request() } @@ -569,6 +599,18 @@ type Request_BitboxSync struct { BitboxSync *BitBoxSyncRequest `protobuf:"bytes,31,opt,name=bitbox_sync,json=bitboxSync,proto3,oneof"` } +type Request_Unlock struct { + Unlock *UnlockRequest `protobuf:"bytes,32,opt,name=unlock,proto3,oneof"` +} + +type Request_UnlockContinue struct { + UnlockContinue *UnlockContinueRequest `protobuf:"bytes,33,opt,name=unlock_continue,json=unlockContinue,proto3,oneof"` +} + +type Request_UnlockHostInfo struct { + UnlockHostInfo *UnlockHostInfoRequest `protobuf:"bytes,34,opt,name=unlock_host_info,json=unlockHostInfo,proto3,oneof"` +} + func (*Request_DeviceName) isRequest_Request() {} func (*Request_DeviceLanguage) isRequest_Request() {} @@ -627,6 +669,12 @@ func (*Request_ChangePassword) isRequest_Request() {} func (*Request_BitboxSync) isRequest_Request() {} +func (*Request_Unlock) isRequest_Request() {} + +func (*Request_UnlockContinue) isRequest_Request() {} + +func (*Request_UnlockHostInfo) isRequest_Request() {} + type Response struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Response: @@ -648,6 +696,7 @@ type Response struct { // *Response_Bip85 // *Response_Bluetooth // *Response_BitboxSync + // *Response_Unlock Response isResponse_Response `protobuf_oneof:"response"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -843,6 +892,15 @@ func (x *Response) GetBitboxSync() *BitBoxSyncResponse { return nil } +func (x *Response) GetUnlock() *UnlockResponse { + if x != nil { + if x, ok := x.Response.(*Response_Unlock); ok { + return x.Unlock + } + } + return nil +} + type isResponse_Response interface { isResponse_Response() } @@ -916,6 +974,10 @@ type Response_BitboxSync struct { BitboxSync *BitBoxSyncResponse `protobuf:"bytes,18,opt,name=bitbox_sync,json=bitboxSync,proto3,oneof"` } +type Response_Unlock struct { + Unlock *UnlockResponse `protobuf:"bytes,19,opt,name=unlock,proto3,oneof"` +} + func (*Response_Success) isResponse_Response() {} func (*Response_Error) isResponse_Response() {} @@ -950,6 +1012,8 @@ func (*Response_Bluetooth) isResponse_Response() {} func (*Response_BitboxSync) isResponse_Response() {} +func (*Response_Unlock) isResponse_Response() {} + var File_hww_proto protoreflect.FileDescriptor const file_hww_proto_rawDesc = "" + @@ -958,7 +1022,7 @@ const file_hww_proto_rawDesc = "" + "\x05Error\x12\x12\n" + "\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\"\t\n" + - "\aSuccess\"\xcb\x12\n" + + "\aSuccess\"\xbb\x14\n" + "\aRequest\x12M\n" + "\vdevice_name\x18\x02 \x01(\v2*.shiftcrypto.bitbox02.SetDeviceNameRequestH\x00R\n" + "deviceName\x12Y\n" + @@ -992,8 +1056,12 @@ const file_hww_proto_rawDesc = "" + "\tbluetooth\x18\x1d \x01(\v2&.shiftcrypto.bitbox02.BluetoothRequestH\x00R\tbluetooth\x12V\n" + "\x0fchange_password\x18\x1e \x01(\v2+.shiftcrypto.bitbox02.ChangePasswordRequestH\x00R\x0echangePassword\x12J\n" + "\vbitbox_sync\x18\x1f \x01(\v2'.shiftcrypto.bitbox02.BitBoxSyncRequestH\x00R\n" + - "bitboxSyncB\t\n" + - "\arequestJ\x04\b\x01\x10\x02J\x04\b\x17\x10\x18\"\xfc\t\n" + + "bitboxSync\x12=\n" + + "\x06unlock\x18 \x01(\v2#.shiftcrypto.bitbox02.UnlockRequestH\x00R\x06unlock\x12V\n" + + "\x0funlock_continue\x18! \x01(\v2+.shiftcrypto.bitbox02.UnlockContinueRequestH\x00R\x0eunlockContinue\x12W\n" + + "\x10unlock_host_info\x18\" \x01(\v2+.shiftcrypto.bitbox02.UnlockHostInfoRequestH\x00R\x0eunlockHostInfoB\t\n" + + "\arequestJ\x04\b\x01\x10\x02J\x04\b\x17\x10\x18\"\xbc\n" + + "\n" + "\bResponse\x129\n" + "\asuccess\x18\x01 \x01(\v2\x1d.shiftcrypto.bitbox02.SuccessH\x00R\asuccess\x123\n" + "\x05error\x18\x02 \x01(\v2\x1b.shiftcrypto.bitbox02.ErrorH\x00R\x05error\x12K\n" + @@ -1014,7 +1082,8 @@ const file_hww_proto_rawDesc = "" + "\x05bip85\x18\x10 \x01(\v2#.shiftcrypto.bitbox02.BIP85ResponseH\x00R\x05bip85\x12G\n" + "\tbluetooth\x18\x11 \x01(\v2'.shiftcrypto.bitbox02.BluetoothResponseH\x00R\tbluetooth\x12K\n" + "\vbitbox_sync\x18\x12 \x01(\v2(.shiftcrypto.bitbox02.BitBoxSyncResponseH\x00R\n" + - "bitboxSyncB\n" + + "bitboxSync\x12>\n" + + "\x06unlock\x18\x13 \x01(\v2$.shiftcrypto.bitbox02.UnlockResponseH\x00R\x06unlockB\n" + "\n" + "\bresponseJ\x04\b\x03\x10\x04b\x06proto3" @@ -1065,21 +1134,25 @@ var file_hww_proto_goTypes = []any{ (*BluetoothRequest)(nil), // 30: shiftcrypto.bitbox02.BluetoothRequest (*ChangePasswordRequest)(nil), // 31: shiftcrypto.bitbox02.ChangePasswordRequest (*BitBoxSyncRequest)(nil), // 32: shiftcrypto.bitbox02.BitBoxSyncRequest - (*DeviceInfoResponse)(nil), // 33: shiftcrypto.bitbox02.DeviceInfoResponse - (*PubResponse)(nil), // 34: shiftcrypto.bitbox02.PubResponse - (*BTCSignNextResponse)(nil), // 35: shiftcrypto.bitbox02.BTCSignNextResponse - (*ListBackupsResponse)(nil), // 36: shiftcrypto.bitbox02.ListBackupsResponse - (*CheckBackupResponse)(nil), // 37: shiftcrypto.bitbox02.CheckBackupResponse - (*PerformAttestationResponse)(nil), // 38: shiftcrypto.bitbox02.PerformAttestationResponse - (*CheckSDCardResponse)(nil), // 39: shiftcrypto.bitbox02.CheckSDCardResponse - (*ETHResponse)(nil), // 40: shiftcrypto.bitbox02.ETHResponse - (*RootFingerprintResponse)(nil), // 41: shiftcrypto.bitbox02.RootFingerprintResponse - (*BTCResponse)(nil), // 42: shiftcrypto.bitbox02.BTCResponse - (*ElectrumEncryptionKeyResponse)(nil), // 43: shiftcrypto.bitbox02.ElectrumEncryptionKeyResponse - (*CardanoResponse)(nil), // 44: shiftcrypto.bitbox02.CardanoResponse - (*BIP85Response)(nil), // 45: shiftcrypto.bitbox02.BIP85Response - (*BluetoothResponse)(nil), // 46: shiftcrypto.bitbox02.BluetoothResponse - (*BitBoxSyncResponse)(nil), // 47: shiftcrypto.bitbox02.BitBoxSyncResponse + (*UnlockRequest)(nil), // 33: shiftcrypto.bitbox02.UnlockRequest + (*UnlockContinueRequest)(nil), // 34: shiftcrypto.bitbox02.UnlockContinueRequest + (*UnlockHostInfoRequest)(nil), // 35: shiftcrypto.bitbox02.UnlockHostInfoRequest + (*DeviceInfoResponse)(nil), // 36: shiftcrypto.bitbox02.DeviceInfoResponse + (*PubResponse)(nil), // 37: shiftcrypto.bitbox02.PubResponse + (*BTCSignNextResponse)(nil), // 38: shiftcrypto.bitbox02.BTCSignNextResponse + (*ListBackupsResponse)(nil), // 39: shiftcrypto.bitbox02.ListBackupsResponse + (*CheckBackupResponse)(nil), // 40: shiftcrypto.bitbox02.CheckBackupResponse + (*PerformAttestationResponse)(nil), // 41: shiftcrypto.bitbox02.PerformAttestationResponse + (*CheckSDCardResponse)(nil), // 42: shiftcrypto.bitbox02.CheckSDCardResponse + (*ETHResponse)(nil), // 43: shiftcrypto.bitbox02.ETHResponse + (*RootFingerprintResponse)(nil), // 44: shiftcrypto.bitbox02.RootFingerprintResponse + (*BTCResponse)(nil), // 45: shiftcrypto.bitbox02.BTCResponse + (*ElectrumEncryptionKeyResponse)(nil), // 46: shiftcrypto.bitbox02.ElectrumEncryptionKeyResponse + (*CardanoResponse)(nil), // 47: shiftcrypto.bitbox02.CardanoResponse + (*BIP85Response)(nil), // 48: shiftcrypto.bitbox02.BIP85Response + (*BluetoothResponse)(nil), // 49: shiftcrypto.bitbox02.BluetoothResponse + (*BitBoxSyncResponse)(nil), // 50: shiftcrypto.bitbox02.BitBoxSyncResponse + (*UnlockResponse)(nil), // 51: shiftcrypto.bitbox02.UnlockResponse } var file_hww_proto_depIdxs = []int32{ 4, // 0: shiftcrypto.bitbox02.Request.device_name:type_name -> shiftcrypto.bitbox02.SetDeviceNameRequest @@ -1111,28 +1184,32 @@ var file_hww_proto_depIdxs = []int32{ 30, // 26: shiftcrypto.bitbox02.Request.bluetooth:type_name -> shiftcrypto.bitbox02.BluetoothRequest 31, // 27: shiftcrypto.bitbox02.Request.change_password:type_name -> shiftcrypto.bitbox02.ChangePasswordRequest 32, // 28: shiftcrypto.bitbox02.Request.bitbox_sync:type_name -> shiftcrypto.bitbox02.BitBoxSyncRequest - 1, // 29: shiftcrypto.bitbox02.Response.success:type_name -> shiftcrypto.bitbox02.Success - 0, // 30: shiftcrypto.bitbox02.Response.error:type_name -> shiftcrypto.bitbox02.Error - 33, // 31: shiftcrypto.bitbox02.Response.device_info:type_name -> shiftcrypto.bitbox02.DeviceInfoResponse - 34, // 32: shiftcrypto.bitbox02.Response.pub:type_name -> shiftcrypto.bitbox02.PubResponse - 35, // 33: shiftcrypto.bitbox02.Response.btc_sign_next:type_name -> shiftcrypto.bitbox02.BTCSignNextResponse - 36, // 34: shiftcrypto.bitbox02.Response.list_backups:type_name -> shiftcrypto.bitbox02.ListBackupsResponse - 37, // 35: shiftcrypto.bitbox02.Response.check_backup:type_name -> shiftcrypto.bitbox02.CheckBackupResponse - 38, // 36: shiftcrypto.bitbox02.Response.perform_attestation:type_name -> shiftcrypto.bitbox02.PerformAttestationResponse - 39, // 37: shiftcrypto.bitbox02.Response.check_sdcard:type_name -> shiftcrypto.bitbox02.CheckSDCardResponse - 40, // 38: shiftcrypto.bitbox02.Response.eth:type_name -> shiftcrypto.bitbox02.ETHResponse - 41, // 39: shiftcrypto.bitbox02.Response.fingerprint:type_name -> shiftcrypto.bitbox02.RootFingerprintResponse - 42, // 40: shiftcrypto.bitbox02.Response.btc:type_name -> shiftcrypto.bitbox02.BTCResponse - 43, // 41: shiftcrypto.bitbox02.Response.electrum_encryption_key:type_name -> shiftcrypto.bitbox02.ElectrumEncryptionKeyResponse - 44, // 42: shiftcrypto.bitbox02.Response.cardano:type_name -> shiftcrypto.bitbox02.CardanoResponse - 45, // 43: shiftcrypto.bitbox02.Response.bip85:type_name -> shiftcrypto.bitbox02.BIP85Response - 46, // 44: shiftcrypto.bitbox02.Response.bluetooth:type_name -> shiftcrypto.bitbox02.BluetoothResponse - 47, // 45: shiftcrypto.bitbox02.Response.bitbox_sync:type_name -> shiftcrypto.bitbox02.BitBoxSyncResponse - 46, // [46:46] is the sub-list for method output_type - 46, // [46:46] is the sub-list for method input_type - 46, // [46:46] is the sub-list for extension type_name - 46, // [46:46] is the sub-list for extension extendee - 0, // [0:46] is the sub-list for field type_name + 33, // 29: shiftcrypto.bitbox02.Request.unlock:type_name -> shiftcrypto.bitbox02.UnlockRequest + 34, // 30: shiftcrypto.bitbox02.Request.unlock_continue:type_name -> shiftcrypto.bitbox02.UnlockContinueRequest + 35, // 31: shiftcrypto.bitbox02.Request.unlock_host_info:type_name -> shiftcrypto.bitbox02.UnlockHostInfoRequest + 1, // 32: shiftcrypto.bitbox02.Response.success:type_name -> shiftcrypto.bitbox02.Success + 0, // 33: shiftcrypto.bitbox02.Response.error:type_name -> shiftcrypto.bitbox02.Error + 36, // 34: shiftcrypto.bitbox02.Response.device_info:type_name -> shiftcrypto.bitbox02.DeviceInfoResponse + 37, // 35: shiftcrypto.bitbox02.Response.pub:type_name -> shiftcrypto.bitbox02.PubResponse + 38, // 36: shiftcrypto.bitbox02.Response.btc_sign_next:type_name -> shiftcrypto.bitbox02.BTCSignNextResponse + 39, // 37: shiftcrypto.bitbox02.Response.list_backups:type_name -> shiftcrypto.bitbox02.ListBackupsResponse + 40, // 38: shiftcrypto.bitbox02.Response.check_backup:type_name -> shiftcrypto.bitbox02.CheckBackupResponse + 41, // 39: shiftcrypto.bitbox02.Response.perform_attestation:type_name -> shiftcrypto.bitbox02.PerformAttestationResponse + 42, // 40: shiftcrypto.bitbox02.Response.check_sdcard:type_name -> shiftcrypto.bitbox02.CheckSDCardResponse + 43, // 41: shiftcrypto.bitbox02.Response.eth:type_name -> shiftcrypto.bitbox02.ETHResponse + 44, // 42: shiftcrypto.bitbox02.Response.fingerprint:type_name -> shiftcrypto.bitbox02.RootFingerprintResponse + 45, // 43: shiftcrypto.bitbox02.Response.btc:type_name -> shiftcrypto.bitbox02.BTCResponse + 46, // 44: shiftcrypto.bitbox02.Response.electrum_encryption_key:type_name -> shiftcrypto.bitbox02.ElectrumEncryptionKeyResponse + 47, // 45: shiftcrypto.bitbox02.Response.cardano:type_name -> shiftcrypto.bitbox02.CardanoResponse + 48, // 46: shiftcrypto.bitbox02.Response.bip85:type_name -> shiftcrypto.bitbox02.BIP85Response + 49, // 47: shiftcrypto.bitbox02.Response.bluetooth:type_name -> shiftcrypto.bitbox02.BluetoothResponse + 50, // 48: shiftcrypto.bitbox02.Response.bitbox_sync:type_name -> shiftcrypto.bitbox02.BitBoxSyncResponse + 51, // 49: shiftcrypto.bitbox02.Response.unlock:type_name -> shiftcrypto.bitbox02.UnlockResponse + 50, // [50:50] is the sub-list for method output_type + 50, // [50:50] is the sub-list for method input_type + 50, // [50:50] is the sub-list for extension type_name + 50, // [50:50] is the sub-list for extension extendee + 0, // [0:50] is the sub-list for field type_name } func init() { file_hww_proto_init() } @@ -1182,6 +1259,9 @@ func file_hww_proto_init() { (*Request_Bluetooth)(nil), (*Request_ChangePassword)(nil), (*Request_BitboxSync)(nil), + (*Request_Unlock)(nil), + (*Request_UnlockContinue)(nil), + (*Request_UnlockHostInfo)(nil), } file_hww_proto_msgTypes[3].OneofWrappers = []any{ (*Response_Success)(nil), @@ -1201,6 +1281,7 @@ func file_hww_proto_init() { (*Response_Bip85)(nil), (*Response_Bluetooth)(nil), (*Response_BitboxSync)(nil), + (*Response_Unlock)(nil), } type x struct{} out := protoimpl.TypeBuilder{ diff --git a/api/firmware/messages/hww.proto b/api/firmware/messages/hww.proto index 39c7a05..bf6d032 100644 --- a/api/firmware/messages/hww.proto +++ b/api/firmware/messages/hww.proto @@ -60,6 +60,9 @@ message Request { BluetoothRequest bluetooth = 29; ChangePasswordRequest change_password = 30; BitBoxSyncRequest bitbox_sync = 31; + UnlockRequest unlock = 32; + UnlockContinueRequest unlock_continue = 33; + UnlockHostInfoRequest unlock_host_info = 34; } } @@ -84,5 +87,6 @@ message Response { BIP85Response bip85 = 16; BluetoothResponse bluetooth = 17; BitBoxSyncResponse bitbox_sync = 18; + UnlockResponse unlock = 19; } } diff --git a/api/firmware/messages/keystore.pb.go b/api/firmware/messages/keystore.pb.go index e8120a5..49ae7c3 100644 --- a/api/firmware/messages/keystore.pb.go +++ b/api/firmware/messages/keystore.pb.go @@ -27,6 +27,238 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type UnlockResponse_State int32 + +const ( + UnlockResponse_PASSPHRASE_PENDING UnlockResponse_State = 0 + UnlockResponse_HOST_ENTRY_READY UnlockResponse_State = 1 + // Passphrase entry on the device finished; confirmation may still be pending. + // Withdraw host entry and send UnlockContinue to await completion. + UnlockResponse_PASSPHRASE_ENTERED UnlockResponse_State = 2 + UnlockResponse_DONE UnlockResponse_State = 3 +) + +// Enum value maps for UnlockResponse_State. +var ( + UnlockResponse_State_name = map[int32]string{ + 0: "PASSPHRASE_PENDING", + 1: "HOST_ENTRY_READY", + 2: "PASSPHRASE_ENTERED", + 3: "DONE", + } + UnlockResponse_State_value = map[string]int32{ + "PASSPHRASE_PENDING": 0, + "HOST_ENTRY_READY": 1, + "PASSPHRASE_ENTERED": 2, + "DONE": 3, + } +) + +func (x UnlockResponse_State) Enum() *UnlockResponse_State { + p := new(UnlockResponse_State) + *p = x + return p +} + +func (x UnlockResponse_State) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (UnlockResponse_State) Descriptor() protoreflect.EnumDescriptor { + return file_keystore_proto_enumTypes[0].Descriptor() +} + +func (UnlockResponse_State) Type() protoreflect.EnumType { + return &file_keystore_proto_enumTypes[0] +} + +func (x UnlockResponse_State) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use UnlockResponse_State.Descriptor instead. +func (UnlockResponse_State) EnumDescriptor() ([]byte, []int) { + return file_keystore_proto_rawDescGZIP(), []int{3, 0} +} + +// Unlock inside the paired Noise channel (since v9.28.0). Uninitialized and already unlocked +// devices return DONE immediately and are left unchanged. Continuations are only valid within +// this workflow. +// If the passphrase feature is enabled, device entry returns PASSPHRASE_PENDING. The host +// polls with UnlockContinueRequest until entry completes or it requests host entry. +type UnlockRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnlockRequest) Reset() { + *x = UnlockRequest{} + mi := &file_keystore_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnlockRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnlockRequest) ProtoMessage() {} + +func (x *UnlockRequest) ProtoReflect() protoreflect.Message { + mi := &file_keystore_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnlockRequest.ProtoReflect.Descriptor instead. +func (*UnlockRequest) Descriptor() ([]byte, []int) { + return file_keystore_proto_rawDescGZIP(), []int{0} +} + +type UnlockContinueRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // False polls device entry; true interrupts it to ask for host-entry consent. + // After PASSPHRASE_ENTERED, continue to await confirmation and unlock; + // host entry is ignored. + RequestHostEntry bool `protobuf:"varint,1,opt,name=request_host_entry,json=requestHostEntry,proto3" json:"request_host_entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnlockContinueRequest) Reset() { + *x = UnlockContinueRequest{} + mi := &file_keystore_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnlockContinueRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnlockContinueRequest) ProtoMessage() {} + +func (x *UnlockContinueRequest) ProtoReflect() protoreflect.Message { + mi := &file_keystore_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnlockContinueRequest.ProtoReflect.Descriptor instead. +func (*UnlockContinueRequest) Descriptor() ([]byte, []int) { + return file_keystore_proto_rawDescGZIP(), []int{1} +} + +func (x *UnlockContinueRequest) GetRequestHostEntry() bool { + if x != nil { + return x.RequestHostEntry + } + return false +} + +type UnlockHostInfoRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Only sent after HOST_ENTRY_READY. Absent cancels host input and restarts device entry; + // the empty string submits the empty passphrase. The device confirms the actual value. + Passphrase *string `protobuf:"bytes,1,opt,name=passphrase,proto3,oneof" json:"passphrase,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnlockHostInfoRequest) Reset() { + *x = UnlockHostInfoRequest{} + mi := &file_keystore_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnlockHostInfoRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnlockHostInfoRequest) ProtoMessage() {} + +func (x *UnlockHostInfoRequest) ProtoReflect() protoreflect.Message { + mi := &file_keystore_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnlockHostInfoRequest.ProtoReflect.Descriptor instead. +func (*UnlockHostInfoRequest) Descriptor() ([]byte, []int) { + return file_keystore_proto_rawDescGZIP(), []int{2} +} + +func (x *UnlockHostInfoRequest) GetPassphrase() string { + if x != nil && x.Passphrase != nil { + return *x.Passphrase + } + return "" +} + +type UnlockResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + State UnlockResponse_State `protobuf:"varint,1,opt,name=state,proto3,enum=shiftcrypto.bitbox02.UnlockResponse_State" json:"state,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnlockResponse) Reset() { + *x = UnlockResponse{} + mi := &file_keystore_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnlockResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnlockResponse) ProtoMessage() {} + +func (x *UnlockResponse) ProtoReflect() protoreflect.Message { + mi := &file_keystore_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnlockResponse.ProtoReflect.Descriptor instead. +func (*UnlockResponse) Descriptor() ([]byte, []int) { + return file_keystore_proto_rawDescGZIP(), []int{3} +} + +func (x *UnlockResponse) GetState() UnlockResponse_State { + if x != nil { + return x.State + } + return UnlockResponse_PASSPHRASE_PENDING +} + type ElectrumEncryptionKeyRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Keypath []uint32 `protobuf:"varint,1,rep,packed,name=keypath,proto3" json:"keypath,omitempty"` @@ -36,7 +268,7 @@ type ElectrumEncryptionKeyRequest struct { func (x *ElectrumEncryptionKeyRequest) Reset() { *x = ElectrumEncryptionKeyRequest{} - mi := &file_keystore_proto_msgTypes[0] + mi := &file_keystore_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -48,7 +280,7 @@ func (x *ElectrumEncryptionKeyRequest) String() string { func (*ElectrumEncryptionKeyRequest) ProtoMessage() {} func (x *ElectrumEncryptionKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_keystore_proto_msgTypes[0] + mi := &file_keystore_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -61,7 +293,7 @@ func (x *ElectrumEncryptionKeyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ElectrumEncryptionKeyRequest.ProtoReflect.Descriptor instead. func (*ElectrumEncryptionKeyRequest) Descriptor() ([]byte, []int) { - return file_keystore_proto_rawDescGZIP(), []int{0} + return file_keystore_proto_rawDescGZIP(), []int{4} } func (x *ElectrumEncryptionKeyRequest) GetKeypath() []uint32 { @@ -80,7 +312,7 @@ type ElectrumEncryptionKeyResponse struct { func (x *ElectrumEncryptionKeyResponse) Reset() { *x = ElectrumEncryptionKeyResponse{} - mi := &file_keystore_proto_msgTypes[1] + mi := &file_keystore_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -92,7 +324,7 @@ func (x *ElectrumEncryptionKeyResponse) String() string { func (*ElectrumEncryptionKeyResponse) ProtoMessage() {} func (x *ElectrumEncryptionKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_keystore_proto_msgTypes[1] + mi := &file_keystore_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -105,7 +337,7 @@ func (x *ElectrumEncryptionKeyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ElectrumEncryptionKeyResponse.ProtoReflect.Descriptor instead. func (*ElectrumEncryptionKeyResponse) Descriptor() ([]byte, []int) { - return file_keystore_proto_rawDescGZIP(), []int{1} + return file_keystore_proto_rawDescGZIP(), []int{5} } func (x *ElectrumEncryptionKeyResponse) GetKey() string { @@ -128,7 +360,7 @@ type BIP85Request struct { func (x *BIP85Request) Reset() { *x = BIP85Request{} - mi := &file_keystore_proto_msgTypes[2] + mi := &file_keystore_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -140,7 +372,7 @@ func (x *BIP85Request) String() string { func (*BIP85Request) ProtoMessage() {} func (x *BIP85Request) ProtoReflect() protoreflect.Message { - mi := &file_keystore_proto_msgTypes[2] + mi := &file_keystore_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -153,7 +385,7 @@ func (x *BIP85Request) ProtoReflect() protoreflect.Message { // Deprecated: Use BIP85Request.ProtoReflect.Descriptor instead. func (*BIP85Request) Descriptor() ([]byte, []int) { - return file_keystore_proto_rawDescGZIP(), []int{2} + return file_keystore_proto_rawDescGZIP(), []int{6} } func (x *BIP85Request) GetApp() isBIP85Request_App { @@ -210,7 +442,7 @@ type BIP85Response struct { func (x *BIP85Response) Reset() { *x = BIP85Response{} - mi := &file_keystore_proto_msgTypes[3] + mi := &file_keystore_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -222,7 +454,7 @@ func (x *BIP85Response) String() string { func (*BIP85Response) ProtoMessage() {} func (x *BIP85Response) ProtoReflect() protoreflect.Message { - mi := &file_keystore_proto_msgTypes[3] + mi := &file_keystore_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -235,7 +467,7 @@ func (x *BIP85Response) ProtoReflect() protoreflect.Message { // Deprecated: Use BIP85Response.ProtoReflect.Descriptor instead. func (*BIP85Response) Descriptor() ([]byte, []int) { - return file_keystore_proto_rawDescGZIP(), []int{3} + return file_keystore_proto_rawDescGZIP(), []int{7} } func (x *BIP85Response) GetApp() isBIP85Response_App { @@ -288,7 +520,7 @@ type BIP85Request_AppLn struct { func (x *BIP85Request_AppLn) Reset() { *x = BIP85Request_AppLn{} - mi := &file_keystore_proto_msgTypes[4] + mi := &file_keystore_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -300,7 +532,7 @@ func (x *BIP85Request_AppLn) String() string { func (*BIP85Request_AppLn) ProtoMessage() {} func (x *BIP85Request_AppLn) ProtoReflect() protoreflect.Message { - mi := &file_keystore_proto_msgTypes[4] + mi := &file_keystore_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -313,7 +545,7 @@ func (x *BIP85Request_AppLn) ProtoReflect() protoreflect.Message { // Deprecated: Use BIP85Request_AppLn.ProtoReflect.Descriptor instead. func (*BIP85Request_AppLn) Descriptor() ([]byte, []int) { - return file_keystore_proto_rawDescGZIP(), []int{2, 0} + return file_keystore_proto_rawDescGZIP(), []int{6, 0} } func (x *BIP85Request_AppLn) GetAccountNumber() uint32 { @@ -327,7 +559,22 @@ var File_keystore_proto protoreflect.FileDescriptor const file_keystore_proto_rawDesc = "" + "\n" + - "\x0ekeystore.proto\x12\x14shiftcrypto.bitbox02\x1a\x1bgoogle/protobuf/empty.proto\"8\n" + + "\x0ekeystore.proto\x12\x14shiftcrypto.bitbox02\x1a\x1bgoogle/protobuf/empty.proto\"\x0f\n" + + "\rUnlockRequest\"E\n" + + "\x15UnlockContinueRequest\x12,\n" + + "\x12request_host_entry\x18\x01 \x01(\bR\x10requestHostEntry\"K\n" + + "\x15UnlockHostInfoRequest\x12#\n" + + "\n" + + "passphrase\x18\x01 \x01(\tH\x00R\n" + + "passphrase\x88\x01\x01B\r\n" + + "\v_passphrase\"\xab\x01\n" + + "\x0eUnlockResponse\x12@\n" + + "\x05state\x18\x01 \x01(\x0e2*.shiftcrypto.bitbox02.UnlockResponse.StateR\x05state\"W\n" + + "\x05State\x12\x16\n" + + "\x12PASSPHRASE_PENDING\x10\x00\x12\x14\n" + + "\x10HOST_ENTRY_READY\x10\x01\x12\x16\n" + + "\x12PASSPHRASE_ENTERED\x10\x02\x12\b\n" + + "\x04DONE\x10\x03\"8\n" + "\x1cElectrumEncryptionKeyRequest\x12\x18\n" + "\akeypath\x18\x01 \x03(\rR\akeypath\"1\n" + "\x1dElectrumEncryptionKeyResponse\x12\x10\n" + @@ -355,24 +602,31 @@ func file_keystore_proto_rawDescGZIP() []byte { return file_keystore_proto_rawDescData } -var file_keystore_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_keystore_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_keystore_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_keystore_proto_goTypes = []any{ - (*ElectrumEncryptionKeyRequest)(nil), // 0: shiftcrypto.bitbox02.ElectrumEncryptionKeyRequest - (*ElectrumEncryptionKeyResponse)(nil), // 1: shiftcrypto.bitbox02.ElectrumEncryptionKeyResponse - (*BIP85Request)(nil), // 2: shiftcrypto.bitbox02.BIP85Request - (*BIP85Response)(nil), // 3: shiftcrypto.bitbox02.BIP85Response - (*BIP85Request_AppLn)(nil), // 4: shiftcrypto.bitbox02.BIP85Request.AppLn - (*emptypb.Empty)(nil), // 5: google.protobuf.Empty + (UnlockResponse_State)(0), // 0: shiftcrypto.bitbox02.UnlockResponse.State + (*UnlockRequest)(nil), // 1: shiftcrypto.bitbox02.UnlockRequest + (*UnlockContinueRequest)(nil), // 2: shiftcrypto.bitbox02.UnlockContinueRequest + (*UnlockHostInfoRequest)(nil), // 3: shiftcrypto.bitbox02.UnlockHostInfoRequest + (*UnlockResponse)(nil), // 4: shiftcrypto.bitbox02.UnlockResponse + (*ElectrumEncryptionKeyRequest)(nil), // 5: shiftcrypto.bitbox02.ElectrumEncryptionKeyRequest + (*ElectrumEncryptionKeyResponse)(nil), // 6: shiftcrypto.bitbox02.ElectrumEncryptionKeyResponse + (*BIP85Request)(nil), // 7: shiftcrypto.bitbox02.BIP85Request + (*BIP85Response)(nil), // 8: shiftcrypto.bitbox02.BIP85Response + (*BIP85Request_AppLn)(nil), // 9: shiftcrypto.bitbox02.BIP85Request.AppLn + (*emptypb.Empty)(nil), // 10: google.protobuf.Empty } var file_keystore_proto_depIdxs = []int32{ - 5, // 0: shiftcrypto.bitbox02.BIP85Request.bip39:type_name -> google.protobuf.Empty - 4, // 1: shiftcrypto.bitbox02.BIP85Request.ln:type_name -> shiftcrypto.bitbox02.BIP85Request.AppLn - 5, // 2: shiftcrypto.bitbox02.BIP85Response.bip39:type_name -> google.protobuf.Empty - 3, // [3:3] is the sub-list for method output_type - 3, // [3:3] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 0, // 0: shiftcrypto.bitbox02.UnlockResponse.state:type_name -> shiftcrypto.bitbox02.UnlockResponse.State + 10, // 1: shiftcrypto.bitbox02.BIP85Request.bip39:type_name -> google.protobuf.Empty + 9, // 2: shiftcrypto.bitbox02.BIP85Request.ln:type_name -> shiftcrypto.bitbox02.BIP85Request.AppLn + 10, // 3: shiftcrypto.bitbox02.BIP85Response.bip39:type_name -> google.protobuf.Empty + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name } func init() { file_keystore_proto_init() } @@ -380,11 +634,12 @@ func file_keystore_proto_init() { if File_keystore_proto != nil { return } - file_keystore_proto_msgTypes[2].OneofWrappers = []any{ + file_keystore_proto_msgTypes[2].OneofWrappers = []any{} + file_keystore_proto_msgTypes[6].OneofWrappers = []any{ (*BIP85Request_Bip39)(nil), (*BIP85Request_Ln)(nil), } - file_keystore_proto_msgTypes[3].OneofWrappers = []any{ + file_keystore_proto_msgTypes[7].OneofWrappers = []any{ (*BIP85Response_Bip39)(nil), (*BIP85Response_Ln)(nil), } @@ -393,13 +648,14 @@ func file_keystore_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_keystore_proto_rawDesc), len(file_keystore_proto_rawDesc)), - NumEnums: 0, - NumMessages: 5, + NumEnums: 1, + NumMessages: 9, NumExtensions: 0, NumServices: 0, }, GoTypes: file_keystore_proto_goTypes, DependencyIndexes: file_keystore_proto_depIdxs, + EnumInfos: file_keystore_proto_enumTypes, MessageInfos: file_keystore_proto_msgTypes, }.Build() File_keystore_proto = out.File diff --git a/api/firmware/messages/keystore.proto b/api/firmware/messages/keystore.proto index 4f63cd3..b793fdf 100644 --- a/api/firmware/messages/keystore.proto +++ b/api/firmware/messages/keystore.proto @@ -8,6 +8,38 @@ package shiftcrypto.bitbox02; import "google/protobuf/empty.proto"; +// Unlock inside the paired Noise channel (since v9.28.0). Uninitialized and already unlocked +// devices return DONE immediately and are left unchanged. Continuations are only valid within +// this workflow. +// If the passphrase feature is enabled, device entry returns PASSPHRASE_PENDING. The host +// polls with UnlockContinueRequest until entry completes or it requests host entry. +message UnlockRequest {} + +message UnlockContinueRequest { + // False polls device entry; true interrupts it to ask for host-entry consent. + // After PASSPHRASE_ENTERED, continue to await confirmation and unlock; + // host entry is ignored. + bool request_host_entry = 1; +} + +message UnlockHostInfoRequest { + // Only sent after HOST_ENTRY_READY. Absent cancels host input and restarts device entry; + // the empty string submits the empty passphrase. The device confirms the actual value. + optional string passphrase = 1; +} + +message UnlockResponse { + enum State { + PASSPHRASE_PENDING = 0; + HOST_ENTRY_READY = 1; + // Passphrase entry on the device finished; confirmation may still be pending. + // Withdraw host entry and send UnlockContinue to await completion. + PASSPHRASE_ENTERED = 2; + DONE = 3; + } + State state = 1; +} + message ElectrumEncryptionKeyRequest { repeated uint32 keypath = 1; } diff --git a/api/firmware/options.go b/api/firmware/options.go index 34548c6..1ef0523 100644 --- a/api/firmware/options.go +++ b/api/firmware/options.go @@ -2,10 +2,36 @@ package firmware +import "context" + type deviceOptions struct { // If true, the host does not require noise pairing confirmation before communicating over the // encrypted noise channel. optionalNoisePairingConfirmation bool + passphrase PassphraseConfig +} + +// PassphraseConfig lets an application offer passphrase entry on the host during unlock. +// The callbacks run on the goroutine performing unlock and must not call device APIs. +type PassphraseConfig struct { + // OnHostPassphraseAvailable shows or hides the app's host-entry control. A non-nil callable + // requests device approval when invoked; nil withdraws the control. The callable is safe to + // call from another goroutine, performs no I/O, and cannot affect subsequent entry attempts. + // This notification must return promptly. If omitted, host entry is requested automatically + // once per unlock when EnterMnemonicPassphrase is set. + OnHostPassphraseAvailable func(requestHostEntry func()) + // EnterMnemonicPassphrase is called only after device approval. Return a passphrase (including + // an empty string) to submit, or nil to cancel and resume device entry. Stop waiting when ctx + // is canceled by Close. GUI applications must marshal their UI work to the UI thread. + EnterMnemonicPassphrase func(ctx context.Context) (*string, error) +} + +// WithPassphraseConfig enables host passphrase input on firmware 9.28.0 and later. +// Device entry is used when EnterMnemonicPassphrase is nil, and on older firmware. +func WithPassphraseConfig(config PassphraseConfig) DeviceOption { + return func(o *deviceOptions) { + o.passphrase = config + } } // DeviceOption provides functional options. diff --git a/api/firmware/pairing.go b/api/firmware/pairing.go index 64ec1b5..6d65e3f 100644 --- a/api/firmware/pairing.go +++ b/api/firmware/pairing.go @@ -135,10 +135,13 @@ func (device *Device) ChannelHash() (string, bool) { // ChannelHashVerify verifies the ChannelHash. func (device *Device) ChannelHashVerify(ok bool) { device.log.Info(fmt.Sprintf("channelHashVerify: %v", ok)) - if ok && !device.channelHashDeviceVerified { + device.mu.Lock() + if device.channelHashAppVerified || (ok && !device.channelHashDeviceVerified) { + device.mu.Unlock() return } device.channelHashAppVerified = ok + device.mu.Unlock() if ok { // No critical error, we will just need to re-confirm the pairing next time. _ = device.config.AddDeviceStaticPubkey(device.deviceNoiseStaticPubkey) @@ -156,6 +159,15 @@ func (device *Device) ChannelHashVerify(ok bool) { return } + if device.supportsPairedUnlock() { + device.changeStatus(StatusConnected) + if err := device.unlock(); err != nil { + device.log.Error("could not unlock device", err) + device.Close() + return + } + } + info, err := device.DeviceInfo() if err != nil { device.log.Error("could not get device info", err) diff --git a/api/firmware/query.go b/api/firmware/query.go index 1ee8e19..cba2f4d 100644 --- a/api/firmware/query.go +++ b/api/firmware/query.go @@ -157,6 +157,8 @@ func (device *Device) nonAtomicQuery(request proto.Message) (*messages.Response, return nil, errp.WithStack(err) } + defer clear(requestBytes) + requestBytesEncrypted, err := device.sendCipher.Encrypt(nil, nil, requestBytes) if err != nil { return nil, errp.WithStack(err) diff --git a/api/firmware/status.go b/api/firmware/status.go index a164483..0da00d3 100644 --- a/api/firmware/status.go +++ b/api/firmware/status.go @@ -6,9 +6,9 @@ package firmware type Status string const ( - // StatusConnected ist the first status, right after the device is connected. We automatically - // move to StatusUnpaired (directly if the device is uninitialized, or after unlocking the - // device if it is initialized). + // StatusConnected is the first status, right after the device is connected. We automatically + // move to StatusUnpaired (directly on firmware >=9.28.0, or after unlocking on older firmware). + // After pairing, firmware >=9.28.0 returns to StatusConnected while the device is being unlocked. StatusConnected Status = "connected" // StatusUnpaired means the pairing has not been confirmed yet. After the pairing screen has diff --git a/api/firmware/unlock.go b/api/firmware/unlock.go new file mode 100644 index 0000000..1ee66e5 --- /dev/null +++ b/api/firmware/unlock.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 + +package firmware + +import ( + "time" + + "github.com/BitBoxSwiss/bitbox02-api-go/api/firmware/messages" + "github.com/BitBoxSwiss/bitbox02-api-go/util/errp" + "github.com/BitBoxSwiss/bitbox02-api-go/util/semver" +) + +func (device *Device) supportsPairedUnlock() bool { + return device.version.AtLeast(semver.NewSemVer(9, 28, 0)) +} + +// unlock owns all protocol I/O until the device has finished unlocking. The UI only signals +// the current phase's channel, so a delayed click cannot affect a later phase or unlock. +func (device *Device) unlock() error { + return device.atomicQueries(func() (err error) { + config := device.options.passphrase + var hostEntry chan struct{} + withdraw := func() { + if hostEntry != nil { + hostEntry = nil + config.OnHostPassphraseAvailable(nil) + } + } + defer func() { + if err != nil && device.ctx.Err() == nil { + // A known local failure must release the device's workflow. Try RESET once; + // ChannelHashVerify closes the connection and reconnect will reset it again. + _, _ = device.communication.Query([]byte(hwwReqReset)) + } + }() + defer withdraw() + + autoRequest := config.EnterMnemonicPassphrase != nil && config.OnHostPassphraseAvailable == nil + consentRequested := false + request := &messages.Request{Request: &messages.Request_Unlock{Unlock: &messages.UnlockRequest{}}} + for { + if err := device.ctx.Err(); err != nil { + return err + } + response, err := device.nonAtomicQuery(request) + if err != nil { + return err + } + reply, ok := response.Response.(*messages.Response_Unlock) + if !ok || reply.Unlock == nil { + return errp.New("expected Unlock response") + } + switch reply.Unlock.State { + case messages.UnlockResponse_DONE: + return nil + case messages.UnlockResponse_PASSPHRASE_PENDING: + consentRequested = false + if autoRequest { + // Rejection or cancellation falls back to device entry without prompting again. + autoRequest = false + consentRequested = true + } else { + if config.OnHostPassphraseAvailable != nil && config.EnterMnemonicPassphrase != nil && hostEntry == nil { + hostEntry = make(chan struct{}, 1) + current := hostEntry + config.OnHostPassphraseAvailable(func() { + select { + case current <- struct{}{}: + default: + } + }) + } + select { + case <-device.ctx.Done(): + return device.ctx.Err() + case <-hostEntry: + consentRequested = true + withdraw() + case <-time.After(100 * time.Millisecond): + } + } + request = &messages.Request{Request: &messages.Request_UnlockContinue{ + UnlockContinue: &messages.UnlockContinueRequest{RequestHostEntry: consentRequested}, + }} + case messages.UnlockResponse_PASSPHRASE_ENTERED: + consentRequested = false + withdraw() + device.fireEvent(EventPassphraseEntered) + request = &messages.Request{Request: &messages.Request_UnlockContinue{ + UnlockContinue: &messages.UnlockContinueRequest{}, + }} + case messages.UnlockResponse_HOST_ENTRY_READY: + if !consentRequested || config.EnterMnemonicPassphrase == nil { + return errp.New("unexpected host passphrase request") + } + consentRequested = false + passphrase, err := config.EnterMnemonicPassphrase(device.ctx) + if err != nil { + return err + } + request = &messages.Request{Request: &messages.Request_UnlockHostInfo{ + UnlockHostInfo: &messages.UnlockHostInfoRequest{Passphrase: passphrase}, + }} + default: + return errp.New("unexpected unlock phase") + } + } + }) +} diff --git a/api/firmware/unlock_test.go b/api/firmware/unlock_test.go new file mode 100644 index 0000000..6635edb --- /dev/null +++ b/api/firmware/unlock_test.go @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 + +package firmware + +import ( + "context" + "encoding/hex" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// Use a locally built simulator with persistent flash to exercise a real power cycle. +// The headless simulator enters an empty password/passphrase and accepts confirmations. +func TestSimulatorUnlock(t *testing.T) { + filename := os.Getenv("SIMULATOR") + if filename == "" { + t.Skip("set SIMULATOR to a locally built firmware simulator") + } + for _, test := range []struct { + name string + enabled bool + requestHostEntry bool + }{ + {name: "without-passphrase"}, + {name: "device-passphrase", enabled: true}, + {name: "device-entry-wins-host-click", enabled: true, requestHostEntry: true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv("FAKE_MEMORY_FILEPATH", filepath.Join(t.TempDir(), "memory")) + stop, device, _, err := runSimulator(filename) + require.NoError(t, err) + t.Cleanup(func() { + if stop != nil { + require.NoError(t, stop()) + } + }) + var availability []bool + var requestHostEntry func() + config := PassphraseConfig{ + OnHostPassphraseAvailable: func(request func()) { + availability = append(availability, request != nil) + if request != nil { + requestHostEntry = request + if test.requestHostEntry { + request() + } + } + }, + EnterMnemonicPassphrase: func(context.Context) (*string, error) { + t.Error("host input must not be requested without host-entry consent") + return nil, nil + }, + } + device.options.passphrase = config + require.NoError(t, device.Init()) + device.ChannelHashVerify(true) + require.Equal(t, StatusUninitialized, device.Status()) + require.NoError(t, device.RestoreFromMnemonic()) + require.NoError(t, device.SetMnemonicPassphraseEnabled(test.enabled)) + require.NoError(t, stop()) + stop = nil + + // Restarting preserves flash but clears the unlocked seed and Noise session. + var stdout *simulatorStdout + stop, device, stdout, err = runSimulator(filename) + require.NoError(t, err) + device.options.passphrase = config + entered := 0 + device.SetOnEvent(func(event Event, _ interface{}) { + if event == EventPassphraseEntered { + entered++ + } + }) + require.NoError(t, device.Init()) + device.ChannelHashVerify(true) + require.Equal(t, StatusInitialized, device.Status()) + fp, err := device.RootFingerprint() + require.NoError(t, err) + require.Equal(t, "4c00739d", hex.EncodeToString(fp)) + if test.enabled { + require.Contains(t, stdout.String(), "Optional passphrase") + } else { + require.NotContains(t, stdout.String(), "Optional passphrase") + } + if device.supportsPairedUnlock() { + if test.enabled { + require.Equal(t, []bool{true, false}, availability) + require.Equal(t, 1, entered) + // A delayed click from the completed phase must have no effect. + requestHostEntry() + } else { + require.Empty(t, availability) + require.Zero(t, entered) + } + checkpoint := stdout.checkpoint() + // Duplicate pairing approvals and unlocks must not open another passphrase flow. + device.ChannelHashVerify(true) + enteredBefore := entered + require.NoError(t, device.unlock()) + require.Equal(t, enteredBefore, entered) + output, err := stdout.snapshot(checkpoint) + require.NoError(t, err) + require.NotContains(t, output, "Optional passphrase") + fpAfter, err := device.RootFingerprint() + require.NoError(t, err) + require.Equal(t, fp, fpAfter) + } else { + require.Empty(t, availability) + require.Zero(t, entered) + } + // A disconnect and an interrupted workflow can both close the real transport. + device.Close() + device.Close() + }) + } +}