fix(app): keys an earlier build kept go too, and a locked keyring stops nothing

On Apple's systems a keychain read finds only the entries of its own
accessibility, so forgetting everyone's keys but one person's missed those
builds before had kept with the default or first_unlock_this_device: they
stayed after signing out, a switch of person or removing the instance (the
short reading's L5). forgetAllBut now also reads the keyring with every
accessibility a build used and deletes each entry found by name, which a
delete finds whatever its accessibility; one that can't be read now holds
back none of the others. The repository's forgetting, run as the person
changes and not awaited, catches a keyring that can't be read (a locked
device), where its error went unhandled.

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 18:06:32 +02:00
commit efc1c7ffd6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 173 additions and 11 deletions

View file

@ -1,6 +1,8 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:halkyon_app/src/password/model/unlocked_account_keys.dart';
@ -22,6 +24,23 @@ const accountKeyStorage = FlutterSecureStorage(
),
);
/// The keyring as builds before kept the account's keys in it on Apple's
/// systems, with other accessibilities: the default (`unlocked`), then
/// `first_unlock_this_device`. There a read finds only the entries of its
/// own accessibility, so those are found through these, and deleted by
/// name ([SecureAccountKeyStore.forgetAllBut]).
const formerAccountKeyStorages = [
FlutterSecureStorage(),
FlutterSecureStorage(
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
mOptions: MacOsOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
),
];
/// Where the device keeps the account's private keys once unlocked (ADR
/// 0017): in the system's keyring, as sessions are, one person at a time
/// per instance, gone once they sign out or another person signs in. Tests
@ -44,7 +63,16 @@ abstract interface class AccountKeyStore() {
class SecureAccountKeyStore(
final String _prefix, {
final FlutterSecureStorage _storage = accountKeyStorage,
/// The keyring as builds before kept keys in it; by default
/// [formerAccountKeyStorages] on Apple's systems, none elsewhere, where a
/// read finds every entry anyway.
final List<FlutterSecureStorage>? formerly,
}) implements AccountKeyStore {
List<FlutterSecureStorage> get _formerly =>
formerly ??
(Platform.isIOS || Platform.isMacOS ? formerAccountKeyStorages : []);
String _keyOf(String person) => '$_prefix:$person';
/// Deletes what the keyring held for [person] first: on Apple's systems
@ -78,10 +106,24 @@ class SecureAccountKeyStore(
}
}
/// Finds the entries kept with every accessibility a build ever used and
/// deletes each by name, which finds it whatever its accessibility (the
/// short reading's L5): read as kept now alone, those an earlier build
/// kept stayed after signing out, a switch of person or removing the
/// instance. An accessibility that can't be read now (an earlier one
/// while the device is locked) holds back none of the others; one that
/// fails as kept now fails it, for the caller to know.
@override
Future<void> forgetAllBut(String? person) async {
final kept = await _storage.readAll();
for (final key in kept.keys) {
final found = {...(await _storage.readAll()).keys};
for (final storage in _formerly) {
try {
found.addAll((await storage.readAll()).keys);
} on PlatformException catch (error) {
log('Keys kept before unreadable now: ${error.code}', name: 'Keys');
}
}
for (final key in found) {
if (key.startsWith('$_prefix:') &&
(person == null || key != _keyOf(person))) {
await _storage.delete(key: key);

View file

@ -106,13 +106,22 @@ class PasswordRepository(
/// minutes; a little less leaves room for the trip.
static const _challengeFor = Duration(seconds: 90);
/// Forgets every person's keys but [status]'s.
void _keepOnlyKeysOf(AuthStatus status) => unawaited(
_keys.forgetAllBut(switch (status) {
SignedIn(:final authUserId) => authUserId,
SignedOut() => null,
}),
);
/// Forgets every person's keys but [status]'s; a keyring that can't be
/// read now (a locked device) is left as it is, and tried again at the
/// next change of person (the short reading's L5).
void _keepOnlyKeysOf(AuthStatus status) => unawaited(_forgetAllBut(status));
Future<void> _forgetAllBut(AuthStatus status) async {
try {
await _keys.forgetAllBut(switch (status) {
SignedIn(:final authUserId) => authUserId,
SignedOut() => null,
});
} on Object catch (error) {
// Its type only: never what the keyring held.
log('Forgetting keys failed: ${error.runtimeType}', name: 'Keys');
}
}
/// Stops following who is signed in; the owner calls it as it closes.
Future<void> dispose() async {