Replace capsule recovery with portable device state, Amber key backup, and legacy installed-app restore

This commit is contained in:
franzap
2026-07-15 13:41:45 -03:00
parent e260c12581
commit f1e97925df
17 changed files with 1203 additions and 1122 deletions
+4 -4
View File
@@ -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.
+19
View File
@@ -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<void>((ref) async {
final appInitializationProvider = FutureProvider<void>((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<void>((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();
+133 -75
View File
@@ -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<bool>(
@@ -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<bool>(
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<bool>(
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);
},
);
}
}
+219 -427
View File
@@ -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<Request<Model<dynamic>>> _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<CustomData?> fetchExistingSettings(
Ref ref,
String devicePubkey,
) async {
final results = await _queryTracked(
ref,
RequestFilter<CustomData>(
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<List<DeviceBackupInfo>> fetchRecoveryCandidates({
required Ref ref,
required Signer amberSigner,
}) async {
_checkCancelled();
final request = RequestFilter<CustomData>(
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 = <String, DeviceBackupInfo>{};
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<Map>()) {
final recovery = Map<String, dynamic>.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<String, dynamic>.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<String, dynamic>.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<void> 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>()
.map((entry) => Map<String, dynamic>.from(entry))
.toList() ??
<Map<String, dynamic>>[];
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<bool> migratePrivateStacksToDeviceKey({
Future<String?> fetchAmberBackup({
required Ref ref,
required Signer amberSigner,
}) async {
_checkCancelled();
final request = RequestFilter<AppStack>(
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<CustomData>(
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<AppStack>(
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<String>.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<void> 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<List<E>> _queryTracked<E extends Model<dynamic>>(
Ref ref,
Request<E> request, {
required Source source,
required String subscriptionPrefix,
Future<List<String>> fetchLegacyInstalledAppIds({
required Ref ref,
required Signer amberSigner,
}) async {
_checkCancelled();
_activeRequests.add(request);
final results = await ref
.read(storageNotifierProvider.notifier)
.query(
RequestFilter<AppStack>(
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<String>()
.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<String> _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<List<String>?> 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<String>().toList() : null;
} catch (_) {
return null;
}
}
final deviceBackupServiceProvider = Provider<DeviceBackupService>(
(ref) => DeviceBackupService(),
);
Map<String, dynamic> _emptySettingsEnvelope() => {
'version': _kSettingsFormatVersion,
_kRecoveriesKey: <Map<String, dynamic>>[],
};
/// Runs the one-time recovery choice after the navigator overlay is available.
Future<void> 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<String, dynamic>? _decodeSettingsEnvelope(String content) {
try {
final decoded = jsonDecode(content);
if (decoded is! Map) return null;
final envelope = Map<String, dynamic>.from(decoded);
if (envelope['version'] != _kSettingsFormatVersion) return null;
return envelope;
} catch (_) {
return null;
final result = await showDialog<DeviceRestoreResult>(
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<String, dynamic> authorization, {
required String amberPubkey,
required String devicePubkey,
}) {
try {
if (authorization['kind'] != 1 ||
authorization['pubkey'] != amberPubkey ||
authorization['content'] != _kRecoveryAuthorization) {
return false;
}
final event = PartialEvent<Model<dynamic>>(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<void> 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<DeviceBackupInfo> 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<DeviceBackupInfo>(
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<bool>(
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<void>(
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;
}
+74 -74
View File
@@ -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<String, dynamic>? _cache;
Future<Map<String, dynamic>> _load() async {
if (_cache != null) return _cache!;
final raw = await _storage.read(key: _kSecurePrefsKey);
_cache = (raw != null && raw.isNotEmpty)
? jsonDecode(raw) as Map<String, dynamic>
: <String, dynamic>{};
return _cache!;
}
Future<void> _persist() async {
await _storage.write(key: _kSecurePrefsKey, value: jsonEncode(_cache));
}
/// Load existing device key or generate a new one. Returns hex private key.
Future<String> 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<void> 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<bool> hasBackupBeenOffered(String pubkey) async {
final prefs = await _load();
final list = (prefs['backup_offered'] as List?)?.cast<String>() ?? [];
return list.contains(pubkey);
}
/// Mark that the backup dialog was shown for [pubkey].
Future<void> markBackupOffered(String pubkey) async {
final prefs = await _load();
final list = (prefs['backup_offered'] as List?)?.cast<String>() ?? [];
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<bool> hasPrivateStacksMigrated(
String amberPubkey,
String devicePubkey,
) async {
final prefs = await _load();
final list =
(prefs['private_stacks_migrated'] as List?)?.cast<String>() ?? [];
return list.contains(_migrationKey(amberPubkey, devicePubkey));
}
/// Mark private stack migration complete for [devicePubkey].
Future<void> markPrivateStacksMigrated(
String amberPubkey,
String devicePubkey,
) async {
final prefs = await _load();
final list =
(prefs['private_stacks_migrated'] as List?)?.cast<String>() ?? [];
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 = <int>[];
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<int>? _convertBits(
List<int> values, {
required int from,
required int to,
}) {
var accumulator = 0;
var bits = 0;
final output = <int>[];
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<int> values) {
const generators = [
0x3b6a57b2,
0x26508e6d,
0x1ea119fa,
0x3d4233dd,
0x2a1462b3,
];
var checksum = 1;
final expandedHrp = <int>[
...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<DeviceKeyService>(
+2 -65
View File
@@ -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<DevicePrivateSyncState> {
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<DevicePrivateSyncState> {
}
}
Future<void> _upgradeLegacyProofs(Request<Model<dynamic>> 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<String>.of(tag));
}
partial.event.createdAt = privateEvents.nextReplaceableTimestamp(
data.createdAt,
);
await privateEvents.signAndSave(
partial,
publish: data.identifier != kTrustedSignersIdentifier,
);
default:
break;
}
}
}
Future<List<Model<dynamic>>> _queryStorage(
Request<Model<dynamic>> request, {
required Source source,
+155
View File
@@ -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<DeviceStateStatus> {
DeviceStateNotifier(this.ref)
: super(const DeviceStateStatus.bootstrapping());
final Ref ref;
Future<void>? _bootstrapFuture;
Future<void> _publishQueue = Future.value();
bool _disposed = false;
Future<void> bootstrap() {
final existing = _bootstrapFuture;
if (existing != null) return existing;
if (!_disposed) {
state = DeviceStateStatus(
DeviceStatePhase.bootstrapping,
startedAt: DateTime.now(),
);
}
return _bootstrapFuture = _bootstrap();
}
Future<void> _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<void> 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<bool> restoreFromLocalEvent() async {
final devicePubkey = ref.read(devicePubkeyProvider);
if (devicePubkey == null) return false;
final models = await ref
.read(storageNotifierProvider.notifier)
.query(
RequestFilter<CustomData>(
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<String, dynamic>.from(decoded)),
);
if (!_disposed) state = const DeviceStateStatus.ready();
return true;
} catch (_) {
return false;
}
}
Future<void> _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<DeviceStateNotifier, DeviceStateStatus>(
(ref) => DeviceStateNotifier(ref),
);
+183 -130
View File
@@ -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<String> 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<String, dynamic> 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<String, dynamic> 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<String>().toSet() ??
const {},
);
}
Map<String, dynamic> toJson() => {
if (nwcConnectionString != null) 'nwc': nwcConnectionString,
'installedAppsBackupEnabled': installedAppsBackupEnabled,
'backgroundAutoUpdatesEnabled': backgroundAutoUpdatesEnabled,
'trustedSigners': trustedSigners.toList()..sort(),
};
PortableSettings copyWith({
bool? installedAppsBackupEnabled,
bool? backgroundAutoUpdatesEnabled,
Set<String>? 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<String, dynamic> 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<String, dynamic> 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<String> 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<String>? 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<LocalSettings> load() async {
final json = await _storage.read(key: _key);
if (json != null && json.isNotEmpty) {
try {
final decoded = jsonDecode(json) as Map<String, dynamic>;
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<LocalSettings> _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<PortableSettings> loadPortable() async =>
_decodePortable(await _storage.read(key: settingsKey));
Future<TempSettings> loadTemp() async =>
_decodeTemp(await _storage.read(key: tempSettingsKey));
Future<void> 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<void> savePortable(PortableSettings settings) =>
_storage.write(key: settingsKey, value: jsonEncode(settings.toJson()));
Future<void> saveTemp(TempSettings settings) => _storage.write(
key: tempSettingsKey,
value: jsonEncode(settings.toJson()),
);
Future<void> saveNwc(String? connectionString) =>
connectionString?.isNotEmpty == true
? _storage.write(key: nwcKey, value: connectionString!)
: _storage.delete(key: nwcKey);
Future<LocalSettings> 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<String, dynamic>);
} catch (_) {
return const PortableSettings();
}
}
static TempSettings _decodeTemp(String? raw) {
try {
return raw == null || raw.isEmpty
? const TempSettings()
: TempSettings.fromJson(jsonDecode(raw) as Map<String, dynamic>);
} 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';
+16 -85
View File
@@ -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<bool> 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<void> 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<void> _saveTrustedSigners(Set<String> 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<Set<String>> _loadTrustedSigners() async {
final devicePubkey = ref.read(devicePubkeyProvider);
if (devicePubkey == null) return <String>{};
try {
final request = Request<CustomData>([
RequestFilter<CustomData>(
authors: {devicePubkey},
tags: {
'#d': {kTrustedSignersIdentifier},
},
limit: 1,
),
]);
final storage = ref.read(storageNotifierProvider.notifier);
final List<CustomData> models = await storage.query(
request,
source: const LocalSource(),
);
if (models.isEmpty) return <String>{};
final model = models.first;
final decrypted = await ref
.read(devicePrivateEventServiceProvider)
.decryptFromDevice(model.content);
final map = jsonDecode(decrypted) as Map<String, dynamic>;
final list =
(map['trusted'] as List?)?.cast<String>() ?? const <String>[];
return list.toSet();
} catch (_) {
return <String>{};
}
}
/// Imports the legacy local Amber-authored preference without publishing it.
Future<void> migrateLegacyAmberRecord(Signer amberSigner) async {
final storage = ref.read(storageNotifierProvider.notifier);
final legacy = await storage.query(
RequestFilter<CustomData>(
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<String, dynamic>;
final oldTrusted =
(map['trusted'] as List?)?.whereType<String>().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),
);
}
}
+14 -47
View File
@@ -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<DeviceBackupInfo> 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'),
),
),
);
}
}
+100
View File
@@ -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'),
),
],
);
}
}
@@ -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<String> appIds;
@override
Widget build(BuildContext context, WidgetRef ref) {
final (:authors, :identifiers) = decomposeAddressableIds(appIds);
final appsState = ref.watch(
query<App>(
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'),
),
],
);
}
}
+92 -86
View File
@@ -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
@@ -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.
+3 -117
View File
@@ -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<DeviceBackupCancelled>()),
);
test('backup exceptions retain a user-safe message', () {
const exception = DeviceBackupException('Amber backup was unavailable.');
expect(exception.toString(), 'Amber backup was unavailable.');
});
}
@@ -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);
});
}
+22 -12
View File
@@ -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')));
});
}