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:
parent
fac9a000a1
commit
9348504163
3 changed files with 78 additions and 13 deletions
|
|
@ -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
|
sodium's are views of memory freed with the isolate that made them) and an
|
||||||
`AccountKeyStore` (`SecureAccountKeyStore`: the account's unlocked keys in
|
`AccountKeyStore` (`SecureAccountKeyStore`: the account's unlocked keys in
|
||||||
the system's keyring, per instance and person, gone once another person
|
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
|
signs in or nobody; on iOS and macOS `unlocked_this_device`, so readable
|
||||||
in a backup restored elsewhere nor in iCloud Keychain). Signing in (SRP's M2 checked and the keys opened before
|
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
|
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
|
its words typed back before anything reaches the instance), a setup never
|
||||||
finished, the authenticators (QR codes drawn with pub.dev's `qr`), recovery
|
finished, the authenticators (QR codes drawn with pub.dev's `qr`), recovery
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,20 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:halkyon_app/src/password/model/unlocked_account_keys.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
|
/// 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
|
/// macOS they open only while the device is unlocked and stay on this
|
||||||
/// stay on this device alone (`first_unlock_this_device`): the default
|
/// device alone (`unlocked_this_device`): the default (`unlocked`) lets
|
||||||
/// (`unlocked`) lets them into encrypted backups, restored on another
|
/// them into encrypted backups, restored on another device, and
|
||||||
/// device; and never into iCloud Keychain (not `synchronizable`).
|
/// `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(
|
const accountKeyStorage = FlutterSecureStorage(
|
||||||
iOptions: IOSOptions(
|
iOptions: IOSOptions(
|
||||||
accessibility: KeychainAccessibility.first_unlock_this_device,
|
accessibility: KeychainAccessibility.unlocked_this_device,
|
||||||
),
|
),
|
||||||
mOptions: MacOsOptions(
|
mOptions: MacOsOptions(
|
||||||
accessibility: KeychainAccessibility.first_unlock_this_device,
|
accessibility: KeychainAccessibility.unlocked_this_device,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -43,9 +47,19 @@ class SecureAccountKeyStore(
|
||||||
}) implements AccountKeyStore {
|
}) implements AccountKeyStore {
|
||||||
String _keyOf(String person) => '$_prefix:$person';
|
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
|
@override
|
||||||
Future<void> keep(String person, UnlockedAccountKeys keys) =>
|
Future<void> keep(String person, UnlockedAccountKeys keys) async {
|
||||||
_storage.write(key: _keyOf(person), value: jsonEncode(keys.toJson()));
|
await _storage.delete(key: _keyOf(person));
|
||||||
|
await _storage.write(
|
||||||
|
key: _keyOf(person),
|
||||||
|
value: jsonEncode(keys.toJson()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<UnlockedAccountKeys?> read(String person) async {
|
Future<UnlockedAccountKeys?> read(String person) async {
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,69 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:halkyon_app/halkyon_app.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() {
|
void main() {
|
||||||
test("the account's keys stay on this device on Apple's systems: never in "
|
test("the account's keys open only while an Apple device is unlocked, and "
|
||||||
'a backup restored elsewhere, nor in iCloud Keychain', () {
|
'stay on it: never in a backup restored elsewhere, nor in iCloud '
|
||||||
|
'Keychain (the reading of the fixes)', () {
|
||||||
for (final options in [
|
for (final options in [
|
||||||
accountKeyStorage.iOptions,
|
accountKeyStorage.iOptions,
|
||||||
accountKeyStorage.mOptions,
|
accountKeyStorage.mOptions,
|
||||||
]) {
|
]) {
|
||||||
expect(
|
expect(
|
||||||
options.accessibility,
|
options.accessibility,
|
||||||
KeychainAccessibility.first_unlock_this_device,
|
KeychainAccessibility.unlocked_this_device,
|
||||||
);
|
);
|
||||||
expect(options.synchronizable, isFalse);
|
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']);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue