Files
buzz/mobile/test/shared/community/community_storage_test.dart
Taylor Ho d8281b9c93 feat(mobile): require device authentication for identity export (#5116)
**Category:** new-feature
**User Impact:** Mobile users must confirm with Face ID, biometrics, or
their device passcode before sending their Buzz identity to Desktop.

**Problem:** A signed-in phone could send its full identity, including
the `nsec`, to a desktop without fresh local verification.

**Solution:** Require OS device authentication before opening the
identity-recovery scanner, retain that authorization only for the active
pairing session and short pairing window, and require fresh
authentication again if it expires before the identity payload is sent.
Normal app opening, identity import, and community removal remain
unchanged.

## Screencasts

| Enable Face ID | Use Face ID |
| --- | --- |
| ![Enabling Face ID during identity
import](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5116/enable-face-id.gif)
| ![Using Face ID for identity
export](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5116/use-face-id.gif)
|

<details>
<summary>File changes</summary>

**Android and iOS integration**
- `mobile/android/app/build.gradle.kts` declares the AppCompat
dependency required by the biometric activity theme.
-
`mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt`
uses the activity type required by the system authentication prompt.
- `mobile/android/app/src/main/res/values/styles.xml` and
`mobile/android/app/src/main/res/values-night/styles.xml` use the
compatible launch theme.
- `mobile/ios/Podfile.lock` records the native local-authentication
dependency.
- `mobile/ios/Runner/Info.plist` explains why Buzz requests Face ID
access.

**Identity policy and pairing flow**
- `mobile/lib/shared/security/sensitive_action_authorizer.dart` wraps OS
authentication and maps platform errors to stable app-level outcomes.
- `mobile/lib/shared/community/community.dart` and
`mobile/lib/shared/community/community_storage.dart` persist the
sensitive-action policy.
- `mobile/lib/features/invites/invite_join_provider.dart` assigns the
explicit policy for invite-created communities.
- `mobile/lib/features/pairing/pairing_provider.dart` gates export,
binds grants to the active community/session, reauthenticates expired
grants, and clears grants on every terminal path.
- `mobile/lib/features/pairing/pairing_page.dart` lets users choose
biometric protection while importing an identity.
- `mobile/lib/features/settings/settings_page.dart` wires pairing into
settings.
- `mobile/lib/features/settings/settings_page/connection_section.dart`
authenticates before opening export recovery and bounds the
foreground-resume wait.
- `mobile/pubspec.yaml` and `mobile/pubspec.lock` add and lock
`local_auth`.

**Coverage**
- `mobile/test/shared/security/sensitive_action_authorizer_test.dart`
covers native result mapping, unsupported devices, and single-flight
behavior.
- `mobile/test/shared/community/community_test.dart` and
`mobile/test/shared/community/community_storage_test.dart` cover policy
defaults and persistence.
- `mobile/test/features/invites/invite_join_provider_test.dart` covers
the invite policy.
- `mobile/test/features/pairing/pairing_page_test.dart` covers import
protection controls.
- `mobile/test/features/pairing/pairing_provider_test.dart` covers
export/import authorization, stale/reset/concurrent guards, malformed
payload cleanup, and no-export failure paths.
- `mobile/test/features/settings/connection_section_test.dart` covers
the tap gate, lifecycle resume, and timeout behavior.

</details>

## Reproduction steps

1. Pair an identity into the mobile app.
2. Open Settings and choose “Send identity to desktop.”
3. Verify Face ID, biometrics, or the device passcode is required before
the recovery scanner opens.
4. Cancel device authentication and verify the scanner does not open and
no identity transfer begins.
5. Authenticate, scan a Desktop recovery code, confirm the SAS, and
verify the identity transfer completes.

## Validation

At `be5620f5f10aa6cc16e86a4f01f102f3d9aeef9b`:
- `cd mobile && ../bin/flutter analyze` — no issues
- `cd mobile && ../bin/flutter test` — 1,368 tests passed
- `cd mobile/android && JAVA_HOME=$(/usr/libexec/java_home -v 21)
./gradlew app:assembleDebug` — debug APK assembled successfully

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-08-15 18:34:02 -07:00

242 lines
7.1 KiB
Dart

import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/community/community_storage.dart';
/// In-memory fake that extends Fake to satisfy all FlutterSecureStorage
/// interface methods, but implements the core read/write/delete with real
/// in-memory logic.
class FakeSecureStorage extends Fake implements FlutterSecureStorage {
final Map<String, String> _data = {};
@override
Future<String?> read({
required String key,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async => _data[key];
@override
Future<void> write({
required String key,
required String? value,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async {
if (value != null) {
_data[key] = value;
} else {
_data.remove(key);
}
}
@override
Future<void> delete({
required String key,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async => _data.remove(key);
@override
Future<Map<String, String>> readAll({
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async => Map.from(_data);
@override
Future<void> deleteAll({
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async => _data.clear();
@override
Future<bool> containsKey({
required String key,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async => _data.containsKey(key);
// Convenience for setting up test data.
String? operator [](String key) => _data[key];
void operator []=(String key, String value) => _data[key] = value;
}
void main() {
late FakeSecureStorage fakeSecure;
late CommunityStorage storage;
setUp(() {
fakeSecure = FakeSecureStorage();
storage = CommunityStorage(secure: fakeSecure);
});
group('CommunityStorage', () {
test('loadAll returns empty list when no data', () async {
final result = await storage.loadAll();
expect(result, isEmpty);
});
test('save and loadAll round-trips a community', () async {
final ws = Community.create(
name: 'Test',
relayUrl: 'https://relay.example.com',
pubkey: 'abc123',
);
await storage.save(ws);
final loaded = await storage.loadAll();
expect(loaded, hasLength(1));
expect(loaded.first.id, ws.id);
expect(loaded.first.name, 'Test');
expect(loaded.first.relayUrl, 'https://relay.example.com');
expect(loaded.first.pubkey, 'abc123');
});
test('save updates existing community with same id', () async {
final ws = Community.create(
name: 'Original',
relayUrl: 'https://relay.example.com',
);
await storage.save(ws);
await storage.save(ws.copyWith(name: 'Updated'));
final loaded = await storage.loadAll();
expect(loaded, hasLength(1));
expect(loaded.first.name, 'Updated');
});
test('remove deletes a community', () async {
final ws1 = Community.create(
name: 'One',
relayUrl: 'https://one.example.com',
);
final ws2 = Community.create(
name: 'Two',
relayUrl: 'https://two.example.com',
);
await storage.save(ws1);
await storage.save(ws2);
await storage.remove(ws1.id);
final loaded = await storage.loadAll();
expect(loaded, hasLength(1));
expect(loaded.first.id, ws2.id);
});
test('active community ID persists', () async {
await storage.saveActiveId('ws-123');
final id = await storage.loadActiveId();
expect(id, 'ws-123');
});
test('clearActiveId removes active ID', () async {
await storage.saveActiveId('ws-123');
await storage.clearActiveId();
final id = await storage.loadActiveId();
expect(id, isNull);
});
group('migration', () {
test('migrates saved workspaces to communities', () async {
final legacy = Community.create(
name: 'Legacy',
relayUrl: 'https://legacy.example.com',
);
fakeSecure['buzz_workspaces'] = jsonEncode([legacy.toJson()]);
fakeSecure['buzz_active_workspace_id'] = legacy.id;
final loaded = await storage.loadAll();
expect(loaded.single.id, legacy.id);
expect(await storage.loadActiveId(), legacy.id);
expect(fakeSecure['buzz_communities'], isNotNull);
expect(fakeSecure['buzz_workspaces'], isNull);
expect(fakeSecure['buzz_active_workspace_id'], isNull);
});
test('migrates legacy keys to community on first load', () async {
fakeSecure['buzz_relay_url'] = 'https://legacy.example.com';
fakeSecure['buzz_token'] = 'legacy_token';
fakeSecure['buzz_pubkey'] = 'legacy_pub';
fakeSecure['buzz_nsec'] = 'legacy_nsec';
final loaded = await storage.loadAll();
expect(loaded, hasLength(1));
expect(loaded.first.relayUrl, 'https://legacy.example.com');
expect(loaded.first.pubkey, 'legacy_pub');
expect(loaded.first.nsec, 'legacy_nsec');
expect(loaded.first.name, isNotEmpty);
expect(
loaded.first.sensitiveActionPolicy,
SensitiveActionPolicy.disabledByUser,
);
// Legacy keys should be deleted.
expect(fakeSecure['buzz_relay_url'], isNull);
expect(fakeSecure['buzz_token'], isNull);
expect(fakeSecure['buzz_pubkey'], isNull);
expect(fakeSecure['buzz_nsec'], isNull);
// Active ID should be set.
final activeId = await storage.loadActiveId();
expect(activeId, loaded.first.id);
});
test('does not migrate when no legacy keys exist', () async {
final loaded = await storage.loadAll();
expect(loaded, isEmpty);
});
test('does not re-migrate after first load', () async {
fakeSecure['buzz_relay_url'] = 'https://legacy.example.com';
fakeSecure['buzz_token'] = 'legacy_token';
final first = await storage.loadAll();
expect(first, hasLength(1));
final second = await storage.loadAll();
expect(second, hasLength(1));
expect(second.first.id, first.first.id);
});
test('migration generates name from localhost URL', () async {
fakeSecure['buzz_relay_url'] = 'http://localhost:3000';
fakeSecure['buzz_token'] = 'tok';
final loaded = await storage.loadAll();
expect(loaded.first.name, 'Local Dev');
});
});
});
}