fix(auth)!: a session alone never gets a code checked

The third reading found a stolen session guessing TOTP codes without end:
nine wrong codes for new recovery codes, then removing an unconfirmed
authenticator of its own, a success that cleared the session's count. As
Proton has it, every signed-in change of how an account is secured now
takes the owner's proof (OwnerProof): the password's SRP proof for a
change's challenge and a TOTP or recovery code. Adding an authenticator
once one is confirmed, removing a confirmed one, new recovery codes, a new
phrase, a new password and deleting take it; only the first authenticator
is confirmed with its own code alone, and removing an unconfirmed one
takes nothing. A proof given where none is taken is refused, and a
confirmed authenticator's code is never checked for a session alone, so
no success passes a proof unchecked.

A session's proofs count for it alone, before they are checked, cleared
only by one accepted; the tenth wrong in a row ends the session. They
never count toward the account's waits nor wait for them, so whoever
knows the password no longer holds a signed-in owner out of changing it
(the second finding), and a new password forgets those waits. Beginning a
change counts nothing against the account (the third finding): 30 an
hour per session, holding back that session alone. Deleting takes a TOTP
code wherever an authenticator is confirmed, a code by mail where no
second factor works (the fourth finding), which SignInWays now tells
(secondFactorWorks). Signed-in calls no longer take the address: nothing
there mails. wrongExistingCode goes, a wrong existing code being a wrong
proof.

The app follows in the next commit.

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-21 10:33:25 +02:00
commit a9459bb875
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
36 changed files with 1461 additions and 1026 deletions

View file

@ -21,9 +21,11 @@ abstract class SignInWays
bool? password, bool? password,
bool? authenticator, bool? authenticator,
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks,
}) : password = password ?? false, }) : password = password ?? false,
authenticator = authenticator ?? false, authenticator = authenticator ?? false,
passwordOnly = passwordOnly ?? false; passwordOnly = passwordOnly ?? false,
secondFactorWorks = secondFactorWorks ?? false;
factory SignInWays({ factory SignInWays({
required bool gitHub, required bool gitHub,
@ -31,6 +33,7 @@ abstract class SignInWays
bool? password, bool? password,
bool? authenticator, bool? authenticator,
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks,
}) = _SignInWaysImpl; }) = _SignInWaysImpl;
factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) { factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) {
@ -46,6 +49,11 @@ abstract class SignInWays
passwordOnly: jsonSerialization['passwordOnly'] == null passwordOnly: jsonSerialization['passwordOnly'] == null
? null ? null
: _isc.BoolJsonExtension.fromJson(jsonSerialization['passwordOnly']), : _isc.BoolJsonExtension.fromJson(jsonSerialization['passwordOnly']),
secondFactorWorks: jsonSerialization['secondFactorWorks'] == null
? null
: _isc.BoolJsonExtension.fromJson(
jsonSerialization['secondFactorWorks'],
),
); );
} }
@ -64,6 +72,11 @@ abstract class SignInWays
/// neither a code by mail nor GitHub signs into it (ADR 0017). /// neither a code by mail nor GitHub signs into it (ADR 0017).
bool passwordOnly; bool passwordOnly;
/// A second factor works: a confirmed authenticator, and no reset of the
/// authenticators that took effect. Where none does, deleting the account
/// takes a code by mail in place of a TOTP or recovery code.
bool secondFactorWorks;
/// Returns a shallow copy of this [SignInWays] /// Returns a shallow copy of this [SignInWays]
/// with some or all fields replaced by the given arguments. /// with some or all fields replaced by the given arguments.
@_isc.useResult @_isc.useResult
@ -73,6 +86,7 @@ abstract class SignInWays
bool? password, bool? password,
bool? authenticator, bool? authenticator,
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks,
}); });
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -83,6 +97,7 @@ abstract class SignInWays
'password': password, 'password': password,
'authenticator': authenticator, 'authenticator': authenticator,
'passwordOnly': passwordOnly, 'passwordOnly': passwordOnly,
'secondFactorWorks': secondFactorWorks,
}; };
} }
@ -95,6 +110,7 @@ abstract class SignInWays
'password': password, 'password': password,
'authenticator': authenticator, 'authenticator': authenticator,
'passwordOnly': passwordOnly, 'passwordOnly': passwordOnly,
'secondFactorWorks': secondFactorWorks,
}; };
} }
@ -111,12 +127,14 @@ class _SignInWaysImpl extends SignInWays {
bool? password, bool? password,
bool? authenticator, bool? authenticator,
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks,
}) : super._( }) : super._(
gitHub: gitHub, gitHub: gitHub,
email: email, email: email,
password: password, password: password,
authenticator: authenticator, authenticator: authenticator,
passwordOnly: passwordOnly, passwordOnly: passwordOnly,
secondFactorWorks: secondFactorWorks,
); );
/// Returns a shallow copy of this [SignInWays] /// Returns a shallow copy of this [SignInWays]
@ -129,6 +147,7 @@ class _SignInWaysImpl extends SignInWays {
bool? password, bool? password,
bool? authenticator, bool? authenticator,
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks,
}) { }) {
return SignInWays( return SignInWays(
gitHub: gitHub ?? this.gitHub, gitHub: gitHub ?? this.gitHub,
@ -136,6 +155,7 @@ class _SignInWaysImpl extends SignInWays {
password: password ?? this.password, password: password ?? this.password,
authenticator: authenticator ?? this.authenticator, authenticator: authenticator ?? this.authenticator,
passwordOnly: passwordOnly ?? this.passwordOnly, passwordOnly: passwordOnly ?? this.passwordOnly,
secondFactorWorks: secondFactorWorks ?? this.secondFactorWorks,
); );
} }
} }

View file

@ -28,6 +28,8 @@ import 'package:halkyon_base_client/src/protocol/password/key_proof_challenge.da
as _ir3ecgdn; as _ir3ecgdn;
import 'package:halkyon_base_client/src/protocol/password/new_password.dart' import 'package:halkyon_base_client/src/protocol/password/new_password.dart'
as _i95l60wq; as _i95l60wq;
import 'package:halkyon_base_client/src/protocol/password/owner_proof.dart'
as _ikq9b2yz;
import 'package:halkyon_base_client/src/protocol/password/password_challenge.dart' import 'package:halkyon_base_client/src/protocol/password/password_challenge.dart'
as _iykren7a; as _iykren7a;
import 'package:halkyon_base_client/src/protocol/password/password_setup.dart' import 'package:halkyon_base_client/src/protocol/password/password_setup.dart'
@ -343,9 +345,11 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
} }
/// The signed-in person's authenticators and recovery codes (ADR 0017), /// The signed-in person's authenticators and recovery codes (ADR 0017),
/// which the instance's register keeps (ADR 0016). Every code tried here /// which the instance's register keeps (ADR 0016). Once one is confirmed,
/// counts for the session (`asSessionCodeTry`): the tenth wrong in a row /// every change takes the owner's proof (`OwnerProof`: the password and a
/// ends it ([PasswordRefusal.sessionEnded]). /// code; the third reading), which counts for the session
/// (`asOwnerProofTry`): the tenth wrong in a row ends it
/// ([PasswordRefusal.sessionEnded]).
/// {@category Endpoint} /// {@category Endpoint}
class EndpointAuthenticator extends _isc.EndpointRef { class EndpointAuthenticator extends _isc.EndpointRef {
EndpointAuthenticator(_isc.EndpointCaller caller) : super(caller); EndpointAuthenticator(_isc.EndpointCaller caller) : super(caller);
@ -369,20 +373,20 @@ class EndpointAuthenticator extends _isc.EndpointRef {
); );
/// Confirms [authenticator] with a [code] from it, and, where the account /// Confirms [authenticator] with a [code] from it, and, where the account
/// has a confirmed authenticator already, an [existingCode] from that one; /// has a confirmed authenticator already, the owner's [proof]; the
/// the account's first returns its recovery codes, shown once. Refusals as /// account's first returns its recovery codes, shown once. Refusals as
/// `confirmAuthenticator`'s. /// `confirmAuthenticator`'s.
_ida.Future<List<String>> confirm({ _ida.Future<List<String>> confirm({
required _isc.UuidValue authenticator, required _isc.UuidValue authenticator,
required String code, required String code,
String? existingCode, _ikq9b2yz.OwnerProof? proof,
}) => caller.callServerEndpoint<List<String>>( }) => caller.callServerEndpoint<List<String>>(
'halkyon_base.authenticator', 'halkyon_base.authenticator',
'confirm', 'confirm',
{ {
'authenticator': authenticator, 'authenticator': authenticator,
'code': code, 'code': code,
'existingCode': existingCode, 'proof': proof,
}, },
); );
@ -394,28 +398,30 @@ class EndpointAuthenticator extends _isc.EndpointRef {
{}, {},
); );
/// Removes [authenticator]: a confirmed one with a [code] from one of the /// Removes [authenticator]: one never confirmed as it is, a confirmed one
/// caller's, while another stays. Refusals as `removeAuthenticator`'s. /// with the owner's [proof], while another stays. Refusals as
/// `removeAuthenticator`'s.
_ida.Future<void> remove({ _ida.Future<void> remove({
required _isc.UuidValue authenticator, required _isc.UuidValue authenticator,
String? code, _ikq9b2yz.OwnerProof? proof,
}) => caller.callServerEndpoint<void>( }) => caller.callServerEndpoint<void>(
'halkyon_base.authenticator', 'halkyon_base.authenticator',
'remove', 'remove',
{ {
'authenticator': authenticator, 'authenticator': authenticator,
'code': code, 'proof': proof,
}, },
); );
/// New recovery codes in place of the old, with a [code] from one of the /// New recovery codes in place of the old, with the owner's [proof]:
/// caller's authenticators: shown once. Refusals as `newRecoveryCodes`'. /// shown once. Refusals as `newRecoveryCodes`'.
_ida.Future<List<String>> newRecoveryCodes({required String code}) => _ida.Future<List<String>> newRecoveryCodes({
caller.callServerEndpoint<List<String>>( required _ikq9b2yz.OwnerProof proof,
'halkyon_base.authenticator', }) => caller.callServerEndpoint<List<String>>(
'newRecoveryCodes', 'halkyon_base.authenticator',
{'code': code}, 'newRecoveryCodes',
); {'proof': proof},
);
} }
/// Giving the signed-in person a password (ADR 0017): the way an anonymous /// Giving the signed-in person a password (ADR 0017): the way an anonymous
@ -448,8 +454,13 @@ class EndpointPassword extends _isc.EndpointRef {
}, },
); );
/// The first step of the caller proving their password to change it: /// The first step of the caller proving their password (`OwnerProof`) to
/// its salt and costs, and SRP's B. Refusals as `beginPasswordChange`'s. /// change how their account is secured: its salt and costs, and SRP's B.
/// At most [changesBegunPerSession] an hour per session, which holds back
/// no other session, the owner's among them (the third reading's third
/// finding); the proofs that fail count against the session too
/// (`asOwnerProofTry`). Refusals as `beginPasswordChange`'s, and
/// [PasswordRefusal.tooManyAttempts].
_ida.Future<_iykren7a.PasswordChallenge> beginChange() => _ida.Future<_iykren7a.PasswordChallenge> beginChange() =>
caller.callServerEndpoint<_iykren7a.PasswordChallenge>( caller.callServerEndpoint<_iykren7a.PasswordChallenge>(
'halkyon_base.password', 'halkyon_base.password',
@ -458,69 +469,38 @@ class EndpointPassword extends _isc.EndpointRef {
); );
/// Changes the caller's password to [password] (the same keys, wrapped /// Changes the caller's password to [password] (the same keys, wrapped
/// under the new password's key on the device), with the old password's /// under the new password's key on the device), with the owner's [proof]
/// proof for [challenge] ([beginChange]) and a [totpCode] or /// (the old password's proof for a challenge [beginChange] gave, and a
/// [recoveryCode], and the account's [address] as the device typed it /// TOTP or recovery code). Every other session of theirs ends, in every
/// (checked before the code; the owner is mailed in [language] should /// service of the instance; this one stays. The proof counts for this
/// codes come to wait). Every other session of theirs ends, in every /// session (`asOwnerProofTry`). Refusals as `changePassword`'s.
/// service of the instance; this one stays. Refusals as `changePassword`'s.
_ida.Future<void> change({ _ida.Future<void> change({
required _isc.UuidValue challenge, required _ikq9b2yz.OwnerProof proof,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
String? totpCode,
String? recoveryCode,
required _i95l60wq.NewPassword password, required _i95l60wq.NewPassword password,
required String host,
required String address,
required String language,
}) => caller.callServerEndpoint<void>( }) => caller.callServerEndpoint<void>(
'halkyon_base.password', 'halkyon_base.password',
'change', 'change',
{ {
'challenge': challenge, 'proof': proof,
'clientValue': clientValue,
'clientProof': clientProof,
'totpCode': totpCode,
'recoveryCode': recoveryCode,
'password': password, 'password': password,
'host': host,
'address': address,
'language': language,
}, },
); );
/// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under /// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under
/// it on the device, replace the old wrapping, and [recoveryProofKey], the /// it on the device, replace the old wrapping, and [recoveryProofKey], the
/// public half of its proof key, the old one, with the password's proof /// public half of its proof key, the old one, with the owner's [proof], as
/// for [challenge] ([beginChange]) and a [totpCode] or [recoveryCode], with /// [change] takes. Refusals as `setRecoveryKeys`'.
/// the account's [address] and [language], as [change] takes. Refusals as
/// `setRecoveryKeys`'.
_ida.Future<void> setRecoveryKeys({ _ida.Future<void> setRecoveryKeys({
required _isc.UuidValue challenge, required _ikq9b2yz.OwnerProof proof,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
String? totpCode,
String? recoveryCode,
required _i3so7e0p.WrappedKeys recoveryKeys, required _i3so7e0p.WrappedKeys recoveryKeys,
required _idt.ByteData recoveryProofKey, required _idt.ByteData recoveryProofKey,
required String host,
required String address,
required String language,
}) => caller.callServerEndpoint<void>( }) => caller.callServerEndpoint<void>(
'halkyon_base.password', 'halkyon_base.password',
'setRecoveryKeys', 'setRecoveryKeys',
{ {
'challenge': challenge, 'proof': proof,
'clientValue': clientValue,
'clientProof': clientProof,
'totpCode': totpCode,
'recoveryCode': recoveryCode,
'recoveryKeys': recoveryKeys, 'recoveryKeys': recoveryKeys,
'recoveryProofKey': recoveryProofKey, 'recoveryProofKey': recoveryProofKey,
'host': host,
'address': address,
'language': language,
}, },
); );
} }

