fix(mobile): bind imported keys to iOS devices (BUZZ-SEC-013)

This commit is contained in:
Jordan Mecom
2026-07-15 11:31:52 -07:00
parent 371ab872e1
commit a5ae40e034
2 changed files with 131 additions and 25 deletions
@@ -1,13 +1,22 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'community.dart';
class CommunityStorage {
static const _keyCommunities = 'buzz_communities';
static const _keyCommunities = 'buzz_communities_device_bound_v1';
static const _keyActiveId = 'buzz_active_community_id';
// These pre-device-bound keys may be present after an app upgrade.
static const _migratableCommunities = 'buzz_communities';
static const _iosOptions = IOSOptions(
accessibility: KeychainAccessibility.unlocked_this_device,
synchronizable: false,
);
// Legacy keys for migration.
static const _legacyCommunities = 'buzz_workspaces';
static const _legacyActiveId = 'buzz_active_workspace_id';
@@ -17,33 +26,53 @@ class CommunityStorage {
static const _legacyNsec = 'buzz_nsec';
final FlutterSecureStorage _secure;
final FlutterSecureStorage _legacySecure;
CommunityStorage({FlutterSecureStorage? secure})
: _secure = secure ?? const FlutterSecureStorage();
CommunityStorage({
FlutterSecureStorage? secure,
FlutterSecureStorage? legacySecure,
}) : _secure = secure ?? const FlutterSecureStorage(iOptions: _iosOptions),
_legacySecure = legacySecure ?? secure ?? const FlutterSecureStorage();
/// Effective iOS options used by the default storage client.
@visibleForTesting
IOSOptions get iosOptionsForTesting => _secure.iOptions;
/// Load all communities. On first call, migrates legacy single-community
/// credentials if present.
Future<List<Community>> loadAll() async {
final raw = await _secure.read(key: _keyCommunities);
if (raw != null) return _decodeList(raw);
if (raw != null) {
final communities = _decodeList(raw);
await _deleteLegacyCommunityCopies();
return communities;
}
final legacyCommunities = await _secure.read(key: _legacyCommunities);
final migratable = await _legacySecure.read(key: _migratableCommunities);
if (migratable != null) {
final communities = _decodeList(migratable);
await _saveListAndVerify(communities);
await _deleteLegacyCommunityCopies();
return communities;
}
final legacyCommunities = await _legacySecure.read(key: _legacyCommunities);
if (legacyCommunities != null) {
final communities = _decodeList(legacyCommunities);
await _saveList(communities);
final legacyActiveId = await _secure.read(key: _legacyActiveId);
await _saveListAndVerify(communities);
final legacyActiveId = await _legacySecure.read(key: _legacyActiveId);
if (legacyActiveId != null) await saveActiveId(legacyActiveId);
await _secure.delete(key: _legacyCommunities);
await _secure.delete(key: _legacyActiveId);
await _deleteLegacyCommunityCopies();
await _legacySecure.delete(key: _legacyActiveId);
return communities;
}
// Migration: check for legacy single-community keys.
final legacyUrl = await _secure.read(key: _legacyRelayUrl);
final legacyToken = await _secure.read(key: _legacyToken);
final legacyUrl = await _legacySecure.read(key: _legacyRelayUrl);
final legacyToken = await _legacySecure.read(key: _legacyToken);
if (legacyUrl != null && legacyToken != null) {
final legacyPubkey = await _secure.read(key: _legacyPubkey);
final legacyNsec = await _secure.read(key: _legacyNsec);
final legacyPubkey = await _legacySecure.read(key: _legacyPubkey);
final legacyNsec = await _legacySecure.read(key: _legacyNsec);
final name = Community.nameFromUrl(legacyUrl);
final community = Community.create(
@@ -53,14 +82,10 @@ class CommunityStorage {
nsec: legacyNsec,
);
await _saveList([community]);
await _saveListAndVerify([community]);
await saveActiveId(community.id);
// Delete legacy keys.
await _secure.delete(key: _legacyRelayUrl);
await _secure.delete(key: _legacyToken);
await _secure.delete(key: _legacyPubkey);
await _secure.delete(key: _legacyNsec);
await _deleteLegacyCommunityCopies();
return [community];
}
@@ -86,15 +111,15 @@ class CommunityStorage {
}
Future<String?> loadActiveId() async {
return _secure.read(key: _keyActiveId);
return _legacySecure.read(key: _keyActiveId);
}
Future<void> saveActiveId(String id) async {
await _secure.write(key: _keyActiveId, value: id);
await _legacySecure.write(key: _keyActiveId, value: id);
}
Future<void> clearActiveId() async {
await _secure.delete(key: _keyActiveId);
await _legacySecure.delete(key: _keyActiveId);
}
List<Community> _decodeList(String raw) {
@@ -105,7 +130,33 @@ class CommunityStorage {
}
Future<void> _saveList(List<Community> communities) async {
final json = jsonEncode(communities.map((item) => item.toJson()).toList());
await _secure.write(key: _keyCommunities, value: json);
await _secure.write(key: _keyCommunities, value: _encodeList(communities));
}
Future<void> _saveListAndVerify(List<Community> communities) async {
final encoded = _encodeList(communities);
await _secure.write(key: _keyCommunities, value: encoded);
final stored = await _secure.read(key: _keyCommunities);
if (stored != encoded) {
throw StateError('failed to verify device-bound community storage');
}
}
String _encodeList(List<Community> communities) =>
jsonEncode(communities.map((item) => item.toJson()).toList());
Future<void> _deleteLegacyCommunityCopies() async {
for (final key in [
_migratableCommunities,
_legacyCommunities,
_legacyRelayUrl,
_legacyToken,
_legacyPubkey,
_legacyNsec,
]) {
if (await _legacySecure.containsKey(key: key)) {
await _legacySecure.delete(key: key);
}
}
}
}
@@ -10,6 +10,7 @@ import 'package:buzz/shared/community/community_storage.dart';
/// in-memory logic.
class FakeSecureStorage extends Fake implements FlutterSecureStorage {
final Map<String, String> _data = {};
bool dropDeviceBoundCommunityWrites = false;
@override
Future<String?> read({
@@ -33,6 +34,10 @@ class FakeSecureStorage extends Fake implements FlutterSecureStorage {
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async {
if (dropDeviceBoundCommunityWrites &&
key == 'buzz_communities_device_bound_v1') {
return;
}
if (value != null) {
_data[key] = value;
} else {
@@ -97,6 +102,16 @@ void main() {
});
group('CommunityStorage', () {
test('default iOS storage is device-bound and not synchronizable', () {
final defaultStorage = CommunityStorage();
expect(
defaultStorage.iosOptionsForTesting.accessibility,
KeychainAccessibility.unlocked_this_device,
);
expect(defaultStorage.iosOptionsForTesting.synchronizable, isFalse);
});
test('loadAll returns empty list when no data', () async {
final result = await storage.loadAll();
expect(result, isEmpty);
@@ -178,11 +193,51 @@ void main() {
expect(loaded.single.id, legacy.id);
expect(await storage.loadActiveId(), legacy.id);
expect(fakeSecure['buzz_communities'], isNotNull);
expect(fakeSecure['buzz_communities_device_bound_v1'], isNotNull);
expect(fakeSecure['buzz_workspaces'], isNull);
expect(fakeSecure['buzz_active_workspace_id'], isNull);
});
test(
'moves an existing community blob to the device-bound key',
() async {
final legacy = Community.create(
name: 'Migratable',
relayUrl: 'https://legacy.example.com',
nsec: 'nsec1devicebound',
);
fakeSecure['buzz_communities'] = jsonEncode([legacy.toJson()]);
fakeSecure['buzz_active_community_id'] = legacy.id;
final loaded = await storage.loadAll();
final activeId = await storage.loadActiveId();
expect(loaded.single.nsec, 'nsec1devicebound');
expect(activeId, legacy.id);
expect(fakeSecure['buzz_communities_device_bound_v1'], isNotNull);
expect(fakeSecure['buzz_communities'], isNull);
expect(fakeSecure['buzz_active_community_id'], legacy.id);
},
);
test(
'keeps the migratable copy when protected write verification fails',
() async {
final legacy = Community.create(
name: 'Migratable',
relayUrl: 'https://legacy.example.com',
nsec: 'nsec1stillavailable',
);
fakeSecure['buzz_communities'] = jsonEncode([legacy.toJson()]);
fakeSecure.dropDeviceBoundCommunityWrites = true;
await expectLater(storage.loadAll(), throwsStateError);
expect(fakeSecure['buzz_communities'], isNotNull);
expect(fakeSecure['buzz_communities_device_bound_v1'], isNull);
},
);
test('migrates legacy keys to community on first load', () async {
fakeSecure['buzz_relay_url'] = 'https://legacy.example.com';
fakeSecure['buzz_token'] = 'legacy_token';