Fix failing Amber restore from backup

This commit is contained in:
franzap
2026-07-17 13:50:05 -03:00
parent c58b029ec2
commit 7e5cf62afe
4 changed files with 130 additions and 22 deletions
+56 -22
View File
@@ -72,28 +72,8 @@ class DeviceBackupService {
required Ref ref, required Ref ref,
required Signer amberSigner, required Signer amberSigner,
}) async { }) async {
final results = await ref final backup = await _findAmberBackup(ref: ref, amberSigner: amberSigner);
.read(storageNotifierProvider.notifier) if (backup == null) return null;
.query(
RequestFilter<CustomData>(
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;
}
try { try {
final plaintext = await amberSigner.nip44Decrypt( final plaintext = await amberSigner.nip44Decrypt(
backup.content, 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<bool> 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<CustomData?> _findAmberBackup({
required Ref ref,
required Signer amberSigner,
}) async {
final results = await ref
.read(storageNotifierProvider.notifier)
.query(
RequestFilter<CustomData>(
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<void> restoreDeviceKey({ Future<void> restoreDeviceKey({
required Ref ref, required Ref ref,
required String privateKeyHex, required String privateKeyHex,
@@ -189,6 +220,9 @@ Future<void> maybeOfferDeviceBackup(Ref ref) async {
final service = ref.read(deviceBackupServiceProvider); final service = ref.read(deviceBackupServiceProvider);
if (service.isRestoringFromAmber) return; if (service.isRestoringFromAmber) return;
try { try {
if (await service.hasAmberBackup(ref: ref, amberSigner: amberSigner)) {
return;
}
await service.backupDeviceKey(ref: ref, amberSigner: amberSigner); await service.backupDeviceKey(ref: ref, amberSigner: amberSigner);
if (!ref.read(deviceStateProvider).isReady) { if (!ref.read(deviceStateProvider).isReady) {
unawaited(ref.read(deviceStateProvider.notifier).bootstrap()); unawaited(ref.read(deviceStateProvider.notifier).bootstrap());
+20
View File
@@ -41,6 +41,26 @@ class DeviceRestoreDialog extends HookConsumerWidget {
'Alternatively, you can sign in with Amber and settings may be recovered that way.', 'Alternatively, you can sign in with Amber and settings may be recovered that way.',
), ),
const SizedBox(height: 16), 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( TextField(
controller: controller, controller: controller,
autocorrect: false, autocorrect: false,
@@ -21,6 +21,12 @@
test/services/device_backup_service_test.dart test/services/device_backup_service_test.dart
test/services/settings_service_test.dart` test/services/settings_service_test.dart`
- `HOME=/tmp fvm flutter analyze` - `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 ## 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 The obsolete persisted `restoreOnboardingComplete` value is removed. Amber
recovery is now explicitly initiated from Device key management, which prevents recovery is now explicitly initiated from Device key management, which prevents
normal Amber sign-in backup from overwriting a recovery record first. 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.
@@ -1,9 +1,44 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.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/services/device_backup_service.dart';
import 'package:zapstore/utils/debug_utils.dart';
void main() { void main() {
test('backup exceptions retain a user-safe message', () { test('backup exceptions retain a user-safe message', () {
const exception = DeviceBackupException('Amber backup was unavailable.'); const exception = DeviceBackupException('Amber backup was unavailable.');
expect(exception.toString(), '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,
);
},
);
} }