View file

@ -16,8 +16,8 @@ import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// What deleting an account that has a password takes (ADR 0017; the second /// What deleting an account that has a password takes (ADR 0017; the second
/// reading): the password's SRP proof for a challenge `password.beginChange` /// reading): the password's SRP proof for a challenge `password.beginChange`
/// gave, a TOTP or recovery code, and the host the device addressed, as a /// gave, a TOTP or recovery code, and the host the device addressed, as a
/// change of password does. A session alone deletes only an account without /// change of password does (`OwnerProof`). A session alone deletes only an
/// one. /// account without one.
abstract class DeletionProof abstract class DeletionProof
implements _isc.SerializableModel, _isc.ProtocolSerialization { implements _isc.SerializableModel, _isc.ProtocolSerialization {
DeletionProof._({ DeletionProof._({
@ -29,8 +29,6 @@ abstract class DeletionProof
this.request, this.request,
this.code, this.code,
required this.host, required this.host,
required this.address,
required this.language,
}); });
factory DeletionProof({ factory DeletionProof({
@ -42,8 +40,6 @@ abstract class DeletionProof
_isc.UuidValue? request, _isc.UuidValue? request,
String? code, String? code,
required String host, required String host,
required String address,
required String language,
}) = _DeletionProofImpl; }) = _DeletionProofImpl;
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) { factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
@ -64,8 +60,6 @@ abstract class DeletionProof
: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['request']), : _isc.UuidValueJsonExtension.fromJson(jsonSerialization['request']),
code: jsonSerialization['code'] as String?, code: jsonSerialization['code'] as String?,
host: jsonSerialization['host'] as String, host: jsonSerialization['host'] as String,
address: jsonSerialization['address'] as String,
language: jsonSerialization['language'] as String,
); );
} }
@ -91,13 +85,6 @@ abstract class DeletionProof
/// checks it is its own before the register binds the proof to it. /// checks it is its own before the register binds the proof to it.
String host; String host;
/// The account's address as the device typed it, and the language its
/// mails are worded in: checked before the code, so the owner is mailed
/// should codes come to wait (the re-review's fourth finding).
String address;
String language;
/// Returns a shallow copy of this [DeletionProof] /// Returns a shallow copy of this [DeletionProof]
/// with some or all fields replaced by the given arguments. /// with some or all fields replaced by the given arguments.
@_isc.useResult @_isc.useResult
@ -110,8 +97,6 @@ abstract class DeletionProof
_isc.UuidValue? request, _isc.UuidValue? request,
String? code, String? code,
String? host, String? host,
String? address,
String? language,
}); });
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -125,8 +110,6 @@ abstract class DeletionProof
if (request != null) 'request': request?.toJson(), if (request != null) 'request': request?.toJson(),
if (code != null) 'code': code, if (code != null) 'code': code,
'host': host, 'host': host,
'address': address,
'language': language,
}; };
} }
@ -142,8 +125,6 @@ abstract class DeletionProof
if (request != null) 'request': request?.toJson(), if (request != null) 'request': request?.toJson(),
if (code != null) 'code': code, if (code != null) 'code': code,
'host': host, 'host': host,
'address': address,
'language': language,
}; };
} }
@ -165,8 +146,6 @@ class _DeletionProofImpl extends DeletionProof {
_isc.UuidValue? request, _isc.UuidValue? request,
String? code, String? code,
required String host, required String host,
required String address,
required String language,
}) : super._( }) : super._(
challenge: challenge, challenge: challenge,
clientValue: clientValue, clientValue: clientValue,
@ -176,8 +155,6 @@ class _DeletionProofImpl extends DeletionProof {
request: request, request: request,
code: code, code: code,
host: host, host: host,
address: address,
language: language,
); );
/// Returns a shallow copy of this [DeletionProof] /// Returns a shallow copy of this [DeletionProof]
@ -193,8 +170,6 @@ class _DeletionProofImpl extends DeletionProof {
Object? request = _Undefined, Object? request = _Undefined,
Object? code = _Undefined, Object? code = _Undefined,
String? host, String? host,
String? address,
String? language,
}) { }) {
return DeletionProof( return DeletionProof(
challenge: challenge ?? this.challenge, challenge: challenge ?? this.challenge,
@ -205,8 +180,6 @@ class _DeletionProofImpl extends DeletionProof {
request: request is _isc.UuidValue? ? request : this.request, request: request is _isc.UuidValue? ? request : this.request,
code: code is String? ? code : this.code, code: code is String? ? code : this.code,
host: host ?? this.host, host: host ?? this.host,
address: address ?? this.address,
language: language ?? this.language,
); );
} }
} }

View file

@ -0,0 +1,158 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// What a signed-in owner proves to change how their account is secured
/// (ADR 0017; the third reading, as Proton has it): the password's SRP
/// proof for a challenge `password.beginChange` gave, bound to the host the
/// device addressed, and a TOTP code from one of the account's confirmed
/// authenticators or one of its recovery codes. Adding an authenticator once
/// one is confirmed, removing a confirmed one, new recovery codes, a new
/// recovery phrase and a new password take it: a session alone never gets a
/// code checked.
abstract class OwnerProof
implements _isc.SerializableModel, _isc.ProtocolSerialization {
OwnerProof._({
required this.challenge,
required this.clientValue,
required this.clientProof,
required this.host,
this.totpCode,
this.recoveryCode,
});
factory OwnerProof({
required _isc.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
required String host,
String? totpCode,
String? recoveryCode,
}) = _OwnerProofImpl;
factory OwnerProof.fromJson(Map<String, dynamic> jsonSerialization) {
return OwnerProof(
challenge: _isc.UuidValueJsonExtension.fromJson(
jsonSerialization['challenge'],
),
clientValue: _isc.ByteDataJsonExtension.fromJson(
jsonSerialization['clientValue'],
),
clientProof: _isc.ByteDataJsonExtension.fromJson(
jsonSerialization['clientProof'],
),
host: jsonSerialization['host'] as String,
totpCode: jsonSerialization['totpCode'] as String?,
recoveryCode: jsonSerialization['recoveryCode'] as String?,
);
}
_isc.UuidValue challenge;
_idt.ByteData clientValue;
_idt.ByteData clientProof;
/// The host the device addressed the instance by: the server it reached
/// checks it is its own before the register binds the proof to it.
String host;
String? totpCode;
String? recoveryCode;
/// Returns a shallow copy of this [OwnerProof]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
OwnerProof copyWith({
_isc.UuidValue? challenge,
_idt.ByteData? clientValue,
_idt.ByteData? clientProof,
String? host,
String? totpCode,
String? recoveryCode,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.OwnerProof',
'challenge': challenge.toJson(),
'clientValue': clientValue.toJson(),
'clientProof': clientProof.toJson(),
'host': host,
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.OwnerProof',
'challenge': challenge.toJson(),
'clientValue': clientValue.toJson(),
'clientProof': clientProof.toJson(),
'host': host,
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _OwnerProofImpl extends OwnerProof {
_OwnerProofImpl({
required _isc.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
required String host,
String? totpCode,
String? recoveryCode,
}) : super._(
challenge: challenge,
clientValue: clientValue,
clientProof: clientProof,
host: host,
totpCode: totpCode,
recoveryCode: recoveryCode,
);
/// Returns a shallow copy of this [OwnerProof]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
OwnerProof copyWith({
_isc.UuidValue? challenge,
_idt.ByteData? clientValue,
_idt.ByteData? clientProof,
String? host,
Object? totpCode = _Undefined,
Object? recoveryCode = _Undefined,
}) {
return OwnerProof(
challenge: challenge ?? this.challenge,
clientValue: clientValue ?? this.clientValue.clone(),
clientProof: clientProof ?? this.clientProof.clone(),
host: host ?? this.host,
totpCode: totpCode is String? ? totpCode : this.totpCode,
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
);
}
}

View file

@ -61,7 +61,7 @@ enum PasswordRefusal implements _isc.SerializableModel {
/// The account has as many authenticators as it may: remove one first. /// The account has as many authenticators as it may: remove one first.
tooManyAuthenticators, tooManyAuthenticators,
/// The code from the authenticator isn't right. /// The code from the authenticator being set up isn't right.
wrongCode, wrongCode,
/// The reset's time hasn't come yet. /// The reset's time hasn't come yet.
@ -70,11 +70,6 @@ enum PasswordRefusal implements _isc.SerializableModel {
/// No such reset is waiting, or the address isn't the account's. /// No such reset is waiting, or the address isn't the account's.
noReset, noReset,
/// Adding an authenticator to an account that has one confirmed takes a
/// code from one of those, which is missing or isn't right: a session
/// alone adds no second factor.
wrongExistingCode,
/// The device addressed this instance by another name than its own /// The device addressed this instance by another name than its own
/// (`apiServer.publicHost`): what it proves is bound to the name it /// (`apiServer.publicHost`): what it proves is bound to the name it
/// addressed, so the operator must configure the name devices use. /// addressed, so the operator must configure the name devices use.
@ -84,8 +79,9 @@ enum PasswordRefusal implements _isc.SerializableModel {
/// `takesEffectAt` (the second reading's M4). Only after a right password. /// `takesEffectAt` (the second reading's M4). Only after a right password.
codesWait, codesWait,
/// Ten codes in a row from this signed-in session were wrong: the /// Ten proofs in a row from this signed-in session (the password with a
/// session ended (the re-review's third finding); sign in again. /// code, `OwnerProof`) were wrong: the session ended (the third reading);
/// sign in again.
sessionEnded, sessionEnded,
/// The password was changed (a reset carried out), but this service /// The password was changed (a reset carried out), but this service
@ -128,8 +124,6 @@ enum PasswordRefusal implements _isc.SerializableModel {
return PasswordRefusal.notDue; return PasswordRefusal.notDue;
case 'noReset': case 'noReset':
return PasswordRefusal.noReset; return PasswordRefusal.noReset;
case 'wrongExistingCode':
return PasswordRefusal.wrongExistingCode;
case 'otherInstance': case 'otherInstance':
return PasswordRefusal.otherInstance; return PasswordRefusal.otherInstance;
case 'codesWait': case 'codesWait':

View file

@ -31,6 +31,7 @@ import 'password/authenticator_enrolment.dart' as _iv7tra7v;
import 'password/deletion_proof.dart' as _ix1u7hdq; import 'password/deletion_proof.dart' as _ix1u7hdq;
import 'password/key_proof_challenge.dart' as _iehtx95q; import 'password/key_proof_challenge.dart' as _iehtx95q;
import 'password/new_password.dart' as _itvvd6m6; import 'password/new_password.dart' as _itvvd6m6;
import 'password/owner_proof.dart' as _i5f4sdg4;
import 'password/password_challenge.dart' as _iuxisac7; import 'password/password_challenge.dart' as _iuxisac7;
import 'password/password_costs.dart' as _izp492e5; import 'password/password_costs.dart' as _izp492e5;
import 'password/password_refusal.dart' as _ibfrwdhm; import 'password/password_refusal.dart' as _ibfrwdhm;
@ -57,6 +58,7 @@ export 'password/authenticator_enrolment.dart';
export 'password/deletion_proof.dart'; export 'password/deletion_proof.dart';
export 'password/key_proof_challenge.dart'; export 'password/key_proof_challenge.dart';
export 'password/new_password.dart'; export 'password/new_password.dart';
export 'password/owner_proof.dart';
export 'password/password_challenge.dart'; export 'password/password_challenge.dart';
export 'password/password_costs.dart'; export 'password/password_costs.dart';
export 'password/password_refusal.dart'; export 'password/password_refusal.dart';
@ -156,6 +158,9 @@ class Protocol extends _isc.SerializationManager {
if (t == _itvvd6m6.NewPassword) { if (t == _itvvd6m6.NewPassword) {
return _itvvd6m6.NewPassword.fromJson(data) as T; return _itvvd6m6.NewPassword.fromJson(data) as T;
} }
if (t == _i5f4sdg4.OwnerProof) {
return _i5f4sdg4.OwnerProof.fromJson(data) as T;
}
if (t == _iuxisac7.PasswordChallenge) { if (t == _iuxisac7.PasswordChallenge) {
return _iuxisac7.PasswordChallenge.fromJson(data) as T; return _iuxisac7.PasswordChallenge.fromJson(data) as T;
} }
@ -253,6 +258,9 @@ class Protocol extends _isc.SerializationManager {
if (t == _isc.getType<_itvvd6m6.NewPassword?>()) { if (t == _isc.getType<_itvvd6m6.NewPassword?>()) {
return (data != null ? _itvvd6m6.NewPassword.fromJson(data) : null) as T; return (data != null ? _itvvd6m6.NewPassword.fromJson(data) : null) as T;
} }
if (t == _isc.getType<_i5f4sdg4.OwnerProof?>()) {
return (data != null ? _i5f4sdg4.OwnerProof.fromJson(data) : null) as T;
}
if (t == _isc.getType<_iuxisac7.PasswordChallenge?>()) { if (t == _isc.getType<_iuxisac7.PasswordChallenge?>()) {
return (data != null ? _iuxisac7.PasswordChallenge.fromJson(data) : null) return (data != null ? _iuxisac7.PasswordChallenge.fromJson(data) : null)
as T; as T;
@ -355,6 +363,7 @@ class Protocol extends _isc.SerializationManager {
_ix1u7hdq.DeletionProof => 'DeletionProof', _ix1u7hdq.DeletionProof => 'DeletionProof',
_iehtx95q.KeyProofChallenge => 'KeyProofChallenge', _iehtx95q.KeyProofChallenge => 'KeyProofChallenge',
_itvvd6m6.NewPassword => 'NewPassword', _itvvd6m6.NewPassword => 'NewPassword',
_i5f4sdg4.OwnerProof => 'OwnerProof',
_iuxisac7.PasswordChallenge => 'PasswordChallenge', _iuxisac7.PasswordChallenge => 'PasswordChallenge',
_izp492e5.PasswordCosts => 'PasswordCosts', _izp492e5.PasswordCosts => 'PasswordCosts',
_ibfrwdhm.PasswordRefusal => 'PasswordRefusal', _ibfrwdhm.PasswordRefusal => 'PasswordRefusal',
@ -411,6 +420,8 @@ class Protocol extends _isc.SerializationManager {
return 'KeyProofChallenge'; return 'KeyProofChallenge';
case _itvvd6m6.NewPassword(): case _itvvd6m6.NewPassword():
return 'NewPassword'; return 'NewPassword';
case _i5f4sdg4.OwnerProof():
return 'OwnerProof';
case _iuxisac7.PasswordChallenge(): case _iuxisac7.PasswordChallenge():
return 'PasswordChallenge'; return 'PasswordChallenge';
case _izp492e5.PasswordCosts(): case _izp492e5.PasswordCosts():
@ -486,6 +497,9 @@ class Protocol extends _isc.SerializationManager {
if (dataClassName == 'NewPassword') { if (dataClassName == 'NewPassword') {
return deserialize<_itvvd6m6.NewPassword>(data['data']); return deserialize<_itvvd6m6.NewPassword>(data['data']);
} }
if (dataClassName == 'OwnerProof') {
return deserialize<_i5f4sdg4.OwnerProof>(data['data']);
}
if (dataClassName == 'PasswordChallenge') { if (dataClassName == 'PasswordChallenge') {
return deserialize<_iuxisac7.PasswordChallenge>(data['data']); return deserialize<_iuxisac7.PasswordChallenge>(data['data']);
} }

View file

@ -3,6 +3,7 @@ import 'package:halkyon_base_server/src/auth/membership.dart';
import 'package:halkyon_base_server/src/auth/scopes.dart'; import 'package:halkyon_base_server/src/auth/scopes.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart'; import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/key_proof.dart'; import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:halkyon_base_server/src/password/owner_proof_tries.dart';
import 'package:halkyon_base_server/src/password/password_change.dart'; import 'package:halkyon_base_server/src/password/password_change.dart';
import 'package:halkyon_base_server/src/register/account_register.dart'; import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:serverpod/serverpod.dart'; import 'package:serverpod/serverpod.dart';
@ -26,12 +27,17 @@ class AccountEndpoint extends Endpoint {
final pod = session.serverpod; final pod = session.serverpod;
final person = session.authenticated!.authUserId; final person = session.authenticated!.authUserId;
final bound = proof?.copyWith(host: deviceHostOn(pod, proof.host)); final bound = proof?.copyWith(host: deviceHostOn(pod, proof.host));
if (!isMember(pod)) { // The proof counts for this session (`asOwnerProofTry`), as every
await requireOwnerToDelete(session, person, bound); // change of how the account is secured does.
} else if (session.authenticated!.scopes.contains(identified)) { await asOwnerProofTry(session, bound, () async {
await AccountRegister.of(pod) if (!isMember(pod)) {
.deleteAccount(session, person, proof: bound); await requireOwnerToDelete(session, person, bound);
} } else if (session.authenticated!.scopes.contains(identified)) {
await AccountRegister.of(
pod,
).deleteAccount(session, person, proof: bound);
}
});
await deletePerson(session, person); await deletePerson(session, person);
await session.messages.authenticationRevoked( await session.messages.authenticationRevoked(
person.uuid, person.uuid,

View file

@ -11,3 +11,7 @@ fields:
### authenticator (a reset may have left it without one since), so ### authenticator (a reset may have left it without one since), so
### neither a code by mail nor GitHub signs into it (ADR 0017). ### neither a code by mail nor GitHub signs into it (ADR 0017).
passwordOnly: bool, default=false passwordOnly: bool, default=false
### A second factor works: a confirmed authenticator, and no reset of the
### authenticators that took effect. Where none does, deleting the account
### takes a code by mail in place of a TOTP or recovery code.
secondFactorWorks: bool, default=false

View file

@ -21,9 +21,11 @@ abstract class SignInWays
bool? password, bool? password,
bool? authenticator, bool? authenticator,
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks,
}) : password = password ?? false, }) : password = password ?? false,
authenticator = authenticator ?? false, authenticator = authenticator ?? false,
passwordOnly = passwordOnly ?? false; passwordOnly = passwordOnly ?? false,
secondFactorWorks = secondFactorWorks ?? false;
factory SignInWays({ factory SignInWays({
required bool gitHub, required bool gitHub,
@ -31,6 +33,7 @@ abstract class SignInWays
bool? password, bool? password,
bool? authenticator, bool? authenticator,
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks,
}) = _SignInWaysImpl; }) = _SignInWaysImpl;
factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) { factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) {
@ -46,6 +49,11 @@ abstract class SignInWays
passwordOnly: jsonSerialization['passwordOnly'] == null passwordOnly: jsonSerialization['passwordOnly'] == null
? null ? null
: _is.BoolJsonExtension.fromJson(jsonSerialization['passwordOnly']), : _is.BoolJsonExtension.fromJson(jsonSerialization['passwordOnly']),
secondFactorWorks: jsonSerialization['secondFactorWorks'] == null
? null
: _is.BoolJsonExtension.fromJson(
jsonSerialization['secondFactorWorks'],
),
); );
} }
@ -64,6 +72,11 @@ abstract class SignInWays
/// neither a code by mail nor GitHub signs into it (ADR 0017). /// neither a code by mail nor GitHub signs into it (ADR 0017).
bool passwordOnly; bool passwordOnly;
/// A second factor works: a confirmed authenticator, and no reset of the
/// authenticators that took effect. Where none does, deleting the account
/// takes a code by mail in place of a TOTP or recovery code.
bool secondFactorWorks;
/// Returns a shallow copy of this [SignInWays] /// Returns a shallow copy of this [SignInWays]
/// with some or all fields replaced by the given arguments. /// with some or all fields replaced by the given arguments.
@_is.useResult @_is.useResult
@ -73,6 +86,7 @@ abstract class SignInWays
bool? password, bool? password,
bool? authenticator, bool? authenticator,
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks,
}); });
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -83,6 +97,7 @@ abstract class SignInWays
'password': password, 'password': password,
'authenticator': authenticator, 'authenticator': authenticator,
'passwordOnly': passwordOnly, 'passwordOnly': passwordOnly,
'secondFactorWorks': secondFactorWorks,
}; };
} }
@ -95,6 +110,7 @@ abstract class SignInWays
'password': password, 'password': password,
'authenticator': authenticator, 'authenticator': authenticator,
'passwordOnly': passwordOnly, 'passwordOnly': passwordOnly,
'secondFactorWorks': secondFactorWorks,
}; };
} }
@ -111,12 +127,14 @@ class _SignInWaysImpl extends SignInWays {
bool? password, bool? password,
bool? authenticator, bool? authenticator,
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks,
}) : super._( }) : super._(
gitHub: gitHub, gitHub: gitHub,
email: email, email: email,
password: password, password: password,
authenticator: authenticator, authenticator: authenticator,
passwordOnly: passwordOnly, passwordOnly: passwordOnly,
secondFactorWorks: secondFactorWorks,
); );
/// Returns a shallow copy of this [SignInWays] /// Returns a shallow copy of this [SignInWays]
@ -129,6 +147,7 @@ class _SignInWaysImpl extends SignInWays {
bool? password, bool? password,
bool? authenticator, bool? authenticator,
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks,
}) { }) {
return SignInWays( return SignInWays(
gitHub: gitHub ?? this.gitHub, gitHub: gitHub ?? this.gitHub,
@ -136,6 +155,7 @@ class _SignInWaysImpl extends SignInWays {
password: password ?? this.password, password: password ?? this.password,
authenticator: authenticator ?? this.authenticator, authenticator: authenticator ?? this.authenticator,
passwordOnly: passwordOnly ?? this.passwordOnly, passwordOnly: passwordOnly ?? this.passwordOnly,
secondFactorWorks: secondFactorWorks ?? this.secondFactorWorks,
); );
} }
} }

