fix(auth)!: the first authenticator takes the password, and a reset ends every session

The fourth reading's second finding: a stolen session outlived the reset
of the authenticators (carrying it out ended no session) and confirmed
the account's first authenticator on its own code alone, ahead of the
owner, so the owner's confirmation and sign-in were refused after.

- Carrying out a reset of the authenticators ends every session of the
  account, on the register at once and on every member by notice, as the
  other resets do; the sign-in that carries it out gets its session after
  that moment (PasswordProof.sessionsEndedBefore, issueSessionAfter).
- Confirming the first authenticator takes the password's proof for a
  change's challenge, without a code (proveOwnersPassword), counted per
  session as the owner's proofs are: a session of a lost phone, or of a
  setup never finished, claims no second factor.

The app sends the password with every confirmation: the sign-up's, a
reset's and the account section's (which asks for it), and a sign-in's
own unlock where the session is still held back.

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 11:43:22 +02:00
commit d7855162c7
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 548 additions and 95 deletions

View file

@ -29,6 +29,7 @@ abstract class PasswordProof
required this.signingPublicKey,
required this.passwordKeys,
required this.needsAuthenticator,
this.sessionsEndedBefore,
});
factory PasswordProof({
@ -38,6 +39,7 @@ abstract class PasswordProof
required _idt.ByteData signingPublicKey,
required _i2rxumte.WrappedKeys passwordKeys,
required bool needsAuthenticator,
DateTime? sessionsEndedBefore,
}) = _PasswordProofImpl;
factory PasswordProof.fromJson(Map<String, dynamic> jsonSerialization) {
@ -60,6 +62,11 @@ abstract class PasswordProof
needsAuthenticator: _is.BoolJsonExtension.fromJson(
jsonSerialization['needsAuthenticator'],
),
sessionsEndedBefore: jsonSerialization['sessionsEndedBefore'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['sessionsEndedBefore'],
),
);
}
@ -75,6 +82,12 @@ abstract class PasswordProof
bool needsAuthenticator;
/// Where the sign-in carried out a reset of the authenticators: the
/// moment every session of the account made before ended (the fourth
/// reading), which the session issued for it comes after
/// (`issueSessionAfter`).
DateTime? sessionsEndedBefore;
/// Returns a shallow copy of this [PasswordProof]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@ -85,6 +98,7 @@ abstract class PasswordProof
_idt.ByteData? signingPublicKey,
_i2rxumte.WrappedKeys? passwordKeys,
bool? needsAuthenticator,
DateTime? sessionsEndedBefore,
});
@override
Map<String, dynamic> toJson() {
@ -96,6 +110,8 @@ abstract class PasswordProof
'signingPublicKey': signingPublicKey.toJson(),
'passwordKeys': passwordKeys.toJson(),
'needsAuthenticator': needsAuthenticator,
if (sessionsEndedBefore != null)
'sessionsEndedBefore': sessionsEndedBefore?.toJson(),
};
}
@ -110,6 +126,8 @@ abstract class PasswordProof
}
}
class _Undefined {}
class _PasswordProofImpl extends PasswordProof {
_PasswordProofImpl({
required _is.UuidValue account,
@ -118,6 +136,7 @@ class _PasswordProofImpl extends PasswordProof {
required _idt.ByteData signingPublicKey,
required _i2rxumte.WrappedKeys passwordKeys,
required bool needsAuthenticator,
DateTime? sessionsEndedBefore,
}) : super._(
account: account,
serverProof: serverProof,
@ -125,6 +144,7 @@ class _PasswordProofImpl extends PasswordProof {
signingPublicKey: signingPublicKey,
passwordKeys: passwordKeys,
needsAuthenticator: needsAuthenticator,
sessionsEndedBefore: sessionsEndedBefore,
);
/// Returns a shallow copy of this [PasswordProof]
@ -138,6 +158,7 @@ class _PasswordProofImpl extends PasswordProof {
_idt.ByteData? signingPublicKey,
_i2rxumte.WrappedKeys? passwordKeys,
bool? needsAuthenticator,
Object? sessionsEndedBefore = _Undefined,
}) {
return PasswordProof(
account: account ?? this.account,
@ -147,6 +168,9 @@ class _PasswordProofImpl extends PasswordProof {
signingPublicKey: signingPublicKey ?? this.signingPublicKey.clone(),
passwordKeys: passwordKeys ?? this.passwordKeys.copyWith(),
needsAuthenticator: needsAuthenticator ?? this.needsAuthenticator,
sessionsEndedBefore: sessionsEndedBefore is DateTime?
? sessionsEndedBefore
: this.sessionsEndedBefore,
);
}
}

View file

@ -146,6 +146,8 @@ Future<AccountSessionsEnded> resetPasswordWithPhrase(
final account = row.authUserId;
// Before any code is checked.
final notice = await _requireNoticeOf(session, account, address, language);
// A reset of the authenticators it carries out ends the sessions too;
// those made since go with the new password below.
await secondFactor(
session,
keys,

View file

@ -29,10 +29,10 @@ class AuthenticatorEndpoint extends Endpoint {
label: label,
);
/// Confirms [authenticator] with a [code] from it, and, where the account
/// has a confirmed authenticator already, the owner's [proof]; the
/// account's first returns its recovery codes, shown once. Refusals as
/// `confirmAuthenticator`'s.
/// Confirms [authenticator] with a [code] from it and the owner's [proof]:
/// the password and a code from a confirmed authenticator, or, for the
/// account's first, the password alone; the first returns its recovery
/// codes, shown once. Refusals as `confirmAuthenticator`'s.
Future<List<String>> confirm(
Session session, {
required UuidValue authenticator,

View file

@ -116,22 +116,25 @@ String _labelOf(String label) {
/// has one again). The account's first confirmed authenticator brings a new
/// set of recovery codes, returned to be shown once; otherwise none.
///
/// While the account has a confirmed authenticator, another counts only
/// with the owner's [proof] (the password and a code from one of those,
/// [proveOwner]; the third reading): otherwise whoever holds a session
/// could add a second factor of their own, sign in with it and remove the
/// owner's. Without one (the first, or after a reset carried out) the new
/// 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`).
/// Every one counts only with the owner's [proof], so a session alone never
/// adds a second factor: while the account has a confirmed authenticator,
/// the password and a code from one of those ([proveOwner]; the third
/// reading), else whoever holds a session could add one of their own, sign
/// in with it and remove the owner's; the first (none confirmed, or after
/// a reset carried out) the password alone ([proveOwnersPassword]; the
/// fourth reading), else a stolen session of a setup never finished, or one
/// a reset left, would claim the account's second factor before its owner.
/// A proof given is always checked or refused, so no success ever passes
/// one unchecked (the session's count of wrong proofs clears only on an
/// accepted one, `asOwnerProofTry`).
///
/// Refused ([PasswordRefusedException]) for an authenticator not the
/// person's, or one confirmed already, whose code a session alone must never
/// get checked ([PasswordRefusal.unknownAuthenticator]); a wrong or used
/// code from the new one ([PasswordRefusal.wrongCode], before any proof is
/// checked, so it spends none); a proof where none is taken
/// ([PasswordRefusal.invalidSetup]); and as [proveOwner].
/// checked, so it spends none); no proof ([PasswordRefusal.wrongCredentials]);
/// a code with the first's ([PasswordRefusal.invalidSetup]); and as
/// [proveOwner].
Future<List<String>> confirmAuthenticator(
Session session, {
required UuidValue person,
@ -159,13 +162,15 @@ Future<List<String>> confirmAuthenticator(
TotpAccepted(:final step) => step,
TotpRefused() => throw refusedFor(PasswordRefusal.wrongCode),
};
if (proof == null) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
if (await hasConfirmedAuthenticator(session, person)) {
if (proof == null) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
await proveOwner(session, keys, person: person, proof: proof);
} else if (proof != null) {
throw refusedFor(PasswordRefusal.invalidSetup);
} else {
// The first: the password alone (the fourth reading), as no confirmed
// authenticator gives a code yet.
await proveOwnersPassword(session, keys, person: person, proof: proof);
}
return await session.db.transaction((transaction) async {
// UPDATE "totp_authenticator" SET "lastStep" = $1, "confirmedAt" = $2

View file

@ -174,6 +174,35 @@ Future<void> requireOwnerToDelete(
}
}
/// [person]'s password row, once [proof] (`OwnerProof`) proved their
/// password alone, for a change's challenge of theirs, without a code: what
/// confirming an account's first authenticator takes (the fourth reading:
/// a session alone claims none; there is no confirmed authenticator yet to
/// take a code from). Refused ([PasswordRefusedException]) for a proof
/// carrying a code ([PasswordRefusal.invalidSetup]: none is checked here),
/// and as [proveOwner] for the password.
Future<AccountPassword> proveOwnersPassword(
Session session,
PasswordKeys keys, {
required UuidValue person,
required OwnerProof proof,
}) async {
if ((proof.totpCode?.trim() ?? '').isNotEmpty ||
(proof.recoveryCode?.trim() ?? '').isNotEmpty) {
throw refusedFor(PasswordRefusal.invalidSetup);
}
final proved = await _provePassword(
session,
keys,
person: person,
challenge: proof.challenge,
clientValue: proof.clientValue,
clientProof: proof.clientProof,
host: proof.host,
);
return proved.password;
}
/// [person]'s password row, once they proved owning the account with
/// [proof] (`OwnerProof`): the password's proof for a change's challenge,
/// [person]'s own, then a code ([ownersCode], which also calls off a reset

View file

@ -12,3 +12,8 @@ fields:
signingPublicKey: ByteData
passwordKeys: WrappedKeys
needsAuthenticator: bool
### Where the sign-in carried out a reset of the authenticators: the
### moment every session of the account made before ended (the fourth
### reading), which the session issued for it comes after
### (`issueSessionAfter`).
sessionsEndedBefore: DateTime?

View file

@ -64,11 +64,21 @@ class PasswordSignInEndpoint extends Endpoint {
address: address,
language: language,
);
final signedIn = await issueSession(
session,
proof.account,
method: passwordMethod,
);
final signedIn = switch (proof.sessionsEndedBefore) {
// A reset of the authenticators carried out ended every session of
// the account: this one comes after (the fourth reading).
final endedBefore? => await issueSessionAfter(
session,
proof.account,
method: passwordMethod,
endedBefore: endedBefore,
),
null => await issueSession(
session,
proof.account,
method: passwordMethod,
),
};
return PasswordSignedIn(
serverProof: proof.serverProof,
encryptionPublicKey: proof.encryptionPublicKey,

View file

@ -13,6 +13,7 @@ import 'package:halkyon_base_server/src/password/password_config.dart';
import 'package:halkyon_base_server/src/password/second_factor_waits.dart';
import 'package:halkyon_base_server/src/password/totp_secrets.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
import 'package:halkyon_base_server/src/srp/srp_group.dart';
import 'package:halkyon_base_server/src/srp/srp_math.dart';
import 'package:halkyon_base_server/src/srp/srp_server.dart';
@ -737,7 +738,7 @@ Future<PasswordProof> completePasswordSignIn(
language: language,
);
final account = proved.password.authUserId;
final bool needsAuthenticator;
final SecondFactorPassed passed;
if (request != null &&
code != null &&
!await hadConfirmedAuthenticator(session, account)) {
@ -751,9 +752,9 @@ Future<PasswordProof> completePasswordSignIn(
throw refusedFor(PasswordRefusal.wrongCredentials);
}
await _callOffResets(session, account);
needsAuthenticator = true;
passed = (needsAuthenticator: true, sessionsEndedBefore: null);
} else {
needsAuthenticator = await secondFactor(
passed = await secondFactor(
session,
keys,
account,
@ -772,7 +773,8 @@ Future<PasswordProof> completePasswordSignIn(
nonce: password.passwordKeysNonce,
cipherText: password.passwordKeysCipherText,
),
needsAuthenticator: needsAuthenticator,
needsAuthenticator: passed.needsAuthenticator,
sessionsEndedBefore: passed.sessionsEndedBefore,
);
}
@ -912,6 +914,16 @@ Future<SignInChallenge> takeChallenge(
return challenge;
}
/// What a second factor that passed says: whether the account must now
/// set up an authenticator, and, where a reset of its authenticators was
/// carried out by it, the moment every session of the account made before
/// ended (the fourth reading): the session the caller issues comes after it
/// (`issueSessionAfter`).
typedef SecondFactorPassed = ({
bool needsAuthenticator,
DateTime? sessionsEndedBefore,
});
/// Checks the second factor of a sign-in whose password (or recovery
/// phrase) proved right for [account], and returns whether the account must
/// now set up an authenticator.
@ -930,7 +942,7 @@ Future<SignInChallenge> takeChallenge(
/// wait, [PasswordRefusal.codesWait]; anything else,
/// [PasswordRefusal.wrongCredentials]. Every code is counted as it is
/// tried ([takeCodeAttempt]); [notice] tells the owner as a wait begins.
Future<bool> secondFactor(
Future<SecondFactorPassed> secondFactor(
Session session,
PasswordKeys keys,
UuidValue account, {
@ -955,7 +967,7 @@ Future<bool> secondFactor(
// and an authenticator enrolled since stays to be confirmed.
if (resetTookEffect) {
await _callOffAccountReset(session, account);
return true;
return (needsAuthenticator: true, sessionsEndedBefore: null);
}
throw refusedFor(PasswordRefusal.setupIncomplete);
}
@ -966,13 +978,14 @@ Future<bool> secondFactor(
: await _useRecoveryCode(session, keys, account, recovery)) {
await clearRefusedCodes(session, account);
await _callOffResets(session, account);
return false;
return (needsAuthenticator: false, sessionsEndedBefore: null);
}
await noticeCodesWait(session, waitBegan, notice: notice);
} else if (resetTookEffect) {
await _carryOutAuthenticatorReset(session, account);
final endedBefore = await _carryOutAuthenticatorReset(session, account);
await _callOffAccountReset(session, account);
return true;
await announceSessionsEnded(session, account);
return (needsAuthenticator: true, sessionsEndedBefore: endedBefore);
} else if (reset != null) {
throw PasswordRefusedException(
reason: PasswordRefusal.notDue,
@ -1173,10 +1186,11 @@ Future<bool> _useRecoveryCode(
}
/// Carries out the reset of [account]'s authenticators that took effect:
/// they, the recovery codes and the codes refused before go. The reset's
/// row stays until an
/// they, the recovery codes and the codes refused before go, and every
/// session of the account ends (the moment returned; call
/// `announceSessionsEnded` after). The reset's row stays until an
/// authenticator is confirmed: until then the password alone signs in.
Future<void> _carryOutAuthenticatorReset(
Future<DateTime> _carryOutAuthenticatorReset(
Session session,
UuidValue account,
) => session.db.transaction((transaction) async {
@ -1200,6 +1214,14 @@ Future<void> _carryOutAuthenticatorReset(
where: (t) => t.authUserId.equals(account),
transaction: transaction,
);
// Every session of the account ends, in every service, as the other
// resets end them (the fourth reading): a session of a lost phone would
// otherwise claim the account's first authenticator before its owner.
return await endSessionsEverywhere(
session,
account,
transaction: transaction,
);
});
/// Calls off the reset of [account] that a code by mail asked for: its