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
30 changed files with 548 additions and 95 deletions

View file

@ -220,5 +220,6 @@
"passwordSignInWithGitHubAgain": "Wieder mit GitHub anmelden", "passwordSignInWithGitHubAgain": "Wieder mit GitHub anmelden",
"passwordOfferTitle": "Richte ein Passwort ein", "passwordOfferTitle": "Richte ein Passwort ein",
"passwordOfferBody": "Die Anmeldung mit GitHub endet. Richte ein Passwort und eine Authenticator-App ein, um dein Konto zu behalten.", "passwordOfferBody": "Die Anmeldung mit GitHub endet. Richte ein Passwort und eine Authenticator-App ein, um dein Konto zu behalten.",
"passwordOfferLater": "Später" "passwordOfferLater": "Später",
"passwordAuthenticatorPasswordIntro": "Bestätige zum Hinzufügen mit deinem Passwort, dass du es bist."
} }

View file

@ -467,5 +467,6 @@
"passwordSignInWithGitHubAgain": "Sign in with GitHub again", "passwordSignInWithGitHubAgain": "Sign in with GitHub again",
"passwordOfferTitle": "Set up a password", "passwordOfferTitle": "Set up a password",
"passwordOfferBody": "Sign-in with GitHub is ending. Set up a password and an authenticator app to keep your account.", "passwordOfferBody": "Sign-in with GitHub is ending. Set up a password and an authenticator app to keep your account.",
"passwordOfferLater": "Later" "passwordOfferLater": "Later",
"passwordAuthenticatorPasswordIntro": "To add it, confirm it is you with your password."
} }

View file

@ -1428,6 +1428,12 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Later'** /// **'Later'**
String get passwordOfferLater; String get passwordOfferLater;
/// No description provided for @passwordAuthenticatorPasswordIntro.
///
/// In en, this message translates to:
/// **'To add it, confirm it is you with your password.'**
String get passwordAuthenticatorPasswordIntro;
} }
class _HalkyonAppLocalizationsDelegate class _HalkyonAppLocalizationsDelegate

View file

@ -828,4 +828,8 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override @override
String get passwordOfferLater => 'Später'; String get passwordOfferLater => 'Später';
@override
String get passwordAuthenticatorPasswordIntro =>
'Bestätige zum Hinzufügen mit deinem Passwort, dass du es bist.';
} }

View file

