fix(app): an Apple device's keychain gives the keys only while it is unlocked

The reading of the full reading's fixes found that first_unlock_this_device
left the account's private keys readable on a locked phone once it had
been unlocked after starting, which is what forensic tools extract from a
stolen one; and that, the accessibility being part of every keychain
lookup, an entry kept with another class stayed unseen and made the next
write fail as a duplicate, so that person couldn't finish signing in on
that device.

As the developer decided, the keys are kept unlocked_this_device (still
never in a backup nor in iCloud Keychain); nothing decrypts with them
while the device is locked. Keeping keys deletes the entry first, which a
delete finds whatever its class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 16:23:20 +02:00
commit 9348504163
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -5,16 +5,20 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:halkyon_app/src/password/model/unlocked_account_keys.dart';
/// The system's keyring as the account's keys are kept in it. On iOS and
/// macOS they open once the device was first unlocked after starting and
/// stay on this device alone (`first_unlock_this_device`): the default
/// (`unlocked`) lets them into encrypted backups, restored on another
/// device; and never into iCloud Keychain (not `synchronizable`).
/// macOS they open only while the device is unlocked and stay on this
/// device alone (`unlocked_this_device`): the default (`unlocked`) lets
/// them into encrypted backups, restored on another device, and
/// `first_unlock_this_device` leaves them readable on a locked phone once
/// it was unlocked after starting, what forensic tools read from a stolen
/// one (the reading of the fixes); and never into iCloud Keychain (not
/// `synchronizable`). Nothing decrypts with them while the device is
/// locked.
const accountKeyStorage = FlutterSecureStorage(
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
accessibility: KeychainAccessibility.unlocked_this_device,
),
mOptions: MacOsOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
accessibility: KeychainAccessibility.unlocked_this_device,
),
);
@ -43,9 +47,19 @@ class SecureAccountKeyStore(
}) implements AccountKeyStore {
String _keyOf(String person) => '$_prefix:$person';
/// Deletes what the keyring held for [person] first: on Apple's systems
/// the accessibility is part of every lookup, so an entry kept with
/// another (by a build before) would stay unseen beside it and make the
/// write fail as a duplicate; a delete finds it whatever its
/// accessibility.
@override
Future<void> keep(String person, UnlockedAccountKeys keys) =>
_storage.write(key: _keyOf(person), value: jsonEncode(keys.toJson()));
Future<void> keep(String person, UnlockedAccountKeys keys) async {
await _storage.delete(key: _keyOf(person));
await _storage.write(
key: _keyOf(person),
value: jsonEncode(keys.toJson()),
);
}
@override
Future<UnlockedAccountKeys?> read(String person) async {