From b913cc8a00ebd1836f4da7291d2cfce02903f8fd Mon Sep 17 00:00:00 2001 From: redsh4de <25299353+redsh4de@users.noreply.github.com> Date: Mon, 8 Jun 2026 02:30:26 +0300 Subject: [PATCH 1/3] feat: type THP channel error frames --- .../lib/src/exceptions/trezor_exception.dart | 43 ++++++++++++++++++- .../thp_handshake_completion_operation.dart | 4 +- .../trezor/thp_handshake_init_operation.dart | 5 ++- .../lib/src/operations/trezor_operations.dart | 33 ++------------ .../crc_validator_transformer.dart | 17 ++------ trezor-flutter/lib/trezor_flutter.dart | 1 + 6 files changed, 56 insertions(+), 47 deletions(-) diff --git a/trezor-flutter/lib/src/exceptions/trezor_exception.dart b/trezor-flutter/lib/src/exceptions/trezor_exception.dart index 72a61a1..2af4a7e 100644 --- a/trezor-flutter/lib/src/exceptions/trezor_exception.dart +++ b/trezor-flutter/lib/src/exceptions/trezor_exception.dart @@ -1,7 +1,7 @@ import 'dart:typed_data'; import 'package:trezor_flutter/src/trezor/protobuf/messages-common.pb.dart'; -import 'package:trezor_flutter/trezor_flutter.dart'; +import 'package:trezor_flutter/src/models/connection_type.dart'; sealed class TrezorException implements Exception {} @@ -143,3 +143,44 @@ enum UnexpectedDataPacketReason { dataLengthAlreadySet, receivedDataWithNoPendingRequest, } + +enum TrezorChannelError { + busy, + unallocatedChannel, + decryptionFailed, + deviceLocked, + unknown; + + static TrezorChannelError fromCode(int code) { + switch (code) { + case 0x01: + return TrezorChannelError.busy; + case 0x02: + return TrezorChannelError.unallocatedChannel; + case 0x03: + return TrezorChannelError.decryptionFailed; + case 0x05: + return TrezorChannelError.deviceLocked; + default: + return TrezorChannelError.unknown; + } + } +} + +class TrezorChannelException implements Exception { + const TrezorChannelException(this.error); + + final TrezorChannelError error; + + @override + String toString() { + final message = switch (error) { + TrezorChannelError.busy => "ThpTransportBusy", + TrezorChannelError.unallocatedChannel => "ThpUnallocatedChannel", + TrezorChannelError.decryptionFailed => "ThpDecryptionFailed", + TrezorChannelError.deviceLocked => "ThpDeviceLocked", + TrezorChannelError.unknown => "ThpUnknownError", + }; + return "$runtimeType($message)"; + } +} diff --git a/trezor-flutter/lib/src/operations/trezor/thp_handshake_completion_operation.dart b/trezor-flutter/lib/src/operations/trezor/thp_handshake_completion_operation.dart index 9239a88..e721ba6 100644 --- a/trezor-flutter/lib/src/operations/trezor/thp_handshake_completion_operation.dart +++ b/trezor-flutter/lib/src/operations/trezor/thp_handshake_completion_operation.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; +import 'package:trezor_flutter/src/exceptions/trezor_exception.dart'; import 'package:trezor_flutter/src/operations/trezor_operations.dart'; import 'package:trezor_flutter/src/trezor/protocol/constants/constants_v2.dart'; import 'package:trezor_flutter/src/trezor/crypto/crypto.dart'; @@ -40,8 +41,7 @@ class TrezorThpHandshakeCompletionOperation extends TrezorTHPOperation { final headers = readHeaders(reader); if (headers.controlByte == THPControlByte.error) { - final error = readError(reader); - throw Exception(error); + throw TrezorChannelException(readError(reader)); } final cipherText = reader.read(headers.length - 4); diff --git a/trezor-flutter/lib/src/operations/trezor/thp_handshake_init_operation.dart b/trezor-flutter/lib/src/operations/trezor/thp_handshake_init_operation.dart index 5df77b1..ca094d5 100644 --- a/trezor-flutter/lib/src/operations/trezor/thp_handshake_init_operation.dart +++ b/trezor-flutter/lib/src/operations/trezor/thp_handshake_init_operation.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; +import 'package:trezor_flutter/src/exceptions/trezor_exception.dart'; import 'package:trezor_flutter/src/operations/trezor_operations.dart'; import 'package:trezor_flutter/src/trezor/protocol/constants/constants_v2.dart'; import 'package:trezor_flutter/src/trezor/protocol/v2/state.dart'; @@ -50,7 +51,9 @@ class TrezorThpHandshakeInitOperation extends TrezorTHPOperation read(ByteDataReader reader) async { final headers = readHeaders(reader); - if (headers.controlByte == THPControlByte.error) throw Exception(readError(reader)); + if (headers.controlByte == THPControlByte.error) { + throw TrezorChannelException(readError(reader)); + } final trezorEphemeralPubkey = reader.read(32); final trezorEncryptedStaticPubkey = reader.read(48); diff --git a/trezor-flutter/lib/src/operations/trezor_operations.dart b/trezor-flutter/lib/src/operations/trezor_operations.dart index 7e4e716..5c5e1b9 100644 --- a/trezor-flutter/lib/src/operations/trezor_operations.dart +++ b/trezor-flutter/lib/src/operations/trezor_operations.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; +import 'package:trezor_flutter/src/exceptions/trezor_exception.dart'; import 'package:trezor_flutter/src/trezor/protocol/constants/constants_v2.dart'; import 'package:trezor_flutter/src/trezor/protocol/v2/state.dart'; import 'package:trezor_flutter/src/trezor/crypto/crypto.dart'; @@ -20,20 +21,8 @@ abstract class TrezorOperation { return ThpHeaders(controlByteRaw: controlByteRaw, channel: channel, length: length); } - String readError(ByteDataReader reader) { - switch (reader.readUint8()) { - case 0x01: - return "ThpTransportBusy"; - case 0x02: - return "ThpUnallocatedChannel"; - case 0x03: - return "ThpDecryptionFailed"; - case 0x05: - return "ThpDeviceLocked"; - default: - return "ThpUnknownError"; - } - } + TrezorChannelError readError(ByteDataReader reader) => + TrezorChannelError.fromCode(reader.readUint8()); } abstract class TrezorTHPOperation extends TrezorOperation { @@ -62,22 +51,6 @@ abstract class TrezorTHPOperation extends TrezorOperation { return ThpHeaders(controlByteRaw: controlByteRaw, channel: channel, length: length); } - - @override - String readError(ByteDataReader reader) { - switch (reader.readUint8()) { - case 0x01: - return "ThpTransportBusy"; - case 0x02: - return "ThpUnallocatedChannel"; - case 0x03: - return "ThpDecryptionFailed"; - case 0x05: - return "ThpDeviceLocked"; - default: - return "ThpUnknownError"; - } - } } class ThpHeaders { diff --git a/trezor-flutter/lib/src/trezor/transformer/crc_validator_transformer.dart b/trezor-flutter/lib/src/trezor/transformer/crc_validator_transformer.dart index 8d66715..2c8ce21 100644 --- a/trezor-flutter/lib/src/trezor/transformer/crc_validator_transformer.dart +++ b/trezor-flutter/lib/src/trezor/transformer/crc_validator_transformer.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; -import 'package:trezor_flutter/src/api/api.dart'; +import 'package:trezor_flutter/src/api/trezor_transformer.dart'; +import 'package:trezor_flutter/src/exceptions/trezor_exception.dart'; import 'package:trezor_flutter/src/trezor/protocol/constants/constants_v2.dart'; import 'package:trezor_flutter/src/trezor/crypto/crypto.dart'; import 'package:trezor_flutter/src/utils/buffer.dart'; @@ -29,18 +30,8 @@ class CrcValidator extends TrezorTransformer { if (calculatedCrc != expectedCrc) throw Exception("Crc Missmatch"); if (controlByteRaw == THPControlByte.error.byte) { - switch (payload.first) { - case 0x01: - return throw Exception("ThpTransportBusy"); - case 0x02: - return throw Exception("ThpUnallocatedChannel"); - case 0x03: - return throw Exception("ThpDecryptionFailed"); - case 0x05: - return throw Exception("ThpDeviceLocked"); - default: - return throw Exception("ThpUnknownError $payload"); - } + throw TrezorChannelException( + payload.isEmpty ? TrezorChannelError.unknown : TrezorChannelError.fromCode(payload.first)); } return bytes; diff --git a/trezor-flutter/lib/trezor_flutter.dart b/trezor-flutter/lib/trezor_flutter.dart index c344f3a..a49145a 100644 --- a/trezor-flutter/lib/trezor_flutter.dart +++ b/trezor-flutter/lib/trezor_flutter.dart @@ -4,6 +4,7 @@ export "package:universal_ble/universal_ble.dart"; export 'src/connect/coins/monero.dart'; export 'src/connect/trezor_client.dart'; +export 'src/exceptions/trezor_exception.dart'; export 'src/models/bluetooth_options.dart'; export 'src/models/connection_type.dart'; export 'src/models/discovered_device.dart'; From 2b79a08a427be2a857880ba7887333546043360a Mon Sep 17 00:00:00 2001 From: redsh4de <25299353+redsh4de@users.noreply.github.com> Date: Mon, 8 Jun 2026 02:30:26 +0300 Subject: [PATCH 2/3] feat: add trezor passphrase session support --- trezor-flutter/example/lib/main.dart | 4 +- trezor-flutter/example/pubspec.lock | 8 + trezor-flutter/lib/src/connect/acquire.dart | 6 +- trezor-flutter/lib/src/connect/pairing.dart | 16 +- .../lib/src/connect/trezor_client.dart | 108 ++++++++++-- .../lib/src/connect/trezor_passphrase.dart | 86 ++++++++++ .../lib/src/exceptions/trezor_exception.dart | 7 + .../lib/src/trezor/protobuf/utils.dart | 1 + trezor-flutter/lib/trezor_flutter.dart | 1 + trezor-flutter/pubspec.lock | 8 + trezor-flutter/pubspec.yaml | 1 + .../test/trezor/connect/passphrase_test.dart | 155 ++++++++++++++++++ 12 files changed, 372 insertions(+), 29 deletions(-) create mode 100644 trezor-flutter/lib/src/connect/trezor_passphrase.dart create mode 100644 trezor-flutter/test/trezor/connect/passphrase_test.dart diff --git a/trezor-flutter/example/lib/main.dart b/trezor-flutter/example/lib/main.dart index e2c5447..1010668 100644 --- a/trezor-flutter/example/lib/main.dart +++ b/trezor-flutter/example/lib/main.dart @@ -127,13 +127,11 @@ class _HomePageState extends State { onCodePin, ); - await client.createChannel(passphrase: "CakeWallet"); + await client.createChannel(passphrase: TrezorPassphrase.value("CakeWallet")); monero = TrezorMonero(client); final monAddress = await monero!.getWatchCredentials(); setState(() => moneroAddress = monAddress.$2); - - }, ), ), diff --git a/trezor-flutter/example/pubspec.lock b/trezor-flutter/example/pubspec.lock index ecd2de5..32661c2 100644 --- a/trezor-flutter/example/pubspec.lock +++ b/trezor-flutter/example/pubspec.lock @@ -467,6 +467,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + unorm_dart: + dependency: transitive + description: + name: unorm_dart + sha256: "0c69186b03ca6addab0774bcc0f4f17b88d4ce78d9d4d8f0619e30a99ead58e7" + url: "https://pub.dev" + source: hosted + version: "0.3.2" vector_graphics: dependency: transitive description: diff --git a/trezor-flutter/lib/src/connect/acquire.dart b/trezor-flutter/lib/src/connect/acquire.dart index f73b200..d881f7f 100644 --- a/trezor-flutter/lib/src/connect/acquire.dart +++ b/trezor-flutter/lib/src/connect/acquire.dart @@ -15,7 +15,6 @@ Future getThpChannel( required String hostName, required String appName, required Future Function() onCodeCode, - String? passphrase, }) async { if (state.phase == ThpPhase.handshake) { await createThpChannel(connection, state); @@ -32,8 +31,5 @@ Future getThpChannel( } } - if (state.phase != ThpPhase.paired) return false; - - await thpCreateSession(connection, state, passphrase); - return true; + return state.phase == ThpPhase.paired; } diff --git a/trezor-flutter/lib/src/connect/pairing.dart b/trezor-flutter/lib/src/connect/pairing.dart index 7eb80b7..350e808 100644 --- a/trezor-flutter/lib/src/connect/pairing.dart +++ b/trezor-flutter/lib/src/connect/pairing.dart @@ -139,15 +139,23 @@ Future thpPairingEnd(TrezorConnection connection, ThpState state) async { /// /// This sends ThpCreateNewSession and waits for Success. /// Must be called after handshake is complete but before sending other messages. -Future thpCreateSession( - TrezorConnection connection, ThpState state, String? passphrase) async { +Future thpCreateSession( + TrezorConnection connection, + ThpState state, + TrezorPassphrase passphrase, +) async { state.createNewSessionId(); await thpCall( connection, state, - ThpCreateNewSession(passphrase: passphrase ?? "", onDevice: false, deriveCardano: false) - .writeToBuffer(), + buildThpCreateNewSession(passphrase).writeToBuffer(), TrezorMessageType.thpCreateNewSession, ); + + return TrezorSessionInfo( + enteredOnDevice: passphrase is TrezorPassphraseOnDevice, + resumed: false, + sessionId: state.sessionId, + ); } diff --git a/trezor-flutter/lib/src/connect/trezor_client.dart b/trezor-flutter/lib/src/connect/trezor_client.dart index 796afc5..6c02866 100644 --- a/trezor-flutter/lib/src/connect/trezor_client.dart +++ b/trezor-flutter/lib/src/connect/trezor_client.dart @@ -1,9 +1,10 @@ import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; + import 'package:trezor_flutter/src/connect/acquire.dart'; import 'package:trezor_flutter/src/connect/pairing.dart'; import 'package:trezor_flutter/src/connect/trezor_thp_call.dart'; -import 'package:trezor_flutter/src/exceptions/trezor_exception.dart'; import 'package:trezor_flutter/src/operations/trezor/v1_operation.dart'; import 'package:trezor_flutter/src/trezor/protobuf/messages-common.pb.dart'; import 'package:trezor_flutter/src/trezor/protobuf/messages-management.pb.dart'; @@ -26,18 +27,30 @@ abstract class TrezorClient { final TrezorConnection connection; - /// Create a new Channel for THP or Initialize a new Session for V1 + /// Create a new Channel for THP or Initialize a new Session for V1. /// - /// [passphrase] is used to optionally set an passphrase for the given channel - Future createChannel({String? passphrase}); + /// Pass [passphrase] to bind the wallet session in one step; otherwise call + /// [createSession] after inspecting [passphraseAlwaysOnDevice]. + Future createChannel({TrezorPassphrase? passphrase}); + + /// Bind a wallet session for [passphrase]; [resumeSessionId] is V1-only + Future createSession(TrezorPassphrase passphrase, + {List? resumeSessionId}); /// Make a call to the Trezor Device Future<(int, Uint8List)> call(Uint8List message, TrezorMessageType messageType); + + /// Whether the device forces passphrase entry on its own screen + bool get passphraseAlwaysOnDevice; } class TrezorClientV1 extends TrezorClient { List? sessionId; - String? _passphrase; + final PassphraseIntentSlot _sessionIntent = PassphraseIntentSlot(); + Features? _features; + + @override + bool get passphraseAlwaysOnDevice => _features?.passphraseAlwaysOnDevice ?? false; TrezorClientV1(super.connection); @@ -56,8 +69,10 @@ class TrezorClientV1 extends TrezorClient { return call(ButtonAck().writeToBuffer(), TrezorMessageType.buttonAck); case TrezorMessageType.passphraseRequest: - return call(PassphraseAck(passphrase: _passphrase ?? "").writeToBuffer(), - TrezorMessageType.passphraseAck); + final ack = _sessionIntent.ackMessage(); + final result = await call(ack.writeToBuffer(), TrezorMessageType.passphraseAck); + _sessionIntent.markUsed(); + return result; default: return (response.messageTypeRaw, response.payload); @@ -65,8 +80,8 @@ class TrezorClientV1 extends TrezorClient { } @override - Future createChannel({String? passphrase}) async { - _passphrase = passphrase; + Future createChannel({TrezorPassphrase? passphrase}) async { + if (passphrase != null) _sessionIntent.store(passphrase); if (sessionId != null) return; final initialize = await connection.sendOperation( @@ -79,6 +94,32 @@ class TrezorClientV1 extends TrezorClient { final feature = Features.fromBuffer(initialize.payload); sessionId = feature.sessionId; + _features = feature; + } + + @override + Future createSession(TrezorPassphrase passphrase, + {List? resumeSessionId}) async { + _sessionIntent.store(passphrase); + + final initialize = await connection.sendOperation( + TrezorV1Operation( + data: Initialize(sessionId: resumeSessionId).writeToBuffer(), + messageType: TrezorMessageType.initialize.raw, + ), + transformer: const V1Transformer(), + ); + + final features = Features.fromBuffer(initialize.payload); + sessionId = features.sessionId; + _features = features; + + return TrezorSessionInfo( + enteredOnDevice: + passphrase is TrezorPassphraseOnDevice || features.passphraseAlwaysOnDevice, + resumed: listEquals(features.sessionId, resumeSessionId), + sessionId: features.sessionId, + ); } } @@ -88,6 +129,10 @@ class TrezorClientV2 extends TrezorClient { final String hostName; Future Function() onPinCode; List? sessionId; + Features? _features; + + @override + bool get passphraseAlwaysOnDevice => _features?.passphraseAlwaysOnDevice ?? false; TrezorClientV2( super.connection, @@ -115,16 +160,45 @@ class TrezorClientV2 extends TrezorClient { } @override - Future createChannel({String? passphrase}) => getThpChannel( - connection, - state, - appName: appName, - hostName: hostName, - onCodeCode: onPinCode, - passphrase: passphrase, - ); + Future createChannel({TrezorPassphrase? passphrase}) async { + await getThpChannel( + connection, + state, + appName: appName, + hostName: hostName, + onCodeCode: onPinCode, + ); + + final features = await call(GetFeatures().writeToBuffer(), TrezorMessageType.getFeatures); + _features = Features.fromBuffer(features.$2); + + if (passphrase != null) await createSession(passphrase); + } + + @override + Future createSession(TrezorPassphrase passphrase, + {List? resumeSessionId}) { + final intent = passphraseAlwaysOnDevice ? const TrezorPassphrase.onDevice() : passphrase; + return thpCreateSession(connection, state, intent); + } Future getAutoparing() { return getThpCredentials(connection, state, true); } } + +/// Result of [TrezorClient.createSession]. +class TrezorSessionInfo { + const TrezorSessionInfo({ + required this.enteredOnDevice, + required this.resumed, + this.sessionId, + }); + + final bool enteredOnDevice; + + /// V1 only: the device kept the session - no passphrase prompt will occur + final bool resumed; + + final List? sessionId; +} diff --git a/trezor-flutter/lib/src/connect/trezor_passphrase.dart b/trezor-flutter/lib/src/connect/trezor_passphrase.dart new file mode 100644 index 0000000..5322d0c --- /dev/null +++ b/trezor-flutter/lib/src/connect/trezor_passphrase.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:trezor_flutter/src/exceptions/trezor_exception.dart'; +import 'package:trezor_flutter/src/trezor/protobuf/messages-common.pb.dart'; +import 'package:trezor_flutter/src/trezor/protobuf/messages-thp.pb.dart'; +import 'package:unorm_dart/unorm_dart.dart' as unorm; + +/// NFKD-normalizes [raw]; throws [ArgumentError] above 50 UTF-8 bytes +String normalizePassphrase(String raw) { + final normalized = unorm.nfkd(raw); + final byteLength = utf8.encode(normalized).length; + if (byteLength > 50) { + throw ArgumentError( + 'Passphrase exceeds 50 bytes after NFKD normalization ($byteLength bytes)'); + } + return normalized; +} + +/// The host's passphrase intent for a Trezor session +sealed class TrezorPassphrase { + const TrezorPassphrase(); + + const factory TrezorPassphrase.empty() = TrezorPassphraseEmpty; + + /// `.value('')` derives the same wallet as [TrezorPassphrase.empty] + factory TrezorPassphrase.value(String raw) = TrezorPassphraseValue; + + const factory TrezorPassphrase.onDevice() = TrezorPassphraseOnDevice; +} + +class TrezorPassphraseEmpty extends TrezorPassphrase { + const TrezorPassphraseEmpty(); +} + +class TrezorPassphraseOnDevice extends TrezorPassphrase { + const TrezorPassphraseOnDevice(); +} + +class TrezorPassphraseValue extends TrezorPassphrase { + TrezorPassphraseValue(String raw) : passphrase = normalizePassphrase(raw); + + final String passphrase; +} + +/// The secret was dropped after the session was bound; the host must re-prompt +class TrezorPassphraseConsumed extends TrezorPassphrase { + const TrezorPassphraseConsumed(); +} + +PassphraseAck buildPassphraseAck(TrezorPassphrase intent) => switch (intent) { + TrezorPassphraseEmpty() => PassphraseAck(passphrase: ''), + TrezorPassphraseValue(:final passphrase) => + PassphraseAck(passphrase: passphrase), + TrezorPassphraseOnDevice() => PassphraseAck(onDevice: true), + TrezorPassphraseConsumed() => throw const TrezorSessionExpiredException(), + }; + +/// On-device entry must leave `passphrase` absent: present-but-empty is +/// rejected under `passphrase_always_on_device` +ThpCreateNewSession buildThpCreateNewSession(TrezorPassphrase intent, + {bool deriveCardano = false}) => + switch (intent) { + TrezorPassphraseEmpty() => + ThpCreateNewSession(passphrase: '', deriveCardano: deriveCardano), + TrezorPassphraseValue(:final passphrase) => ThpCreateNewSession( + passphrase: passphrase, deriveCardano: deriveCardano), + TrezorPassphraseOnDevice() => + ThpCreateNewSession(onDevice: true, deriveCardano: deriveCardano), + TrezorPassphraseConsumed() => throw const TrezorSessionExpiredException(), + }; + +/// Answers V1 PassphraseRequests; a typed secret is dropped after first use +class PassphraseIntentSlot { + TrezorPassphrase? _intent; + + void store(TrezorPassphrase intent) => _intent = intent; + + PassphraseAck ackMessage() => + buildPassphraseAck(_intent ?? const TrezorPassphrase.empty()); + + void markUsed() { + if (_intent is TrezorPassphraseValue) { + _intent = const TrezorPassphraseConsumed(); + } + } +} diff --git a/trezor-flutter/lib/src/exceptions/trezor_exception.dart b/trezor-flutter/lib/src/exceptions/trezor_exception.dart index 2af4a7e..43af9c8 100644 --- a/trezor-flutter/lib/src/exceptions/trezor_exception.dart +++ b/trezor-flutter/lib/src/exceptions/trezor_exception.dart @@ -184,3 +184,10 @@ class TrezorChannelException implements Exception { return "$runtimeType($message)"; } } + +class TrezorSessionExpiredException implements Exception { + const TrezorSessionExpiredException(); + + @override + String toString() => "$runtimeType()"; +} diff --git a/trezor-flutter/lib/src/trezor/protobuf/utils.dart b/trezor-flutter/lib/src/trezor/protobuf/utils.dart index 589a6db..3be21c8 100644 --- a/trezor-flutter/lib/src/trezor/protobuf/utils.dart +++ b/trezor-flutter/lib/src/trezor/protobuf/utils.dart @@ -10,6 +10,7 @@ enum TrezorMessageType { success(2), failure(3), features(17), + getFeatures(55), buttonRequest(26), // device is waiting for user confirmation buttonAck(27), // host acknowledgment of button request diff --git a/trezor-flutter/lib/trezor_flutter.dart b/trezor-flutter/lib/trezor_flutter.dart index a49145a..1b69647 100644 --- a/trezor-flutter/lib/trezor_flutter.dart +++ b/trezor-flutter/lib/trezor_flutter.dart @@ -4,6 +4,7 @@ export "package:universal_ble/universal_ble.dart"; export 'src/connect/coins/monero.dart'; export 'src/connect/trezor_client.dart'; +export 'src/connect/trezor_passphrase.dart'; export 'src/exceptions/trezor_exception.dart'; export 'src/models/bluetooth_options.dart'; export 'src/models/connection_type.dart'; diff --git a/trezor-flutter/pubspec.lock b/trezor-flutter/pubspec.lock index 9ce66c1..a90232b 100644 --- a/trezor-flutter/pubspec.lock +++ b/trezor-flutter/pubspec.lock @@ -482,6 +482,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + unorm_dart: + dependency: "direct main" + description: + name: unorm_dart + sha256: "0c69186b03ca6addab0774bcc0f4f17b88d4ce78d9d4d8f0619e30a99ead58e7" + url: "https://pub.dev" + source: hosted + version: "0.3.2" vector_math: dependency: transitive description: diff --git a/trezor-flutter/pubspec.yaml b/trezor-flutter/pubspec.yaml index da1e2af..29b47fe 100644 --- a/trezor-flutter/pubspec.yaml +++ b/trezor-flutter/pubspec.yaml @@ -25,6 +25,7 @@ dependencies: fixnum: ^1.1.1 pointycastle: ^3.7.3 fast_base58: ^0.2.1 + unorm_dart: ^0.3.2 dev_dependencies: # flutter_test: diff --git a/trezor-flutter/test/trezor/connect/passphrase_test.dart b/trezor-flutter/test/trezor/connect/passphrase_test.dart new file mode 100644 index 0000000..4b48313 --- /dev/null +++ b/trezor-flutter/test/trezor/connect/passphrase_test.dart @@ -0,0 +1,155 @@ +import 'package:test/test.dart'; +import 'package:trezor_flutter/src/connect/trezor_passphrase.dart'; +import 'package:trezor_flutter/src/exceptions/trezor_exception.dart'; +import 'package:trezor_flutter/src/trezor/protobuf/messages-common.pb.dart'; +import 'package:trezor_flutter/src/trezor/protobuf/messages-thp.pb.dart'; + +void main() { + group('normalizePassphrase', () { + test('NFKD-decomposes the fi ligature (U+FB01) to "fi"', () { + expect(normalizePassphrase('fi'), 'fi'); + }); + + test('composed and decomposed é normalize to the same string', () { + expect(normalizePassphrase('é'), normalizePassphrase('é')); + }); + + test('enforces the 50-byte limit at the boundary', () { + expect(normalizePassphrase('a' * 50), 'a' * 50); + expect(() => normalizePassphrase('a' * 51), throwsArgumentError); + }); + + test('limit counts UTF-8 bytes after NFKD, not characters', () { + expect(normalizePassphrase('é' * 16), 'é' * 16); // 48 bytes: ok + expect(() => normalizePassphrase('é' * 17), throwsArgumentError); // 51 bytes + expect(() => normalizePassphrase('é' * 50), throwsArgumentError); // 150 bytes + }); + + test('rejects input whose raw form fits 50 bytes but NFKD form does not', () { + expect(() => normalizePassphrase('é' * 25), throwsArgumentError); + }); + + test('the ArgumentError message never contains the passphrase', () { + const secret = 'SuperSecretHiddenWalletPassphraseThatIsWayTooLong!!!'; // 52 bytes + try { + normalizePassphrase(secret); + fail('expected ArgumentError'); + } on ArgumentError catch (e) { + expect(e.toString(), isNot(contains('SuperSecret'))); + } + }); + + test('.value normalizes its input', () { + expect(TrezorPassphraseValue('first').passphrase, 'first'); + }); + }); + + group('builders', () { + test('PassphraseAck wire round-trip: empty keeps the empty string present', + () { + final decoded = PassphraseAck.fromBuffer( + buildPassphraseAck(const TrezorPassphrase.empty()).writeToBuffer()); + expect(decoded.hasPassphrase(), isTrue); + expect(decoded.passphrase, ''); + expect(decoded.hasOnDevice(), isFalse); + }); + + test('PassphraseAck wire round-trip: onDevice keeps the passphrase field absent', + () { + final decoded = PassphraseAck.fromBuffer( + buildPassphraseAck(const TrezorPassphrase.onDevice()).writeToBuffer()); + expect(decoded.hasPassphrase(), isFalse); + expect(decoded.onDevice, isTrue); + }); + + test('PassphraseAck consumed: throws TrezorSessionExpiredException', () { + expect(() => buildPassphraseAck(const TrezorPassphraseConsumed()), + throwsA(isA())); + }); + + test('ThpCreateNewSession wire round-trip: onDevice keeps the passphrase field absent', + () { + final decoded = ThpCreateNewSession.fromBuffer( + buildThpCreateNewSession(const TrezorPassphrase.onDevice()) + .writeToBuffer()); + expect(decoded.hasPassphrase(), isFalse); + expect(decoded.onDevice, isTrue); + }); + + test('ThpCreateNewSession wire round-trip: empty keeps the empty string present', + () { + final decoded = ThpCreateNewSession.fromBuffer( + buildThpCreateNewSession(const TrezorPassphrase.empty()) + .writeToBuffer()); + expect(decoded.hasPassphrase(), isTrue); + expect(decoded.passphrase, ''); + expect(decoded.hasOnDevice(), isFalse); + }); + + test('ThpCreateNewSession wire round-trip: value carries the normalized string', + () { + final decoded = ThpCreateNewSession.fromBuffer( + buildThpCreateNewSession(TrezorPassphrase.value('é')) + .writeToBuffer()); + expect(decoded.hasPassphrase(), isTrue); + expect(decoded.passphrase, 'é'); + }); + + test('ThpCreateNewSession consumed: throws TrezorSessionExpiredException', + () { + expect(() => buildThpCreateNewSession(const TrezorPassphraseConsumed()), + throwsA(isA())); + }); + + test('deriveCardano defaults to explicit false (matches old behavior)', () { + final msg = buildThpCreateNewSession(const TrezorPassphrase.empty()); + expect(msg.hasDeriveCardano(), isTrue); + expect(msg.deriveCardano, isFalse); + }); + }); + + group('PassphraseIntentSlot', () { + test('no intent answers as a standard wallet (empty passphrase)', () { + final slot = PassphraseIntentSlot(); + final ack = slot.ackMessage(); + expect(ack.hasPassphrase(), isTrue); + expect(ack.passphrase, ''); + expect(ack.hasOnDevice(), isFalse); + }); + + test('value intent can answer repeatedly before markUsed', () { + final slot = PassphraseIntentSlot()..store(TrezorPassphrase.value('hunter2')); + expect(slot.ackMessage().passphrase, 'hunter2'); + expect(slot.ackMessage().passphrase, 'hunter2'); + }); + + test('markUsed degrades value intent: re-ask throws TrezorSessionExpiredException', () { + final slot = PassphraseIntentSlot()..store(TrezorPassphrase.value('hunter2')); + slot.ackMessage(); + slot.markUsed(); + expect(slot.ackMessage, throwsA(isA())); + }); + + test('markUsed keeps empty and onDevice intents silently re-answerable', () { + final empty = PassphraseIntentSlot()..store(const TrezorPassphrase.empty()); + empty.ackMessage(); + empty.markUsed(); + expect(empty.ackMessage().passphrase, ''); + + final onDevice = PassphraseIntentSlot() + ..store(const TrezorPassphrase.onDevice()); + onDevice.ackMessage(); + onDevice.markUsed(); + expect(onDevice.ackMessage().onDevice, isTrue); + }); + + test('store replaces a consumed intent (new createSession re-arms answering)', () { + final slot = PassphraseIntentSlot()..store(TrezorPassphrase.value('hunter2')); + slot.ackMessage(); + slot.markUsed(); + slot.store(TrezorPassphrase.value('hunter3')); + expect(slot.ackMessage().passphrase, 'hunter3'); + }); + }); + +} From cc156c915ba5f7818de4890749b068740299bd74 Mon Sep 17 00:00:00 2001 From: redsh4de <25299353+redsh4de@users.noreply.github.com> Date: Tue, 9 Jun 2026 02:07:59 +0300 Subject: [PATCH 3/3] feat: cancel in-flight on-device prompts Add TrezorClient.cancel() so the host can abort a prompt the device is showing (e.g. on-device passphrase entry) and send it back to its home screen --- .../lib/src/api/connection_manager.dart | 2 ++ trezor-flutter/lib/src/api/gatt_gateway.dart | 2 ++ .../lib/src/connect/trezor_client.dart | 23 +++++++++++++++++-- .../lib/src/connect/trezor_thp_call.dart | 8 +++++-- .../lib/src/trezor/protobuf/utils.dart | 1 + .../lib/src/trezor/trezor_ble_manager.dart | 15 ++++++++++++ .../lib/src/trezor/trezor_gatt_gateway.dart | 19 ++++++++++----- .../lib/src/trezor/trezor_usb_manager.dart | 13 +++++++++++ trezor-flutter/lib/src/trezor_connection.dart | 6 +++++ 9 files changed, 79 insertions(+), 10 deletions(-) diff --git a/trezor-flutter/lib/src/api/connection_manager.dart b/trezor-flutter/lib/src/api/connection_manager.dart index b55f44a..dbf0a64 100644 --- a/trezor-flutter/lib/src/api/connection_manager.dart +++ b/trezor-flutter/lib/src/api/connection_manager.dart @@ -14,6 +14,8 @@ abstract class ConnectionManager { TrezorTransformer? transformer, ); + Future sendOutOfBand(TrezorDevice device, TrezorOperation operation); + Future dispose(); ConnectionType get connectionType; diff --git a/trezor-flutter/lib/src/api/gatt_gateway.dart b/trezor-flutter/lib/src/api/gatt_gateway.dart index 4eb5731..06592e7 100644 --- a/trezor-flutter/lib/src/api/gatt_gateway.dart +++ b/trezor-flutter/lib/src/api/gatt_gateway.dart @@ -25,6 +25,8 @@ abstract class GattGateway { Future sendOperation(TrezorOperation operation, {TrezorTransformer? transformer}); + Future sendWriteOnly(TrezorOperation operation); + Future getService(String serviceId); Future getCharacteristic(BleService service, String characteristic); diff --git a/trezor-flutter/lib/src/connect/trezor_client.dart b/trezor-flutter/lib/src/connect/trezor_client.dart index 6c02866..c9ee94b 100644 --- a/trezor-flutter/lib/src/connect/trezor_client.dart +++ b/trezor-flutter/lib/src/connect/trezor_client.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:trezor_flutter/src/connect/acquire.dart'; import 'package:trezor_flutter/src/connect/pairing.dart'; import 'package:trezor_flutter/src/connect/trezor_thp_call.dart'; +import 'package:trezor_flutter/src/operations/trezor/thp_encrypted_operation.dart'; import 'package:trezor_flutter/src/operations/trezor/v1_operation.dart'; import 'package:trezor_flutter/src/trezor/protobuf/messages-common.pb.dart'; import 'package:trezor_flutter/src/trezor/protobuf/messages-management.pb.dart'; @@ -40,6 +41,9 @@ abstract class TrezorClient { /// Make a call to the Trezor Device Future<(int, Uint8List)> call(Uint8List message, TrezorMessageType messageType); + /// Abort an in-flight on-device prompt; the pending call fails with ActionCancelled + Future cancel(); + /// Whether the device forces passphrase entry on its own screen bool get passphraseAlwaysOnDevice; } @@ -79,6 +83,9 @@ class TrezorClientV1 extends TrezorClient { } } + @override + Future cancel() => connection.disconnect(); + @override Future createChannel({TrezorPassphrase? passphrase}) async { if (passphrase != null) _sessionIntent.store(passphrase); @@ -115,8 +122,7 @@ class TrezorClientV1 extends TrezorClient { _features = features; return TrezorSessionInfo( - enteredOnDevice: - passphrase is TrezorPassphraseOnDevice || features.passphraseAlwaysOnDevice, + enteredOnDevice: passphrase is TrezorPassphraseOnDevice || features.passphraseAlwaysOnDevice, resumed: listEquals(features.sessionId, resumeSessionId), sessionId: features.sessionId, ); @@ -159,6 +165,19 @@ class TrezorClientV2 extends TrezorClient { } } + @override + Future cancel() { + if (!state.cancelablePromise) return connection.disconnect(); + + return connection.sendOutOfBand( + TrezorThpEncryptedOperation( + state, + data: Cancel().writeToBuffer(), + messageType: TrezorMessageType.cancel, + ), + ); + } + @override Future createChannel({TrezorPassphrase? passphrase}) async { await getThpChannel( diff --git a/trezor-flutter/lib/src/connect/trezor_thp_call.dart b/trezor-flutter/lib/src/connect/trezor_thp_call.dart index efc5c60..e1f5429 100644 --- a/trezor-flutter/lib/src/connect/trezor_thp_call.dart +++ b/trezor-flutter/lib/src/connect/trezor_thp_call.dart @@ -30,8 +30,12 @@ Future<(TrezorMessageType, Uint8List)> thpCall( if (response.messageType == TrezorMessageType.buttonRequest) { state.cancelablePromise = true; - - return thpCall(connection, state, ButtonAck().writeToBuffer(), TrezorMessageType.buttonAck); + try { + return await thpCall( + connection, state, ButtonAck().writeToBuffer(), TrezorMessageType.buttonAck); + } finally { + state.cancelablePromise = false; + } } return (response.messageType, response.payload); diff --git a/trezor-flutter/lib/src/trezor/protobuf/utils.dart b/trezor-flutter/lib/src/trezor/protobuf/utils.dart index 3be21c8..9d075fc 100644 --- a/trezor-flutter/lib/src/trezor/protobuf/utils.dart +++ b/trezor-flutter/lib/src/trezor/protobuf/utils.dart @@ -10,6 +10,7 @@ enum TrezorMessageType { success(2), failure(3), features(17), + cancel(20), getFeatures(55), buttonRequest(26), // device is waiting for user confirmation diff --git a/trezor-flutter/lib/src/trezor/trezor_ble_manager.dart b/trezor-flutter/lib/src/trezor/trezor_ble_manager.dart index ae89609..95b90dc 100644 --- a/trezor-flutter/lib/src/trezor/trezor_ble_manager.dart +++ b/trezor-flutter/lib/src/trezor/trezor_ble_manager.dart @@ -159,6 +159,21 @@ class TrezorBleConnectionManager extends ConnectionManager { ); } + @override + Future sendOutOfBand(TrezorDevice device, TrezorOperation operation) async { + if (_disposed) throw TrezorManagerDisposedException(ConnectionType.ble); + + final d = _connectedDevices[device.id]; + if (d == null) { + throw DeviceNotConnectedException( + requestedOperation: 'ble_manager: sendOutOfBand', + connectionType: ConnectionType.ble, + ); + } + + return d.gateway.sendWriteOnly(operation); + } + @override Future get status { if (_disposed) throw TrezorManagerDisposedException(ConnectionType.ble); diff --git a/trezor-flutter/lib/src/trezor/trezor_gatt_gateway.dart b/trezor-flutter/lib/src/trezor/trezor_gatt_gateway.dart index 0caa6bb..e88cfc2 100644 --- a/trezor-flutter/lib/src/trezor/trezor_gatt_gateway.dart +++ b/trezor-flutter/lib/src/trezor/trezor_gatt_gateway.dart @@ -218,6 +218,19 @@ class TrezorGattGateway extends GattGateway { _pendingOperations.addFirst(_Request(operation, transformer, completer)); } + await _writePayloads(operation); + + if (operation is TrezorThpAckOperation) { + completer.complete(); + } + + return completer.future; + } + + @override + Future sendWriteOnly(TrezorOperation operation) => _writePayloads(operation); + + Future _writePayloads(TrezorOperation operation) async { final writer = ByteDataWriter(); final output = await operation.write(writer); final payloads = _packer.pack(output, 244); @@ -230,13 +243,7 @@ class TrezorGattGateway extends GattGateway { withoutResponse: false, timeout: _bleWriteTimeout, ); - - if (operation is TrezorThpAckOperation) { - completer.complete(); - } } - - return completer.future; } @override diff --git a/trezor-flutter/lib/src/trezor/trezor_usb_manager.dart b/trezor-flutter/lib/src/trezor/trezor_usb_manager.dart index 37a49cd..eb94439 100644 --- a/trezor-flutter/lib/src/trezor/trezor_usb_manager.dart +++ b/trezor-flutter/lib/src/trezor/trezor_usb_manager.dart @@ -88,6 +88,19 @@ class TrezorUsbManager extends ConnectionManager { } } + @override + Future sendOutOfBand(TrezorDevice device, TrezorOperation operation) async { + if (_disposed) throw TrezorManagerDisposedException(connectionType); + + try { + final writer = ByteDataWriter(); + final payload = await operation.write(writer); + await _usbTransport.transferOut(payload); + } on PlatformException catch (ex) { + throw TrezorExceptionUtils.fromPlatformException(ex, connectionType); + } + } + @override // TODO this may need to be implemented Stream get deviceStateChanges { if (_disposed) throw TrezorManagerDisposedException(connectionType); diff --git a/trezor-flutter/lib/src/trezor_connection.dart b/trezor-flutter/lib/src/trezor_connection.dart index 5d9753f..1247312 100644 --- a/trezor-flutter/lib/src/trezor_connection.dart +++ b/trezor-flutter/lib/src/trezor_connection.dart @@ -51,6 +51,12 @@ class TrezorConnection { ); } + Future sendOutOfBand(TrezorOperation operation) { + if (_isDisconnected) return Future.value(); + + return _connectionManager.sendOutOfBand(device, operation); + } + Future _sendOperationImpl( TrezorDevice device, TrezorOperation operation,