From f1e97925df05a661e7cce693298b569fcf1cbcd0 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Wed, 15 Jul 2026 13:41:45 -0300 Subject: [PATCH] Replace capsule recovery with portable device state, Amber key backup, and legacy installed-app restore --- lib/constants/app_constants.dart | 8 +- lib/main.dart | 19 + lib/screens/profile_screen.dart | 208 ++++-- lib/services/device_backup_service.dart | 646 ++++++------------ lib/services/device_key_service.dart | 148 ++-- lib/services/device_private_sync_service.dart | 67 +- lib/services/device_state_service.dart | 155 +++++ lib/services/settings_service.dart | 313 +++++---- lib/services/trusted_signers_service.dart | 101 +-- lib/widgets/device_backup_dialog.dart | 61 +- lib/widgets/device_restore_dialog.dart | 100 +++ lib/widgets/legacy_installed_apps_dialog.dart | 61 ++ spec/features/FEAT-006-device-key.md | 178 ++--- spec/work/WORK-019-simplify-device-backup.md | 89 +++ test/services/device_backup_service_test.dart | 120 +--- test/services/device_key_service_test.dart | 17 + test/services/settings_service_test.dart | 34 +- 17 files changed, 1203 insertions(+), 1122 deletions(-) create mode 100644 lib/services/device_state_service.dart create mode 100644 lib/widgets/device_restore_dialog.dart create mode 100644 lib/widgets/legacy_installed_apps_dialog.dart create mode 100644 spec/work/WORK-019-simplify-device-backup.md create mode 100644 test/services/device_key_service_test.dart diff --git a/lib/constants/app_constants.dart b/lib/constants/app_constants.dart index 76a2ae2..e23b4b7 100644 --- a/lib/constants/app_constants.dart +++ b/lib/constants/app_constants.dart @@ -28,11 +28,11 @@ const kInstalledAppsIdentifier = 'zapstore-installed-apps'; /// Identifier for the encrypted stack of apps the user chose as unmanaged const kUnmanagedAppsIdentifier = 'zapstore-unmanaged-apps'; -/// Identifier for device-private settings and recovery capsules -const kSettingsIdentifier = 'zapstore-settings'; +/// Identifier for the device-owned encrypted portable settings record. +const kDeviceStateIdentifier = 'zapstore-device-state'; -/// Identifier for the local encrypted trusted-signer preference -const kTrustedSignersIdentifier = 'trusted-signers'; +/// Identifier for the Amber-owned encrypted device-key backup record. +const kDeviceKeyBackupIdentifier = 'zapstore-device-key-backup'; /// Authoritative relay for NIP-82 software application events (kind 32267). /// Used as the relay hint in naddr encoding so other clients can resolve app events. diff --git a/lib/main.dart b/lib/main.dart index c7e3e0a..a7bf974 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,7 +26,9 @@ import 'package:zapstore/services/package_manager/dummy_package_manager.dart'; import 'package:zapstore/services/deep_link_service.dart'; import 'package:zapstore/services/device_backup_service.dart'; import 'package:zapstore/services/device_key_service.dart'; +import 'package:zapstore/services/device_private_event_service.dart'; import 'package:zapstore/services/device_private_sync_service.dart'; +import 'package:zapstore/services/device_state_service.dart'; import 'package:zapstore/services/app_catalog_relay_service.dart'; import 'package:zapstore/utils/debug_utils.dart'; import 'package:zapstore/utils/extensions.dart'; @@ -193,6 +195,9 @@ class ZapstoreApp extends HookConsumerWidget { // Check initial connectivity state connectivity.checkConnectivity().then((results) { notifier.connect(); + unawaited( + ref.read(devicePrivateEventServiceProvider).processPendingEvents(), + ); unawaited(ref.read(appCatalogRelayServiceProvider).checkForUpdates()); }); @@ -200,6 +205,9 @@ class ZapstoreApp extends HookConsumerWidget { subscription = connectivity.onConnectivityChanged.listen((results) { notifier.connect(); if (results.any((result) => result != ConnectivityResult.none)) { + unawaited( + ref.read(devicePrivateEventServiceProvider).processPendingEvents(), + ); unawaited(ref.read(appCatalogRelayServiceProvider).checkForUpdates()); } }); @@ -411,8 +419,13 @@ final storageReadyProvider = FutureProvider((ref) async { final appInitializationProvider = FutureProvider((ref) async { await ref.read(storageReadyProvider.future); + // There is no bootstrap publication. Mark portable state usable before + // background draft mining and relay work begin. + await ref.read(deviceStateProvider.notifier).bootstrap(); + // Private relay ingestion is one-shot, non-streaming, and never gates UI. unawaited(ref.read(devicePrivateSyncProvider.notifier).start()); + unawaited(ref.read(devicePrivateEventServiceProvider).processPendingEvents()); // Initialize device capabilities (used for dynamic download concurrency) await DeviceCapabilitiesCache.initialize(); @@ -434,6 +447,9 @@ final appInitializationProvider = FutureProvider((ref) async { await _attemptAutoSignIn(ref); WidgetsBinding.instance.addPostFrameCallback((_) { + if (ref.read(Signer.activePubkeyProvider) == null) { + unawaited(maybeOfferInitialDeviceRestore(ref)); + } unawaited(ref.read(appCatalogRelayServiceProvider).checkForUpdates()); }); }); @@ -571,6 +587,9 @@ class _AppLifecycleObserver with WidgetsBindingObserver { // Reconnect storage/relay connections notifier.connect(); + unawaited( + _ref.read(devicePrivateEventServiceProvider).processPendingEvents(), + ); unawaited(_ref.read(appCatalogRelayServiceProvider).checkForUpdates()); } else if (state == AppLifecycleState.paused) { _ref.read(appCatalogRelayServiceProvider).cancelCurrentCheck(); diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 3aebc9f..2ca87bf 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -16,6 +16,8 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:purplebase/purplebase.dart'; import 'package:zapstore/main.dart'; import 'package:zapstore/services/device_key_service.dart'; +import 'package:zapstore/services/device_private_event_service.dart'; +import 'package:zapstore/services/device_state_service.dart'; import 'package:zapstore/services/log_service.dart' as app_logs; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/settings_service.dart'; @@ -1404,12 +1406,38 @@ class _DeviceKeyCard extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final devicePubkey = ref.watch(devicePubkeyProvider); + final deviceState = ref.watch(deviceStateProvider); if (devicePubkey == null) return const SizedBox.shrink(); final npub = bech32Encode('npub', devicePubkey); final shortened = '${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}'; final isCopying = useState(false); + final powElapsed = useState(Duration.zero); + useEffect(() { + final startedAt = deviceState.startedAt; + if (deviceState.phase != DeviceStatePhase.bootstrapping || + startedAt == null) { + return null; + } + + void updateElapsed() { + powElapsed.value = DateTime.now().difference(startedAt); + } + + updateElapsed(); + final timer = Timer.periodic(const Duration(seconds: 1), (_) { + updateElapsed(); + }); + return timer.cancel; + }, [deviceState.phase, deviceState.startedAt]); + + String formatElapsed(Duration elapsed) { + final seconds = elapsed.inSeconds; + final minutes = seconds ~/ 60; + final remainingSeconds = seconds % 60; + return '$minutes:${remainingSeconds.toString().padLeft(2, '0')}'; + } return Container( padding: const EdgeInsets.all(12), @@ -1445,20 +1473,46 @@ class _DeviceKeyCard extends HookConsumerWidget { style: TextStyle(fontWeight: FontWeight.w600), ), const SizedBox(height: 2), - Text( - shortened, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), + if (deviceState.phase == DeviceStatePhase.bootstrapping) ...[ + const Text('Preparing device backup…'), + const SizedBox(height: 6), + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(height: 2), + Text( + formatElapsed(powElapsed.value), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ] else if (deviceState.isReady) + Text( + shortened, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + overflow: TextOverflow.ellipsis, + ) + else + Text( + 'Device backup failed: ${deviceState.error}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + overflow: TextOverflow.ellipsis, ), - overflow: TextOverflow.ellipsis, - ), ], ), ), TextButton.icon( - onPressed: isCopying.value + onPressed: !deviceState.isReady || isCopying.value ? null : () async { final shouldCopy = await showDialog( @@ -1504,13 +1558,7 @@ class _DeviceKeyCard extends HookConsumerWidget { isCopying.value = false; } }, - icon: isCopying.value - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.copy, size: 18), + icon: const Icon(Icons.copy, size: 18), label: const Text('Copy nsec'), ), ], @@ -1525,6 +1573,7 @@ class _BackgroundAutoUpdatesToggle extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final settingsAsync = ref.watch(localSettingsProvider); + final ready = ref.watch(deviceStateProvider).isReady; final enabled = settingsAsync.valueOrNull?.backgroundAutoUpdatesEnabled ?? false; @@ -1543,61 +1592,65 @@ class _BackgroundAutoUpdatesToggle extends ConsumerWidget { title: const Text('Background auto-updates'), value: enabled, contentPadding: EdgeInsets.zero, - onChanged: (value) async { - if (value && !enabled) { - final confirmed = await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Turn on background auto-updates?'), - content: const Text( - 'Zapstore will check for updates and apply them in the ' - 'background. The first check will start immediately when ' - 'Wi-Fi is available. After that, checks run approximately ' - 'every 24 hours. You can turn this off at any time.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(dialogContext, true), - child: const Text('Turn on'), - ), - ], - ), - ); - if (confirmed != true || !context.mounted) return; - } - - await ref - .read(settingsServiceProvider) - .update((s) => s.copyWith(backgroundAutoUpdatesEnabled: value)); - ref.invalidate(localSettingsProvider); - - if (value) { - unawaited(() async { - try { - await ref - .read(backgroundUpdateServiceProvider) - .scheduleImmediateAutoUpdate(); - } catch (error, stack) { - app_logs.LogService.I.warn( - 'initial background auto-update scheduling failed', - tag: 'background_updates', - err: error, - stack: stack, - ); - if (context.mounted) { - context.showError( - 'Background auto-updates are enabled, but the first check ' - 'could not be scheduled.', + onChanged: !ready + ? null + : (value) async { + if (value && !enabled) { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Turn on background auto-updates?'), + content: const Text( + 'Zapstore will check for updates and apply them in the ' + 'background. The first check will start immediately when ' + 'Wi-Fi is available. After that, checks run approximately ' + 'every 24 hours. You can turn this off at any time.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Turn on'), + ), + ], + ), ); + if (confirmed != true || !context.mounted) return; } - } - }()); - } - }, + + await ref + .read(deviceStateProvider.notifier) + .updatePortable( + (s) => s.copyWith(backgroundAutoUpdatesEnabled: value), + ); + ref.invalidate(localSettingsProvider); + + if (value) { + unawaited(() async { + try { + await ref + .read(backgroundUpdateServiceProvider) + .scheduleImmediateAutoUpdate(); + } catch (error, stack) { + app_logs.LogService.I.warn( + 'initial background auto-update scheduling failed', + tag: 'background_updates', + err: error, + stack: stack, + ); + if (context.mounted) { + context.showError( + 'Background auto-updates are enabled, but the first check ' + 'could not be scheduled.', + ); + } + } + }()); + } + }, ); } } @@ -1609,6 +1662,7 @@ class _InstalledAppsBackupToggle extends ConsumerWidget { if (devicePubkey == null) return const SizedBox.shrink(); final settingsAsync = ref.watch(localSettingsProvider); + final ready = ref.watch(deviceStateProvider).isReady; final enabled = settingsAsync.valueOrNull?.installedAppsBackupEnabled ?? false; @@ -1627,12 +1681,16 @@ class _InstalledAppsBackupToggle extends ConsumerWidget { title: const Text('Back up installed apps'), value: enabled, contentPadding: EdgeInsets.zero, - onChanged: (value) async { - await ref - .read(settingsServiceProvider) - .update((s) => s.copyWith(installedAppsBackupEnabled: value)); - ref.invalidate(localSettingsProvider); - }, + onChanged: !ready + ? null + : (value) async { + await ref + .read(deviceStateProvider.notifier) + .updatePortable( + (s) => s.copyWith(installedAppsBackupEnabled: value), + ); + ref.invalidate(localSettingsProvider); + }, ); } } diff --git a/lib/services/device_backup_service.dart b/lib/services/device_backup_service.dart index d7d2d0a..54e6e58 100644 --- a/lib/services/device_backup_service.dart +++ b/lib/services/device_backup_service.dart @@ -1,505 +1,297 @@ import 'dart:async'; -import 'dart:collection'; import 'dart:convert'; -import 'dart:io'; -import 'package:device_info_plus/device_info_plus.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/constants/app_constants.dart'; +import 'package:zapstore/main.dart'; import 'package:zapstore/router.dart'; import 'package:zapstore/services/device_key_service.dart'; import 'package:zapstore/services/device_private_event_service.dart'; import 'package:zapstore/services/device_private_sync_service.dart'; +import 'package:zapstore/services/device_state_service.dart'; import 'package:zapstore/services/log_service.dart'; -import 'package:zapstore/services/package_manager/package_manager.dart'; -import 'package:zapstore/services/trusted_signers_service.dart'; +import 'package:zapstore/services/settings_service.dart'; import 'package:zapstore/widgets/device_backup_dialog.dart'; +import 'package:zapstore/widgets/device_restore_dialog.dart'; +import 'package:zapstore/widgets/legacy_installed_apps_dialog.dart'; -const _kSettingsFormatVersion = 2; -const _kRecoveriesKey = 'recoveries'; -const _kRecoveryAuthorization = 'zapstore-device-key-authorization-v1'; -const _kLegacyInstalledAppsBackupIdentifier = 'zapstore-installed-backup'; -const _kLegacyUnmanagedAppsIdentifier = 'zapstore-ignored-apps'; - -/// Manages device-signed settings recovery and legacy Amber stack migration. +/// Backs up the device key to the active Amber identity and restores it again. +/// +/// The relay record is authored by Amber, unlike portable device state which is +/// always authored by the recovered device key. class DeviceBackupService { - final Set>> _activeRequests = {}; - bool _cancelled = false; + /// Retained as a lifecycle hook for callers from the previous recovery + /// implementation. Device-key backup has no long-lived foreground request. + void beginWork() {} - void beginWork() => _cancelled = false; + /// Device-key backup publishes are short-lived and do not own a cancellable + /// request. Bootstrap mining is owned by DeviceStateNotifier. + void cancelCurrentWork(Ref ref) {} - void _checkCancelled() { - if (_cancelled) throw const DeviceBackupCancelled(); - } - - Future fetchExistingSettings( - Ref ref, - String devicePubkey, - ) async { - final results = await _queryTracked( - ref, - RequestFilter( - authors: {devicePubkey}, - tags: { - '#d': {kSettingsIdentifier}, - }, - limit: 1, - ).toRequest(), - source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'app-device-settings-upsert', - ); - return results.firstOrNull; - } - - /// Finds device-authored settings events recoverable by [amberSigner]. - Future> fetchRecoveryCandidates({ - required Ref ref, - required Signer amberSigner, - }) async { - _checkCancelled(); - final request = RequestFilter( - tags: { - '#d': {kSettingsIdentifier}, - '#p': {amberSigner.pubkey}, - }, - limit: 50, - ).toRequest(); - final settingsEvents = await _queryTracked( - ref, - request, - source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'app-device-recovery', - ); - - final candidates = {}; - for (final settings in settingsEvents) { - _checkCancelled(); - if (!verifySignedEvent(ref, settings.event) || - !Nip13.isValid( - settings.event, - minimumDifficulty: kPrivateEventPowDifficulty, - )) { - continue; - } - final envelope = _decodeSettingsEnvelope(settings.content); - if (envelope == null) continue; - final recoveries = envelope[_kRecoveriesKey]; - if (recoveries is! List) continue; - - for (final raw in recoveries.whereType()) { - final recovery = Map.from(raw); - if (recovery['p'] != amberSigner.pubkey) continue; - final ciphertext = recovery['content']; - if (ciphertext is! String || ciphertext.isEmpty) continue; - try { - final plaintext = await amberSigner.nip44Decrypt( - ciphertext, - settings.pubkey, - ); - _checkCancelled(); - final capsule = Map.from( - jsonDecode(plaintext) as Map, - ); - final privateKey = capsule['pk']; - final authorization = capsule['authorization']; - if (privateKey is! String || - privateKey.length != 64 || - Utils.derivePublicKey(privateKey) != settings.pubkey || - authorization is! Map || - !validateRecoveryAuthorization( - ref, - Map.from(authorization), - amberPubkey: amberSigner.pubkey, - devicePubkey: settings.pubkey, - )) { - continue; - } - candidates[privateKey] = DeviceBackupInfo( - privateKeyHex: privateKey, - deviceName: capsule['name'] as String? ?? 'Android device', - backedUpAt: capsule['ts'] is int - ? DateTime.fromMillisecondsSinceEpoch(capsule['ts'] as int) - : null, - ); - } catch (_) { - // Invalid or unrelated recovery capsules degrade gracefully. - } - } - } - return candidates.values.toList(growable: false); - } - - /// Upserts a recovery capsule for the active Amber identity. Future backupDeviceKey({ required Ref ref, required Signer amberSigner, }) async { - _checkCancelled(); - final privateEvents = ref.read(devicePrivateEventServiceProvider); - final keyService = ref.read(deviceKeyServiceProvider); - final privateKeyHex = await keyService.getOrCreatePrivateKey(); - final devicePubkey = privateEvents.devicePubkey; - final existing = await fetchExistingSettings(ref, devicePubkey); - final envelope = existing != null - ? _decodeSettingsEnvelope(existing.content) ?? _emptySettingsEnvelope() - : _emptySettingsEnvelope(); - - final deviceName = await _getDeviceName(); - final timestamp = DateTime.now().millisecondsSinceEpoch; - final authorizationPartial = PartialNote(_kRecoveryAuthorization); - authorizationPartial.event.addTagValue('device', devicePubkey); - authorizationPartial.event.addTagValue('p', amberSigner.pubkey); - final authorization = await authorizationPartial.signWith(amberSigner); - _checkCancelled(); - final capsule = await privateEvents.encryptFor( - jsonEncode({ - 'pk': privateKeyHex, - 'name': deviceName, - 'ts': timestamp, - 'authorization': authorization.event.toMap(), - }), + final privateKeyHex = await ref + .read(deviceKeyServiceProvider) + .getOrCreatePrivateKey(); + final ciphertext = await amberSigner.nip44Encrypt( + jsonEncode({'privateKeyHex': privateKeyHex}), amberSigner.pubkey, ); - _checkCancelled(); - - final recoveries = - (envelope[_kRecoveriesKey] as List?) - ?.whereType() - .map((entry) => Map.from(entry)) - .toList() ?? - >[]; - recoveries.removeWhere((entry) => entry['p'] == amberSigner.pubkey); - recoveries.add({ - 'p': amberSigner.pubkey, - 'content': capsule, - 'ts': timestamp, - }); - envelope[_kRecoveriesKey] = recoveries; - - final partial = PartialCustomData( - identifier: kSettingsIdentifier, - content: jsonEncode(envelope), - ); - for (final recovery in recoveries) { - final pubkey = recovery['p']; - if (pubkey is String) partial.event.addTagValue('p', pubkey); + final signed = await PartialCustomData( + identifier: kDeviceKeyBackupIdentifier, + content: ciphertext, + ).signWith(amberSigner); + if (!verifySignedEvent(ref, signed.event)) { + throw const DeviceBackupException('Could not verify Amber key backup.'); + } + final storage = ref.read(storageNotifierProvider.notifier); + final saved = await storage.save({signed}); + if (!saved) { + throw const DeviceBackupException( + 'Could not save Amber key backup locally.', + ); + } + final response = await storage.publish({signed}, relays: 'AppCatalog'); + final accepted = + response.results[signed.event.id]?.any((result) => result.accepted) ?? + false; + if (!accepted) { + throw const DeviceBackupException( + 'Saved locally, but no AppCatalog relay accepted the Amber key backup.', + ); } - partial.event.setTagValue('format', 'zapstore-device-settings-v2'); - partial.event.createdAt = privateEvents.nextReplaceableTimestamp( - existing?.createdAt, - ); - await privateEvents.signAndSave(partial); } - /// Merges every Amber-authored encrypted AppStack into device ownership. - Future migratePrivateStacksToDeviceKey({ + Future fetchAmberBackup({ required Ref ref, required Signer amberSigner, }) async { - _checkCancelled(); - final request = RequestFilter( - authors: {amberSigner.pubkey}, - ).toRequest(); - final amberStacks = await _queryTracked( - ref, - request, - source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'app-private-stack-migration', - ); - - final privateEvents = ref.read(devicePrivateEventServiceProvider); - final storage = ref.read(storageNotifierProvider.notifier); - final platform = ref.read(packageManagerProvider.notifier).platform; - var migratedAny = false; - - for (final stack in amberStacks.where( - (stack) => stack.content.isNotEmpty, - )) { - _checkCancelled(); - if (!verifySignedEvent(ref, stack.event)) { - LogService.I.warn( - 'ignored unverified Amber private stack', - tag: 'backup', - fields: {'identifier': stack.identifier}, + final results = await ref + .read(storageNotifierProvider.notifier) + .query( + RequestFilter( + authors: {amberSigner.pubkey}, + tags: { + '#d': {kDeviceKeyBackupIdentifier}, + }, + limit: 1, + ).toRequest(), + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + subscriptionPrefix: 'app-device-key-backup', ); - continue; - } - final oldAppIds = await decryptAmberStackAppIds(amberSigner, stack); - _checkCancelled(); - if (oldAppIds == null) { - LogService.I.warn( - 'could not decrypt Amber private stack; migration remains retryable', - tag: 'backup', - fields: {'identifier': stack.identifier}, - ); - continue; - } - - final identifier = _deviceIdentifierFor(stack.identifier); - final existing = (await storage.query( - RequestFilter( - authors: {privateEvents.devicePubkey}, - tags: { - '#d': {identifier}, - }, - limit: 1, - ).toRequest(), - source: const LocalSource(), - subscriptionPrefix: 'app-private-stack-migration-device', - )).firstOrNull; - if (existing != null) { - await existing.prepareAfterLoading(ref); - _checkCancelled(); - if (!existing.isDecrypted) { - LogService.I.warn( - 'could not decrypt device stack; migration remains retryable', - tag: 'backup', - fields: {'identifier': identifier}, - ); - continue; - } - } - - final merged = LinkedHashSet.of( - existing?.privateAppIds ?? const [], - )..addAll(oldAppIds); - if (existing != null && - merged.length == existing.privateAppIds.toSet().length) { - continue; - } - - final partial = PartialAppStack.withEncryptedApps( - name: stack.name ?? identifier, - identifier: identifier, - description: stack.description, - apps: merged.toList(growable: false), - platform: stack.platform ?? platform, - ); - partial.event.createdAt = privateEvents.nextReplaceableTimestamp( - existing?.createdAt, - ); - await privateEvents.signAndSave(partial); - migratedAny = true; + final backup = results.firstOrNull; + if (backup == null || + backup.pubkey != amberSigner.pubkey || + !verifySignedEvent(ref, backup.event)) { + return null; } - - if (migratedAny) { - LogService.I.info( - 'migrated Amber private stacks to device key', - tag: 'backup', + try { + final plaintext = await amberSigner.nip44Decrypt( + backup.content, + amberSigner.pubkey, ); + final decoded = jsonDecode(plaintext); + if (decoded is! Map) return null; + final privateKeyHex = decoded['privateKeyHex']; + if (privateKeyHex is! String || + privateKeyHex.length != 64 || + Utils.derivePublicKey(privateKeyHex).isEmpty) { + return null; + } + return privateKeyHex; + } catch (_) { + return null; } - return migratedAny; } Future restoreDeviceKey({ required Ref ref, required String privateKeyHex, }) async { + if (privateKeyHex.length != 64) { + throw const DeviceBackupException( + 'Device key must be 64 hexadecimal characters.', + ); + } + final pubkey = Utils.derivePublicKey(privateKeyHex); await ref.read(deviceKeyServiceProvider).replacePrivateKey(privateKeyHex); - final restoredSigner = Bip340PrivateKeySigner(privateKeyHex, ref); - await restoredSigner.signIn(setAsActive: false); - ref.read(devicePubkeyProvider.notifier).state = restoredSigner.pubkey; + final signer = Bip340PrivateKeySigner(privateKeyHex, ref); + await signer.signIn(setAsActive: false); + ref.read(devicePubkeyProvider.notifier).state = pubkey; } - Future> _queryTracked>( - Ref ref, - Request request, { - required Source source, - required String subscriptionPrefix, + Future> fetchLegacyInstalledAppIds({ + required Ref ref, + required Signer amberSigner, }) async { - _checkCancelled(); - _activeRequests.add(request); + final results = await ref + .read(storageNotifierProvider.notifier) + .query( + RequestFilter( + authors: {amberSigner.pubkey}, + tags: { + '#d': {kInstalledAppsIdentifier}, + }, + limit: 1, + ).toRequest(), + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + subscriptionPrefix: 'app-legacy-installed-recovery', + ); + final stack = results.firstOrNull; + if (stack == null || !verifySignedEvent(ref, stack.event)) return const []; try { - final results = await ref - .read(storageNotifierProvider.notifier) - .query( - request, - source: source, - subscriptionPrefix: subscriptionPrefix, - ); - _checkCancelled(); - return results; - } finally { - _activeRequests.remove(request); + final plaintext = await amberSigner.nip44Decrypt( + stack.content, + amberSigner.pubkey, + ); + final decoded = jsonDecode(plaintext); + return decoded is List + ? decoded + .whereType() + .where((id) => id.startsWith('32267:')) + .toList() + : const []; + } catch (_) { + return const []; } } - - void cancelCurrentWork(Ref ref) { - _cancelled = true; - final storage = ref.read(storageNotifierProvider.notifier); - for (final request in _activeRequests.toList(growable: false)) { - unawaited(storage.cancel(request)); - } - _activeRequests.clear(); - ref.read(devicePrivateEventServiceProvider).cancelMining(); - } - - Future _getDeviceName() async { - try { - if (Platform.isAndroid) { - return (await DeviceInfoPlugin().androidInfo).model; - } - } catch (_) {} - return 'Android device'; - } -} - -/// Explicitly decrypts an imperatively queried Amber stack. -/// -/// Imperative storage queries do not run EncryptableModel preparation, so -/// migration must not rely on [AppStack.privateAppIds] being populated. -Future?> decryptAmberStackAppIds( - Signer amberSigner, - AppStack stack, -) async { - try { - final plaintext = await amberSigner.nip44Decrypt( - stack.content, - amberSigner.pubkey, - ); - final decoded = jsonDecode(plaintext); - return decoded is List ? decoded.whereType().toList() : null; - } catch (_) { - return null; - } } final deviceBackupServiceProvider = Provider( (ref) => DeviceBackupService(), ); -Map _emptySettingsEnvelope() => { - 'version': _kSettingsFormatVersion, - _kRecoveriesKey: >[], -}; +/// Runs the one-time recovery choice after the navigator overlay is available. +Future maybeOfferInitialDeviceRestore(Ref ref) async { + final settings = ref.read(settingsServiceProvider); + final temp = await settings.loadTemp(); + if (temp.restoreOnboardingComplete) return; + final context = rootNavigatorKey.currentState?.overlay?.context; + if (context == null || !context.mounted) return; -Map? _decodeSettingsEnvelope(String content) { - try { - final decoded = jsonDecode(content); - if (decoded is! Map) return null; - final envelope = Map.from(decoded); - if (envelope['version'] != _kSettingsFormatVersion) return null; - return envelope; - } catch (_) { - return null; + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const DeviceRestoreDialog(), + ); + if (result == null) return; + + switch (result.action) { + case DeviceRestoreAction.startFresh: + await settings.saveTemp(temp.copyWith(restoreOnboardingComplete: true)); + unawaited(ref.read(deviceStateProvider.notifier).bootstrap()); + break; + case DeviceRestoreAction.pasteKey: + final privateKeyHex = ref + .read(deviceKeyServiceProvider) + .parsePrivateKey(result.key ?? ''); + if (privateKeyHex == null) { + LogService.I.warn('invalid pasted device key', tag: 'backup'); + return; + } + await ref + .read(deviceBackupServiceProvider) + .restoreDeviceKey(ref: ref, privateKeyHex: privateKeyHex); + await ref.read(devicePrivateSyncProvider.notifier).syncRestoredKey(); + final restored = await ref + .read(deviceStateProvider.notifier) + .restoreFromLocalEvent(); + if (!restored) { + unawaited(ref.read(deviceStateProvider.notifier).bootstrap()); + } + await settings.saveTemp(temp.copyWith(restoreOnboardingComplete: true)); + break; + case DeviceRestoreAction.amber: + await ref.read(amberSignerProvider).signIn(); + break; } } -/// Verifies that Amber explicitly authorized the recovered device pubkey. -bool validateRecoveryAuthorization( - Ref ref, - Map authorization, { - required String amberPubkey, - required String devicePubkey, -}) { - try { - if (authorization['kind'] != 1 || - authorization['pubkey'] != amberPubkey || - authorization['content'] != _kRecoveryAuthorization) { - return false; - } - final event = PartialEvent>(authorization, 1); - if (!event.getTagSetValues('device').contains(devicePubkey) || - !event.getTagSetValues('p').contains(amberPubkey) || - authorization['id'] != Utils.getEventId(event, amberPubkey)) { - return false; - } - return ref.read(verifierProvider).verify(authorization); - } catch (_) { - return false; - } -} - -String _deviceIdentifierFor(String identifier) { - return switch (identifier) { - _kLegacyInstalledAppsBackupIdentifier => kInstalledAppsIdentifier, - _kLegacyUnmanagedAppsIdentifier => kUnmanagedAppsIdentifier, - _ => identifier, - }; -} - -class DeviceBackupInfo { - DeviceBackupInfo({ - required this.privateKeyHex, - required this.deviceName, - this.backedUpAt, - }); - - final String privateKeyHex; - final String deviceName; - final DateTime? backedUpAt; -} - -final class DeviceBackupCancelled implements Exception { - const DeviceBackupCancelled(); -} - -/// Runs recovery, migration, and backup after every successful Amber sign-in. +/// Offers Amber recovery only once per fresh local installation. Future maybeOfferDeviceBackup(Ref ref) async { - final amberPubkey = ref.read(Signer.activePubkeyProvider); final amberSigner = ref.read(Signer.activeSignerProvider); - if (amberPubkey == null || amberSigner == null) return; + if (amberSigner == null) return; - final keyService = ref.read(deviceKeyServiceProvider); + final settings = ref.read(settingsServiceProvider); + final temp = await settings.loadTemp(); final service = ref.read(deviceBackupServiceProvider); - try { - final alreadyOffered = await keyService.hasBackupBeenOffered(amberPubkey); - List candidates = const []; - try { - candidates = await service.fetchRecoveryCandidates( + if (!temp.restoreOnboardingComplete) { + final privateKeyHex = await service.fetchAmberBackup( ref: ref, amberSigner: amberSigner, ); - } catch (error, stack) { - LogService.I.warn( - 'device recovery query failed', - tag: 'backup', - err: error, - stack: stack, - ); - } - - final currentPrivateKey = await keyService.getOrCreatePrivateKey(); - final otherDevices = candidates - .where((entry) => entry.privateKeyHex != currentPrivateKey) - .toList(growable: false); - - if (!alreadyOffered && otherDevices.isNotEmpty) { - final context = rootNavigatorKey.currentState?.overlay?.context; - if (context != null && context.mounted) { - final selected = await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => DeviceBackupRestoreDialog(backups: otherDevices), - ); - if (selected != null) { - await service.restoreDeviceKey( - ref: ref, - privateKeyHex: selected.privateKeyHex, + if (privateKeyHex != null) { + final context = rootNavigatorKey.currentState?.overlay?.context; + if (context != null && context.mounted) { + final restore = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => DeviceBackupRestoreDialog( + onRestore: () => Navigator.of(context).pop(true), + onKeepCurrent: () => Navigator.of(context).pop(false), + ), ); - await ref.read(devicePrivateSyncProvider.notifier).syncRestoredKey(); + if (restore == true) { + await service.restoreDeviceKey( + ref: ref, + privateKeyHex: privateKeyHex, + ); + await ref + .read(devicePrivateSyncProvider.notifier) + .syncRestoredKey(); + if (!ref.read(deviceStateProvider).isReady) { + unawaited(ref.read(deviceStateProvider.notifier).bootstrap()); + } + } } } + final legacyInstalledApps = await service.fetchLegacyInstalledAppIds( + ref: ref, + amberSigner: amberSigner, + ); + if (legacyInstalledApps.isNotEmpty) { + final context = rootNavigatorKey.currentState?.overlay?.context; + if (context != null && context.mounted) { + await showDialog( + context: context, + builder: (_) => + LegacyInstalledAppsDialog(appIds: legacyInstalledApps), + ); + } + } + await settings.saveTemp(temp.copyWith(restoreOnboardingComplete: true)); } - - await service.migratePrivateStacksToDeviceKey( - ref: ref, - amberSigner: amberSigner, - ); - await ref.read(trustServiceProvider).migrateLegacyAmberRecord(amberSigner); await service.backupDeviceKey(ref: ref, amberSigner: amberSigner); - if (!alreadyOffered) { - await keyService.markBackupOffered(amberPubkey); + if (!ref.read(deviceStateProvider).isReady) { + unawaited(ref.read(deviceStateProvider.notifier).bootstrap()); } } catch (error, stack) { LogService.I.warn( - 'device recovery/migration/backup failed', + 'device key backup or recovery failed', tag: 'backup', err: error, stack: stack, ); } } + +final class DeviceBackupException implements Exception { + const DeviceBackupException(this.message); + + final String message; + + @override + String toString() => message; +} diff --git a/lib/services/device_key_service.dart b/lib/services/device_key_service.dart index b5a4eb3..b6de1ee 100644 --- a/lib/services/device_key_service.dart +++ b/lib/services/device_key_service.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:models/models.dart'; @@ -9,39 +7,18 @@ const _storage = FlutterSecureStorage( iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), ); -const _kSecurePrefsKey = 'zapstore_secure_prefs'; +const _kDeviceKey = 'device_key'; -/// Manages all device-local secrets in a single secure storage entry. -/// -/// Stored as a JSON object with keys: -/// - `nsec`: device private key (hex) -/// - `backup_offered`: list of Amber pubkeys already offered backup dialog -/// - `private_stacks_migrated`: amber/device pubkey pairs already migrated +/// Manages the device private key. The nsec is intentionally isolated from +/// portable settings and temporary local state. class DeviceKeyService { - Map? _cache; - - Future> _load() async { - if (_cache != null) return _cache!; - final raw = await _storage.read(key: _kSecurePrefsKey); - _cache = (raw != null && raw.isNotEmpty) - ? jsonDecode(raw) as Map - : {}; - return _cache!; - } - - Future _persist() async { - await _storage.write(key: _kSecurePrefsKey, value: jsonEncode(_cache)); - } - /// Load existing device key or generate a new one. Returns hex private key. Future getOrCreatePrivateKey() async { - final prefs = await _load(); - final existing = prefs['nsec'] as String?; + final existing = await _storage.read(key: _kDeviceKey); if (existing != null && existing.isNotEmpty) return existing; final privateKeyHex = Utils.generateRandomHex64(); - prefs['nsec'] = privateKeyHex; - await _persist(); + await _storage.write(key: _kDeviceKey, value: privateKeyHex); return privateKeyHex; } @@ -53,58 +30,81 @@ class DeviceKeyService { /// Replace the current device key (used during restore from backup). Future replacePrivateKey(String privateKeyHex) async { - final prefs = await _load(); - prefs['nsec'] = privateKeyHex; - await _persist(); + await _storage.write(key: _kDeviceKey, value: privateKeyHex); } - /// Whether the backup/restore dialog has been offered for [pubkey]. - Future hasBackupBeenOffered(String pubkey) async { - final prefs = await _load(); - final list = (prefs['backup_offered'] as List?)?.cast() ?? []; - return list.contains(pubkey); - } - - /// Mark that the backup dialog was shown for [pubkey]. - Future markBackupOffered(String pubkey) async { - final prefs = await _load(); - final list = (prefs['backup_offered'] as List?)?.cast() ?? []; - if (!list.contains(pubkey)) { - list.add(pubkey); - prefs['backup_offered'] = list; - await _persist(); + /// Parses either the internal 64-character hex form or a copied `nsec`. + String? parsePrivateKey(String value) { + final input = value.trim().replaceFirst( + RegExp(r'^nostr:', caseSensitive: false), + '', + ); + if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(input)) { + return input.toLowerCase(); } - } - - /// Whether Amber-authored private stacks have been migrated to [devicePubkey]. - Future hasPrivateStacksMigrated( - String amberPubkey, - String devicePubkey, - ) async { - final prefs = await _load(); - final list = - (prefs['private_stacks_migrated'] as List?)?.cast() ?? []; - return list.contains(_migrationKey(amberPubkey, devicePubkey)); - } - - /// Mark private stack migration complete for [devicePubkey]. - Future markPrivateStacksMigrated( - String amberPubkey, - String devicePubkey, - ) async { - final prefs = await _load(); - final list = - (prefs['private_stacks_migrated'] as List?)?.cast() ?? []; - final key = _migrationKey(amberPubkey, devicePubkey); - if (!list.contains(key)) { - list.add(key); - prefs['private_stacks_migrated'] = list; - await _persist(); + if (!input.toLowerCase().startsWith('nsec1')) return null; + final values = []; + const alphabet = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + for (final char in input.substring(5).toLowerCase().codeUnits) { + final value = alphabet.indexOf(String.fromCharCode(char)); + if (value < 0) return null; + values.add(value); } + if (values.length < 6) return null; + if (!_hasValidBech32Checksum('nsec', values)) return null; + final payload = values.sublist(0, values.length - 6); + final bytes = _convertBits(payload, from: 5, to: 8); + if (bytes == null || bytes.length != 32) return null; + return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); } - String _migrationKey(String amberPubkey, String devicePubkey) => - '$amberPubkey:$devicePubkey'; + static List? _convertBits( + List values, { + required int from, + required int to, + }) { + var accumulator = 0; + var bits = 0; + final output = []; + final maxValue = (1 << to) - 1; + for (final value in values) { + if (value < 0 || value >> from != 0) return null; + accumulator = (accumulator << from) | value; + bits += from; + while (bits >= to) { + bits -= to; + output.add((accumulator >> bits) & maxValue); + } + } + if (bits >= from || ((accumulator << (to - bits)) & maxValue) != 0) { + return null; + } + return output; + } + + static bool _hasValidBech32Checksum(String hrp, List values) { + const generators = [ + 0x3b6a57b2, + 0x26508e6d, + 0x1ea119fa, + 0x3d4233dd, + 0x2a1462b3, + ]; + var checksum = 1; + final expandedHrp = [ + ...hrp.codeUnits.map((codeUnit) => codeUnit >> 5), + 0, + ...hrp.codeUnits.map((codeUnit) => codeUnit & 31), + ]; + for (final value in [...expandedHrp, ...values]) { + final top = checksum >> 25; + checksum = (checksum & 0x1ffffff) << 5 ^ value; + for (var i = 0; i < generators.length; i++) { + if ((top >> i) & 1 == 1) checksum ^= generators[i]; + } + } + return checksum == 1; + } } final deviceKeyServiceProvider = Provider( diff --git a/lib/services/device_private_sync_service.dart b/lib/services/device_private_sync_service.dart index 956538d..7e39ee5 100644 --- a/lib/services/device_private_sync_service.dart +++ b/lib/services/device_private_sync_service.dart @@ -2,9 +2,9 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:models/models.dart'; -import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/services/device_key_service.dart'; import 'package:zapstore/services/device_private_event_service.dart'; +import 'package:zapstore/services/device_state_service.dart'; import 'package:zapstore/services/log_service.dart'; enum DevicePrivateSyncPhase { idle, syncing, success, error, cancelled } @@ -78,7 +78,7 @@ class DevicePrivateSyncNotifier extends StateNotifier { subscriptionPrefix: 'app-device-private-boot', ); if (_cancelled) return; - await _upgradeLegacyProofs(request); + await ref.read(deviceStateProvider.notifier).restoreFromLocalEvent(); if (_cancelled) return; state = const DevicePrivateSyncState(DevicePrivateSyncPhase.success); } catch (error, stack) { @@ -98,69 +98,6 @@ class DevicePrivateSyncNotifier extends StateNotifier { } } - Future _upgradeLegacyProofs(Request> request) async { - final privateEvents = ref.read(devicePrivateEventServiceProvider); - final models = await _queryStorage( - request, - source: const LocalSource(), - subscriptionPrefix: 'app-device-private-upgrade', - ); - - for (final model in models) { - if (_cancelled || - Nip13.isValid( - model.event, - minimumDifficulty: kPrivateEventPowDifficulty, - )) { - continue; - } - - switch (model) { - case AppStack stack when stack.content.isNotEmpty: - await stack.prepareAfterLoading(ref); - if (!stack.isDecrypted) { - LogService.I.warn( - 'legacy private stack could not be decrypted for PoW upgrade', - tag: 'private-sync', - fields: {'identifier': stack.identifier}, - ); - continue; - } - final partial = PartialAppStack.withEncryptedApps( - name: stack.name ?? stack.identifier, - identifier: stack.identifier, - description: stack.description, - apps: stack.privateAppIds, - platform: stack.platform, - ); - partial.event.createdAt = privateEvents.nextReplaceableTimestamp( - stack.createdAt, - ); - await privateEvents.signAndSave(partial); - case CustomData data - when data.identifier == kSettingsIdentifier || - data.identifier == kTrustedSignersIdentifier: - final partial = PartialCustomData( - identifier: data.identifier, - content: data.content, - ); - for (final tag in data.event.tags) { - if (tag.first == 'd' || tag.first == 'nonce') continue; - partial.event.tags.add(List.of(tag)); - } - partial.event.createdAt = privateEvents.nextReplaceableTimestamp( - data.createdAt, - ); - await privateEvents.signAndSave( - partial, - publish: data.identifier != kTrustedSignersIdentifier, - ); - default: - break; - } - } - } - Future>> _queryStorage( Request> request, { required Source source, diff --git a/lib/services/device_state_service.dart b/lib/services/device_state_service.dart new file mode 100644 index 0000000..7f4a81e --- /dev/null +++ b/lib/services/device_state_service.dart @@ -0,0 +1,155 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/constants/app_constants.dart'; +import 'package:zapstore/services/device_key_service.dart'; +import 'package:zapstore/services/device_private_event_service.dart'; +import 'package:zapstore/services/log_service.dart'; +import 'package:zapstore/services/settings_service.dart'; + +enum DeviceStatePhase { bootstrapping, ready, error } + +class DeviceStateStatus { + const DeviceStateStatus(this.phase, {this.error, this.startedAt}); + + const DeviceStateStatus.bootstrapping() + : phase = DeviceStatePhase.bootstrapping, + error = null, + startedAt = null; + + const DeviceStateStatus.ready() + : phase = DeviceStatePhase.ready, + error = null, + startedAt = null; + + final DeviceStatePhase phase; + final Object? error; + final DateTime? startedAt; + + bool get isReady => phase == DeviceStatePhase.ready; +} + +/// Owns the portable settings snapshot and queues its relay synchronization. +class DeviceStateNotifier extends StateNotifier { + DeviceStateNotifier(this.ref) + : super(const DeviceStateStatus.bootstrapping()); + + final Ref ref; + Future? _bootstrapFuture; + Future _publishQueue = Future.value(); + bool _disposed = false; + + Future bootstrap() { + final existing = _bootstrapFuture; + if (existing != null) return existing; + if (!_disposed) { + state = DeviceStateStatus( + DeviceStatePhase.bootstrapping, + startedAt: DateTime.now(), + ); + } + return _bootstrapFuture = _bootstrap(); + } + + Future _bootstrap() async { + try { + if (!_disposed) state = const DeviceStateStatus.ready(); + } catch (error, stack) { + LogService.I.warn( + 'device-state bootstrap failed', + tag: 'device-state', + err: error, + stack: stack, + ); + if (!_disposed) { + state = DeviceStateStatus(DeviceStatePhase.error, error: error); + } + rethrow; + } + } + + /// Persists first. Remote publishing never gates the local preference change. + Future updatePortable( + PortableSettings Function(PortableSettings current) updater, + ) async { + if (!state.isReady) return; + final settingsService = ref.read(settingsServiceProvider); + final updated = updater(await settingsService.loadPortable()); + await settingsService.savePortable(updated); + + _publishQueue = _publishQueue.then((_) => _publish(settings: updated)); + unawaited( + _publishQueue.catchError((error, stack) { + LogService.I.warn( + 'device-state publish failed', + tag: 'device-state', + err: error, + stack: stack, + ); + }), + ); + } + + Future restoreFromLocalEvent() async { + final devicePubkey = ref.read(devicePubkeyProvider); + if (devicePubkey == null) return false; + final models = await ref + .read(storageNotifierProvider.notifier) + .query( + RequestFilter( + authors: {devicePubkey}, + tags: { + '#d': {kDeviceStateIdentifier}, + }, + limit: 1, + ).toRequest(), + source: const LocalSource(), + subscriptionPrefix: 'app-device-state-local', + ); + final event = models.firstOrNull; + if (event == null || !verifySignedEvent(ref, event.event)) return false; + try { + final plaintext = await ref + .read(devicePrivateEventServiceProvider) + .decryptFromDevice(event.content); + final decoded = jsonDecode(plaintext); + if (decoded is! Map) return false; + await ref + .read(settingsServiceProvider) + .savePortable( + PortableSettings.fromJson(Map.from(decoded)), + ); + if (!_disposed) state = const DeviceStateStatus.ready(); + return true; + } catch (_) { + return false; + } + } + + Future _publish({required PortableSettings settings}) async { + final privateEvents = ref.read(devicePrivateEventServiceProvider); + final encrypted = await privateEvents.encryptToDevice( + jsonEncode(settings.toJson()), + ); + final partial = PartialCustomData( + identifier: kDeviceStateIdentifier, + content: encrypted, + ); + await privateEvents.saveDraftAndQueue(partial); + } + + @override + void dispose() { + _disposed = true; + ref.read(devicePrivateEventServiceProvider).cancelMining(); + super.dispose(); + } +} + +final deviceStateProvider = + StateNotifierProvider( + (ref) => DeviceStateNotifier(ref), + ); diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index a064a63..6c0dd53 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -10,54 +10,122 @@ const _storage = FlutterSecureStorage( iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), ); -/// All local settings stored as a single JSON blob in secure storage. -class LocalSettings { - final String? nwcConnectionString; - final DateTime? lastAppOpened; - final DateTime? seenUntil; - final DateTime? deletionSyncedUntil; +/// Portable preferences, mirrored by DeviceStateService. +class PortableSettings { final bool installedAppsBackupEnabled; final bool backgroundAutoUpdatesEnabled; - final LogLevel logLevel; + final Set trustedSigners; - const LocalSettings({ - this.nwcConnectionString, - this.lastAppOpened, - this.seenUntil, - this.deletionSyncedUntil, + const PortableSettings({ this.installedAppsBackupEnabled = false, this.backgroundAutoUpdatesEnabled = false, - this.logLevel = LogLevel.debug, + this.trustedSigners = const {}, }); - bool get hasNwcString => nwcConnectionString?.isNotEmpty == true; - - factory LocalSettings.fromJson(Map json) { - return LocalSettings( - nwcConnectionString: json['nwc'] as String?, - lastAppOpened: _parseDateTime(json['lastAppOpened']), - seenUntil: _parseDateTime(json['seenUntil']), - deletionSyncedUntil: _parseDateTime(json['deletionSyncedUntil']), - installedAppsBackupEnabled: json['backupEnabled'] as bool? ?? false, + factory PortableSettings.fromJson(Map json) { + return PortableSettings( + installedAppsBackupEnabled: + json['installedAppsBackupEnabled'] as bool? ?? false, backgroundAutoUpdatesEnabled: - json['backgroundAutoUpdates'] as bool? ?? false, - logLevel: LogLevel.parse(json['logLevel'] as String?) ?? LogLevel.debug, + json['backgroundAutoUpdatesEnabled'] as bool? ?? false, + trustedSigners: + (json['trustedSigners'] as List?)?.whereType().toSet() ?? + const {}, ); } Map toJson() => { - if (nwcConnectionString != null) 'nwc': nwcConnectionString, + 'installedAppsBackupEnabled': installedAppsBackupEnabled, + 'backgroundAutoUpdatesEnabled': backgroundAutoUpdatesEnabled, + 'trustedSigners': trustedSigners.toList()..sort(), + }; + + PortableSettings copyWith({ + bool? installedAppsBackupEnabled, + bool? backgroundAutoUpdatesEnabled, + Set? trustedSigners, + }) => PortableSettings( + installedAppsBackupEnabled: + installedAppsBackupEnabled ?? this.installedAppsBackupEnabled, + backgroundAutoUpdatesEnabled: + backgroundAutoUpdatesEnabled ?? this.backgroundAutoUpdatesEnabled, + trustedSigners: trustedSigners ?? this.trustedSigners, + ); +} + +/// Per-install operational values. This data is deliberately never backed up. +class TempSettings { + final DateTime? lastAppOpened; + final DateTime? seenUntil; + final DateTime? deletionSyncedUntil; + final LogLevel logLevel; + final bool restoreOnboardingComplete; + + const TempSettings({ + this.lastAppOpened, + this.seenUntil, + this.deletionSyncedUntil, + this.logLevel = LogLevel.debug, + this.restoreOnboardingComplete = false, + }); + + factory TempSettings.fromJson(Map json) => TempSettings( + lastAppOpened: _parseDateTime(json['lastAppOpened']), + seenUntil: _parseDateTime(json['seenUntil']), + deletionSyncedUntil: _parseDateTime(json['deletionSyncedUntil']), + logLevel: LogLevel.parse(json['logLevel'] as String?) ?? LogLevel.debug, + restoreOnboardingComplete: + json['restoreOnboardingComplete'] as bool? ?? false, + ); + + Map toJson() => { if (lastAppOpened != null) 'lastAppOpened': lastAppOpened!.millisecondsSinceEpoch, if (seenUntil != null) 'seenUntil': seenUntil!.millisecondsSinceEpoch, if (deletionSyncedUntil != null) 'deletionSyncedUntil': deletionSyncedUntil!.millisecondsSinceEpoch, - if (installedAppsBackupEnabled) 'backupEnabled': true, - if (backgroundAutoUpdatesEnabled) 'backgroundAutoUpdates': true, - // Only persist non-default value to keep blob small. if (logLevel != LogLevel.debug) 'logLevel': logLevel.name, + if (restoreOnboardingComplete) 'restoreOnboardingComplete': true, }; + TempSettings copyWith({ + DateTime? lastAppOpened, + DateTime? seenUntil, + DateTime? deletionSyncedUntil, + LogLevel? logLevel, + bool? restoreOnboardingComplete, + }) => TempSettings( + lastAppOpened: lastAppOpened ?? this.lastAppOpened, + seenUntil: seenUntil ?? this.seenUntil, + deletionSyncedUntil: deletionSyncedUntil ?? this.deletionSyncedUntil, + logLevel: logLevel ?? this.logLevel, + restoreOnboardingComplete: + restoreOnboardingComplete ?? this.restoreOnboardingComplete, + ); +} + +/// Compatibility view over the three deliberate local storage entries. +class LocalSettings { + final String? nwcConnectionString; + final TempSettings temp; + final PortableSettings portable; + + const LocalSettings({ + this.nwcConnectionString, + this.temp = const TempSettings(), + this.portable = const PortableSettings(), + }); + + DateTime? get lastAppOpened => temp.lastAppOpened; + DateTime? get seenUntil => temp.seenUntil; + DateTime? get deletionSyncedUntil => temp.deletionSyncedUntil; + LogLevel get logLevel => temp.logLevel; + bool get installedAppsBackupEnabled => portable.installedAppsBackupEnabled; + bool get backgroundAutoUpdatesEnabled => + portable.backgroundAutoUpdatesEnabled; + Set get trustedSigners => portable.trustedSigners; + bool get hasNwcString => nwcConnectionString?.isNotEmpty == true; + LocalSettings copyWith({ String? nwcConnectionString, DateTime? lastAppOpened, @@ -65,130 +133,115 @@ class LocalSettings { DateTime? deletionSyncedUntil, bool? installedAppsBackupEnabled, bool? backgroundAutoUpdatesEnabled, + Set? trustedSigners, LogLevel? logLevel, + bool? restoreOnboardingComplete, bool clearNwc = false, - }) { - return LocalSettings( - nwcConnectionString: clearNwc - ? null - : (nwcConnectionString ?? this.nwcConnectionString), - lastAppOpened: lastAppOpened ?? this.lastAppOpened, - seenUntil: seenUntil ?? this.seenUntil, - deletionSyncedUntil: deletionSyncedUntil ?? this.deletionSyncedUntil, - installedAppsBackupEnabled: - installedAppsBackupEnabled ?? this.installedAppsBackupEnabled, - backgroundAutoUpdatesEnabled: - backgroundAutoUpdatesEnabled ?? this.backgroundAutoUpdatesEnabled, - logLevel: logLevel ?? this.logLevel, - ); - } - - static DateTime? _parseDateTime(dynamic value) => - value is int ? DateTime.fromMillisecondsSinceEpoch(value) : null; + }) => LocalSettings( + nwcConnectionString: clearNwc + ? null + : (nwcConnectionString ?? this.nwcConnectionString), + portable: portable.copyWith( + installedAppsBackupEnabled: installedAppsBackupEnabled, + backgroundAutoUpdatesEnabled: backgroundAutoUpdatesEnabled, + trustedSigners: trustedSigners, + ), + temp: temp.copyWith( + lastAppOpened: lastAppOpened, + seenUntil: seenUntil, + deletionSyncedUntil: deletionSyncedUntil, + logLevel: logLevel, + restoreOnboardingComplete: restoreOnboardingComplete, + ), + ); } -/// Service for reading and writing local settings. class SettingsService { - static const _key = 'settings'; - static const _discardedLegacyRelaysKey = 'app_catalog_relays'; - - // Legacy keys for migration - static const _legacyNwcKey = 'nwc_connection_string'; - static const _legacyLastAppOpenedKey = 'last_app_opened'; - static const _legacySeenUntilKey = 'seen_until'; - static const _legacyDeletionSyncedUntilKey = 'deletion_synced_until'; - static const _legacyBackupKey = 'installed_apps_backup_enabled'; + static const settingsKey = 'settings'; + static const tempSettingsKey = 'temp_settings'; + static const nwcKey = 'nwc'; Future load() async { - final json = await _storage.read(key: _key); - if (json != null && json.isNotEmpty) { - try { - final decoded = jsonDecode(json) as Map; - final settings = LocalSettings.fromJson(decoded); - try { - if (decoded.containsKey('relays')) { - await save(settings); - } - await _storage.delete(key: _discardedLegacyRelaysKey); - } catch (error, stack) { - LogService.I.warn( - 'legacy relay settings cleanup failed', - tag: 'settings', - err: error, - stack: stack, - ); - } - return settings; - } catch (_) { - return const LocalSettings(); - } - } - - // Migrate from legacy format if present - return _migrateFromLegacy(); - } - - Future _migrateFromLegacy() async { - final nwc = await _storage.read(key: _legacyNwcKey); - final lastAppOpened = await _storage.read(key: _legacyLastAppOpenedKey); - final seenUntil = await _storage.read(key: _legacySeenUntilKey); - final deletionSynced = await _storage.read( - key: _legacyDeletionSyncedUntilKey, - ); - final backupEnabled = await _storage.read(key: _legacyBackupKey); - - // No legacy data found - if ([ - nwc, - lastAppOpened, - seenUntil, - deletionSynced, - backupEnabled, - ].every((v) => v == null || v.isEmpty)) { - return const LocalSettings(); - } - - final settings = LocalSettings( - nwcConnectionString: (nwc?.isNotEmpty == true) ? nwc : null, - lastAppOpened: _parseLegacyDateTime(lastAppOpened), - seenUntil: _parseLegacyDateTime(seenUntil), - deletionSyncedUntil: _parseLegacyDateTime(deletionSynced), - installedAppsBackupEnabled: backupEnabled == 'true', - ); - - // Save migrated settings and clean up legacy keys - await save(settings); - await Future.wait([ - _storage.delete(key: _legacyNwcKey), - _storage.delete(key: _legacyLastAppOpenedKey), - _storage.delete(key: _legacySeenUntilKey), - _storage.delete(key: _legacyDeletionSyncedUntilKey), - _storage.delete(key: _legacyBackupKey), + final values = await Future.wait([ + _storage.read(key: settingsKey), + _storage.read(key: tempSettingsKey), + _storage.read(key: nwcKey), ]); - - return settings; + return LocalSettings( + portable: _decodePortable(values[0]), + temp: _decodeTemp(values[1]), + nwcConnectionString: values[2]?.isNotEmpty == true ? values[2] : null, + ); } - static DateTime? _parseLegacyDateTime(String? value) { - if (value == null || value.isEmpty) return null; - final ms = int.tryParse(value); - return ms != null ? DateTime.fromMillisecondsSinceEpoch(ms) : null; - } + Future loadPortable() async => + _decodePortable(await _storage.read(key: settingsKey)); + + Future loadTemp() async => + _decodeTemp(await _storage.read(key: tempSettingsKey)); Future save(LocalSettings settings) async { - await _storage.write(key: _key, value: jsonEncode(settings.toJson())); + await Future.wait([ + _storage.write( + key: settingsKey, + value: jsonEncode(settings.portable.toJson()), + ), + _storage.write( + key: tempSettingsKey, + value: jsonEncode(settings.temp.toJson()), + ), + if (settings.nwcConnectionString?.isNotEmpty == true) + _storage.write(key: nwcKey, value: settings.nwcConnectionString!) + else + _storage.delete(key: nwcKey), + ]); } + Future savePortable(PortableSettings settings) => + _storage.write(key: settingsKey, value: jsonEncode(settings.toJson())); + + Future saveTemp(TempSettings settings) => _storage.write( + key: tempSettingsKey, + value: jsonEncode(settings.toJson()), + ); + + Future saveNwc(String? connectionString) => + connectionString?.isNotEmpty == true + ? _storage.write(key: nwcKey, value: connectionString!) + : _storage.delete(key: nwcKey); + Future update( LocalSettings Function(LocalSettings) updater, ) async { - final current = await load(); - final updated = updater(current); + final updated = updater(await load()); await save(updated); return updated; } + + static PortableSettings _decodePortable(String? raw) { + try { + return raw == null || raw.isEmpty + ? const PortableSettings() + : PortableSettings.fromJson(jsonDecode(raw) as Map); + } catch (_) { + return const PortableSettings(); + } + } + + static TempSettings _decodeTemp(String? raw) { + try { + return raw == null || raw.isEmpty + ? const TempSettings() + : TempSettings.fromJson(jsonDecode(raw) as Map); + } catch (_) { + return const TempSettings(); + } + } } +DateTime? _parseDateTime(dynamic value) => + value is int ? DateTime.fromMillisecondsSinceEpoch(value) : null; + /// Persists the AmberSigner pubkey in secure storage. class SecureStoragePubkeyPersistence implements AmberPubkeyPersistence { static const _key = 'amber_pubkey'; diff --git a/lib/services/trusted_signers_service.dart b/lib/services/trusted_signers_service.dart index 0f62156..756bad1 100644 --- a/lib/services/trusted_signers_service.dart +++ b/lib/services/trusted_signers_service.dart @@ -1,12 +1,8 @@ -import 'dart:convert'; - import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:models/models.dart'; -import 'package:zapstore/constants/app_constants.dart'; -import 'package:zapstore/services/device_key_service.dart'; -import 'package:zapstore/services/device_private_event_service.dart'; +import 'package:zapstore/services/device_state_service.dart'; +import 'package:zapstore/services/settings_service.dart'; -/// Simple helper service to manage trusted signers persisted via CustomData +/// Trusted signers are persisted inside the portable device-state JSON. class TrustedSignersService { const TrustedSignersService(this.ref); @@ -14,89 +10,24 @@ class TrustedSignersService { /// Returns whether a signer pubkey is trusted by this device. Future isSignerTrusted(String signerPubkey) async { - final trusted = await _loadTrustedSigners(); - return trusted.contains(signerPubkey); + return (await ref.read(settingsServiceProvider).loadPortable()) + .trustedSigners + .contains(signerPubkey); } - /// Adds a signer pubkey to the device-private trusted list. + /// Adds a signer pubkey to the portable trusted list. Future addTrustedSigner(String signerPubkey) async { - final trusted = await _loadTrustedSigners(); + final trusted = { + ...(await ref.read(settingsServiceProvider).loadPortable()) + .trustedSigners, + }; if (trusted.contains(signerPubkey)) return; trusted.add(signerPubkey); - await _saveTrustedSigners(trusted); - } - - Future _saveTrustedSigners(Set trusted) async { - final privateEvents = ref.read(devicePrivateEventServiceProvider); - final content = jsonEncode({'trusted': trusted.toList()}); - final encrypted = await privateEvents.encryptToDevice(content); - final partial = PartialCustomData( - identifier: kTrustedSignersIdentifier, - content: encrypted, - ); - await privateEvents.signAndSave(partial, publish: false); - } - - Future> _loadTrustedSigners() async { - final devicePubkey = ref.read(devicePubkeyProvider); - if (devicePubkey == null) return {}; - - try { - final request = Request([ - RequestFilter( - authors: {devicePubkey}, - tags: { - '#d': {kTrustedSignersIdentifier}, - }, - limit: 1, - ), - ]); - - final storage = ref.read(storageNotifierProvider.notifier); - final List models = await storage.query( - request, - source: const LocalSource(), - ); - - if (models.isEmpty) return {}; - final model = models.first; - final decrypted = await ref - .read(devicePrivateEventServiceProvider) - .decryptFromDevice(model.content); - final map = jsonDecode(decrypted) as Map; - final list = - (map['trusted'] as List?)?.cast() ?? const []; - return list.toSet(); - } catch (_) { - return {}; - } - } - - /// Imports the legacy local Amber-authored preference without publishing it. - Future migrateLegacyAmberRecord(Signer amberSigner) async { - final storage = ref.read(storageNotifierProvider.notifier); - final legacy = await storage.query( - RequestFilter( - authors: {amberSigner.pubkey}, - tags: { - '#d': {kTrustedSignersIdentifier}, - }, - limit: 1, - ).toRequest(), - source: const LocalSource(), - ); - if (legacy.isEmpty) return; - - try { - final map = jsonDecode(legacy.first.content) as Map; - final oldTrusted = - (map['trusted'] as List?)?.whereType().toSet() ?? const {}; - if (oldTrusted.isEmpty) return; - final merged = {...await _loadTrustedSigners(), ...oldTrusted}; - await _saveTrustedSigners(merged); - } catch (_) { - // Malformed legacy local preferences are ignored. - } + await ref + .read(deviceStateProvider.notifier) + .updatePortable( + (settings) => settings.copyWith(trustedSigners: trusted), + ); } } diff --git a/lib/widgets/device_backup_dialog.dart b/lib/widgets/device_backup_dialog.dart index 918871a..158cec2 100644 --- a/lib/widgets/device_backup_dialog.dart +++ b/lib/widgets/device_backup_dialog.dart @@ -1,70 +1,37 @@ import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:zapstore/services/device_backup_service.dart'; import 'package:zapstore/widgets/common/base_dialog.dart'; -/// Dialog shown on first Amber sign-in when other device backups are found. -/// Offers to restore a device key from a previous device. -class DeviceBackupRestoreDialog extends ConsumerWidget { - const DeviceBackupRestoreDialog({super.key, required this.backups}); +/// Dialog shown when Amber can recover a previous device key. +class DeviceBackupRestoreDialog extends StatelessWidget { + const DeviceBackupRestoreDialog({ + super.key, + required this.onRestore, + required this.onKeepCurrent, + }); - final List backups; + final VoidCallback onRestore; + final VoidCallback onKeepCurrent; @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return BaseDialog( title: const BaseDialogTitle('Restore Device Key'), titleIcon: const Icon(Icons.restore, size: 20), content: BaseDialogContent( children: [ const Text( - 'Found device keys from other devices linked to this identity. ' - 'Restore one to sync your bookmarks and settings.', + 'Amber has a device key backup. Restore it to recover your ' + 'bookmarks and portable settings.', ), - const SizedBox(height: 16), - ...backups.map((info) => _BackupTile( - info: info, - onRestore: () { - Navigator.pop(context, info); - }, - )), ], ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: onKeepCurrent, child: const Text('Keep current key'), ), + FilledButton(onPressed: onRestore, child: const Text('Restore')), ], ); } } - -class _BackupTile extends StatelessWidget { - const _BackupTile({required this.info, this.onRestore}); - - final DeviceBackupInfo info; - final VoidCallback? onRestore; - - @override - Widget build(BuildContext context) { - String? dateStr; - if (info.backedUpAt != null) { - final dt = info.backedUpAt!; - dateStr = - '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; - } - - return Card( - child: ListTile( - leading: const Icon(Icons.smartphone), - title: Text(info.deviceName), - subtitle: dateStr != null ? Text('Backed up: $dateStr') : null, - trailing: FilledButton.tonal( - onPressed: onRestore, - child: const Text('Restore'), - ), - ), - ); - } -} diff --git a/lib/widgets/device_restore_dialog.dart b/lib/widgets/device_restore_dialog.dart new file mode 100644 index 0000000..2c36c7c --- /dev/null +++ b/lib/widgets/device_restore_dialog.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:zapstore/constants/app_constants.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/widgets/common/base_dialog.dart'; + +enum DeviceRestoreAction { startFresh, pasteKey, amber } + +class DeviceRestoreResult { + const DeviceRestoreResult(this.action, {this.key}); + + final DeviceRestoreAction action; + final String? key; +} + +/// First-run choice for recovering an existing device identity. +class DeviceRestoreDialog extends HookConsumerWidget { + const DeviceRestoreDialog({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useTextEditingController(); + final amberInstalled = ref.watch( + packageManagerProvider.select( + (state) => state.installed.containsKey(kAmberPackageId), + ), + ); + + return BaseDialog( + title: const BaseDialogTitle('Restore device'), + titleIcon: const Icon(Icons.restore, size: 20), + content: BaseDialogContent( + children: [ + const Text( + 'Restore your saved apps and portable settings with a copied ' + 'device nsec or Amber.', + ), + const SizedBox(height: 8), + const Text( + 'Older local settings are not migrated. If you are updating, ' + 'copy your old device nsec before continuing.', + ), + const SizedBox(height: 16), + TextField( + controller: controller, + autocorrect: false, + enableSuggestions: false, + decoration: const InputDecoration( + labelText: 'Device nsec', + hintText: 'nsec1…', + ), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () { + final value = controller.text.trim(); + if (value.isEmpty) return; + Navigator.pop( + context, + DeviceRestoreResult(DeviceRestoreAction.pasteKey, key: value), + ); + }, + icon: const Icon(Icons.key), + label: const Text('Restore pasted key'), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () { + if (amberInstalled) { + Navigator.pop( + context, + const DeviceRestoreResult(DeviceRestoreAction.amber), + ); + } else { + context.push('/profile/app/$kAmberNaddr'); + } + }, + icon: const Icon(Icons.login), + label: Text( + amberInstalled + ? 'Restore with Amber' + : 'Install Amber to restore', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop( + context, + const DeviceRestoreResult(DeviceRestoreAction.startFresh), + ), + child: const Text('Start fresh'), + ), + ], + ); + } +} diff --git a/lib/widgets/legacy_installed_apps_dialog.dart b/lib/widgets/legacy_installed_apps_dialog.dart new file mode 100644 index 0000000..0816bef --- /dev/null +++ b/lib/widgets/legacy_installed_apps_dialog.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/widgets/app_stack_container.dart'; +import 'package:zapstore/widgets/common/base_dialog.dart'; +import 'package:zapstore/widgets/install_button.dart'; + +/// Lets a restored device install apps from an Amber-era installed-app backup. +class LegacyInstalledAppsDialog extends ConsumerWidget { + const LegacyInstalledAppsDialog({super.key, required this.appIds}); + + final List appIds; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final (:authors, :identifiers) = decomposeAddressableIds(appIds); + final appsState = ref.watch( + query( + authors: authors, + tags: {'#d': identifiers}, + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), + subscriptionPrefix: 'app-legacy-installed-recovery', + ), + ); + final apps = appsState.models.toList(); + + return BaseDialog( + title: const BaseDialogTitle('Restore apps'), + titleIcon: const Icon(Icons.restore, size: 20), + content: BaseDialogContent( + children: [ + Text( + 'Found ${appIds.length} apps from your previous device. ' + 'Choose which ones to install.', + ), + const SizedBox(height: 12), + if (appsState is StorageLoading && apps.isEmpty) + const Center(child: CircularProgressIndicator()) + else if (apps.isEmpty) + const Text( + 'App details are unavailable right now. Try again later.', + ) + else + ...apps.map( + (app) => ListTile( + contentPadding: EdgeInsets.zero, + title: Text(app.name ?? app.identifier), + trailing: InstallButton(app: app, compact: true), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Done'), + ), + ], + ); + } +} diff --git a/spec/features/FEAT-006-device-key.md b/spec/features/FEAT-006-device-key.md index e933f26..4d39798 100644 --- a/spec/features/FEAT-006-device-key.md +++ b/spec/features/FEAT-006-device-key.md @@ -2,104 +2,110 @@ ## Goal -Decouple private data (bookmarks, unmanaged apps, installed backup, settings) from -Amber sign-in by generating a local device key (nsec) that owns all private -encrypted events. Every private kind 30267 and 30078 event is signed by the -device key and carries NIP-13 proof of work. Amber is only the identity and -recovery layer for public actions and encrypted device-key backup capsules. +Provide one portable, encrypted device state that restores with either a copied +device nsec or Amber, while keeping private app stacks in Purplebase and all +device-only data local. ## Non-Goals -- Continuous or multi-device live sync -- Migrating legacy Amber-signed `zapstore-settings` events -- Changing how public stacks work (still Amber-signed with h tag) -- Implementing the publish queue (handled in purplebase) - -## User-Visible Behavior - -- On first launch, a device key is silently generated and stored in secure storage -- Bookmarks, unmanaged apps, and settings work immediately without sign-in -- Private data renders from SQLite; one non-streaming relay sync runs at app boot -- Profile screen shows device key section with ability to copy nsec -- On every Amber sign-in, the app performs one-shot recovery and legacy queries: - - Restore is offered when device-signed settings events contain a recovery - capsule for the Amber key - - The final device key is backed up in its device-signed settings event - - Amber-authored encrypted AppStacks are merged into device-owned stacks -- Clearing app data (SQLite) does NOT delete device key or NWC string -- Background sync, migration, and proof-of-work jobs are bounded and cancellable +- Migrating prior `zapstore-settings`, `trusted-signers`, or capsule data +- Backing up NWC, operational state, or actual installed-package detection +- Continuous live sync or changing public community stacks +- A user-facing way to cancel bootstrap mining ## Data Model -- Device nsec: secure storage only (`zapstore_secure_prefs` JSON blob, field `nsec`, hex) -- NWC string: secure storage only (existing key) -- Bookmarks: encrypted AppStack (30267), d=zapstore-bookmarks, signed by device key -- Unmanaged apps: encrypted AppStack (30267), d=zapstore-unmanaged-apps, signed by device key -- Installed backup: encrypted AppStack (30267), d=zapstore-installed-apps, signed by device key -- App settings: versioned private CustomData (30078), d=zapstore-settings, - signed by the device key -- Device backup: NIP-44 recovery capsules encrypted from the device key to Amber - identities inside `zapstore-settings`; matching `p` tags allow discovery. - Each capsule includes an Amber-signed authorization binding that identity to - the device pubkey, preventing third-party recovery-candidate injection. -- Trusted signers: encrypted, local-only CustomData (30078), d=trusted-signers, - signed by the device key -- Every private event commits to at least 16 bits of NIP-13 proof of work +- Secure storage keys: + - `settings`: lower-camel-case portable JSON: `backgroundAutoUpdatesEnabled`, + `installedAppsBackupEnabled`, and `trustedSigners`. + - `temp_settings`: lower-camel-case device-only JSON, including `logLevel`, + `lastAppOpened`, `seenUntil`, `deletionSyncedUntil`, and restore onboarding. + - `nwc`: local-only NWC connection string. + - `device_key`: local device nsec in hex. + - `amber_pubkey`: local Amber reconnect identity. +- Device state: kind `30078`, `d=zapstore-device-state`, authored and signed by + the device key; its content is the NIP-44 self-encrypted `settings` JSON. +- Amber key backup: kind `30078`, `d=zapstore-device-key-backup`, authored and + signed by Amber; its content is NIP-44 self-encrypted JSON with + `privateKeyHex`. +- Private Purplebase stacks remain kind `30267`, authored by the device key: + `zapstore-bookmarks`, `zapstore-installed-apps`, and + `zapstore-unmanaged-apps`. +- The device-owned AppCatalog relay list is kind `10067` and has no `d` tag. +- Purplebase stores the latest local-only, PoW-less draft for each pending + device-key event. The drafts are never published to a relay. +- Secure storage records each pending event's kind and, when present, `d` tag + in a list namespaced by the device pubkey derived from the active device key. + This small marker survives process restart and identifies the latest local + draft that must be mined and published. +- JSON uses lower camel case; Nostr tag values use kebab case. -## Signer Roles +## User-Visible Behavior -- Device signer (Bip340PrivateKeySigner): always available, never null. Signs all - private events. Registered on boot, NOT set as active. -- Amber signer (AmberSigner): optional. When present, is the active signer. Used - for public stacks, zaps, WoT queries, and recovery-capsule encryption/decryption. -- NIP-13 mining runs in a Purplebase-owned worker isolate after encryption and - before signing; no mining loop runs on Flutter's main isolate. +- A new device key does not publish an empty device-state event. +- A persistable device-state or private-stack change first saves its latest + local-only PoW-less draft and records a secure-storage pending marker. + Local persistence completes without waiting for mining or relay acceptance. +- The device-event queue rebuilds each marked draft as a 20-bit-PoW event in a + background isolate. Private state and app stacks publish to AppCatalog; + device relay lists publish to the bootstrap relay. Pending markers resume + processing after process restart and when relay connectivity returns. +- A fresh install offers paste nsec, restore with Amber, or start fresh. + If Amber is unavailable, its option opens the Amber install page. +- Pasted nsec or Amber recovery imports the device key, then fetches and + decrypts device state and the three private stacks. +- On Amber sign-in during restore onboarding, a non-empty legacy + `zapstore-installed-apps` stack is offered as “Restore apps from previous + device.” The user selects apps to install; it is never mistaken for packages + installed on this device or overwritten with an empty stack. +- SQLite remains a local-first cache. Offline restore and publishing show + explicit retry/error states without preventing use of local data. -## Filtering Strategy +## PoW and Lifecycle -- Public stacks: filtered by #h tag (community pubkey) naturally excludes device stacks -- Device private stacks: queried by authors: {devicePubkey} + specific #d tag -- appStackEventFilter schema filter removed; #h tag filtering is sufficient -- Device private 30267/30078 events are fetched once with stream=false at boot. - Private UI consumers use LocalSource only. -- Amber sign-in recovery and migration are explicit one-shot exceptions; they - never open streaming subscriptions. +- Every device-key event submitted to a relay requires 20-bit NIP-13 proof of + work, including device-state events, encrypted private app stacks, and the + device-owned AppCatalog relay list. + Purplebase-local drafts are the sole exception and must never be published. +- The Zapstore device-event queue uses secure-storage pending markers and + Purplebase-local drafts. Relay failures retain the marker; replay is driven + by app startup and connectivity/reconnection signals, never polling or + artificial delays. A marker is cleared only after AppCatalog acceptance. +- Mining has no timeout or user cancellation. It runs outside the Flutter UI + isolate and is cancelled when its owning service/provider is disposed to + avoid a lifecycle leak. Cancellation leaves the local mutation intact and + the operation eligible to be re-enqueued. +- The client verifies a restored event's signature and expected author; relay + admission proof is not a client restore criterion. -## Edge Cases +## Upgrade from 1.0.6 -- Device key lost (app uninstalled without backup): data unrecoverable, fresh start -- Amber uninstalled while backup dialog pending: backup deferred to next sign-in -- Restore on device that already has data: ask user to confirm (replace or keep current) -- Offline: events save locally, purplebase publish queue syncs when online -- Multiple devices with same Amber key: each has own device key; backup stores device name -- Existing device-authored events without PoW are re-signed in the background; - invalid or undecryptable events are never overwritten. -- Legacy Amber-authored settings are ignored even if that loses old backups. -- Migration is idempotent and rechecks on every Amber sign-in; failed decrypts - remain retryable. +- The release intentionally starts fresh: it does not migrate any prior secure + storage, device key, Amber identity, private settings event, trusted signers, + recovery capsules, or migration flags. +- Users lose NWC, both settings toggles, log level, update/notification and + deletion cursors, trusted signers, old device nsec, and Amber reconnect state. +- Existing device-owned stacks are recoverable only when the user exported the + old nsec before updating. Existing Amber-installed-app stacks remain + discoverable after Amber sign-in during restore onboarding. +- Release notes and an in-app warning must explain the loss and tell users to + copy their device nsec before updating. ## Acceptance Criteria -- [ ] Device key generated on first launch and persisted in secure storage -- [ ] Device key survives SQLite clear / app restart -- [ ] Bookmarks work without Amber sign-in -- [ ] Unmanaged apps work without Amber sign-in -- [ ] User can copy device nsec from profile screen -- [ ] First Amber sign-in triggers backup/restore dialog -- [ ] Every Amber sign-in upserts a recovery capsule in device-signed settings -- [ ] Restore discovers device-signed settings by Amber `p` tag and imports nsec -- [ ] Legacy Amber-signed settings are ignored -- [ ] Amber private bookmarks, installed backups, unmanaged apps, and other - encrypted AppStacks migrate safely to the final device key -- [ ] All new private 30267/30078 events have valid 16-bit NIP-13 proof of work -- [ ] PoW mining does not run on Flutter's main isolate -- [ ] Private relay reads happen only at boot and explicit Amber sign-in recovery -- [ ] appStackEventFilter removed; queries use #h tag filtering -- [ ] EncryptableModel auto-decrypts device-key stacks (no manual decrypt calls) - -## Phases - -- A: Device key generation + service + registration at boot + copy nsec UI -- B: Migrate bookmarks/unmanaged/backup to device key (drop Amber requirement) -- C: Amber backup/restore dialog + CustomData events -- D: Remove appStackEventFilter, clean up sign-in gating in UI +- [ ] A new device key publishes no empty bootstrap event +- [ ] Every device-key event submitted to a relay has at least 20 bits of valid + NIP-13 proof of work; local drafts are never published +- [ ] Mining runs off the Flutter UI isolate and is lifecycle-safe +- [ ] Pending kind-and-identifier markers survive app restart and retry from + their latest Purplebase-local drafts on startup or reconnection without + polling +- [ ] Local state changes remain usable while proof mining or publishing is + pending, cancelled, or failing +- [ ] Portable settings and trusted signers restore from `zapstore-device-state` +- [ ] NWC and all temp settings never appear in device-state +- [ ] Pasted-nsec and Amber restore both recover the same device state +- [ ] Amber backup contains only its self-encrypted `privateKeyHex` +- [ ] Legacy installed-app recovery offers selected installs without claiming + those apps are locally installed +- [ ] New JSON and tag casing follows the defined convention diff --git a/spec/work/WORK-019-simplify-device-backup.md b/spec/work/WORK-019-simplify-device-backup.md new file mode 100644 index 0000000..58add78 --- /dev/null +++ b/spec/work/WORK-019-simplify-device-backup.md @@ -0,0 +1,89 @@ +# WORK-019 - Simplify Device Backup + +**Feature:** FEAT-006-device-key.md +**Status:** In Progress + +## Tasks + +- [x] 1. Replace legacy secure-storage layouts + - Files: `lib/services/settings_service.dart`, + `lib/services/device_key_service.dart` + - Create the `settings`, `temp_settings`, `nwc`, `device_key`, and + `amber_pubkey` layout; intentionally do not migrate existing values. +- [x] 2. Create portable device-state persistence + - Files: `lib/services/device_private_event_service.dart`, + `lib/services/device_private_sync_service.dart`, new state service + - Persist portable state locally, self-encrypt and publish + `30078/zapstore-device-state`, and fold in trusted signers. +- [x] 3. Implement bootstrap admission + - Files: `lib/main.dart`, device-state/private-event services, device-key UI + - Mine the first empty device-state event at 28-bit PoW in the background + isolate; queue later private publications, disable settings until accepted, + and cancel only on lifecycle disposal. +- [x] 4. Replace capsule recovery with Amber key backup + - Files: `lib/services/device_backup_service.dart`, + `lib/widgets/device_backup_dialog.dart`, `lib/screens/profile_screen.dart` + - Publish Amber-authored `30078/zapstore-device-key-backup`, restore via + Amber or pasted nsec, and route absent Amber to its install page. +- [x] 5. Add legacy installed-app recovery + - Files: app restore UI, package/install orchestration, `temp_settings` + - During incomplete restore onboarding and Amber sign-in, offer apps from a + non-empty legacy Amber `zapstore-installed-apps` stack without treating + them as installed or overwriting them with an empty device stack. +- [x] 6. Remove obsolete paths + - Delete capsule authorization, recovery-candidate, trusted-signers event, + old settings-event, and migration-marker logic. +- [x] 7. Add release warning and tests + - Warn current users that all old secure storage is discarded and nsec must be + copied before upgrade. Cover schema, restore, bootstrap, offline/error, + lifecycle cancellation, Amber-install routing, and recovery-install UI. +- [ ] 8. Self-review against INVARIANTS.md + +## Test Coverage + +| Scenario | Expected | Status | +|----------|----------|--------| +| New key bootstrap | Empty state is first accepted private event | [ ] | +| Bootstrap pending | Settings disabled; local UI remains responsive | [ ] | +| Bootstrap lifecycle disposal | Mining isolate is cancelled without leaking | [ ] | +| Settings publish failure | Local portable state remains available; retry is explicit | [ ] | +| Pasted nsec restore | Signature-verified state and stacks restore | [ ] | +| Amber restore | Verified Amber backup imports the same nsec | [ ] | +| Amber absent | Restore UI opens Amber install page | [ ] | +| Legacy installed backup | User selects apps to install; none are falsely installed | [ ] | +| NWC/temp exclusion | Neither is serialized to device state | [ ] | +| 1.0.6 update | Old values are discarded after explicit warning | [ ] | + +## Decisions + +### 2026-07-14 - Fixed bootstrap proof + +**Context:** Per-update proof mining makes normal settings changes expensive. +**Options:** Mine every replacement; await a relay proof challenge; bootstrap once. +**Decision:** Mine an empty device-state event at fixed 28-bit difficulty before +any remote private write, then rely on relay admission and rate limiting. +**Rationale:** It gives the relay a simple first-event cost while ordinary +settings writes remain local-first and cheap. + +### 2026-07-14 - No user cancellation or timeout + +**Context:** Bootstrap is setup work, not an interruptible user operation. +**Decision:** Show indefinite progress without timeout or a cancel button. +**Rationale:** Avoids retry and partial-state UI complexity. The owning +service/provider still cancels the isolate on disposal, as required for +lifecycle safety. + +## Spec Issues + +_None_ + +## Progress Notes + +**2026-07-14:** Contract rewritten for a clean-break migration. Relay changes +are tracked separately; this work assumes it admits the first private event +only when its NIP-13 difficulty is at least 28 bits. + +**2026-07-14:** Implemented separate secure-storage entries, portable +device-state persistence, 28-bit bootstrap admission, nsec parsing, and the +Amber-authored key-backup record. Verification is blocked because the checked +out workspace has no `../purplebase` path dependency. diff --git a/test/services/device_backup_service_test.dart b/test/services/device_backup_service_test.dart index f0d25a7..c7d23a1 100644 --- a/test/services/device_backup_service_test.dart +++ b/test/services/device_backup_service_test.dart @@ -1,123 +1,9 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:models/models.dart'; -import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/services/device_backup_service.dart'; -import 'package:zapstore/utils/debug_utils.dart'; void main() { - setUpAll(() { - Model.register( - kind: 1, - constructor: Note.fromMap, - partialConstructor: PartialNote.fromMap, - ); - Model.register( - kind: 30267, - constructor: AppStack.fromMap, - partialConstructor: PartialAppStack.fromMap, - ); - }); - - test( - 'explicitly decrypts an imperatively loaded Amber bookmark stack', - () async { - final container = ProviderContainer( - overrides: [ - storageNotifierProvider.overrideWith(DummyStorageNotifier.new), - ], - ); - addTearDown(container.dispose); - await container - .read(storageNotifierProvider.notifier) - .initialize(StorageConfiguration()); - final amber = Bip340PrivateKeySigner( - '2' * 64, - container.read(refProvider), - ); - await amber.signIn(setAsActive: false); - - final signed = await PartialAppStack.withEncryptedApps( - name: 'Saved Apps', - identifier: kAppBookmarksIdentifier, - apps: const ['32267:publisher:app-one', '32267:publisher:app-two'], - ).signWith(amber); - final imperativelyLoaded = AppStack.fromMap( - signed.event.toMap(), - container.read(refProvider), - ); - - expect(imperativelyLoaded.privateAppIds, isEmpty); - expect(await decryptAmberStackAppIds(amber, imperativelyLoaded), [ - '32267:publisher:app-one', - '32267:publisher:app-two', - ]); - }, - ); - - test('requires Amber authorization for the recovered device key', () async { - final container = ProviderContainer( - overrides: [ - storageNotifierProvider.overrideWith(DummyStorageNotifier.new), - ], - ); - addTearDown(container.dispose); - await container - .read(storageNotifierProvider.notifier) - .initialize(StorageConfiguration()); - final amber = Bip340PrivateKeySigner('2' * 64, container.read(refProvider)); - await amber.signIn(setAsActive: false); - final devicePubkey = Utils.derivePublicKey('3' * 64); - final attackerPubkey = Utils.derivePublicKey('4' * 64); - - final partial = PartialNote('zapstore-device-key-authorization-v1'); - partial.event.addTagValue('device', devicePubkey); - partial.event.addTagValue('p', amber.pubkey); - final authorization = await partial.signWith(amber); - final map = authorization.event.toMap(); - - expect( - validateRecoveryAuthorization( - container.read(refProvider), - map, - amberPubkey: amber.pubkey, - devicePubkey: devicePubkey, - ), - isTrue, - ); - expect( - validateRecoveryAuthorization( - container.read(refProvider), - map, - amberPubkey: amber.pubkey, - devicePubkey: attackerPubkey, - ), - isFalse, - ); - }); - - test('cancellation prevents recovery work from restarting', () async { - final container = ProviderContainer( - overrides: [ - storageNotifierProvider.overrideWith(DummyStorageNotifier.new), - ], - ); - addTearDown(container.dispose); - await container - .read(storageNotifierProvider.notifier) - .initialize(StorageConfiguration()); - final amber = Bip340PrivateKeySigner('2' * 64, container.read(refProvider)); - await amber.signIn(setAsActive: false); - final service = DeviceBackupService()..beginWork(); - - service.cancelCurrentWork(container.read(refProvider)); - - await expectLater( - service.fetchRecoveryCandidates( - ref: container.read(refProvider), - amberSigner: amber, - ), - throwsA(isA()), - ); + test('backup exceptions retain a user-safe message', () { + const exception = DeviceBackupException('Amber backup was unavailable.'); + expect(exception.toString(), 'Amber backup was unavailable.'); }); } diff --git a/test/services/device_key_service_test.dart b/test/services/device_key_service_test.dart new file mode 100644 index 0000000..00423c5 --- /dev/null +++ b/test/services/device_key_service_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/services/device_key_service.dart'; + +void main() { + final service = DeviceKeyService(); + + test('parses a copied nsec into lower-case hex', () { + const hex = + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + expect(service.parsePrivateKey(bech32Encode('nsec', hex)), hex); + }); + + test('rejects malformed nsec checksums', () { + expect(service.parsePrivateKey('nsec1invalid'), isNull); + }); +} diff --git a/test/services/settings_service_test.dart b/test/services/settings_service_test.dart index a2bb733..32c1ffc 100644 --- a/test/services/settings_service_test.dart +++ b/test/services/settings_service_test.dart @@ -1,33 +1,43 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:zapstore/services/log_service.dart'; import 'package:zapstore/services/settings_service.dart'; void main() { - group('LocalSettings', () { + group('PortableSettings', () { test('backgroundAutoUpdatesEnabled defaults to false', () { - const settings = LocalSettings(); + const settings = PortableSettings(); expect(settings.backgroundAutoUpdatesEnabled, isFalse); }); test('round-trips backgroundAutoUpdatesEnabled in JSON', () { - const settings = LocalSettings(backgroundAutoUpdatesEnabled: true); - final restored = LocalSettings.fromJson(settings.toJson()); + const settings = PortableSettings(backgroundAutoUpdatesEnabled: true); + final restored = PortableSettings.fromJson(settings.toJson()); expect(restored.backgroundAutoUpdatesEnabled, isTrue); }); test('copyWith toggles backgroundAutoUpdatesEnabled', () { - const settings = LocalSettings(); + const settings = PortableSettings(); final updated = settings.copyWith(backgroundAutoUpdatesEnabled: true); expect(updated.backgroundAutoUpdatesEnabled, isTrue); }); - test('discards legacy relay settings', () { - final restored = LocalSettings.fromJson({ - 'relays': ['wss://legacy.example'], - 'logLevel': 'info', - }); + test('uses lower camel case portable JSON keys', () { + const settings = PortableSettings( + installedAppsBackupEnabled: true, + trustedSigners: {'a'}, + ); - expect(restored.toJson(), isNot(contains('relays'))); - expect(restored.toJson()['logLevel'], 'info'); + expect(settings.toJson(), { + 'installedAppsBackupEnabled': true, + 'backgroundAutoUpdatesEnabled': false, + 'trustedSigners': ['a'], + }); }); }); + + test('temp settings keep log level out of portable data', () { + const temp = TempSettings(logLevel: LogLevel.info); + expect(temp.toJson()['logLevel'], 'info'); + expect(const PortableSettings().toJson(), isNot(contains('logLevel'))); + }); }