mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
fix(mobile): retain invite keys and reject incomplete onboarding (#8753)
Self-hosted invite claims retain the generated identity in secure storage before the HTTP request and reuse it after a lost response or restart. Saving or reopening an existing destination retires the matching journal. Sign-out/removal deletes all matching pending key copies before removing the saved community, preserving the community for retry if cleanup fails. Pairing verifies that relay authentication completed before saving/activating a destination or reporting success. On iOS 16, hosted entry permits recovery and reuse of backed-up identities, including binding a recovered identity with a confirmed backup receipt. Paths requiring a new secure backup explain the iOS 17 requirement before generating or binding a new key. Self-hosted entry remains available. ### Validation - Initial focused entry/recovery files: 83 passed, 1 skipped. Review-fix focused storage, community, invite and hosted files: 82 passed, 1 skipped; extended invite journey: 17 passed, 1 skipped. - Final hosted journey extension: 20 passed, including both bound and unbound recovery with native sealing unavailable. - Isolated mutations removing invite retention, pairing validation, journal removal/re-entry cleanup or restoring the blanket platform gate fail their intended regressions. Restored controls pass. - iOS simulator build passed on the original integration base; final native/device acceptance remains outstanding. - Carl and three specialist reviewers found no source-review blockers at `323cec6ea2`. `a55a246857` adds analyzer fixes, copy cleanup and the recovered-unbound journey coverage. Reviewers did not run tests or the app. - Fixed a push-hook timeout in `4564b3562a`: idle serial queues now release completed futures, avoiding scheduler retention across widget tests while preserving in-flight ordering and failure isolation. The existing dispatcher journey fails promptly before the fix and passes afterward; Carl reviewed this delta with no blockers. Focused dispatcher/invite/storage checks passed, and all 20 hosted journeys passed with hosted auth enabled. - Mandatory pre-push checks passed at `4564b3562a` (scope, branch skew, file size, mobile analysis/tests and crypto package checks); GitHub head verified at that commit. ### Remaining acceptance Draft pending agent/device and human acceptance. Exercise interrupted one-use invite claim and retry across restart, sign-out then reopen without restoring the removed identity, rejected self-hosted pairing, iOS 16 backed-up identity recovery (including binding), and iOS 16 new setup blocked before key creation. Synthetic network/storage boundaries do not prove native durability or live server behavior. Hosted CI results are not claimed here. Optional follow-ups remain outside this patch: pruning provably unadmitted journals, malformed-record recovery, avoiding an iOS 16 dead-end device-key selection, and pre-existing removal/push-revocation ordering. This PR contains three existing-path fixes. The two organization-login fixes depend on #8700 and remain separate: its stale parent conflicts with main in unrelated relay code, and the required branch-skew hook prevents publishing that stack unchanged. This PR does not refresh that parent or change deployment policy. --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -90,6 +90,10 @@ import os.log
|
||||
browserAuthBridge = BuzzBrowserAuthBridge(messenger: messenger)
|
||||
hostedHpkeChannel = FlutterMethodChannel(name: "buzz/hosted_hpke", binaryMessenger: messenger)
|
||||
hostedHpkeChannel?.setMethodCallHandler { call, result in
|
||||
if call.method == "isSupported" {
|
||||
if #available(iOS 17.0, *) { result(true) } else { result(false) }
|
||||
return
|
||||
}
|
||||
guard call.method == "seal" else { result(FlutterMethodNotImplemented); return }
|
||||
guard #available(iOS 17.0, *) else {
|
||||
result(FlutterError(code: "unsupported", message: "Backup requires iOS 17 or later", details: nil)); return
|
||||
|
||||
@@ -125,6 +125,7 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
|
||||
final communities = await ref.read(communityListProvider.future);
|
||||
final existing = _existingCommunity(communities, invite.relayUrl);
|
||||
if (existing != null) {
|
||||
await _retirePendingInvite(invite, existing);
|
||||
await ref
|
||||
.read(communityListProvider.notifier)
|
||||
.switchCommunity(existing.id);
|
||||
@@ -177,6 +178,7 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
|
||||
final communities = await ref.read(communityListProvider.future);
|
||||
final existing = _existingCommunity(communities, invite.relayUrl);
|
||||
if (existing != null) {
|
||||
await _retirePendingInvite(invite, existing);
|
||||
await ref
|
||||
.read(communityListProvider.notifier)
|
||||
.switchCommunity(existing.id);
|
||||
@@ -202,7 +204,17 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final keys = ref.read(inviteKeyGeneratorProvider)();
|
||||
final storage = ref.read(communityStorageProvider);
|
||||
final nsec = await storage.pendingInviteIdentity(
|
||||
invite.relayUrl,
|
||||
invite.code,
|
||||
() => ref.read(inviteKeyGeneratorProvider)().nsec,
|
||||
);
|
||||
final decodedKey = nostr.Nip19.decode(payload: nsec);
|
||||
if (decodedKey.prefix != nostr.Nip19Prefix.nsec) {
|
||||
throw const FormatException('Pending invite identity is unreadable');
|
||||
}
|
||||
final keys = nostr.Keys(decodedKey.data);
|
||||
final body = jsonEncode({
|
||||
'code': invite.code,
|
||||
if (invite.policyReceipt != null)
|
||||
@@ -250,6 +262,7 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
|
||||
await ref
|
||||
.read(authProvider.notifier)
|
||||
.authenticateWithCommunity(community);
|
||||
await _retirePendingInvite(invite, community);
|
||||
_pendingStarterSetupCommunity = community;
|
||||
state = state.copyWith(isStarterSetupRecovery: true);
|
||||
await startStarterSetupRecovery();
|
||||
@@ -263,6 +276,20 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _retirePendingInvite(
|
||||
InviteDeepLink invite,
|
||||
Community community,
|
||||
) async {
|
||||
try {
|
||||
await ref
|
||||
.read(communityStorageProvider)
|
||||
.completePendingInvite(invite.relayUrl, invite.code, community);
|
||||
} catch (_) {
|
||||
// The saved destination is authoritative. Reopening retries cleanup;
|
||||
// community removal must erase every journal copy before it succeeds.
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the pending recovery after its progress UI has been presented.
|
||||
Future<void> startStarterSetupRecovery() async {
|
||||
final community = _pendingStarterSetupCommunity;
|
||||
|
||||
@@ -945,6 +945,9 @@ class PairingNotifier extends Notifier<PairingState> {
|
||||
_validationSocket = socket;
|
||||
try {
|
||||
await socket.connect().timeout(const Duration(seconds: 8));
|
||||
if (socket.state != SocketState.connected) {
|
||||
throw StateError('Relay did not authenticate the paired identity');
|
||||
}
|
||||
} finally {
|
||||
if (identical(_validationSocket, socket)) _validationSocket = null;
|
||||
await socket.disconnect();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../storage/secure_storage.dart';
|
||||
import '../storage/serial_queue.dart';
|
||||
|
||||
import 'community.dart';
|
||||
|
||||
@@ -71,6 +73,55 @@ class CommunityStorage {
|
||||
return [];
|
||||
}
|
||||
|
||||
static final _pendingInvites = SerialQueue();
|
||||
static const _pendingInvitePrefix = 'buzz_pending_invite_v1_';
|
||||
|
||||
static String _pendingInviteKey(String relayUrl, String code) {
|
||||
final uri = Uri.parse(relayUrl);
|
||||
final origin = uri
|
||||
.replace(
|
||||
scheme: ['https', 'wss'].contains(uri.scheme) ? 'https' : 'http',
|
||||
)
|
||||
.origin;
|
||||
return '$_pendingInvitePrefix${sha256.convert(utf8.encode(jsonEncode([origin, code])))}';
|
||||
}
|
||||
|
||||
/// Retain the claim identity before sending a request with uncertain outcomes.
|
||||
/// A failed read/write must never generate a replacement for an unknown key.
|
||||
Future<String> pendingInviteIdentity(
|
||||
String relayUrl,
|
||||
String code,
|
||||
String Function() generate,
|
||||
) => _pendingInvites.run(() async {
|
||||
final key = _pendingInviteKey(relayUrl, code);
|
||||
final saved = await _secure.read(key: key);
|
||||
if (saved != null) return saved;
|
||||
final nsec = generate();
|
||||
await _secure.write(key: key, value: nsec);
|
||||
if (await _secure.read(key: key) != nsec) {
|
||||
throw StateError('Could not retain the pending invite identity');
|
||||
}
|
||||
return nsec;
|
||||
});
|
||||
|
||||
/// Retire the journal only after the destination identity is durably saved.
|
||||
Future<void> completePendingInvite(
|
||||
String relayUrl,
|
||||
String code,
|
||||
Community community,
|
||||
) => _pendingInvites.run(() async {
|
||||
final saved = await loadAll();
|
||||
if (!saved.any(
|
||||
(item) => item.id == community.id && item.nsec == community.nsec,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
final key = _pendingInviteKey(relayUrl, code);
|
||||
if (await _secure.read(key: key) == community.nsec) {
|
||||
await _secure.delete(key: key);
|
||||
}
|
||||
});
|
||||
|
||||
Future<void> save(Community community) async {
|
||||
final all = await loadAll();
|
||||
final index = all.indexWhere((w) => w.id == community.id);
|
||||
@@ -85,11 +136,30 @@ class CommunityStorage {
|
||||
/// Replaces the complete stored community list in one secure-storage write.
|
||||
Future<void> saveAll(List<Community> communities) => _saveList(communities);
|
||||
|
||||
Future<void> remove(String id) async {
|
||||
Future<void> remove(String id) => _pendingInvites.run(() async {
|
||||
final all = await loadAll();
|
||||
final removedKeys = all
|
||||
.where((item) => item.id == id)
|
||||
.map((item) => item.nsec)
|
||||
.whereType<String>()
|
||||
.toSet();
|
||||
if (removedKeys.isNotEmpty) {
|
||||
// Retire every journal copy before removing the saved community. If a
|
||||
// deletion fails, keep that community so sign-out can safely retry.
|
||||
final records = await _secure.readAll();
|
||||
for (final entry in records.entries) {
|
||||
if (entry.key.startsWith(_pendingInvitePrefix) &&
|
||||
removedKeys.contains(entry.value)) {
|
||||
await _secure.delete(key: entry.key);
|
||||
if (await _secure.read(key: entry.key) != null) {
|
||||
throw StateError('Could not remove the pending invite identity');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
all.removeWhere((w) => w.id == id);
|
||||
await _saveList(all);
|
||||
}
|
||||
});
|
||||
|
||||
Future<String?> loadActiveId() async {
|
||||
return _secure.read(key: _keyActiveId);
|
||||
|
||||
@@ -42,6 +42,7 @@ enum HostedFlowProblem {
|
||||
differentAccount,
|
||||
identityUnreadable,
|
||||
backupUnavailable,
|
||||
unsupportedPlatform,
|
||||
invalidResponse,
|
||||
requestRejected,
|
||||
identityMismatch,
|
||||
@@ -508,10 +509,13 @@ class HostedFlow extends ChangeNotifier {
|
||||
_clearCommunityBatch();
|
||||
_batchSubject = session.subject;
|
||||
}
|
||||
final canSealBackup =
|
||||
target is NostrBindDeepLink || await HpkeBackup.supportsSealing();
|
||||
if (_disposed || generation != _generation) return;
|
||||
var resolved = await identity.resolve(
|
||||
isCurrent: _isCurrent(generation),
|
||||
selectedPubkey: selectedPubkey ?? _pairedPubkey,
|
||||
createNew: createNew,
|
||||
createNew: createNew && canSealBackup,
|
||||
);
|
||||
if (_pairedPubkey != null && resolved.pubkey != _pairedPubkey) {
|
||||
_set(HostedFlowStage.blocked, HostedFlowProblem.identityMismatch);
|
||||
@@ -520,6 +524,26 @@ class HostedFlow extends ChangeNotifier {
|
||||
if (target is! NostrBindDeepLink) {
|
||||
for (var step = 0; step < 2; step++) {
|
||||
if (_disposed || generation != _generation) return;
|
||||
if (!canSealBackup) {
|
||||
final saved = await store.read();
|
||||
if (_disposed || generation != _generation) return;
|
||||
final needsNewBackup =
|
||||
resolved.state == HostedIdentityState.needsSetup ||
|
||||
resolved.state == HostedIdentityState.needsBackup ||
|
||||
(resolved.state == HostedIdentityState.needsBinding &&
|
||||
!(saved?.hasBackupFor(
|
||||
backend: HostedEnvironment.origin.origin,
|
||||
subject: session.subject,
|
||||
) ??
|
||||
false));
|
||||
if (needsNewBackup) {
|
||||
_set(
|
||||
HostedFlowStage.blocked,
|
||||
HostedFlowProblem.unsupportedPlatform,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (resolved.state == HostedIdentityState.needsBinding &&
|
||||
resolved.pubkey != null) {
|
||||
final response = await account.post(
|
||||
|
||||
@@ -26,6 +26,8 @@ String hostedFlowProblemMessage(
|
||||
'Your browser signed in to a different account. Sign in to your original account on the web, then reopen setup. Your saved account and keys have been kept.',
|
||||
HostedFlowProblem.identityUnreadable =>
|
||||
'Your saved identity could not be read safely. Leave setup and recover access to this device’s secure storage before continuing.',
|
||||
HostedFlowProblem.unsupportedPlatform =>
|
||||
'Creating a secure backup requires iOS 17 or later. Update iOS to continue. You can still use self-hosted communities.',
|
||||
HostedFlowProblem.backupUnavailable =>
|
||||
'Secure backup is not supported on this device yet. Your identity has been kept; joining requires a confirmed backup.',
|
||||
HostedFlowProblem.requestRejected =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:buzz/shared/crypto/event_signing.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Never include input, plaintext, or native exception details in this error.
|
||||
enum HpkeBackupFailure { invalidEnrollment, identityMismatch, unavailable }
|
||||
@@ -14,6 +15,12 @@ enum HpkeBackupFailure { invalidEnrollment, identityMismatch, unavailable }
|
||||
class HpkeBackup {
|
||||
static const _channel = MethodChannel('buzz/hosted_hpke');
|
||||
|
||||
/// Existing backups can be recovered on iOS 16, but new ones require iOS 17.
|
||||
static Future<bool> supportsSealing() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS) return true;
|
||||
return await _channel.invokeMethod<bool>('isSupported') == true;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> seal({
|
||||
required Map<String, dynamic> enrollment,
|
||||
required String secretHex,
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
/// Serializes operations without letting a failed operation poison later work.
|
||||
class SerialQueue {
|
||||
Future<void> _tail = Future<void>.value();
|
||||
Future<void>? _tail;
|
||||
|
||||
Future<T> run<T>(Future<T> Function() operation) {
|
||||
final result = _tail.then((_) => operation());
|
||||
_tail = result.then<void>((_) {}, onError: (Object _, StackTrace _) {});
|
||||
final result = (_tail ?? Future<void>.value()).then((_) => operation());
|
||||
late final Future<void> tail;
|
||||
void release() {
|
||||
// An idle queue must not retain a completed future's scheduler/zone.
|
||||
// A later operation may already have replaced this tail.
|
||||
if (identical(_tail, tail)) _tail = null;
|
||||
}
|
||||
|
||||
tail = result.then<void>(
|
||||
(_) => release(),
|
||||
onError: (Object _, StackTrace _) => release(),
|
||||
);
|
||||
_tail = tail;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -827,7 +827,7 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await recovery.started.future;
|
||||
expect(recovery.started.isCompleted, isTrue);
|
||||
|
||||
expect(find.text('Finish setting up'), findsOneWidget);
|
||||
expect(find.text('Finishing setup…'), findsOneWidget);
|
||||
@@ -893,7 +893,7 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await recovery.started.future;
|
||||
expect(recovery.started.isCompleted, isTrue);
|
||||
|
||||
recovery.completeError(Exception('relay did not respond'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -676,65 +676,167 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
test('failed claim can be retried and preserves policy receipt', () async {
|
||||
final keys = nostr.Keys.generate();
|
||||
var attempts = 0;
|
||||
final bodies = <String>[];
|
||||
final storage = CommunityStorage(secure: FakeSecureStorage());
|
||||
final auth = _RecordingAuthNotifier();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
communityStorageProvider.overrideWithValue(storage),
|
||||
authProvider.overrideWith(() => auth),
|
||||
inviteKeyGeneratorProvider.overrideWithValue(() => keys),
|
||||
inviteJoinRecoveryProvider.overrideWithValue(
|
||||
(_) => _successfulRecovery(),
|
||||
),
|
||||
inviteJoinHttpClientProvider.overrideWithValue(
|
||||
http_testing.MockClient((request) async {
|
||||
attempts++;
|
||||
bodies.add(request.body);
|
||||
if (attempts == 1) {
|
||||
return http.Response(jsonEncode({'error': 'temporary'}), 503);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'status': 'joined',
|
||||
'host': 'relay.example.com',
|
||||
'role': 'member',
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container
|
||||
.read(inviteJoinProvider.notifier)
|
||||
.prepare(
|
||||
const InviteDeepLink(
|
||||
relayUrl: 'wss://relay.example.com',
|
||||
code: 'code',
|
||||
policyReceipt: 'receipt.value',
|
||||
test(
|
||||
'committed claim with lost response retries the same key after restart',
|
||||
() async {
|
||||
var attempts = 0;
|
||||
String? claimedPubkey;
|
||||
String? lastClaimPubkey;
|
||||
final bodies = <String>[];
|
||||
final secure = FakeSecureStorage();
|
||||
final storage = CommunityStorage(secure: secure);
|
||||
final auth = _RecordingAuthNotifier();
|
||||
ProviderContainer open() => ProviderContainer(
|
||||
overrides: [
|
||||
communityStorageProvider.overrideWithValue(storage),
|
||||
communitySnapshotWriterProvider.overrideWithValue((_) async {}),
|
||||
communityPushLeaseRevocationEnqueuerProvider.overrideWithValue(
|
||||
(_) async => false,
|
||||
),
|
||||
);
|
||||
await container.read(inviteJoinProvider.notifier).confirmJoin();
|
||||
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error);
|
||||
authProvider.overrideWith(() => auth),
|
||||
// Keep the production fresh-key generator: a fixed fake masks key loss.
|
||||
inviteJoinRecoveryProvider.overrideWithValue(
|
||||
(_) => _successfulRecovery(),
|
||||
),
|
||||
inviteJoinHttpClientProvider.overrideWithValue(
|
||||
http_testing.MockClient((request) async {
|
||||
attempts++;
|
||||
bodies.add(request.body);
|
||||
final event =
|
||||
jsonDecode(
|
||||
utf8.decode(
|
||||
base64Decode(
|
||||
request.headers['Authorization']!.substring(
|
||||
'Nostr '.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
as Map;
|
||||
final pubkey = event['pubkey'] as String;
|
||||
lastClaimPubkey = pubkey;
|
||||
if (attempts == 1) {
|
||||
claimedPubkey = pubkey;
|
||||
throw http.ClientException('Response lost after commit');
|
||||
}
|
||||
if (pubkey != claimedPubkey) {
|
||||
return http.Response(
|
||||
jsonEncode({'error': 'invite exhausted'}),
|
||||
403,
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'status': 'already_member',
|
||||
'host': 'relay.example.com',
|
||||
'role': 'member',
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
const invite = InviteDeepLink(
|
||||
relayUrl: 'wss://relay.example.com',
|
||||
code: 'code',
|
||||
policyReceipt: 'receipt.value',
|
||||
);
|
||||
var container = open();
|
||||
await container.read(inviteJoinProvider.notifier).prepare(invite);
|
||||
await container.read(inviteJoinProvider.notifier).confirmJoin();
|
||||
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error);
|
||||
expect(await storage.loadAll(), isEmpty);
|
||||
container.dispose();
|
||||
container = open();
|
||||
addTearDown(container.dispose);
|
||||
await container.read(inviteJoinProvider.notifier).prepare(invite);
|
||||
await container.read(inviteJoinProvider.notifier).confirmJoin();
|
||||
expect(
|
||||
container.read(inviteJoinProvider).status,
|
||||
InviteJoinStatus.success,
|
||||
);
|
||||
expect(attempts, 2);
|
||||
expect(
|
||||
bodies,
|
||||
everyElement(
|
||||
jsonEncode({'code': 'code', 'policy_receipt': 'receipt.value'}),
|
||||
),
|
||||
);
|
||||
expect((await storage.loadAll()).single.pubkey, claimedPubkey);
|
||||
expect(
|
||||
(await secure.readAll()).keys.where(
|
||||
(key) => key.startsWith('buzz_pending_invite_'),
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
final saved = (await storage.loadAll()).single;
|
||||
// A crash after saving the community can leave the journal behind.
|
||||
await storage.pendingInviteIdentity(
|
||||
invite.relayUrl,
|
||||
invite.code,
|
||||
() => saved.nsec!,
|
||||
);
|
||||
await container.read(inviteJoinProvider.notifier).prepare(invite);
|
||||
expect(
|
||||
(await secure.readAll()).values.where((value) => value == saved.nsec),
|
||||
isEmpty,
|
||||
);
|
||||
|
||||
await container.read(inviteJoinProvider.notifier).confirmJoin();
|
||||
// Also cover a destination saved after the confirmation page was opened.
|
||||
await storage.saveAll([]);
|
||||
container.invalidate(communityListProvider);
|
||||
await container.read(inviteJoinProvider.notifier).prepare(invite);
|
||||
await storage.saveAll([saved]);
|
||||
container.invalidate(communityListProvider);
|
||||
await storage.pendingInviteIdentity(
|
||||
invite.relayUrl,
|
||||
invite.code,
|
||||
() => saved.nsec!,
|
||||
);
|
||||
await container.read(inviteJoinProvider.notifier).confirmJoin();
|
||||
expect(
|
||||
(await secure.readAll()).values.where((value) => value == saved.nsec),
|
||||
isEmpty,
|
||||
);
|
||||
|
||||
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.success);
|
||||
expect(attempts, 2);
|
||||
expect(
|
||||
bodies,
|
||||
everyElement(
|
||||
jsonEncode({'code': 'code', 'policy_receipt': 'receipt.value'}),
|
||||
),
|
||||
);
|
||||
expect(auth.authenticatedCommunities, hasLength(1));
|
||||
});
|
||||
// Any matching journal, including a different invite, must be retired on
|
||||
// sign-out. Unrelated pending identities must survive.
|
||||
await storage.pendingInviteIdentity(
|
||||
invite.relayUrl,
|
||||
invite.code,
|
||||
() => saved.nsec!,
|
||||
);
|
||||
await storage.pendingInviteIdentity(
|
||||
invite.relayUrl,
|
||||
'another-code',
|
||||
() => saved.nsec!,
|
||||
);
|
||||
final unrelated = nostr.Keys.generate().nsec;
|
||||
await storage.pendingInviteIdentity(
|
||||
'wss://other.example',
|
||||
'other-code',
|
||||
() => unrelated,
|
||||
);
|
||||
secure.failPendingInviteDeletes = true;
|
||||
await expectLater(
|
||||
container.read(authProvider.notifier).signOut(),
|
||||
throwsStateError,
|
||||
);
|
||||
expect((await storage.loadAll()).single.nsec, saved.nsec);
|
||||
secure.failPendingInviteDeletes = false;
|
||||
await container.read(authProvider.notifier).signOut();
|
||||
expect(await storage.loadAll(), isEmpty);
|
||||
expect((await secure.readAll()).values, isNot(contains(saved.nsec)));
|
||||
expect((await secure.readAll()).values, contains(unrelated));
|
||||
await container.read(inviteJoinProvider.notifier).prepare(invite);
|
||||
await container.read(inviteJoinProvider.notifier).confirmJoin();
|
||||
expect(attempts, 3);
|
||||
expect(lastClaimPubkey, isNot(claimedPubkey));
|
||||
expect(container.read(inviteJoinProvider).status, InviteJoinStatus.error);
|
||||
expect(await storage.loadAll(), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('builds a fresh recovery with the second community identity', () async {
|
||||
final firstKeys = nostr.Keys.generate();
|
||||
|
||||
@@ -24,6 +24,8 @@ import 'package:buzz/shared/relay/relay_provider.dart';
|
||||
import 'package:buzz/shared/relay/media_image.dart';
|
||||
import 'package:buzz/shared/utils/string_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
@@ -68,6 +70,30 @@ void main() {
|
||||
f.bound = null;
|
||||
f.offerProfile = false;
|
||||
await f.mount(tester);
|
||||
// iOS 16 stops before any identity generation, binding or admission.
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('buzz/hosted_hpke'),
|
||||
(_) async => false,
|
||||
);
|
||||
try {
|
||||
await f.flow.signInAccount();
|
||||
expect(f.flow.problem, HostedFlowProblem.unsupportedPlatform);
|
||||
expect(await f.store.read(), isNull);
|
||||
expect(f.bound, isNull);
|
||||
expect(f.probes, isEmpty);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('buzz/hosted_hpke'),
|
||||
(call) async => call.method == 'isSupported'
|
||||
? true
|
||||
: {
|
||||
'enc': Uint8List.fromList([4, ...List.filled(64, 2)]),
|
||||
'ciphertext': Uint8List(48),
|
||||
},
|
||||
);
|
||||
// Account restoration may resume independently of the Add choices.
|
||||
await f.flow.signInAccount();
|
||||
await f.settle(tester);
|
||||
@@ -753,13 +779,36 @@ void main() {
|
||||
'new device recovers and preserves existing/unreadable profile ($unreadableProfile)',
|
||||
(tester, f) async {
|
||||
await f.initialize(localIdentity: false);
|
||||
f.cloudKeys.addAll([f.secondKeys, f.keys]);
|
||||
// Exercise both an already-bound recovery and recovery that must bind
|
||||
// its confirmed backup, without requiring native sealing in either case.
|
||||
if (unreadableProfile) {
|
||||
f.bound = null;
|
||||
f.cloudKeys.add(f.keys);
|
||||
} else {
|
||||
f.cloudKeys.addAll([f.secondKeys, f.keys]);
|
||||
}
|
||||
f.rows.addAll([f.row('one'), f.row('two')]);
|
||||
f.offerProfile = false;
|
||||
f.profileUnavailable = unreadableProfile;
|
||||
await f.mount(tester);
|
||||
// Account restoration may resume independently of the Add choices.
|
||||
await f.flow.signInAccount();
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('buzz/hosted_hpke'),
|
||||
(call) async {
|
||||
expect(
|
||||
call.method,
|
||||
'isSupported',
|
||||
reason: 'Recovery must not seal a new backup',
|
||||
);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
try {
|
||||
await f.flow.signInAccount();
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
await f.settle(tester);
|
||||
expect(f.flow.stage, HostedFlowStage.chooseCommunities);
|
||||
expect(f.probes, isEmpty);
|
||||
@@ -784,7 +833,9 @@ void main() {
|
||||
expect(saved.pubkey, f.keys.public);
|
||||
expect((await f.store.read())!.pubkey, f.keys.public);
|
||||
expect(f.sent('/recovery'), hasLength(1));
|
||||
expect(f.sent('/challenge'), isEmpty);
|
||||
expect(f.sent('/challenge'), hasLength(unreadableProfile ? 1 : 0));
|
||||
expect(f.sent('/nsec-backups'), isEmpty);
|
||||
expect(f.sent('/enrollment'), isEmpty);
|
||||
expect(await f.storage.loadActiveId(), saved.id);
|
||||
expect(find.byType(OnboardingAppEntry), findsNothing);
|
||||
},
|
||||
|
||||
@@ -73,6 +73,45 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
test('starts in idle state', () {
|
||||
container = createContainer();
|
||||
final state = container.read(pairingProvider);
|
||||
@@ -1475,3 +1514,22 @@ class _PendingValidationSocket extends RelaySocket {
|
||||
@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 {}
|
||||
}
|
||||
|
||||
@@ -323,10 +323,12 @@ class HostedJourneyFixture {
|
||||
await tester.binding.setSurfaceSize(const Size(440, 1100));
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('buzz/hosted_hpke'),
|
||||
(_) async => {
|
||||
'enc': Uint8List.fromList([4, ...List.filled(64, 2)]),
|
||||
'ciphertext': Uint8List(48),
|
||||
},
|
||||
(call) async => call.method == 'isSupported'
|
||||
? true
|
||||
: {
|
||||
'enc': Uint8List.fromList([4, ...List.filled(64, 2)]),
|
||||
'ciphertext': Uint8List(48),
|
||||
},
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:buzz/shared/push/push_subscription.dart';
|
||||
class FakeSecureStorage extends Fake implements FlutterSecureStorage {
|
||||
final Map<String, String> _data = {};
|
||||
final Map<String, int> _writeCounts = {};
|
||||
bool failPendingInviteDeletes = false;
|
||||
|
||||
@override
|
||||
Future<String?> read({
|
||||
@@ -52,7 +53,12 @@ class FakeSecureStorage extends Fake implements FlutterSecureStorage {
|
||||
WebOptions? webOptions,
|
||||
AppleOptions? mOptions,
|
||||
WindowsOptions? wOptions,
|
||||
}) async => _data.remove(key);
|
||||
}) async {
|
||||
if (failPendingInviteDeletes && key.startsWith('buzz_pending_invite_')) {
|
||||
throw StateError('Secure storage deletion unavailable');
|
||||
}
|
||||
_data.remove(key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> readAll({
|
||||
|
||||
Reference in New Issue
Block a user