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:
parent
f0413531ff
commit
d7855162c7
13 changed files with 548 additions and 95 deletions
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -182,10 +182,13 @@ void main() {
|
|||
label: 'anna@example.org',
|
||||
);
|
||||
await atNow(
|
||||
() => endpoints.authenticator.confirm(
|
||||
() async => await endpoints.authenticator.confirm(
|
||||
onPhone(),
|
||||
authenticator: enrolment.authenticator,
|
||||
code: codeOf(enrolment, at: now),
|
||||
proof: device.ownerProof(
|
||||
await endpoints.password.beginChange(onPhone()),
|
||||
),
|
||||
),
|
||||
);
|
||||
nextStep();
|
||||
|
|
@ -462,7 +465,8 @@ void main() {
|
|||
'spending its own begins holds back no other session', () async {
|
||||
// The third reading's third finding: ten begun an hour per account
|
||||
// let a stolen session keep its owner from changing anything.
|
||||
for (var i = 0; i < changesBegunPerSession; i++) {
|
||||
// One went on confirming the first authenticator, in setUp.
|
||||
for (var i = 1; i < changesBegunPerSession; i++) {
|
||||
await atNow(() => endpoints.password.beginChange(onPhone()));
|
||||
}
|
||||
await expectLater(
|
||||
|
|
|
|||
|
|
@ -91,6 +91,18 @@ void main() {
|
|||
),
|
||||
);
|
||||
|
||||
/// Anna's password alone, from [from] (her phone unless given), for a
|
||||
/// change's challenge: what her first authenticator takes (the fourth
|
||||
/// reading).
|
||||
Future<OwnerProof> passwordAlone({
|
||||
TestSessionBuilder? from,
|
||||
PasswordDevice? with_,
|
||||
}) => atNow(
|
||||
() async => (with_ ?? device).ownerProof(
|
||||
await endpoints.password.beginChange(from ?? asAnna()),
|
||||
),
|
||||
);
|
||||
|
||||
/// A code mailed to [address], as the device asks for it: the request
|
||||
/// and the code.
|
||||
Future<({UuidValue request, String code})> mailedCode(
|
||||
|
|
@ -150,6 +162,7 @@ void main() {
|
|||
asAnna(),
|
||||
authenticator: enrolment.authenticator,
|
||||
code: codeOf(enrolment),
|
||||
proof: await passwordAlone(),
|
||||
);
|
||||
nextStep();
|
||||
return (enrolment: enrolment, recoveryCodes: codes);
|
||||
|
|
@ -507,6 +520,7 @@ void main() {
|
|||
asAnna(),
|
||||
authenticator: enrolment.authenticator,
|
||||
code: codeOf(enrolment),
|
||||
proof: await passwordAlone(with_: other),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1216,6 +1230,7 @@ void main() {
|
|||
asAnna(),
|
||||
authenticator: enrolment.authenticator,
|
||||
code: codeOf(enrolment),
|
||||
proof: await passwordAlone(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1271,6 +1286,174 @@ void main() {
|
|||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'the first is confirmed only with the password too, so a '
|
||||
'session alone claims no second factor (the fourth reading)',
|
||||
() async {
|
||||
await annaSetsPassword();
|
||||
final enrolment = await endpoints.authenticator.enrol(
|
||||
asAnna(),
|
||||
name: 'Phone',
|
||||
label: '',
|
||||
);
|
||||
Future<List<String>> confirm({OwnerProof? proof}) => atNow(
|
||||
() => endpoints.authenticator.confirm(
|
||||
asAnna(),
|
||||
authenticator: enrolment.authenticator,
|
||||
code: codeOf(enrolment, at: now),
|
||||
proof: proof,
|
||||
),
|
||||
);
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
final asBen = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
ben.uuid,
|
||||
{},
|
||||
authId: bensLaptop.uuid,
|
||||
),
|
||||
);
|
||||
final bensDevice = await PasswordDevice.make(seed: 7);
|
||||
final bensCode = await mailedCode('ben@example.org');
|
||||
await endpoints.password.set(
|
||||
asBen,
|
||||
setup: bensDevice.setup(),
|
||||
request: bensCode.request,
|
||||
code: bensCode.code,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
confirm(),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
confirm(
|
||||
proof: await atNow(
|
||||
() async => device.ownerProof(
|
||||
await endpoints.password.beginChange(asAnna()),
|
||||
privateValue: BigInt.from(12345),
|
||||
),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
// Ben's own password, for a challenge of his: not Anna's.
|
||||
await expectLater(
|
||||
confirm(
|
||||
proof: await atNow(
|
||||
() async => bensDevice.ownerProof(
|
||||
await endpoints.password.beginChange(asBen),
|
||||
),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
// A code with it, which nothing here checks.
|
||||
await expectLater(
|
||||
confirm(
|
||||
proof: await atNow(
|
||||
() async => device.ownerProof(
|
||||
await endpoints.password.beginChange(asAnna()),
|
||||
totpCode: codeOf(enrolment, at: now),
|
||||
),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.invalidSetup),
|
||||
);
|
||||
expect(
|
||||
(await TotpAuthenticator.db.findById(
|
||||
session,
|
||||
enrolment.authenticator,
|
||||
))!.confirmedAt,
|
||||
isNull,
|
||||
);
|
||||
// Each wrong one counted for the session (no proof checks none).
|
||||
expect(
|
||||
(await SessionCodeFailures.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.sessionId.equals(annasPhone),
|
||||
))?.failures,
|
||||
2,
|
||||
);
|
||||
|
||||
final codes = await confirm(proof: await passwordAlone());
|
||||
expect(codes, hasLength(recoveryCodeCount));
|
||||
expect(
|
||||
await SessionCodeFailures.db.count(
|
||||
session,
|
||||
where: (t) => t.sessionId.equals(annasPhone),
|
||||
),
|
||||
0,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("a session that outlived its device's loss claims no second "
|
||||
'factor once the authenticators were reset: the reset carried '
|
||||
'out ends every session, and the first takes the password '
|
||||
'(the fourth reading)', () async {
|
||||
await annaComplete();
|
||||
// Anna's phone, session and authenticator, is stolen; she asks for
|
||||
// the reset with her password and a code by mail.
|
||||
await atNow(() async {
|
||||
final challenge = await endpoints.passwordSignIn.begin(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
);
|
||||
final answer = device.answer(challenge);
|
||||
final code = await mailedCode(
|
||||
'anna@example.org',
|
||||
purpose: EmailCodePurpose.authenticatorReset,
|
||||
);
|
||||
await endpoints.accountRecovery.resetAuthenticator(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
host: testHost,
|
||||
);
|
||||
});
|
||||
now = now.add(resetDelay);
|
||||
|
||||
// Her password alone on a new device carries it out.
|
||||
expect((await signIn()).needsAuthenticator, isTrue);
|
||||
expect(
|
||||
await ServerSideSession.db.findById(session, annasPhone),
|
||||
isNull,
|
||||
);
|
||||
|
||||
// Were the thief's session still taken, it would confirm nothing
|
||||
// of its own without her password.
|
||||
final thiefs = await atNow(
|
||||
() => endpoints.authenticator.enrol(
|
||||
asAnna(),
|
||||
name: 'Thief',
|
||||
label: '',
|
||||
),
|
||||
);
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.authenticator.confirm(
|
||||
asAnna(),
|
||||
authenticator: thiefs.authenticator,
|
||||
code: codeOf(thiefs, at: now),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
expect(
|
||||
await TotpAuthenticator.db.count(
|
||||
session,
|
||||
where: (t) => t.confirmedAt.notEquals(null),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('need a password, a name, and stop at ten', () async {
|
||||
await expectLater(
|
||||
endpoints.authenticator.enrol(asAnna(), name: 'Phone', label: ''),
|
||||
|
|
@ -1425,7 +1608,7 @@ void main() {
|
|||
|
||||
test("never have a confirmed one's code checked for a session "
|
||||
'alone: confirming it again is refused as unknown, and the '
|
||||
'first takes no proof', () async {
|
||||
"first takes its owner's password, never another's", () async {
|
||||
final complete = await annaComplete();
|
||||
await expectLater(
|
||||
atNow(
|
||||
|
|
@ -1465,10 +1648,11 @@ void main() {
|
|||
asBen,
|
||||
authenticator: bens.authenticator,
|
||||
code: codeOf(bens, at: now),
|
||||
// Anna's password, for a challenge of hers.
|
||||
proof: await ownerProof(),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.invalidSetup),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1683,8 +1867,10 @@ void main() {
|
|||
test('count proofs a session sends at once before they are checked: '
|
||||
'nine checked, the session ended', () async {
|
||||
await annaComplete();
|
||||
// 29 more than the one confirming the first authenticator took:
|
||||
// the session's 30 changes begun an hour.
|
||||
final proofs = [
|
||||
for (var i = 0; i < 30; i++)
|
||||
for (var i = 0; i < 29; i++)
|
||||
await ownerProof(totpCode: '${100000 + i}'),
|
||||
];
|
||||
final reasons = await Future.wait([
|
||||
|
|
@ -1707,7 +1893,7 @@ void main() {
|
|||
);
|
||||
expect(
|
||||
reasons.where((r) => r == PasswordRefusal.sessionEnded),
|
||||
hasLength(21),
|
||||
hasLength(20),
|
||||
);
|
||||
expect(
|
||||
await ServerSideSession.db.findById(session, annasPhone),
|
||||
|
|
@ -1952,6 +2138,19 @@ void main() {
|
|||
expect(signedIn.needsAuthenticator, isTrue);
|
||||
expect(await TotpAuthenticator.db.count(session), 0);
|
||||
expect(await RecoveryCode.db.count(session), 0);
|
||||
// Carried out, it ended every session made before (the fourth
|
||||
// reading): only the one this sign-in made is left.
|
||||
expect(
|
||||
await ServerSideSession.db.findById(session, annasPhone),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
await ServerSideSession.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
1,
|
||||
);
|
||||
// The password alone, whatever code comes with it.
|
||||
expect(
|
||||
(await signIn(
|
||||
|
|
@ -1960,10 +2159,12 @@ void main() {
|
|||
isTrue,
|
||||
);
|
||||
|
||||
final again = await atNow(() => signInNewDevice(session, anna));
|
||||
final asAnnaAgain = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
anna.uuid,
|
||||
{identified},
|
||||
authId: again.uuid,
|
||||
),
|
||||
);
|
||||
final fresh = await endpoints.authenticator.enrol(
|
||||
|
|
@ -1974,10 +2175,11 @@ void main() {
|
|||
// Signing in elsewhere meanwhile leaves it to be confirmed.
|
||||
expect((await signIn()).needsAuthenticator, isTrue);
|
||||
final codes = await atNow(
|
||||
() => endpoints.authenticator.confirm(
|
||||
() async => await endpoints.authenticator.confirm(
|
||||
asAnnaAgain,
|
||||
authenticator: fresh.authenticator,
|
||||
code: codeOf(fresh, at: now),
|
||||
proof: await passwordAlone(from: asAnnaAgain),
|
||||
),
|
||||
);
|
||||
expect(codes, hasLength(recoveryCodeCount));
|
||||
|
|
|
|||
|
|
@ -51,10 +51,13 @@ void main() {
|
|||
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
);
|
||||
|
||||
/// [person], signed in on a device whose session's ID is theirs.
|
||||
TestSessionBuilder as(UuidValue person) => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
person.uuid,
|
||||
{identified},
|
||||
authId: person.uuid,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -153,10 +156,14 @@ void main() {
|
|||
label: 'anna@example.org',
|
||||
);
|
||||
final codes = await atNow(
|
||||
() => endpoints.authenticator.confirm(
|
||||
() async => await endpoints.authenticator.confirm(
|
||||
as(anna),
|
||||
authenticator: enrolment.authenticator,
|
||||
code: codeOf(enrolment, at: now),
|
||||
// The first takes the password too (the fourth reading).
|
||||
proof: device.ownerProof(
|
||||
await endpoints.password.beginChange(as(anna)),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(codes, hasLength(10));
|
||||
|
|
@ -328,10 +335,14 @@ void main() {
|
|||
label: 'anna@example.org',
|
||||
);
|
||||
await atNow(
|
||||
() => endpoints.authenticator.confirm(
|
||||
() async => await endpoints.authenticator.confirm(
|
||||
as(anna),
|
||||
authenticator: enrolment.authenticator,
|
||||
code: codeOf(enrolment, at: now),
|
||||
// The first takes the password too (the fourth reading).
|
||||
proof: device.ownerProof(
|
||||
await endpoints.password.beginChange(as(anna)),
|
||||
),
|
||||
),
|
||||
);
|
||||
now = now.add(totpPeriod);
|
||||
|
|
|
|||
|
|
@ -683,6 +683,50 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('carrying out a reset of the authenticators, end the sessions '
|
||||
'here made before the register ended them, the new one staying '
|
||||
'(the fourth reading)', () async {
|
||||
final endedBefore = DateTime.now().toUtc();
|
||||
register.proof = PasswordProof(
|
||||
account: anna,
|
||||
serverProof: ByteData(32),
|
||||
encryptionPublicKey: ByteData(32),
|
||||
signingPublicKey: ByteData(32),
|
||||
passwordKeys: WrappedKeys(
|
||||
nonce: ByteData(24),
|
||||
cipherText: ByteData(112),
|
||||
),
|
||||
needsAuthenticator: true,
|
||||
sessionsEndedBefore: endedBefore,
|
||||
);
|
||||
|
||||
final challenge = await endpoints.passwordSignIn.begin(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
);
|
||||
final signedIn = await endpoints.passwordSignIn.complete(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
clientValue: ByteData(384),
|
||||
clientProof: ByteData(32),
|
||||
host: testHost,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
|
||||
expect(signedIn.authUserId, anna);
|
||||
expect(
|
||||
await ServerSideSession.db.findById(session, annasPhone),
|
||||
isNull,
|
||||
);
|
||||
final left = await ServerSideSession.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
);
|
||||
expect(left, hasLength(1));
|
||||
expect(left.single.createdAt.isBefore(endedBefore), isFalse);
|
||||
});
|
||||
|
||||
test('sign up at the register, the new account signed in here, '
|
||||
'identified', () async {
|
||||
final device = await PasswordDevice.make();
|
||||
|
|
|
|||
|
|
@ -514,6 +514,9 @@ void main() {
|
|||
person: annaOnTodo,
|
||||
authenticator: enrolment.authenticator,
|
||||
code: codeOf(enrolment),
|
||||
proof: device.ownerProof(
|
||||
await remote.beginPasswordChange(session, person: annaOnTodo),
|
||||
),
|
||||
);
|
||||
expect(codes, hasLength(recoveryCodeCount));
|
||||
expect(
|
||||
|
|
@ -746,6 +749,12 @@ void main() {
|
|||
person: annaOnTodo,
|
||||
authenticator: enrolment.authenticator,
|
||||
code: codeOf(enrolment),
|
||||
proof: device.ownerProof(
|
||||
await remote.beginPasswordChange(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
),
|
||||
),
|
||||
);
|
||||
now = now.add(totpPeriod);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue