diff --git a/AGENTS.md b/AGENTS.md index 37f8839..4e6f7fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,12 @@ All behavioral authority lives in `spec/guidelines/`. If this file conflicts, gu | Feature specs | `spec/features/` | | Active work | `spec/work/` | | Decisions & learnings | `spec/knowledge/` | +| ADR-equivalent decisions | `spec/knowledge/DEC-XXX-*.md` | Guidelines are symlinked into `.cursor/rules/` and auto-load. +If a skill references `docs/adr/`, read `spec/knowledge/` instead. If a skill references an issue tracker, this repo doesn't have one configured — work is tracked in `spec/work/` and `spec/features/`. + ## File Ownership | Path | Owner | AI May Modify | diff --git a/lib/services/device_key_service.dart b/lib/services/device_key_service.dart new file mode 100644 index 0000000..b5a4eb3 --- /dev/null +++ b/lib/services/device_key_service.dart @@ -0,0 +1,115 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:models/models.dart'; + +const _storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), +); + +const _kSecurePrefsKey = 'zapstore_secure_prefs'; + +/// Manages all device-local secrets in a single secure storage entry. +/// +/// Stored as a JSON object with keys: +/// - `nsec`: device private key (hex) +/// - `backup_offered`: list of Amber pubkeys already offered backup dialog +/// - `private_stacks_migrated`: amber/device pubkey pairs already migrated +class DeviceKeyService { + Map? _cache; + + Future> _load() async { + if (_cache != null) return _cache!; + final raw = await _storage.read(key: _kSecurePrefsKey); + _cache = (raw != null && raw.isNotEmpty) + ? jsonDecode(raw) as Map + : {}; + return _cache!; + } + + Future _persist() async { + await _storage.write(key: _kSecurePrefsKey, value: jsonEncode(_cache)); + } + + /// Load existing device key or generate a new one. Returns hex private key. + Future getOrCreatePrivateKey() async { + final prefs = await _load(); + final existing = prefs['nsec'] as String?; + if (existing != null && existing.isNotEmpty) return existing; + + final privateKeyHex = Utils.generateRandomHex64(); + prefs['nsec'] = privateKeyHex; + await _persist(); + return privateKeyHex; + } + + /// Returns the bech32-encoded private key for display/copy. + Future getNsec() async { + final hex = await getOrCreatePrivateKey(); + return bech32Encode('nsec', hex); + } + + /// Replace the current device key (used during restore from backup). + Future replacePrivateKey(String privateKeyHex) async { + final prefs = await _load(); + prefs['nsec'] = privateKeyHex; + await _persist(); + } + + /// Whether the backup/restore dialog has been offered for [pubkey]. + Future hasBackupBeenOffered(String pubkey) async { + final prefs = await _load(); + final list = (prefs['backup_offered'] as List?)?.cast() ?? []; + return list.contains(pubkey); + } + + /// Mark that the backup dialog was shown for [pubkey]. + Future markBackupOffered(String pubkey) async { + final prefs = await _load(); + final list = (prefs['backup_offered'] as List?)?.cast() ?? []; + if (!list.contains(pubkey)) { + list.add(pubkey); + prefs['backup_offered'] = list; + await _persist(); + } + } + + /// Whether Amber-authored private stacks have been migrated to [devicePubkey]. + Future hasPrivateStacksMigrated( + String amberPubkey, + String devicePubkey, + ) async { + final prefs = await _load(); + final list = + (prefs['private_stacks_migrated'] as List?)?.cast() ?? []; + return list.contains(_migrationKey(amberPubkey, devicePubkey)); + } + + /// Mark private stack migration complete for [devicePubkey]. + Future markPrivateStacksMigrated( + String amberPubkey, + String devicePubkey, + ) async { + final prefs = await _load(); + final list = + (prefs['private_stacks_migrated'] as List?)?.cast() ?? []; + final key = _migrationKey(amberPubkey, devicePubkey); + if (!list.contains(key)) { + list.add(key); + prefs['private_stacks_migrated'] = list; + await _persist(); + } + } + + String _migrationKey(String amberPubkey, String devicePubkey) => + '$amberPubkey:$devicePubkey'; +} + +final deviceKeyServiceProvider = Provider( + (ref) => DeviceKeyService(), +); + +/// The device pubkey (hex). Available after storageReadyProvider resolves. +final devicePubkeyProvider = StateProvider((_) => null); diff --git a/spec/features/FEAT-006-device-key.md b/spec/features/FEAT-006-device-key.md new file mode 100644 index 0000000..1c611a8 --- /dev/null +++ b/spec/features/FEAT-006-device-key.md @@ -0,0 +1,76 @@ +# FEAT-006 - Device Key Architecture + +## Goal + +Decouple private data (bookmarks, ignored 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). + +## 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) +- Changing how public stacks work (still Amber-signed with h tag) +- Implementing the publish queue (handled in purplebase) + +## User-Visible Behavior + +- On first launch, a device key is silently generated and stored in secure storage +- Bookmarks, ignored apps, and settings work immediately without sign-in +- 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) +- Clearing app data (SQLite) does NOT delete device key or NWC string + +## Data Model + +- Device nsec: secure storage only (key: device_nsec) +- NWC string: secure storage only (existing key) +- Bookmarks: encrypted AppStack (30267), d=zapstore-bookmarks, signed by device key +- Ignored apps: encrypted AppStack (30267), d=zapstore-ignored-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 + +## 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. + +## 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 + +## Edge Cases + +- Device key lost (app uninstalled without backup): data unrecoverable, fresh start +- Amber uninstalled while backup dialog pending: backup deferred to next sign-in +- Restore on device that already has data: ask user to confirm (replace or keep current) +- Offline: events save locally, purplebase publish queue syncs when online +- Multiple devices with same Amber key: each has own device key; backup stores device name + +## Acceptance Criteria + +- [ ] Device key generated on first launch and persisted in secure storage +- [ ] Device key survives SQLite clear / app restart +- [ ] Bookmarks work without Amber sign-in +- [ ] Ignored 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 +- [ ] appStackEventFilter removed; queries use #h tag filtering +- [ ] EncryptableModel auto-decrypts device-key stacks (no manual decrypt calls) + +## Phases + +- A: Device key generation + service + registration at boot + copy nsec UI +- B: Migrate bookmarks/ignored/backup to device key (drop Amber requirement) +- C: Amber backup/restore dialog + CustomData events +- D: Remove appStackEventFilter, clean up sign-in gating in UI diff --git a/spec/work/WORK-011-device-key.md b/spec/work/WORK-011-device-key.md new file mode 100644 index 0000000..82dad0a --- /dev/null +++ b/spec/work/WORK-011-device-key.md @@ -0,0 +1,85 @@ +# WORK-011 - Device Key (Phase A + B + D) + +**Feature:** FEAT-006-device-key.md +**Status:** In Progress + +## Tasks + +- [x] 1. Create DeviceKeyService + - Files: `lib/services/device_key_service.dart` + - Generates/loads key from FlutterSecureStorage + - Exposes devicePubkeyProvider (StateProvider) +- [x] 2. Register device signer at boot + - Files: `lib/main.dart` + - In storageReadyProvider, after SQLite init + - signIn(setAsActive: false), sets devicePubkeyProvider +- [x] 3. Device key UI in profile screen + - Files: `lib/screens/profile_screen.dart` + - Shows truncated npub, copy private key with warning dialog +- [x] 4. Rewrite bookmarks to use device key + - Files: `lib/services/bookmarks_service.dart`, `lib/widgets/app_detail_widgets.dart`, `lib/widgets/floating_overflow_menu.dart` + - Changed from FutureProvider to synchronous Provider + - No manual nip44Decrypt calls (EncryptableModel auto-decrypts) + - No sign-in gate; always available +- [x] 5. Rewrite ignored apps to use device key + - Files: `lib/services/ignored_apps_service.dart`, `lib/services/updates_service.dart` + - Changed from FutureProvider to synchronous Provider + - No sign-in gate +- [x] 6. Remove sign-in gate from SaveAppDialog + - Files: `lib/widgets/bookmark_widgets.dart` + - Removed SignInPrompt, uses device signer directly +- [x] 7. Remove appStackEventFilter, use #h tag filtering + - Files: `lib/constants/app_constants.dart`, `lib/screens/user_screen.dart`, `lib/widgets/app_stack_container.dart`, `lib/widgets/bookmark_widgets.dart` + - Removed filter function entirely + - Public stack queries now use '#h': {kZapstoreCommunityPubkey} tag +- [x] 8. Migrate Amber private stacks on sign-in + - Files: `lib/services/device_backup_service.dart`, `lib/services/device_key_service.dart` + - Offers restore before migration so the final device key is chosen first + - Queries Amber-authored encrypted AppStacks after Amber connection + - Merges them into device-authored encrypted stacks using the device signer + - Normalizes legacy installed/ignored d-tags to current identifiers + - Marks migration complete per Amber pubkey + device pubkey; empty results retry +- [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 + +## Decisions + +### 2026-05-07 - Migrate Amber-authored private stacks + +**Context:** Users may have bookmarks encrypted to their Amber key. +**Decision:** On Amber connection, migrate encrypted AppStacks authored by the Amber pubkey to equivalent device-key stacks. +**Rationale:** Private data should follow the new device-key ownership model without losing existing saved apps, installed-app backups, or ignored/unmanaged app state. + +### 2026-05-07 - Synchronous providers for bookmarks/ignored + +**Context:** Previously FutureProvider because of manual decrypt. Now EncryptableModel auto-decrypts. +**Decision:** Changed to synchronous Provider>. +**Rationale:** EncryptableModel.prepareAfterLoading runs in RequestNotifier before emission. By the time the provider reads the stack, privateAppIds is already decrypted. + +### 2026-05-07 - #h tag replaces schemaFilter + +**Context:** appStackEventFilter rejected encrypted stacks and stacks without app refs. +**Decision:** Removed entirely. Public stacks are identified by having '#h': {communityPubkey} tag. +**Rationale:** Per invariant "Encrypted stacks MUST NOT include a community h tag", filtering by #h naturally excludes all private stacks. + +### 2026-05-07 - Device backups live inside settings + +**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. + +## Spec Issues + +- `spec/features/FEAT-006-device-key.md` still lists migration as a non-goal and uses legacy d-tags for installed/ignored apps. Implementation now follows the product direction from this work session: migrate private stacks to the device pubkey on Amber connection. + +## Progress Notes + +**2026-05-07:** Phase A, B, and D complete. Migration added for Amber-authored private stacks. Analysis could not be rerun in this sandbox because `fvm` and pub-cache reads are blocked. + +**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.