mirror of
https://github.com/zapstore/zapstore.git
synced 2026-09-14 03:05:06 +00:00
Add device key work plan
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user