@ -811,6 +811,10 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override @override
String get passwordOfferLater => 'Later'; String get passwordOfferLater => 'Later';
@override
String get passwordAuthenticatorPasswordIntro =>
'To add it, confirm it is you with your password.';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -119,8 +119,8 @@ final class const AddAuthenticatorState({
} }
/// Adds an authenticator to the signed-in person's account: enrolled as it /// Adds an authenticator to the signed-in person's account: enrolled as it
/// opens, confirmed with its code (and, once one is confirmed, the owner's /// opens, confirmed with its code and the owner's proof (the password, and
/// proof: the password and a code from one of those, [needsExistingCode]), /// once one is confirmed a code from one of those, [needsExistingCode]),
/// the first one's recovery codes shown. /// the first one's recovery codes shown.
class AddAuthenticatorCubit( class AddAuthenticatorCubit(
final PasswordRepository _passwords, { final PasswordRepository _passwords, {
@ -147,7 +147,7 @@ class AddAuthenticatorCubit(
} }
} }
/// Confirms it with its [code] and, where needed, the [password] and /// Confirms it with its [code], the [password] and, where needed,
/// [existingCode]. /// [existingCode].
Future<void> codeEntered( Future<void> codeEntered(
String code, { String code, {
@ -166,7 +166,7 @@ class AddAuthenticatorCubit(
final codes = await _passwords.confirm( final codes = await _passwords.confirm(
setup.id, setup.id,
code, code,
password: needsExistingCode ? password : null, password: password,
existingCode: needsExistingCode ? existingCode : null, existingCode: needsExistingCode ? existingCode : null,
); );
if (!isClosed) { if (!isClosed) {

View file

@ -145,6 +145,10 @@ class AccountSetupCubit(
String? _request; String? _request;
PendingSignIn? _pending; PendingSignIn? _pending;
/// The password typed: the first authenticator takes it (the fourth
/// reading).
String? _password;
/// Whether the steps end in a session to put in place. /// Whether the steps end in a session to put in place.
bool get _entersSession => _mode != AccountSetupMode.setPassword; bool get _entersSession => _mode != AccountSetupMode.setPassword;
@ -176,6 +180,7 @@ class AccountSetupCubit(
return; return;
} }
emit(state.copyWith(passwordsDiffer: false)); emit(state.copyWith(passwordsDiffer: false));
_password = password;
await _run(() async { await _run(() async {
final phrase = _phrase; final phrase = _phrase;
final material = phrase == null final material = phrase == null
@ -301,6 +306,7 @@ class AccountSetupCubit(
final codes = await _passwords.confirm( final codes = await _passwords.confirm(
state.authenticator!.id, state.authenticator!.id,
code, code,
password: _password,
pending: _pending, pending: _pending,
); );
emit( emit(

View file

@ -223,6 +223,8 @@ class PasswordSignInCubit(
final codes = await _passwords.confirm( final codes = await _passwords.confirm(
state.authenticator!.id, state.authenticator!.id,
code, code,
// The first takes the password too (the fourth reading).
unlocked: _unlocked,
pending: _pending, pending: _pending,
); );
if (codes.isEmpty) { if (codes.isEmpty) {

View file

@ -121,7 +121,7 @@ abstract interface class PasswordGateway() {
Future<PasswordWays> ways(); Future<PasswordWays> ways();
/// A change of password's first step. /// A change of password's first step.
Future<protocol.PasswordChallenge> beginChange(); Future<protocol.PasswordChallenge> beginChange({SessionGrant? as});
/// Deletes the signed-in person's account, which has a password: [answer] /// Deletes the signed-in person's account, which has a password: [answer]
/// to [challenge] ([beginChange]) proves it, with a [totpCode] or /// to [challenge] ([beginChange]) proves it, with a [totpCode] or

View file

@ -267,24 +267,31 @@ class PasswordRepository(
PendingSignIn? pending, PendingSignIn? pending,
}) => _gateway.enrol(name: name, label: label, as: pending?.grant); }) => _gateway.enrol(name: name, label: label, as: pending?.grant);
/// Confirms authenticator [id] with its [code], and, where the account /// Confirms authenticator [id] with its [code] and the owner's proof (a
/// has one confirmed already, the person's [password] and [existingCode] /// session alone adds no second factor): the person's [password] (or
/// from one of those (the owner's proof: a session alone adds no second /// [unlocked], a sign-in's), and, where the account has one confirmed
/// factor); the recovery codes the first brings (shown once), none for /// already, [existingCode] from one of those; the first takes the
/// another. /// password alone (the fourth reading). The recovery codes the first
/// brings (shown once), none for another.
Future<List<String>> confirm( Future<List<String>> confirm(
String id, String id,
String code, { String code, {
String? password, String? password,
PasswordUnlocked? unlocked,
String? existingCode, String? existingCode,
PendingSignIn? pending, PendingSignIn? pending,
}) async { }) async {
final codes = await _gateway.confirm( final codes = await _gateway.confirm(
id, id,
code.replaceAll(' ', ''), code.replaceAll(' ', ''),
owner: password == null owner: password == null && unlocked == null
? null ? null
: await _ownerAnswer(password, totpCode: existingCode), : await _ownerAnswer(
password,
unlocked: unlocked,
totpCode: existingCode,
as: pending?.grant,
),
as: pending?.grant, as: pending?.grant,
); );
_changed(); _changed();
@ -317,19 +324,28 @@ class PasswordRepository(
await _ownerAnswer(password, totpCode: code), await _ownerAnswer(password, totpCode: code),
); );
/// The owner's proof (`OwnerProof`): [password] answering a change's /// The owner's proof (`OwnerProof`): [password] (or [unlocked], unlocked
/// challenge, with a [totpCode] or [recoveryCode]. /// at a sign-in already) answering a change's challenge, of the signed-in
/// session or [as], with a [totpCode] or [recoveryCode] where given.
Future<OwnerAnswer> _ownerAnswer( Future<OwnerAnswer> _ownerAnswer(
String password, { String? password, {
PasswordUnlocked? unlocked,
String? totpCode, String? totpCode,
String? recoveryCode, String? recoveryCode,
SessionGrant? as,
}) async { }) async {
final challenge = await _gateway.beginChange(); final challenge = await _gateway.beginChange(as: as);
final unlock = await _crypto.unlock( final salt = Uint8List.sublistView(challenge.salt);
password, final unlock = switch (unlocked?._unlock) {
salt: Uint8List.sublistView(challenge.salt), final kept? when kept.fits(salt, challenge.costs) => kept,
costs: challenge.costs, _ when password != null => await _crypto.unlock(
); password,
salt: salt,
costs: challenge.costs,
),
// Its password changed since: begin again.
_ => throw const PasswordFailed(PasswordProblem.challengeExpired),
};
return ( return (
challenge: challenge, challenge: challenge,
answer: await _crypto.answer(unlock, challenge, host: _gateway.host), answer: await _crypto.answer(unlock, challenge, host: _gateway.host),

View file

@ -209,8 +209,8 @@ class ServerpodPasswordGateway(
}); });
@override @override
Future<protocol.PasswordChallenge> beginChange() => Future<protocol.PasswordChallenge> beginChange({SessionGrant? as}) =>
_failing(_base.password.beginChange); _as(as, (base) => base.password.beginChange());
@override @override
Future<void> deleteAccount( Future<void> deleteAccount(

View file

@ -462,7 +462,9 @@ class _AddAuthenticatorState() extends State<_AddAuthenticator> {
AddAuthenticatorStep.code => AuthenticatorSetupView( AddAuthenticatorStep.code => AuthenticatorSetupView(
setup: state.setup!, setup: state.setup!,
code: _code, code: _code,
password: widget.needsExistingCode ? _password : null, // The owner's password always: a session alone adds no
// authenticator, the first included (the fourth reading).
password: _password,
existingCode: widget.needsExistingCode ? _existing : null, existingCode: widget.needsExistingCode ? _existing : null,
enabled: !state.busy, enabled: !state.busy,
// The new one's own code under it; the owner's proof (the // The new one's own code under it; the owner's proof (the

View file

@ -482,8 +482,10 @@ class _QrPainter(final QrImage _code) extends CustomPainter {
/// What setting up an authenticator shows: the QR code of its URI, the key /// What setting up an authenticator shows: the QR code of its URI, the key
/// to type by hand, on phones a button opening the authenticator app, and /// to type by hand, on phones a button opening the authenticator app, and
/// the fields for its code (and, once one is confirmed, the password and a /// the fields for its code and the owner's proof where the account's own
/// code from an authenticator added before: the owner's proof). /// session sets it up ([password], and once one is confirmed [existingCode]
/// from an authenticator added before; [existingErrorText] under the last
/// of them).
class const AuthenticatorSetupView({ class const AuthenticatorSetupView({
required final AuthenticatorSetup setup, required final AuthenticatorSetup setup,
required final TextEditingController code, required final TextEditingController code,
@ -563,19 +565,30 @@ class const AuthenticatorSetupView({
kind: CodeKind.totp, kind: CodeKind.totp,
enabled: enabled, enabled: enabled,
errorText: errorText, errorText: errorText,
textInputAction: existingCode == null textInputAction: password == null && existingCode == null
? TextInputAction.done ? TextInputAction.done
: TextInputAction.next, : TextInputAction.next,
onSubmitted: existingCode == null ? (_) => onDone?.call() : null, onSubmitted: password == null && existingCode == null
? (_) => onDone?.call()
: null,
), ),
if (password case final password?) ...[ if (password case final password?) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Text(l10n.passwordAuthenticatorOwnerIntro), Text(
existingCode == null
? l10n.passwordAuthenticatorPasswordIntro
: l10n.passwordAuthenticatorOwnerIntro,
),
const SizedBox(height: 8), const SizedBox(height: 8),
PasswordField( PasswordField(
controller: password, controller: password,
label: l10n.passwordCurrent, label: l10n.passwordCurrent,
enabled: enabled, enabled: enabled,
errorText: existingCode == null ? existingErrorText : null,
textInputAction: existingCode == null
? TextInputAction.done
: TextInputAction.next,
onSubmitted: existingCode == null ? (_) => onDone?.call() : null,
), ),
], ],
if (existingCode case final existing?) ...[ if (existingCode case final existing?) ...[

View file

@ -480,13 +480,21 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
throw const PasswordFailed(PasswordProblem.wrongCode); throw const PasswordFailed(PasswordProblem.wrongCode);
} }
final first = !account.finished; final first = !account.finished;
if (owner == null) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
if (!first) { if (!first) {
if (owner == null) { _ownerProved(owner);
} else {
// The first takes the password alone (the fourth reading).
final proved = _proved(owner.challenge, owner.answer);
if (proved.person != account.person) {
throw const PasswordFailed(PasswordProblem.wrongCredentials); throw const PasswordFailed(PasswordProblem.wrongCredentials);
} }
_ownerProved(owner); if ((owner.totpCode ?? '').isNotEmpty ||
} else if (owner != null) { (owner.recoveryCode ?? '').isNotEmpty) {
throw const PasswordFailed(PasswordProblem.invalidSetup); throw const PasswordFailed(PasswordProblem.invalidSetup);
}
} }
final added = account.authenticators[index]; final added = account.authenticators[index];
account.authenticators[index] = AccountAuthenticator( account.authenticators[index] = AccountAuthenticator(
@ -562,13 +570,14 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
}); });
@override @override
Future<protocol.PasswordChallenge> beginChange() => _call('beginChange', () { Future<protocol.PasswordChallenge> beginChange({SessionGrant? as}) =>
final account = _accountAs(null); _call('beginChange', () {
if (account.password == null) { final account = _accountAs(as);
throw const PasswordFailed(PasswordProblem.noPassword); if (account.password == null) {
} throw const PasswordFailed(PasswordProblem.noPassword);
return _challengeFor(account.address); }
}); return _challengeFor(account.address);
});
@override @override
Future<void> deleteAccount( Future<void> deleteAccount(

View file

@ -207,8 +207,8 @@ void main() {
expect(anna.authenticators.where((each) => each.confirmed), hasLength(2)); expect(anna.authenticators.where((each) => each.confirmed), hasLength(2));
}); });
test("the first after a reset brings recovery codes, without another's " test('the first after a reset brings recovery codes, with the password '
'code', () async { "alone (the fourth reading), without another's code", () async {
anna.authenticators.clear(); anna.authenticators.clear();
final cubit = AddAuthenticatorCubit( final cubit = AddAuthenticatorCubit(
passwords, passwords,
@ -219,7 +219,23 @@ void main() {
addTearDown(cubit.close); addTearDown(cubit.close);
await cubit.start(); await cubit.start();
await cubit.codeEntered(fakeTotpCode, existingCode: 'ignored'); await cubit.codeEntered(
fakeTotpCode,
password: 'wrong horse',
existingCode: 'ignored',
);
expect(
cubit.state,
at(
AddAuthenticatorStep.code,
problem: PasswordProblem.wrongCredentials,
),
);
await cubit.codeEntered(
fakeTotpCode,
password: 'correct horse',
existingCode: 'ignored',
);
expect(cubit.state, at(AddAuthenticatorStep.recoveryCodes)); expect(cubit.state, at(AddAuthenticatorStep.recoveryCodes));
expect(cubit.state.recoveryCodes, hasLength(10)); expect(cubit.state.recoveryCodes, hasLength(10));

View file

@ -154,9 +154,15 @@ void main() {
label: 'carla@example.org', label: 'carla@example.org',
pending: pending, pending: pending,
); );
// The pending session alone confirms none (the fourth reading).
await expectLater(
passwords.confirm(setup.id, '123 456', pending: pending),
throwsA(const PasswordFailed(PasswordProblem.wrongCredentials)),
);
final codes = await passwords.confirm( final codes = await passwords.confirm(
setup.id, setup.id,
'123 456', '123 456',
unlocked: unlocked,
pending: pending, pending: pending,
); );

View file

@ -372,10 +372,10 @@ class EndpointAuthenticator extends _isc.EndpointRef {
}, },
); );
/// Confirms [authenticator] with a [code] from it, and, where the account /// Confirms [authenticator] with a [code] from it and the owner's [proof]:
/// has a confirmed authenticator already, the owner's [proof]; the /// the password and a code from a confirmed authenticator, or, for the
/// account's first returns its recovery codes, shown once. Refusals as /// account's first, the password alone; the first returns its recovery
/// `confirmAuthenticator`'s. /// codes, shown once. Refusals as `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,

View file

@ -29,6 +29,7 @@ abstract class PasswordProof
required this.signingPublicKey, required this.signingPublicKey,
required this.passwordKeys, required this.passwordKeys,
required this.needsAuthenticator, required this.needsAuthenticator,
this.sessionsEndedBefore,
}); });
factory PasswordProof({ factory PasswordProof({
@ -38,6 +39,7 @@ abstract class PasswordProof
required _idt.ByteData signingPublicKey, required _idt.ByteData signingPublicKey,
required _i2rxumte.WrappedKeys passwordKeys, required _i2rxumte.WrappedKeys passwordKeys,
required bool needsAuthenticator, required bool needsAuthenticator,
DateTime? sessionsEndedBefore,
}) = _PasswordProofImpl; }) = _PasswordProofImpl;
factory PasswordProof.fromJson(Map<String, dynamic> jsonSerialization) { factory PasswordProof.fromJson(Map<String, dynamic> jsonSerialization) {
@ -60,6 +62,11 @@ abstract class PasswordProof
needsAuthenticator: _is.BoolJsonExtension.fromJson( needsAuthenticator: _is.BoolJsonExtension.fromJson(
jsonSerialization['needsAuthenticator'], jsonSerialization['needsAuthenticator'],
), ),
sessionsEndedBefore: jsonSerialization['sessionsEndedBefore'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['sessionsEndedBefore'],
),
); );
} }
@ -75,6 +82,12 @@ abstract class PasswordProof
bool needsAuthenticator; 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] /// Returns a shallow copy of this [PasswordProof]
/// with some or all fields replaced by the given arguments. /// with some or all fields replaced by the given arguments.
@_is.useResult @_is.useResult
@ -85,6 +98,7 @@ abstract class PasswordProof
_idt.ByteData? signingPublicKey, _idt.ByteData? signingPublicKey,
_i2rxumte.WrappedKeys? passwordKeys, _i2rxumte.WrappedKeys? passwordKeys,
bool? needsAuthenticator, bool? needsAuthenticator,
DateTime? sessionsEndedBefore,
}); });
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -96,6 +110,8 @@ abstract class PasswordProof
'signingPublicKey': signingPublicKey.toJson(), 'signingPublicKey': signingPublicKey.toJson(),
'passwordKeys': passwordKeys.toJson(), 'passwordKeys': passwordKeys.toJson(),
'needsAuthenticator': needsAuthenticator, 'needsAuthenticator': needsAuthenticator,
if (sessionsEndedBefore != null)
'sessionsEndedBefore': sessionsEndedBefore?.toJson(),
}; };
} }
@ -110,6 +126,8 @@ abstract class PasswordProof
} }
} }
class _Undefined {}
class _PasswordProofImpl extends PasswordProof { class _PasswordProofImpl extends PasswordProof {
_PasswordProofImpl({ _PasswordProofImpl({
required _is.UuidValue account, required _is.UuidValue account,
@ -118,6 +136,7 @@ class _PasswordProofImpl extends PasswordProof {
required _idt.ByteData signingPublicKey, required _idt.ByteData signingPublicKey,
required _i2rxumte.WrappedKeys passwordKeys, required _i2rxumte.WrappedKeys passwordKeys,
required bool needsAuthenticator, required bool needsAuthenticator,
DateTime? sessionsEndedBefore,
}) : super._( }) : super._(
account: account, account: account,
serverProof: serverProof, serverProof: serverProof,
@ -125,6 +144,7 @@ class _PasswordProofImpl extends PasswordProof {
signingPublicKey: signingPublicKey, signingPublicKey: signingPublicKey,
passwordKeys: passwordKeys, passwordKeys: passwordKeys,
needsAuthenticator: needsAuthenticator, needsAuthenticator: needsAuthenticator,
sessionsEndedBefore: sessionsEndedBefore,
); );
/// Returns a shallow copy of this [PasswordProof] /// Returns a shallow copy of this [PasswordProof]
@ -138,6 +158,7 @@ class _PasswordProofImpl extends PasswordProof {
_idt.ByteData? signingPublicKey, _idt.ByteData? signingPublicKey,
_i2rxumte.WrappedKeys? passwordKeys, _i2rxumte.WrappedKeys? passwordKeys,
bool? needsAuthenticator, bool? needsAuthenticator,
Object? sessionsEndedBefore = _Undefined,
}) { }) {
return PasswordProof( return PasswordProof(
account: account ?? this.account, account: account ?? this.account,
@ -147,6 +168,9 @@ class _PasswordProofImpl extends PasswordProof {
signingPublicKey: signingPublicKey ?? this.signingPublicKey.clone(), signingPublicKey: signingPublicKey ?? this.signingPublicKey.clone(),
passwordKeys: passwordKeys ?? this.passwordKeys.copyWith(), passwordKeys: passwordKeys ?? this.passwordKeys.copyWith(),
needsAuthenticator: needsAuthenticator ?? this.needsAuthenticator, needsAuthenticator: needsAuthenticator ?? this.needsAuthenticator,
sessionsEndedBefore: sessionsEndedBefore is DateTime?
? sessionsEndedBefore
: this.sessionsEndedBefore,
); );
} }
} }

View file

@ -146,6 +146,8 @@ Future<AccountSessionsEnded> resetPasswordWithPhrase(
final account = row.authUserId; final account = row.authUserId;
// Before any code is checked. // Before any code is checked.
final notice = await _requireNoticeOf(session, account, address, language); 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( await secondFactor(
session, session,
keys, keys,

View file

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

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