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
13 changed files with 447 additions and 169 deletions

View file

@ -15,7 +15,7 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
| Endpoint | Methods | Who may call |
|---|---|---|
| `anonymousIdp` | `login` (Serverpod's anonymous sign-in, at most 30 new accounts per network and hour) | anyone |
| `password` | `set`: the caller's password, with the address a mailed code proves unless they have one; `beginChange`, `change`: a new password with the old one's proof and a code, every other session ending; `setRecoveryKeys`: a new recovery phrase's wrapping, for a code | signed in |
| `password` | `set`: the caller's password, with the address a mailed code proves unless they have one; `beginChange`, `change`: a new password with the old one's proof and a code, every other session ending; `setRecoveryKeys`: a new recovery phrase's wrapping, with the old password's proof and a code | signed in |
| `passwordSignIn` | `begin`, `complete`: SRP-6a's two steps with a code from an authenticator (or a recovery code; a setup never finished, a code by mail); a session. `signUp`: an account at the address a mailed code proves, with a password; a session | without a session |
| `authenticator` | `enrol`, `confirm` (the first brings ten recovery codes; another takes a code from one confirmed before), `list`, `remove`, `newRecoveryCodes` | signed in |
| `accountRecovery` | `resetAuthenticator`, `beginKeyProof`, `resetPassword` (with the recovery phrase), `startAccountReset`, `completeAccountReset` | without a session |
@ -216,8 +216,9 @@ Built on the three parts above, on the register (members forward every step thro
password, new keys, no authenticators.
- **Changing while signed in:** `password.beginChange` and `password.change` take the old
password's SRP proof, a TOTP or recovery code and the same keys wrapped under the new
password; `password.setRecoveryKeys` replaces the phrase's wrapping for a code. A
session alone changes neither.
password; `password.setRecoveryKeys` replaces the phrase's wrapping with the same
proof and code (a phrase of one's own would otherwise set a new password through the
phrase's reset). A session alone changes neither, nor a session and a code.
- **Sessions end everywhere:** a change of password ends every other session of the
account; a reset of the password or the account ends all. The register ends its own
and keeps a `sessions_ended_notice` per member (told at once and hourly until it

View file

@ -729,11 +729,31 @@ class Endpoints extends _is.EndpointDispatch {
'setRecoveryKeys': _is.MethodConnector(
name: 'setRecoveryKeys',
params: {
'code': _is.ParameterDescription(
name: 'code',
type: _is.getType<String>(),
'challenge': _is.ParameterDescription(
name: 'challenge',
type: _is.getType<_is.UuidValue>(),
nullable: false,
),
'clientValue': _is.ParameterDescription(
name: 'clientValue',
type: _is.getType<_idt.ByteData>(),
nullable: false,
),
'clientProof': _is.ParameterDescription(
name: 'clientProof',
type: _is.getType<_idt.ByteData>(),
nullable: false,
),
'totpCode': _is.ParameterDescription(
name: 'totpCode',
type: _is.getType<String?>(),
nullable: true,
),
'recoveryCode': _is.ParameterDescription(
name: 'recoveryCode',
type: _is.getType<String?>(),
nullable: true,
),
'recoveryKeys': _is.ParameterDescription(
name: 'recoveryKeys',
type: _is.getType<_iji0wmu8.WrappedKeys>(),
@ -747,7 +767,11 @@ class Endpoints extends _is.EndpointDispatch {
) async => (endpoints['password'] as _ig7fyg1b.PasswordEndpoint)
.setRecoveryKeys(
session,
code: params['code'],
challenge: params['challenge'],
clientValue: params['clientValue'],
clientProof: params['clientProof'],
totpCode: params['totpCode'],
recoveryCode: params['recoveryCode'],
recoveryKeys: params['recoveryKeys'],
),
),

View file

@ -272,7 +272,7 @@ Future<void> removeAuthenticator(
if (confirmed.length < 2) {
throw refusedFor(PasswordRefusal.lastAuthenticator);
}
await requireAuthenticatorCode(session, keys, person, confirmed, code);
await _requireAuthenticatorCode(session, keys, person, confirmed, code);
// Serializable: two removals at once must not take both of the last two.
await session.db.transaction(
(transaction) async {
@ -318,7 +318,7 @@ Future<List<String>> newRecoveryCodes(
if (confirmed.isEmpty) {
throw refusedFor(PasswordRefusal.setupIncomplete);
}
await requireAuthenticatorCode(session, keys, person, confirmed, code);
await _requireAuthenticatorCode(session, keys, person, confirmed, code);
return await session.db.transaction(
(transaction) =>
_replaceRecoveryCodes(session, keys, person, transaction: transaction),
@ -360,7 +360,7 @@ Future<void> _countTry(Session session, UuidValue person) async {
/// authenticators, each try counted (10 an hour,
/// [PasswordRefusal.tooManyAttempts]); refused otherwise
/// ([PasswordRefusal.wrongCode]).
Future<void> requireAuthenticatorCode(
Future<void> _requireAuthenticatorCode(
Session session,
PasswordKeys keys,
UuidValue person,

View file

@ -2,16 +2,18 @@ import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/authenticators.dart';
import 'package:halkyon_base_server/src/password/password_config.dart';
import 'package:halkyon_base_server/src/password/passwords.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
import 'package:serverpod/serverpod.dart';
// Changing what keeps an account while signed in (ADR 0017), on the
// register: the password, with the old password's proof and a code from an
// authenticator, and the recovery phrase's wrapping of the keys, with a code.
// A session alone changes neither: whoever took one over would otherwise
// lock the owner out of their keys.
// register: the password, and the recovery phrase's wrapping of the keys,
// each with the old password's proof and a code from an authenticator (or a
// recovery code). A session alone changes neither, nor a session with the
// authenticator beside it (a phone signed in): a phrase of the holder's own
// would set a new password through `resetPasswordWithPhrase`, so the phrase
// takes the password too.
/// Changes [person]'s password to [password] (the same keys, wrapped under
/// the new password's key on the device): the old password's proof for
@ -41,32 +43,16 @@ Future<void> changePassword(
if (!isNewPassword(password)) {
throw refusedFor(PasswordRefusal.invalidSetup);
}
final proved = await provePassword(
final row = await _proveOwner(
session,
keys,
person: person,
challenge: challenge,
clientValue: clientValue,
clientProof: clientProof,
);
final row = proved.password;
if (row.authUserId != person) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
if ((totpCode?.trim() ?? '').isEmpty &&
(recoveryCode?.trim() ?? '').isEmpty) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
// With a code: the code is checked, or, where the authenticators' reset
// took effect and none is confirmed, it says so (true).
if (await secondFactor(
session,
keys,
person,
totpCode: totpCode,
recoveryCode: recoveryCode,
)) {
throw refusedFor(PasswordRefusal.setupIncomplete);
}
);
await session.db.transaction((transaction) async {
// UPDATE "account_password" SET "salt" = $1, "kdfAlgorithm" = $2,
// "kdfOpsLimit" = $3, "kdfMemLimit" = $4, "verifier" = $5,
@ -98,38 +84,37 @@ Future<void> changePassword(
/// Replaces the wrapping of [person]'s keys under their recovery phrase
/// with [recoveryKeys] (a new phrase, made on the device, which holds the
/// keys), for a [code] from one of their authenticators: the old phrase opens
/// nothing any more.
/// keys): the old password's proof for [challenge] (`beginPasswordChange`)
/// and a [totpCode] or [recoveryCode] from the account, as changing the
/// password takes. The old phrase opens nothing any more.
///
/// Refused ([PasswordRefusedException]) for wrapped keys out of range
/// ([PasswordRefusal.invalidSetup]), without a password
/// ([PasswordRefusal.noPassword]) or a confirmed authenticator
/// ([PasswordRefusal.setupIncomplete]), and for a wrong code
/// ([PasswordRefusal.wrongCode]; 10 tries an hour,
/// [PasswordRefusal.tooManyAttempts]). Nothing changes then.
/// ([PasswordRefusal.invalidSetup]), and otherwise as [changePassword].
/// Nothing changes then.
Future<void> setRecoveryKeys(
Session session, {
required UuidValue person,
required String code,
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
required WrappedKeys recoveryKeys,
}) async {
final keys = requirePasswordKeys(session.serverpod);
if (!isWrappedKeys(recoveryKeys)) {
throw refusedFor(PasswordRefusal.invalidSetup);
}
// SELECT * FROM "account_password" WHERE "authUserId" = $1
final row = await AccountPassword.db.findFirstRow(
final row = await _proveOwner(
session,
where: (t) => t.authUserId.equals(person),
keys,
person: person,
challenge: challenge,
clientValue: clientValue,
clientProof: clientProof,
totpCode: totpCode,
recoveryCode: recoveryCode,
);
if (row == null) {
throw refusedFor(PasswordRefusal.noPassword);
}
final confirmed = await confirmedAuthenticatorsOf(session, person);
if (confirmed.isEmpty) {
throw refusedFor(PasswordRefusal.setupIncomplete);
}
await requireAuthenticatorCode(session, keys, person, confirmed, code);
// UPDATE "account_password" SET "recoveryKeysNonce" = $1,
// "recoveryKeysCipherText" = $2, "updatedAt" = $3 WHERE "id" = $4
await AccountPassword.db.updateRow(
@ -141,3 +126,51 @@ Future<void> setRecoveryKeys(
),
);
}
/// [person]'s password row, once they proved owning the account for a
/// change: the old password's proof for [challenge], [person]'s own, and a
/// [totpCode] or [recoveryCode] ([secondFactor], which also calls off a reset
/// waiting). Refused ([PasswordRefusedException]) for a wrong password, a
/// challenge that isn't [person]'s, or a wrong, used or missing code, alike
/// ([PasswordRefusal.wrongCredentials]); a challenge unknown, used or too old
/// ([PasswordRefusal.challengeExpired]); and, once the password proved right,
/// for an account without a confirmed authenticator
/// ([PasswordRefusal.setupIncomplete]).
Future<AccountPassword> _proveOwner(
Session session,
PasswordKeys keys, {
required UuidValue person,
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
}) async {
final proved = await provePassword(
session,
keys,
challenge: challenge,
clientValue: clientValue,
clientProof: clientProof,
);
final row = proved.password;
if (row.authUserId != person) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
if ((totpCode?.trim() ?? '').isEmpty &&
(recoveryCode?.trim() ?? '').isEmpty) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
// With a code: the code is checked, or, where the authenticators' reset
// took effect and none is confirmed, it says so (true).
if (await secondFactor(
session,
keys,
person,
totpCode: totpCode,
recoveryCode: recoveryCode,
)) {
throw refusedFor(PasswordRefusal.setupIncomplete);
}
return row;
}

View file

@ -79,16 +79,25 @@ class PasswordEndpoint extends Endpoint {
}
/// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under
/// it on the device, replace the old wrapping, for a [code] from one of
/// their authenticators. Refusals as `setRecoveryKeys`'.
/// it on the device, replace the old wrapping, with the password's proof
/// for [challenge] ([beginChange]) and a [totpCode] or [recoveryCode], as
/// [change] takes. Refusals as `setRecoveryKeys`'.
Future<void> setRecoveryKeys(
Session session, {
required String code,
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
required WrappedKeys recoveryKeys,
}) => AccountRegister.of(session.serverpod).setRecoveryKeys(
session,
person: session.authenticated!.authUserId,
code: code,
challenge: challenge,
clientValue: clientValue,
clientProof: clientProof,
totpCode: totpCode,
recoveryCode: recoveryCode,
recoveryKeys: recoveryKeys,
);
}

View file

@ -300,12 +300,16 @@ abstract interface class AccountRegister {
UuidValue? keepSession,
});
/// Replaces the wrapping of [person]'s keys under their recovery phrase.
/// Refusals as `setRecoveryKeys`'.
/// Replaces the wrapping of [person]'s keys under their recovery phrase,
/// with the password's proof and a code. Refusals as `setRecoveryKeys`'.
Future<void> setRecoveryKeys(
Session session, {
required UuidValue person,
required String code,
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
required WrappedKeys recoveryKeys,
});

View file

@ -360,7 +360,11 @@ Future<Map<String, Object?>?> _answer(
await register.setRecoveryKeys(
session,
person: arguments.id('person'),
code: arguments.string('code'),
challenge: arguments.id('challenge'),
clientValue: arguments.bytes('clientValue'),
clientProof: arguments.bytes('clientProof'),
totpCode: arguments.optionalString('totpCode'),
recoveryCode: arguments.optionalString('recoveryCode'),
recoveryKeys: arguments.model('recoveryKeys', WrappedKeys.fromJson),
);
return const {};

View file

@ -620,12 +620,20 @@ final class LocalRegister implements AccountRegister {
Future<void> setRecoveryKeys(
Session session, {
required UuidValue person,
required String code,
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
required WrappedKeys recoveryKeys,
}) => change.setRecoveryKeys(
session,
person: person,
code: code,
challenge: challenge,
clientValue: clientValue,
clientProof: clientProof,
totpCode: totpCode,
recoveryCode: recoveryCode,
recoveryKeys: recoveryKeys,
);

View file

@ -584,11 +584,19 @@ final class RemoteRegister implements AccountRegister {
Future<void> setRecoveryKeys(
Session session, {
required UuidValue person,
required String code,
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
required WrappedKeys recoveryKeys,
}) => _ask(RegisterOperation.setRecoveryKeys, {
'person': person.uuid,
'code': code,
'challenge': challenge.uuid,
'clientValue': _bytes(clientValue),
'clientProof': _bytes(clientProof),
'totpCode': totpCode,
'recoveryCode': recoveryCode,
'recoveryKeys': recoveryKeys.toJson(),
});

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(),
);
});

View file

@ -407,7 +407,11 @@ final class _Register implements AccountRegister {
Future<void> setRecoveryKeys(
Session session, {
required UuidValue person,
required String code,
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
required WrappedKeys recoveryKeys,
}) => _answer('setRecoveryKeys', () {});

View file

@ -740,20 +740,28 @@ void main() {
nonce: ByteData(24),
cipherText: ByteData(wrappedKeysLength),
);
await remote.setRecoveryKeys(
session,
person: annaOnTodo,
code: codeOf(enrolment),
recoveryKeys: fresh,
);
await expectLater(
remote.setRecoveryKeys(
Future<void> newPhrase() async {
final challenge = await remote.beginPasswordChange(
session,
person: annaOnTodo,
code: codeOf(enrolment),
);
final answer = device.answer(challenge);
await remote.setRecoveryKeys(
session,
person: annaOnTodo,
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(enrolment),
recoveryKeys: fresh,
),
refused(PasswordRefusal.wrongCode),
);
}
await newPhrase();
// The same step's code again.
await expectLater(
newPhrase(),
refused(PasswordRefusal.wrongCredentials),
);
final start = await remote.sendSignInCode(

View file

@ -1065,7 +1065,11 @@ class _PasswordEndpoint {
_ida.Future<void> setRecoveryKeys(
_ist.TestSessionBuilder sessionBuilder, {
required String code,
required _is.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
String? totpCode,
String? recoveryCode,
required _iji0wmu8.WrappedKeys recoveryKeys,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
@ -1080,7 +1084,11 @@ class _PasswordEndpoint {
endpointPath: 'password',
methodName: 'setRecoveryKeys',
parameters: _ist.testObjectToJson({
'code': code,
'challenge': challenge,
'clientValue': clientValue,
'clientProof': clientProof,
'totpCode': totpCode,
'recoveryCode': recoveryCode,
'recoveryKeys': recoveryKeys,
}),
serializationManager: _serializationManager,