fix(auth): a new recovery phrase takes the password too

password.setRecoveryKeys took a session and a TOTP code alone, so a phone
signed in with its authenticator beside it could set a phrase of its
holder's own and, through the phrase's reset, a new password, never
knowing the old one. It now takes the old password's SRP proof (from
beginChange, the caller's own) and a TOTP or recovery code, as changing
the password does: one owner proof for both (_proveOwner), forwarded by
members, asked for in the account section's New recovery phrase.

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-20 20:01:07 +02:00
commit 5fb28bd817
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -316,77 +316,98 @@ void main() {
});
group('the recovery phrase', () {
test("takes new wrapped keys for a code: the old phrase's wrapping "
'goes', () async {
final fresh = WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(wrappedKeysLength),
);
final fresh = WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(wrappedKeysLength),
);
await atNow(
() => endpoints.password.setRecoveryKeys(
onPhone(),
code: codeOf(enrolment, at: now),
recoveryKeys: fresh,
),
/// Gives Anna's keys a new phrase's wrapping ([fresh]) from her
/// phone, the old password proved by [privateValue] (hers by
/// default) or a made-up proof ([withoutProof]), with [totpCode] (her
/// authenticator's now by default).
Future<void> newPhrase({
BigInt? privateValue,
bool withoutProof = false,
String? totpCode,
bool withoutCode = false,
WrappedKeys? recoveryKeys,
TestSessionBuilder? as,
}) => atNow(() async {
final challenge = await endpoints.password.beginChange(onPhone());
final answer = device.answer(challenge, privateValue: privateValue);
await endpoints.password.setRecoveryKeys(
as ?? onPhone(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: withoutProof ? ByteData(32) : answer.clientProof,
totpCode: withoutCode
? null
: totpCode ?? codeOf(enrolment, at: now),
recoveryKeys: recoveryKeys ?? fresh,
);
final proof = await endpoints.accountRecovery.beginKeyProof(
notSignedIn(),
address: 'anna@example.org',
);
expect(proof.recoveryKeys.toJson(), fresh.toJson());
});
test('is refused for a wrong code, keys out of range, and without a '
'password, keeping the wrapping', () async {
final fresh = WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(wrappedKeysLength),
Future<WrappedKeys> wrapping() async =>
(await endpoints.accountRecovery.beginKeyProof(
notSignedIn(),
address: 'anna@example.org',
)).recoveryKeys;
test('takes new wrapped keys with the old password and a code: the '
"old phrase's wrapping goes", () async {
await newPhrase();
expect((await wrapping()).toJson(), fresh.toJson());
});
test("is refused alike without the password's proof, for a wrong "
"one, another person's, and a missing or wrong code, keeping "
'the wrapping', () async {
// A session and a code alone: whoever holds a phone signed in with
// its authenticator would otherwise set a phrase of their own, and
// with it a new password.
await expectLater(
newPhrase(withoutProof: true),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
atNow(
() => endpoints.password.setRecoveryKeys(
onPhone(),
code: '000000',
recoveryKeys: fresh,
newPhrase(privateValue: BigInt.from(4242)),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
newPhrase(withoutCode: true),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
newPhrase(totpCode: '000000'),
refused(PasswordRefusal.wrongCredentials),
);
final ben = await newAnonymousPerson(session);
final bensLaptop = await signInNewDevice(session, ben);
await expectLater(
newPhrase(
as: sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
ben.uuid,
{identified},
authId: bensLaptop.uuid,
),
),
),
refused(PasswordRefusal.wrongCode),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
atNow(
() => endpoints.password.setRecoveryKeys(
onPhone(),
code: codeOf(enrolment, at: now),
recoveryKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(8),
),
newPhrase(
recoveryKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(8),
),
),
refused(PasswordRefusal.invalidSetup),
);
final ben = await newAnonymousPerson(session);
await expectLater(
endpoints.password.setRecoveryKeys(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
ben.uuid,
{},
),
),
code: '123456',
recoveryKeys: fresh,
),
refused(PasswordRefusal.noPassword),
);
final proof = await endpoints.accountRecovery.beginKeyProof(
notSignedIn(),
address: 'anna@example.org',
);
expect(
proof.recoveryKeys.toJson(),
(await wrapping()).toJson(),
device.recoveryKeys.toJson(),
);
});