fix(app): what a password unlocked is wiped once done with

The reading of the full reading's fixes noted that the main isolate's
copies of SRP's x and the key-encryption key, Dart's own bytes, stayed
for as long as the sign-in sheet's cubit lived, and after every change's
proof.

PasswordUnlock.forget zeroes them: the sign-in cubit wipes what the
password unlocked once signed in, once another password replaced it, and
as it closes; a change's or deletion's proof wipes what it unlocked once
its answer is made. The fake crypto's proof copies its bytes, as a real
one is new bytes. halkyon_keys' README says what is wiped and what isn't.

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:48:48 +02:00
commit 0c9bc3e007
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 120 additions and 25 deletions

View file

@ -124,7 +124,9 @@ class PasswordSignInCubit(
/// until the code.
Future<void> credentialsEntered(String address, String password) =>
_run(() async {
final previous = _unlocked;
_unlocked = await _passwords.unlock(address, password);
previous?.forget();
emit(
state.copyWith(
step: PasswordSignInStep.secondFactor,
@ -344,11 +346,22 @@ class PasswordSignInCubit(
Future<void> _putInPlace() async {
await _passwords.enter(_pending!);
// Done with what the password unlocked (the reading of the fixes).
_unlocked?.forget();
_unlocked = null;
if (!isClosed) {
emit(state.copyWith(step: PasswordSignInStep.done, clearProblem: true));
}
}
/// Wipes what the password unlocked, whatever step it closes on.
@override
Future<void> close() {
_unlocked?.forget();
_unlocked = null;
return super.close();
}
/// Runs [step], busy meanwhile; a refusal is shown on the step it came
/// from.
Future<void> _run(Future<void> Function() step) async {

View file

@ -18,6 +18,13 @@ final class const PasswordUnlock({
/// The key-encryption key, 32 bytes.
required final Uint8List keyEncryptionKey,
}) {
/// Zeroes [srpSecret] and [keyEncryptionKey] once nothing needs them (the
/// reading of the fixes): Dart's own bytes, which no collector wipes.
void forget() {
srpSecret.fillRange(0, srpSecret.length, 0);
keyEncryptionKey.fillRange(0, keyEncryptionKey.length, 0);
}
/// Whether these were derived for [salt] and [costs].
bool fits(Uint8List salt, protocol.PasswordCosts costs) =>
_same(this.salt, salt) &&

View file

@ -29,7 +29,10 @@ final class PasswordUnlocked._(
final PasswordUnlock _unlock,
var protocol.PasswordChallenge? _challenge,
var DateTime _begunAt,
);
) {
/// Wipes what the password unlocked, once the sign-in is done with it.
void forget() => _unlock.forget();
}
/// A session granted but not yet this device's: a sign-in or sign-up,
/// held back while an authenticator is set up with it (`needsAuthenticator`),
@ -382,7 +385,8 @@ class PasswordRepository(
}) async {
final challenge = await _gateway.beginChange(as: as);
final salt = Uint8List.sublistView(challenge.salt);
final unlock = switch (unlocked?._unlock) {
final kept = unlocked?._unlock;
final unlock = switch (kept) {
final kept? when kept.fits(salt, challenge.costs) => kept,
_ when password != null => await _crypto.unlock(
password,
@ -392,18 +396,25 @@ class PasswordRepository(
// Its password changed since: begin again.
_ => throw const PasswordFailed(PasswordProblem.challengeExpired),
};
return (
challenge: challenge,
answer: await _crypto.answer(
unlock,
challenge,
instanceUrl: _gateway.instanceUrl,
purpose: purpose,
digest: digest,
),
totpCode: totpCode?.replaceAll(' ', ''),
recoveryCode: recoveryCode,
);
// One unlocked here is wiped once its answer is made; one kept by a
// sign-in under way is its to wipe.
final ownUnlock = identical(unlock, kept) ? null : unlock;
try {
return (
challenge: challenge,
answer: await _crypto.answer(
unlock,
challenge,
instanceUrl: _gateway.instanceUrl,
purpose: purpose,
digest: digest,
),
totpCode: totpCode?.replaceAll(' ', ''),
recoveryCode: recoveryCode,
);
} finally {
ownUnlock?.forget();
}
}
/// What keeps the signed-in person's account.
@ -498,14 +509,20 @@ class PasswordRepository(
salt: Uint8List.sublistView(challenge.salt),
costs: challenge.costs,
);
await _gateway.deleteAccount(
challenge,
await _crypto.answer(
final SrpAnswer answer;
try {
answer = await _crypto.answer(
unlock,
challenge,
instanceUrl: _gateway.instanceUrl,
purpose: srp.PasswordProofPurpose.deleteAccount,
),
);
} finally {
unlock.forget();
}
await _gateway.deleteAccount(
challenge,
answer,
totpCode: totpCode?.replaceAll(' ', ''),
recoveryCode: recoveryCode,
request: request,

View file

@ -26,6 +26,10 @@ const otherFakePhrase =
class FakeAccountCrypto() implements AccountCrypto {
var _accounts = 0;
/// What each password unlocked, as [unlock] made it, in order: tests see
/// it wiped once done with.
final unlocks = <PasswordUnlock>[];
/// The keys of a new account, numbered so each differs.
UnlockedAccountKeys keysNumbered(int number) => UnlockedAccountKeys(
encryptionPublicKey: Uint8List.fromList(List.filled(32, number)),
@ -143,12 +147,14 @@ class FakeAccountCrypto() implements AccountCrypto {
required protocol.PasswordCosts costs,
}) async {
_check(password);
return PasswordUnlock(
final unlock = PasswordUnlock(
salt: salt,
costs: costs,
srpSecret: Uint8List.fromList(utf8.encode(password)),
keyEncryptionKey: Uint8List.fromList(utf8.encode(password)),
);
unlocks.add(unlock);
return unlock;
}
@override
@ -160,7 +166,8 @@ class FakeAccountCrypto() implements AccountCrypto {
List<int> digest = const [],
}) async => SrpAnswer(
clientValue: ByteData.sublistView(bindingOf(purpose, digest)),
clientProof: ByteData.sublistView(unlock.srpSecret),
// A copy, as a real proof is new bytes: the unlock is wiped after.
clientProof: ByteData.sublistView(Uint8List.fromList(unlock.srpSecret)),
verifies: (serverProof) =>
utf8.decode(Uint8List.sublistView(serverProof)) == 'M2',
);

View file

@ -53,6 +53,31 @@ void main() {
finished: finished,
);
bool wiped(PasswordUnlock unlock) =>
unlock.srpSecret.every((byte) => byte == 0) &&
unlock.keyEncryptionKey.every((byte) => byte == 0);
test(
'wipes what the password unlocked once signed in, once another '
'password replaced it, and as it closes (the reading of the fixes)',
() async {
anna();
await cubit.credentialsEntered('anna@example.org', 'wrong horse!');
await cubit.credentialsEntered('anna@example.org', 'correct horse');
expect(wiped(crypto.unlocks.first), isTrue, reason: 'replaced');
expect(wiped(crypto.unlocks.last), isFalse, reason: 'a code to come');
await cubit.codeEntered(fakeTotpCode);
expect(cubit.state.step, PasswordSignInStep.done);
expect(crypto.unlocks.every(wiped), isTrue);
final other = PasswordSignInCubit(passwords, language: 'en');
await other.credentialsEntered('anna@example.org', 'correct horse');
await other.close();
expect(crypto.unlocks.every(wiped), isTrue, reason: 'closed');
},
);
test('signs in with the address, the password and a code: the code asked '
'for once the password is unlocked, signed in only at the end', () async {
final account = anna();

View file

@ -0,0 +1,24 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
void main() {
test("forgetting what a password unlocked zeroes SRP's x and the "
'key-encryption key (the reading of the fixes)', () {
final unlock = PasswordUnlock(
salt: Uint8List(16),
costs: protocol.PasswordCosts(
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 64 * 1024 * 1024,
),
srpSecret: Uint8List.fromList(List.filled(32, 7)),
keyEncryptionKey: Uint8List.fromList(List.filled(32, 9)),
)..forget();
expect(unlock.srpSecret, everyElement(0));
expect(unlock.keyEncryptionKey, everyElement(0));
});
}