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 {
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
}
+5
View File
@@ -28,6 +28,11 @@ const kInstalledAppsIdentifier = 'zapstore-installed-apps';
/// Identifier for the encrypted stack of apps the user chose as unmanaged
const kUnmanagedAppsIdentifier = 'zapstore-unmanaged-apps';
/// Identifier for device-private settings and recovery capsules
const kSettingsIdentifier = 'zapstore-settings';
/// Identifier for the local encrypted trusted-signer preference
const kTrustedSignersIdentifier = 'trusted-signers';
/// 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.
+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/device_backup_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/utils/debug_utils.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/breathing_logo.dart';
@@ -169,6 +171,13 @@ class ZapstoreApp extends HookConsumerWidget {
// Watch initialization state for error overlay display
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
useEffect(() {
final observer = _AppLifecycleObserver(ref);
@@ -402,6 +411,9 @@ final storageReadyProvider = FutureProvider<void>((ref) async {
final appInitializationProvider = FutureProvider<void>((ref) async {
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)
await DeviceCapabilitiesCache.initialize();
@@ -464,7 +476,6 @@ Future<void> _maybeCopySeedDatabase(String dbPath, {bool skip = false}) async {
Future<void> _attemptAutoSignIn(Ref ref) async {
try {
await ref.read(amberSignerProvider).attemptAutoSignIn();
onSignInSuccess(ref);
} catch (e, st) {
// Auto sign-in fails on first install — that's fine, just continue.
// Logged at debug because this is expected for new users.
@@ -486,6 +497,7 @@ Future<void> _attemptAutoSignIn(Ref ref) async {
void onSignInSuccess(Ref ref) {
final pubkey = ref.read(Signer.activePubkeyProvider);
if (pubkey == null) return;
ref.read(deviceBackupServiceProvider).beginWork();
final storage =
ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier;
@@ -562,6 +574,10 @@ class _AppLifecycleObserver with WidgetsBindingObserver {
unawaited(_ref.read(appCatalogRelayServiceProvider).checkForUpdates());
} else if (state == AppLifecycleState.paused) {
_ref.read(appCatalogRelayServiceProvider).cancelCurrentCheck();
_ref.read(devicePrivateSyncProvider.notifier).cancel();
_ref
.read(deviceBackupServiceProvider)
.cancelCurrentWork(_ref.read(refProvider));
notifier.disconnect();
// Flush any pending log entries to disk before the OS may freeze
// 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:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.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/utils/extensions.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/theme.dart';
class AppStackScreen extends HookConsumerWidget {
class AppStackScreen extends ConsumerWidget {
const AppStackScreen({super.key, required this.stackId, this.authorPubkey});
final String stackId;
@@ -25,6 +23,8 @@ class AppStackScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isDeviceOwned =
authorPubkey != null && authorPubkey == ref.watch(devicePubkeyProvider);
final stackState = ref.watch(
query<AppStack>(
authors: authorPubkey != null ? {authorPubkey!} : null,
@@ -32,10 +32,9 @@ class AppStackScreen extends HookConsumerWidget {
'#d': {stackId},
},
limit: 1,
source: const LocalAndRemoteSource(
relays: 'AppCatalog',
stream: false,
),
source: isDeviceOwned
? const LocalSource()
: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-stack-detail-$stackId',
),
);
@@ -65,7 +64,7 @@ class AppStackScreen extends HookConsumerWidget {
}
/// Intermediate widget that loads apps with their release relationships
class _AppStackContentWithApps extends HookConsumerWidget {
class _AppStackContentWithApps extends ConsumerWidget {
final AppStack stack;
const _AppStackContentWithApps({required this.stack});
@@ -73,58 +72,18 @@ class _AppStackContentWithApps extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isEncrypted = stack.content.isNotEmpty;
// 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) {
if (isEncrypted && !stack.isDecrypted) {
return _AppStackContent(
stack: stack,
apps: const [],
errorMessage: decryptError.value,
);
}
// For encrypted stacks, wait for decryption
if (isEncrypted && decryptedAppIds.value == null) {
return Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: _AppStackSkeleton(),
),
errorMessage:
'This private stack could not be decrypted on this device',
);
}
// Get app addressable IDs from either tags (public) or decrypted content (private)
final appAddressableIds = isEncrypted
? decryptedAppIds.value!
? stack.privateAppIds.toSet()
: stack.event
.getTagSetValues('a')
.where((id) => id.startsWith('32267:'))
@@ -316,7 +275,8 @@ class _AppStackContent extends HookConsumerWidget {
_EmptyAppsPlaceholder()
else
...sortedApps.map(
(app) => AppCard(app: app, showUpdateArrow: app.hasUpdate),
(app) =>
AppCard(app: app, showUpdateArrow: app.hasUpdate),
),
// Comments section - hidden for private/encrypted stacks
if (stack.content.isEmpty)
@@ -374,9 +334,9 @@ class _StackHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final subtitleColor = Theme.of(context).colorScheme.onSurfaceVariant;
final subtitleStyle = Theme.of(context).textTheme.bodySmall?.copyWith(
color: subtitleColor,
);
final subtitleStyle = Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: subtitleColor);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -434,7 +394,9 @@ class _StackHeader extends StatelessWidget {
const SizedBox(width: 4),
Text(
'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
.query(
req,
source:
const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
source: const LocalAndRemoteSource(
relays: 'AppCatalog',
stream: false,
),
subscriptionPrefix: 'app-stacks-older',
)
.timeout(
@@ -158,6 +160,9 @@ class AppStacksScreen extends HookConsumerWidget {
? ref.watch(
query<AppStack>(
authors: {signedInPubkey},
tags: {
'#h': {kZapstoreCommunityPubkey},
},
where: (s) => stackNeedsMigration(s, platform),
source: LocalAndRemoteSource(
relays: {'social', 'AppCatalog'},
+4 -1
View File
@@ -263,6 +263,9 @@ class _StackMigrationWarning extends ConsumerWidget {
final stacksState = ref.watch(
query<AppStack>(
authors: {pubkey},
tags: {
'#h': {kZapstoreCommunityPubkey},
},
where: (s) => stackNeedsMigration(s, platform),
source: LocalAndRemoteSource(relays: {'AppCatalog'}, stream: false),
subscriptionPrefix: 'app-user-stacks-migration-profile',
@@ -1796,7 +1799,7 @@ class _UserStacksSection extends ConsumerWidget {
final stacksState = ref.watch(
query<AppStack>(
authors: {devicePubkey},
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
source: const LocalSource(),
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: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/package_manager/package_manager.dart';
/// Reactive set of bookmarked app addressable IDs.
///
/// 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 _persistedBookmarksProvider = Provider<AppStack?>((ref) {
final devicePubkey = ref.watch(devicePubkeyProvider);
if (devicePubkey == null) return const {};
if (devicePubkey == null) return null;
final stackState = ref.watch(
query<AppStack>(
@@ -17,21 +18,140 @@ final bookmarksProvider = Provider<Set<String>>((ref) {
tags: {
'#d': {kAppBookmarksIdentifier},
},
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
source: const LocalSource(),
subscriptionPrefix: 'app-user-saved-apps',
),
);
final stack = switch (stackState) {
StorageLoading() => null,
return switch (stackState) {
StorageLoading(:final models) => models.firstOrNull,
StorageError() => null,
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 SavedAppsChecker on WidgetRef {
bool isAppSaved(App app) {
+348 -176
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
@@ -8,206 +10,289 @@ import 'package:models/models.dart';
import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/router.dart';
import 'package:zapstore/services/device_key_service.dart';
import 'package:zapstore/services/device_private_event_service.dart';
import 'package:zapstore/services/device_private_sync_service.dart';
import 'package:zapstore/services/log_service.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';
const kSettingsIdentifier = 'zapstore-settings';
const _kDeviceBackupsKey = 'deviceBackups';
const _kSettingsFormatVersion = 2;
const _kRecoveriesKey = 'recoveries';
const _kRecoveryAuthorization = 'zapstore-device-key-authorization-v1';
const _kLegacyInstalledAppsBackupIdentifier = 'zapstore-installed-backup';
const _kLegacyUnmanagedAppsIdentifier = 'zapstore-ignored-apps';
/// Manages device key backup/restore via the Amber-signed settings event.
///
/// 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...}, ...].
/// Manages device-signed settings recovery and legacy Amber stack migration.
class DeviceBackupService {
/// Fetch the existing encrypted settings event for the given Amber pubkey.
Future<CustomData?> fetchExistingSettings(Ref ref, String amberPubkey) async {
final storage = ref.read(storageNotifierProvider.notifier);
final results = await storage.query(
final Set<Request<Model<dynamic>>> _activeRequests = {};
bool _cancelled = false;
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>(
authors: {amberPubkey},
authors: {devicePubkey},
tags: {
'#d': {kSettingsIdentifier},
},
limit: 1,
).toRequest(),
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-settings-check',
subscriptionPrefix: 'app-device-settings-upsert',
);
return results.firstOrNull;
}
/// Decrypt the settings content. Invalid or legacy list content degrades
/// gracefully to an empty object.
Future<Map<String, dynamic>> decryptSettings(
Signer signer,
CustomData settings,
) async {
try {
final decrypted = await signer.nip44Decrypt(
settings.content,
signer.pubkey,
/// Finds device-authored settings events recoverable by [amberSigner].
Future<List<DeviceBackupInfo>> fetchRecoveryCandidates({
required Ref ref,
required Signer amberSigner,
}) async {
_checkCancelled();
final request = RequestFilter<CustomData>(
tags: {
'#d': {kSettingsIdentifier},
'#p': {amberSigner.pubkey},
},
limit: 50,
).toRequest();
final settingsEvents = await _queryTracked(
ref,
request,
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-device-recovery',
);
final decoded = jsonDecode(decrypted);
if (decoded is Map<String, dynamic>) return decoded;
if (decoded is List) {
return {_kDeviceBackupsKey: decoded};
final candidates = <String, DeviceBackupInfo>{};
for (final settings in settingsEvents) {
_checkCancelled();
if (!verifySignedEvent(ref, settings.event) ||
!Nip13.isValid(
settings.event,
minimumDifficulty: kPrivateEventPowDifficulty,
)) {
continue;
}
final envelope = _decodeSettingsEnvelope(settings.content);
if (envelope == null) continue;
final recoveries = envelope[_kRecoveriesKey];
if (recoveries is! List) continue;
for (final raw in recoveries.whereType<Map>()) {
final recovery = Map<String, dynamic>.from(raw);
if (recovery['p'] != amberSigner.pubkey) continue;
final ciphertext = recovery['content'];
if (ciphertext is! String || ciphertext.isEmpty) continue;
try {
final plaintext = await amberSigner.nip44Decrypt(
ciphertext,
settings.pubkey,
);
_checkCancelled();
final capsule = Map<String, dynamic>.from(
jsonDecode(plaintext) as Map,
);
final privateKey = capsule['pk'];
final authorization = capsule['authorization'];
if (privateKey is! String ||
privateKey.length != 64 ||
Utils.derivePublicKey(privateKey) != settings.pubkey ||
authorization is! Map ||
!validateRecoveryAuthorization(
ref,
Map<String, dynamic>.from(authorization),
amberPubkey: amberSigner.pubkey,
devicePubkey: settings.pubkey,
)) {
continue;
}
candidates[privateKey] = DeviceBackupInfo(
privateKeyHex: privateKey,
deviceName: capsule['name'] as String? ?? 'Android device',
backedUpAt: capsule['ts'] is int
? DateTime.fromMillisecondsSinceEpoch(capsule['ts'] as int)
: null,
);
} catch (_) {
return {};
// Invalid or unrelated recovery capsules degrade gracefully.
}
return {};
}
}
return candidates.values.toList(growable: false);
}
/// Decode device backup entries from an encrypted settings event.
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.
/// Upserts a recovery capsule for the active Amber identity.
Future<void> backupDeviceKey({
required Ref ref,
required Signer amberSigner,
}) async {
final deviceKeyService = ref.read(deviceKeyServiceProvider);
final privateKeyHex = await deviceKeyService.getOrCreatePrivateKey();
_checkCancelled();
final privateEvents = ref.read(devicePrivateEventServiceProvider);
final keyService = ref.read(deviceKeyServiceProvider);
final privateKeyHex = await keyService.getOrCreatePrivateKey();
final devicePubkey = privateEvents.devicePubkey;
final existing = await fetchExistingSettings(ref, devicePubkey);
final envelope = existing != null
? _decodeSettingsEnvelope(existing.content) ?? _emptySettingsEnvelope()
: _emptySettingsEnvelope();
final deviceName = await _getDeviceName();
// Load and preserve existing encrypted settings fields.
final existing = await fetchExistingSettings(ref, amberSigner.pubkey);
final settings = existing != null
? await decryptSettings(amberSigner, existing)
: <String, dynamic>{};
final entries = (settings[_kDeviceBackupsKey] as List?)
?.whereType<Map<String, dynamic>>()
.toList() ??
<Map<String, dynamic>>[];
// Upsert this device's entry (match by pk)
entries.removeWhere((e) => e['pk'] == privateKeyHex);
entries.add({
final timestamp = DateTime.now().millisecondsSinceEpoch;
final authorizationPartial = PartialNote(_kRecoveryAuthorization);
authorizationPartial.event.addTagValue('device', devicePubkey);
authorizationPartial.event.addTagValue('p', amberSigner.pubkey);
final authorization = await authorizationPartial.signWith(amberSigner);
_checkCancelled();
final capsule = await privateEvents.encryptFor(
jsonEncode({
'pk': privateKeyHex,
'name': deviceName,
'ts': DateTime.now().millisecondsSinceEpoch,
});
settings[_kDeviceBackupsKey] = entries;
final encrypted = await amberSigner.nip44Encrypt(
jsonEncode(settings),
'ts': timestamp,
'authorization': authorization.event.toMap(),
}),
amberSigner.pubkey,
);
_checkCancelled();
final recoveries =
(envelope[_kRecoveriesKey] as List?)
?.whereType<Map>()
.map((entry) => Map<String, dynamic>.from(entry))
.toList() ??
<Map<String, dynamic>>[];
recoveries.removeWhere((entry) => entry['p'] == amberSigner.pubkey);
recoveries.add({
'p': amberSigner.pubkey,
'content': capsule,
'ts': timestamp,
});
envelope[_kRecoveriesKey] = recoveries;
final partial = PartialCustomData(
identifier: kSettingsIdentifier,
content: encrypted,
content: jsonEncode(envelope),
);
final signed = await partial.signWith(amberSigner);
final storage = ref.read(storageNotifierProvider.notifier);
await storage.save({signed});
storage.publish({signed}, relays: 'AppCatalog');
for (final recovery in recoveries) {
final pubkey = recovery['p'];
if (pubkey is String) partial.event.addTagValue('p', pubkey);
}
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.
///
/// Existing device-key stacks are merged, not overwritten. Legacy identifiers
/// from the pre-device-key branch are normalized to the current d-tags.
/// Merges every Amber-authored encrypted AppStack into device ownership.
Future<bool> migratePrivateStacksToDeviceKey({
required Ref ref,
required Signer amberSigner,
}) async {
final keyService = ref.read(deviceKeyServiceProvider);
final devicePubkey = ref.read(devicePubkeyProvider);
if (devicePubkey == null) return false;
if (await keyService.hasPrivateStacksMigrated(
amberSigner.pubkey,
devicePubkey,
)) {
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(),
_checkCancelled();
final request = RequestFilter<AppStack>(
authors: {amberSigner.pubkey},
).toRequest();
final amberStacks = await _queryTracked(
ref,
request,
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-private-stack-migration',
);
final encryptedAmberStacks = amberStacks
.where((stack) => stack.content.isNotEmpty)
.toList();
if (encryptedAmberStacks.isEmpty) {
return false;
final privateEvents = ref.read(devicePrivateEventServiceProvider);
final storage = ref.read(storageNotifierProvider.notifier);
final platform = ref.read(packageManagerProvider.notifier).platform;
var migratedAny = false;
for (final stack in amberStacks.where(
(stack) => stack.content.isNotEmpty,
)) {
_checkCancelled();
if (!verifySignedEvent(ref, stack.event)) {
LogService.I.warn(
'ignored unverified Amber private stack',
tag: 'backup',
fields: {'identifier': stack.identifier},
);
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 oldAppIds = stack.privateAppIds;
if (oldAppIds.isEmpty) continue;
final existingDeviceStacks = await storage.query(
final existing = (await storage.query(
RequestFilter<AppStack>(
authors: {devicePubkey},
authors: {privateEvents.devicePubkey},
tags: {
'#d': {identifier},
},
limit: 1,
).toRequest(),
source: const LocalSource(),
subscriptionPrefix: 'app-private-stack-migration-device',
)).firstOrNull;
if (existing != null) {
await existing.prepareAfterLoading(ref);
_checkCancelled();
if (!existing.isDecrypted) {
LogService.I.warn(
'could not decrypt device stack; migration remains retryable',
tag: 'backup',
fields: {'identifier': identifier},
);
final existingAppIds =
existingDeviceStacks.firstOrNull?.privateAppIds ?? const <String>[];
final mergedAppIds = [
...existingAppIds,
...oldAppIds.where((id) => !existingAppIds.contains(id)),
];
continue;
}
}
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(
name: stack.name ?? identifier,
identifier: identifier,
apps: mergedAppIds,
platform: platform,
description: stack.description,
apps: merged.toList(growable: false),
platform: stack.platform ?? platform,
);
final signed = await partial.signWith(deviceSigner);
await storage.save({signed});
storage.publish({signed}, relays: 'AppCatalog');
partial.event.createdAt = privateEvents.nextReplaceableTimestamp(
existing?.createdAt,
);
await privateEvents.signAndSave(partial);
migratedAny = true;
}
await keyService.markPrivateStacksMigrated(
amberSigner.pubkey,
devicePubkey,
);
if (migratedAny) {
LogService.I.info(
'migrated Amber private stacks to device key',
tag: 'backup',
);
}
return true;
return migratedAny;
}
/// Replace the stored device key and register the restored signer immediately.
Future<void> restoreDeviceKey({
required Ref ref,
required String privateKeyHex,
@@ -218,21 +303,115 @@ class DeviceBackupService {
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 {
try {
if (Platform.isAndroid) {
final info = await DeviceInfoPlugin().androidInfo;
return info.model;
return (await DeviceInfoPlugin().androidInfo).model;
}
} catch (_) {}
return 'Android device';
}
}
/// Explicitly decrypts an imperatively queried Amber stack.
///
/// Imperative storage queries do not run EncryptableModel preparation, so
/// migration must not rely on [AppStack.privateAppIds] being populated.
Future<List<String>?> decryptAmberStackAppIds(
Signer amberSigner,
AppStack stack,
) async {
try {
final plaintext = await amberSigner.nip44Decrypt(
stack.content,
amberSigner.pubkey,
);
final decoded = jsonDecode(plaintext);
return decoded is List ? decoded.whereType<String>().toList() : null;
} catch (_) {
return null;
}
}
final deviceBackupServiceProvider = Provider<DeviceBackupService>(
(ref) => DeviceBackupService(),
);
Map<String, dynamic> _emptySettingsEnvelope() => {
'version': _kSettingsFormatVersion,
_kRecoveriesKey: <Map<String, dynamic>>[],
};
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) {
return switch (identifier) {
_kLegacyInstalledAppsBackupIdentifier => kInstalledAppsIdentifier,
@@ -241,7 +420,6 @@ String _deviceIdentifierFor(String identifier) {
};
}
/// Decoded device entry for display in the restore dialog.
class DeviceBackupInfo {
DeviceBackupInfo({
required this.privateKeyHex,
@@ -254,80 +432,74 @@ class DeviceBackupInfo {
final DateTime? backedUpAt;
}
/// Called after successful Amber sign-in. Automatically backs up the device
/// key, then shows a restore dialog only if other device entries exist.
final class DeviceBackupCancelled implements Exception {
const DeviceBackupCancelled();
}
/// Runs recovery, migration, and backup after every successful Amber sign-in.
Future<void> maybeOfferDeviceBackup(Ref ref) async {
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 service = ref.read(deviceBackupServiceProvider);
final signer = ref.read(Signer.activeSignerProvider);
if (signer == null) return;
try {
final hasBackupBeenOffered = await keyService.hasBackupBeenOffered(
amberPubkey,
final alreadyOffered = await keyService.hasBackupBeenOffered(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,
barrierDismissible: false,
builder: (_) => DeviceBackupRestoreDialog(backups: otherDevices),
);
if (selectedBackup != null) {
if (selected != null) {
await service.restoreDeviceKey(
ref: ref,
privateKeyHex: selectedBackup.privateKeyHex,
privateKeyHex: selected.privateKeyHex,
);
await ref.read(devicePrivateSyncProvider.notifier).syncRestoredKey();
}
}
}
await service.migratePrivateStacksToDeviceKey(
ref: ref,
amberSigner: signer,
amberSigner: amberSigner,
);
if (hasBackupBeenOffered) return;
// Always back up whichever device key is active after optional restore.
await service.backupDeviceKey(ref: ref, amberSigner: signer);
await ref.read(trustServiceProvider).migrateLegacyAmberRecord(amberSigner);
await service.backupDeviceKey(ref: ref, amberSigner: amberSigner);
if (!alreadyOffered) {
await keyService.markBackupOffered(amberPubkey);
} catch (e, st) {
}
} catch (error, stack) {
LogService.I.warn(
'device backup/restore check failed',
'device recovery/migration/backup failed',
tag: 'backup',
err: e,
stack: st,
err: error,
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:models/models.dart';
/// Identifier used to store trusted signer preferences
const String kTrustedSignersIdentifier = 'trusted-signers';
import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/services/device_key_service.dart';
import 'package:zapstore/services/device_private_event_service.dart';
/// Simple helper service to manage trusted signers persisted via CustomData
class TrustedSignersService {
@@ -12,45 +12,39 @@ class TrustedSignersService {
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 {
final trusted = await _loadTrustedSigners();
return trusted.contains(signerPubkey);
}
/// Adds a signer pubkey to the trusted list for the active user.
/// If no signer is available (not signed in), this method is a no-op.
/// Adds a signer pubkey to the device-private trusted list.
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();
if (trusted.contains(signerPubkey)) return;
trusted.add(signerPubkey);
await _saveTrustedSigners(trusted);
}
Future<void> _saveTrustedSigners(Set<String> trusted) async {
final privateEvents = ref.read(devicePrivateEventServiceProvider);
final content = jsonEncode({'trusted': trusted.toList()});
final encrypted = await privateEvents.encryptToDevice(content);
final partial = PartialCustomData(
identifier: kTrustedSignersIdentifier,
content: content,
content: encrypted,
);
// 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();
await privateEvents.signAndSave(partial, publish: false);
}
Future<Set<String>> _loadTrustedSigners() async {
final activePubkey = ref.read(Signer.activePubkeyProvider);
if (activePubkey == null) return <String>{};
final devicePubkey = ref.read(devicePubkeyProvider);
if (devicePubkey == null) return <String>{};
try {
final request = Request<CustomData>([
RequestFilter<CustomData>(
authors: {activePubkey},
authors: {devicePubkey},
tags: {
'#d': {kTrustedSignersIdentifier},
},
@@ -66,7 +60,10 @@ class TrustedSignersService {
if (models.isEmpty) return <String>{};
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 =
(map['trusted'] as List?)?.cast<String>() ?? const <String>[];
return list.toSet();
@@ -74,6 +71,33 @@ class TrustedSignersService {
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>(
+13 -28
View File
@@ -4,6 +4,7 @@ import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.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/log_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
@@ -32,14 +33,15 @@ final _persistedUnmanagedAppsProvider = Provider<_UnmanagedAppsSnapshot?>((
tags: {
'#d': {kUnmanagedAppsIdentifier},
},
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
source: const LocalSource(),
subscriptionPrefix: 'app-unmanaged-apps',
),
);
final stack = switch (stackState) {
StorageLoading() || StorageError() => null,
StorageLoading(:final models) ||
StorageData(:final models) => models.firstOrNull,
StorageError() => null,
};
if (stack == null) {
@@ -196,8 +198,9 @@ class UnmanagedAppsPublishException implements Exception {
String toString() => message;
}
bool wasUnmanagedStackAccepted(PublishResponse response, String eventId) {
return response.results[eventId]?.any((result) => result.accepted) ?? false;
bool wasUnmanagedStackAccepted(PublishResponse response, AppStack stack) {
return response.results[stack.event.id]?.any((result) => result.accepted) ??
false;
}
PartialAppStack createUnmanagedAppsStack({
@@ -220,17 +223,6 @@ Future<void> _writeUnmanagedApps(
Set<String> appIds, {
required DateTime createdAt,
}) 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 partial = createUnmanagedAppsStack(
appIds: appIds,
@@ -238,19 +230,12 @@ Future<void> _writeUnmanagedApps(
createdAt: createdAt,
);
final signed = await partial.signWith(signer);
final saved = await storage.save({signed});
if (!saved) {
throw const UnmanagedAppsSaveException(
'Could not save the unmanaged apps list locally.',
);
}
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.',
);
try {
await ref.read(devicePrivateEventServiceProvider).signAndSave(partial);
} on DevicePrivateSaveException catch (error) {
throw UnmanagedAppsSaveException(error.message);
} on DevicePrivatePublishException catch (error) {
throw UnmanagedAppsPublishException(error.message);
}
}
+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/deletion_processor.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/unmanaged_apps_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();
if (!settings.installedAppsBackupEnabled) return;
final signer = ref.read(Signer.signerProvider(devicePubkey));
if (signer == null) return;
final pmNotifier = ref.read(packageManagerProvider.notifier);
final installed = ref.read(packageManagerProvider).installed;
final platform = pmNotifier.platform;
@@ -324,9 +322,9 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
platform: platform,
);
final signed = await partialStack.signWith(signer);
await storage.save({signed});
await storage.publish({signed}, relays: 'AppCatalog');
await ref
.read(devicePrivateEventServiceProvider)
.signAndSave(partialStack);
_lastBackedUpIds = appIds;
} catch (e, st) {
LogService.I.warn(
@@ -387,9 +385,11 @@ final categorizedUpdatesProvider = Provider<CategorizedUpdates>((ref) {
final unmanagedIds = ref.watch(unmanagedAppsProvider);
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
.where(catalogedIds.contains)
.where((id) => !unmanagedIds.contains(id))
.toSet();
if (catalogedInstalledIds.isEmpty) {
@@ -457,7 +457,7 @@ final categorizedUpdatesProvider = Provider<CategorizedUpdates>((ref) {
for (final app in apps) {
final pkg = installed[app.identifier];
if (pkg == null) continue;
if (pkg == null || unmanagedIds.contains(app.identifier)) continue;
if (app.hasUpdate) {
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:skeletonizer/skeletonizer.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/notification_service.dart';
import 'package:zapstore/theme.dart';
@@ -117,12 +116,8 @@ class SocialActionsRow extends HookConsumerWidget {
Expanded(
flex: 16,
child: FilledButton(
onPressed: () => _handleSaveApp(
context,
ref,
app,
isPrivatelySaved,
),
onPressed: () =>
_handleSaveApp(context, ref, app, isPrivatelySaved),
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
backgroundColor: isPrivatelySaved
@@ -142,9 +137,7 @@ class SocialActionsRow extends HookConsumerWidget {
),
),
child: Icon(
isPrivatelySaved
? Icons.bookmark
: Icons.bookmark_border,
isPrivatelySaved ? Icons.bookmark : Icons.bookmark_border,
size: 20,
),
),
@@ -178,6 +171,7 @@ class SocialActionsRow extends HookConsumerWidget {
],
);
}
Future<void> _handleSaveApp(
BuildContext context,
WidgetRef ref,
@@ -185,53 +179,11 @@ class SocialActionsRow extends HookConsumerWidget {
bool isPrivatelySaved,
) async {
try {
final devicePubkey = ref.read(devicePubkeyProvider);
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');
final added = await toggleBookmark(ref, app);
if (context.mounted) {
context.showInfo(
isPrivatelySaved ? 'App removed from saved' : 'App saved privately',
added ? 'App saved privately' : 'App removed from saved',
);
}
} catch (e) {
@@ -241,8 +193,6 @@ class SocialActionsRow extends HookConsumerWidget {
}
}
Future<void> _showAddToStackDialog(BuildContext context, App app) async {
await showBaseDialog(
context: context,
-2
View File
@@ -6,7 +6,6 @@ import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/main.dart';
import 'package:zapstore/services/notification_service.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.
/// Displays a styled tappable message prompting the user to sign in.
@@ -40,7 +39,6 @@ class SignInPrompt extends HookConsumerWidget {
isLoading.value = true;
try {
await ref.read(amberSignerProvider).signIn();
onSignInSuccess(ref.read(refProvider));
} catch (e) {
if (context.mounted) {
context.showError(
+3 -45
View File
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart';
@@ -83,54 +83,12 @@ class SaveAppDialog extends HookConsumerWidget {
bool isCurrentlySaved,
) async {
try {
final devicePubkey = ref.read(devicePubkeyProvider);
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');
final added = await toggleBookmark(ref, app);
if (context.mounted) {
Navigator.pop(context);
context.showInfo(
isCurrentlySaved ? 'App removed from saved' : 'App saved privately',
added ? 'App saved privately' : 'App removed from saved',
);
}
} catch (e) {
+20 -41
View File
@@ -1,7 +1,4 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.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.
/// Shows stack name, app count badge, and padlock icon for private stacks.
class StackLinkCard extends HookConsumerWidget {
const StackLinkCard({
super.key,
required this.stack,
this.displayName,
});
class StackLinkCard extends ConsumerWidget {
const StackLinkCard({super.key, required this.stack, this.displayName});
final AppStack stack;
final String? displayName;
@@ -23,35 +16,12 @@ class StackLinkCard extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final appCount = useState<int?>(null);
useEffect(() {
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
final appCount = _isEncrypted
? (stack.isDecrypted ? stack.privateAppIds.length : null)
: stack.event
.getTagSetValues('a')
.where((id) => id.startsWith('32267:'))
.length;
appCount.value = count;
}
return null;
}, [stack.content, stack.id]);
final title = displayName ?? stack.name ?? stack.identifier;
@@ -72,7 +42,9 @@ class StackLinkCard extends HookConsumerWidget {
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
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(
@@ -98,18 +70,25 @@ class StackLinkCard extends HookConsumerWidget {
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
if (appCount.value != null && appCount.value! > 0) ...[
if (appCount != null && appCount > 0) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
color: Theme.of(
context,
).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${appCount.value}',
'$appCount',
style: context.textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
+2 -45
View File
@@ -182,53 +182,10 @@ class FloatingOverflowMenu extends HookConsumerWidget {
if (a == null) return;
try {
final devicePubkey = ref.read(devicePubkeyProvider);
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');
final added = await toggleBookmark(ref, a);
if (context.mounted) {
context.showInfo(
isCurrentlySaved ? 'App removed from saved' : 'App saved',
);
context.showInfo(added ? 'App saved' : 'App removed from saved');
}
} catch (e) {
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/services/notification_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/debug_utils.dart';
class SignInButton extends ConsumerWidget {
const SignInButton({
@@ -40,7 +39,6 @@ class SignInButton extends ConsumerWidget {
} else {
try {
await ref.read(amberSignerProvider).signIn();
onSignInSuccess(ref.read(refProvider));
} catch (e) {
if (context.mounted) {
context.showError('Sign-in failed', technicalDetails: '$e');
@@ -7,7 +7,7 @@ buildscript {
mavenCentral()
}
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"
description:
name: background_downloader
sha256: "2d17c211eb9bf1f947f154fb85d1bea96944fb1efb1b0714851caac993c02dc8"
sha256: d27fcaf94f738032f1e6db098230799bf31ae7bf184f173fc9a752fcdae75c0e
url: "https://pub.dev"
source: hosted
version: "9.4.3"
version: "9.5.6"
bech32:
dependency: transitive
description:
+1 -1
View File
@@ -50,7 +50,7 @@ dependencies:
collection: ^1.19.1
qr_flutter: ^4.1.0
share_plus: ^12.0.0
background_downloader: ^9.2.6
background_downloader: ^9.5.2
connectivity_plus: ^7.0.0
app_links: ^6.4.1
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
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
(sharing stacks, zaps, web of trust).
encrypted events. Every private kind 30267 and 30078 event is signed by the
device key and carries NIP-13 proof of work. Amber is only the identity and
recovery layer for public actions and encrypted device-key backup capsules.
## Non-Goals
- Multi-device sync without Amber (backup/restore requires one Amber sign-in)
- Migrating existing Amber-signed private stacks (users start fresh or restore)
- Continuous or multi-device live sync
- Migrating legacy Amber-signed `zapstore-settings` events
- Changing how public stacks work (still Amber-signed with h tag)
- Implementing the publish queue (handled in purplebase)
@@ -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
- Bookmarks, unmanaged apps, and settings work immediately without sign-in
- Private data renders from SQLite; one non-streaming relay sync runs at app boot
- Profile screen shows device key section with ability to copy nsec
- On first Amber sign-in, a dialog offers to:
- Back up this device (stores device nsec inside encrypted `zapstore-settings`)
- Restore from another device (if settings backups exist for this Amber key)
- On every Amber sign-in, the app performs one-shot recovery and legacy queries:
- Restore is offered when device-signed settings events contain a recovery
capsule for the Amber key
- The final device key is backed up in its device-signed settings event
- Amber-authored encrypted AppStacks are merged into device-owned stacks
- Clearing app data (SQLite) does NOT delete device key or NWC string
- Background sync, migration, and proof-of-work jobs are bounded and cancellable
## Data Model
@@ -30,22 +35,35 @@ encrypted events. Amber becomes purely the identity layer for public actions
- NWC string: secure storage only (existing key)
- Bookmarks: encrypted AppStack (30267), d=zapstore-bookmarks, signed by device key
- Unmanaged apps: encrypted AppStack (30267), d=zapstore-unmanaged-apps, signed by device key
- Installed backup: encrypted AppStack (30267), d=zapstore-installed-backup, signed by device key
- App settings: encrypted CustomData (30078), d=zapstore-settings
- Device backup: entries inside encrypted `zapstore-settings`, signed by Amber key
- Installed backup: encrypted AppStack (30267), d=zapstore-installed-apps, signed by device key
- App settings: versioned private CustomData (30078), d=zapstore-settings,
signed by the device key
- Device backup: NIP-44 recovery capsules encrypted from the device key to Amber
identities inside `zapstore-settings`; matching `p` tags allow discovery.
Each capsule includes an Amber-signed authorization binding that identity to
the device pubkey, preventing third-party recovery-candidate injection.
- Trusted signers: encrypted, local-only CustomData (30078), d=trusted-signers,
signed by the device key
- Every private event commits to at least 16 bits of NIP-13 proof of work
## Signer Roles
- Device signer (Bip340PrivateKeySigner): always available, never null. Signs all
private events. Registered on boot, NOT set as active.
- Amber signer (AmberSigner): optional. When present, is the active signer. Used
for public stacks, zaps, WoT queries.
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
- Public stacks: filtered by #h tag (community pubkey) naturally excludes device stacks
- Device private stacks: queried by authors: {devicePubkey} + specific #d tag
- appStackEventFilter schema filter removed; #h tag filtering is sufficient
- Device private 30267/30078 events are fetched once with stream=false at boot.
Private UI consumers use LocalSource only.
- Amber sign-in recovery and migration are explicit one-shot exceptions; they
never open streaming subscriptions.
## 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)
- Offline: events save locally, purplebase publish queue syncs when online
- Multiple devices with same Amber key: each has own device key; backup stores device name
- Existing device-authored events without PoW are re-signed in the background;
invalid or undecryptable events are never overwritten.
- Legacy Amber-authored settings are ignored even if that loses old backups.
- Migration is idempotent and rechecks on every Amber sign-in; failed decrypts
remain retryable.
## Acceptance Criteria
@@ -63,8 +86,14 @@ encrypted events. Amber becomes purely the identity layer for public actions
- [ ] Unmanaged apps work without Amber sign-in
- [ ] User can copy device nsec from profile screen
- [ ] First Amber sign-in triggers backup/restore dialog
- [ ] Backup encrypts device nsec inside `zapstore-settings` to Amber key
- [ ] Restore decrypts `zapstore-settings` and imports device nsec
- [ ] Every Amber sign-in upserts a recovery capsule in device-signed settings
- [ ] Restore discovers device-signed settings by Amber `p` tag and imports nsec
- [ ] Legacy Amber-signed settings are ignored
- [ ] Amber private bookmarks, installed backups, unmanaged apps, and other
encrypted AppStacks migrate safely to the final device key
- [ ] All new private 30267/30078 events have valid 16-bit NIP-13 proof of work
- [ ] PoW mining does not run on Flutter's main isolate
- [ ] Private relay reads happen only at boot and explicit Amber sign-in recovery
- [ ] appStackEventFilter removed; queries use #h tag filtering
- [ ] EncryptableModel auto-decrypts device-key stacks (no manual decrypt calls)
+5
View File
@@ -29,6 +29,8 @@ cross-device sync.
- [x] Include the platform tag and require explicit relay acceptance
- [x] Surface local-save and relay-publish failures to the user
- [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
- [ ] Extend native package scan with Android installer-source metadata
- [ ] Default apps installed by known third-party app stores to unmanaged
@@ -57,6 +59,9 @@ cross-device sync.
map when Android reports no changes.
- Android package enumeration runs on a lifecycle-owned worker so package,
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
- 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)
**Feature:** FEAT-006-device-key.md
**Status:** In Progress
**Status:** Complete
## Tasks
@@ -38,13 +38,44 @@
- Queries Amber-authored encrypted AppStacks after Amber connection
- Merges them into device-authored encrypted stacks using the device signer
- 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
- Files: `lib/services/device_backup_service.dart`
- Uses Amber-signed `CustomData` with `d=zapstore-settings`
- Encrypts the full JSON settings object to the Amber key before signing
- Stores device backup entries under `deviceBackups`
- [ ] 10. Self-review against INVARIANTS.md
- Uses device-signed `CustomData` with `d=zapstore-settings`
- Stores NIP-44 Amber recovery capsules authorized by Amber signatures
- Ignores legacy Amber-signed settings
- [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
@@ -68,13 +99,53 @@
### 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.
**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.
### 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/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
@@ -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:** 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 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/services/unmanaged_apps_service.dart';
@@ -118,23 +119,53 @@ void main() {
});
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()
..addEvent(
'stack-id',
stack.event.id,
relayUrl: 'wss://relay.zapstore.dev',
accepted: true,
);
final rejected = PublishResponse()
..addEvent(
'stack-id',
stack.event.id,
relayUrl: 'wss://relay.zapstore.dev',
accepted: false,
);
final addressableIdResponse = PublishResponse()
..addEvent(
stack.id,
relayUrl: 'wss://relay.zapstore.dev',
accepted: true,
);
expect(wasUnmanagedStackAccepted(accepted, 'stack-id'), isTrue);
expect(wasUnmanagedStackAccepted(rejected, 'stack-id'), isFalse);
expect(wasUnmanagedStackAccepted(PublishResponse(), 'stack-id'), isFalse);
});
expect(wasUnmanagedStackAccepted(accepted, stack), isTrue);
expect(wasUnmanagedStackAccepted(rejected, stack), isFalse);
expect(
wasUnmanagedStackAccepted(addressableIdResponse, stack),
isFalse,
);
expect(wasUnmanagedStackAccepted(PublishResponse(), stack), isFalse);
},
);
});
}