View file

@ -17,6 +17,8 @@ import 'package:halkyon_base_server/src/generated/password/deletion_proof.dart'
as _i619l6vv; as _i619l6vv;
import 'package:halkyon_base_server/src/generated/password/new_password.dart' import 'package:halkyon_base_server/src/generated/password/new_password.dart'
as _ijye8mcv; as _ijye8mcv;
import 'package:halkyon_base_server/src/generated/password/owner_proof.dart'
as _ifhod73s;
import 'package:halkyon_base_server/src/generated/password/password_setup.dart' import 'package:halkyon_base_server/src/generated/password/password_setup.dart'
as _ikhyee47; as _ikhyee47;
import 'package:halkyon_base_server/src/generated/password/wrapped_keys.dart' import 'package:halkyon_base_server/src/generated/password/wrapped_keys.dart'
@ -593,9 +595,9 @@ class Endpoints extends _is.EndpointDispatch {
type: _is.getType<String>(), type: _is.getType<String>(),
nullable: false, nullable: false,
), ),
'existingCode': _is.ParameterDescription( 'proof': _is.ParameterDescription(
name: 'existingCode', name: 'proof',
type: _is.getType<String?>(), type: _is.getType<_ifhod73s.OwnerProof?>(),
nullable: true, nullable: true,
), ),
}, },
@ -610,7 +612,7 @@ class Endpoints extends _is.EndpointDispatch {
session, session,
authenticator: params['authenticator'], authenticator: params['authenticator'],
code: params['code'], code: params['code'],
existingCode: params['existingCode'], proof: params['proof'],
), ),
), ),
'list': _is.MethodConnector( 'list': _is.MethodConnector(
@ -633,9 +635,9 @@ class Endpoints extends _is.EndpointDispatch {
type: _is.getType<_is.UuidValue>(), type: _is.getType<_is.UuidValue>(),
nullable: false, nullable: false,
), ),
'code': _is.ParameterDescription( 'proof': _is.ParameterDescription(
name: 'code', name: 'proof',
type: _is.getType<String?>(), type: _is.getType<_ifhod73s.OwnerProof?>(),
nullable: true, nullable: true,
), ),
}, },
@ -649,15 +651,15 @@ class Endpoints extends _is.EndpointDispatch {
.remove( .remove(
session, session,
authenticator: params['authenticator'], authenticator: params['authenticator'],
code: params['code'], proof: params['proof'],
), ),
), ),
'newRecoveryCodes': _is.MethodConnector( 'newRecoveryCodes': _is.MethodConnector(
name: 'newRecoveryCodes', name: 'newRecoveryCodes',
params: { params: {
'code': _is.ParameterDescription( 'proof': _is.ParameterDescription(
name: 'code', name: 'proof',
type: _is.getType<String>(), type: _is.getType<_ifhod73s.OwnerProof>(),
nullable: false, nullable: false,
), ),
}, },
@ -670,7 +672,7 @@ class Endpoints extends _is.EndpointDispatch {
as _i3xqawxj.AuthenticatorEndpoint) as _i3xqawxj.AuthenticatorEndpoint)
.newRecoveryCodes( .newRecoveryCodes(
session, session,
code: params['code'], proof: params['proof'],
), ),
), ),
}, },
@ -723,51 +725,16 @@ class Endpoints extends _is.EndpointDispatch {
'change': _is.MethodConnector( 'change': _is.MethodConnector(
name: 'change', name: 'change',
params: { params: {
'challenge': _is.ParameterDescription( 'proof': _is.ParameterDescription(
name: 'challenge', name: 'proof',
type: _is.getType<_is.UuidValue>(), type: _is.getType<_ifhod73s.OwnerProof>(),
nullable: false, 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,
),
'password': _is.ParameterDescription( 'password': _is.ParameterDescription(
name: 'password', name: 'password',
type: _is.getType<_ijye8mcv.NewPassword>(), type: _is.getType<_ijye8mcv.NewPassword>(),
nullable: false, nullable: false,
), ),
'host': _is.ParameterDescription(
name: 'host',
type: _is.getType<String>(),
nullable: false,
),
'address': _is.ParameterDescription(
name: 'address',
type: _is.getType<String>(),
nullable: false,
),
'language': _is.ParameterDescription(
name: 'language',
type: _is.getType<String>(),
nullable: false,
),
}, },
call: call:
( (
@ -776,45 +743,18 @@ class Endpoints extends _is.EndpointDispatch {
) async => ) async =>
(endpoints['password'] as _ig7fyg1b.PasswordEndpoint).change( (endpoints['password'] as _ig7fyg1b.PasswordEndpoint).change(
session, session,
challenge: params['challenge'], proof: params['proof'],
clientValue: params['clientValue'],
clientProof: params['clientProof'],
totpCode: params['totpCode'],
recoveryCode: params['recoveryCode'],
password: params['password'], password: params['password'],
host: params['host'],
address: params['address'],
language: params['language'],
), ),
), ),
'setRecoveryKeys': _is.MethodConnector( 'setRecoveryKeys': _is.MethodConnector(
name: 'setRecoveryKeys', name: 'setRecoveryKeys',
params: { params: {
'challenge': _is.ParameterDescription( 'proof': _is.ParameterDescription(
name: 'challenge', name: 'proof',
type: _is.getType<_is.UuidValue>(), type: _is.getType<_ifhod73s.OwnerProof>(),
nullable: false, 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( 'recoveryKeys': _is.ParameterDescription(
name: 'recoveryKeys', name: 'recoveryKeys',
type: _is.getType<_iji0wmu8.WrappedKeys>(), type: _is.getType<_iji0wmu8.WrappedKeys>(),
@ -825,21 +765,6 @@ class Endpoints extends _is.EndpointDispatch {
type: _is.getType<_idt.ByteData>(), type: _is.getType<_idt.ByteData>(),
nullable: false, nullable: false,
), ),
'host': _is.ParameterDescription(
name: 'host',
type: _is.getType<String>(),
nullable: false,
),
'address': _is.ParameterDescription(
name: 'address',
type: _is.getType<String>(),
nullable: false,
),
'language': _is.ParameterDescription(
name: 'language',
type: _is.getType<String>(),
nullable: false,
),
}, },
call: call:
( (
@ -848,16 +773,9 @@ class Endpoints extends _is.EndpointDispatch {
) async => (endpoints['password'] as _ig7fyg1b.PasswordEndpoint) ) async => (endpoints['password'] as _ig7fyg1b.PasswordEndpoint)
.setRecoveryKeys( .setRecoveryKeys(
session, session,
challenge: params['challenge'], proof: params['proof'],
clientValue: params['clientValue'],
clientProof: params['clientProof'],
totpCode: params['totpCode'],
recoveryCode: params['recoveryCode'],
recoveryKeys: params['recoveryKeys'], recoveryKeys: params['recoveryKeys'],
recoveryProofKey: params['recoveryProofKey'], recoveryProofKey: params['recoveryProofKey'],
host: params['host'],
address: params['address'],
language: params['language'],
), ),
), ),
}, },

View file

@ -16,8 +16,8 @@ import 'package:serverpod/serverpod.dart' as _is;
/// What deleting an account that has a password takes (ADR 0017; the second /// What deleting an account that has a password takes (ADR 0017; the second
/// reading): the password's SRP proof for a challenge `password.beginChange` /// reading): the password's SRP proof for a challenge `password.beginChange`
/// gave, a TOTP or recovery code, and the host the device addressed, as a /// gave, a TOTP or recovery code, and the host the device addressed, as a
/// change of password does. A session alone deletes only an account without /// change of password does (`OwnerProof`). A session alone deletes only an
/// one. /// account without one.
abstract class DeletionProof abstract class DeletionProof
implements _is.SerializableModel, _is.ProtocolSerialization { implements _is.SerializableModel, _is.ProtocolSerialization {
DeletionProof._({ DeletionProof._({
@ -29,8 +29,6 @@ abstract class DeletionProof
this.request, this.request,
this.code, this.code,
required this.host, required this.host,
required this.address,
required this.language,
}); });
factory DeletionProof({ factory DeletionProof({
@ -42,8 +40,6 @@ abstract class DeletionProof
_is.UuidValue? request, _is.UuidValue? request,
String? code, String? code,
required String host, required String host,
required String address,
required String language,
}) = _DeletionProofImpl; }) = _DeletionProofImpl;
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) { factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
@ -64,8 +60,6 @@ abstract class DeletionProof
: _is.UuidValueJsonExtension.fromJson(jsonSerialization['request']), : _is.UuidValueJsonExtension.fromJson(jsonSerialization['request']),
code: jsonSerialization['code'] as String?, code: jsonSerialization['code'] as String?,
host: jsonSerialization['host'] as String, host: jsonSerialization['host'] as String,
address: jsonSerialization['address'] as String,
language: jsonSerialization['language'] as String,
); );
} }
@ -91,13 +85,6 @@ abstract class DeletionProof
/// checks it is its own before the register binds the proof to it. /// checks it is its own before the register binds the proof to it.
String host; String host;
/// The account's address as the device typed it, and the language its
/// mails are worded in: checked before the code, so the owner is mailed
/// should codes come to wait (the re-review's fourth finding).
String address;
String language;
/// Returns a shallow copy of this [DeletionProof] /// Returns a shallow copy of this [DeletionProof]
/// with some or all fields replaced by the given arguments. /// with some or all fields replaced by the given arguments.
@_is.useResult @_is.useResult
@ -110,8 +97,6 @@ abstract class DeletionProof
_is.UuidValue? request, _is.UuidValue? request,
String? code, String? code,
String? host, String? host,
String? address,
String? language,
}); });
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -125,8 +110,6 @@ abstract class DeletionProof
if (request != null) 'request': request?.toJson(), if (request != null) 'request': request?.toJson(),
if (code != null) 'code': code, if (code != null) 'code': code,
'host': host, 'host': host,
'address': address,
'language': language,
}; };
} }
@ -142,8 +125,6 @@ abstract class DeletionProof
if (request != null) 'request': request?.toJson(), if (request != null) 'request': request?.toJson(),
if (code != null) 'code': code, if (code != null) 'code': code,
'host': host, 'host': host,
'address': address,
'language': language,
}; };
} }
@ -165,8 +146,6 @@ class _DeletionProofImpl extends DeletionProof {
_is.UuidValue? request, _is.UuidValue? request,
String? code, String? code,
required String host, required String host,
required String address,
required String language,
}) : super._( }) : super._(
challenge: challenge, challenge: challenge,
clientValue: clientValue, clientValue: clientValue,
@ -176,8 +155,6 @@ class _DeletionProofImpl extends DeletionProof {
request: request, request: request,
code: code, code: code,
host: host, host: host,
address: address,
language: language,
); );
/// Returns a shallow copy of this [DeletionProof] /// Returns a shallow copy of this [DeletionProof]
@ -193,8 +170,6 @@ class _DeletionProofImpl extends DeletionProof {
Object? request = _Undefined, Object? request = _Undefined,
Object? code = _Undefined, Object? code = _Undefined,
String? host, String? host,
String? address,
String? language,
}) { }) {
return DeletionProof( return DeletionProof(
challenge: challenge ?? this.challenge, challenge: challenge ?? this.challenge,
@ -205,8 +180,6 @@ class _DeletionProofImpl extends DeletionProof {
request: request is _is.UuidValue? ? request : this.request, request: request is _is.UuidValue? ? request : this.request,
code: code is String? ? code : this.code, code: code is String? ? code : this.code,
host: host ?? this.host, host: host ?? this.host,
address: address ?? this.address,
language: language ?? this.language,
); );
} }
} }

