mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(mobile): bind imported keys to iOS devices (BUZZ-SEC-013)
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user