Encrypted app catalog relay list, make secure storage the source of truth for now

This commit is contained in:
franzap
2026-01-29 17:37:26 -03:00
parent 1b0915d01e
commit fa67678123
5 changed files with 238 additions and 87 deletions
+12 -4
View File
@@ -201,6 +201,8 @@ class ZapstoreHome extends StatelessWidget {
}
}
const _kDefaultAppCatalogRelay = 'wss://relay.zapstore.dev';
final appInitializationProvider = FutureProvider<void>((ref) async {
final dir = await getApplicationSupportDirectory();
final dbPath = path.join(dir.path, 'zapstore.db');
@@ -208,7 +210,13 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
// Clear storage if requested from a clear all operation
await maybeClearStorage(dbPath);
// Initialize storage
// Load local relay config BEFORE storage init
// This ensures custom relays work even when signed out
final secureStorage = ref.read(secureStorageServiceProvider);
final localRelays = await secureStorage.getAppCatalogRelays();
final appCatalogRelays = localRelays ?? {_kDefaultAppCatalogRelay};
// Initialize storage with local relay config
await ref.read(
initializationProvider(
StorageConfiguration(
@@ -218,10 +226,10 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
stream: false,
),
defaultRelays: {
'default': {'wss://relay.zapstore.dev'},
'bootstrap': {'wss://relay.zapstore.dev'},
'default': {_kDefaultAppCatalogRelay},
'bootstrap': {_kDefaultAppCatalogRelay},
// TODO: add 'wss://purplepag.es' back when it's fixed
'AppCatalog': {'wss://relay.zapstore.dev'},
'AppCatalog': appCatalogRelays,
'social': {
'wss://relay.damus.io',
'wss://relay.primal.net',
+38 -2
View File
@@ -1,9 +1,12 @@
import 'dart:convert';
import 'package:amber_signer/amber_signer.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Service for securely storing sensitive data (NWC connection strings)
/// using platform-native secure storage (Keychain on iOS, KeyStore on Android).
/// Service for securely storing sensitive data (NWC connection strings,
/// app catalog relays) using platform-native secure storage
/// (Keychain on iOS, KeyStore on Android).
///
/// This does NOT require user authentication - data is encrypted at rest
/// by the platform's secure storage mechanism.
@@ -13,6 +16,7 @@ class SecureStorageService {
static const _storage = FlutterSecureStorage();
static const _nwcKey = 'nwc_connection_string';
static const _appCatalogRelaysKey = 'app_catalog_relays';
/// Get the stored NWC connection string
Future<String?> getNWCString() async {
@@ -35,6 +39,38 @@ class SecureStorageService {
final value = await _storage.read(key: _nwcKey);
return value?.isNotEmpty == true;
}
// =========================================================================
// App Catalog Relays
// =========================================================================
/// Get the stored app catalog relay URLs.
///
/// Returns null if no relays have been stored (use defaults).
/// Returns empty set if user explicitly cleared all relays (invalid state,
/// but handled gracefully).
Future<Set<String>?> getAppCatalogRelays() async {
final json = await _storage.read(key: _appCatalogRelaysKey);
if (json == null || json.isEmpty) return null;
try {
final list = jsonDecode(json) as List;
return Set<String>.from(list.cast<String>());
} catch (e) {
// Corrupted data - treat as unset
return null;
}
}
/// Store app catalog relay URLs.
///
/// This is the local source of truth for relay configuration,
/// used to initialize the app before sign-in.
Future<void> setAppCatalogRelays(Set<String> relays) async {
await _storage.write(
key: _appCatalogRelaysKey,
value: jsonEncode(relays.toList()),
);
}
}
/// Persists the AmberSigner pubkey in flutter_secure_storage.
+173 -66
View File
@@ -6,10 +6,12 @@ import 'package:models/models.dart';
import 'package:purplebase/purplebase.dart';
import 'package:zapstore/services/app_restart_service.dart';
import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/services/secure_storage_service.dart';
import 'package:zapstore/theme.dart';
import 'package:zapstore/utils/extensions.dart';
/// Provider for the user's app catalog relay list.
/// Provider for the user's app catalog relay list (from signed 10067 event).
/// Returns null when signed out.
final _appCatalogRelayListProvider =
Provider<StorageState<AppCatalogRelayList>?>((ref) {
final pubkey = ref.watch(Signer.activePubkeyProvider);
@@ -31,6 +33,7 @@ final _appCatalogRelayListProvider =
);
});
/// App Catalog Relay Management Card - manages app catalog relays (kind 10067)
/// These are relays for discovering apps, NOT social relays like Damus/Primal.
///
@@ -44,11 +47,7 @@ class RelayManagementCard extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
// Only show when user is signed in
if (signedInPubkey == null) {
return const SizedBox.shrink();
}
final isSignedIn = signedInPubkey != null;
final relayUrlController = useTextEditingController();
final hasText = useState(false);
@@ -57,27 +56,50 @@ class RelayManagementCard extends HookConsumerWidget {
// Watch pool state for relay connection status
final poolState = ref.watch(poolStateProvider);
// Watch remote relay list
final relayListState = ref.watch(_appCatalogRelayListProvider);
// Load local relays once - they only change on app restart
final localRelaysFuture = useMemoized(
() => ref.read(secureStorageServiceProvider).getAppCatalogRelays(),
);
final localRelaysSnapshot = useFuture(localRelaysFuture);
final localRelays = localRelaysSnapshot.data?.toList()?..sort();
final hasLocalRelays = localRelays != null && localRelays.isNotEmpty;
// Only check 10067 if no local relays are stored
// This makes secure storage the authoritative local source
final relayListState = hasLocalRelays
? null
: ref.watch(_appCatalogRelayListProvider);
final existingRelayList = relayListState?.models.firstOrNull;
final savedRelays = (existingRelayList?.readRelays ?? <String>{}).toList()
final remoteRelays = (existingRelayList?.readRelays ?? <String>{}).toList()
..sort();
// If no relays saved, show default relay
final effectiveSavedRelays = savedRelays.isEmpty
? [_kDefaultRelay]
: savedRelays;
// Determine effective saved relays:
// 1. Local secure storage (if set) - always wins
// 2. Remote 10067 (if signed in and no local relays)
// 3. Default relay
List<String> effectiveSavedRelays;
if (hasLocalRelays) {
effectiveSavedRelays = localRelays;
} else if (remoteRelays.isNotEmpty) {
effectiveSavedRelays = remoteRelays;
} else {
effectiveSavedRelays = [_kDefaultRelay];
}
// Local pending state - initialized from effective saved relays
final pendingRelays = useState<List<String>?>(null);
// Determine if data is still loading
final isLoading = localRelaysSnapshot.connectionState == ConnectionState.waiting ||
(!hasLocalRelays && relayListState is StorageLoading);
// Initialize pending from effective saved when first loaded
useEffect(() {
if (pendingRelays.value == null && relayListState is StorageData) {
if (pendingRelays.value == null && !isLoading) {
pendingRelays.value = effectiveSavedRelays;
}
return null;
}, [relayListState]);
}, [isLoading, effectiveSavedRelays]);
// Current display relays (pending if modified, else effective saved)
final displayRelays = pendingRelays.value ?? effectiveSavedRelays;
@@ -110,7 +132,7 @@ class RelayManagementCard extends HookConsumerWidget {
}
// Check for duplicates
final currentRelays = pendingRelays.value ?? savedRelays;
final currentRelays = pendingRelays.value ?? effectiveSavedRelays;
if (_isDuplicateRelay(normalizedUrl, currentRelays.toSet())) {
context.showError(
'Relay already exists',
@@ -124,7 +146,7 @@ class RelayManagementCard extends HookConsumerWidget {
}
void removeRelay(String relayUrl) {
final currentRelays = pendingRelays.value ?? savedRelays;
final currentRelays = pendingRelays.value ?? effectiveSavedRelays;
final newRelays = currentRelays.where((r) => r != relayUrl).toList();
// App catalog relays can never be empty - show error if trying to remove last
if (newRelays.isEmpty) {
@@ -140,65 +162,57 @@ class RelayManagementCard extends HookConsumerWidget {
}
Future<void> applyChanges() async {
final confirmed = await showDialog<bool>(
// Show confirmation dialog with privacy option (only when signed in)
final result = await showDialog<({bool confirmed, bool makePrivate})>(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.dns, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text('Apply Relay Changes'),
),
),
],
),
content: const Text(
'Changing app catalog relays will clear cached app data and restart the app. '
'Your sign-in and wallet connection will be preserved.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Apply Changes'),
),
],
),
builder: (context) => _ApplyRelayChangesDialog(isSignedIn: isSignedIn),
);
if (confirmed != true || !context.mounted) return;
if (result?.confirmed != true || !context.mounted) return;
isApplying.value = true;
var loadingDialogShown = false;
try {
final signer = ref.read(Signer.activeSignerProvider);
if (signer == null) {
isApplying.value = false;
if (context.mounted) {
context.showError('Sign in required');
final secureStorage = ref.read(secureStorageServiceProvider);
final relaysToSave = displayRelays.toSet();
// Always save to local secure storage (works signed out or in)
await secureStorage.setAppCatalogRelays(relaysToSave);
// If signed in, also publish 10067 event for cross-device sync
if (isSignedIn) {
final signer = ref.read(Signer.activeSignerProvider);
if (signer == null) {
isApplying.value = false;
if (context.mounted) {
context.showError('Sign in required to publish relay list');
}
return;
}
return;
}
// Create and sign the relay list (use displayRelays which reflects user's intent)
final partialRelayList = PartialAppCatalogRelayList();
for (final relay in displayRelays) {
partialRelayList.addReadRelay(relay);
}
final signedRelayList = await partialRelayList.signWith(signer);
// Create relay list (private or public based on user choice)
final PartialAppCatalogRelayList partialRelayList;
if (result!.makePrivate) {
// Encrypted: all relays in content field
partialRelayList = PartialAppCatalogRelayList.withEncryptedRelays(
publicRelays: {},
privateRelays: relaysToSave,
);
} else {
// Public: relays in r tags
partialRelayList = PartialAppCatalogRelayList();
for (final relay in displayRelays) {
partialRelayList.addReadRelay(relay);
}
}
final signedRelayList = await partialRelayList.signWith(signer);
// Publish to bootstrap relays
await ref.storage.publish({
signedRelayList,
}, source: const RemoteSource(relays: 'bootstrap'));
// Publish to bootstrap relays
await ref.storage.publish({
signedRelayList,
}, source: const RemoteSource(relays: 'bootstrap'));
}
// Show loading dialog
if (context.mounted) {
@@ -281,7 +295,9 @@ class RelayManagementCard extends HookConsumerWidget {
const SizedBox(width: 10),
Expanded(
child: Text(
'These relays are used to discover apps, not social content. Modify this list at your own risk.',
isSignedIn
? 'These relays are used to discover apps, not social content. Modify this list at your own risk.'
: 'These relays are used to discover apps. Sign in to sync relay settings across devices.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
@@ -599,3 +615,94 @@ class RelayManagementCard extends HookConsumerWidget {
};
}
}
/// Dialog for confirming relay changes with privacy option.
class _ApplyRelayChangesDialog extends HookWidget {
const _ApplyRelayChangesDialog({required this.isSignedIn});
final bool isSignedIn;
@override
Widget build(BuildContext context) {
final makePrivate = useState(false);
return AlertDialog(
title: Row(
children: [
Icon(Icons.dns, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text('Apply Relay Changes'),
),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Changing app catalog relays will clear cached app data and restart the app. '
'Your sign-in and wallet connection will be preserved.',
),
if (isSignedIn) ...[
const SizedBox(height: 16),
CheckboxListTile(
value: makePrivate.value,
onChanged: (v) => makePrivate.value = v ?? false,
title: const Text('Make relay selection private'),
subtitle: const Text(
'Encrypted — only you can see these relays',
),
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
),
],
if (!isSignedIn) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Sign in to sync relay settings across devices.',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, null),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(
context,
(confirmed: true, makePrivate: makePrivate.value),
),
child: const Text('Apply Changes'),
),
],
);
}
}
+14 -14
View File
@@ -581,26 +581,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "10.0.9"
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.9"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
lints:
dependency: transitive
description:
@@ -653,10 +653,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -669,8 +669,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "641e29c"
resolved-ref: "641e29caa931bae99214cd44d8708c3da10ff4dd"
ref: "3c76e2e"
resolved-ref: "3c76e2e1cd5145cb57303d3ac4ff25ddb71d73bb"
url: "https://github.com/purplebase/models"
source: git
version: "0.3.3"
@@ -1141,10 +1141,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.4"
version: "0.7.7"
timezone:
dependency: transitive
description:
@@ -1261,10 +1261,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description:
+1 -1
View File
@@ -61,7 +61,7 @@ dependency_overrides:
# path: ../../purplebase/models
git:
url: https://github.com/purplebase/models
ref: 641e29c
ref: 3c76e2e
purplebase:
# path: ../../purplebase/purplebase
git: