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:
parent
b9bf9813c5
commit
0c9bc3e007
6 changed files with 120 additions and 25 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) &&
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue