fix(auth): a reset waits while its owner is held out, and devices show it
The full reading's H1: an owner could be kept from calling a reset off, and the reset then went through. Whoever knew the password and had the mailbox asked an authenticators' reset and kept the codes waiting, so the owner's sign-ins were refused until it took effect; whoever had the mailbox filled the address's proofs ceiling from three networks and completed an account reset. Now every hold-out of the owner after a reset was asked (a wait that begins, an attempt a wait refuses, a sign-in or proof refused by the address's ceiling) moves the account's resets not carried out to take effect no sooner than 72 hours after it ends (pushOutResets). A reset carried out (the password alone signing in until an authenticator is confirmed) is never pushed, nor a lapsed one brought back. Signed-in devices are told too: SignInWays carries when a waiting reset of each kind takes effect, the account section shows them with Call it off, which takes the owner's proof (password and code; password. callOffResets, forwarded by members) and calls every reset off, as no wait holds a signed-in owner's proof back. Texts in English and German. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
4d1cd2045c
commit
9d0d037c86
4 changed files with 1022 additions and 18 deletions
|
|
@ -238,6 +238,208 @@ void main() {
|
|||
orderBy: (t) => t.kind,
|
||||
);
|
||||
|
||||
group('wait while their owner is held out (H1)', () {
|
||||
test('codes made to wait push a reset of the authenticators out: '
|
||||
'whoever knows the password and has the mailbox carries out '
|
||||
'nothing while the owner is held back, and the owner calls it '
|
||||
'off once codes are taken again', () async {
|
||||
final enrolment = await annaComplete();
|
||||
final asked = await askAuthenticatorReset();
|
||||
expect(mailbox.notices, hasLength(1));
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await expectLater(
|
||||
signInFrom('192.0.2.9', totp: '000000'),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
final waitEnds = mailbox.codesWait.single.until;
|
||||
expect(
|
||||
(await resets()).single.takesEffectAt,
|
||||
waitEnds.add(resetDelay),
|
||||
);
|
||||
// Anna, held back while codes wait: her try pushes it no less.
|
||||
nextStep();
|
||||
await expectLater(
|
||||
signInFrom('198.51.100.7', totp: codeOf(enrolment, at: now)),
|
||||
refused(PasswordRefusal.codesWait),
|
||||
);
|
||||
// When it would have taken effect, the password alone carries
|
||||
// out nothing.
|
||||
now = asked.takesEffectAt;
|
||||
await expectLater(
|
||||
signInFrom('192.0.2.10'),
|
||||
refused(PasswordRefusal.notDue),
|
||||
);
|
||||
// Codes taken again: Anna signs in, and it is called off.
|
||||
now = waitEnds.add(const Duration(minutes: 1));
|
||||
await signInFrom('198.51.100.7', totp: codeOf(enrolment, at: now));
|
||||
expect(await resets(), isEmpty);
|
||||
});
|
||||
|
||||
test("the address's ceiling filled from three networks pushes the "
|
||||
"account's reset out: whoever has the mailbox completes nothing "
|
||||
'while the owner is held back', () async {
|
||||
final enrolment = await annaComplete();
|
||||
final asked = await askAccountReset();
|
||||
final wrong = await PasswordDevice.make(seed: 77);
|
||||
for (final network in ['192.0.2.1', '192.0.2.2', '192.0.2.3']) {
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await expectLater(
|
||||
signInFrom(network, with_: wrong),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
}
|
||||
final heldOutAt = now;
|
||||
await expectLater(
|
||||
signInFrom('198.51.100.7', totp: codeOf(enrolment, at: now)),
|
||||
refused(PasswordRefusal.tooManyAttempts),
|
||||
);
|
||||
expect(
|
||||
(await resets()).single.takesEffectAt,
|
||||
heldOutAt.add(const Duration(hours: 1)).add(resetDelay),
|
||||
);
|
||||
now = asked.takesEffectAt;
|
||||
final other = await PasswordDevice.make(seed: 5);
|
||||
await expectLater(
|
||||
completeAccountReset(other.setup()),
|
||||
refused(PasswordRefusal.notDue),
|
||||
);
|
||||
// The hour over, Anna signs in and calls it off.
|
||||
nextStep();
|
||||
await signInFrom('198.51.100.7', totp: codeOf(enrolment, at: now));
|
||||
expect(await resets(), isEmpty);
|
||||
});
|
||||
|
||||
test('a reset carried out is never pushed: its owner, mid-way, '
|
||||
'still signs in with the password alone', () async {
|
||||
await annaComplete();
|
||||
final asked = await askAuthenticatorReset();
|
||||
now = asked.takesEffectAt;
|
||||
expect(
|
||||
(await signInFrom('198.51.100.7')).needsAuthenticator,
|
||||
isTrue,
|
||||
);
|
||||
// 29 more proofs fill the address's 30.
|
||||
final wrong = await PasswordDevice.make(seed: 77);
|
||||
for (var i = 0; i < 29; i++) {
|
||||
await expectLater(
|
||||
signInFrom('192.0.2.${i ~/ 10 + 1}', with_: wrong),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
await expectLater(
|
||||
signInFrom('198.51.100.8'),
|
||||
refused(PasswordRefusal.tooManyAttempts),
|
||||
);
|
||||
now = now.add(const Duration(hours: 1, minutes: 1));
|
||||
expect(
|
||||
(await signInFrom('198.51.100.8')).needsAuthenticator,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('a reset that lapsed is never brought back by a push', () async {
|
||||
await annaComplete();
|
||||
final asked = await askAccountReset();
|
||||
now = asked.takesEffectAt.add(
|
||||
resetLapsesAfter + const Duration(minutes: 1),
|
||||
);
|
||||
final wrong = await PasswordDevice.make(seed: 77);
|
||||
for (final network in ['192.0.2.1', '192.0.2.2', '192.0.2.3']) {
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await expectLater(
|
||||
signInFrom(network, with_: wrong),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
}
|
||||
await expectLater(
|
||||
signInFrom('198.51.100.7'),
|
||||
refused(PasswordRefusal.tooManyAttempts),
|
||||
);
|
||||
expect((await resets()).single.takesEffectAt, asked.takesEffectAt);
|
||||
});
|
||||
|
||||
test("are shown to the owner's signed-in devices, each kind with "
|
||||
'when it takes effect, none lapsed or carried out', () async {
|
||||
await annaComplete();
|
||||
var ways = await endpoints.account.signInWays(asAnna());
|
||||
expect(ways.authenticatorResetAt, isNull);
|
||||
expect(ways.accountResetAt, isNull);
|
||||
final authenticator = await askAuthenticatorReset();
|
||||
final account = await askAccountReset();
|
||||
ways = await endpoints.account.signInWays(asAnna());
|
||||
expect(ways.authenticatorResetAt, authenticator.takesEffectAt);
|
||||
expect(ways.accountResetAt, account.takesEffectAt);
|
||||
// Lapsed: shown no more.
|
||||
now = account.takesEffectAt.add(
|
||||
resetLapsesAfter + const Duration(minutes: 1),
|
||||
);
|
||||
ways = await atNow(() => endpoints.account.signInWays(asAnna()));
|
||||
expect(ways.authenticatorResetAt, isNull);
|
||||
expect(ways.accountResetAt, isNull);
|
||||
});
|
||||
|
||||
test("are called off by the signed-in owner's proof, even while "
|
||||
'codes wait', () async {
|
||||
final enrolment = await annaComplete();
|
||||
await askAuthenticatorReset();
|
||||
await askAccountReset();
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await expectLater(
|
||||
signInFrom('192.0.2.9', totp: '000000'),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
nextStep();
|
||||
await atNow(
|
||||
() async => await endpoints.password.callOffResets(
|
||||
asAnna(),
|
||||
proof: device.ownerProof(
|
||||
await endpoints.password.beginChange(asAnna()),
|
||||
totpCode: codeOf(enrolment, at: now),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(await resets(), isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'are called off only with the password and a code: a session '
|
||||
'alone, a wrong password or a wrong code calls off nothing',
|
||||
() async {
|
||||
final enrolment = await annaComplete();
|
||||
await askAuthenticatorReset();
|
||||
final wrong = await PasswordDevice.make(seed: 77);
|
||||
for (final proof in <Future<OwnerProof> Function()>[
|
||||
() async => device.ownerProof(
|
||||
await endpoints.password.beginChange(asAnna()),
|
||||
),
|
||||
() async => wrong.ownerProof(
|
||||
await endpoints.password.beginChange(asAnna()),
|
||||
totpCode: codeOf(enrolment, at: now),
|
||||
),
|
||||
() async => device.ownerProof(
|
||||
await endpoints.password.beginChange(asAnna()),
|
||||
totpCode: '000000',
|
||||
),
|
||||
]) {
|
||||
await expectLater(
|
||||
atNow(
|
||||
() async => await endpoints.password.callOffResets(
|
||||
asAnna(),
|
||||
proof: await proof(),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
expect(await resets(), hasLength(1));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group("the phrase's codes count apart from the password's (M3)", () {
|
||||
test('codes waiting after the password never hold back the phrase: '
|
||||
'its owner resets the password with it and a code', () async {
|
||||
|
|
|
|||
|
|
@ -453,6 +453,13 @@ final class _Register implements AccountRegister {
|
|||
changedWith.add(proof);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> callOffResets(
|
||||
Session session, {
|
||||
required UuidValue person,
|
||||
required OwnerProof proof,
|
||||
}) => _answer('callOffResets', () {});
|
||||
|
||||
@override
|
||||
Future<void> setRecoveryKeys(
|
||||
Session session, {
|
||||
|
|
@ -1285,6 +1292,37 @@ void main() {
|
|||
expect(register.warned, isEmpty);
|
||||
});
|
||||
|
||||
test("passes its person's calling off of resets to the register, "
|
||||
'never one signed out', () async {
|
||||
await endpoints.password.callOffResets(
|
||||
asBen(),
|
||||
proof: OwnerProof(
|
||||
challenge: const Uuid().v7obj(),
|
||||
clientValue: ByteData(384),
|
||||
clientProof: ByteData(32),
|
||||
totpCode: '123456',
|
||||
host: testHost,
|
||||
),
|
||||
);
|
||||
expect(register.asked, contains('callOffResets'));
|
||||
register.asked.clear();
|
||||
await expectLater(
|
||||
endpoints.password.callOffResets(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
),
|
||||
proof: OwnerProof(
|
||||
challenge: const Uuid().v7obj(),
|
||||
clientValue: ByteData(384),
|
||||
clientProof: ByteData(32),
|
||||
host: testHost,
|
||||
),
|
||||
),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
expect(register.asked, isNot(contains('callOffResets')));
|
||||
});
|
||||
|
||||
test("passes its person's dismissing of the warning to the register, "
|
||||
"never another's", () async {
|
||||
await endpoints.password.dismissSessionEndedWarning(asBen());
|
||||
|
|
|
|||
|
|
@ -868,6 +868,29 @@ void main() {
|
|||
),
|
||||
),
|
||||
);
|
||||
|
||||
// The member's device is shown the reset, and its owner calls
|
||||
// it off with their proof (the full reading's H1).
|
||||
expect(
|
||||
(await remote.signInWaysOf(session, annaOnTodo)).accountResetAt,
|
||||
pending.takesEffectAt,
|
||||
);
|
||||
now = now.add(totpPeriod);
|
||||
await remote.callOffResets(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
proof: device.ownerProof(
|
||||
await remote.beginPasswordChange(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
),
|
||||
totpCode: codeOf(enrolment),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(await remote.signInWaysOf(session, annaOnTodo)).accountResetAt,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1074,6 +1074,37 @@ class _PasswordEndpoint {
|
|||
});
|
||||
}
|
||||
|
||||
_ida.Future<void> callOffResets(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required _ifhod73s.OwnerProof proof,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'password',
|
||||
method: 'callOffResets',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'password',
|
||||
methodName: 'callOffResets',
|
||||
parameters: _ist.testObjectToJson({'proof': proof}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<void>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<void> setRecoveryKeys(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required _ifhod73s.OwnerProof proof,
|
||||
|
|
|
|||
Loading…
Reference in a new issue