mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Fix failing Amber restore from backup
This commit is contained in:
@@ -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());
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user