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

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

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

The app follows in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 10:33:25 +02:00
commit a9459bb875
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 1461 additions and 1026 deletions

View file

@ -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 {

View file

@ -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();

View file

@ -282,7 +282,7 @@ final class _Register implements AccountRegister {
required UuidValue person,
required UuidValue authenticator,
required String code,
String? existingCode,
OwnerProof? proof,
}) => _answer('confirmAuthenticator', () {
authenticated.add(person);
return const ['K7QX-M2PD'];
@ -299,14 +299,14 @@ final class _Register implements AccountRegister {
Session session, {
required UuidValue person,
required UuidValue authenticator,
String? code,
OwnerProof? proof,
}) => _answer('removeAuthenticator', () {});
@override
Future<List<String>> newRecoveryCodes(
Session session, {
required UuidValue person,
required String code,
required OwnerProof proof,
}) => _answer('newRecoveryCodes', () => const ['K7QX-M2PD']);
@override
@ -407,34 +407,21 @@ final class _Register implements AccountRegister {
Future<void> changePassword(
Session session, {
required UuidValue person,
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
required OwnerProof proof,
required NewPassword password,
UuidValue? keepSession,
required String host,
required String address,
required String language,
}) => _answer('changePassword', () {
changed[person] = keepSession;
changedWith.add(proof);
});
@override
Future<void> setRecoveryKeys(
Session session, {
required UuidValue person,
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
required OwnerProof proof,
required WrappedKeys recoveryKeys,
required ByteData recoveryProofKey,
required String host,
required String address,
required String language,
}) => _answer('setRecoveryKeys', () {});
@override
@ -450,6 +437,7 @@ final class _Register implements AccountRegister {
/// The proofs deletions came with, as the member bound them.
final deletedWith = <DeletionProof?>[];
final changedWith = <OwnerProof>[];
@override
Future<Set<UuidValue>> knownAccounts(
@ -859,8 +847,6 @@ void main() {
clientProof: ByteData(32),
totpCode: '123456',
host: 'LOCALHOST',
address: 'anna@example.org',
language: 'en',
),
);
@ -1047,10 +1033,13 @@ void main() {
await endpoints.password.change(
asBen(),
challenge: challenge.challenge,
clientValue: ByteData(384),
clientProof: ByteData(32),
totpCode: '123456',
proof: OwnerProof(
challenge: challenge.challenge,
clientValue: ByteData(384),
clientProof: ByteData(32),
host: 'LOCALHOST',
totpCode: '123456',
),
password: NewPassword(
salt: ByteData(16),
costs: newPasswordCosts,
@ -1060,13 +1049,57 @@ void main() {
cipherText: ByteData(112),
),
),
host: testHost,
address: 'anna@example.org',
language: 'en',
);
expect(register.asked, ['beginPasswordChange', 'changePassword']);
expect(register.changed, {ben: bensLaptop});
// Bound to the host the device addressed here.
expect(register.changedWith.single.host, testHost);
expect(register.changedWith.single.totpCode, '123456');
});
test("counts its own session's wrong proofs, which the register "
'refuses: the tenth in a row ends the session here', () async {
register.failing = PasswordRefusedException(
reason: PasswordRefusal.wrongCredentials,
);
Future<List<String>> newCodes() =>
endpoints.authenticator.newRecoveryCodes(
asBen(),
proof: OwnerProof(
challenge: const Uuid().v7obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
host: testHost,
totpCode: '000000',
),
);
for (var i = 0; i < 9; i++) {
await expectLater(
newCodes(),
throwsA(
isA<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
PasswordRefusal.wrongCredentials,
),
),
);
}
await expectLater(
newCodes(),
throwsA(
isA<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
PasswordRefusal.sessionEnded,
),
),
);
expect(
await ServerSideSession.db.findById(session, bensLaptop),
isNull,
);
});
test('a reset ends the sessions here made before the register ended '

View file

@ -533,16 +533,20 @@ void main() {
authenticator: spare.authenticator,
code: codeOf(spare),
),
refused(PasswordRefusal.wrongExistingCode),
refused(PasswordRefusal.wrongCredentials),
);
now = now.add(totpPeriod);
Future<OwnerProof> ownerProof() async => device.ownerProof(
await remote.beginPasswordChange(session, person: annaOnTodo),
totpCode: codeOf(enrolment),
);
expect(
await remote.confirmAuthenticator(
session,
person: annaOnTodo,
authenticator: spare.authenticator,
code: codeOf(spare),
existingCode: codeOf(enrolment),
proof: await ownerProof(),
),
isEmpty,
);
@ -551,7 +555,7 @@ void main() {
session,
person: annaOnTodo,
authenticator: spare.authenticator,
code: codeOf(enrolment),
proof: await ownerProof(),
);
final ways = await remote.signInWaysOf(session, annaOnTodo);
expect(ways.password && ways.authenticator, isTrue);
@ -601,7 +605,7 @@ void main() {
await remote.newRecoveryCodes(
session,
person: annaOnTodo,
code: codeOf(enrolment, at: now),
proof: await ownerProof(),
),
hasLength(recoveryCodeCount),
);
@ -749,19 +753,15 @@ void main() {
session,
person: annaOnTodo,
);
final answer = device.answer(challenge);
await remote.changePassword(
session,
person: annaOnTodo,
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(enrolment),
proof: device.ownerProof(
challenge,
totpCode: codeOf(enrolment),
),
password: device.newPassword(),
keepSession: annasPhone,
host: testHost,
address: 'anna@example.org',
language: 'en',
);
expect(members.sessionsTold.single, (
_todo,
@ -780,21 +780,17 @@ void main() {
session,
person: annaOnTodo,
);
final answer = device.answer(challenge);
await remote.setRecoveryKeys(
session,
person: annaOnTodo,
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(enrolment),
proof: device.ownerProof(
challenge,
totpCode: codeOf(enrolment),
),
recoveryKeys: fresh,
recoveryProofKey: ByteData.sublistView(
device.recoveryProofKey,
),
host: testHost,
address: 'anna@example.org',
language: 'en',
);
}

View file

@ -155,6 +155,27 @@ final class PasswordDevice {
);
}
/// The owner's proof for [challenge] (a change's): the device's answer
/// with its password, or the [privateValue] of another, and a
/// [totpCode] or [recoveryCode].
OwnerProof ownerProof(
PasswordChallenge challenge, {
String? totpCode,
String? recoveryCode,
BigInt? privateValue,
String host = testHost,
}) {
final reply = answer(challenge, privateValue: privateValue, host: host);
return OwnerProof(
challenge: challenge.challenge,
clientValue: reply.clientValue,
clientProof: reply.clientProof,
host: host,
totpCode: totpCode,
recoveryCode: recoveryCode,
);
}
/// The signature proving the recovery phrase for [challenge] on [host]:
/// for the new [password] it sets, or for an authenticator's reset
/// without one; by the phrase's proof key, or [keys]' where given.

View file

@ -30,6 +30,8 @@ import 'package:halkyon_base_server/src/generated/password/key_proof_challenge.d
as _iaysae70;
import 'package:halkyon_base_server/src/generated/password/new_password.dart'
as _ijye8mcv;
import 'package:halkyon_base_server/src/generated/password/owner_proof.dart'
as _ifhod73s;
import 'package:halkyon_base_server/src/generated/password/password_challenge.dart'
as _isz0atlx;
import 'package:halkyon_base_server/src/generated/password/password_setup.dart'
@ -830,7 +832,7 @@ class _AuthenticatorEndpoint {
_ist.TestSessionBuilder sessionBuilder, {
required _is.UuidValue authenticator,
required String code,
String? existingCode,
_ifhod73s.OwnerProof? proof,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
@ -846,7 +848,7 @@ class _AuthenticatorEndpoint {
parameters: _ist.testObjectToJson({
'authenticator': authenticator,
'code': code,
'existingCode': existingCode,
'proof': proof,
}),
serializationManager: _serializationManager,
);
@ -896,7 +898,7 @@ class _AuthenticatorEndpoint {
_ida.Future<void> remove(
_ist.TestSessionBuilder sessionBuilder, {
required _is.UuidValue authenticator,
String? code,
_ifhod73s.OwnerProof? proof,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
@ -911,7 +913,7 @@ class _AuthenticatorEndpoint {
methodName: 'remove',
parameters: _ist.testObjectToJson({
'authenticator': authenticator,
'code': code,
'proof': proof,
}),
serializationManager: _serializationManager,
);
@ -930,7 +932,7 @@ class _AuthenticatorEndpoint {
_ida.Future<List<String>> newRecoveryCodes(
_ist.TestSessionBuilder sessionBuilder, {
required String code,
required _ifhod73s.OwnerProof proof,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
@ -943,7 +945,7 @@ class _AuthenticatorEndpoint {
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'authenticator',
methodName: 'newRecoveryCodes',
parameters: _ist.testObjectToJson({'code': code}),
parameters: _ist.testObjectToJson({'proof': proof}),
serializationManager: _serializationManager,
);
var _localReturnValue =
@ -1039,15 +1041,8 @@ class _PasswordEndpoint {
_ida.Future<void> change(
_ist.TestSessionBuilder sessionBuilder, {
required _is.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
String? totpCode,
String? recoveryCode,
required _ifhod73s.OwnerProof proof,
required _ijye8mcv.NewPassword password,
required String host,
required String address,
required String language,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
@ -1061,15 +1056,8 @@ class _PasswordEndpoint {
endpointPath: 'password',
methodName: 'change',
parameters: _ist.testObjectToJson({
'challenge': challenge,
'clientValue': clientValue,
'clientProof': clientProof,
'totpCode': totpCode,
'recoveryCode': recoveryCode,
'proof': proof,
'password': password,
'host': host,
'address': address,
'language': language,
}),
serializationManager: _serializationManager,
);
@ -1088,16 +1076,9 @@ class _PasswordEndpoint {
_ida.Future<void> setRecoveryKeys(
_ist.TestSessionBuilder sessionBuilder, {
required _is.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
String? totpCode,
String? recoveryCode,
required _ifhod73s.OwnerProof proof,
required _iji0wmu8.WrappedKeys recoveryKeys,
required _idt.ByteData recoveryProofKey,
required String host,
required String address,
required String language,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
@ -1111,16 +1092,9 @@ class _PasswordEndpoint {
endpointPath: 'password',
methodName: 'setRecoveryKeys',
parameters: _ist.testObjectToJson({
'challenge': challenge,
'clientValue': clientValue,
'clientProof': clientProof,
'totpCode': totpCode,
'recoveryCode': recoveryCode,
'proof': proof,
'recoveryKeys': recoveryKeys,
'recoveryProofKey': recoveryProofKey,
'host': host,
'address': address,
'language': language,
}),
serializationManager: _serializationManager,
);