fix(app): the short reading's nits on the device

- Deleting an identified account whose security can't be read (the
  instance out of reach) says so and deletes nothing; it went on to a
  deletion by the session alone, refused for an account with a password,
  and the person saw a spinner, then nothing. A refusal still leaves the
  session's own deletion.
- InstanceChoice's sharesStorage is required, so no app leaves it out and
  removes a folder another instance uses.
- A PasswordUnlock forgotten is spent: its secrets read again fail
  (StateError) rather than make a proof or a wrapping of zeros.

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:15:14 +02:00
commit 012f2375a5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 106 additions and 21 deletions

View file

@ -132,13 +132,23 @@ class const AccountTiles({
try {
security = identified ? await passwords.security() : null;
} on Object catch (error) {
// Unreachable, or anything else (the reading of the fixes: a failure
// that isn't a refusal escaped, and the tap did nothing): deleting
// goes on, and fails as the session bloc says if it must.
if (error is PasswordFailed && error.problem != PasswordProblem.failed) {
// The instance refused to say (no passwords offered there): the
// session's own deletion, which the instance checks.
session.add(const SessionEventDeleteRequested());
return;
}
// Out of reach, or anything else: said so, nothing deleted (the short
// reading: a deletion by the session alone, refused for an account
// with a password, showed a spinner, then nothing).
if (error is! PasswordFailed) {
log('Reading the account failed: ${error.runtimeType}', name: 'Auth');
}
session.add(const SessionEventDeleteRequested());
if (context.mounted) {
ScaffoldMessenger.maybeOf(
context,
)?.showSnackBar(SnackBar(content: Text(l10n.passwordFailed)));
}
return;
}
if (security == null || !security.hasPassword) {

View file

@ -25,7 +25,9 @@ class InstanceChoice(
/// Whether the device keeps two instances in one place
/// (`InstanceStorage.sharesWith`): such addresses are one account here.
final bool Function(Instance a, Instance b) sharesStorage = _keptApart,
/// Required, so no app forgets it and removes a folder another instance
/// uses (the short reading).
required final bool Function(Instance a, Instance b) sharesStorage,
}) extends ChangeNotifier {
Instance? _current;
var _known = const <Instance>[];
@ -112,5 +114,3 @@ class InstanceChoice(
notifyListeners();
}
}
bool _keptApart(Instance a, Instance b) => false;

View file

@ -7,24 +7,43 @@ import 'package:halkyon_base_client/srp.dart' as srp;
/// What a password unlocks on the device (ADR 0017), for one account's salt
/// and costs: SRP's private value and the key that wraps the account's
/// private keys, kept only while a sign-in, reset or change needs them.
final class const PasswordUnlock({
final class PasswordUnlock({
/// The salt and costs they were derived with.
required final Uint8List salt,
required final protocol.PasswordCosts costs,
/// SRP's x, 32 bytes, big-endian.
required final Uint8List srpSecret,
/// SRP's x, 32 bytes, big-endian ([srpSecret]).
required final Uint8List _srpSecret,
/// The key-encryption key, 32 bytes.
required final Uint8List keyEncryptionKey,
/// The key-encryption key, 32 bytes ([keyEncryptionKey]).
required final Uint8List _keyEncryptionKey,
}) {
var _forgotten = false;
/// SRP's x, 32 bytes, big-endian; a [StateError] once [forget] wiped it.
Uint8List get srpSecret => _unlessForgotten(_srpSecret);
/// The key-encryption key, 32 bytes; a [StateError] once [forget] wiped
/// it.
Uint8List get keyEncryptionKey => _unlessForgotten(_keyEncryptionKey);
/// Whether [forget] wiped them.
bool get isForgotten => _forgotten;
/// Zeroes [srpSecret] and [keyEncryptionKey] once nothing needs them (the
/// reading of the fixes): Dart's own bytes, which no collector wipes.
/// reading of the fixes): Dart's own bytes, which no collector wipes. The
/// unlock is spent then (the short reading): read again, they fail
/// rather than make a proof or a wrapping of zeros.
void forget() {
srpSecret.fillRange(0, srpSecret.length, 0);
keyEncryptionKey.fillRange(0, keyEncryptionKey.length, 0);
_srpSecret.fillRange(0, _srpSecret.length, 0);
_keyEncryptionKey.fillRange(0, _keyEncryptionKey.length, 0);
_forgotten = true;
}
Uint8List _unlessForgotten(Uint8List secret) => _forgotten
? throw StateError('What the password unlocked was forgotten')
: secret;
/// Whether these were derived for [salt] and [costs].
bool fits(Uint8List salt, protocol.PasswordCosts costs) =>
_same(this.salt, salt) &&