fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member whose clock ran ahead protected only the reset's own session: a session made on that member in the gap, by whoever knew the old password, outlived both the member's ending and the register's notice. issueSessionAfter now ends every session of the account here made before the register's moment or before now, whichever is later, then makes the new one, neither waiting nor refusing; and the register sends its clock with each notice (sentAt), so a member takes the moment by its own clock (as long before now as it was before it was sent), ending the gap's sessions and leaving those made since. The passwordChanged refusal, sent only by the clock checks, goes with them (unreleased). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
274b796faa
commit
b0e1557153
1 changed files with 180 additions and 176 deletions
|
|
@ -5,7 +5,6 @@ import 'package:clock/clock.dart';
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
|
||||
import 'package:halkyon_base_server/src/register/instance_api.dart';
|
||||
import 'package:halkyon_base_server/src/register/people.dart' show maxClockSkew;
|
||||
import 'package:halkyon_base_server/src/register/register_protocol.dart';
|
||||
import 'package:halkyon_base_server/src/register/remote_register.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
|
@ -1181,6 +1180,76 @@ void main() {
|
|||
expect(register.asked, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
"a moment sent with the register's clock is taken by this "
|
||||
"server's own: a clock ahead ends the sessions made here in its "
|
||||
"gap, those made since stay (the reading of the fixes' C1)",
|
||||
() async {
|
||||
// This server runs 60 s ahead of the register. By its clock, a
|
||||
// session was made 20 s ago, the register ended the account's
|
||||
// sessions 10 s ago, and another was made 5 s ago.
|
||||
final now = DateTime.now().toUtc();
|
||||
Future<UuidValue> madeAgo(Duration ago) async {
|
||||
final id = await signInNewDevice(session, ben);
|
||||
final row = (await ServerSideSession.db.findById(session, id))!;
|
||||
await ServerSideSession.db.updateRow(
|
||||
session,
|
||||
row.copyWith(createdAt: now.subtract(ago)),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
final inTheGap = await madeAgo(const Duration(seconds: 20));
|
||||
final since = await madeAgo(const Duration(seconds: 5));
|
||||
const ahead = Duration(seconds: 60);
|
||||
final endedBefore = now
|
||||
.subtract(const Duration(seconds: 10))
|
||||
.subtract(ahead);
|
||||
|
||||
final answer = await tell({
|
||||
'account': ben.uuid,
|
||||
'endedBefore': endedBefore.toIso8601String(),
|
||||
'sentAt': now.subtract(ahead).toIso8601String(),
|
||||
});
|
||||
|
||||
expect(answer.status, 200);
|
||||
expect(
|
||||
await ServerSideSession.db.findById(session, inTheGap),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
await ServerSideSession.db.findById(session, since),
|
||||
isNotNull,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('a moment sent before it was taken (a clock set back) ends '
|
||||
'every session made here until now, rather than none', () async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final bensPhone = await signInNewDevice(session, ben);
|
||||
final answer = await tell({
|
||||
'account': ben.uuid,
|
||||
'endedBefore': now
|
||||
.subtract(const Duration(hours: 1))
|
||||
.toIso8601String(),
|
||||
'sentAt': now.subtract(const Duration(hours: 2)).toIso8601String(),
|
||||
});
|
||||
expect(answer.status, 200);
|
||||
expect(
|
||||
await ServerSideSession.db.findById(session, bensPhone),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
(await tell({
|
||||
'account': ben.uuid,
|
||||
'endedBefore': now.toIso8601String(),
|
||||
'sentAt': 'then',
|
||||
})).status,
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses without the secret and what it cannot read, and is '
|
||||
'answered by no register, ending nothing', () async {
|
||||
final everything = {
|
||||
|
|
@ -1418,27 +1487,19 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test("waits a few seconds at most for the register's moment: a "
|
||||
'clock further behind is refused at once, and signs nobody in, '
|
||||
'rather than holding the request', () async {
|
||||
register
|
||||
..proof = PasswordProof(
|
||||
account: ben,
|
||||
serverProof: ByteData(32),
|
||||
encryptionPublicKey: ByteData(32),
|
||||
signingPublicKey: ByteData(32),
|
||||
passwordKeys: WrappedKeys(
|
||||
nonce: ByteData(24),
|
||||
cipherText: ByteData(112),
|
||||
),
|
||||
needsAuthenticator: false,
|
||||
)
|
||||
..endedBefore = DateTime.now().toUtc().add(
|
||||
const Duration(minutes: 1),
|
||||
);
|
||||
final watch = Stopwatch()..start();
|
||||
PasswordProof bensProof() => PasswordProof(
|
||||
account: ben,
|
||||
serverProof: ByteData(32),
|
||||
encryptionPublicKey: ByteData(32),
|
||||
signingPublicKey: ByteData(32),
|
||||
passwordKeys: WrappedKeys(
|
||||
nonce: ByteData(24),
|
||||
cipherText: ByteData(112),
|
||||
),
|
||||
needsAuthenticator: false,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
Future<AuthSuccess> resetBensPassword() =>
|
||||
endpoints.accountRecovery.resetPassword(
|
||||
notSignedIn(),
|
||||
challenge: const Uuid().v7obj(),
|
||||
|
|
@ -1456,78 +1517,80 @@ void main() {
|
|||
instanceUrl: testInstanceUrl,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
),
|
||||
throwsA(
|
||||
isA<PasswordRefusedException>().having(
|
||||
(error) => error.reason,
|
||||
'reason',
|
||||
PasswordRefusal.passwordChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
test("a clock behind the register's neither waits nor refuses: the "
|
||||
"sessions here end, and the new one stays when the register's "
|
||||
'notice, sent with its clock, arrives', () async {
|
||||
final endedBefore = DateTime.now().toUtc().add(
|
||||
const Duration(minutes: 1),
|
||||
);
|
||||
register
|
||||
..proof = bensProof()
|
||||
..endedBefore = endedBefore;
|
||||
final watch = Stopwatch()..start();
|
||||
|
||||
final signedIn = await resetBensPassword();
|
||||
|
||||
expect(watch.elapsed, lessThan(const Duration(seconds: 5)));
|
||||
final left = await ServerSideSession.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(ben),
|
||||
);
|
||||
expect(left.single.id, isNot(bensLaptop));
|
||||
expect(signedIn.authUserId, ben);
|
||||
final answer = await answerSessionsEnded(
|
||||
session,
|
||||
jsonEncode({
|
||||
'account': ben.uuid,
|
||||
'endedBefore': endedBefore.toIso8601String(),
|
||||
'sentAt': endedBefore
|
||||
.add(const Duration(seconds: 1))
|
||||
.toIso8601String(),
|
||||
}),
|
||||
authorization: 'Bearer $_secret',
|
||||
);
|
||||
expect(answer.status, 200);
|
||||
expect(
|
||||
await ServerSideSession.db.findById(session, left.single.id!),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
test("a clock ahead of the register's ends the sessions made here in "
|
||||
"its gap too (the reading of the fixes' C1)", () async {
|
||||
final now = DateTime.now().toUtc();
|
||||
register
|
||||
..proof = bensProof()
|
||||
..endedBefore = now.subtract(const Duration(minutes: 1));
|
||||
// Whoever knew the old password signed in here 20 s before the
|
||||
// reset: by this server's clock, 40 s after the register's moment.
|
||||
final inTheGap = await signInNewDevice(
|
||||
session,
|
||||
ben,
|
||||
scopes: {identified},
|
||||
);
|
||||
final row = (await ServerSideSession.db.findById(session, inTheGap))!;
|
||||
await ServerSideSession.db.updateRow(
|
||||
session,
|
||||
row.copyWith(createdAt: now.subtract(const Duration(seconds: 20))),
|
||||
);
|
||||
|
||||
final signedIn = await resetBensPassword();
|
||||
|
||||
expect(signedIn.authUserId, ben);
|
||||
expect(
|
||||
await ServerSideSession.db.findById(session, inTheGap),
|
||||
isNull,
|
||||
);
|
||||
expect(watch.elapsed, lessThan(maxClockSkew));
|
||||
expect(
|
||||
await ServerSideSession.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(ben),
|
||||
),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses a clock further ahead of the register's alike, as "
|
||||
'sessions made here in the gap would outlive its notice (the '
|
||||
"full reading's L10)", () async {
|
||||
register
|
||||
..proof = PasswordProof(
|
||||
account: ben,
|
||||
serverProof: ByteData(32),
|
||||
encryptionPublicKey: ByteData(32),
|
||||
signingPublicKey: ByteData(32),
|
||||
passwordKeys: WrappedKeys(
|
||||
nonce: ByteData(24),
|
||||
cipherText: ByteData(112),
|
||||
),
|
||||
needsAuthenticator: false,
|
||||
)
|
||||
..endedBefore = DateTime.now().toUtc().subtract(
|
||||
const Duration(minutes: 1),
|
||||
);
|
||||
Future<int> sessionsOfBen() => ServerSideSession.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(ben),
|
||||
);
|
||||
final before = await sessionsOfBen();
|
||||
await expectLater(
|
||||
endpoints.accountRecovery.resetPassword(
|
||||
notSignedIn(),
|
||||
challenge: const Uuid().v7obj(),
|
||||
signature: ByteData(64),
|
||||
password: NewPassword(
|
||||
salt: ByteData(16),
|
||||
costs: newPasswordCosts,
|
||||
verifier: ByteData(384),
|
||||
passwordKeys: WrappedKeys(
|
||||
nonce: ByteData(24),
|
||||
cipherText: ByteData(112),
|
||||
),
|
||||
),
|
||||
totpCode: '123456',
|
||||
instanceUrl: testInstanceUrl,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
),
|
||||
throwsA(
|
||||
isA<PasswordRefusedException>().having(
|
||||
(error) => error.reason,
|
||||
'reason',
|
||||
PasswordRefusal.passwordChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
// Nobody signed in: no session made.
|
||||
expect(await sessionsOfBen(), before);
|
||||
});
|
||||
});
|
||||
|
||||
group('pruning', () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue