Add device key work plan

This commit is contained in:
franzap
2026-05-27 19:42:00 -03:00
parent a3ed899819
commit 212a19a5a7
4 changed files with 279 additions and 0 deletions
+3
View File
@@ -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 |
+115
View File
@@ -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<String, dynamic>? _cache;
Future<Map<String, dynamic>> _load() async {
if (_cache != null) return _cache!;
final raw = await _storage.read(key: _kSecurePrefsKey);
_cache = (raw != null && raw.isNotEmpty)
? jsonDecode(raw) as Map<String, dynamic>
: <String, dynamic>{};
return _cache!;
}
Future<void> _persist() async {
await _storage.write(key: _kSecurePrefsKey, value: jsonEncode(_cache));
}
/// Load existing device key or generate a new one. Returns hex private key.
Future<String> getOrCreatePrivateKey() async {
final prefs = await _load();
final existing = prefs['nsec'] as String?;
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<String> getNsec() async {
final hex = await getOrCreatePrivateKey();
return bech32Encode('nsec', hex);
}
/// Replace the current device key (used during restore from backup).
Future<void> replacePrivateKey(String privateKeyHex) async {
final prefs = await _load();
prefs['nsec'] = privateKeyHex;
await _persist();
}
/// Whether the backup/restore dialog has been offered for [pubkey].
Future<bool> hasBackupBeenOffered(String pubkey) async {
final prefs = await _load();
final list = (prefs['backup_offered'] as List?)?.cast<String>() ?? [];
return list.contains(pubkey);
}
/// Mark that the backup dialog was shown for [pubkey].
Future<void> markBackupOffered(String pubkey) async {
final prefs = await _load();
final list = (prefs['backup_offered'] as List?)?.cast<String>() ?? [];
if (!list.contains(pubkey)) {
list.add(pubkey);
prefs['backup_offered'] = list;
await _persist();
}
}
/// Whether Amber-authored private stacks have been migrated to [devicePubkey].
Future<bool> hasPrivateStacksMigrated(
String amberPubkey,
String devicePubkey,
) async {
final prefs = await _load();
final list =
(prefs['private_stacks_migrated'] as List?)?.cast<String>() ?? [];
return list.contains(_migrationKey(amberPubkey, devicePubkey));
}
/// Mark private stack migration complete for [devicePubkey].
Future<void> markPrivateStacksMigrated(
String amberPubkey,
String devicePubkey,
) async {
final prefs = await _load();
final list =
(prefs['private_stacks_migrated'] as List?)?.cast<String>() ?? [];
final key = _migrationKey(amberPubkey, devicePubkey);
if (!list.contains(key)) {
list.add(key);
prefs['private_stacks_migrated'] = list;
await _persist();
}
}
String _migrationKey(String amberPubkey, String devicePubkey) =>
'$amberPubkey:$devicePubkey';
}
final deviceKeyServiceProvider = Provider<DeviceKeyService>(
(ref) => DeviceKeyService(),
);
/// The device pubkey (hex). Available after storageReadyProvider resolves.
final devicePubkeyProvider = StateProvider<String?>((_) => null);
+76
View File
@@ -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
+85
View File
@@ -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<Set<String>>.
**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.