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:
parent
70b434bb84
commit
a9459bb875
2 changed files with 1461 additions and 1026 deletions
|
|
@ -3,6 +3,8 @@ import 'dart:typed_data';
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_base_server/src/password/password_endpoint.dart'
|
||||
show changesBegunPerSession;
|
||||
import 'package:halkyon_base_server/src/password/passwords.dart'
|
||||
show writeNewPassword;
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
|
@ -71,6 +73,13 @@ void main() {
|
|||
authId: annasPhone.uuid,
|
||||
),
|
||||
);
|
||||
TestSessionBuilder onLaptop() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
anna.uuid,
|
||||
{identified},
|
||||
authId: annasLaptop.uuid,
|
||||
),
|
||||
);
|
||||
|
||||
Future<({UuidValue request, String code})> mailedCode(
|
||||
String address, {
|
||||
|
|
@ -106,28 +115,30 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
/// Changes Anna's password from her phone, the old one proved by
|
||||
/// [privateValue] (hers by default) with [totpCode] (her
|
||||
/// authenticator's now by default).
|
||||
/// Changes Anna's password from her phone (or [from]), the old one
|
||||
/// proved by [privateValue] (hers by default) with [totpCode] (her
|
||||
/// authenticator's now by default); sent as [as] where given.
|
||||
Future<void> change({
|
||||
BigInt? privateValue,
|
||||
String? totpCode,
|
||||
bool withoutCode = false,
|
||||
NewPassword? password,
|
||||
TestSessionBuilder? from,
|
||||
TestSessionBuilder? as,
|
||||
}) => atNow(() async {
|
||||
final challenge = await endpoints.password.beginChange(onPhone());
|
||||
final answer = device.answer(challenge, privateValue: privateValue);
|
||||
final challenge = await endpoints.password.beginChange(
|
||||
from ?? onPhone(),
|
||||
);
|
||||
await endpoints.password.change(
|
||||
as ?? onPhone(),
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: withoutCode ? null : totpCode ?? codeOf(enrolment, at: now),
|
||||
as ?? from ?? onPhone(),
|
||||
proof: device.ownerProof(
|
||||
challenge,
|
||||
privateValue: privateValue,
|
||||
totpCode: withoutCode
|
||||
? null
|
||||
: totpCode ?? codeOf(enrolment, at: now),
|
||||
),
|
||||
password: password ?? device.newPassword(),
|
||||
host: testHost,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -344,20 +355,16 @@ void main() {
|
|||
),
|
||||
);
|
||||
await expectLater(
|
||||
atNow(() async {
|
||||
final answer = device.answer(signInChallenge);
|
||||
await endpoints.password.change(
|
||||
atNow(
|
||||
() => endpoints.password.change(
|
||||
onPhone(),
|
||||
challenge: signInChallenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: codeOf(enrolment, at: now),
|
||||
proof: device.ownerProof(
|
||||
signInChallenge,
|
||||
totpCode: codeOf(enrolment, at: now),
|
||||
),
|
||||
password: device.newPassword(),
|
||||
host: testHost,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.challengeExpired),
|
||||
);
|
||||
final changeChallenge = await atNow(
|
||||
|
|
@ -381,43 +388,89 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test("takes the account's address, refused for another before "
|
||||
'the code is checked; its wrong codes make codes wait, the owner '
|
||||
'mailed', () async {
|
||||
await expectLater(
|
||||
atNow(() async {
|
||||
final challenge = await endpoints.password.beginChange(
|
||||
onPhone(),
|
||||
);
|
||||
final answer = device.answer(challenge);
|
||||
await endpoints.password.change(
|
||||
onPhone(),
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: codeOf(enrolment, at: now),
|
||||
// Another device's: a refused change leaves Anna's as it is.
|
||||
password: (await PasswordDevice.make()).newPassword(),
|
||||
host: testHost,
|
||||
address: 'ben@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
}),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
expect(await SecondFactorFailures.db.count(session), 0);
|
||||
// Past the hour of the changes begun (ten an hour per account).
|
||||
now = now.add(const Duration(hours: 1, seconds: 1));
|
||||
test("a session's wrong proofs count for it alone: the tenth in a "
|
||||
"row ends it, the account's codes never waiting, the owner's "
|
||||
'other sessions changing as before', () async {
|
||||
// Another device's new password: refused changes leave Anna's.
|
||||
final other = await PasswordDevice.make();
|
||||
for (var i = 0; i < 10; i++) {
|
||||
for (var i = 0; i < 9; i++) {
|
||||
await expectLater(
|
||||
change(totpCode: '000000', password: other.newPassword()),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
expect(mailbox.codesWait.single.to, 'anna@example.org');
|
||||
expect(mailbox.codesWait.single.language, 'en');
|
||||
await expectLater(
|
||||
change(
|
||||
privateValue: BigInt.from(4242),
|
||||
password: other.newPassword(),
|
||||
),
|
||||
refused(PasswordRefusal.sessionEnded),
|
||||
);
|
||||
expect(await sessionsOf(anna), {annasLaptop});
|
||||
// A proof sent on by the ended session is refused all the same.
|
||||
await expectLater(
|
||||
change(password: other.newPassword()),
|
||||
refused(PasswordRefusal.sessionEnded),
|
||||
);
|
||||
expect(await SecondFactorFailures.db.count(session), 0);
|
||||
expect(mailbox.codesWait, isEmpty);
|
||||
|
||||
nextStep();
|
||||
await change(from: onLaptop());
|
||||
});
|
||||
|
||||
test('a proof accepted clears the count: wrong ones before it never '
|
||||
'add up to ten with those after', () async {
|
||||
final other = await PasswordDevice.make();
|
||||
for (var i = 0; i < 9; i++) {
|
||||
await expectLater(
|
||||
change(totpCode: '000000', password: other.newPassword()),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
nextStep();
|
||||
await change(password: other.newPassword());
|
||||
device = other;
|
||||
for (var i = 0; i < 9; i++) {
|
||||
await expectLater(
|
||||
change(totpCode: '000000'),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
expect(await sessionsOf(anna), contains(annasPhone));
|
||||
});
|
||||
|
||||
test('whoever knows the password holds the signed-in owner out of '
|
||||
"nothing: codes waiting after wrong ones at sign-in don't hold "
|
||||
'back a change, which forgets them', () async {
|
||||
// The third reading's second finding: ten wrong codes at sign-in.
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await expectLater(
|
||||
signIn(totpCode: '000000'),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
expect(mailbox.codesWait, hasLength(1));
|
||||
expect(await SecondFactorFailures.db.count(session), 1);
|
||||
|
||||
await change();
|
||||
|
||||
expect(await SecondFactorFailures.db.count(session), 0);
|
||||
});
|
||||
|
||||
test('beginning changes counts nothing against the owner: a session '
|
||||
'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++) {
|
||||
await atNow(() => endpoints.password.beginChange(onPhone()));
|
||||
}
|
||||
await expectLater(
|
||||
atNow(() => endpoints.password.beginChange(onPhone())),
|
||||
refused(PasswordRefusal.tooManyAttempts),
|
||||
);
|
||||
|
||||
await change(from: onLaptop());
|
||||
});
|
||||
|
||||
test('is begun only by someone with a password', () async {
|
||||
|
|
@ -480,20 +533,20 @@ void main() {
|
|||
TestSessionBuilder? as,
|
||||
}) => atNow(() async {
|
||||
final challenge = await endpoints.password.beginChange(onPhone());
|
||||
final answer = device.answer(challenge, privateValue: privateValue);
|
||||
await endpoints.password.setRecoveryKeys(
|
||||
as ?? onPhone(),
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: withoutProof ? ByteData(32) : answer.clientProof,
|
||||
final proof = device.ownerProof(
|
||||
challenge,
|
||||
privateValue: privateValue,
|
||||
totpCode: withoutCode
|
||||
? null
|
||||
: totpCode ?? codeOf(enrolment, at: now),
|
||||
);
|
||||
await endpoints.password.setRecoveryKeys(
|
||||
as ?? onPhone(),
|
||||
proof: withoutProof
|
||||
? proof.copyWith(clientProof: ByteData(32))
|
||||
: proof,
|
||||
recoveryKeys: recoveryKeys ?? fresh,
|
||||
recoveryProofKey: recoveryProofKey ?? freshProofKey,
|
||||
host: testHost,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -669,8 +722,6 @@ void main() {
|
|||
? null
|
||||
: totpCode ?? codeOf(enrolment, at: now),
|
||||
host: testHost,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
|
@ -751,8 +802,6 @@ void main() {
|
|||
request: mailed?.request,
|
||||
code: mailed?.code,
|
||||
host: testHost,
|
||||
address: 'ben@example.org',
|
||||
language: 'en',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
|
@ -761,9 +810,11 @@ void main() {
|
|||
deleteBen(),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
// A TOTP code, once the password proved right: no authenticator
|
||||
// to check it against.
|
||||
await expectLater(
|
||||
deleteBen(totpCode: '123456'),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
refused(PasswordRefusal.setupIncomplete),
|
||||
);
|
||||
await expectLater(
|
||||
deleteBen(mailedFor: EmailCodePurpose.signUp),
|
||||
|
|
@ -828,8 +879,6 @@ void main() {
|
|||
request: mailed.request,
|
||||
code: mailed.code,
|
||||
host: testHost,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
|
@ -837,6 +886,39 @@ void main() {
|
|||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'with the reset of the authenticators taken effect but not carried '
|
||||
'out, takes a TOTP code from them too, as the app learns from '
|
||||
'the ways in',
|
||||
() async {
|
||||
// The third reading's fourth finding: the app offered a code
|
||||
// field there, which the instance refused.
|
||||
await AccountReset.db.insertRow(
|
||||
session,
|
||||
AccountReset(
|
||||
authUserId: anna,
|
||||
kind: AccountResetKind.authenticator,
|
||||
takesEffectAt: now.subtract(const Duration(hours: 1)),
|
||||
createdAt: now.subtract(const Duration(hours: 73)),
|
||||
),
|
||||
);
|
||||
final ways = await endpoints.account.signInWays(onPhone());
|
||||
expect(ways.authenticator, isTrue);
|
||||
expect(ways.secondFactorWorks, isFalse);
|
||||
|
||||
await delete();
|
||||
|
||||
expect(await AuthUser.db.findById(session, anna), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('a second factor works while nothing was reset', () async {
|
||||
expect(
|
||||
(await endpoints.account.signInWays(onPhone())).secondFactorWorks,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'of an anonymous person takes its session alone, as before',
|
||||
() async {
|
||||
|
|
|
|||
|
|
@ -75,6 +75,22 @@ void main() {
|
|||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
);
|
||||
|
||||
/// Anna's proof from her phone (`OwnerProof`): her password (or
|
||||
/// [privateValue]'s) for a change's challenge, and [totpCode] or
|
||||
/// [recoveryCode].
|
||||
Future<OwnerProof> ownerProof({
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
BigInt? privateValue,
|
||||
}) => atNow(
|
||||
() async => device.ownerProof(
|
||||
await endpoints.password.beginChange(asAnna()),
|
||||
totpCode: totpCode,
|
||||
recoveryCode: recoveryCode,
|
||||
privateValue: privateValue,
|
||||
),
|
||||
);
|
||||
|
||||
/// A code mailed to [address], as the device asks for it: the request
|
||||
/// and the code.
|
||||
Future<({UuidValue request, String code})> mailedCode(
|
||||
|
|
@ -993,16 +1009,17 @@ void main() {
|
|||
'holds nobody out of their codes', () async {
|
||||
final complete = await annaComplete();
|
||||
for (var i = 0; i < 10; i++) {
|
||||
final proof = await ownerProof(totpCode: '000000');
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.authenticator.newRecoveryCodes(
|
||||
asAnna(),
|
||||
code: '000000',
|
||||
proof: proof,
|
||||
),
|
||||
),
|
||||
refused(
|
||||
i < 9
|
||||
? PasswordRefusal.wrongCode
|
||||
? PasswordRefusal.wrongCredentials
|
||||
: PasswordRefusal.sessionEnded,
|
||||
),
|
||||
);
|
||||
|
|
@ -1164,12 +1181,15 @@ void main() {
|
|||
),
|
||||
refused(PasswordRefusal.wrongCode),
|
||||
);
|
||||
final proof = await ownerProof(
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
);
|
||||
final codes = await atNow(
|
||||
() => endpoints.authenticator.confirm(
|
||||
asAnna(),
|
||||
authenticator: second.authenticator,
|
||||
code: codeOf(second, at: now),
|
||||
existingCode: codeOf(complete.enrolment, at: now),
|
||||
proof: proof,
|
||||
),
|
||||
);
|
||||
expect(codes, isEmpty);
|
||||
|
|
@ -1181,44 +1201,56 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('take, once one is confirmed, a code from one confirmed before '
|
||||
'to add another: a session alone adds none', () async {
|
||||
test("take, once one is confirmed, the owner's proof to add another: "
|
||||
'the password and a code from one confirmed before; a session '
|
||||
'alone adds none', () async {
|
||||
final complete = await annaComplete();
|
||||
final another = await endpoints.authenticator.enrol(
|
||||
asAnna(),
|
||||
name: 'Not hers',
|
||||
label: '',
|
||||
);
|
||||
Future<List<String>> confirm({
|
||||
String? code,
|
||||
String? existingCode,
|
||||
}) => atNow(
|
||||
() => endpoints.authenticator.confirm(
|
||||
asAnna(),
|
||||
authenticator: another.authenticator,
|
||||
code: code ?? codeOf(another, at: now),
|
||||
existingCode: existingCode,
|
||||
),
|
||||
);
|
||||
Future<List<String>> confirm({String? code, OwnerProof? proof}) =>
|
||||
atNow(
|
||||
() => endpoints.authenticator.confirm(
|
||||
asAnna(),
|
||||
authenticator: another.authenticator,
|
||||
code: code ?? codeOf(another, at: now),
|
||||
proof: proof,
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
confirm(),
|
||||
refused(PasswordRefusal.wrongExistingCode),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
confirm(existingCode: '000000'),
|
||||
refused(PasswordRefusal.wrongExistingCode),
|
||||
confirm(proof: await ownerProof(totpCode: '000000')),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
// Its own code is no code from one confirmed before.
|
||||
await expectLater(
|
||||
confirm(existingCode: codeOf(another, at: now)),
|
||||
refused(PasswordRefusal.wrongExistingCode),
|
||||
confirm(
|
||||
proof: await ownerProof(totpCode: codeOf(another, at: now)),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
// A wrong code from the new one takes nothing of the other's.
|
||||
await expectLater(
|
||||
confirm(
|
||||
proof: await ownerProof(
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
privateValue: BigInt.from(4242),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
// A wrong code from the new one takes nothing of the proof's.
|
||||
await expectLater(
|
||||
confirm(
|
||||
code: '000000',
|
||||
existingCode: codeOf(complete.enrolment, at: now),
|
||||
proof: await ownerProof(
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCode),
|
||||
);
|
||||
|
|
@ -1230,7 +1262,11 @@ void main() {
|
|||
);
|
||||
|
||||
expect(
|
||||
await confirm(existingCode: codeOf(complete.enrolment, at: now)),
|
||||
await confirm(
|
||||
proof: await ownerProof(
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
),
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
|
|
@ -1246,6 +1282,55 @@ 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 {
|
||||
final complete = await annaComplete();
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.authenticator.confirm(
|
||||
asAnna(),
|
||||
authenticator: complete.enrolment.authenticator,
|
||||
code: codeOf(complete.enrolment, at: now),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.unknownAuthenticator),
|
||||
);
|
||||
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
final asBen = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
ben.uuid,
|
||||
{},
|
||||
authId: bensLaptop.uuid,
|
||||
),
|
||||
);
|
||||
final code = await mailedCode('ben@example.org');
|
||||
await endpoints.password.set(
|
||||
asBen,
|
||||
setup: (await PasswordDevice.make(seed: 7)).setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
);
|
||||
final bens = await endpoints.authenticator.enrol(
|
||||
asBen,
|
||||
name: 'Phone',
|
||||
label: '',
|
||||
);
|
||||
await expectLater(
|
||||
atNow(
|
||||
() async => await endpoints.authenticator.confirm(
|
||||
asBen,
|
||||
authenticator: bens.authenticator,
|
||||
code: codeOf(bens, at: now),
|
||||
proof: await ownerProof(),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.invalidSetup),
|
||||
);
|
||||
});
|
||||
|
||||
test("are another person's to nobody: unknown", () async {
|
||||
final complete = await annaComplete();
|
||||
final ben = await newAnonymousPerson(session);
|
||||
|
|
@ -1277,8 +1362,8 @@ void main() {
|
|||
expect(await TotpAuthenticator.db.count(session), 1);
|
||||
});
|
||||
|
||||
test('are removed with a code while another stays, never the '
|
||||
'last', () async {
|
||||
test("are removed with the owner's proof while another stays, never "
|
||||
'the last', () async {
|
||||
final complete = await annaComplete();
|
||||
final unconfirmed = await endpoints.authenticator.enrol(
|
||||
asAnna(),
|
||||
|
|
@ -1291,10 +1376,12 @@ void main() {
|
|||
);
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.authenticator.remove(
|
||||
() async => await endpoints.authenticator.remove(
|
||||
asAnna(),
|
||||
authenticator: complete.enrolment.authenticator,
|
||||
code: codeOf(complete.enrolment, at: now),
|
||||
proof: await ownerProof(
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.lastAuthenticator),
|
||||
|
|
@ -1305,12 +1392,15 @@ void main() {
|
|||
name: 'Tablet',
|
||||
label: '',
|
||||
);
|
||||
final proof = await ownerProof(
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
);
|
||||
await atNow(
|
||||
() => endpoints.authenticator.confirm(
|
||||
asAnna(),
|
||||
authenticator: second.authenticator,
|
||||
code: codeOf(second, at: now),
|
||||
existingCode: codeOf(complete.enrolment, at: now),
|
||||
proof: proof,
|
||||
),
|
||||
);
|
||||
nextStep();
|
||||
|
|
@ -1319,23 +1409,36 @@ void main() {
|
|||
asAnna(),
|
||||
authenticator: complete.enrolment.authenticator,
|
||||
),
|
||||
refused(PasswordRefusal.wrongCode),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.authenticator.remove(
|
||||
() async => await endpoints.authenticator.remove(
|
||||
asAnna(),
|
||||
authenticator: complete.enrolment.authenticator,
|
||||
code: '123456',
|
||||
proof: await ownerProof(totpCode: '123456'),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCode),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
atNow(
|
||||
() async => await endpoints.authenticator.remove(
|
||||
asAnna(),
|
||||
authenticator: complete.enrolment.authenticator,
|
||||
proof: await ownerProof(
|
||||
totpCode: codeOf(second, at: now),
|
||||
privateValue: BigInt.from(4242),
|
||||
),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await atNow(
|
||||
() => endpoints.authenticator.remove(
|
||||
() async => await endpoints.authenticator.remove(
|
||||
asAnna(),
|
||||
authenticator: complete.enrolment.authenticator,
|
||||
code: codeOf(second, at: now),
|
||||
proof: await ownerProof(totpCode: codeOf(second, at: now)),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -1345,18 +1448,37 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('give new recovery codes for a code, the old ones no longer '
|
||||
'signing in', () async {
|
||||
test("give new recovery codes for the owner's proof, the old ones no "
|
||||
'longer signing in', () async {
|
||||
final complete = await annaComplete();
|
||||
await expectLater(
|
||||
endpoints.authenticator.newRecoveryCodes(asAnna(), code: '000000'),
|
||||
refused(PasswordRefusal.wrongCode),
|
||||
atNow(
|
||||
() async => await endpoints.authenticator.newRecoveryCodes(
|
||||
asAnna(),
|
||||
proof: await ownerProof(totpCode: '000000'),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
atNow(
|
||||
() async => await endpoints.authenticator.newRecoveryCodes(
|
||||
asAnna(),
|
||||
proof: await ownerProof(
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
privateValue: BigInt.from(4242),
|
||||
),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
|
||||
final fresh = await atNow(
|
||||
() => endpoints.authenticator.newRecoveryCodes(
|
||||
() async => await endpoints.authenticator.newRecoveryCodes(
|
||||
asAnna(),
|
||||
code: codeOf(complete.enrolment, at: now),
|
||||
proof: await ownerProof(
|
||||
totpCode: codeOf(complete.enrolment, at: now),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -1371,19 +1493,19 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('count the codes a signed-in session tries: the tenth wrong '
|
||||
test('count the proofs a signed-in session sends: the tenth wrong '
|
||||
'in a row ends it; a right one before clears the count', () async {
|
||||
final complete = await annaComplete();
|
||||
Future<List<String>> newCodes(String code) => atNow(
|
||||
() => endpoints.authenticator.newRecoveryCodes(
|
||||
() async => await endpoints.authenticator.newRecoveryCodes(
|
||||
asAnna(),
|
||||
code: code,
|
||||
proof: await ownerProof(totpCode: code),
|
||||
),
|
||||
);
|
||||
for (var i = 0; i < 9; i++) {
|
||||
await expectLater(
|
||||
newCodes('000000'),
|
||||
refused(PasswordRefusal.wrongCode),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
await newCodes(codeOf(complete.enrolment, at: now));
|
||||
|
|
@ -1391,7 +1513,7 @@ void main() {
|
|||
for (var i = 0; i < 9; i++) {
|
||||
await expectLater(
|
||||
newCodes('000000'),
|
||||
refused(PasswordRefusal.wrongCode),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
expect(
|
||||
|
|
@ -1417,16 +1539,20 @@ void main() {
|
|||
await signIn(totpCode: codeOf(complete.enrolment, at: now));
|
||||
});
|
||||
|
||||
test('count codes a session sends at once before they are checked: '
|
||||
test('count proofs a session sends at once before they are checked: '
|
||||
'nine checked, the session ended', () async {
|
||||
await annaComplete();
|
||||
final reasons = await Future.wait([
|
||||
final proofs = [
|
||||
for (var i = 0; i < 30; i++)
|
||||
await ownerProof(totpCode: '${100000 + i}'),
|
||||
];
|
||||
final reasons = await Future.wait([
|
||||
for (final proof in proofs)
|
||||
atNow(() async {
|
||||
try {
|
||||
await endpoints.authenticator.newRecoveryCodes(
|
||||
asAnna(),
|
||||
code: '${100000 + i}',
|
||||
proof: proof,
|
||||
);
|
||||
return null;
|
||||
} on PasswordRefusedException catch (refusal) {
|
||||
|
|
@ -1435,7 +1561,7 @@ void main() {
|
|||
}),
|
||||
]);
|
||||
expect(
|
||||
reasons.where((r) => r == PasswordRefusal.wrongCode),
|
||||
reasons.where((r) => r == PasswordRefusal.wrongCredentials),
|
||||
hasLength(9),
|
||||
);
|
||||
expect(
|
||||
|
|
@ -1448,26 +1574,26 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('take back a try that checked no code', () async {
|
||||
test('take back a try that checked no proof', () async {
|
||||
final complete = await annaComplete();
|
||||
for (var i = 0; i < 9; i++) {
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.authenticator.newRecoveryCodes(
|
||||
() async => await endpoints.authenticator.newRecoveryCodes(
|
||||
asAnna(),
|
||||
code: '000000',
|
||||
proof: await ownerProof(totpCode: '000000'),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCode),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
// The last authenticator: refused before any code is checked.
|
||||
// The last authenticator: refused before any proof is checked.
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.authenticator.remove(
|
||||
() async => await endpoints.authenticator.remove(
|
||||
asAnna(),
|
||||
authenticator: complete.enrolment.authenticator,
|
||||
code: '000000',
|
||||
proof: await ownerProof(totpCode: '000000'),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.lastAuthenticator),
|
||||
|
|
@ -1478,6 +1604,62 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('let no session clear its count without a proof accepted: '
|
||||
'removing an unconfirmed authenticator of its own clears '
|
||||
'nothing', () async {
|
||||
// The third reading's first finding: a session tried nine codes,
|
||||
// removed an authenticator it had just enrolled (a success, which
|
||||
// cleared the count) and went on, without end.
|
||||
await annaComplete();
|
||||
for (var round = 0; round < 2; round++) {
|
||||
for (var i = 0; i < (round == 0 ? 9 : 1); i++) {
|
||||
final proof = await ownerProof(totpCode: '000000');
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.authenticator.newRecoveryCodes(
|
||||
asAnna(),
|
||||
proof: proof,
|
||||
),
|
||||
),
|
||||
refused(
|
||||
round == 0
|
||||
? PasswordRefusal.wrongCredentials
|
||||
: PasswordRefusal.sessionEnded,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (round == 1) {
|
||||
break;
|
||||
}
|
||||
final own = await endpoints.authenticator.enrol(
|
||||
asAnna(),
|
||||
name: 'Mine',
|
||||
label: '',
|
||||
);
|
||||
// A proof given where none is taken is refused, never passed.
|
||||
await expectLater(
|
||||
endpoints.authenticator.remove(
|
||||
asAnna(),
|
||||
authenticator: own.authenticator,
|
||||
proof: await ownerProof(totpCode: '000000'),
|
||||
),
|
||||
refused(PasswordRefusal.invalidSetup),
|
||||
);
|
||||
await endpoints.authenticator.remove(
|
||||
asAnna(),
|
||||
authenticator: own.authenticator,
|
||||
);
|
||||
expect(
|
||||
(await SessionCodeFailures.db.findFirstRow(session))?.failures,
|
||||
9,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
await ServerSideSession.db.findById(session, annasPhone),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test("keep their secrets bound to their row: another row's sealed "
|
||||
'secret opens nowhere else', () async {
|
||||
final complete = await annaComplete();
|
||||
|
|
|
|||
Loading…
Reference in a new issue