mirror of
https://github.com/block/buzz.git
synced 2026-11-01 23:11:58 +00:00
fix(mobile): preserve unreadable identities and repair recovery choices
Use shared fail-closed Android secure-storage options across all namespace clients. Allow token-only legacy records through cloud recovery, clarify consent, and prevent stale chooser taps from interrupting cancellation. Exercise real Android plugin decryption failures with an isolated emulator probe, and cover actual link-entry consent and lifecycle guards. Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package xyz.block.buzz.mobile
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.it_nomads.fluttersecurestorage.FlutterSecureStorageConfig
|
||||
import io.flutter.FlutterInjector
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Uses the real pinned plugin, Android Keystore and production Dart services. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SecureStorageReadFailureTest {
|
||||
@Test
|
||||
fun decryptFailurePreservesDataAndNeverGenerates() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
// Never install/run the corruption probe using a person's app identity.
|
||||
assumeTrue("Requires the disposable storage-probe build", context.packageName.endsWith(".identitysafety"))
|
||||
val done = CountDownLatch(1)
|
||||
var failure: Throwable? = null
|
||||
var passed = false
|
||||
var engine: FlutterEngine? = null
|
||||
var preferences: SharedPreferences? = null
|
||||
var corruptedKey: String? = null
|
||||
var original: String? = null
|
||||
var damagedSnapshot: Map<String, *>? = null
|
||||
try {
|
||||
instrumentation.runOnMainSync {
|
||||
val loader = FlutterInjector.instance().flutterLoader()
|
||||
loader.startInitialization(context)
|
||||
loader.ensureInitializationComplete(context, null)
|
||||
val running = FlutterEngine(context)
|
||||
engine = running
|
||||
MethodChannel(running.dartExecutor.binaryMessenger, "buzz/storage_failure_probe")
|
||||
.setMethodCallHandler { call, reply ->
|
||||
try {
|
||||
when (call.method) {
|
||||
"isolated" -> reply.success(true)
|
||||
"corrupt" -> {
|
||||
val options = call.argument<Map<String, Any>>("options")!!
|
||||
val config = FlutterSecureStorageConfig(options)
|
||||
val prefs = context.getSharedPreferences(config.effectiveDataPrefsName, Context.MODE_PRIVATE)
|
||||
val key = config.sharedPreferencesKeyPrefix + "_" + call.argument<String>("key")!!
|
||||
preferences = prefs
|
||||
corruptedKey = key
|
||||
original = prefs.getString(key, null)
|
||||
assertTrue(original != null, "Probe must corrupt an existing encrypted record")
|
||||
// Keep framing/Base64 valid, but invalidate the ciphertext authentication tag.
|
||||
val damaged = Base64.decode(original, Base64.DEFAULT)
|
||||
assertTrue(damaged.isNotEmpty())
|
||||
damaged[damaged.lastIndex] = (damaged.last().toInt() xor 1).toByte()
|
||||
assertTrue(prefs.edit().putString(key, Base64.encodeToString(damaged, Base64.DEFAULT)).commit())
|
||||
damagedSnapshot = prefs.all.toMap()
|
||||
reply.success(null)
|
||||
}
|
||||
"assertPreserved" -> {
|
||||
assertEquals(damagedSnapshot, preferences!!.all)
|
||||
reply.success(null)
|
||||
}
|
||||
"restore" -> {
|
||||
assertTrue(preferences!!.edit().putString(corruptedKey, original).commit())
|
||||
reply.success(null)
|
||||
}
|
||||
"done" -> {
|
||||
passed = call.argument<Boolean>("passed") == true
|
||||
reply.success(null)
|
||||
done.countDown()
|
||||
}
|
||||
else -> reply.notImplemented()
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
failure = error
|
||||
reply.error("probe_failure", error.javaClass.simpleName, null)
|
||||
done.countDown()
|
||||
}
|
||||
}
|
||||
running.dartExecutor.executeDartEntrypoint(
|
||||
DartExecutor.DartEntrypoint(loader.findAppBundlePath(), "main"),
|
||||
)
|
||||
}
|
||||
assertTrue(done.await(45, TimeUnit.SECONDS), "Dart probe did not complete")
|
||||
failure?.let { throw it }
|
||||
assertTrue(passed, "Production Dart resolver did not fail closed")
|
||||
} finally {
|
||||
if (original != null) preferences?.edit()?.putString(corruptedKey, original)?.commit()
|
||||
instrumentation.runOnMainSync { engine?.destroy() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,9 @@ class HostedConnectPage extends HookConsumerWidget {
|
||||
HostedFlowStage.idle => 'Continue connecting your account.',
|
||||
HostedFlowStage.working => 'Connecting your account…',
|
||||
HostedFlowStage.chooseIdentity =>
|
||||
'Choose the identity to connect to Builderlab. Existing device and cloud keys will be kept.',
|
||||
flow.identityChoices.any((choice) => choice.fromCloud)
|
||||
? 'Use your Builderlab identity on this device? It differs from your saved community identities. Those keys and your cloud backups will be kept. This app cannot change the selected Builderlab identity later.'
|
||||
: 'Choose the identity to connect to Builderlab. It will be bound to your account and backed up. Existing keys will be kept. This app cannot change the selected Builderlab identity later.',
|
||||
HostedFlowStage.signIn => 'Complete sign-in in your browser.',
|
||||
HostedFlowStage.browserReturned =>
|
||||
'Continue setup in your browser. Open your community in Buzz when it is ready.',
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
import 'community.dart';
|
||||
|
||||
class CommunityStorage {
|
||||
@@ -19,7 +21,7 @@ class CommunityStorage {
|
||||
final FlutterSecureStorage _secure;
|
||||
|
||||
CommunityStorage({FlutterSecureStorage? secure})
|
||||
: _secure = secure ?? const FlutterSecureStorage();
|
||||
: _secure = secure ?? communitySecureStorage;
|
||||
|
||||
/// Load all communities. On first call, migrates legacy single-community
|
||||
/// credentials if present.
|
||||
|
||||
@@ -2,8 +2,11 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
import 'hosted_environment.dart';
|
||||
|
||||
/// Safe failures: never include a secret or the storage/plugin exception.
|
||||
@@ -77,13 +80,7 @@ class AccountIdentitySnapshot {
|
||||
/// FlutterSecureStorage does not provide a cross-process compare-and-set.
|
||||
class AccountIdentityStore {
|
||||
AccountIdentityStore({FlutterSecureStorage? storage})
|
||||
: _storage =
|
||||
storage ??
|
||||
const FlutterSecureStorage(
|
||||
iOptions: IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock_this_device,
|
||||
),
|
||||
);
|
||||
: _storage = storage ?? accountSecureStorage;
|
||||
|
||||
static final storageKey =
|
||||
'buzz_account_identity_${HostedEnvironment.origin.host.replaceAll('.', '_')}_v1';
|
||||
|
||||
@@ -7,8 +7,11 @@ import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
import 'hosted_environment.dart';
|
||||
|
||||
/// Safe to show as a generic retry prompt. Never carries a URL or server text.
|
||||
@@ -29,13 +32,7 @@ class BrowserAuth {
|
||||
FlutterSecureStorage? storage,
|
||||
Future<String?> Function(Uri)? openBrowser,
|
||||
DateTime Function()? now,
|
||||
}) : _storage =
|
||||
storage ??
|
||||
const FlutterSecureStorage(
|
||||
iOptions: IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock_this_device,
|
||||
),
|
||||
),
|
||||
}) : _storage = storage ?? accountSecureStorage,
|
||||
_openBrowser = openBrowser ?? _openPlatformBrowser,
|
||||
_now = now ?? DateTime.now;
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
import 'hosted_environment.dart';
|
||||
|
||||
/// Errors contain no response bodies, credentials, or recovered private keys.
|
||||
@@ -47,11 +50,7 @@ class BuilderlabAccountClient {
|
||||
|
||||
/// Device-only: the session never syncs or restores onto another device.
|
||||
@visibleForTesting
|
||||
static const defaultStorage = FlutterSecureStorage(
|
||||
iOptions: IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock_this_device,
|
||||
),
|
||||
);
|
||||
static const defaultStorage = accountSecureStorage;
|
||||
|
||||
static final origin = HostedEnvironment.origin;
|
||||
static const _prefix = '/api/goose';
|
||||
|
||||
@@ -2,20 +2,23 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
import 'account_identity_store.dart';
|
||||
|
||||
/// Public identity choices; never contains private signing material.
|
||||
class DeviceIdentityChoice {
|
||||
const DeviceIdentityChoice(this.pubkey, this.label);
|
||||
const DeviceIdentityChoice(this.pubkey, this.label, {this.fromCloud = false});
|
||||
final String pubkey;
|
||||
final String label;
|
||||
final bool fromCloud;
|
||||
}
|
||||
|
||||
/// Read-only inventory, including legacy records hidden by a newer community list.
|
||||
/// Do not call CommunityStorage.loadAll here: it migrates and deletes records.
|
||||
class DeviceIdentitySource {
|
||||
DeviceIdentitySource({FlutterSecureStorage? storage})
|
||||
: _storage = storage ?? const FlutterSecureStorage();
|
||||
: _storage = storage ?? communitySecureStorage;
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
Future<List<DeviceIdentityChoice>> choices() async => [
|
||||
@@ -38,10 +41,10 @@ class DeviceIdentitySource {
|
||||
Future<Map<String, ({String nsec, String label})>> _read() async {
|
||||
try {
|
||||
final result = <String, ({String nsec, String label})>{};
|
||||
final labels = <String, Set<String>>{};
|
||||
void add(dynamic nsec, dynamic advertised, String label) {
|
||||
if (nsec == null) {
|
||||
// An identity reference without its secret is not authoritative absence.
|
||||
if (advertised != null) throw AccountIdentityFailure.unreadable;
|
||||
// Token-only legacy communities legitimately have no private key.
|
||||
return;
|
||||
}
|
||||
if (nsec is! String) throw AccountIdentityFailure.unreadable;
|
||||
@@ -49,11 +52,8 @@ class DeviceIdentitySource {
|
||||
if (advertised != null && advertised != pubkey) {
|
||||
throw AccountIdentityFailure.unreadable;
|
||||
}
|
||||
final previous = result[pubkey];
|
||||
result[pubkey] = (
|
||||
nsec: nsec,
|
||||
label: previous == null ? label : '${previous.label}, $label',
|
||||
);
|
||||
final names = labels.putIfAbsent(pubkey, () => <String>{})..add(label);
|
||||
result[pubkey] = (nsec: nsec, label: names.join(', '));
|
||||
}
|
||||
|
||||
for (final key in ['buzz_communities', 'buzz_workspaces']) {
|
||||
@@ -65,7 +65,9 @@ class DeviceIdentitySource {
|
||||
add(
|
||||
value['nsec'],
|
||||
value['pubkey'],
|
||||
value['name'] as String? ?? 'Saved community',
|
||||
(value['name'] as String?)?.trim().isNotEmpty == true
|
||||
? (value['name'] as String).trim()
|
||||
: 'Saved community',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'dart:convert';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
import 'account_identity_store.dart';
|
||||
import 'builderlab_account.dart';
|
||||
import 'hosted_environment.dart';
|
||||
@@ -26,13 +28,7 @@ class HostedBackupService {
|
||||
HpkeBackup? hpke,
|
||||
FlutterSecureStorage? storage,
|
||||
}) : _hpke = hpke ?? HpkeBackup(),
|
||||
_storage =
|
||||
storage ??
|
||||
const FlutterSecureStorage(
|
||||
iOptions: IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock_this_device,
|
||||
),
|
||||
);
|
||||
_storage = storage ?? accountSecureStorage;
|
||||
|
||||
final BuilderlabAccountClient account;
|
||||
final AccountIdentityStore identity;
|
||||
|
||||
@@ -227,7 +227,8 @@ class HostedFlow extends ChangeNotifier {
|
||||
|
||||
/// Resume only an offered public identity; reread local and cloud facts first.
|
||||
Future<void> chooseIdentity(String pubkey) {
|
||||
if (stage != HostedFlowStage.chooseIdentity ||
|
||||
if (_target == null ||
|
||||
stage != HostedFlowStage.chooseIdentity ||
|
||||
!identityChoices.any((item) => item.pubkey == pubkey)) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
@@ -225,7 +225,8 @@ class HostedIdentityService {
|
||||
choices: [
|
||||
DeviceIdentityChoice(
|
||||
candidate.keys.public,
|
||||
'Builderlab backup (keep existing community identities)',
|
||||
'Use Builderlab identity',
|
||||
fromCloud: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -4,9 +4,12 @@ import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
import '../community/community.dart';
|
||||
import '../relay/signed_event_relay.dart';
|
||||
import 'dev_push_lease.dart';
|
||||
@@ -166,7 +169,7 @@ class BuzzPushLeaseRevocationStorage {
|
||||
final FlutterSecureStorage _secure;
|
||||
|
||||
BuzzPushLeaseRevocationStorage({FlutterSecureStorage? secure})
|
||||
: _secure = secure ?? const FlutterSecureStorage();
|
||||
: _secure = secure ?? communitySecureStorage;
|
||||
|
||||
Future<List<BuzzPushLeaseRevocationRecord>> loadAll() async {
|
||||
final raw = await _secure.read(key: _key);
|
||||
|
||||
18
mobile/lib/shared/storage/secure_storage.dart
Normal file
18
mobile/lib/shared/storage/secure_storage.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// Android caches the first configuration per namespace. Every production
|
||||
/// client must use these options: a failed read must never delete a key.
|
||||
const secureStorageAndroidOptions = AndroidOptions(resetOnError: false);
|
||||
|
||||
/// Preserve the existing community-storage namespace and iOS accessibility.
|
||||
const communitySecureStorage = FlutterSecureStorage(
|
||||
aOptions: secureStorageAndroidOptions,
|
||||
);
|
||||
|
||||
/// Account credentials and identities remain device-only on iOS.
|
||||
const accountSecureStorage = FlutterSecureStorage(
|
||||
aOptions: secureStorageAndroidOptions,
|
||||
iOptions: IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock_this_device,
|
||||
),
|
||||
);
|
||||
@@ -2,7 +2,8 @@ import 'dart:convert';
|
||||
|
||||
import 'package:buzz/features/onboarding/hosted_connect_page.dart';
|
||||
import 'package:buzz/hosted_app_gate.dart';
|
||||
import 'package:buzz/shared/deeplink/deep_link.dart';
|
||||
import 'package:buzz/onboarding_app_entry.dart';
|
||||
import 'package:buzz/shared/community/community.dart';
|
||||
import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart';
|
||||
import 'package:buzz/shared/hosted/account_identity_store.dart';
|
||||
import 'package:buzz/shared/hosted/browser_auth.dart';
|
||||
@@ -12,9 +13,9 @@ import 'package:buzz/shared/hosted/hosted_identity_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../../helpers/widget_helpers.dart';
|
||||
import '../../shared/hosted/device_identity_reconciliation_test.dart'
|
||||
show InventoryAccount;
|
||||
|
||||
@@ -31,92 +32,161 @@ class _Browser extends BrowserAuth {
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'inline link flow shows identity choice; leave and explicit selection preserve keys',
|
||||
'real link form supports local choice and cloud consent, leave and resubmit',
|
||||
(tester) async {
|
||||
final first = nostr.Keys('1'.padLeft(64, '0'));
|
||||
final second = nostr.Keys('2'.padLeft(64, '0'));
|
||||
final raw = jsonEncode([
|
||||
{'name': 'First community', 'nsec': first.nsec, 'pubkey': first.public},
|
||||
{
|
||||
'name': 'Second community',
|
||||
'nsec': second.nsec,
|
||||
'pubkey': second.public,
|
||||
},
|
||||
]);
|
||||
FlutterSecureStorage.setMockInitialValues({'buzz_communities': raw});
|
||||
PendingDeepLinkNotifier.debugUriStreamOverride =
|
||||
const Stream<Uri>.empty();
|
||||
final account = InventoryAccount();
|
||||
final store = AccountIdentityStore();
|
||||
final browser = _Browser();
|
||||
var admitted = false;
|
||||
final flow = HostedFlow(
|
||||
account: account,
|
||||
browser: browser,
|
||||
store: store,
|
||||
identity: HostedIdentityService(
|
||||
// One widget zone matches the process-wide secure-store writer queue.
|
||||
for (final cloud in [false, true]) {
|
||||
final first = nostr.Keys('1'.padLeft(64, '0'));
|
||||
final second = nostr.Keys('2'.padLeft(64, '0'));
|
||||
final raw = jsonEncode([
|
||||
Community.create(
|
||||
name: 'First community',
|
||||
relayUrl: 'wss://first.example.invalid',
|
||||
nsec: first.nsec,
|
||||
pubkey: first.public,
|
||||
).toJson(),
|
||||
if (!cloud)
|
||||
Community.create(
|
||||
name: 'Second community',
|
||||
relayUrl: 'wss://second.example.invalid',
|
||||
nsec: second.nsec,
|
||||
pubkey: second.public,
|
||||
).toJson(),
|
||||
]);
|
||||
FlutterSecureStorage.setMockInitialValues({'buzz_communities': raw});
|
||||
PendingDeepLinkNotifier.debugUriStreamOverride =
|
||||
const Stream<Uri>.empty();
|
||||
final account = InventoryAccount();
|
||||
if (cloud) {
|
||||
account.bound = second.public;
|
||||
account.backups = [second.public];
|
||||
account.recovery = {
|
||||
'backups': [
|
||||
{
|
||||
'backup_id': 'fixture-backup',
|
||||
'nostr_pubkey': second.public,
|
||||
'secret_key': base64UrlEncode([
|
||||
...List.filled(31, 0),
|
||||
2,
|
||||
]).replaceAll('=', ''),
|
||||
},
|
||||
],
|
||||
'failed_backup_ids': [],
|
||||
};
|
||||
}
|
||||
final store = AccountIdentityStore();
|
||||
final browser = _Browser();
|
||||
var admitted = false;
|
||||
final flow = HostedFlow(
|
||||
account: account,
|
||||
browser: browser,
|
||||
store: store,
|
||||
deviceIdentities: DeviceIdentitySource(),
|
||||
),
|
||||
enterCommunity: (_, _, _, _) async {
|
||||
admitted = true;
|
||||
},
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [hostedFlowProvider.overrideWithValue(flow)],
|
||||
);
|
||||
addTearDown(() async {
|
||||
PendingDeepLinkNotifier.debugUriStreamOverride = null;
|
||||
container.dispose();
|
||||
flow.dispose();
|
||||
await browser.dispose();
|
||||
account.dispose();
|
||||
});
|
||||
container.read(hostedInlineEntryProvider.notifier).setActive(true);
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const MaterialApp(
|
||||
home: HostedAppGate(
|
||||
identity: HostedIdentityService(
|
||||
account: account,
|
||||
store: store,
|
||||
deviceIdentities: DeviceIdentitySource(),
|
||||
),
|
||||
enterCommunity: (_, _, _, _) async {
|
||||
admitted = true;
|
||||
},
|
||||
);
|
||||
addTearDown(() async {
|
||||
PendingDeepLinkNotifier.debugUriStreamOverride = null;
|
||||
flow.dispose();
|
||||
await browser.dispose();
|
||||
account.dispose();
|
||||
});
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
disableAnimations: true,
|
||||
overrides: [hostedFlowProvider.overrideWithValue(flow)],
|
||||
child: const HostedAppGate(
|
||||
authenticated: false,
|
||||
child: Scaffold(body: Text('Link form')),
|
||||
child: OnboardingAppEntry(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
const target = AddCommunityDeepLink(
|
||||
relayUrl: 'wss://team.communities.buzz.xyz',
|
||||
name: 'Team',
|
||||
);
|
||||
await flow.start(target);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(HostedConnectPage), findsOneWidget);
|
||||
expect(find.text('First community'), findsOneWidget);
|
||||
expect(find.text('Second community'), findsOneWidget);
|
||||
expect(await store.read(), isNull);
|
||||
await tester.tap(find.text('Leave setup'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(flow.stage, HostedFlowStage.idle);
|
||||
expect(await store.read(), isNull);
|
||||
await flow.start(target);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Second community'));
|
||||
await tester.pumpAndSettle();
|
||||
expect((await store.read())!.pubkey, second.public);
|
||||
expect(
|
||||
await const FlutterSecureStorage().read(key: 'buzz_communities'),
|
||||
raw,
|
||||
);
|
||||
expect(admitted, isFalse);
|
||||
// Fixture refuses binding: choosing a key never bypasses remaining gates.
|
||||
expect(flow.problem, HostedFlowProblem.denied);
|
||||
expect(
|
||||
account.calls.where((path) => path.endsWith('/challenge')).length,
|
||||
1,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Enter a community link').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
const url = 'https://team.communities.buzz.xyz';
|
||||
final input = find.byKey(const Key('community-link-input'));
|
||||
await tester.enterText(input, url);
|
||||
await tester.testTextInput.receiveAction(TextInputAction.go);
|
||||
await tester.pumpAndSettle();
|
||||
for (
|
||||
var attempt = 0;
|
||||
attempt < 30 && flow.stage != HostedFlowStage.chooseIdentity;
|
||||
attempt++
|
||||
) {
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
expect(
|
||||
flow.stage,
|
||||
HostedFlowStage.chooseIdentity,
|
||||
reason: 'Calls: ${account.calls}',
|
||||
);
|
||||
expect(find.byType(HostedConnectPage), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining(
|
||||
'This app cannot change the selected Builderlab identity later.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
if (cloud) {
|
||||
expect(
|
||||
find.textContaining(
|
||||
'It differs from your saved community identities.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Use Builderlab identity'), findsOneWidget);
|
||||
} else {
|
||||
expect(find.text('First community'), findsOneWidget);
|
||||
expect(find.text('Second community'), findsOneWidget);
|
||||
}
|
||||
expect(await store.read(), isNull);
|
||||
await tester.ensureVisible(find.text('Leave setup'));
|
||||
await tester.tap(find.text('Leave setup'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(flow.stage, HostedFlowStage.idle);
|
||||
expect(flow.identityChoices, isEmpty);
|
||||
expect(await store.read(), isNull);
|
||||
expect(tester.widget<TextField>(input).controller!.text, url);
|
||||
expect(tester.widget<TextField>(input).enabled, isTrue);
|
||||
await tester.ensureVisible(find.text('Continue'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Continue').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
final choice = find.text(
|
||||
cloud ? 'Use Builderlab identity' : 'Second community',
|
||||
);
|
||||
await tester.ensureVisible(choice);
|
||||
await tester.tap(choice);
|
||||
await tester.pumpAndSettle();
|
||||
expect((await store.read())!.pubkey, second.public);
|
||||
expect(
|
||||
await const FlutterSecureStorage().read(key: 'buzz_communities'),
|
||||
raw,
|
||||
);
|
||||
expect(flow.identityChoices, isEmpty);
|
||||
expect(admitted, cloud);
|
||||
if (!cloud) {
|
||||
// The fixture refuses binding: a choice never bypasses later gates.
|
||||
expect(flow.problem, HostedFlowProblem.denied);
|
||||
expect(
|
||||
find.text('This account does not have access.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
account.calls.where((path) => path.endsWith('/challenge')).length,
|
||||
1,
|
||||
);
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,7 +124,6 @@ void main() {
|
||||
for (final data in [
|
||||
{'buzz_workspaces': '{broken'},
|
||||
{'buzz_nsec': ''},
|
||||
{'buzz_pubkey': first.public},
|
||||
{
|
||||
'buzz_communities': jsonEncode([
|
||||
{'nsec': first.nsec, 'pubkey': second.public},
|
||||
@@ -195,6 +194,55 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'token-only legacy records do not prevent recovering the same cloud identity',
|
||||
() async {
|
||||
final raw = jsonEncode([
|
||||
{'pubkey': first.public, 'name': 'Token-only community'},
|
||||
]);
|
||||
await secure.write(key: 'buzz_communities', value: raw);
|
||||
await secure.write(key: 'buzz_pubkey', value: first.public);
|
||||
account.bound = first.public;
|
||||
account.backups = [first.public];
|
||||
account.recovery = {
|
||||
'backups': [
|
||||
{
|
||||
'backup_id': 'fixture-backup',
|
||||
'nostr_pubkey': first.public,
|
||||
'secret_key': base64UrlEncode([
|
||||
...List.filled(31, 0),
|
||||
1,
|
||||
]).replaceAll('=', ''),
|
||||
},
|
||||
],
|
||||
'failed_backup_ids': [],
|
||||
};
|
||||
final result = await service.resolve();
|
||||
expect(result.state, HostedIdentityState.ready);
|
||||
expect(result.pubkey, first.public);
|
||||
expect(account.calls, [
|
||||
'/v1/buzz/nsec-backups/status',
|
||||
'/v1/buzz/nsec-backups/recovery',
|
||||
]);
|
||||
expect(await secure.read(key: 'buzz_communities'), raw);
|
||||
expect(await secure.read(key: 'buzz_pubkey'), first.public);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'inventory labels are deduplicated and empty names get a fallback',
|
||||
() async {
|
||||
final raw = jsonEncode([record(first, 'Team'), record(second, ' ')]);
|
||||
await secure.write(key: 'buzz_communities', value: raw);
|
||||
await secure.write(key: 'buzz_workspaces', value: raw);
|
||||
final choices = await DeviceIdentitySource().choices();
|
||||
expect(choices.map((choice) => choice.label), [
|
||||
'Team',
|
||||
'Saved community',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test('cancelled resolution cannot adopt or generate', () async {
|
||||
await secure.write(key: 'buzz_nsec', value: first.nsec);
|
||||
await expectLater(
|
||||
|
||||
107
mobile/test/shared/hosted/identity_choice_lifecycle_test.dart
Normal file
107
mobile/test/shared/hosted/identity_choice_lifecycle_test.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:buzz/shared/deeplink/deep_link.dart';
|
||||
import 'package:buzz/shared/hosted/account_identity_store.dart';
|
||||
import 'package:buzz/shared/hosted/browser_auth.dart';
|
||||
import 'package:buzz/shared/hosted/device_identity_source.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_flow.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_identity_service.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import 'device_identity_reconciliation_test.dart' show InventoryAccount;
|
||||
|
||||
class _Browser extends BrowserAuth {
|
||||
Completer<void>? cancelling;
|
||||
int cleared = 0;
|
||||
@override
|
||||
Future<void> cancel() async => cancelling?.future;
|
||||
@override
|
||||
Future<void> clearIntent() async {
|
||||
cleared++;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final first = nostr.Keys('1'.padLeft(64, '0'));
|
||||
final second = nostr.Keys('2'.padLeft(64, '0'));
|
||||
late InventoryAccount account;
|
||||
late AccountIdentityStore store;
|
||||
late _Browser browser;
|
||||
late HostedFlow flow;
|
||||
const target = AddCommunityDeepLink(
|
||||
relayUrl: 'wss://team.communities.buzz.xyz',
|
||||
name: 'Team',
|
||||
);
|
||||
setUp(() {
|
||||
FlutterSecureStorage.setMockInitialValues({
|
||||
'buzz_communities': jsonEncode([
|
||||
{'name': 'First', 'pubkey': first.public, 'nsec': first.nsec},
|
||||
{'name': 'Second', 'pubkey': second.public, 'nsec': second.nsec},
|
||||
]),
|
||||
});
|
||||
account = InventoryAccount();
|
||||
store = AccountIdentityStore();
|
||||
browser = _Browser();
|
||||
flow = HostedFlow(
|
||||
account: account,
|
||||
browser: browser,
|
||||
store: store,
|
||||
identity: HostedIdentityService(
|
||||
account: account,
|
||||
store: store,
|
||||
deviceIdentities: DeviceIdentitySource(),
|
||||
),
|
||||
enterCommunity: (_, _, _, _) async => fail('Must not bypass binding'),
|
||||
);
|
||||
});
|
||||
tearDown(() async {
|
||||
flow.dispose();
|
||||
await browser.dispose();
|
||||
account.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'unoffered choices cannot restart resolution or perform any remote work',
|
||||
() async {
|
||||
await flow.start(target);
|
||||
final calls = List.of(account.calls);
|
||||
final choices = List.of(flow.identityChoices);
|
||||
await flow.chooseIdentity('0' * 64);
|
||||
expect(flow.stage, HostedFlowStage.chooseIdentity);
|
||||
expect(account.calls, calls);
|
||||
expect(flow.identityChoices, choices);
|
||||
expect(await store.read(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'choices are retired on selection and on leaving; late tap cannot cancel leave',
|
||||
() async {
|
||||
await flow.start(target);
|
||||
browser.cancelling = Completer<void>();
|
||||
final calls = List.of(account.calls);
|
||||
final leaving = flow.leave();
|
||||
await flow.chooseIdentity(first.public);
|
||||
expect(account.calls, calls);
|
||||
expect(await store.read(), isNull);
|
||||
browser.cancelling!.complete();
|
||||
await leaving;
|
||||
expect(browser.cleared, 1);
|
||||
expect(flow.stage, HostedFlowStage.idle);
|
||||
expect(flow.identityChoices, isEmpty);
|
||||
await flow.start(target);
|
||||
final choosing = flow.chooseIdentity(second.public);
|
||||
expect(flow.identityChoices, isEmpty);
|
||||
await choosing;
|
||||
expect(flow.problem, HostedFlowProblem.denied);
|
||||
final completedCalls = List.of(account.calls);
|
||||
await flow.chooseIdentity(first.public);
|
||||
expect(account.calls, completedCalls);
|
||||
expect((await store.read())!.pubkey, second.public);
|
||||
},
|
||||
);
|
||||
}
|
||||
36
mobile/tool/storage/README.md
Normal file
36
mobile/tool/storage/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Android secure-storage failure probe
|
||||
|
||||
This instrumentation test uses the pinned `flutter_secure_storage` plugin,
|
||||
Android Keystore, and the production community/account identity services. It
|
||||
corrupts the authentication tag of synthetic encrypted community and account
|
||||
records. The resolver must report unreadable storage, preserve the damaged
|
||||
ciphertext and other keys, and never generate an identity.
|
||||
|
||||
Use an isolated checkout with its own build outputs and an emulator. In that
|
||||
checkout, set the **gitignored** `android/AppOverrides.properties` to:
|
||||
|
||||
```properties
|
||||
applicationIdSuffix=.identitysafety
|
||||
appName=Buzz Identity Safety Probe
|
||||
```
|
||||
|
||||
From `mobile/android`, with the repository Flutter toolchain and JDK 21:
|
||||
|
||||
```sh
|
||||
ANDROID_SERIAL=emulator-5554 ./gradlew :app:connectedDebugAndroidTest \
|
||||
-Ptarget="$PWD/../tool/storage/android_read_failure_probe.dart" \
|
||||
-Pandroid.testInstrumentationRunnerArguments.class=xyz.block.buzz.mobile.SecureStorageReadFailureTest
|
||||
```
|
||||
|
||||
Verify the emulator serial with `adb devices` first. The test must report one
|
||||
executed test, zero failures and **zero skipped**. It intentionally skips normal
|
||||
app IDs; do not treat that skip as a pass. The native bridge checks the disposable
|
||||
app ID before starting Dart, and the Dart entry point requires that bridge before
|
||||
writing anything. No live account/backend is used. Normal app entry points and
|
||||
release builds do not use this probe.
|
||||
|
||||
The first storage access is through `CommunityStorage`, so hosted reads reuse its
|
||||
cached plugin configuration. Both storage constant variants share fail-closed
|
||||
Android options while retaining their existing iOS settings. Regression control:
|
||||
in an isolated source/build tree, changing the shared `resetOnError` option to
|
||||
`true` must fail the test. Restore the source and rerun the clean control.
|
||||
106
mobile/tool/storage/android_read_failure_probe.dart
Normal file
106
mobile/tool/storage/android_read_failure_probe.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
// Instrumentation-only entry point. Never launches against the normal app ID.
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import 'package:buzz/shared/community/community.dart';
|
||||
import 'package:buzz/shared/community/community_storage.dart';
|
||||
import 'package:buzz/shared/hosted/account_identity_store.dart';
|
||||
import 'package:buzz/shared/hosted/builderlab_account.dart';
|
||||
import 'package:buzz/shared/hosted/device_identity_source.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_identity_service.dart';
|
||||
import 'package:buzz/shared/storage/secure_storage.dart';
|
||||
|
||||
class _Account extends BuilderlabAccountClient {
|
||||
int calls = 0;
|
||||
@override
|
||||
BuilderlabAccount get account =>
|
||||
BuilderlabAccount(subject: 'synthetic', expiresAt: DateTime.utc(2100));
|
||||
@override
|
||||
Future<Map<String, dynamic>> currentIdentity() async {
|
||||
calls++;
|
||||
return {};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> post(
|
||||
String path,
|
||||
Map<String, dynamic> body,
|
||||
) async {
|
||||
calls++;
|
||||
if (!path.endsWith('/status')) throw StateError('Unexpected remote write');
|
||||
return {'backup_nostr_pubkeys': <String>[]};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
const bridge = MethodChannel('buzz/storage_failure_probe');
|
||||
// Native test rejects any application ID other than the disposable probe ID.
|
||||
if (await bridge.invokeMethod<bool>('isolated') != true) return;
|
||||
final account = _Account();
|
||||
try {
|
||||
final keys = nostr.Keys('1'.padLeft(64, '0'));
|
||||
final store = AccountIdentityStore();
|
||||
final source = DeviceIdentitySource();
|
||||
final resolver = HostedIdentityService(
|
||||
account: account,
|
||||
store: store,
|
||||
deviceIdentities: source,
|
||||
);
|
||||
// Exercise first-config-wins: a real community writer initializes the plugin
|
||||
// before the account store and inventory read through that same namespace.
|
||||
await CommunityStorage().saveAll([
|
||||
Community.create(
|
||||
name: 'Synthetic',
|
||||
relayUrl: 'wss://example.invalid',
|
||||
nsec: keys.nsec,
|
||||
pubkey: keys.public,
|
||||
),
|
||||
]);
|
||||
// Only synthetic fixture data in the guarded disposable application.
|
||||
await accountSecureStorage.delete(key: AccountIdentityStore.storageKey);
|
||||
await communitySecureStorage.write(key: 'probe_sentinel', value: 'keep');
|
||||
for (final key in ['buzz_communities', AccountIdentityStore.storageKey]) {
|
||||
if (key == AccountIdentityStore.storageKey) {
|
||||
await store.create(keys.nsec);
|
||||
}
|
||||
final beforeCalls = account.calls;
|
||||
await bridge.invokeMethod<void>('corrupt', {
|
||||
'key': key,
|
||||
'options': secureStorageAndroidOptions.toMap(),
|
||||
});
|
||||
var failedClosed = false;
|
||||
try {
|
||||
await resolver.resolve();
|
||||
} on AccountIdentityFailure catch (failure) {
|
||||
failedClosed = failure == AccountIdentityFailure.unreadable;
|
||||
}
|
||||
if (!failedClosed) throw StateError('Read failure became absence');
|
||||
if (key == 'buzz_communities' && await store.read() != null) {
|
||||
throw StateError('Generated after unreadable inventory');
|
||||
}
|
||||
if (key == AccountIdentityStore.storageKey &&
|
||||
account.calls != beforeCalls) {
|
||||
throw StateError('Unreadable account identity reached remote setup');
|
||||
}
|
||||
if (await communitySecureStorage.read(key: 'probe_sentinel') != 'keep') {
|
||||
throw StateError('Other key lost');
|
||||
}
|
||||
await bridge.invokeMethod<void>('assertPreserved');
|
||||
await bridge.invokeMethod<void>('restore');
|
||||
}
|
||||
if ((await store.read())?.pubkey != keys.public) {
|
||||
throw StateError('Original key did not survive');
|
||||
}
|
||||
await bridge.invokeMethod<void>('done', {'passed': true});
|
||||
} catch (error) {
|
||||
// Diagnostic category only; never expose plugin exception details or keys.
|
||||
await bridge.invokeMethod<void>('done', {
|
||||
'passed': false,
|
||||
'error': error.runtimeType.toString(),
|
||||
});
|
||||
} finally {
|
||||
account.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user