Rewrite device keypair architecture

This commit is contained in:
franzap
2026-07-13 23:49:43 -03:00
parent 84c6799dee
commit ab1690f89f
30 changed files with 1569 additions and 564 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.3" apply false id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false
} }
+5
View File
@@ -28,6 +28,11 @@ const kInstalledAppsIdentifier = 'zapstore-installed-apps';
/// Identifier for the encrypted stack of apps the user chose as unmanaged /// Identifier for the encrypted stack of apps the user chose as unmanaged
const kUnmanagedAppsIdentifier = 'zapstore-unmanaged-apps'; const kUnmanagedAppsIdentifier = 'zapstore-unmanaged-apps';
/// Identifier for device-private settings and recovery capsules
const kSettingsIdentifier = 'zapstore-settings';
/// Identifier for the local encrypted trusted-signer preference
const kTrustedSignersIdentifier = 'trusted-signers';
/// Authoritative relay for NIP-82 software application events (kind 32267). /// 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. /// Used as the relay hint in naddr encoding so other clients can resolve app events.
+17 -1
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/deep_link_service.dart';
import 'package:zapstore/services/device_backup_service.dart'; import 'package:zapstore/services/device_backup_service.dart';
import 'package:zapstore/services/device_key_service.dart'; import 'package:zapstore/services/device_key_service.dart';
import 'package:zapstore/services/device_private_sync_service.dart';
import 'package:zapstore/services/app_catalog_relay_service.dart'; import 'package:zapstore/services/app_catalog_relay_service.dart';
import 'package:zapstore/utils/debug_utils.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/breathing_logo.dart'; import 'package:zapstore/widgets/breathing_logo.dart';
@@ -169,6 +171,13 @@ class ZapstoreApp extends HookConsumerWidget {
// Watch initialization state for error overlay display // Watch initialization state for error overlay display
final initState = ref.watch(appInitializationProvider); final initState = ref.watch(appInitializationProvider);
// One coordinator handles every successful Amber key transition, including
// auto sign-in and every manual sign-in entry point.
ref.listen<String?>(Signer.activePubkeyProvider, (previous, next) {
if (next == null || next == previous) return;
onSignInSuccess(ref.read(refProvider));
});
// Listen to app lifecycle and check for updates when app regains focus // Listen to app lifecycle and check for updates when app regains focus
useEffect(() { useEffect(() {
final observer = _AppLifecycleObserver(ref); final observer = _AppLifecycleObserver(ref);
@@ -402,6 +411,9 @@ final storageReadyProvider = FutureProvider<void>((ref) async {
final appInitializationProvider = FutureProvider<void>((ref) async { final appInitializationProvider = FutureProvider<void>((ref) async {
await ref.read(storageReadyProvider.future); await ref.read(storageReadyProvider.future);
// Private relay ingestion is one-shot, non-streaming, and never gates UI.
unawaited(ref.read(devicePrivateSyncProvider.notifier).start());
// Initialize device capabilities (used for dynamic download concurrency) // Initialize device capabilities (used for dynamic download concurrency)
await DeviceCapabilitiesCache.initialize(); await DeviceCapabilitiesCache.initialize();
@@ -464,7 +476,6 @@ Future<void> _maybeCopySeedDatabase(String dbPath, {bool skip = false}) async {
Future<void> _attemptAutoSignIn(Ref ref) async { Future<void> _attemptAutoSignIn(Ref ref) async {
try { try {
await ref.read(amberSignerProvider).attemptAutoSignIn(); await ref.read(amberSignerProvider).attemptAutoSignIn();
onSignInSuccess(ref);
} catch (e, st) { } catch (e, st) {
// Auto sign-in fails on first install — that's fine, just continue. // Auto sign-in fails on first install — that's fine, just continue.
// Logged at debug because this is expected for new users. // Logged at debug because this is expected for new users.
@@ -486,6 +497,7 @@ Future<void> _attemptAutoSignIn(Ref ref) async {
void onSignInSuccess(Ref ref) { void onSignInSuccess(Ref ref) {
final pubkey = ref.read(Signer.activePubkeyProvider); final pubkey = ref.read(Signer.activePubkeyProvider);
if (pubkey == null) return; if (pubkey == null) return;
ref.read(deviceBackupServiceProvider).beginWork();
final storage = final storage =
ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier; ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier;
@@ -562,6 +574,10 @@ class _AppLifecycleObserver with WidgetsBindingObserver {
unawaited(_ref.read(appCatalogRelayServiceProvider).checkForUpdates()); unawaited(_ref.read(appCatalogRelayServiceProvider).checkForUpdates());
} else if (state == AppLifecycleState.paused) { } else if (state == AppLifecycleState.paused) {
_ref.read(appCatalogRelayServiceProvider).cancelCurrentCheck(); _ref.read(appCatalogRelayServiceProvider).cancelCurrentCheck();
_ref.read(devicePrivateSyncProvider.notifier).cancel();
_ref
.read(deviceBackupServiceProvider)
.cancelCurrentWork(_ref.read(refProvider));
notifier.disconnect(); notifier.disconnect();
// Flush any pending log entries to disk before the OS may freeze // Flush any pending log entries to disk before the OS may freeze
// or kill us, so diagnostics survive backgrounding. // or kill us, so diagnostics survive backgrounding.
+20 -58
View File
@@ -1,11 +1,9 @@
import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:zapstore/services/device_key_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/nostr_route.dart'; import 'package:zapstore/utils/nostr_route.dart';
@@ -17,7 +15,7 @@ import 'package:zapstore/widgets/common/time_utils.dart';
import 'package:zapstore/widgets/floating_overflow_menu.dart'; import 'package:zapstore/widgets/floating_overflow_menu.dart';
import 'package:zapstore/theme.dart'; import 'package:zapstore/theme.dart';
class AppStackScreen extends HookConsumerWidget { class AppStackScreen extends ConsumerWidget {
const AppStackScreen({super.key, required this.stackId, this.authorPubkey}); const AppStackScreen({super.key, required this.stackId, this.authorPubkey});
final String stackId; final String stackId;
@@ -25,6 +23,8 @@ class AppStackScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isDeviceOwned =
authorPubkey != null && authorPubkey == ref.watch(devicePubkeyProvider);
final stackState = ref.watch( final stackState = ref.watch(
query<AppStack>( query<AppStack>(
authors: authorPubkey != null ? {authorPubkey!} : null, authors: authorPubkey != null ? {authorPubkey!} : null,
@@ -32,10 +32,9 @@ class AppStackScreen extends HookConsumerWidget {
'#d': {stackId}, '#d': {stackId},
}, },
limit: 1, limit: 1,
source: const LocalAndRemoteSource( source: isDeviceOwned
relays: 'AppCatalog', ? const LocalSource()
stream: false, : const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
),
subscriptionPrefix: 'app-stack-detail-$stackId', subscriptionPrefix: 'app-stack-detail-$stackId',
), ),
); );
@@ -65,7 +64,7 @@ class AppStackScreen extends HookConsumerWidget {
} }
/// Intermediate widget that loads apps with their release relationships /// Intermediate widget that loads apps with their release relationships
class _AppStackContentWithApps extends HookConsumerWidget { class _AppStackContentWithApps extends ConsumerWidget {
final AppStack stack; final AppStack stack;
const _AppStackContentWithApps({required this.stack}); const _AppStackContentWithApps({required this.stack});
@@ -73,58 +72,18 @@ class _AppStackContentWithApps extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isEncrypted = stack.content.isNotEmpty; final isEncrypted = stack.content.isNotEmpty;
if (isEncrypted && !stack.isDecrypted) {
// For encrypted stacks, decrypt content to get app IDs
final decryptedAppIds = useState<Set<String>?>(null);
final decryptError = useState<String?>(null);
useEffect(() {
if (!isEncrypted) return null;
Future<void> decrypt() async {
final signer = ref.read(Signer.activeSignerProvider);
final pubkey = ref.read(Signer.activePubkeyProvider);
if (signer == null || pubkey == null) {
decryptError.value = 'Sign in required to view this stack';
return;
}
try {
final decrypted = await signer.nip44Decrypt(stack.content, pubkey);
final ids = (jsonDecode(decrypted) as List).cast<String>().toSet();
decryptedAppIds.value = ids;
} catch (e) {
decryptError.value = 'Failed to decrypt stack';
}
}
decrypt();
return null;
}, [stack.content]);
// Handle decrypt error
if (decryptError.value != null) {
return _AppStackContent( return _AppStackContent(
stack: stack, stack: stack,
apps: const [], apps: const [],
errorMessage: decryptError.value, errorMessage:
); 'This private stack could not be decrypted on this device',
}
// For encrypted stacks, wait for decryption
if (isEncrypted && decryptedAppIds.value == null) {
return Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: _AppStackSkeleton(),
),
); );
} }
// Get app addressable IDs from either tags (public) or decrypted content (private) // Get app addressable IDs from either tags (public) or decrypted content (private)
final appAddressableIds = isEncrypted final appAddressableIds = isEncrypted
? decryptedAppIds.value! ? stack.privateAppIds.toSet()
: stack.event : stack.event
.getTagSetValues('a') .getTagSetValues('a')
.where((id) => id.startsWith('32267:')) .where((id) => id.startsWith('32267:'))
@@ -316,7 +275,8 @@ class _AppStackContent extends HookConsumerWidget {
_EmptyAppsPlaceholder() _EmptyAppsPlaceholder()
else else
...sortedApps.map( ...sortedApps.map(
(app) => AppCard(app: app, showUpdateArrow: app.hasUpdate), (app) =>
AppCard(app: app, showUpdateArrow: app.hasUpdate),
), ),
// Comments section - hidden for private/encrypted stacks // Comments section - hidden for private/encrypted stacks
if (stack.content.isEmpty) if (stack.content.isEmpty)
@@ -374,9 +334,9 @@ class _StackHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final subtitleColor = Theme.of(context).colorScheme.onSurfaceVariant; final subtitleColor = Theme.of(context).colorScheme.onSurfaceVariant;
final subtitleStyle = Theme.of(context).textTheme.bodySmall?.copyWith( final subtitleStyle = Theme.of(
color: subtitleColor, context,
); ).textTheme.bodySmall?.copyWith(color: subtitleColor);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -434,7 +394,9 @@ class _StackHeader extends StatelessWidget {
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'Private', 'Private',
style: subtitleStyle?.copyWith(fontWeight: FontWeight.w600), style: subtitleStyle?.copyWith(
fontWeight: FontWeight.w600,
),
), ),
], ],
), ),
+7 -2
View File
@@ -82,8 +82,10 @@ class StacksNotifier extends PagedSubscriptionNotifier<AppStack> {
final items = await storage final items = await storage
.query( .query(
req, req,
source: source: const LocalAndRemoteSource(
const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), relays: 'AppCatalog',
stream: false,
),
subscriptionPrefix: 'app-stacks-older', subscriptionPrefix: 'app-stacks-older',
) )
.timeout( .timeout(
@@ -158,6 +160,9 @@ class AppStacksScreen extends HookConsumerWidget {
? ref.watch( ? ref.watch(
query<AppStack>( query<AppStack>(
authors: {signedInPubkey}, authors: {signedInPubkey},
tags: {
'#h': {kZapstoreCommunityPubkey},
},
where: (s) => stackNeedsMigration(s, platform), where: (s) => stackNeedsMigration(s, platform),
source: LocalAndRemoteSource( source: LocalAndRemoteSource(
relays: {'social', 'AppCatalog'}, relays: {'social', 'AppCatalog'},
+4 -1
View File
@@ -263,6 +263,9 @@ class _StackMigrationWarning extends ConsumerWidget {
final stacksState = ref.watch( final stacksState = ref.watch(
query<AppStack>( query<AppStack>(
authors: {pubkey}, authors: {pubkey},
tags: {
'#h': {kZapstoreCommunityPubkey},
},
where: (s) => stackNeedsMigration(s, platform), where: (s) => stackNeedsMigration(s, platform),
source: LocalAndRemoteSource(relays: {'AppCatalog'}, stream: false), source: LocalAndRemoteSource(relays: {'AppCatalog'}, stream: false),
subscriptionPrefix: 'app-user-stacks-migration-profile', subscriptionPrefix: 'app-user-stacks-migration-profile',
@@ -1796,7 +1799,7 @@ class _UserStacksSection extends ConsumerWidget {
final stacksState = ref.watch( final stacksState = ref.watch(
query<AppStack>( query<AppStack>(
authors: {devicePubkey}, authors: {devicePubkey},
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), source: const LocalSource(),
subscriptionPrefix: 'app-profile-user-stacks', subscriptionPrefix: 'app-profile-user-stacks',
), ),
); );
+132 -12
View File
@@ -1,15 +1,16 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/services/device_key_service.dart'; import 'package:zapstore/services/device_key_service.dart';
import 'package:zapstore/services/device_private_event_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
/// Reactive set of bookmarked app addressable IDs. final _persistedBookmarksProvider = Provider<AppStack?>((ref) {
///
/// Backed by an encrypted AppStack signed by the device key. Auto-decrypted
/// by EncryptableModel since the device signer is always registered.
final bookmarksProvider = Provider<Set<String>>((ref) {
final devicePubkey = ref.watch(devicePubkeyProvider); final devicePubkey = ref.watch(devicePubkeyProvider);
if (devicePubkey == null) return const {}; if (devicePubkey == null) return null;
final stackState = ref.watch( final stackState = ref.watch(
query<AppStack>( query<AppStack>(
@@ -17,21 +18,140 @@ final bookmarksProvider = Provider<Set<String>>((ref) {
tags: { tags: {
'#d': {kAppBookmarksIdentifier}, '#d': {kAppBookmarksIdentifier},
}, },
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), source: const LocalSource(),
subscriptionPrefix: 'app-user-saved-apps', subscriptionPrefix: 'app-user-saved-apps',
), ),
); );
final stack = switch (stackState) { return switch (stackState) {
StorageLoading() => null, StorageLoading(:final models) => models.firstOrNull,
StorageError() => null, StorageError() => null,
StorageData(:final models) => models.firstOrNull, StorageData(:final models) => models.firstOrNull,
}; };
if (stack == null) return const {};
return stack.privateAppIds.toSet();
}); });
typedef BookmarksWriter =
Future<void> Function(Set<String> appIds, DateTime createdAt);
/// Owns bookmarked IDs and serializes replacement writes.
class BookmarksNotifier extends StateNotifier<Set<String>> {
BookmarksNotifier(this._write, {DateTime Function()? clock})
: _clock = clock ?? DateTime.now,
super(const {});
static const _equality = SetEquality<String>();
final BookmarksWriter _write;
final DateTime Function() _clock;
Future<void> _writeQueue = Future.value();
Set<String> _lastPersisted = const {};
DateTime? _lastPersistedAt;
DateTime? _lastIssuedAt;
int _pendingWrites = 0;
void acceptPersisted(AppStack? stack) {
if (stack == null || !stack.isDecrypted) return;
if (_lastPersistedAt == null ||
!stack.createdAt.isBefore(_lastPersistedAt!)) {
_lastPersisted = Set.unmodifiable(stack.privateAppIds);
_lastPersistedAt = stack.createdAt;
}
if (_pendingWrites > 0 ||
(_lastIssuedAt != null && stack.createdAt.isBefore(_lastIssuedAt!))) {
return;
}
_setState(stack.privateAppIds.toSet());
}
Future<bool> toggle(String appId) {
final updated = {...state};
final removed = updated.remove(appId);
if (!removed) updated.add(appId);
_setState(updated);
final createdAt = _nextCreatedAt();
final snapshot = Set<String>.unmodifiable(updated);
final completer = Completer<bool>();
_pendingWrites++;
_writeQueue = _writeQueue.then((_) async {
try {
await _write(snapshot, createdAt);
completer.complete(!removed);
} catch (error, stackTrace) {
if (error is DevicePrivateSaveException && _pendingWrites == 1) {
_lastIssuedAt = _lastPersistedAt;
_setState(_lastPersisted);
}
completer.completeError(error, stackTrace);
} finally {
_pendingWrites--;
}
});
return completer.future;
}
DateTime _nextCreatedAt() {
final now = _clock();
final candidate = DateTime.fromMillisecondsSinceEpoch(
(now.millisecondsSinceEpoch ~/ 1000) * 1000,
isUtc: now.isUtc,
);
final baseline = switch ((_lastIssuedAt, _lastPersistedAt)) {
(final issued?, final persisted?) =>
issued.isAfter(persisted) ? issued : persisted,
(final issued?, null) => issued,
(null, final persisted?) => persisted,
(null, null) => null,
};
final next = baseline != null && !candidate.isAfter(baseline)
? baseline.add(const Duration(seconds: 1))
: candidate;
_lastIssuedAt = next;
return next;
}
void _setState(Set<String> value) {
if (!_equality.equals(state, value)) {
state = Set.unmodifiable(value);
}
}
}
/// Reactive set of bookmarked app addressable IDs.
final bookmarksProvider = StateNotifierProvider<BookmarksNotifier, Set<String>>(
(ref) {
final notifier = BookmarksNotifier(
(ids, createdAt) => _writeBookmarks(ref, ids, createdAt),
);
ref.listen<AppStack?>(
_persistedBookmarksProvider,
(_, stack) => notifier.acceptPersisted(stack),
fireImmediately: true,
);
return notifier;
},
);
Future<void> _writeBookmarks(
Ref ref,
Set<String> appIds,
DateTime createdAt,
) async {
final platform = ref.read(packageManagerProvider.notifier).platform;
final partial = PartialAppStack.withEncryptedApps(
name: 'Saved Apps',
identifier: kAppBookmarksIdentifier,
apps: appIds.toList(growable: false),
platform: platform,
);
partial.event.createdAt = createdAt;
await ref.read(devicePrivateEventServiceProvider).signAndSave(partial);
}
Future<bool> toggleBookmark(WidgetRef ref, App app) {
final appId = '${app.event.kind}:${app.pubkey}:${app.identifier}';
return ref.read(bookmarksProvider.notifier).toggle(appId);
}
/// Extension to check if an app is saved /// Extension to check if an app is saved
extension SavedAppsChecker on WidgetRef { extension SavedAppsChecker on WidgetRef {
bool isAppSaved(App app) { bool isAppSaved(App app) {
+348 -176
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@@ -8,206 +10,289 @@ import 'package:models/models.dart';
import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/router.dart'; import 'package:zapstore/router.dart';
import 'package:zapstore/services/device_key_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/log_service.dart'; import 'package:zapstore/services/log_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/trusted_signers_service.dart';
import 'package:zapstore/widgets/device_backup_dialog.dart'; import 'package:zapstore/widgets/device_backup_dialog.dart';
const kSettingsIdentifier = 'zapstore-settings'; const _kSettingsFormatVersion = 2;
const _kDeviceBackupsKey = 'deviceBackups'; const _kRecoveriesKey = 'recoveries';
const _kRecoveryAuthorization = 'zapstore-device-key-authorization-v1';
const _kLegacyInstalledAppsBackupIdentifier = 'zapstore-installed-backup'; const _kLegacyInstalledAppsBackupIdentifier = 'zapstore-installed-backup';
const _kLegacyUnmanagedAppsIdentifier = 'zapstore-ignored-apps'; const _kLegacyUnmanagedAppsIdentifier = 'zapstore-ignored-apps';
/// Manages device key backup/restore via the Amber-signed settings event. /// Manages device-signed settings recovery and legacy Amber stack migration.
///
/// The settings event is a single replaceable CustomData (kind 30078) event
/// with d-tag "zapstore-settings". Its content is always NIP-44 encrypted to
/// the Amber key and contains a JSON object. Device key backups live under
/// "deviceBackups": [{"pk": "<hex>", "name": "Pixel 7", "ts": 1715...}, ...].
class DeviceBackupService { class DeviceBackupService {
/// Fetch the existing encrypted settings event for the given Amber pubkey. final Set<Request<Model<dynamic>>> _activeRequests = {};
Future<CustomData?> fetchExistingSettings(Ref ref, String amberPubkey) async { bool _cancelled = false;
final storage = ref.read(storageNotifierProvider.notifier);
final results = await storage.query( void beginWork() => _cancelled = false;
void _checkCancelled() {
if (_cancelled) throw const DeviceBackupCancelled();
}
Future<CustomData?> fetchExistingSettings(
Ref ref,
String devicePubkey,
) async {
final results = await _queryTracked(
ref,
RequestFilter<CustomData>( RequestFilter<CustomData>(
authors: {amberPubkey}, authors: {devicePubkey},
tags: { tags: {
'#d': {kSettingsIdentifier}, '#d': {kSettingsIdentifier},
}, },
limit: 1,
).toRequest(), ).toRequest(),
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-settings-check', subscriptionPrefix: 'app-device-settings-upsert',
); );
return results.firstOrNull; return results.firstOrNull;
} }
/// Decrypt the settings content. Invalid or legacy list content degrades /// Finds device-authored settings events recoverable by [amberSigner].
/// gracefully to an empty object. Future<List<DeviceBackupInfo>> fetchRecoveryCandidates({
Future<Map<String, dynamic>> decryptSettings( required Ref ref,
Signer signer, required Signer amberSigner,
CustomData settings, }) async {
) async { _checkCancelled();
try { final request = RequestFilter<CustomData>(
final decrypted = await signer.nip44Decrypt( tags: {
settings.content, '#d': {kSettingsIdentifier},
signer.pubkey, '#p': {amberSigner.pubkey},
},
limit: 50,
).toRequest();
final settingsEvents = await _queryTracked(
ref,
request,
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-device-recovery',
); );
final decoded = jsonDecode(decrypted);
if (decoded is Map<String, dynamic>) return decoded; final candidates = <String, DeviceBackupInfo>{};
if (decoded is List) { for (final settings in settingsEvents) {
return {_kDeviceBackupsKey: decoded}; _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 (_) { } catch (_) {
return {}; // Invalid or unrelated recovery capsules degrade gracefully.
} }
return {}; }
}
return candidates.values.toList(growable: false);
} }
/// Decode device backup entries from an encrypted settings event. /// Upserts a recovery capsule for the active Amber identity.
Future<List<Map<String, dynamic>>> decryptEntries(
Signer signer,
CustomData settings,
) async {
final decodedSettings = await decryptSettings(signer, settings);
final entries = decodedSettings[_kDeviceBackupsKey];
if (entries is! List) return [];
return entries.whereType<Map<String, dynamic>>().toList();
}
/// Back up the current device key. Reads existing entries, upserts the
/// current device, then publishes the updated array.
Future<void> backupDeviceKey({ Future<void> backupDeviceKey({
required Ref ref, required Ref ref,
required Signer amberSigner, required Signer amberSigner,
}) async { }) async {
final deviceKeyService = ref.read(deviceKeyServiceProvider); _checkCancelled();
final privateKeyHex = await deviceKeyService.getOrCreatePrivateKey(); 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 deviceName = await _getDeviceName();
final timestamp = DateTime.now().millisecondsSinceEpoch;
// Load and preserve existing encrypted settings fields. final authorizationPartial = PartialNote(_kRecoveryAuthorization);
final existing = await fetchExistingSettings(ref, amberSigner.pubkey); authorizationPartial.event.addTagValue('device', devicePubkey);
final settings = existing != null authorizationPartial.event.addTagValue('p', amberSigner.pubkey);
? await decryptSettings(amberSigner, existing) final authorization = await authorizationPartial.signWith(amberSigner);
: <String, dynamic>{}; _checkCancelled();
final entries = (settings[_kDeviceBackupsKey] as List?) final capsule = await privateEvents.encryptFor(
?.whereType<Map<String, dynamic>>() jsonEncode({
.toList() ??
<Map<String, dynamic>>[];
// Upsert this device's entry (match by pk)
entries.removeWhere((e) => e['pk'] == privateKeyHex);
entries.add({
'pk': privateKeyHex, 'pk': privateKeyHex,
'name': deviceName, 'name': deviceName,
'ts': DateTime.now().millisecondsSinceEpoch, 'ts': timestamp,
}); 'authorization': authorization.event.toMap(),
settings[_kDeviceBackupsKey] = entries; }),
final encrypted = await amberSigner.nip44Encrypt(
jsonEncode(settings),
amberSigner.pubkey, 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( final partial = PartialCustomData(
identifier: kSettingsIdentifier, identifier: kSettingsIdentifier,
content: encrypted, content: jsonEncode(envelope),
); );
for (final recovery in recoveries) {
final signed = await partial.signWith(amberSigner); final pubkey = recovery['p'];
final storage = ref.read(storageNotifierProvider.notifier); if (pubkey is String) partial.event.addTagValue('p', pubkey);
await storage.save({signed}); }
storage.publish({signed}, relays: 'AppCatalog'); partial.event.setTagValue('format', 'zapstore-device-settings-v2');
partial.event.createdAt = privateEvents.nextReplaceableTimestamp(
existing?.createdAt,
);
await privateEvents.signAndSave(partial);
} }
/// Move Amber-authored encrypted AppStacks to equivalent device-key stacks. /// Merges every Amber-authored encrypted AppStack into device ownership.
///
/// Existing device-key stacks are merged, not overwritten. Legacy identifiers
/// from the pre-device-key branch are normalized to the current d-tags.
Future<bool> migratePrivateStacksToDeviceKey({ Future<bool> migratePrivateStacksToDeviceKey({
required Ref ref, required Ref ref,
required Signer amberSigner, required Signer amberSigner,
}) async { }) async {
final keyService = ref.read(deviceKeyServiceProvider); _checkCancelled();
final devicePubkey = ref.read(devicePubkeyProvider); final request = RequestFilter<AppStack>(
if (devicePubkey == null) return false; authors: {amberSigner.pubkey},
).toRequest();
if (await keyService.hasPrivateStacksMigrated( final amberStacks = await _queryTracked(
amberSigner.pubkey, ref,
devicePubkey, request,
)) {
return true;
}
final deviceSigner = ref.read(Signer.signerProvider(devicePubkey));
if (deviceSigner == null) return false;
final storage = ref.read(storageNotifierProvider.notifier);
final platform = ref.read(packageManagerProvider.notifier).platform;
final amberStacks = await storage.query(
RequestFilter<AppStack>(authors: {amberSigner.pubkey}).toRequest(),
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-private-stack-migration', subscriptionPrefix: 'app-private-stack-migration',
); );
final encryptedAmberStacks = amberStacks final privateEvents = ref.read(devicePrivateEventServiceProvider);
.where((stack) => stack.content.isNotEmpty) final storage = ref.read(storageNotifierProvider.notifier);
.toList(); final platform = ref.read(packageManagerProvider.notifier).platform;
if (encryptedAmberStacks.isEmpty) { var migratedAny = false;
return 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},
);
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;
} }
var migratedAny = false;
for (final stack in encryptedAmberStacks) {
final identifier = _deviceIdentifierFor(stack.identifier); final identifier = _deviceIdentifierFor(stack.identifier);
final oldAppIds = stack.privateAppIds; final existing = (await storage.query(
if (oldAppIds.isEmpty) continue;
final existingDeviceStacks = await storage.query(
RequestFilter<AppStack>( RequestFilter<AppStack>(
authors: {devicePubkey}, authors: {privateEvents.devicePubkey},
tags: { tags: {
'#d': {identifier}, '#d': {identifier},
}, },
limit: 1,
).toRequest(), ).toRequest(),
source: const LocalSource(), source: const LocalSource(),
subscriptionPrefix: 'app-private-stack-migration-device', 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},
); );
final existingAppIds = continue;
existingDeviceStacks.firstOrNull?.privateAppIds ?? const <String>[]; }
final mergedAppIds = [ }
...existingAppIds,
...oldAppIds.where((id) => !existingAppIds.contains(id)),
];
if (mergedAppIds.length == existingAppIds.length) 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( final partial = PartialAppStack.withEncryptedApps(
name: stack.name ?? identifier, name: stack.name ?? identifier,
identifier: identifier, identifier: identifier,
apps: mergedAppIds, description: stack.description,
platform: platform, apps: merged.toList(growable: false),
platform: stack.platform ?? platform,
); );
partial.event.createdAt = privateEvents.nextReplaceableTimestamp(
final signed = await partial.signWith(deviceSigner); existing?.createdAt,
await storage.save({signed}); );
storage.publish({signed}, relays: 'AppCatalog'); await privateEvents.signAndSave(partial);
migratedAny = true; migratedAny = true;
} }
await keyService.markPrivateStacksMigrated(
amberSigner.pubkey,
devicePubkey,
);
if (migratedAny) { if (migratedAny) {
LogService.I.info( LogService.I.info(
'migrated Amber private stacks to device key', 'migrated Amber private stacks to device key',
tag: 'backup', tag: 'backup',
); );
} }
return true; return migratedAny;
} }
/// Replace the stored device key and register the restored signer immediately.
Future<void> restoreDeviceKey({ Future<void> restoreDeviceKey({
required Ref ref, required Ref ref,
required String privateKeyHex, required String privateKeyHex,
@@ -218,21 +303,115 @@ class DeviceBackupService {
ref.read(devicePubkeyProvider.notifier).state = restoredSigner.pubkey; ref.read(devicePubkeyProvider.notifier).state = restoredSigner.pubkey;
} }
Future<List<E>> _queryTracked<E extends Model<dynamic>>(
Ref ref,
Request<E> request, {
required Source source,
required String subscriptionPrefix,
}) async {
_checkCancelled();
_activeRequests.add(request);
try {
final results = await ref
.read(storageNotifierProvider.notifier)
.query(
request,
source: source,
subscriptionPrefix: subscriptionPrefix,
);
_checkCancelled();
return results;
} finally {
_activeRequests.remove(request);
}
}
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 { Future<String> _getDeviceName() async {
try { try {
if (Platform.isAndroid) { if (Platform.isAndroid) {
final info = await DeviceInfoPlugin().androidInfo; return (await DeviceInfoPlugin().androidInfo).model;
return info.model;
} }
} catch (_) {} } catch (_) {}
return 'Android device'; 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>( final deviceBackupServiceProvider = Provider<DeviceBackupService>(
(ref) => DeviceBackupService(), (ref) => DeviceBackupService(),
); );
Map<String, dynamic> _emptySettingsEnvelope() => {
'version': _kSettingsFormatVersion,
_kRecoveriesKey: <Map<String, dynamic>>[],
};
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;
}
}
/// 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) { String _deviceIdentifierFor(String identifier) {
return switch (identifier) { return switch (identifier) {
_kLegacyInstalledAppsBackupIdentifier => kInstalledAppsIdentifier, _kLegacyInstalledAppsBackupIdentifier => kInstalledAppsIdentifier,
@@ -241,7 +420,6 @@ String _deviceIdentifierFor(String identifier) {
}; };
} }
/// Decoded device entry for display in the restore dialog.
class DeviceBackupInfo { class DeviceBackupInfo {
DeviceBackupInfo({ DeviceBackupInfo({
required this.privateKeyHex, required this.privateKeyHex,
@@ -254,80 +432,74 @@ class DeviceBackupInfo {
final DateTime? backedUpAt; final DateTime? backedUpAt;
} }
/// Called after successful Amber sign-in. Automatically backs up the device final class DeviceBackupCancelled implements Exception {
/// key, then shows a restore dialog only if other device entries exist. const DeviceBackupCancelled();
}
/// Runs recovery, migration, and backup after every successful Amber sign-in.
Future<void> maybeOfferDeviceBackup(Ref ref) async { Future<void> maybeOfferDeviceBackup(Ref ref) async {
final amberPubkey = ref.read(Signer.activePubkeyProvider); final amberPubkey = ref.read(Signer.activePubkeyProvider);
if (amberPubkey == null) return; final amberSigner = ref.read(Signer.activeSignerProvider);
if (amberPubkey == null || amberSigner == null) return;
final keyService = ref.read(deviceKeyServiceProvider); final keyService = ref.read(deviceKeyServiceProvider);
final service = ref.read(deviceBackupServiceProvider); final service = ref.read(deviceBackupServiceProvider);
final signer = ref.read(Signer.activeSignerProvider);
if (signer == null) return;
try { try {
final hasBackupBeenOffered = await keyService.hasBackupBeenOffered( final alreadyOffered = await keyService.hasBackupBeenOffered(amberPubkey);
amberPubkey, List<DeviceBackupInfo> candidates = const [];
try {
candidates = await service.fetchRecoveryCandidates(
ref: ref,
amberSigner: amberSigner,
);
} catch (error, stack) {
LogService.I.warn(
'device recovery query failed',
tag: 'backup',
err: error,
stack: stack,
); );
final backup = hasBackupBeenOffered
? null
: await service.fetchExistingSettings(ref, amberPubkey);
final entries = backup != null
? await service.decryptEntries(signer, backup)
: const <Map<String, dynamic>>[];
final currentPrivateKey = await keyService.getOrCreatePrivateKey();
final otherDevices = entries
.where((e) => e['pk'] != currentPrivateKey)
.map(
(e) => DeviceBackupInfo(
privateKeyHex: e['pk'] as String,
deviceName: e['name'] as String? ?? 'Unknown device',
backedUpAt: e['ts'] != null
? DateTime.fromMillisecondsSinceEpoch(e['ts'] as int)
: null,
),
)
.toList();
if (otherDevices.isNotEmpty) {
final context = rootNavigatorKey.currentState?.overlay?.context;
if (context == null || !context.mounted) {
return;
} }
final selectedBackup = await showDialog<DeviceBackupInfo>( 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, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (_) => DeviceBackupRestoreDialog(backups: otherDevices), builder: (_) => DeviceBackupRestoreDialog(backups: otherDevices),
); );
if (selected != null) {
if (selectedBackup != null) {
await service.restoreDeviceKey( await service.restoreDeviceKey(
ref: ref, ref: ref,
privateKeyHex: selectedBackup.privateKeyHex, privateKeyHex: selected.privateKeyHex,
); );
await ref.read(devicePrivateSyncProvider.notifier).syncRestoredKey();
}
} }
} }
await service.migratePrivateStacksToDeviceKey( await service.migratePrivateStacksToDeviceKey(
ref: ref, ref: ref,
amberSigner: signer, amberSigner: amberSigner,
); );
await ref.read(trustServiceProvider).migrateLegacyAmberRecord(amberSigner);
if (hasBackupBeenOffered) return; await service.backupDeviceKey(ref: ref, amberSigner: amberSigner);
if (!alreadyOffered) {
// Always back up whichever device key is active after optional restore.
await service.backupDeviceKey(ref: ref, amberSigner: signer);
await keyService.markBackupOffered(amberPubkey); await keyService.markBackupOffered(amberPubkey);
} catch (e, st) { }
} catch (error, stack) {
LogService.I.warn( LogService.I.warn(
'device backup/restore check failed', 'device recovery/migration/backup failed',
tag: 'backup', tag: 'backup',
err: e, err: error,
stack: st, stack: stack,
); );
} }
} }
@@ -0,0 +1,206 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:models/models.dart';
import 'package:purplebase/purplebase.dart';
import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/services/device_key_service.dart';
const kPrivateEventPowDifficulty = 16;
final privateEventPowExecutorProvider = Provider<IsolateProofOfWorkExecutor>((
ref,
) {
final executor = IsolateProofOfWorkExecutor();
ref.onDispose(executor.dispose);
return executor;
});
final devicePrivateEventServiceProvider = Provider<DevicePrivateEventService>((
ref,
) {
return DevicePrivateEventService(
ref,
executor: ref.watch(privateEventPowExecutorProvider),
);
});
/// Enforces the signing and publication contract for device-private events.
class DevicePrivateEventService {
DevicePrivateEventService(
this.ref, {
required ProofOfWorkExecutor executor,
this.difficulty = kPrivateEventPowDifficulty,
this.timeout = const Duration(seconds: 10),
this.maxAttempts = 1 << 21,
this.batchSize = 512,
}) : _executor = executor;
final Ref ref;
final ProofOfWorkExecutor _executor;
final int difficulty;
final Duration timeout;
final int maxAttempts;
final int batchSize;
String get devicePubkey {
final pubkey = ref.read(devicePubkeyProvider);
if (pubkey == null) {
throw const DevicePrivateEventException('Device key is unavailable.');
}
return pubkey;
}
Signer get deviceSigner {
final signer = ref.read(Signer.signerProvider(devicePubkey));
if (signer == null) {
throw const DevicePrivateEventException('Device signer is unavailable.');
}
return signer;
}
ProofOfWorkOptions get proofOfWork => ProofOfWorkOptions(
difficulty: difficulty,
timeout: timeout,
maxAttempts: maxAttempts,
batchSize: batchSize,
executor: _executor,
);
Future<String> encryptToDevice(String plaintext) =>
deviceSigner.nip44Encrypt(plaintext, devicePubkey);
Future<String> decryptFromDevice(String ciphertext) =>
deviceSigner.nip44Decrypt(ciphertext, devicePubkey);
Future<String> encryptFor(String plaintext, String recipientPubkey) =>
deviceSigner.nip44Encrypt(plaintext, recipientPubkey);
Future<E> signAndSave<E extends Model<dynamic>>(
PartialModel<E> partial, {
bool publish = true,
}) async {
_validatePartial(partial);
final signer = deviceSigner;
final signed = await partial.signWith(signer, proofOfWork: proofOfWork);
_validateSigned(signed);
final storage = ref.read(storageNotifierProvider.notifier);
final saved = await storage.save({signed});
if (!saved) {
throw const DevicePrivateSaveException(
'Could not save private data locally.',
);
}
if (publish) {
final response = await storage.publish({signed}, relays: 'AppCatalog');
final accepted =
response.results[signed.event.id]?.any((result) => result.accepted) ??
false;
if (!accepted) {
throw const DevicePrivatePublishException(
'Saved locally, but no AppCatalog relay accepted the event.',
);
}
}
return signed;
}
DateTime nextReplaceableTimestamp(
DateTime? existing, {
DateTime Function()? clock,
}) {
final now = (clock ?? DateTime.now)();
final candidate = DateTime.fromMillisecondsSinceEpoch(
(now.millisecondsSinceEpoch ~/ 1000) * 1000,
isUtc: now.isUtc,
);
if (existing == null || candidate.isAfter(existing)) return candidate;
return existing.add(const Duration(seconds: 1));
}
void cancelMining() {
final executor = _executor;
if (executor is IsolateProofOfWorkExecutor) {
executor.cancelAll();
}
}
void _validatePartial(PartialModel<dynamic> partial) {
final event = partial.event;
switch (event.kind) {
case 30267:
if (event.content.isEmpty) {
throw const DevicePrivateEventException(
'Public app stacks must not use the private event signer.',
);
}
if (event.containsTag('h')) {
throw const DevicePrivateEventException(
'Encrypted app stacks must not carry a community h tag.',
);
}
case 30078:
final identifier = event.identifier;
if (identifier != kSettingsIdentifier &&
identifier != kTrustedSignersIdentifier) {
throw DevicePrivateEventException(
'Unsupported private CustomData identifier: $identifier',
);
}
default:
throw DevicePrivateEventException(
'Unsupported private event kind: ${event.kind}',
);
}
}
void _validateSigned(Model<dynamic> signed) {
if (signed.pubkey != devicePubkey) {
throw const DevicePrivateEventException(
'Private event was not signed by the device key.',
);
}
if (!verifySignedEvent(ref, signed.event)) {
throw const DevicePrivateEventException(
'Private event signature or event ID is invalid.',
);
}
if (!Nip13.isValid(signed.event, minimumDifficulty: difficulty)) {
throw DevicePrivateEventException(
'Private event does not meet $difficulty-bit proof of work.',
);
}
}
}
/// Verifies both the canonical Nostr ID and its BIP-340 signature.
bool verifySignedEvent(Ref ref, EventBase event) {
try {
final map = event.toMap();
final pubkey = map['pubkey'];
if (pubkey is! String) return false;
final partial = PartialEvent<Model<dynamic>>(map, event.kind)
..tags = [for (final tag in event.tags) List<String>.of(tag)];
return map['id'] == Utils.getEventId(partial, pubkey) &&
ref.read(verifierProvider).verify(map);
} catch (_) {
return false;
}
}
class DevicePrivateEventException implements Exception {
const DevicePrivateEventException(this.message);
final String message;
@override
String toString() => message;
}
final class DevicePrivateSaveException extends DevicePrivateEventException {
const DevicePrivateSaveException(super.message);
}
final class DevicePrivatePublishException extends DevicePrivateEventException {
const DevicePrivatePublishException(super.message);
}
@@ -0,0 +1,201 @@
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/log_service.dart';
enum DevicePrivateSyncPhase { idle, syncing, success, error, cancelled }
class DevicePrivateSyncState {
const DevicePrivateSyncState(this.phase, {this.error});
const DevicePrivateSyncState.idle() : this(DevicePrivateSyncPhase.idle);
final DevicePrivateSyncPhase phase;
final Object? error;
}
typedef DevicePrivateQuery =
Future<List<Model<dynamic>>> Function(
Request<Model<dynamic>> request,
Source source,
String subscriptionPrefix,
);
class DevicePrivateSyncNotifier extends StateNotifier<DevicePrivateSyncState> {
DevicePrivateSyncNotifier(this.ref, {DevicePrivateQuery? query})
: _query = query,
super(const DevicePrivateSyncState.idle());
final Ref ref;
final DevicePrivateQuery? _query;
Request<Model<dynamic>>? _activeRequest;
Future<void>? _runFuture;
bool _started = false;
bool _cancelled = false;
Future<void> start() async {
if (_started) return;
_started = true;
_runFuture = _runSync();
await _runFuture;
}
/// Explicit recovery exception for a device key restored after app boot.
Future<void> syncRestoredKey() async {
cancel();
await _runFuture;
_runFuture = _runSync();
await _runFuture;
}
Future<void> _runSync() async {
_cancelled = false;
state = const DevicePrivateSyncState(DevicePrivateSyncPhase.syncing);
final devicePubkey = ref.read(devicePubkeyProvider);
if (devicePubkey == null) {
state = const DevicePrivateSyncState(
DevicePrivateSyncPhase.error,
error: 'Device key is unavailable.',
);
return;
}
final request = RequestFilter<Model<dynamic>>(
kinds: const {30267, 30078},
authors: {devicePubkey},
).toRequest();
_activeRequest = request;
try {
await _queryStorage(
request,
source: const RemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-device-private-boot',
);
if (_cancelled) return;
await _upgradeLegacyProofs(request);
if (_cancelled) return;
state = const DevicePrivateSyncState(DevicePrivateSyncPhase.success);
} catch (error, stack) {
if (_cancelled) return;
LogService.I.warn(
'device private boot sync failed',
tag: 'private-sync',
err: error,
stack: stack,
);
state = DevicePrivateSyncState(
DevicePrivateSyncPhase.error,
error: error,
);
} finally {
_activeRequest = null;
}
}
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,
required String subscriptionPrefix,
}) {
final query = _query;
if (query != null) {
return query(request, source, subscriptionPrefix);
}
return ref
.read(storageNotifierProvider.notifier)
.query(request, source: source, subscriptionPrefix: subscriptionPrefix);
}
void cancel() {
_cancelled = true;
final request = _activeRequest;
_activeRequest = null;
if (request != null) {
unawaited(ref.read(storageNotifierProvider.notifier).cancel(request));
}
ref.read(devicePrivateEventServiceProvider).cancelMining();
if (state.phase == DevicePrivateSyncPhase.syncing) {
state = const DevicePrivateSyncState(DevicePrivateSyncPhase.cancelled);
}
}
@override
void dispose() {
cancel();
super.dispose();
}
}
final devicePrivateSyncProvider =
StateNotifierProvider<DevicePrivateSyncNotifier, DevicePrivateSyncState>(
(ref) => DevicePrivateSyncNotifier(ref),
);
+46 -22
View File
@@ -2,9 +2,9 @@ import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/constants/app_constants.dart';
/// Identifier used to store trusted signer preferences import 'package:zapstore/services/device_key_service.dart';
const String kTrustedSignersIdentifier = 'trusted-signers'; import 'package:zapstore/services/device_private_event_service.dart';
/// Simple helper service to manage trusted signers persisted via CustomData /// Simple helper service to manage trusted signers persisted via CustomData
class TrustedSignersService { class TrustedSignersService {
@@ -12,45 +12,39 @@ class TrustedSignersService {
final Ref ref; final Ref ref;
/// Returns whether a signer pubkey is trusted by the active user /// Returns whether a signer pubkey is trusted by this device.
Future<bool> isSignerTrusted(String signerPubkey) async { Future<bool> isSignerTrusted(String signerPubkey) async {
final trusted = await _loadTrustedSigners(); final trusted = await _loadTrustedSigners();
return trusted.contains(signerPubkey); return trusted.contains(signerPubkey);
} }
/// Adds a signer pubkey to the trusted list for the active user. /// Adds a signer pubkey to the device-private trusted list.
/// If no signer is available (not signed in), this method is a no-op.
Future<void> addTrustedSigner(String signerPubkey) async { Future<void> addTrustedSigner(String signerPubkey) async {
final signer = ref.read(Signer.activeSignerProvider);
final activePubkey = ref.read(Signer.activePubkeyProvider);
if (signer == null || activePubkey == null) return;
final trusted = await _loadTrustedSigners(); final trusted = await _loadTrustedSigners();
if (trusted.contains(signerPubkey)) return; if (trusted.contains(signerPubkey)) return;
trusted.add(signerPubkey); 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 content = jsonEncode({'trusted': trusted.toList()});
final encrypted = await privateEvents.encryptToDevice(content);
final partial = PartialCustomData( final partial = PartialCustomData(
identifier: kTrustedSignersIdentifier, identifier: kTrustedSignersIdentifier,
content: content, content: encrypted,
); );
await privateEvents.signAndSave(partial, publish: false);
// Sign with the active signer so the data is addressable under the user's pubkey
final model = await partial.signWith(signer);
// Save locally only (do not publish)
await model.save();
} }
Future<Set<String>> _loadTrustedSigners() async { Future<Set<String>> _loadTrustedSigners() async {
final activePubkey = ref.read(Signer.activePubkeyProvider); final devicePubkey = ref.read(devicePubkeyProvider);
if (activePubkey == null) return <String>{}; if (devicePubkey == null) return <String>{};
try { try {
final request = Request<CustomData>([ final request = Request<CustomData>([
RequestFilter<CustomData>( RequestFilter<CustomData>(
authors: {activePubkey}, authors: {devicePubkey},
tags: { tags: {
'#d': {kTrustedSignersIdentifier}, '#d': {kTrustedSignersIdentifier},
}, },
@@ -66,7 +60,10 @@ class TrustedSignersService {
if (models.isEmpty) return <String>{}; if (models.isEmpty) return <String>{};
final model = models.first; final model = models.first;
final map = jsonDecode(model.content) as Map<String, dynamic>; final decrypted = await ref
.read(devicePrivateEventServiceProvider)
.decryptFromDevice(model.content);
final map = jsonDecode(decrypted) as Map<String, dynamic>;
final list = final list =
(map['trusted'] as List?)?.cast<String>() ?? const <String>[]; (map['trusted'] as List?)?.cast<String>() ?? const <String>[];
return list.toSet(); return list.toSet();
@@ -74,6 +71,33 @@ class TrustedSignersService {
return <String>{}; 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.
}
}
} }
final trustServiceProvider = Provider<TrustedSignersService>( final trustServiceProvider = Provider<TrustedSignersService>(
+13 -28
View File
@@ -4,6 +4,7 @@ import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/services/device_private_event_service.dart';
import 'package:zapstore/services/device_key_service.dart'; import 'package:zapstore/services/device_key_service.dart';
import 'package:zapstore/services/log_service.dart'; import 'package:zapstore/services/log_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
@@ -32,14 +33,15 @@ final _persistedUnmanagedAppsProvider = Provider<_UnmanagedAppsSnapshot?>((
tags: { tags: {
'#d': {kUnmanagedAppsIdentifier}, '#d': {kUnmanagedAppsIdentifier},
}, },
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), source: const LocalSource(),
subscriptionPrefix: 'app-unmanaged-apps', subscriptionPrefix: 'app-unmanaged-apps',
), ),
); );
final stack = switch (stackState) { final stack = switch (stackState) {
StorageLoading() || StorageError() => null, StorageLoading(:final models) ||
StorageData(:final models) => models.firstOrNull, StorageData(:final models) => models.firstOrNull,
StorageError() => null,
}; };
if (stack == null) { if (stack == null) {
@@ -196,8 +198,9 @@ class UnmanagedAppsPublishException implements Exception {
String toString() => message; String toString() => message;
} }
bool wasUnmanagedStackAccepted(PublishResponse response, String eventId) { bool wasUnmanagedStackAccepted(PublishResponse response, AppStack stack) {
return response.results[eventId]?.any((result) => result.accepted) ?? false; return response.results[stack.event.id]?.any((result) => result.accepted) ??
false;
} }
PartialAppStack createUnmanagedAppsStack({ PartialAppStack createUnmanagedAppsStack({
@@ -220,17 +223,6 @@ Future<void> _writeUnmanagedApps(
Set<String> appIds, { Set<String> appIds, {
required DateTime createdAt, required DateTime createdAt,
}) async { }) async {
final devicePubkey = ref.read(devicePubkeyProvider);
if (devicePubkey == null) {
throw const UnmanagedAppsSaveException('Device key is unavailable.');
}
final signer = ref.read(Signer.signerProvider(devicePubkey));
if (signer == null) {
throw const UnmanagedAppsSaveException('Device signer is unavailable.');
}
final storage = ref.read(storageNotifierProvider.notifier);
final platform = ref.read(packageManagerProvider.notifier).platform; final platform = ref.read(packageManagerProvider.notifier).platform;
final partial = createUnmanagedAppsStack( final partial = createUnmanagedAppsStack(
appIds: appIds, appIds: appIds,
@@ -238,19 +230,12 @@ Future<void> _writeUnmanagedApps(
createdAt: createdAt, createdAt: createdAt,
); );
final signed = await partial.signWith(signer); try {
final saved = await storage.save({signed}); await ref.read(devicePrivateEventServiceProvider).signAndSave(partial);
if (!saved) { } on DevicePrivateSaveException catch (error) {
throw const UnmanagedAppsSaveException( throw UnmanagedAppsSaveException(error.message);
'Could not save the unmanaged apps list locally.', } on DevicePrivatePublishException catch (error) {
); throw UnmanagedAppsPublishException(error.message);
}
final response = await storage.publish({signed}, relays: 'AppCatalog');
if (!wasUnmanagedStackAccepted(response, signed.id)) {
throw const UnmanagedAppsPublishException(
'Saved locally, but no AppCatalog relay accepted the event.',
);
} }
} }
+8 -8
View File
@@ -8,6 +8,7 @@ import 'package:zapstore/services/catalog_fetcher.dart';
import 'package:zapstore/services/log_service.dart'; import 'package:zapstore/services/log_service.dart';
import 'package:zapstore/services/deletion_processor.dart'; import 'package:zapstore/services/deletion_processor.dart';
import 'package:zapstore/services/device_key_service.dart'; import 'package:zapstore/services/device_key_service.dart';
import 'package:zapstore/services/device_private_event_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/unmanaged_apps_service.dart'; import 'package:zapstore/services/unmanaged_apps_service.dart';
import 'package:zapstore/services/settings_service.dart'; import 'package:zapstore/services/settings_service.dart';
@@ -289,9 +290,6 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
final settings = await ref.read(settingsServiceProvider).load(); final settings = await ref.read(settingsServiceProvider).load();
if (!settings.installedAppsBackupEnabled) return; if (!settings.installedAppsBackupEnabled) return;
final signer = ref.read(Signer.signerProvider(devicePubkey));
if (signer == null) return;
final pmNotifier = ref.read(packageManagerProvider.notifier); final pmNotifier = ref.read(packageManagerProvider.notifier);
final installed = ref.read(packageManagerProvider).installed; final installed = ref.read(packageManagerProvider).installed;
final platform = pmNotifier.platform; final platform = pmNotifier.platform;
@@ -324,9 +322,9 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
platform: platform, platform: platform,
); );
final signed = await partialStack.signWith(signer); await ref
await storage.save({signed}); .read(devicePrivateEventServiceProvider)
await storage.publish({signed}, relays: 'AppCatalog'); .signAndSave(partialStack);
_lastBackedUpIds = appIds; _lastBackedUpIds = appIds;
} catch (e, st) { } catch (e, st) {
LogService.I.warn( LogService.I.warn(
@@ -387,9 +385,11 @@ final categorizedUpdatesProvider = Provider<CategorizedUpdates>((ref) {
final unmanagedIds = ref.watch(unmanagedAppsProvider); final unmanagedIds = ref.watch(unmanagedAppsProvider);
final catalogedIds = pollerState.catalogedIds; final catalogedIds = pollerState.catalogedIds;
// Keep the local App query independent of the unmanaged set. Otherwise a
// toggle changes its filter, briefly replaces the cached result with a
// loading query, and makes the updates list visibly jump.
final catalogedInstalledIds = installed.keys final catalogedInstalledIds = installed.keys
.where(catalogedIds.contains) .where(catalogedIds.contains)
.where((id) => !unmanagedIds.contains(id))
.toSet(); .toSet();
if (catalogedInstalledIds.isEmpty) { if (catalogedInstalledIds.isEmpty) {
@@ -457,7 +457,7 @@ final categorizedUpdatesProvider = Provider<CategorizedUpdates>((ref) {
for (final app in apps) { for (final app in apps) {
final pkg = installed[app.identifier]; final pkg = installed[app.identifier];
if (pkg == null) continue; if (pkg == null || unmanagedIds.contains(app.identifier)) continue;
if (app.hasUpdate) { if (app.hasUpdate) {
if (pkg.canInstallSilently) { if (pkg.canInstallSilently) {
+6 -56
View File
@@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:zapstore/services/bookmarks_service.dart'; import 'package:zapstore/services/bookmarks_service.dart';
import 'package:zapstore/services/device_key_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/theme.dart'; import 'package:zapstore/theme.dart';
@@ -117,12 +116,8 @@ class SocialActionsRow extends HookConsumerWidget {
Expanded( Expanded(
flex: 16, flex: 16,
child: FilledButton( child: FilledButton(
onPressed: () => _handleSaveApp( onPressed: () =>
context, _handleSaveApp(context, ref, app, isPrivatelySaved),
ref,
app,
isPrivatelySaved,
),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
backgroundColor: isPrivatelySaved backgroundColor: isPrivatelySaved
@@ -142,9 +137,7 @@ class SocialActionsRow extends HookConsumerWidget {
), ),
), ),
child: Icon( child: Icon(
isPrivatelySaved isPrivatelySaved ? Icons.bookmark : Icons.bookmark_border,
? Icons.bookmark
: Icons.bookmark_border,
size: 20, size: 20,
), ),
), ),
@@ -178,6 +171,7 @@ class SocialActionsRow extends HookConsumerWidget {
], ],
); );
} }
Future<void> _handleSaveApp( Future<void> _handleSaveApp(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
@@ -185,53 +179,11 @@ class SocialActionsRow extends HookConsumerWidget {
bool isPrivatelySaved, bool isPrivatelySaved,
) async { ) async {
try { try {
final devicePubkey = ref.read(devicePubkeyProvider); final added = await toggleBookmark(ref, app);
if (devicePubkey == null) return;
final signer = ref.read(Signer.signerProvider(devicePubkey));
if (signer == null) return;
final existingStacks = await ref.storage.query(
RequestFilter<AppStack>(
authors: {devicePubkey},
tags: {
'#d': {kAppBookmarksIdentifier},
},
).toRequest(),
source: const LocalSource(),
);
final existingStack = existingStacks.firstOrNull;
final existingAppIds = List<String>.from(
existingStack?.privateAppIds ?? [],
);
final appAddressableId =
'${app.event.kind}:${app.pubkey}:${app.identifier}';
if (isPrivatelySaved) {
existingAppIds.remove(appAddressableId);
} else {
if (!existingAppIds.contains(appAddressableId)) {
existingAppIds.add(appAddressableId);
}
}
final platform = ref.read(packageManagerProvider.notifier).platform;
final partialStack = PartialAppStack.withEncryptedApps(
name: 'Saved Apps',
identifier: kAppBookmarksIdentifier,
apps: existingAppIds,
platform: platform,
);
final signedStack = await partialStack.signWith(signer);
await ref.storage.save({signedStack});
ref.storage.publish({signedStack}, relays: 'AppCatalog');
if (context.mounted) { if (context.mounted) {
context.showInfo( context.showInfo(
isPrivatelySaved ? 'App removed from saved' : 'App saved privately', added ? 'App saved privately' : 'App removed from saved',
); );
} }
} catch (e) { } catch (e) {
@@ -241,8 +193,6 @@ class SocialActionsRow extends HookConsumerWidget {
} }
} }
Future<void> _showAddToStackDialog(BuildContext context, App app) async { Future<void> _showAddToStackDialog(BuildContext context, App app) async {
await showBaseDialog( await showBaseDialog(
context: context, context: context,
-2
View File
@@ -6,7 +6,6 @@ import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/main.dart'; import 'package:zapstore/main.dart';
import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/debug_utils.dart';
/// Reusable sign-in prompt widget for dialogs and bottom sheets. /// Reusable sign-in prompt widget for dialogs and bottom sheets.
/// Displays a styled tappable message prompting the user to sign in. /// Displays a styled tappable message prompting the user to sign in.
@@ -40,7 +39,6 @@ class SignInPrompt extends HookConsumerWidget {
isLoading.value = true; isLoading.value = true;
try { try {
await ref.read(amberSignerProvider).signIn(); await ref.read(amberSignerProvider).signIn();
onSignInSuccess(ref.read(refProvider));
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
context.showError( context.showError(
+3 -45
View File
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/services/device_key_service.dart'; import 'package:zapstore/services/bookmarks_service.dart';
import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
@@ -83,54 +83,12 @@ class SaveAppDialog extends HookConsumerWidget {
bool isCurrentlySaved, bool isCurrentlySaved,
) async { ) async {
try { try {
final devicePubkey = ref.read(devicePubkeyProvider); final added = await toggleBookmark(ref, app);
if (devicePubkey == null) return;
final signer = ref.read(Signer.signerProvider(devicePubkey));
if (signer == null) return;
final existingStacks = await ref.storage.query(
RequestFilter<AppStack>(
authors: {devicePubkey},
tags: {
'#d': {kAppBookmarksIdentifier},
},
).toRequest(),
source: const LocalSource(),
);
final existingStack = existingStacks.firstOrNull;
final existingAppIds = List<String>.from(
existingStack?.privateAppIds ?? [],
);
final appAddressableId =
'${app.event.kind}:${app.pubkey}:${app.identifier}';
if (isCurrentlySaved) {
existingAppIds.remove(appAddressableId);
} else {
if (!existingAppIds.contains(appAddressableId)) {
existingAppIds.add(appAddressableId);
}
}
final platform = ref.read(packageManagerProvider.notifier).platform;
final partialStack = PartialAppStack.withEncryptedApps(
name: 'Saved Apps',
identifier: kAppBookmarksIdentifier,
apps: existingAppIds,
platform: platform,
);
final signedStack = await partialStack.signWith(signer);
await ref.storage.save({signedStack});
ref.storage.publish({signedStack}, relays: 'AppCatalog');
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); Navigator.pop(context);
context.showInfo( context.showInfo(
isCurrentlySaved ? 'App removed from saved' : 'App saved privately', added ? 'App saved privately' : 'App removed from saved',
); );
} }
} catch (e) { } catch (e) {
+20 -41
View File
@@ -1,7 +1,4 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
@@ -9,12 +6,8 @@ import 'package:zapstore/utils/nostr_route.dart';
/// Shared stack link card used in profile and user screens. /// Shared stack link card used in profile and user screens.
/// Shows stack name, app count badge, and padlock icon for private stacks. /// Shows stack name, app count badge, and padlock icon for private stacks.
class StackLinkCard extends HookConsumerWidget { class StackLinkCard extends ConsumerWidget {
const StackLinkCard({ const StackLinkCard({super.key, required this.stack, this.displayName});
super.key,
required this.stack,
this.displayName,
});
final AppStack stack; final AppStack stack;
final String? displayName; final String? displayName;
@@ -23,35 +16,12 @@ class StackLinkCard extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final appCount = useState<int?>(null); final appCount = _isEncrypted
? (stack.isDecrypted ? stack.privateAppIds.length : null)
useEffect(() { : stack.event
if (_isEncrypted) {
Future<void> decrypt() async {
final signer = ref.read(Signer.activeSignerProvider);
final pubkey = ref.read(Signer.activePubkeyProvider);
if (signer == null || pubkey == null) return;
try {
final decrypted = await signer.nip44Decrypt(stack.content, pubkey);
final ids = (jsonDecode(decrypted) as List).cast<String>();
appCount.value = ids.length;
} catch (_) {
// Decryption failed, leave count as null
}
}
decrypt();
} else {
// Public stack: count 'a' tags
final count = stack.event
.getTagSetValues('a') .getTagSetValues('a')
.where((id) => id.startsWith('32267:')) .where((id) => id.startsWith('32267:'))
.length; .length;
appCount.value = count;
}
return null;
}, [stack.content, stack.id]);
final title = displayName ?? stack.name ?? stack.identifier; final title = displayName ?? stack.name ?? stack.identifier;
@@ -72,7 +42,9 @@ class StackLinkCard extends HookConsumerWidget {
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.2),
), ),
), ),
child: Row( child: Row(
@@ -98,18 +70,25 @@ class StackLinkCard extends HookConsumerWidget {
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
], ],
if (appCount.value != null && appCount.value! > 0) ...[ if (appCount != null && appCount > 0) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh, color: Theme.of(
context,
).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Text( child: Text(
'${appCount.value}', '$appCount',
style: context.textTheme.labelSmall?.copyWith( style: context.textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
+2 -45
View File
@@ -182,53 +182,10 @@ class FloatingOverflowMenu extends HookConsumerWidget {
if (a == null) return; if (a == null) return;
try { try {
final devicePubkey = ref.read(devicePubkeyProvider); final added = await toggleBookmark(ref, a);
if (devicePubkey == null) return;
final signer = ref.read(Signer.signerProvider(devicePubkey));
if (signer == null) return;
final existingStacks = await ref.storage.query(
RequestFilter<AppStack>(
authors: {devicePubkey},
tags: {
'#d': {kAppBookmarksIdentifier},
},
).toRequest(),
source: const LocalSource(),
);
final existingStack = existingStacks.firstOrNull;
final existingAppIds = List<String>.from(
existingStack?.privateAppIds ?? [],
);
final appAddressableId = '${a.event.kind}:${a.pubkey}:${a.identifier}';
if (isCurrentlySaved) {
existingAppIds.remove(appAddressableId);
} else {
if (!existingAppIds.contains(appAddressableId)) {
existingAppIds.add(appAddressableId);
}
}
final platform = ref.read(packageManagerProvider.notifier).platform;
final partialStack = PartialAppStack.withEncryptedApps(
name: 'Saved Apps',
identifier: kAppBookmarksIdentifier,
apps: existingAppIds,
platform: platform,
);
final signedStack = await partialStack.signWith(signer);
await ref.storage.save({signedStack});
ref.storage.publish({signedStack}, relays: 'AppCatalog');
if (context.mounted) { if (context.mounted) {
context.showInfo( context.showInfo(added ? 'App saved' : 'App removed from saved');
isCurrentlySaved ? 'App removed from saved' : 'App saved',
);
} }
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
-2
View File
@@ -6,7 +6,6 @@ import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/main.dart'; import 'package:zapstore/main.dart';
import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/debug_utils.dart';
class SignInButton extends ConsumerWidget { class SignInButton extends ConsumerWidget {
const SignInButton({ const SignInButton({
@@ -40,7 +39,6 @@ class SignInButton extends ConsumerWidget {
} else { } else {
try { try {
await ref.read(amberSignerProvider).signIn(); await ref.read(amberSignerProvider).signIn();
onSignInSuccess(ref.read(refProvider));
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
context.showError('Sign-in failed', technicalDetails: '$e'); context.showError('Sign-in failed', technicalDetails: '$e');
@@ -7,7 +7,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.7.3' classpath 'com.android.tools.build:gradle:8.9.1'
} }
} }
+2 -2
View File
@@ -94,10 +94,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: background_downloader name: background_downloader
sha256: "2d17c211eb9bf1f947f154fb85d1bea96944fb1efb1b0714851caac993c02dc8" sha256: d27fcaf94f738032f1e6db098230799bf31ae7bf184f173fc9a752fcdae75c0e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.4.3" version: "9.5.6"
bech32: bech32:
dependency: transitive dependency: transitive
description: description:
+1 -1
View File
@@ -50,7 +50,7 @@ dependencies:
collection: ^1.19.1 collection: ^1.19.1
qr_flutter: ^4.1.0 qr_flutter: ^4.1.0
share_plus: ^12.0.0 share_plus: ^12.0.0
background_downloader: ^9.2.6 background_downloader: ^9.5.2
connectivity_plus: ^7.0.0 connectivity_plus: ^7.0.0
app_links: ^6.4.1 app_links: ^6.4.1
flutter_secure_storage: ^9.2.4 # DO NOT UPDATE TO BROKEN ^10.0.0 flutter_secure_storage: ^9.2.4 # DO NOT UPDATE TO BROKEN ^10.0.0
+42 -13
View File
@@ -4,13 +4,14 @@
Decouple private data (bookmarks, unmanaged apps, installed backup, settings) from Decouple private data (bookmarks, unmanaged apps, installed backup, settings) from
Amber sign-in by generating a local device key (nsec) that owns all private Amber sign-in by generating a local device key (nsec) that owns all private
encrypted events. Amber becomes purely the identity layer for public actions encrypted events. Every private kind 30267 and 30078 event is signed by the
(sharing stacks, zaps, web of trust). 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.
## Non-Goals ## Non-Goals
- Multi-device sync without Amber (backup/restore requires one Amber sign-in) - Continuous or multi-device live sync
- Migrating existing Amber-signed private stacks (users start fresh or restore) - Migrating legacy Amber-signed `zapstore-settings` events
- Changing how public stacks work (still Amber-signed with h tag) - Changing how public stacks work (still Amber-signed with h tag)
- Implementing the publish queue (handled in purplebase) - Implementing the publish queue (handled in purplebase)
@@ -18,11 +19,15 @@ encrypted events. Amber becomes purely the identity layer for public actions
- On first launch, a device key is silently generated and stored in secure storage - On first launch, a device key is silently generated and stored in secure storage
- Bookmarks, unmanaged apps, and settings work immediately without sign-in - 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 - Profile screen shows device key section with ability to copy nsec
- On first Amber sign-in, a dialog offers to: - On every Amber sign-in, the app performs one-shot recovery and legacy queries:
- Back up this device (stores device nsec inside encrypted `zapstore-settings`) - Restore is offered when device-signed settings events contain a recovery
- Restore from another device (if settings backups exist for this Amber key) 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 - Clearing app data (SQLite) does NOT delete device key or NWC string
- Background sync, migration, and proof-of-work jobs are bounded and cancellable
## Data Model ## Data Model
@@ -30,22 +35,35 @@ encrypted events. Amber becomes purely the identity layer for public actions
- NWC string: secure storage only (existing key) - NWC string: secure storage only (existing key)
- Bookmarks: encrypted AppStack (30267), d=zapstore-bookmarks, signed by device 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 - Unmanaged apps: encrypted AppStack (30267), d=zapstore-unmanaged-apps, signed by device key
- Installed backup: encrypted AppStack (30267), d=zapstore-installed-backup, signed by device key - Installed backup: encrypted AppStack (30267), d=zapstore-installed-apps, signed by device key
- App settings: encrypted CustomData (30078), d=zapstore-settings - App settings: versioned private CustomData (30078), d=zapstore-settings,
- Device backup: entries inside encrypted `zapstore-settings`, signed by Amber key 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
## Signer Roles ## Signer Roles
- Device signer (Bip340PrivateKeySigner): always available, never null. Signs all - Device signer (Bip340PrivateKeySigner): always available, never null. Signs all
private events. Registered on boot, NOT set as active. private events. Registered on boot, NOT set as active.
- Amber signer (AmberSigner): optional. When present, is the active signer. Used - Amber signer (AmberSigner): optional. When present, is the active signer. Used
for public stacks, zaps, WoT queries. 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.
## Filtering Strategy ## Filtering Strategy
- Public stacks: filtered by #h tag (community pubkey) naturally excludes device stacks - Public stacks: filtered by #h tag (community pubkey) naturally excludes device stacks
- Device private stacks: queried by authors: {devicePubkey} + specific #d tag - Device private stacks: queried by authors: {devicePubkey} + specific #d tag
- appStackEventFilter schema filter removed; #h tag filtering is sufficient - 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.
## Edge Cases ## Edge Cases
@@ -54,6 +72,11 @@ encrypted events. Amber becomes purely the identity layer for public actions
- Restore on device that already has data: ask user to confirm (replace or keep current) - 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 - 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 - 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.
## Acceptance Criteria ## Acceptance Criteria
@@ -63,8 +86,14 @@ encrypted events. Amber becomes purely the identity layer for public actions
- [ ] Unmanaged apps work without Amber sign-in - [ ] Unmanaged apps work without Amber sign-in
- [ ] User can copy device nsec from profile screen - [ ] User can copy device nsec from profile screen
- [ ] First Amber sign-in triggers backup/restore dialog - [ ] First Amber sign-in triggers backup/restore dialog
- [ ] Backup encrypts device nsec inside `zapstore-settings` to Amber key - [ ] Every Amber sign-in upserts a recovery capsule in device-signed settings
- [ ] Restore decrypts `zapstore-settings` and imports device nsec - [ ] 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 - [ ] appStackEventFilter removed; queries use #h tag filtering
- [ ] EncryptableModel auto-decrypts device-key stacks (no manual decrypt calls) - [ ] EncryptableModel auto-decrypts device-key stacks (no manual decrypt calls)
+5
View File
@@ -29,6 +29,8 @@ cross-device sync.
- [x] Include the platform tag and require explicit relay acceptance - [x] Include the platform tag and require explicit relay acceptance
- [x] Surface local-save and relay-publish failures to the user - [x] Surface local-save and relay-publish failures to the user
- [x] Cover accumulation, overlapping writes, timestamps, tags, and failures - [x] Cover accumulation, overlapping writes, timestamps, tags, and failures
- [x] Load the decrypted local stack before the background AppCatalog refresh completes
- [x] Use a non-streaming background refresh for the unmanaged-apps query
- [ ] Preserve catalog metadata for cataloged unmanaged apps in the Unmanaged Apps section - [ ] Preserve catalog metadata for cataloged unmanaged apps in the Unmanaged Apps section
- [ ] Extend native package scan with Android installer-source metadata - [ ] Extend native package scan with Android installer-source metadata
- [ ] Default apps installed by known third-party app stores to unmanaged - [ ] Default apps installed by known third-party app stores to unmanaged
@@ -57,6 +59,9 @@ cross-device sync.
map when Android reports no changes. map when Android reports no changes.
- Android package enumeration runs on a lifecycle-owned worker so package, - Android package enumeration runs on a lifecycle-owned worker so package,
signature, and installer-source reads cannot block rendering. signature, and installer-source reads cannot block rendering.
- The unmanaged-apps query uses `LocalAndRemoteSource(stream: false)`: local
SQLite state is emitted immediately, while the one-shot AppCatalog query
refreshes it in the background without leaving a live subscription.
## Implementation Notes ## Implementation Notes
- Android can expose source through `PackageManager.getInstallSourceInfo(packageName)` on API 30+ and `getInstallerPackageName(packageName)` on older APIs. - Android can expose source through `PackageManager.getInstallSourceInfo(packageName)` on API 30+ and `getInstallerPackageName(packageName)` on older APIs.
+82 -7
View File
@@ -1,7 +1,7 @@
# WORK-011 - Device Key (Phase A + B + D) # WORK-011 - Device Key (Phase A + B + D)
**Feature:** FEAT-006-device-key.md **Feature:** FEAT-006-device-key.md
**Status:** In Progress **Status:** Complete
## Tasks ## Tasks
@@ -38,13 +38,44 @@
- Queries Amber-authored encrypted AppStacks after Amber connection - Queries Amber-authored encrypted AppStacks after Amber connection
- Merges them into device-authored encrypted stacks using the device signer - Merges them into device-authored encrypted stacks using the device signer
- Normalizes legacy installed/unmanaged d-tags to current identifiers - Normalizes legacy installed/unmanaged d-tags to current identifiers
- Marks migration complete per Amber pubkey + device pubkey; empty results retry - Rechecks idempotently on every sign-in; failed decrypts remain retryable
- [x] 9. Store device key backups in encrypted settings - [x] 9. Store device key backups in encrypted settings
- Files: `lib/services/device_backup_service.dart` - Files: `lib/services/device_backup_service.dart`
- Uses Amber-signed `CustomData` with `d=zapstore-settings` - Uses device-signed `CustomData` with `d=zapstore-settings`
- Encrypts the full JSON settings object to the Amber key before signing - Stores NIP-44 Amber recovery capsules authorized by Amber signatures
- Stores device backup entries under `deviceBackups` - Ignores legacy Amber-signed settings
- [ ] 10. Self-review against INVARIANTS.md - [x] 10. Self-review against INVARIANTS.md
- [x] 11. Centralize private 30267/30078 signing
- Device signer only, encrypted-stack h-tag rejection, 16-bit NIP-13
- Purplebase worker executor keeps mining off Flutter's main isolate
- [x] 12. Add boot-only private event synchronization
- One cancellable stream=false request for device-authored 30267/30078
- Private consumers use LocalSource only
- [x] 13. Replace Amber-owned settings backup
- Device-signed `zapstore-settings` with Amber recovery capsules and p tags
- Ignore all legacy Amber-signed settings
- [x] 14. Repair Amber migration
- Explicitly decrypt Amber stacks before merge
- Cover bookmarks, installed, unmanaged, and arbitrary encrypted stacks
- Recheck idempotently on every Amber sign-in
- [x] 15. Move trusted-signers CustomData to device ownership
- [x] 16. Consolidate bookmark writes and private stack reads
- [x] 17. Add lifecycle, failure, migration, and PoW tests
## Test Coverage
| Scenario | Expected | Status |
|----------|----------|--------|
| Device private write | Device author, valid PoW, local save | [x] |
| Invalid private policy | Unknown 30078 and encrypted h-tag rejected | [x] |
| PoW cancellation | Worker terminates and tags remain unchanged | [x] |
| Boot sync lifecycle | Single-flight and cancellable | [x] |
| Bookmark write overlap | Serialized monotonic replacements | [x] |
| Bookmark save failure | Optimistic state rolls back | [x] |
| Amber bookmark migration | Imperative result explicitly decrypts | [x] |
| Recovery injection | Amber authorization required | [x] |
| Recovery cancellation | No work restarts after cancellation | [x] |
| Full verification | App, models, and Purplebase suites pass | [x] |
## Decisions ## Decisions
@@ -68,13 +99,53 @@
### 2026-05-07 - Device backups live inside settings ### 2026-05-07 - Device backups live inside settings
**Status:** Superseded by the 2026-07-13 device-settings decision below.
**Context:** Device key backup needs to be tied to the user's encrypted settings event, not a separate replaceable event. **Context:** Device key backup needs to be tied to the user's encrypted settings event, not a separate replaceable event.
**Decision:** Store backup entries inside the Amber-signed `CustomData` event with `d=zapstore-settings`, under the `deviceBackups` JSON key. **Decision:** Store backup entries inside the Amber-signed `CustomData` event with `d=zapstore-settings`, under the `deviceBackups` JSON key.
**Rationale:** Keeps backup state with settings and avoids a standalone `zapstore-device-backup` event. The entire settings JSON object is NIP-44 encrypted before signing. **Rationale:** Keeps backup state with settings and avoids a standalone `zapstore-device-backup` event. The entire settings JSON object is NIP-44 encrypted before signing.
### 2026-07-13 - All private events are device-owned
**Context:** Amber ownership made private state depend on the current identity
and allowed private write paths to drift.
**Decision:** Every private kind 30267/30078 event is device-signed. Public
30267 stacks remain Amber-signed and carry the community h tag.
**Rationale:** The device key is always available and is the stable owner of
per-device state.
### 2026-07-13 - Device settings contain recovery capsules
**Context:** Amber must recover a device nsec without authoring the settings
event or owning device settings.
**Decision:** `zapstore-settings` is signed by the device key. Its versioned
envelope stores device-private data plus NIP-44 capsules addressed to Amber
keys and indexed by p tags. Capsules include an embedded Amber-signed
authorization binding the Amber identity to the device pubkey.
**Rationale:** Settings remain per-device while Amber remains an orthogonal
recovery recipient, while copied or attacker-created capsules cannot inject a
device key that Amber never authorized.
### 2026-07-13 - Boot-only relay ingestion
**Context:** Private state changes originate on the device and live
subscriptions waste relay and lifecycle resources.
**Decision:** Fetch device-authored private events once at boot with
`stream:false`; UI providers are local-only. Amber recovery/migration is the
only sign-in-time one-shot exception.
**Rationale:** Preserves local-first rendering and avoids duplicate or leaked
subscriptions.
### 2026-07-13 - 16-bit off-isolate proof of work
**Context:** Private events need NIP-13 without running CPU-bound mining on
Flutter's main isolate.
**Decision:** Apply 16-bit PoW through a cancellable Purplebase worker executor.
**Rationale:** 16 bits is a practical mobile spam cost; worker execution keeps
rendering responsive.
## Spec Issues ## Spec Issues
- `spec/features/FEAT-006-device-key.md` still lists migration as a non-goal and uses legacy d-tags for installed/unmanaged apps. Implementation now follows the product direction from this work session: migrate private stacks to the device pubkey on Amber connection. _None_
## Progress Notes ## Progress Notes
@@ -83,3 +154,7 @@
**2026-05-07:** Restore ordering hardened: if an Amber backup contains other device keys, the restore/keep-current choice happens before migration and backup. Migration completion is keyed by both Amber pubkey and final device pubkey, and empty migration results are left retryable. **2026-05-07:** Restore ordering hardened: if an Amber backup contains other device keys, the restore/keep-current choice happens before migration and backup. Migration completion is keyed by both Amber pubkey and final device pubkey, and empty migration results are left retryable.
**2026-05-07:** Device key backup moved into the encrypted `zapstore-settings` CustomData event. No `zapstore-device-backup` event is written. **2026-05-07:** Device key backup moved into the encrypted `zapstore-settings` CustomData event. No `zapstore-device-backup` event is written.
**2026-07-13:** FEAT-006 was updated by explicit product authorization. The new
contract supersedes the earlier Amber-signed settings decision and ignores
legacy Amber settings even when that loses old backups.
+61
View File
@@ -0,0 +1,61 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:zapstore/services/bookmarks_service.dart';
import 'package:zapstore/services/device_private_event_service.dart';
void main() {
test('optimistically toggles and serializes replacement writes', () async {
final writes = <Set<String>>[];
final gates = <Completer<void>>[];
final notifier = BookmarksNotifier((ids, _) {
writes.add(ids);
final gate = Completer<void>();
gates.add(gate);
return gate.future;
});
addTearDown(notifier.dispose);
final first = notifier.toggle('app.one');
final second = notifier.toggle('app.two');
expect(notifier.state, {'app.one', 'app.two'});
await Future<void>.value();
expect(writes, [
{'app.one'},
]);
gates.first.complete();
await first;
await Future<void>.value();
expect(writes, [
{'app.one'},
{'app.one', 'app.two'},
]);
gates.last.complete();
await second;
});
test('returns whether the app was added or removed', () async {
final notifier = BookmarksNotifier((_, _) async {});
addTearDown(notifier.dispose);
expect(await notifier.toggle('app.one'), isTrue);
expect(await notifier.toggle('app.one'), isFalse);
expect(notifier.state, isEmpty);
});
test('rolls back optimistic state when the local save fails', () async {
final notifier = BookmarksNotifier((_, _) {
throw const DevicePrivateSaveException('save failed');
});
addTearDown(notifier.dispose);
await expectLater(
notifier.toggle('app.one'),
throwsA(isA<DevicePrivateSaveException>()),
);
expect(notifier.state, isEmpty);
});
}
@@ -0,0 +1,123 @@
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>()),
);
});
}
@@ -0,0 +1,95 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:models/models.dart';
import 'package:purplebase/purplebase.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/utils/debug_utils.dart';
void main() {
late ProviderContainer container;
late Bip340PrivateKeySigner signer;
late IsolateProofOfWorkExecutor executor;
late DevicePrivateEventService service;
setUp(() async {
container = ProviderContainer(
overrides: [
storageNotifierProvider.overrideWith(DummyStorageNotifier.new),
],
);
await container
.read(storageNotifierProvider.notifier)
.initialize(StorageConfiguration());
signer = Bip340PrivateKeySigner('1' * 64, container.read(refProvider));
await signer.signIn(setAsActive: false);
container.read(devicePubkeyProvider.notifier).state = signer.pubkey;
executor = IsolateProofOfWorkExecutor();
service = DevicePrivateEventService(
container.read(refProvider),
executor: executor,
difficulty: 4,
maxAttempts: 100000,
);
});
tearDown(() {
executor.dispose();
container.dispose();
});
test('production policy requires 16 proof-of-work bits', () {
expect(kPrivateEventPowDifficulty, 16);
});
test('device-signs, mines, and saves a private CustomData event', () async {
final ciphertext = await service.encryptToDevice('{"trusted":[]}');
final signed = await service.signAndSave(
PartialCustomData(
identifier: kTrustedSignersIdentifier,
content: ciphertext,
),
publish: false,
);
expect(signed.pubkey, signer.pubkey);
expect(Nip13.isValid(signed.event, minimumDifficulty: 4), isTrue);
final stored = await container
.read(storageNotifierProvider.notifier)
.query(
RequestFilter<CustomData>(
authors: {signer.pubkey},
tags: {
'#d': {kTrustedSignersIdentifier},
},
).toRequest(),
source: const LocalSource(),
);
expect(stored.single.event.id, signed.event.id);
});
test('rejects encrypted stacks carrying a community tag', () async {
final partial = PartialAppStack.withEncryptedApps(
name: 'Saved Apps',
identifier: kAppBookmarksIdentifier,
apps: const ['32267:pubkey:app'],
)..event.addTagValue('h', kZapstoreCommunityPubkey);
await expectLater(
service.signAndSave(partial, publish: false),
throwsA(isA<DevicePrivateEventException>()),
);
});
test('rejects unknown private CustomData identifiers', () async {
await expectLater(
service.signAndSave(
PartialCustomData(identifier: 'unknown', content: 'secret'),
publish: false,
),
throwsA(isA<DevicePrivateEventException>()),
);
});
}
@@ -0,0 +1,47 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:models/models.dart';
import 'package:zapstore/services/device_key_service.dart';
import 'package:zapstore/services/device_private_sync_service.dart';
import 'package:zapstore/utils/debug_utils.dart';
void main() {
test('boot sync is single-flight and cancellable', () async {
final container = ProviderContainer(
overrides: [
storageNotifierProvider.overrideWith(DummyStorageNotifier.new),
],
);
addTearDown(container.dispose);
await container
.read(storageNotifierProvider.notifier)
.initialize(StorageConfiguration());
container.read(devicePubkeyProvider.notifier).state = 'a' * 64;
var calls = 0;
final remote = Completer<List<Model<dynamic>>>();
final notifier = DevicePrivateSyncNotifier(
container.read(refProvider),
query: (request, source, prefix) {
calls++;
return remote.future;
},
);
addTearDown(notifier.dispose);
final first = notifier.start();
await notifier.start();
expect(calls, 1);
expect(notifier.state.phase, DevicePrivateSyncPhase.syncing);
notifier.cancel();
expect(notifier.state.phase, DevicePrivateSyncPhase.cancelled);
remote.complete(const []);
await first;
expect(calls, 1);
expect(notifier.state.phase, DevicePrivateSyncPhase.cancelled);
});
}
+38 -7
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/services/unmanaged_apps_service.dart'; import 'package:zapstore/services/unmanaged_apps_service.dart';
@@ -118,23 +119,53 @@ void main() {
}); });
group('wasUnmanagedStackAccepted', () { group('wasUnmanagedStackAccepted', () {
test('requires explicit acceptance for the stack event', () { test(
'uses the raw event ID for a parameterized replaceable stack',
() async {
final container = ProviderContainer(
overrides: [
storageNotifierProvider.overrideWith(DummyStorageNotifier.new),
],
);
addTearDown(container.dispose);
await container
.read(storageNotifierProvider.notifier)
.initialize(StorageConfiguration());
final stack = createUnmanagedAppsStack(
appIds: {'app.one'},
platform: 'android-arm64-v8a',
createdAt: DateTime.utc(2026, 7, 10, 20),
).dummySign('a' * 64);
expect(stack.id, isNot(stack.event.id));
final accepted = PublishResponse() final accepted = PublishResponse()
..addEvent( ..addEvent(
'stack-id', stack.event.id,
relayUrl: 'wss://relay.zapstore.dev', relayUrl: 'wss://relay.zapstore.dev',
accepted: true, accepted: true,
); );
final rejected = PublishResponse() final rejected = PublishResponse()
..addEvent( ..addEvent(
'stack-id', stack.event.id,
relayUrl: 'wss://relay.zapstore.dev', relayUrl: 'wss://relay.zapstore.dev',
accepted: false, accepted: false,
); );
final addressableIdResponse = PublishResponse()
..addEvent(
stack.id,
relayUrl: 'wss://relay.zapstore.dev',
accepted: true,
);
expect(wasUnmanagedStackAccepted(accepted, 'stack-id'), isTrue); expect(wasUnmanagedStackAccepted(accepted, stack), isTrue);
expect(wasUnmanagedStackAccepted(rejected, 'stack-id'), isFalse); expect(wasUnmanagedStackAccepted(rejected, stack), isFalse);
expect(wasUnmanagedStackAccepted(PublishResponse(), 'stack-id'), isFalse); expect(
}); wasUnmanagedStackAccepted(addressableIdResponse, stack),
isFalse,
);
expect(wasUnmanagedStackAccepted(PublishResponse(), stack), isFalse);
},
);
}); });
} }