mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
## Summary After a pairing QR scan, the app shows connection progress until code entry or a connection error. A WebSocket handshake that remains pending stops after ten seconds. Cancel is available until storage starts. It resets pairing and restores the entry form. Pasted codes remain available after an error or cancellation. Back navigation stays blocked during identity transfer and storage. Cancel on Send identity to desktop returns to Settings, so another attempt starts through the authorization step. ### Related issue Related to [#9001](https://github.com/block/buzz/pull/9001), which covers organization sign-in after identity transfer. This change covers the earlier connection stage. ### Testing A simulator build with PR #9001 completes identity transfer and saves the community. The community chooser still blocks organization sign-in. [PR #9028](https://github.com/block/buzz/pull/9028) covers that step. Physical iPhone testing and successful real-relay checks in light and dark mode remain pending. On a build with this change, scan a desktop pairing QR code. Check that progress appears, Cancel returns to the entry form, and a fresh attempt reaches code entry. Paste a pairing code, cancel during connection, and check that the form keeps the code. Test a failed connection and retry. In Settings, open Send identity to desktop, start a recovery connection, and cancel. Check that Settings appears and another attempt asks for authorization. Camera recognition speed and the reported intermittent phone network failure remain unverified. Generated with Codex --------- Signed-off-by: Daniel Rouleau <drouleau@squareup.com> Co-authored-by: Sol <c35e7cc95f6fdbfb7e861dccc02bdca567b6d6b20f16b5115c3c6ae082bbeede@buzz.block.builderlab.xyz>
1568 lines
55 KiB
Dart
1568 lines
55 KiB
Dart
import '../../helpers/controllable_pairing_socket.dart';
|
||
import 'package:buzz/features/pairing/pairing_page.dart';
|
||
import 'package:buzz/onboarding_app_entry.dart';
|
||
import 'package:buzz/shared/theme/theme.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:buzz/shared/community/paired_community_landing.dart';
|
||
import 'dart:async';
|
||
import 'dart:convert';
|
||
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||
import 'package:local_auth/local_auth.dart';
|
||
import 'package:nostr/nostr.dart' as nostr;
|
||
import 'package:buzz/features/pairing/pairing_provider.dart';
|
||
import 'package:buzz/features/pairing/pairing_socket.dart';
|
||
import 'package:buzz/shared/auth/auth.dart';
|
||
import 'package:buzz/shared/relay/relay.dart';
|
||
import 'package:buzz/shared/security/sensitive_action_authorizer.dart';
|
||
|
||
/// Exercises payload validation, credential import, and cancellation across
|
||
/// pairing and credential-validation socket lifetimes.
|
||
void main() {
|
||
group('PairingNotifier', () {
|
||
late ProviderContainer container;
|
||
late FakeAuthNotifier fakeAuth;
|
||
|
||
ProviderContainer createContainer() {
|
||
fakeAuth = FakeAuthNotifier();
|
||
return ProviderContainer(
|
||
overrides: [authProvider.overrideWith(() => fakeAuth)],
|
||
);
|
||
}
|
||
|
||
tearDown(() => container.dispose());
|
||
|
||
test(
|
||
'reset closes legacy validation and rejects its late success',
|
||
() async {
|
||
final socket = _PendingValidationSocket();
|
||
final auth = FakeAuthNotifier();
|
||
final notifier = PairingNotifier(
|
||
validationSocketFactory:
|
||
({
|
||
required wsUrl,
|
||
required nsec,
|
||
required onMessage,
|
||
required onConnected,
|
||
required onDisconnected,
|
||
}) => socket,
|
||
);
|
||
container = ProviderContainer(
|
||
overrides: [
|
||
pairingProvider.overrideWith(() => notifier),
|
||
authProvider.overrideWith(() => auth),
|
||
],
|
||
);
|
||
final input = base64Url.encode(
|
||
utf8.encode(
|
||
jsonEncode({
|
||
'relayUrl': 'https://relay.example',
|
||
'nsec': nostr.Keys.generate().nsec,
|
||
}),
|
||
),
|
||
);
|
||
final pending = container.read(pairingProvider.notifier).pair(input);
|
||
notifier.reset();
|
||
expect(socket.disposed, isTrue);
|
||
socket.connection.complete();
|
||
await pending;
|
||
expect(auth.lastCommunity, isNull);
|
||
expect(container.read(pairingProvider).status, PairingStatus.idle);
|
||
},
|
||
);
|
||
|
||
test(
|
||
'legacy validation rejects failed connection before saving, then retries',
|
||
() async {
|
||
final auth = FakeAuthNotifier();
|
||
var connected = false;
|
||
final notifier = PairingNotifier(
|
||
validationSocketFactory:
|
||
({
|
||
required wsUrl,
|
||
required nsec,
|
||
required onMessage,
|
||
required onConnected,
|
||
required onDisconnected,
|
||
}) => _ValidationOutcomeSocket(() => connected),
|
||
);
|
||
container = ProviderContainer(
|
||
overrides: [
|
||
pairingProvider.overrideWith(() => notifier),
|
||
authProvider.overrideWith(() => auth),
|
||
],
|
||
);
|
||
final input = base64Url.encode(
|
||
utf8.encode(
|
||
jsonEncode({
|
||
'relayUrl': 'https://relay.example',
|
||
'nsec': nostr.Keys.generate().nsec,
|
||
}),
|
||
),
|
||
);
|
||
await container.read(pairingProvider.notifier).pair(input);
|
||
expect(container.read(pairingProvider).status, PairingStatus.error);
|
||
expect(auth.lastCommunity, isNull);
|
||
connected = true;
|
||
await notifier.pair(input);
|
||
expect(container.read(pairingProvider).status, PairingStatus.success);
|
||
expect(auth.lastCommunity, isNotNull);
|
||
},
|
||
);
|
||
|
||
for (final entry in ['pairing', 'add', 'inline']) {
|
||
testWidgets('legacy storage blocks Back from $entry', (tester) async {
|
||
final auth = FakeAuthNotifier()
|
||
..entered = Completer<void>()
|
||
..authentication = Completer<void>();
|
||
final notifier = PairingNotifier(
|
||
credentialValidator: ({required relayUrl, required nsec}) async {},
|
||
);
|
||
container = ProviderContainer(
|
||
overrides: [
|
||
pairingProvider.overrideWith(() => notifier),
|
||
authProvider.overrideWith(() => auth),
|
||
],
|
||
);
|
||
var exits = 0;
|
||
final navigator = GlobalKey<NavigatorState>();
|
||
await tester.pumpWidget(
|
||
UncontrolledProviderScope(
|
||
container: container,
|
||
child: MaterialApp(
|
||
theme: AppTheme.light(),
|
||
navigatorKey: navigator,
|
||
home: const Scaffold(body: Text('Previous page')),
|
||
),
|
||
),
|
||
);
|
||
navigator.currentState!.push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => entry != 'pairing'
|
||
? OnboardingAppEntry(
|
||
addingCommunity: true,
|
||
restoreAccount: false,
|
||
onExit: entry == 'inline' ? () => exits++ : null,
|
||
)
|
||
: const PairingPage(),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
final pending = notifier.pair(
|
||
_encodePairingCode(nsec: nostr.Keys.generate().nsec),
|
||
);
|
||
await auth.entered!.future;
|
||
await tester.pump();
|
||
expect(container.read(pairingProvider).status, PairingStatus.storing);
|
||
expect(find.text('Cancel'), findsNothing);
|
||
expect(await navigator.currentState!.maybePop(), isTrue);
|
||
await tester.pump();
|
||
await tester.binding.handlePopRoute();
|
||
await tester.pump();
|
||
expect(find.byType(PairingPage), findsOneWidget);
|
||
expect(exits, 0);
|
||
expect(container.read(pairingProvider).status, PairingStatus.storing);
|
||
auth.authentication!.complete();
|
||
await pending;
|
||
expect(container.read(pairingProvider).status, PairingStatus.success);
|
||
await tester.pumpWidget(const SizedBox.shrink());
|
||
});
|
||
}
|
||
|
||
test(
|
||
'organization relays are admitted by their session, not pre-validated',
|
||
() async {
|
||
// An organization relay refuses this unauthenticated check until
|
||
// organization sign-in, so running it would always fail pairing.
|
||
final auth = FakeAuthNotifier();
|
||
var validated = false;
|
||
final notifier = PairingNotifier(
|
||
organizationRelay: (relay) => relay.host == 'org.example',
|
||
validationSocketFactory:
|
||
({
|
||
required wsUrl,
|
||
required nsec,
|
||
required onMessage,
|
||
required onConnected,
|
||
required onDisconnected,
|
||
}) {
|
||
validated = true;
|
||
return _ValidationOutcomeSocket(() => false);
|
||
},
|
||
);
|
||
container = ProviderContainer(
|
||
overrides: [
|
||
pairingProvider.overrideWith(() => notifier),
|
||
authProvider.overrideWith(() => auth),
|
||
],
|
||
);
|
||
String input(String relayUrl, {String? nsec}) => base64Url.encode(
|
||
utf8.encode(
|
||
jsonEncode({
|
||
'relayUrl': relayUrl,
|
||
'nsec': nsec ?? nostr.Keys.generate().nsec,
|
||
}),
|
||
),
|
||
);
|
||
|
||
// Skipping the relay check must not admit a key that can never sign.
|
||
await container
|
||
.read(pairingProvider.notifier)
|
||
.pair(input('https://org.example', nsec: 'nsec1notakey'));
|
||
expect(container.read(pairingProvider).status, PairingStatus.error);
|
||
expect(auth.lastCommunity, isNull);
|
||
|
||
await container
|
||
.read(pairingProvider.notifier)
|
||
.pair(input('https://org.example'));
|
||
expect(validated, isFalse);
|
||
expect(container.read(pairingProvider).status, PairingStatus.success);
|
||
expect(auth.lastCommunity?.relayUrl, 'https://org.example');
|
||
|
||
// Other relays still have to accept the identity before it is saved.
|
||
auth.lastCommunity = null;
|
||
await container
|
||
.read(pairingProvider.notifier)
|
||
.pair(input('https://relay.example'));
|
||
expect(validated, isTrue);
|
||
expect(container.read(pairingProvider).status, PairingStatus.error);
|
||
expect(auth.lastCommunity, isNull);
|
||
},
|
||
);
|
||
|
||
test('starts in idle state', () {
|
||
container = createContainer();
|
||
final state = container.read(pairingProvider);
|
||
expect(state.status, PairingStatus.idle);
|
||
expect(state.errorMessage, isNull);
|
||
});
|
||
|
||
test(
|
||
'disconnect during connect does not null-dereference the socket',
|
||
() async {
|
||
final notifier = PairingNotifier(
|
||
socketFactory:
|
||
({
|
||
required wsUrl,
|
||
required ephemeralPrivkey,
|
||
required onMessage,
|
||
required void Function(Object? error) onDisconnected,
|
||
}) => _DisconnectingSocket(disconnectCallback: onDisconnected),
|
||
);
|
||
container = ProviderContainer(
|
||
overrides: [pairingProvider.overrideWith(() => notifier)],
|
||
);
|
||
const code =
|
||
'nostrpair://62287897da61e3fa294b4570575f7db8bea147d6631150f2e4656714c645fb1e'
|
||
'?secret=abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'
|
||
'&relay=wss%3A%2F%2Fpairing.buzz.xyz&v=1';
|
||
|
||
await container.read(pairingProvider.notifier).pair(code);
|
||
|
||
expect(container.read(pairingProvider).status, PairingStatus.error);
|
||
expect(
|
||
container.read(pairingProvider).errorMessage,
|
||
contains('Lost connection to pairing relay'),
|
||
);
|
||
},
|
||
);
|
||
|
||
test('payload missing nsec errors before contacting relay', () async {
|
||
container = createContainer();
|
||
|
||
// Valid payload shape but no nsec — provider should refuse without
|
||
// attempting any network call.
|
||
final code = _encodePairingCode();
|
||
await container.read(pairingProvider.notifier).pair(code);
|
||
|
||
final state = container.read(pairingProvider);
|
||
expect(state.status, PairingStatus.error);
|
||
expect(state.errorMessage, contains('missing nsec'));
|
||
expect(fakeAuth.lastCommunity, isNull);
|
||
});
|
||
|
||
test('accepts buzz scheme prefix', () async {
|
||
container = createContainer();
|
||
|
||
final code = 'buzz://${_encodePairingCode()}';
|
||
await container.read(pairingProvider.notifier).pair(code);
|
||
|
||
final state = container.read(pairingProvider);
|
||
expect(state.status, PairingStatus.error);
|
||
expect(state.errorMessage, contains('missing nsec'));
|
||
expect(fakeAuth.lastCommunity, isNull);
|
||
});
|
||
|
||
test('invalid base64 sets format error', () async {
|
||
container = createContainer();
|
||
|
||
await container.read(pairingProvider.notifier).pair('not-valid!!!');
|
||
|
||
final state = container.read(pairingProvider);
|
||
expect(state.status, PairingStatus.error);
|
||
expect(state.errorMessage, contains('Invalid pairing code'));
|
||
});
|
||
|
||
test('base64 with valid JSON but missing fields errors', () async {
|
||
container = createContainer();
|
||
|
||
final code = base64Url.encode(utf8.encode(jsonEncode({'foo': 'bar'})));
|
||
await container.read(pairingProvider.notifier).pair(code);
|
||
|
||
final state = container.read(pairingProvider);
|
||
expect(state.status, PairingStatus.error);
|
||
expect(state.errorMessage, contains('Missing relayUrl'));
|
||
});
|
||
|
||
test('empty input errors', () async {
|
||
container = createContainer();
|
||
|
||
await container.read(pairingProvider.notifier).pair('');
|
||
|
||
final state = container.read(pairingProvider);
|
||
expect(state.status, PairingStatus.error);
|
||
});
|
||
|
||
test('rejects private IP relay URLs (SSRF)', () async {
|
||
container = createContainer();
|
||
|
||
for (final ip in [
|
||
'10.0.0.1',
|
||
'172.16.0.1',
|
||
'192.168.1.1',
|
||
'169.254.169.254',
|
||
]) {
|
||
final code = _encodePairingCode(relayUrl: 'http://$ip:3000');
|
||
await container.read(pairingProvider.notifier).pair(code);
|
||
final state = container.read(pairingProvider);
|
||
expect(state.status, PairingStatus.error, reason: 'should reject $ip');
|
||
expect(state.errorMessage, contains('private network'));
|
||
container.read(pairingProvider.notifier).reset();
|
||
}
|
||
});
|
||
|
||
test('rejects non-http/https schemes', () async {
|
||
container = createContainer();
|
||
|
||
final code = _encodePairingCode(relayUrl: 'file:///etc/passwd');
|
||
await container.read(pairingProvider.notifier).pair(code);
|
||
|
||
final state = container.read(pairingProvider);
|
||
expect(state.status, PairingStatus.error);
|
||
expect(state.errorMessage, contains('Invalid pairing code'));
|
||
});
|
||
|
||
test('rejects JSON array payload', () async {
|
||
container = createContainer();
|
||
|
||
final code = base64Url.encode(utf8.encode(jsonEncode([1, 2, 3])));
|
||
await container.read(pairingProvider.notifier).pair(code);
|
||
|
||
final state = container.read(pairingProvider);
|
||
expect(state.status, PairingStatus.error);
|
||
expect(state.errorMessage, contains('not a JSON object'));
|
||
});
|
||
|
||
test('reset returns to idle from error state', () async {
|
||
container = createContainer();
|
||
|
||
// Trigger an error.
|
||
await container.read(pairingProvider.notifier).pair('not-valid!!!');
|
||
expect(container.read(pairingProvider).status, PairingStatus.error);
|
||
|
||
container.read(pairingProvider.notifier).reset();
|
||
expect(container.read(pairingProvider).status, PairingStatus.idle);
|
||
});
|
||
|
||
group('identity import protection', () {
|
||
const sourceSecret =
|
||
'09b3065e3570a3a4054660dccd66e12774a99a904fdb0ca02dbc6c3136249506';
|
||
const sessionSecretHex =
|
||
'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789';
|
||
late ControllablePairingSocket socket;
|
||
late PairingNotifier notifier;
|
||
late FakeAuthNotifier importAuth;
|
||
late _FakeSensitiveActionAuthorizer authorizer;
|
||
late Completer<void> validation;
|
||
late String pairingCode;
|
||
|
||
setUp(() {
|
||
final source = nostr.Keys(sourceSecret);
|
||
pairingCode =
|
||
'nostrpair://${source.public}'
|
||
'?secret=$sessionSecretHex'
|
||
'&relay=wss%3A%2F%2Fpairing.buzz.xyz&v=1';
|
||
validation = Completer<void>();
|
||
importAuth = FakeAuthNotifier();
|
||
authorizer = _FakeSensitiveActionAuthorizer();
|
||
notifier = PairingNotifier(
|
||
credentialValidator:
|
||
({required String relayUrl, required String? nsec}) =>
|
||
validation.future,
|
||
socketFactory:
|
||
({
|
||
required wsUrl,
|
||
required ephemeralPrivkey,
|
||
required onMessage,
|
||
required onDisconnected,
|
||
}) {
|
||
socket = ControllablePairingSocket(
|
||
ephemeralPrivkey: ephemeralPrivkey,
|
||
onMessage: onMessage,
|
||
onDisconnected: onDisconnected,
|
||
);
|
||
return socket;
|
||
},
|
||
);
|
||
container = ProviderContainer(
|
||
overrides: [
|
||
pairingProvider.overrideWith(() => notifier),
|
||
authProvider.overrideWith(() => importAuth),
|
||
sensitiveActionAuthorizerProvider.overrideWithValue(authorizer),
|
||
],
|
||
);
|
||
container.read(pairingProvider);
|
||
notifier = container.read(pairingProvider.notifier);
|
||
});
|
||
|
||
Future<void> beginImport({required bool protected}) async {
|
||
await notifier.pair(pairingCode);
|
||
notifier.setProtectSensitiveActions(protected);
|
||
notifier.confirmSas();
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'sas-confirm'},
|
||
includeTranscriptHash: true,
|
||
);
|
||
await Future<void>.delayed(Duration.zero);
|
||
if (protected && authorizer.result != DeviceAuthResult.success) return;
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {
|
||
'type': 'payload',
|
||
'payload_type': 'credentials',
|
||
'payload': jsonEncode({
|
||
'relayUrl': 'https://relay.test',
|
||
'pubkey': nostr.Keys(sourceSecret).public,
|
||
'nsec': nostr.Keys(sourceSecret).nsec,
|
||
}),
|
||
},
|
||
);
|
||
await Future<void>.delayed(Duration.zero);
|
||
expect(container.read(pairingProvider).status, PairingStatus.storing);
|
||
}
|
||
|
||
testWidgets('early input is submitted when desktop negotiation arrives', (
|
||
tester,
|
||
) async {
|
||
final pairing = notifier.pair(pairingCode);
|
||
await tester.pump();
|
||
await tester.pump(const Duration(milliseconds: 500));
|
||
await pairing;
|
||
await tester.pumpWidget(
|
||
UncontrolledProviderScope(
|
||
container: container,
|
||
child: MaterialApp(
|
||
theme: AppTheme.light(),
|
||
home: const PairingPage(),
|
||
),
|
||
),
|
||
);
|
||
final localCode = container.read(pairingProvider).sasCode;
|
||
final code = localCode == '111111' ? '222222' : '111111';
|
||
await tester.enterText(
|
||
find.byKey(const Key('pairing-code-input')),
|
||
code,
|
||
);
|
||
await tester.pump();
|
||
expect(
|
||
socket
|
||
.decryptedPublishedMessages(sourceSecret)
|
||
.where((m) => m['type'] == 'code-submit'),
|
||
isEmpty,
|
||
);
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'desktop-code'},
|
||
);
|
||
await tester.pump();
|
||
await tester.pump();
|
||
final submissions = socket
|
||
.decryptedPublishedMessages(sourceSecret)
|
||
.where((m) => m['type'] == 'code-submit')
|
||
.toList();
|
||
expect(submissions, hasLength(1));
|
||
expect(submissions.single['code'], code);
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'sas-confirm'},
|
||
includeTranscriptHash: true,
|
||
);
|
||
await tester.pump();
|
||
expect(find.text('Enter pairing code'), findsNothing);
|
||
expect(find.text('Protect your identity'), findsOneWidget);
|
||
notifier.reset();
|
||
await tester.pumpWidget(const SizedBox.shrink());
|
||
});
|
||
|
||
testWidgets(
|
||
'delayed desktop responses retain one attempt and leave code entry',
|
||
(tester) async {
|
||
final pairing = notifier.pair(pairingCode);
|
||
await tester.pump();
|
||
await tester.pump(const Duration(milliseconds: 500));
|
||
await pairing;
|
||
expect(socket.published.single['tags'], hasLength(1));
|
||
expect(
|
||
socket
|
||
.decryptedPublishedMessages(sourceSecret)
|
||
.single['confirmation'],
|
||
'desktop-code-v1',
|
||
);
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'desktop-code'},
|
||
);
|
||
expect(container.read(pairingProvider).requiresDesktopCode, isTrue);
|
||
final wrong = notifier.verifyDesktopCode('111111');
|
||
final first = socket.decryptedPublishedMessages(sourceSecret).last;
|
||
expect(first['type'], 'code-submit');
|
||
expect(first['code'], '111111');
|
||
bool? wrongResult;
|
||
unawaited(wrong.then((value) => wrongResult = value));
|
||
await tester.pump(const Duration(seconds: 11));
|
||
expect(wrongResult, isNull);
|
||
expect(await notifier.verifyDesktopCode('111111'), isFalse);
|
||
expect(
|
||
socket
|
||
.decryptedPublishedMessages(sourceSecret)
|
||
.where((m) => m['type'] == 'code-submit'),
|
||
hasLength(1),
|
||
);
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {
|
||
'type': 'code-rejected',
|
||
'request_id': first['request_id'],
|
||
'remaining_attempts': 4,
|
||
},
|
||
);
|
||
expect(await wrong, isFalse);
|
||
expect(importAuth.lastCommunity, isNull);
|
||
await tester.pumpWidget(
|
||
UncontrolledProviderScope(
|
||
container: container,
|
||
child: MaterialApp(
|
||
theme: AppTheme.light(),
|
||
home: const PairingPage(),
|
||
),
|
||
),
|
||
);
|
||
await tester.enterText(
|
||
find.byKey(const Key('pairing-code-input')),
|
||
'222222',
|
||
);
|
||
await tester.pump(const Duration(seconds: 11));
|
||
expect(find.text('Enter pairing code'), findsOneWidget);
|
||
expect(
|
||
find.text('Couldn’t check the code. Try again.'),
|
||
findsNothing,
|
||
);
|
||
expect(
|
||
socket
|
||
.decryptedPublishedMessages(sourceSecret)
|
||
.where((m) => m['type'] == 'code-submit'),
|
||
hasLength(2),
|
||
);
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'sas-confirm'},
|
||
includeTranscriptHash: true,
|
||
);
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {
|
||
'type': 'payload',
|
||
'payload_type': 'credentials',
|
||
'payload': jsonEncode({
|
||
'relayUrl': 'https://relay.test',
|
||
'pubkey': nostr.Keys(sourceSecret).public,
|
||
'nsec': nostr.Keys(sourceSecret).nsec,
|
||
}),
|
||
},
|
||
);
|
||
await tester.pump();
|
||
expect(find.text('Enter pairing code'), findsNothing);
|
||
expect(find.text('Protect your identity'), findsOneWidget);
|
||
expect(
|
||
container.read(pairingProvider).status,
|
||
PairingStatus.confirmingSas,
|
||
);
|
||
expect(importAuth.lastCommunity, isNull);
|
||
notifier.setProtectSensitiveActions(false);
|
||
notifier.confirmSas();
|
||
await tester.pump();
|
||
expect(container.read(pairingProvider).status, PairingStatus.storing);
|
||
expect(
|
||
socket
|
||
.decryptedPublishedMessages(sourceSecret)
|
||
.where((m) => m['type'] == 'sas-confirm'),
|
||
isEmpty,
|
||
);
|
||
notifier.reset();
|
||
await tester.pumpWidget(const SizedBox.shrink());
|
||
},
|
||
);
|
||
|
||
testWidgets(
|
||
'delayed final rejection exits the session and reset cancels pending verification',
|
||
(tester) async {
|
||
final pairing = notifier.pair(pairingCode);
|
||
await tester.pump();
|
||
await tester.pump(const Duration(milliseconds: 500));
|
||
await pairing;
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'desktop-code'},
|
||
);
|
||
final result = notifier.verifyDesktopCode('111111');
|
||
final request = socket.decryptedPublishedMessages(sourceSecret).last;
|
||
await tester.pump(const Duration(seconds: 11));
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {
|
||
'type': 'code-rejected',
|
||
'request_id': request['request_id'],
|
||
'remaining_attempts': 0,
|
||
},
|
||
);
|
||
expect(await result, isFalse);
|
||
expect(container.read(pairingProvider).status, PairingStatus.error);
|
||
expect(importAuth.lastCommunity, isNull);
|
||
notifier.reset();
|
||
final nextPairing = notifier.pair(pairingCode);
|
||
await tester.pump();
|
||
await tester.pump(const Duration(milliseconds: 500));
|
||
await nextPairing;
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'desktop-code'},
|
||
);
|
||
final pending = notifier.verifyDesktopCode('222222');
|
||
notifier.reset();
|
||
expect(await pending, isFalse);
|
||
expect(container.read(pairingProvider).status, PairingStatus.idle);
|
||
},
|
||
);
|
||
|
||
test(
|
||
'a rejected offer exits code entry instead of silently waiting',
|
||
() async {
|
||
await notifier.pair(pairingCode);
|
||
socket.relayMessageCallback([
|
||
'OK',
|
||
socket.published.single['id'],
|
||
false,
|
||
'event must have exactly one p tag',
|
||
]);
|
||
expect(container.read(pairingProvider).status, PairingStatus.error);
|
||
expect(
|
||
container.read(pairingProvider).errorMessage,
|
||
contains('Scan a new desktop QR code'),
|
||
);
|
||
expect(socket.isConnected, isFalse);
|
||
},
|
||
);
|
||
|
||
test('admission rejection never reports a successful transfer', () async {
|
||
importAuth.authentication = Completer<void>();
|
||
importAuth.entered = Completer<void>();
|
||
final finished = Completer<void>();
|
||
container.listen(pairingProvider, (_, next) {
|
||
if (next.status == PairingStatus.error && !finished.isCompleted) {
|
||
finished.complete();
|
||
}
|
||
});
|
||
await beginImport(protected: false);
|
||
validation.complete();
|
||
await importAuth.entered!.future;
|
||
expect(
|
||
socket
|
||
.decryptedPublishedMessages(sourceSecret)
|
||
.where((message) => message['type'] == 'complete'),
|
||
isEmpty,
|
||
);
|
||
importAuth.authentication!.completeError(
|
||
StateError('Admission refused'),
|
||
);
|
||
await finished.future;
|
||
final completions = socket
|
||
.decryptedPublishedMessages(sourceSecret)
|
||
.where((message) => message['type'] == 'complete')
|
||
.toList();
|
||
expect(completions, hasLength(1));
|
||
expect(completions.single['success'], isFalse);
|
||
expect(container.read(pairedCommunityLandingProvider), isNull);
|
||
});
|
||
|
||
test('unchecked protection persists on a successful import', () async {
|
||
await beginImport(protected: false);
|
||
|
||
validation.complete();
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
expect(
|
||
importAuth.lastCommunity?.sensitiveActionPolicy,
|
||
SensitiveActionPolicy.disabledByUser,
|
||
);
|
||
expect(container.read(pairingProvider).status, PairingStatus.success);
|
||
expect(
|
||
container.read(pairedCommunityLandingProvider),
|
||
importAuth.lastCommunity,
|
||
);
|
||
});
|
||
|
||
test('checked protection persists on a successful import', () async {
|
||
await beginImport(protected: true);
|
||
|
||
expect(authorizer.calls, 1);
|
||
validation.complete();
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
expect(
|
||
importAuth.lastCommunity?.sensitiveActionPolicy,
|
||
SensitiveActionPolicy.enabled,
|
||
);
|
||
});
|
||
|
||
test('unenrolled biometrics prevent a protected import', () async {
|
||
authorizer.result = DeviceAuthResult.unavailable;
|
||
|
||
await beginImport(protected: true);
|
||
|
||
final state = container.read(pairingProvider);
|
||
expect(authorizer.calls, 1);
|
||
expect(state.status, PairingStatus.confirmingSas);
|
||
expect(state.userConfirmedSas, isFalse);
|
||
expect(state.errorMessage, contains('Enroll Face ID or biometrics'));
|
||
expect(importAuth.lastCommunity, isNull);
|
||
expect(
|
||
socket
|
||
.decryptedPublishedMessages(sourceSecret)
|
||
.any((message) => message['type'] == 'complete'),
|
||
isFalse,
|
||
);
|
||
});
|
||
|
||
test('reset during NIP-AB connection retires its continuation', () async {
|
||
final pending = notifier.pair(pairingCode);
|
||
notifier.reset();
|
||
await pending;
|
||
expect(container.read(pairingProvider).status, PairingStatus.idle);
|
||
expect(socket.isConnected, isFalse);
|
||
expect(socket.decryptedPublishedMessages(sourceSecret), isEmpty);
|
||
});
|
||
|
||
test('reset during NIP-AB offer delay prevents a late offer', () async {
|
||
final pending = notifier.pair(pairingCode);
|
||
await Future<void>.delayed(Duration.zero);
|
||
notifier.reset();
|
||
await pending;
|
||
expect(container.read(pairingProvider).status, PairingStatus.idle);
|
||
expect(socket.isConnected, isFalse);
|
||
expect(socket.decryptedPublishedMessages(sourceSecret), isEmpty);
|
||
});
|
||
|
||
test('stale biometric approval cannot advance a reset import', () async {
|
||
final pendingAuthorization = Completer<DeviceAuthResult>();
|
||
authorizer.pending = pendingAuthorization;
|
||
await notifier.pair(pairingCode);
|
||
notifier.setProtectSensitiveActions(true);
|
||
notifier.confirmSas();
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'sas-confirm'},
|
||
includeTranscriptHash: true,
|
||
);
|
||
await Future<void>.delayed(Duration.zero);
|
||
expect(authorizer.calls, 1);
|
||
|
||
notifier.reset();
|
||
pendingAuthorization.complete(DeviceAuthResult.success);
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
expect(container.read(pairingProvider).status, PairingStatus.idle);
|
||
expect(importAuth.lastCommunity, isNull);
|
||
expect(
|
||
socket
|
||
.decryptedPublishedMessages(sourceSecret)
|
||
.any((message) => message['type'] == 'complete'),
|
||
isFalse,
|
||
);
|
||
});
|
||
|
||
test('reset invalidates pending authentication persistence', () async {
|
||
importAuth.authentication = Completer<void>();
|
||
await beginImport(protected: false);
|
||
|
||
validation.complete();
|
||
await Future<void>.delayed(Duration.zero);
|
||
expect(importAuth.lastCommunity, isNotNull);
|
||
|
||
notifier.reset();
|
||
importAuth.authentication!.complete();
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
expect(container.read(pairingProvider).status, PairingStatus.idle);
|
||
expect(
|
||
container.read(authProvider).value!.status,
|
||
AuthStatus.unauthenticated,
|
||
);
|
||
expect(socket.isConnected, isFalse);
|
||
});
|
||
|
||
test('reset invalidates pending credential validation', () async {
|
||
await beginImport(protected: false);
|
||
|
||
notifier.reset();
|
||
validation.complete();
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
expect(importAuth.lastCommunity, isNull);
|
||
expect(container.read(pairingProvider).status, PairingStatus.idle);
|
||
expect(
|
||
socket
|
||
.decryptedPublishedMessages(sourceSecret)
|
||
.any((message) => message['type'] == 'complete'),
|
||
isFalse,
|
||
);
|
||
});
|
||
});
|
||
|
||
group('desktop identity recovery', () {
|
||
const sourceSecret =
|
||
'09b3065e3570a3a4054660dccd66e12774a99a904fdb0ca02dbc6c3136249506';
|
||
const sessionSecretHex =
|
||
'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789';
|
||
late ControllablePairingSocket socket;
|
||
late PairingNotifier notifier;
|
||
late String recoveryCode;
|
||
late _FakeSensitiveActionAuthorizer authorizer;
|
||
late DateTime now;
|
||
|
||
setUp(() async {
|
||
final source = nostr.Keys(sourceSecret);
|
||
recoveryCode =
|
||
'nostrpair://${source.public}'
|
||
'?secret=$sessionSecretHex'
|
||
'&relay=wss%3A%2F%2Fpairing.buzz.xyz&v=1&mode=recover';
|
||
authorizer = _FakeSensitiveActionAuthorizer();
|
||
now = DateTime.utc(2026, 8, 6);
|
||
notifier = PairingNotifier(
|
||
socketFactory:
|
||
({
|
||
required wsUrl,
|
||
required ephemeralPrivkey,
|
||
required onMessage,
|
||
required onDisconnected,
|
||
}) {
|
||
socket = ControllablePairingSocket(
|
||
ephemeralPrivkey: ephemeralPrivkey,
|
||
onMessage: onMessage,
|
||
onDisconnected: onDisconnected,
|
||
);
|
||
return socket;
|
||
},
|
||
);
|
||
container = ProviderContainer(
|
||
overrides: [
|
||
pairingProvider.overrideWith(() => notifier),
|
||
relayConfigProvider.overrideWith(_RecoveryRelayConfig.new),
|
||
sensitiveActionAuthorizerProvider.overrideWithValue(authorizer),
|
||
identityExportClockProvider.overrideWithValue(() => now),
|
||
],
|
||
);
|
||
container.read(pairingProvider);
|
||
notifier = container.read(pairingProvider.notifier);
|
||
});
|
||
|
||
test('recovery authorization honors biometric protection', () async {
|
||
expect(
|
||
await notifier.authorizeIdentityExport(
|
||
community: _exportCommunity(SensitiveActionPolicy.enabled),
|
||
),
|
||
isTrue,
|
||
);
|
||
expect(authorizer.calls, 1);
|
||
expect(authorizer.lastIdentityBiometricOnly, isTrue);
|
||
|
||
await notifier.pair(recoveryCode);
|
||
|
||
final state = container.read(pairingProvider);
|
||
expect(state.status, PairingStatus.confirmingSas);
|
||
expect(state.sendsIdentityToDesktop, isTrue);
|
||
expect(
|
||
socket.published.first['tags'],
|
||
isNot(contains(equals(['confirmation', 'code-entry']))),
|
||
);
|
||
notifier.confirmSas();
|
||
expect(
|
||
socket.decryptedPublishedMessages(sourceSecret).map((m) => m['type']),
|
||
['offer'],
|
||
);
|
||
expect(state.sasCode, hasLength(6));
|
||
});
|
||
|
||
test('reset invalidates pending preflight authorization', () async {
|
||
final pendingAuthorization = Completer<DeviceAuthResult>();
|
||
authorizer.pending = pendingAuthorization;
|
||
|
||
final authorization = notifier.authorizeIdentityExport(
|
||
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
|
||
);
|
||
expect(container.read(pairingProvider).authorizationInProgress, isTrue);
|
||
|
||
notifier.reset();
|
||
pendingAuthorization.complete(DeviceAuthResult.success);
|
||
|
||
expect(await authorization, isFalse);
|
||
expect(container.read(pairingProvider).status, PairingStatus.idle);
|
||
await notifier.pair(recoveryCode);
|
||
notifier.confirmSas();
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'sas-confirm'},
|
||
includeTranscriptHash: true,
|
||
);
|
||
await Future<void>.delayed(Duration.zero);
|
||
expect(
|
||
socket
|
||
.decryptedPublishedMessages(sourceSecret)
|
||
.any((message) => message['type'] == 'payload'),
|
||
isFalse,
|
||
);
|
||
});
|
||
|
||
test('concurrent preflight requests start one authentication', () async {
|
||
final pendingAuthorization = Completer<DeviceAuthResult>();
|
||
authorizer.pending = pendingAuthorization;
|
||
final community = _exportCommunity(
|
||
SensitiveActionPolicy.disabledByUser,
|
||
);
|
||
|
||
final first = notifier.authorizeIdentityExport(community: community);
|
||
final second = notifier.authorizeIdentityExport(community: community);
|
||
|
||
expect(await second, isFalse);
|
||
expect(authorizer.calls, 1);
|
||
pendingAuthorization.complete(DeviceAuthResult.success);
|
||
expect(await first, isTrue);
|
||
});
|
||
|
||
test('unchecked protection allows device passcode fallback', () async {
|
||
expect(
|
||
await notifier.authorizeIdentityExport(
|
||
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
|
||
),
|
||
isTrue,
|
||
);
|
||
|
||
expect(authorizer.lastIdentityBiometricOnly, isFalse);
|
||
});
|
||
|
||
for (final padded in [false, true]) {
|
||
test(
|
||
'matching SAS exports normalized nsec (padded: $padded)',
|
||
() async {
|
||
final community =
|
||
_exportCommunity(SensitiveActionPolicy.disabledByUser).copyWith(
|
||
nsec: padded
|
||
? ' \n${_RecoveryRelayConfig.nsec}\t '
|
||
: _RecoveryRelayConfig.nsec,
|
||
);
|
||
container
|
||
.read(relayConfigProvider.notifier)
|
||
.update(baseUrl: community.relayUrl, nsec: community.nsec);
|
||
expect(
|
||
await notifier.authorizeIdentityExport(community: community),
|
||
isTrue,
|
||
);
|
||
await notifier.pair(recoveryCode);
|
||
notifier.confirmSas();
|
||
expect(container.read(pairingProvider).userConfirmedSas, isTrue);
|
||
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'sas-confirm'},
|
||
includeTranscriptHash: true,
|
||
);
|
||
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
expect(
|
||
container.read(pairingProvider).status,
|
||
PairingStatus.transferring,
|
||
);
|
||
expect(container.read(pairingProvider).errorMessage, isNull);
|
||
expect(authorizer.calls, 1);
|
||
final sentMessages = socket.decryptedPublishedMessages(
|
||
sourceSecret,
|
||
);
|
||
final payloads = sentMessages.where(
|
||
(message) => message['type'] == 'payload',
|
||
);
|
||
expect(payloads, hasLength(1));
|
||
expect(payloads.single['payload_type'], 'nsec');
|
||
expect(payloads.single['payload'], _RecoveryRelayConfig.nsec);
|
||
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'complete', 'success': true},
|
||
);
|
||
expect(
|
||
container.read(pairingProvider).status,
|
||
PairingStatus.success,
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
test(
|
||
'malformed payload invalidates authorization before another session',
|
||
() async {
|
||
expect(
|
||
await notifier.authorizeIdentityExport(
|
||
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
|
||
),
|
||
isTrue,
|
||
);
|
||
await notifier.pair(recoveryCode);
|
||
notifier.confirmSas();
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'sas-confirm'},
|
||
includeTranscriptHash: true,
|
||
);
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'payload', 'payload_type': 'nsec'},
|
||
);
|
||
expect(container.read(pairingProvider).status, PairingStatus.error);
|
||
|
||
await notifier.pair(recoveryCode);
|
||
final replacementSocket = socket;
|
||
notifier.confirmSas();
|
||
replacementSocket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'sas-confirm'},
|
||
includeTranscriptHash: true,
|
||
);
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
final state = container.read(pairingProvider);
|
||
expect(state.status, PairingStatus.confirmingSas);
|
||
expect(state.userConfirmedSas, isFalse);
|
||
expect(state.errorMessage, contains('active community changed'));
|
||
expect(authorizer.calls, 1);
|
||
expect(
|
||
replacementSocket
|
||
.decryptedPublishedMessages(sourceSecret)
|
||
.any((message) => message['type'] == 'payload'),
|
||
isFalse,
|
||
);
|
||
},
|
||
);
|
||
|
||
test('skipped preflight authorization sends no identity', () async {
|
||
await notifier.pair(recoveryCode);
|
||
notifier.confirmSas();
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'sas-confirm'},
|
||
includeTranscriptHash: true,
|
||
);
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
final state = container.read(pairingProvider);
|
||
expect(authorizer.calls, 0);
|
||
expect(state.status, PairingStatus.confirmingSas);
|
||
expect(state.userConfirmedSas, isFalse);
|
||
expect(state.errorMessage, contains('active community changed'));
|
||
expect(
|
||
socket
|
||
.decryptedPublishedMessages(sourceSecret)
|
||
.any((message) => message['type'] == 'payload'),
|
||
isFalse,
|
||
);
|
||
});
|
||
|
||
test(
|
||
'failed transfer reauthorization publishes no identity payload',
|
||
() async {
|
||
expect(
|
||
await notifier.authorizeIdentityExport(
|
||
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
|
||
),
|
||
isTrue,
|
||
);
|
||
await notifier.pair(recoveryCode);
|
||
now = now.add(identityExportAuthorizationTtl);
|
||
authorizer.result = DeviceAuthResult.cancelled;
|
||
notifier.confirmSas();
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'sas-confirm'},
|
||
includeTranscriptHash: true,
|
||
);
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
expect(authorizer.calls, 2);
|
||
final state = container.read(pairingProvider);
|
||
expect(state.status, PairingStatus.confirmingSas);
|
||
expect(state.userConfirmedSas, isFalse);
|
||
expect(state.errorMessage, contains('cancelled'));
|
||
final sentMessages = socket.decryptedPublishedMessages(sourceSecret);
|
||
expect(
|
||
sentMessages.any((message) => message['type'] == 'payload'),
|
||
isFalse,
|
||
);
|
||
},
|
||
);
|
||
|
||
test(
|
||
'stale authorization cannot advance a replacement pairing session',
|
||
() async {
|
||
expect(
|
||
await notifier.authorizeIdentityExport(
|
||
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
|
||
),
|
||
isTrue,
|
||
);
|
||
await notifier.pair(recoveryCode);
|
||
now = now.add(identityExportAuthorizationTtl);
|
||
final firstSocket = socket;
|
||
final pendingAuthorization = Completer<DeviceAuthResult>();
|
||
authorizer.pending = pendingAuthorization;
|
||
notifier.confirmSas();
|
||
firstSocket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'sas-confirm'},
|
||
includeTranscriptHash: true,
|
||
);
|
||
await Future<void>.delayed(Duration.zero);
|
||
expect(authorizer.calls, 2);
|
||
|
||
notifier.reset();
|
||
await notifier.pair(recoveryCode);
|
||
final replacementSocket = socket;
|
||
expect(
|
||
container.read(pairingProvider).status,
|
||
PairingStatus.confirmingSas,
|
||
);
|
||
expect(container.read(pairingProvider).userConfirmedSas, isFalse);
|
||
|
||
pendingAuthorization.complete(DeviceAuthResult.success);
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
final state = container.read(pairingProvider);
|
||
expect(state.status, PairingStatus.confirmingSas);
|
||
expect(state.userConfirmedSas, isFalse);
|
||
for (final pairingSocket in [firstSocket, replacementSocket]) {
|
||
final sentMessages = pairingSocket.decryptedPublishedMessages(
|
||
sourceSecret,
|
||
);
|
||
expect(
|
||
sentMessages.any((message) => message['type'] == 'payload'),
|
||
isFalse,
|
||
);
|
||
}
|
||
},
|
||
);
|
||
|
||
test('clock rollback invalidates the export authorization', () async {
|
||
expect(
|
||
await notifier.authorizeIdentityExport(
|
||
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
|
||
),
|
||
isTrue,
|
||
);
|
||
await notifier.pair(recoveryCode);
|
||
now = now.subtract(const Duration(minutes: 1));
|
||
notifier.confirmSas();
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'sas-confirm'},
|
||
includeTranscriptHash: true,
|
||
);
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
expect(authorizer.calls, 2);
|
||
expect(
|
||
container.read(pairingProvider).status,
|
||
PairingStatus.transferring,
|
||
);
|
||
});
|
||
|
||
test(
|
||
'websocket relay origin remains bound after canonicalization',
|
||
() async {
|
||
final community = _exportCommunity(
|
||
SensitiveActionPolicy.disabledByUser,
|
||
).copyWith(relayUrl: 'wss://relay.test');
|
||
container
|
||
.read(relayConfigProvider.notifier)
|
||
.update(baseUrl: community.relayUrl, nsec: community.nsec);
|
||
expect(
|
||
await notifier.authorizeIdentityExport(community: community),
|
||
isTrue,
|
||
);
|
||
await notifier.pair(recoveryCode);
|
||
notifier.confirmSas();
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'sas-confirm'},
|
||
includeTranscriptHash: true,
|
||
);
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
expect(
|
||
container.read(pairingProvider).status,
|
||
PairingStatus.transferring,
|
||
);
|
||
expect(
|
||
socket
|
||
.decryptedPublishedMessages(sourceSecret)
|
||
.any(
|
||
(message) =>
|
||
message['type'] == 'payload' &&
|
||
message['payload'] == community.nsec,
|
||
),
|
||
isTrue,
|
||
);
|
||
},
|
||
);
|
||
|
||
for (final change in ['community', 'key']) {
|
||
for (final expired in [false, true]) {
|
||
test(
|
||
'$change switch aborts ${expired ? 'expired' : 'fresh'} export grant',
|
||
() async {
|
||
expect(
|
||
await notifier.authorizeIdentityExport(
|
||
community: _exportCommunity(
|
||
SensitiveActionPolicy.disabledByUser,
|
||
),
|
||
),
|
||
isTrue,
|
||
);
|
||
await notifier.pair(recoveryCode);
|
||
if (expired) now = now.add(identityExportAuthorizationTtl);
|
||
container
|
||
.read(relayConfigProvider.notifier)
|
||
.update(
|
||
baseUrl: change == 'community'
|
||
? 'https://other-relay.test'
|
||
: 'https://relay.test',
|
||
nsec: change == 'community'
|
||
? _RecoveryRelayConfig.nsec
|
||
: nostr.Keys(
|
||
'2222222222222222222222222222222222222222222222222222222222222222',
|
||
).nsec,
|
||
);
|
||
notifier.confirmSas();
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'sas-confirm'},
|
||
includeTranscriptHash: true,
|
||
);
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
final state = container.read(pairingProvider);
|
||
expect(state.status, PairingStatus.confirmingSas);
|
||
expect(state.userConfirmedSas, isFalse);
|
||
expect(state.errorMessage, contains('active community changed'));
|
||
expect(authorizer.calls, 1);
|
||
expect(
|
||
socket
|
||
.decryptedPublishedMessages(sourceSecret)
|
||
.any((message) => message['type'] == 'payload'),
|
||
isFalse,
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
test('expired biometric-only authorization preserves its mode', () async {
|
||
expect(
|
||
await notifier.authorizeIdentityExport(
|
||
community: _exportCommunity(SensitiveActionPolicy.enabled),
|
||
),
|
||
isTrue,
|
||
);
|
||
await notifier.pair(recoveryCode);
|
||
now = now.add(identityExportAuthorizationTtl);
|
||
notifier.confirmSas();
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'sas-confirm'},
|
||
includeTranscriptHash: true,
|
||
);
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
expect(authorizer.calls, 2);
|
||
expect(authorizer.lastIdentityBiometricOnly, isTrue);
|
||
expect(
|
||
container.read(pairingProvider).status,
|
||
PairingStatus.transferring,
|
||
);
|
||
});
|
||
|
||
test(
|
||
'expired passcode-fallback authorization preserves its mode',
|
||
() async {
|
||
expect(
|
||
await notifier.authorizeIdentityExport(
|
||
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
|
||
),
|
||
isTrue,
|
||
);
|
||
await notifier.pair(recoveryCode);
|
||
now = now.add(identityExportAuthorizationTtl);
|
||
notifier.confirmSas();
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'sas-confirm'},
|
||
includeTranscriptHash: true,
|
||
);
|
||
await Future<void>.delayed(Duration.zero);
|
||
|
||
expect(authorizer.calls, 2);
|
||
expect(authorizer.lastIdentityBiometricOnly, isFalse);
|
||
expect(
|
||
container.read(pairingProvider).status,
|
||
PairingStatus.transferring,
|
||
);
|
||
},
|
||
);
|
||
|
||
test('cancelled authorization prevents pairing from starting', () async {
|
||
authorizer.result = DeviceAuthResult.cancelled;
|
||
|
||
expect(
|
||
await notifier.authorizeIdentityExport(
|
||
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
|
||
),
|
||
isFalse,
|
||
);
|
||
|
||
expect(authorizer.calls, 1);
|
||
expect(container.read(pairingProvider).status, PairingStatus.idle);
|
||
expect(
|
||
container.read(pairingProvider).errorMessage,
|
||
contains('cancelled'),
|
||
);
|
||
});
|
||
|
||
test('desktop storage failure surfaces an error', () async {
|
||
expect(
|
||
await notifier.authorizeIdentityExport(
|
||
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
|
||
),
|
||
isTrue,
|
||
);
|
||
await notifier.pair(recoveryCode);
|
||
notifier.confirmSas();
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'sas-confirm'},
|
||
includeTranscriptHash: true,
|
||
);
|
||
await Future<void>.delayed(Duration.zero);
|
||
socket.sendSourceMessage(
|
||
sourceSecret: sourceSecret,
|
||
sessionSecretHex: sessionSecretHex,
|
||
message: {'type': 'complete', 'success': false},
|
||
);
|
||
|
||
final state = container.read(pairingProvider);
|
||
expect(state.status, PairingStatus.error);
|
||
expect(state.errorMessage, contains('could not store'));
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
/// Encode a credentials payload the same way the desktop app would.
|
||
String _encodePairingCode({
|
||
String relayUrl = 'http://test:3000',
|
||
String? pubkey,
|
||
String? nsec,
|
||
}) {
|
||
final json = <String, dynamic>{
|
||
'relayUrl': relayUrl,
|
||
// ignore: use_null_aware_elements
|
||
if (pubkey != null) 'pubkey': pubkey,
|
||
// ignore: use_null_aware_elements
|
||
if (nsec != null) 'nsec': nsec,
|
||
};
|
||
return base64Url.encode(utf8.encode(jsonEncode(json)));
|
||
}
|
||
|
||
/// A fake [AuthNotifier] that records calls instead of touching secure storage.
|
||
class FakeAuthNotifier extends AsyncNotifier<AuthState>
|
||
implements AuthNotifier {
|
||
Community? lastCommunity;
|
||
Completer<void>? authentication;
|
||
Completer<void>? entered;
|
||
|
||
@override
|
||
Future<AuthState> build() async =>
|
||
const AuthState(status: AuthStatus.unauthenticated);
|
||
|
||
@override
|
||
Future<void> authenticateWithCommunity(
|
||
Community community, {
|
||
bool Function()? shouldContinue,
|
||
bool activate = true,
|
||
}) async {
|
||
if (shouldContinue != null && !shouldContinue()) return;
|
||
lastCommunity = community;
|
||
entered?.complete();
|
||
await authentication?.future;
|
||
if (shouldContinue != null && !shouldContinue()) return;
|
||
state = AsyncData(
|
||
AuthState(status: AuthStatus.authenticated, community: community),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DisconnectingSocket extends PairingSocket {
|
||
final void Function(Object? error) disconnectCallback;
|
||
|
||
_DisconnectingSocket({required this.disconnectCallback})
|
||
: super(
|
||
wsUrl: 'ws://unused',
|
||
ephemeralPrivkey:
|
||
'09b3065e3570a3a4054660dccd66e12774a99a904fdb0ca02dbc6c3136249506',
|
||
onMessage: (_) {},
|
||
onDisconnected: (_) {},
|
||
);
|
||
|
||
@override
|
||
Future<void> connect() async {
|
||
disconnectCallback(Exception('Connection closed'));
|
||
}
|
||
}
|
||
|
||
Community _exportCommunity(SensitiveActionPolicy policy) => Community(
|
||
id: 'export-community',
|
||
name: 'Export',
|
||
relayUrl: 'https://relay.test',
|
||
nsec: _RecoveryRelayConfig.nsec,
|
||
sensitiveActionPolicy: policy,
|
||
addedAt: DateTime.utc(2026),
|
||
);
|
||
|
||
class _RecoveryRelayConfig extends RelayConfigNotifier {
|
||
static final nsec = nostr.Keys(
|
||
'1111111111111111111111111111111111111111111111111111111111111111',
|
||
).nsec;
|
||
|
||
@override
|
||
RelayConfig build() => RelayConfig(baseUrl: 'https://relay.test', nsec: nsec);
|
||
}
|
||
|
||
class _FakeSensitiveActionAuthorizer implements SensitiveActionAuthorizer {
|
||
DeviceAuthResult result = DeviceAuthResult.success;
|
||
Completer<DeviceAuthResult>? pending;
|
||
int calls = 0;
|
||
bool? lastIdentityBiometricOnly;
|
||
|
||
@override
|
||
Future<DeviceAuthResult> authorizeIdentityAction({
|
||
required bool biometricOnly,
|
||
}) async {
|
||
calls++;
|
||
lastIdentityBiometricOnly = biometricOnly;
|
||
return pending?.future ?? result;
|
||
}
|
||
|
||
@override
|
||
Future<DeviceAuthResult> authorizeBiometricProtection() async {
|
||
calls++;
|
||
return pending?.future ?? result;
|
||
}
|
||
|
||
@override
|
||
Future<List<BiometricType>> enrolledBiometrics() async => const [];
|
||
}
|
||
|
||
class _PendingValidationSocket extends RelaySocket {
|
||
_PendingValidationSocket()
|
||
: super(
|
||
wsUrl: 'wss://relay.example',
|
||
nsec: null,
|
||
onMessage: (_) {},
|
||
onConnected: () {},
|
||
onDisconnected: (_) {},
|
||
);
|
||
final connection = Completer<void>();
|
||
bool disposed = false;
|
||
@override
|
||
Future<void> connect() => connection.future;
|
||
@override
|
||
void dispose() => disposed = true;
|
||
@override
|
||
Future<void> disconnect() async => disposed = true;
|
||
}
|
||
|
||
class _ValidationOutcomeSocket extends RelaySocket {
|
||
_ValidationOutcomeSocket(this.connected)
|
||
: super(
|
||
wsUrl: 'wss://relay.example',
|
||
nsec: null,
|
||
onMessage: (_) {},
|
||
onConnected: () {},
|
||
onDisconnected: (_) {},
|
||
);
|
||
final bool Function() connected;
|
||
@override
|
||
SocketState get state =>
|
||
connected() ? SocketState.connected : SocketState.disconnected;
|
||
@override
|
||
Future<void> connect() async {}
|
||
@override
|
||
Future<void> disconnect() async {}
|
||
}
|