diff --git a/mobile/lib/shared/community/community_storage.dart b/mobile/lib/shared/community/community_storage.dart index 20f65b01c..48bd1f763 100644 --- a/mobile/lib/shared/community/community_storage.dart +++ b/mobile/lib/shared/community/community_storage.dart @@ -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> 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 loadActiveId() async { - return _secure.read(key: _keyActiveId); + return _legacySecure.read(key: _keyActiveId); } Future saveActiveId(String id) async { - await _secure.write(key: _keyActiveId, value: id); + await _legacySecure.write(key: _keyActiveId, value: id); } Future clearActiveId() async { - await _secure.delete(key: _keyActiveId); + await _legacySecure.delete(key: _keyActiveId); } List _decodeList(String raw) { @@ -105,7 +130,33 @@ class CommunityStorage { } Future _saveList(List 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 _saveListAndVerify(List 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 communities) => + jsonEncode(communities.map((item) => item.toJson()).toList()); + + Future _deleteLegacyCommunityCopies() async { + for (final key in [ + _migratableCommunities, + _legacyCommunities, + _legacyRelayUrl, + _legacyToken, + _legacyPubkey, + _legacyNsec, + ]) { + if (await _legacySecure.containsKey(key: key)) { + await _legacySecure.delete(key: key); + } + } } } diff --git a/mobile/test/shared/community/community_storage_test.dart b/mobile/test/shared/community/community_storage_test.dart index dc0835e62..28be4bce0 100644 --- a/mobile/test/shared/community/community_storage_test.dart +++ b/mobile/test/shared/community/community_storage_test.dart @@ -10,6 +10,7 @@ import 'package:buzz/shared/community/community_storage.dart'; /// in-memory logic. class FakeSecureStorage extends Fake implements FlutterSecureStorage { final Map _data = {}; + bool dropDeviceBoundCommunityWrites = false; @override Future 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';