diff --git a/.golangci.yml b/.golangci.yml index 332fdec..dc8536b 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -81,6 +81,7 @@ linters: rules: - linters: - errcheck + - goconst - gocyclo - godot - gosec 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..212c56b --- /dev/null +++ b/api/firmware/unlock.go @@ -0,0 +1,114 @@ +// 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" + "github.com/BitBoxSwiss/bitbox02-api-go/util/sleep" +) + +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) { + // Passphrase entry spans multiple queries, including time spent waiting for host input. + sleep.Prevent() + defer sleep.Allow() + + 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() + }) + } +} diff --git a/util/sleep/sleep.go b/util/sleep/sleep.go index 07bdf63..47c62d9 100644 --- a/util/sleep/sleep.go +++ b/util/sleep/sleep.go @@ -1,13 +1,47 @@ // SPDX-License-Identifier: Apache-2.0 -//go:build !darwin || ios || nosleep - package sleep -// Prevent is a no-op on non macOS platforms or when nosleep is configured. +import "sync" + +type inhibitor struct { + mu sync.Mutex + references uint + prevent func() + allow func() +} + +func (i *inhibitor) acquire() { + i.mu.Lock() + defer i.mu.Unlock() + if i.references == 0 { + i.prevent() + } + i.references++ +} + +func (i *inhibitor) release() { + i.mu.Lock() + defer i.mu.Unlock() + if i.references == 0 { + return + } + i.references-- + if i.references == 0 { + i.allow() + } +} + +var shared = inhibitor{prevent: preventSleep, allow: allowSleep} + +// Prevent prevents macOS from going to sleep. Must be paired with Allow(). +// Calls may be nested or overlap across goroutines; sleep is allowed only after the last Allow(). +// It has no effect on non macOS platforms or when nosleep is configured. func Prevent() { + shared.acquire() } -// Allow is a no-op on non macOS platforms or when nosleep is configured. +// Allow releases one request to prevent sleep. Calls without a matching Prevent have no effect. func Allow() { + shared.release() } diff --git a/util/sleep/sleep_macos.go b/util/sleep/sleep_macos.go index 2b7ae9a..5715319 100644 --- a/util/sleep/sleep_macos.go +++ b/util/sleep/sleep_macos.go @@ -32,12 +32,10 @@ void allowSleep() { */ import "C" -// Prevent prevents macOS from going to sleep. Must be paired with `Allow()`. -func Prevent() { +func preventSleep() { C.preventSleep() } -// Allow allows macOS to go to sleep. -func Allow() { +func allowSleep() { C.allowSleep() } diff --git a/util/sleep/sleep_other.go b/util/sleep/sleep_other.go new file mode 100644 index 0000000..f855b3e --- /dev/null +++ b/util/sleep/sleep_other.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build !darwin || ios || nosleep + +package sleep + +// preventSleep is a no-op on non macOS platforms or when nosleep is configured. +func preventSleep() { +} + +// allowSleep is a no-op on non macOS platforms or when nosleep is configured. +func allowSleep() { +} diff --git a/util/sleep/sleep_test.go b/util/sleep/sleep_test.go new file mode 100644 index 0000000..0d5a28b --- /dev/null +++ b/util/sleep/sleep_test.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sleep + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInhibitorNested(t *testing.T) { + var transitions []string + i := inhibitor{ + prevent: func() { transitions = append(transitions, "prevent") }, + allow: func() { transitions = append(transitions, "allow") }, + } + + i.acquire() // Unlock workflow. + i.acquire() // Long-running transport query. + i.release() // The query finishes while passphrase entry continues. + require.Equal(t, []string{"prevent"}, transitions) + i.acquire() // Another query must not replace the workflow's assertion. + i.release() + require.Equal(t, []string{"prevent"}, transitions) + i.release() + require.Equal(t, []string{"prevent", "allow"}, transitions) + + // An unmatched release must not underflow and prevent later acquisitions. + i.release() + require.Equal(t, []string{"prevent", "allow"}, transitions) + i.acquire() + i.release() + require.Equal(t, []string{"prevent", "allow", "prevent", "allow"}, transitions) +} + +func TestInhibitorConcurrent(t *testing.T) { + var transitions []string + i := inhibitor{ + prevent: func() { transitions = append(transitions, "prevent") }, + allow: func() { transitions = append(transitions, "allow") }, + } + var acquired, finished sync.WaitGroup + release := make(chan struct{}) + for range 32 { + acquired.Add(1) + finished.Go(func() { + i.acquire() + acquired.Done() + <-release + i.release() + }) + } + acquired.Wait() + require.Equal(t, []string{"prevent"}, transitions) + + // The first caller can finish before other overlapping callers. + i.acquire() + close(release) + finished.Wait() + require.Equal(t, []string{"prevent"}, transitions) + i.release() + require.Equal(t, []string{"prevent", "allow"}, transitions) +}