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
3 changed files with 78 additions and 13 deletions

View file

@ -65,8 +65,9 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
sodium's are views of memory freed with the isolate that made them) and an
`AccountKeyStore` (`SecureAccountKeyStore`: the account's unlocked keys in
the system's keyring, per instance and person, gone once another person
signs in or nobody; on iOS and macOS `first_unlock_this_device`, so never
in a backup restored elsewhere nor in iCloud Keychain). Signing in (SRP's M2 checked and the keys opened before
signs in or nobody; on iOS and macOS `unlocked_this_device`, so readable
only while the device is unlocked, never in a backup restored elsewhere nor
in iCloud Keychain, each write deleting the entry before it). Signing in (SRP's M2 checked and the keys opened before
anything is trusted), signing up (the recovery phrase shown once and three of
its words typed back before anything reaches the instance), a setup never
finished, the authenticators (QR codes drawn with pub.dev's `qr`), recovery

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 {

View file

@ -1,19 +1,69 @@
import 'dart:typed_data';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
/// A keyring noting what it is asked, in order.
final class _Recording() extends Fake implements FlutterSecureStorage {
final calls = <String>[];
@override
Future<void> delete({
required String key,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async => calls.add('delete $key');
@override
Future<void> write({
required String key,
required String? value,
AppleOptions? iOptions,
AndroidOptions? aOptions,
LinuxOptions? lOptions,
WebOptions? webOptions,
AppleOptions? mOptions,
WindowsOptions? wOptions,
}) async => calls.add('write $key');
}
void main() {
test("the account's keys stay on this device on Apple's systems: never in "
'a backup restored elsewhere, nor in iCloud Keychain', () {
test("the account's keys open only while an Apple device is unlocked, and "
'stay on it: never in a backup restored elsewhere, nor in iCloud '
'Keychain (the reading of the fixes)', () {
for (final options in [
accountKeyStorage.iOptions,
accountKeyStorage.mOptions,
]) {
expect(
options.accessibility,
KeychainAccessibility.first_unlock_this_device,
KeychainAccessibility.unlocked_this_device,
);
expect(options.synchronizable, isFalse);
}
});
test('keeping keys deletes what the keyring held for that person first: '
'an entry kept with another accessibility is never left, nor makes '
'the write fail', () async {
final keyring = _Recording();
final store = SecureAccountKeyStore('learn', storage: keyring);
await store.keep(
'anna',
UnlockedAccountKeys(
encryptionPublicKey: Uint8List(32),
encryptionSecretKey: Uint8List(32),
signingPublicKey: Uint8List(32),
signingSecretKey: Uint8List(64),
),
);
expect(keyring.calls, ['delete learn:anna', 'write learn:anna']);
});
}