Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions trezor-flutter/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,11 @@ class _HomePageState extends State<HomePage> {
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);


},
),
),
Expand Down
8 changes: 8 additions & 0 deletions trezor-flutter/example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions trezor-flutter/lib/src/api/connection_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ abstract class ConnectionManager {
TrezorTransformer? transformer,
);

Future<void> sendOutOfBand(TrezorDevice device, TrezorOperation operation);

Future<void> dispose();

ConnectionType get connectionType;
Expand Down
2 changes: 2 additions & 0 deletions trezor-flutter/lib/src/api/gatt_gateway.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ abstract class GattGateway {

Future<T> sendOperation<T>(TrezorOperation<T> operation, {TrezorTransformer? transformer});

Future<void> sendWriteOnly(TrezorOperation operation);

Future<BleService?> getService(String serviceId);

Future<BleCharacteristic?> getCharacteristic(BleService service, String characteristic);
Expand Down
6 changes: 1 addition & 5 deletions trezor-flutter/lib/src/connect/acquire.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ Future<bool> getThpChannel(
required String hostName,
required String appName,
required Future<String> Function() onCodeCode,
String? passphrase,
}) async {
if (state.phase == ThpPhase.handshake) {
await createThpChannel(connection, state);
Expand All @@ -32,8 +31,5 @@ Future<bool> getThpChannel(
}
}

if (state.phase != ThpPhase.paired) return false;

await thpCreateSession(connection, state, passphrase);
return true;
return state.phase == ThpPhase.paired;
}
16 changes: 12 additions & 4 deletions trezor-flutter/lib/src/connect/pairing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -139,15 +139,23 @@ Future<void> 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<void> thpCreateSession(
TrezorConnection connection, ThpState state, String? passphrase) async {
Future<TrezorSessionInfo> 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,
);
}
125 changes: 109 additions & 16 deletions trezor-flutter/lib/src/connect/trezor_client.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
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/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';
Expand All @@ -26,18 +28,33 @@ 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<void> createChannel({String? passphrase});
/// Pass [passphrase] to bind the wallet session in one step; otherwise call
/// [createSession] after inspecting [passphraseAlwaysOnDevice].
Future<void> createChannel({TrezorPassphrase? passphrase});

/// Bind a wallet session for [passphrase]; [resumeSessionId] is V1-only
Future<TrezorSessionInfo> createSession(TrezorPassphrase passphrase,
{List<int>? resumeSessionId});

/// 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<void> cancel();

/// Whether the device forces passphrase entry on its own screen
bool get passphraseAlwaysOnDevice;
}

class TrezorClientV1 extends TrezorClient {
List<int>? sessionId;
String? _passphrase;
final PassphraseIntentSlot _sessionIntent = PassphraseIntentSlot();
Features? _features;

@override
bool get passphraseAlwaysOnDevice => _features?.passphraseAlwaysOnDevice ?? false;

TrezorClientV1(super.connection);

Expand All @@ -56,17 +73,22 @@ 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);
}
}

@override
Future<void> createChannel({String? passphrase}) async {
_passphrase = passphrase;
Future<void> cancel() => connection.disconnect();

@override
Future<void> createChannel({TrezorPassphrase? passphrase}) async {
if (passphrase != null) _sessionIntent.store(passphrase);
if (sessionId != null) return;

final initialize = await connection.sendOperation(
Expand All @@ -79,6 +101,31 @@ class TrezorClientV1 extends TrezorClient {

final feature = Features.fromBuffer(initialize.payload);
sessionId = feature.sessionId;
_features = feature;
}

@override
Future<TrezorSessionInfo> createSession(TrezorPassphrase passphrase,
{List<int>? 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,
);
}
}

Expand All @@ -88,6 +135,10 @@ class TrezorClientV2 extends TrezorClient {
final String hostName;
Future<String> Function() onPinCode;
List<int>? sessionId;
Features? _features;

@override
bool get passphraseAlwaysOnDevice => _features?.passphraseAlwaysOnDevice ?? false;

TrezorClientV2(
super.connection,
Expand Down Expand Up @@ -115,16 +166,58 @@ class TrezorClientV2 extends TrezorClient {
}

@override
Future<void> createChannel({String? passphrase}) => getThpChannel(
connection,
Future<void> cancel() {
if (!state.cancelablePromise) return connection.disconnect();

return connection.sendOutOfBand(
TrezorThpEncryptedOperation(
state,
appName: appName,
hostName: hostName,
onCodeCode: onPinCode,
passphrase: passphrase,
);
data: Cancel().writeToBuffer(),
messageType: TrezorMessageType.cancel,
),
);
}

@override
Future<void> 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<TrezorSessionInfo> createSession(TrezorPassphrase passphrase,
{List<int>? resumeSessionId}) {
final intent = passphraseAlwaysOnDevice ? const TrezorPassphrase.onDevice() : passphrase;
return thpCreateSession(connection, state, intent);
}

Future<ThpCredentials> 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<int>? sessionId;
}
86 changes: 86 additions & 0 deletions trezor-flutter/lib/src/connect/trezor_passphrase.dart
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
8 changes: 6 additions & 2 deletions trezor-flutter/lib/src/connect/trezor_thp_call.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading