From 7e5cf62afef1972c5e8fd10ab83550530a3995c2 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Fri, 17 Jul 2026 13:50:05 -0300 Subject: [PATCH] Fix failing Amber restore from backup --- lib/services/device_backup_service.dart | 78 +++++++++++++------ lib/widgets/device_restore_dialog.dart | 20 +++++ spec/work/WORK-026-new-device-key-reminder.md | 19 +++++ test/services/device_backup_service_test.dart | 35 +++++++++ 4 files changed, 130 insertions(+), 22 deletions(-) diff --git a/lib/services/device_backup_service.dart b/lib/services/device_backup_service.dart index 2d6a741..351d4a7 100644 --- a/lib/services/device_backup_service.dart +++ b/lib/services/device_backup_service.dart @@ -72,28 +72,8 @@ class DeviceBackupService { required Ref ref, required Signer amberSigner, }) async { - final results = await ref - .read(storageNotifierProvider.notifier) - .query( - RequestFilter( - authors: {amberSigner.pubkey}, - tags: { - '#d': {kDeviceKeyBackupIdentifier}, - }, - limit: 1, - ).toRequest(), - source: const LocalAndRemoteSource( - relays: 'AppCatalog', - stream: false, - ), - subscriptionPrefix: 'app-device-key-backup', - ); - final backup = results.firstOrNull; - if (backup == null || - backup.pubkey != amberSigner.pubkey || - !verifySignedEvent(ref, backup.event)) { - return null; - } + final backup = await _findAmberBackup(ref: ref, amberSigner: amberSigner); + if (backup == null) return null; try { final plaintext = await amberSigner.nip44Decrypt( backup.content, @@ -113,6 +93,57 @@ class DeviceBackupService { } } + /// Returns whether Amber already holds a signed recovery record. + /// + /// A normal sign-in must not replace an existing recovery key. In + /// particular, a fresh install has already generated a new device key before + /// Amber becomes available; publishing that key would orphan the user's + /// existing private state. + Future hasAmberBackup({ + required Ref ref, + required Signer amberSigner, + }) async => + await _findAmberBackup(ref: ref, amberSigner: amberSigner) != null; + + static bool isValidAmberBackup({ + required Ref ref, + required CustomData? backup, + required Signer amberSigner, + }) => + backup != null && + backup.pubkey == amberSigner.pubkey && + verifySignedEvent(ref, backup.event); + + Future _findAmberBackup({ + required Ref ref, + required Signer amberSigner, + }) async { + final results = await ref + .read(storageNotifierProvider.notifier) + .query( + RequestFilter( + authors: {amberSigner.pubkey}, + tags: { + '#d': {kDeviceKeyBackupIdentifier}, + }, + limit: 1, + ).toRequest(), + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + subscriptionPrefix: 'app-device-key-backup', + ); + final backup = results.firstOrNull; + return isValidAmberBackup( + ref: ref, + backup: backup, + amberSigner: amberSigner, + ) + ? backup + : null; + } + Future restoreDeviceKey({ required Ref ref, required String privateKeyHex, @@ -189,6 +220,9 @@ Future maybeOfferDeviceBackup(Ref ref) async { final service = ref.read(deviceBackupServiceProvider); if (service.isRestoringFromAmber) return; try { + if (await service.hasAmberBackup(ref: ref, amberSigner: amberSigner)) { + return; + } await service.backupDeviceKey(ref: ref, amberSigner: amberSigner); if (!ref.read(deviceStateProvider).isReady) { unawaited(ref.read(deviceStateProvider.notifier).bootstrap()); diff --git a/lib/widgets/device_restore_dialog.dart b/lib/widgets/device_restore_dialog.dart index 0fdade6..58cb7b2 100644 --- a/lib/widgets/device_restore_dialog.dart +++ b/lib/widgets/device_restore_dialog.dart @@ -41,6 +41,26 @@ class DeviceRestoreDialog extends HookConsumerWidget { 'Alternatively, you can sign in with Amber and settings may be recovered that way.', ), const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () { + if (amberInstalled) { + Navigator.pop( + context, + const DeviceRestoreResult(DeviceRestoreAction.amber), + ); + } else { + Navigator.pop(context); + context.push('/search/app/$kAmberNaddr'); + } + }, + icon: const Icon(Icons.login), + label: Text( + amberInstalled + ? 'Restore with Amber' + : 'Install Amber to restore', + ), + ), + const SizedBox(height: 12), TextField( controller: controller, autocorrect: false, diff --git a/spec/work/WORK-026-new-device-key-reminder.md b/spec/work/WORK-026-new-device-key-reminder.md index cd3aeb0..b11863c 100644 --- a/spec/work/WORK-026-new-device-key-reminder.md +++ b/spec/work/WORK-026-new-device-key-reminder.md @@ -21,6 +21,12 @@ test/services/device_backup_service_test.dart test/services/settings_service_test.dart` - `HOME=/tmp fvm flutter analyze` +- [x] 5. Protect Amber recovery records + - Files: `lib/services/device_backup_service.dart`, + `lib/widgets/device_restore_dialog.dart` + - Normal Amber sign-in preserves an existing verified Amber recovery record; + explicit Device key management offers Amber recovery without scheduling a + replacement backup. ## Verification @@ -47,3 +53,16 @@ any time without treating Amber as a prerequisite or gating the UI. The obsolete persisted `restoreOnboardingComplete` value is removed. Amber recovery is now explicitly initiated from Device key management, which prevents normal Amber sign-in backup from overwriting a recovery record first. + +### 2026-07-17 - Existing Amber recovery records are immutable on sign-in + +**Context:** A fresh install creates a new device key before the user can sign +in with Amber. Automatically backing up that key could replace the only record +that points to the prior device's private state. + +**Decision:** On a normal Amber sign-in, query for a signature-verified backup +first and only create one when none exists. Recovery must be started explicitly +from Device key management, which now exposes the Amber option. + +**Rationale:** An Amber sign-in must not silently make prior private stacks and +portable settings unrecoverable. diff --git a/test/services/device_backup_service_test.dart b/test/services/device_backup_service_test.dart index c7d23a1..1bd3fa1 100644 --- a/test/services/device_backup_service_test.dart +++ b/test/services/device_backup_service_test.dart @@ -1,9 +1,44 @@ +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() { test('backup exceptions retain a user-safe message', () { const exception = DeviceBackupException('Amber backup was unavailable.'); expect(exception.toString(), 'Amber backup was unavailable.'); }); + + test( + 'recognizes a signed Amber recovery record before replacement', + () async { + final container = ProviderContainer( + overrides: [ + storageNotifierProvider.overrideWith(DummyStorageNotifier.new), + ], + ); + addTearDown(container.dispose); + await container + .read(storageNotifierProvider.notifier) + .initialize(StorageConfiguration()); + final ref = container.read(refProvider); + final amber = Bip340PrivateKeySigner('2' * 64, ref); + await amber.signIn(setAsActive: false); + final backup = await PartialCustomData( + identifier: kDeviceKeyBackupIdentifier, + content: 'encrypted-device-key', + ).signWith(amber); + + expect( + DeviceBackupService.isValidAmberBackup( + ref: ref, + backup: backup, + amberSigner: amber, + ), + isTrue, + ); + }, + ); }