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 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;
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<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({
required Ref ref,
required String privateKeyHex,
@@ -189,6 +220,9 @@ Future<void> 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());
+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.',
),
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,
@@ -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.
@@ -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,
);
},
);
}