View file

@ -0,0 +1,158 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:serverpod/serverpod.dart' as _is;
/// What a signed-in owner proves to change how their account is secured
/// (ADR 0017; the third reading, as Proton has it): the password's SRP
/// proof for a challenge `password.beginChange` gave, bound to the host the
/// device addressed, and a TOTP code from one of the account's confirmed
/// authenticators or one of its recovery codes. Adding an authenticator once
/// one is confirmed, removing a confirmed one, new recovery codes, a new
/// recovery phrase and a new password take it: a session alone never gets a
/// code checked.
abstract class OwnerProof
implements _is.SerializableModel, _is.ProtocolSerialization {
OwnerProof._({
required this.challenge,
required this.clientValue,
required this.clientProof,
required this.host,
this.totpCode,
this.recoveryCode,
});
factory OwnerProof({
required _is.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
required String host,
String? totpCode,
String? recoveryCode,
}) = _OwnerProofImpl;
factory OwnerProof.fromJson(Map<String, dynamic> jsonSerialization) {
return OwnerProof(
challenge: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['challenge'],
),
clientValue: _is.ByteDataJsonExtension.fromJson(
jsonSerialization['clientValue'],
),
clientProof: _is.ByteDataJsonExtension.fromJson(
jsonSerialization['clientProof'],
),
host: jsonSerialization['host'] as String,
totpCode: jsonSerialization['totpCode'] as String?,
recoveryCode: jsonSerialization['recoveryCode'] as String?,
);
}
_is.UuidValue challenge;
_idt.ByteData clientValue;
_idt.ByteData clientProof;
/// The host the device addressed the instance by: the server it reached
/// checks it is its own before the register binds the proof to it.
String host;
String? totpCode;
String? recoveryCode;
/// Returns a shallow copy of this [OwnerProof]
/// with some or all fields replaced by the given arguments.
@_is.useResult
OwnerProof copyWith({
_is.UuidValue? challenge,
_idt.ByteData? clientValue,
_idt.ByteData? clientProof,
String? host,
String? totpCode,
String? recoveryCode,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.OwnerProof',
'challenge': challenge.toJson(),
'clientValue': clientValue.toJson(),
'clientProof': clientProof.toJson(),
'host': host,
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.OwnerProof',
'challenge': challenge.toJson(),
'clientValue': clientValue.toJson(),
'clientProof': clientProof.toJson(),
'host': host,
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _OwnerProofImpl extends OwnerProof {
_OwnerProofImpl({
required _is.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
required String host,
String? totpCode,
String? recoveryCode,
}) : super._(
challenge: challenge,
clientValue: clientValue,
clientProof: clientProof,
host: host,
totpCode: totpCode,
recoveryCode: recoveryCode,
);
/// Returns a shallow copy of this [OwnerProof]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
OwnerProof copyWith({
_is.UuidValue? challenge,
_idt.ByteData? clientValue,
_idt.ByteData? clientProof,
String? host,
Object? totpCode = _Undefined,
Object? recoveryCode = _Undefined,
}) {
return OwnerProof(
challenge: challenge ?? this.challenge,
clientValue: clientValue ?? this.clientValue.clone(),
clientProof: clientProof ?? this.clientProof.clone(),
host: host ?? this.host,
totpCode: totpCode is String? ? totpCode : this.totpCode,
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
);
}
}

View file

@ -61,7 +61,7 @@ enum PasswordRefusal implements _is.SerializableModel {
/// The account has as many authenticators as it may: remove one first. /// The account has as many authenticators as it may: remove one first.
tooManyAuthenticators, tooManyAuthenticators,
/// The code from the authenticator isn't right. /// The code from the authenticator being set up isn't right.
wrongCode, wrongCode,
/// The reset's time hasn't come yet. /// The reset's time hasn't come yet.
@ -70,11 +70,6 @@ enum PasswordRefusal implements _is.SerializableModel {
/// No such reset is waiting, or the address isn't the account's. /// No such reset is waiting, or the address isn't the account's.
noReset, noReset,
/// Adding an authenticator to an account that has one confirmed takes a
/// code from one of those, which is missing or isn't right: a session
/// alone adds no second factor.
wrongExistingCode,
/// The device addressed this instance by another name than its own /// The device addressed this instance by another name than its own
/// (`apiServer.publicHost`): what it proves is bound to the name it /// (`apiServer.publicHost`): what it proves is bound to the name it
/// addressed, so the operator must configure the name devices use. /// addressed, so the operator must configure the name devices use.
@ -84,8 +79,9 @@ enum PasswordRefusal implements _is.SerializableModel {
/// `takesEffectAt` (the second reading's M4). Only after a right password. /// `takesEffectAt` (the second reading's M4). Only after a right password.
codesWait, codesWait,
/// Ten codes in a row from this signed-in session were wrong: the /// Ten proofs in a row from this signed-in session (the password with a
/// session ended (the re-review's third finding); sign in again. /// code, `OwnerProof`) were wrong: the session ended (the third reading);
/// sign in again.
sessionEnded, sessionEnded,
/// The password was changed (a reset carried out), but this service /// The password was changed (a reset carried out), but this service
@ -128,8 +124,6 @@ enum PasswordRefusal implements _is.SerializableModel {
return PasswordRefusal.notDue; return PasswordRefusal.notDue;
case 'noReset': case 'noReset':
return PasswordRefusal.noReset; return PasswordRefusal.noReset;
case 'wrongExistingCode':
return PasswordRefusal.wrongExistingCode;
case 'otherInstance': case 'otherInstance':
return PasswordRefusal.otherInstance; return PasswordRefusal.otherInstance;
case 'codesWait': case 'codesWait':

View file

@ -12,12 +12,13 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes // ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is; import 'package:serverpod/serverpod.dart' as _is;
/// The codes a signed-in session tried in a row without one accepted (ADR /// The proofs a signed-in session sent in a row without one accepted
/// 0017; the re-review's third finding): removing an authenticator, new /// (`OwnerProof`, the password and a code; ADR 0017, the third reading):
/// recovery codes, confirming an authenticator. They count per session, /// every change of how an account is secured. They count per session,
/// never toward the account's waits (a stolen session alone would hold its /// never toward the account's waits (whoever holds a session or knows the
/// owner out of their codes), and the tenth in a row ends the session. The /// password would hold its owner out of their codes), and the tenth wrong in
/// row goes once a code is accepted, with the session, or with the person. /// a row ends the session. The row goes once a proof is accepted, with the
/// session, or with the person.
abstract class SessionCodeFailures abstract class SessionCodeFailures
implements _is.TableRow<int?>, _is.ProtocolSerialization { implements _is.TableRow<int?>, _is.ProtocolSerialization {
SessionCodeFailures._({ SessionCodeFailures._({
@ -65,7 +66,7 @@ abstract class SessionCodeFailures
/// Its person (deleted with them, `deletePerson`). /// Its person (deleted with them, `deletePerson`).
_is.UuidValue authUserId; _is.UuidValue authUserId;
/// Codes tried in a row since the last accepted, counted before each is /// Proofs sent in a row since the last accepted, counted before each is
/// checked. /// checked.
int failures; int failures;
@ -227,7 +228,7 @@ class SessionCodeFailuresTable extends _is.Table<int?> {
/// Its person (deleted with them, `deletePerson`). /// Its person (deleted with them, `deletePerson`).
late final _is.ColumnUuid authUserId; late final _is.ColumnUuid authUserId;
/// Codes tried in a row since the last accepted, counted before each is /// Proofs sent in a row since the last accepted, counted before each is
/// checked. /// checked.
late final _is.ColumnInt failures; late final _is.ColumnInt failures;

View file

@ -36,6 +36,7 @@ import 'password/authenticator_enrolment.dart' as _iv7tra7v;
import 'password/deletion_proof.dart' as _ix1u7hdq; import 'password/deletion_proof.dart' as _ix1u7hdq;
import 'password/key_proof_challenge.dart' as _iehtx95q; import 'password/key_proof_challenge.dart' as _iehtx95q;
import 'password/new_password.dart' as _itvvd6m6; import 'password/new_password.dart' as _itvvd6m6;
import 'password/owner_proof.dart' as _i5f4sdg4;
import 'password/password_challenge.dart' as _iuxisac7; import 'password/password_challenge.dart' as _iuxisac7;
import 'password/password_costs.dart' as _izp492e5; import 'password/password_costs.dart' as _izp492e5;
import 'password/password_proof.dart' as _ionb75gc; import 'password/password_proof.dart' as _ionb75gc;
@ -75,6 +76,7 @@ export 'password/authenticator_enrolment.dart';
export 'password/deletion_proof.dart'; export 'password/deletion_proof.dart';
export 'password/key_proof_challenge.dart'; export 'password/key_proof_challenge.dart';
export 'password/new_password.dart'; export 'password/new_password.dart';
export 'password/owner_proof.dart';
export 'password/password_challenge.dart'; export 'password/password_challenge.dart';
export 'password/password_costs.dart'; export 'password/password_costs.dart';
export 'password/password_proof.dart'; export 'password/password_proof.dart';
@ -1068,6 +1070,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _itvvd6m6.NewPassword) { if (t == _itvvd6m6.NewPassword) {
return _itvvd6m6.NewPassword.fromJson(data) as T; return _itvvd6m6.NewPassword.fromJson(data) as T;
} }
if (t == _i5f4sdg4.OwnerProof) {
return _i5f4sdg4.OwnerProof.fromJson(data) as T;
}
if (t == _iuxisac7.PasswordChallenge) { if (t == _iuxisac7.PasswordChallenge) {
return _iuxisac7.PasswordChallenge.fromJson(data) as T; return _iuxisac7.PasswordChallenge.fromJson(data) as T;
} }
@ -1207,6 +1212,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _is.getType<_itvvd6m6.NewPassword?>()) { if (t == _is.getType<_itvvd6m6.NewPassword?>()) {
return (data != null ? _itvvd6m6.NewPassword.fromJson(data) : null) as T; return (data != null ? _itvvd6m6.NewPassword.fromJson(data) : null) as T;
} }
if (t == _is.getType<_i5f4sdg4.OwnerProof?>()) {
return (data != null ? _i5f4sdg4.OwnerProof.fromJson(data) : null) as T;
}
if (t == _is.getType<_iuxisac7.PasswordChallenge?>()) { if (t == _is.getType<_iuxisac7.PasswordChallenge?>()) {
return (data != null ? _iuxisac7.PasswordChallenge.fromJson(data) : null) return (data != null ? _iuxisac7.PasswordChallenge.fromJson(data) : null)
as T; as T;
@ -1359,6 +1367,7 @@ class Protocol extends _is.DatabaseSerializationManager {
_ix1u7hdq.DeletionProof => 'DeletionProof', _ix1u7hdq.DeletionProof => 'DeletionProof',
_iehtx95q.KeyProofChallenge => 'KeyProofChallenge', _iehtx95q.KeyProofChallenge => 'KeyProofChallenge',
_itvvd6m6.NewPassword => 'NewPassword', _itvvd6m6.NewPassword => 'NewPassword',
_i5f4sdg4.OwnerProof => 'OwnerProof',
_iuxisac7.PasswordChallenge => 'PasswordChallenge', _iuxisac7.PasswordChallenge => 'PasswordChallenge',
_izp492e5.PasswordCosts => 'PasswordCosts', _izp492e5.PasswordCosts => 'PasswordCosts',
_ionb75gc.PasswordProof => 'PasswordProof', _ionb75gc.PasswordProof => 'PasswordProof',
@ -1432,6 +1441,8 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'KeyProofChallenge'; return 'KeyProofChallenge';
case _itvvd6m6.NewPassword(): case _itvvd6m6.NewPassword():
return 'NewPassword'; return 'NewPassword';
case _i5f4sdg4.OwnerProof():
return 'OwnerProof';
case _iuxisac7.PasswordChallenge(): case _iuxisac7.PasswordChallenge():
return 'PasswordChallenge'; return 'PasswordChallenge';
case _izp492e5.PasswordCosts(): case _izp492e5.PasswordCosts():
@ -1541,6 +1552,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'NewPassword') { if (dataClassName == 'NewPassword') {
return deserialize<_itvvd6m6.NewPassword>(data['data']); return deserialize<_itvvd6m6.NewPassword>(data['data']);
} }
if (dataClassName == 'OwnerProof') {
return deserialize<_i5f4sdg4.OwnerProof>(data['data']);
}
if (dataClassName == 'PasswordChallenge') { if (dataClassName == 'PasswordChallenge') {
return deserialize<_iuxisac7.PasswordChallenge>(data['data']); return deserialize<_iuxisac7.PasswordChallenge>(data['data']);
} }

View file

@ -1,13 +1,16 @@
import 'package:halkyon_base_server/src/generated/protocol.dart'; import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/session_code_tries.dart'; import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:halkyon_base_server/src/password/owner_proof_tries.dart';
import 'package:halkyon_base_server/src/register/account_register.dart'; import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:serverpod/serverpod.dart'; import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart'; import 'package:serverpod_auth_idp_server/core.dart';
/// The signed-in person's authenticators and recovery codes (ADR 0017), /// The signed-in person's authenticators and recovery codes (ADR 0017),
/// which the instance's register keeps (ADR 0016). Every code tried here /// which the instance's register keeps (ADR 0016). Once one is confirmed,
/// counts for the session (`asSessionCodeTry`): the tenth wrong in a row /// every change takes the owner's proof (`OwnerProof`: the password and a
/// ends it ([PasswordRefusal.sessionEnded]). /// code; the third reading), which counts for the session
/// (`asOwnerProofTry`): the tenth wrong in a row ends it
/// ([PasswordRefusal.sessionEnded]).
class AuthenticatorEndpoint extends Endpoint { class AuthenticatorEndpoint extends Endpoint {
@override @override
bool get requireLogin => true; bool get requireLogin => true;
@ -27,57 +30,75 @@ class AuthenticatorEndpoint extends Endpoint {
); );
/// Confirms [authenticator] with a [code] from it, and, where the account /// Confirms [authenticator] with a [code] from it, and, where the account
/// has a confirmed authenticator already, an [existingCode] from that one; /// has a confirmed authenticator already, the owner's [proof]; the
/// the account's first returns its recovery codes, shown once. Refusals as /// account's first returns its recovery codes, shown once. Refusals as
/// `confirmAuthenticator`'s. /// `confirmAuthenticator`'s.
Future<List<String>> confirm( Future<List<String>> confirm(
Session session, { Session session, {
required UuidValue authenticator, required UuidValue authenticator,
required String code, required String code,
String? existingCode, OwnerProof? proof,
}) => asSessionCodeTry( }) {
session, final bound = _bound(session, proof);
() => AccountRegister.of(session.serverpod).confirmAuthenticator( return asOwnerProofTry(
session, session,
person: session.authenticated!.authUserId, bound,
authenticator: authenticator, () => AccountRegister.of(session.serverpod).confirmAuthenticator(
code: code, session,
existingCode: existingCode, person: session.authenticated!.authUserId,
), authenticator: authenticator,
); code: code,
proof: bound,
),
);
}
/// The caller's authenticators, the oldest first. /// The caller's authenticators, the oldest first.
Future<List<Authenticator>> list(Session session) => AccountRegister.of( Future<List<Authenticator>> list(Session session) => AccountRegister.of(
session.serverpod, session.serverpod,
).authenticatorsOf(session, session.authenticated!.authUserId); ).authenticatorsOf(session, session.authenticated!.authUserId);
/// Removes [authenticator]: a confirmed one with a [code] from one of the /// Removes [authenticator]: one never confirmed as it is, a confirmed one
/// caller's, while another stays. Refusals as `removeAuthenticator`'s. /// with the owner's [proof], while another stays. Refusals as
/// `removeAuthenticator`'s.
Future<void> remove( Future<void> remove(
Session session, { Session session, {
required UuidValue authenticator, required UuidValue authenticator,
String? code, OwnerProof? proof,
}) => asSessionCodeTry( }) {
session, final bound = _bound(session, proof);
() => AccountRegister.of(session.serverpod).removeAuthenticator( return asOwnerProofTry(
session, session,
person: session.authenticated!.authUserId, bound,
authenticator: authenticator, () => AccountRegister.of(session.serverpod).removeAuthenticator(
code: code, session,
), person: session.authenticated!.authUserId,
); authenticator: authenticator,
proof: bound,
),
);
}
/// New recovery codes in place of the old, with a [code] from one of the /// New recovery codes in place of the old, with the owner's [proof]:
/// caller's authenticators: shown once. Refusals as `newRecoveryCodes`'. /// shown once. Refusals as `newRecoveryCodes`'.
Future<List<String>> newRecoveryCodes( Future<List<String>> newRecoveryCodes(
Session session, { Session session, {
required String code, required OwnerProof proof,
}) => asSessionCodeTry( }) {
session, final bound = _bound(session, proof)!;
() => AccountRegister.of(session.serverpod).newRecoveryCodes( return asOwnerProofTry(
session, session,
person: session.authenticated!.authUserId, bound,
code: code, () => AccountRegister.of(session.serverpod).newRecoveryCodes(
), session,
); person: session.authenticated!.authUserId,
proof: bound,
),
);
}
/// [proof] bound to the host the device addressed, checked to be this
/// server's own before anything counts (`deviceHostOn`).
OwnerProof? _bound(Session session, OwnerProof? proof) =>
proof?.copyWith(host: deviceHostOn(session.serverpod, proof.host));
} }

View file

@ -1,8 +1,9 @@
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart'; import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/owner_proof_tries.dart';
import 'package:halkyon_base_server/src/password/password_change.dart';
import 'package:halkyon_base_server/src/password/password_config.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/password/passwords.dart';
import 'package:halkyon_base_server/src/password/session_code_tries.dart';
import 'package:halkyon_base_server/src/password/totp_secrets.dart'; import 'package:halkyon_base_server/src/password/totp_secrets.dart';
import 'package:halkyon_base_server/src/profile/profile_rules.dart'; import 'package:halkyon_base_server/src/profile/profile_rules.dart';
import 'package:halkyon_base_server/src/totp/recovery_codes.dart'; import 'package:halkyon_base_server/src/totp/recovery_codes.dart';
@ -14,6 +15,9 @@ import 'package:serverpod/serverpod.dart';
// is made here and shown once (an `otpauth://` URI), counts once a code from // is made here and shown once (an `otpauth://` URI), counts once a code from
// it is accepted, and the first one confirmed brings ten recovery codes, // it is accepted, and the first one confirmed brings ten recovery codes,
// shown once too. TOTP is mandatory: the last confirmed authenticator stays. // shown once too. TOTP is mandatory: the last confirmed authenticator stays.
// Once one is confirmed, every change takes the owner's proof (`OwnerProof`:
// the password and a code, `proveOwner`, the third reading): a session alone
// never gets a code of the account's checked.
/// How many authenticators an account may have, confirmed or not. /// How many authenticators an account may have, confirmed or not.
const maxAuthenticators = 10; const maxAuthenticators = 10;
@ -106,36 +110,40 @@ String _labelOf(String label) {
: plain; : plain;
} }
/// Confirms [person]'s [authenticator] with a [code] from it: it counts from /// Confirms [person]'s [authenticator], waiting to be confirmed, with a
/// now on, and a reset of the authenticators, waiting or taken effect, is /// [code] from it: it counts from now on, and a reset of the
/// over (the person, signed in, has one again). The /// authenticators, waiting or taken effect, is over (the person, signed in,
/// account's first confirmed authenticator brings a new set of recovery /// has one again). The account's first confirmed authenticator brings a new
/// codes, returned to be shown once; otherwise none. Confirming one /// set of recovery codes, returned to be shown once; otherwise none.
/// confirmed before only checks the code.
/// ///
/// While the account has a confirmed authenticator, another counts only /// While the account has a confirmed authenticator, another counts only
/// with an [existingCode] from one of those too (each step's code once, as /// with the owner's [proof] (the password and a code from one of those,
/// signing in takes it; decided 20 Oct 2026): otherwise whoever holds a /// [proveOwner]; the third reading): otherwise whoever holds a session
/// session could add a second factor of their own, sign in with it and /// could add a second factor of their own, sign in with it and remove the
/// remove the owner's. Without one (the first, or after a reset carried /// owner's. Without one (the first, or after a reset carried out) the new
/// out) none is asked for. /// one's own code alone confirms it, and a [proof] is refused: one given is
/// always checked or refused, so no success ever passes a proof unchecked
/// (the session's count of wrong proofs clears only on an accepted one,
/// `asOwnerProofTry`).
/// ///
/// Refused ([PasswordRefusedException]) for an authenticator not the /// Refused ([PasswordRefusedException]) for an authenticator not the
/// person's ([PasswordRefusal.unknownAuthenticator]), a wrong or used code /// person's, or one confirmed already, whose code a session alone must never
/// ([PasswordRefusal.wrongCode]), a missing, wrong or used code from one /// get checked ([PasswordRefusal.unknownAuthenticator]); a wrong or used
/// confirmed before ([PasswordRefusal.wrongExistingCode]; checked only once /// code from the new one ([PasswordRefusal.wrongCode], before any proof is
/// [code] is right, so a wrong one uses none). The session's tries are /// checked, so it spends none); a proof where none is taken
/// counted where it is (`asSessionCodeTry`, the endpoint's), never toward /// ([PasswordRefusal.invalidSetup]); and as [proveOwner].
/// the account's waits.
Future<List<String>> confirmAuthenticator( Future<List<String>> confirmAuthenticator(
Session session, { Session session, {
required UuidValue person, required UuidValue person,
required UuidValue authenticator, required UuidValue authenticator,
required String code, required String code,
String? existingCode, OwnerProof? proof,
}) async { }) async {
final keys = requirePasswordKeys(session.serverpod); final keys = requirePasswordKeys(session.serverpod);
final row = await _authenticatorOf(session, person, authenticator); final row = await _authenticatorOf(session, person, authenticator);
if (row.confirmedAt != null) {
throw refusedFor(PasswordRefusal.unknownAuthenticator);
}
final secret = await openTotpSecret( final secret = await openTotpSecret(
row.secret, row.secret,
totpKey: keys.totpKey, totpKey: keys.totpKey,
@ -151,15 +159,13 @@ Future<List<String>> confirmAuthenticator(
TotpAccepted(:final step) => step, TotpAccepted(:final step) => step,
TotpRefused() => throw refusedFor(PasswordRefusal.wrongCode), TotpRefused() => throw refusedFor(PasswordRefusal.wrongCode),
}; };
if (row.confirmedAt == null) { if (await hasConfirmedAuthenticator(session, person)) {
final confirmedBefore = await confirmedAuthenticatorsOf(session, person); if (proof == null) {
if (confirmedBefore.isNotEmpty) { throw refusedFor(PasswordRefusal.wrongCredentials);
final typed = existingCode?.trim() ?? '';
if (typed.isEmpty ||
!await acceptTotpCode(session, keys, confirmedBefore, typed)) {
throw refusedFor(PasswordRefusal.wrongExistingCode);
}
} }
await proveOwner(session, keys, person: person, proof: proof);
} else if (proof != null) {
throw refusedFor(PasswordRefusal.invalidSetup);
} }
return await session.db.transaction((transaction) async { return await session.db.transaction((transaction) async {
// UPDATE "totp_authenticator" SET "lastStep" = $1, "confirmedAt" = $2 // UPDATE "totp_authenticator" SET "lastStep" = $1, "confirmedAt" = $2
@ -169,7 +175,7 @@ Future<List<String>> confirmAuthenticator(
session, session,
columnValues: (t) => [ columnValues: (t) => [
t.lastStep(step), t.lastStep(step),
t.confirmedAt(row.confirmedAt ?? clock.now().toUtc()), t.confirmedAt(clock.now().toUtc()),
], ],
where: (t) => where: (t) =>
t.id.equals(authenticator) & t.id.equals(authenticator) &
@ -179,9 +185,6 @@ Future<List<String>> confirmAuthenticator(
if (confirmed.isEmpty) { if (confirmed.isEmpty) {
throw refusedFor(PasswordRefusal.wrongCode); throw refusedFor(PasswordRefusal.wrongCode);
} }
if (row.confirmedAt != null) {
return const <String>[];
}
// DELETE FROM "account_reset" WHERE "authUserId" = $1 AND "kind" = $2 // DELETE FROM "account_reset" WHERE "authUserId" = $1 AND "kind" = $2
await AccountReset.db.deleteWhere( await AccountReset.db.deleteWhere(
session, session,
@ -232,24 +235,29 @@ Future<List<Authenticator>> authenticatorsOf(
), ),
]; ];
/// Removes [person]'s [authenticator]. One never confirmed goes as it is; a /// Removes [person]'s [authenticator]. One never confirmed goes as it is,
/// confirmed one only with a [code] from one of the account's confirmed /// with no [proof] (one given is refused, so it is never passed unchecked);
/// authenticators, and only while another confirmed one stays. /// a confirmed one only with the owner's [proof] (the password and a code,
/// [proveOwner]; the third reading), and only while another confirmed one
/// stays.
/// ///
/// Refused ([PasswordRefusedException]) for an authenticator not the /// Refused ([PasswordRefusedException]) for an authenticator not the
/// person's ([PasswordRefusal.unknownAuthenticator]), the last confirmed one /// person's ([PasswordRefusal.unknownAuthenticator]), the last confirmed one
/// ([PasswordRefusal.lastAuthenticator]), a wrong, used or missing code /// ([PasswordRefusal.lastAuthenticator], before any proof is checked), a
/// ([PasswordRefusal.wrongCode]), and beyond 10 codes tried an hour /// proof missing ([PasswordRefusal.wrongCredentials]) or given where none is
/// ([PasswordRefusal.tooManyAttempts]). /// taken ([PasswordRefusal.invalidSetup]), and as [proveOwner].
Future<void> removeAuthenticator( Future<void> removeAuthenticator(
Session session, { Session session, {
required UuidValue person, required UuidValue person,
required UuidValue authenticator, required UuidValue authenticator,
String? code, OwnerProof? proof,
}) async { }) async {
final keys = requirePasswordKeys(session.serverpod); final keys = requirePasswordKeys(session.serverpod);
final row = await _authenticatorOf(session, person, authenticator); final row = await _authenticatorOf(session, person, authenticator);
if (row.confirmedAt == null) { if (row.confirmedAt == null) {
if (proof != null) {
throw refusedFor(PasswordRefusal.invalidSetup);
}
// DELETE FROM "totp_authenticator" WHERE "id" = $1 AND "authUserId" = $2 // DELETE FROM "totp_authenticator" WHERE "id" = $1 AND "authUserId" = $2
await TotpAuthenticator.db.deleteWhere( await TotpAuthenticator.db.deleteWhere(
session, session,
@ -261,7 +269,10 @@ Future<void> removeAuthenticator(
if (confirmed.length < 2) { if (confirmed.length < 2) {
throw refusedFor(PasswordRefusal.lastAuthenticator); throw refusedFor(PasswordRefusal.lastAuthenticator);
} }
await _requireAuthenticatorCode(session, keys, person, confirmed, code); if (proof == null) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
await proveOwner(session, keys, person: person, proof: proof);
// Serializable: two removals at once must not take both of the last two. // Serializable: two removals at once must not take both of the last two.
await session.db.transaction( await session.db.transaction(
(transaction) async { (transaction) async {
@ -292,22 +303,16 @@ Future<void> removeAuthenticator(
); );
} }
/// A new set of recovery codes for [person], replacing the old, with a /// A new set of recovery codes for [person], replacing the old, with the
/// [code] from one of their confirmed authenticators: returned to be shown /// owner's [proof] (the password and a code, [proveOwner]; the third
/// once. Refused as [removeAuthenticator] refuses a code /// reading): returned to be shown once. Refused as [proveOwner].
/// ([PasswordRefusal.wrongCode], [PasswordRefusal.tooManyAttempts]), and
/// without a confirmed authenticator ([PasswordRefusal.setupIncomplete]).
Future<List<String>> newRecoveryCodes( Future<List<String>> newRecoveryCodes(
Session session, { Session session, {
required UuidValue person, required UuidValue person,
required String code, required OwnerProof proof,
}) async { }) async {
final keys = requirePasswordKeys(session.serverpod); final keys = requirePasswordKeys(session.serverpod);
final confirmed = await confirmedAuthenticatorsOf(session, person); await proveOwner(session, keys, person: person, proof: proof);
if (confirmed.isEmpty) {
throw refusedFor(PasswordRefusal.setupIncomplete);
}
await _requireAuthenticatorCode(session, keys, person, confirmed, code);
return await session.db.transaction( return await session.db.transaction(
(transaction) => (transaction) =>
_replaceRecoveryCodes(session, keys, person, transaction: transaction), _replaceRecoveryCodes(session, keys, person, transaction: transaction),
@ -316,10 +321,9 @@ Future<List<String>> newRecoveryCodes(
/// Deletes the authenticators nobody confirmed within /// Deletes the authenticators nobody confirmed within
/// [unconfirmedAuthenticatorLifetime] at [now], the sign-in challenges past /// [unconfirmedAuthenticatorLifetime] at [now], the sign-in challenges past
/// their time and the code tries of sessions that ended /// their time and the counts of wrong proofs of sessions that ended
/// (`forgetEndedSessionsCodeTries`), and returns how many rows went: hourly, /// (`forgetEndedSessionsCodeTries`), and returns how many rows went: hourly,
/// with the /// with the pruning (`PruningFutureCall`).
/// pruning (`PruningFutureCall`).
Future<int> forgetUnconfirmedAuthenticators( Future<int> forgetUnconfirmedAuthenticators(
Session session, { Session session, {
required DateTime now, required DateTime now,
@ -341,24 +345,6 @@ Future<int> forgetUnconfirmedAuthenticators(
return authenticators.length + challenges.length + tries; return authenticators.length + challenges.length + tries;
} }
/// Requires [code] to be a fresh code of one of [person]'s [confirmed]
/// authenticators; refused otherwise ([PasswordRefusal.wrongCode]). A
/// signed-in session's try: counted where the session is
/// (`asSessionCodeTry`), never toward the account's waits.
Future<void> _requireAuthenticatorCode(
Session session,
PasswordKeys keys,
UuidValue person,
List<TotpAuthenticator> confirmed,
String? code, {
PasswordRefusal refusal = PasswordRefusal.wrongCode,
}) async {
final typed = code?.trim() ?? '';
if (typed.isEmpty || !await acceptTotpCode(session, keys, confirmed, typed)) {
throw refusedFor(refusal);
}
}
/// [person]'s authenticator [id]; refused /// [person]'s authenticator [id]; refused
/// ([PasswordRefusal.unknownAuthenticator]) for anyone else's, or none. /// ([PasswordRefusal.unknownAuthenticator]) for anyone else's, or none.
Future<TotpAuthenticator> _authenticatorOf( Future<TotpAuthenticator> _authenticatorOf(

View file

@ -1,8 +1,8 @@
### What deleting an account that has a password takes (ADR 0017; the second ### What deleting an account that has a password takes (ADR 0017; the second
### reading): the password's SRP proof for a challenge `password.beginChange` ### reading): the password's SRP proof for a challenge `password.beginChange`
### gave, a TOTP or recovery code, and the host the device addressed, as a ### gave, a TOTP or recovery code, and the host the device addressed, as a
### change of password does. A session alone deletes only an account without ### change of password does (`OwnerProof`). A session alone deletes only an
### one. ### account without one.
class: DeletionProof class: DeletionProof
fields: fields:
challenge: UuidValue challenge: UuidValue
@ -19,8 +19,3 @@ fields:
### The host the device addressed the instance by: the server it reached ### The host the device addressed the instance by: the server it reached
### checks it is its own before the register binds the proof to it. ### checks it is its own before the register binds the proof to it.
host: String host: String
### The account's address as the device typed it, and the language its
### mails are worded in: checked before the code, so the owner is mailed
### should codes come to wait (the re-review's fourth finding).
address: String
language: String

View file

@ -0,0 +1,18 @@
### What a signed-in owner proves to change how their account is secured
### (ADR 0017; the third reading, as Proton has it): the password's SRP
### proof for a challenge `password.beginChange` gave, bound to the host the
### device addressed, and a TOTP code from one of the account's confirmed
### authenticators or one of its recovery codes. Adding an authenticator once
### one is confirmed, removing a confirmed one, new recovery codes, a new
### recovery phrase and a new password take it: a session alone never gets a
### code checked.
class: OwnerProof
fields:
challenge: UuidValue
clientValue: ByteData
clientProof: ByteData
### The host the device addressed the instance by: the server it reached
### checks it is its own before the register binds the proof to it.
host: String
totpCode: String?
recoveryCode: String?

View file

@ -0,0 +1,133 @@
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
// A signed-in session's proofs (`OwnerProof`: the password and a code, ADR
// 0017; the third reading, as Proton has it): adding an authenticator once
// one is confirmed, removing a confirmed one, new recovery codes, a new
// recovery phrase, a new password, deleting the account. A session alone
// never gets a code checked: every code a signed-in call carries comes after
// the password proved right. Each proof sent counts against its session,
// where the session is (the register's own, or a member's, which forwards
// the proof), never toward the account's waits: those hold sign-ins back,
// and a session (or whoever knows the password) would otherwise hold its
// owner out of changing the password, the way to end that session. A proof
// is counted before it is checked (one statement, so proofs sent at once
// count each), only a proof accepted clears the count (an operation given a
// proof always checks it or refuses it: `confirmAuthenticator`,
// `removeAuthenticator`), and the [ownerProofTriesBeforeSignOut]th wrong in a
// row ends the session.
/// How many proofs a session sends wrong in a row before it ends.
const ownerProofTriesBeforeSignOut = 10;
/// The refusal that says a proof (the password, or the code with it) was
/// wrong: it counts. Any other outcome (a refusal of something else, an
/// error) checked no proof or found it right, and is taken back.
const _wrongProofs = {PasswordRefusal.wrongCredentials};
/// [check]'s outcome, with [proof] sent from [session]'s signed-in session
/// (none: [check] alone, counted nowhere, as it checks no code of the
/// account's): counted before [check] runs, refused
/// ([PasswordRefusal.sessionEnded]) and the session ended once proofs in a
/// row go past [ownerProofTriesBeforeSignOut] or the last of them is wrong.
/// A proof accepted clears the count.
Future<T> asOwnerProofTry<T>(
Session session,
Object? proof,
Future<T> Function() check,
) async {
if (proof == null) {
return await check();
}
final authenticated = session.authenticated!;
final sessionId = UuidValue.withValidation(authenticated.authId);
final person = authenticated.authUserId;
final now = clock.now().toUtc();
final counted = await session.db.unsafeQuery(
'INSERT INTO "session_code_failures" '
'("sessionId", "authUserId", "failures", "updatedAt") '
'VALUES (@session, @person, 1, @now) '
'ON CONFLICT ("sessionId") DO UPDATE SET '
'"failures" = "session_code_failures"."failures" + 1, '
'"updatedAt" = @now '
'RETURNING "failures"',
parameters: QueryParameters.named({
'session': sessionId.uuid,
'person': person.uuid,
'now': now,
}),
);
final tries = counted.first[0]! as int;
if (tries > ownerProofTriesBeforeSignOut) {
await _endSession(session, sessionId, person);
throw PasswordRefusedException(reason: PasswordRefusal.sessionEnded);
}
try {
final result = await check();
await _clear(session, sessionId);
return result;
} on PasswordRefusedException catch (refusal) {
if (!_wrongProofs.contains(refusal.reason)) {
await _takeBack(session, sessionId);
rethrow;
}
if (tries >= ownerProofTriesBeforeSignOut) {
await _endSession(session, sessionId, person);
throw PasswordRefusedException(reason: PasswordRefusal.sessionEnded);
}
rethrow;
} on Object {
await _takeBack(session, sessionId);
rethrow;
}
}
/// Deletes the counts of sessions that ended: hourly
/// (`PruningFutureCall`); returns how many.
Future<int> forgetEndedSessionsCodeTries(Session session) async {
final forgotten = await session.db.unsafeExecute(
'DELETE FROM "session_code_failures" WHERE "sessionId" NOT IN '
'(SELECT "id" FROM "serverpod_auth_core_session")',
);
return forgotten;
}
/// Clears [sessionId]'s count: a proof was accepted.
Future<void> _clear(Session session, UuidValue sessionId) =>
// DELETE FROM "session_code_failures" WHERE "sessionId" = $1
SessionCodeFailures.db.deleteWhere(
session,
where: (t) => t.sessionId.equals(sessionId),
);
/// Takes back [sessionId]'s last try: it checked no proof, or found it
/// right.
Future<void> _takeBack(Session session, UuidValue sessionId) =>
session.db.unsafeExecute(
'UPDATE "session_code_failures" '
'SET "failures" = GREATEST("failures" - 1, 0) '
'WHERE "sessionId" = @session',
parameters: QueryParameters.named({'session': sessionId.uuid}),
);
/// Ends [sessionId], [person]'s; their device learns at once. Its count
/// stays, so tries already under way when it ended are refused too, until
/// the hourly pruning forgets it ([forgetEndedSessionsCodeTries]).
Future<void> _endSession(
Session session,
UuidValue sessionId,
UuidValue person,
) async {
// DELETE FROM "serverpod_auth_core_session"
// WHERE "id" = $1 AND "authUserId" = $2
await ServerSideSession.db.deleteWhere(
session,
where: (t) => t.id.equals(sessionId) & t.authUserId.equals(person),
);
await session.messages.authenticationRevoked(
person.uuid,
RevokedAuthenticationAuthId(authId: sessionId.uuid),
);
}

Some files were not shown because too many files have changed in this diff Show more