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
4 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',
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue