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:
parent
4f7b123ad4
commit
5fb28bd817
8 changed files with 447 additions and 169 deletions
|
|
@ -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'],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue