halkyon/packages/halkyon_base_server/test/integration/keys/keys_test.dart

1341 lines
47 KiB
Dart
Raw Normal View History

feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
import 'dart:typed_data';
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
import 'package:clock/clock.dart';
import 'package:cryptography/cryptography.dart';
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_base_server/src/password/passwords.dart'
show passwordRowOf;
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
import 'package:halkyon_oidc_server/oidc.dart';
import 'package:halkyon_oidc_server/testing.dart';
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../support/fixtures.dart';
import '../support/keys_device.dart';
import '../support/mailbox.dart';
import '../support/password_device.dart';
import '../support/password_support.dart';
import '../test_tools/serverpod_test_tools.dart';
// The keys of an account that signs in with SimpleLogin on a signed-in
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
// device (Halkyon Learn's ADR 0020, 7, D19 and D20): the password proven
// with its own proof key, a signature over the register's nonce, before the
// device gets the keys wrapped under it; the changes, each with that proof
// (the phrase's for a lost password) and a SimpleLogin sign-in of the last
// ten minutes, against a fake SimpleLogin (halkyon_oidc's testing.dart).
// Every way a proof is refused, and a session's wrong proofs ending it as
// ADR 0017's do.
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
void main() {
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
late TestKey key;
setUpAll(() => key = TestKey.generate('sl', seed: 13));
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
withServerpod(
'The keys of an account that signs in with SimpleLogin',
// Sessions ended for wrong proofs, in transactions of their own: these
// tests delete what they make instead.
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, endpoints) {
late Session session;
late KeysDevice device;
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
late FakeProvider simpleLogin;
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
/// With keys, signed in on her phone and her laptop; SimpleLogin's
/// `sl-anna`.
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
late UuidValue anna;
late UuidValue annasPhone;
late UuidValue annasLaptop;
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
/// Without keys, signed in on his laptop; SimpleLogin's `sl-ben`.
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
late UuidValue ben;
late UuidValue bensLaptop;
setUp(() async {
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
useBase(sessionBuilder, app: 'learn');
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
session = sessionBuilder.build();
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
await _deleteAll(session);
simpleLogin = FakeProvider.simpleLogin(
key: key,
clientId: 'halkyon-learn',
clientSecret: 'a secret of the app',
callbackUrl: Uri.parse('https://learn.test/simplelogin-callback'),
);
useSimpleLogin(sessionBuilder, simpleLogin);
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
device = await KeysDevice.make();
anna = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await giveKeys(session, anna, device);
annasPhone = await signInNewDevice(
session,
anna,
method: 'simplelogin',
);
annasLaptop = await signInNewDevice(
session,
anna,
method: 'simplelogin',
);
ben = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
bensLaptop = await signInNewDevice(session, ben, method: 'simplelogin');
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
for (final (person, subject) in [(anna, 'sl-anna'), (ben, 'sl-ben')]) {
await OidcIdentity.db.insertRow(
session,
OidcIdentity(
authUserId: person,
provider: simpleLoginProvider,
subject: subject,
),
);
}
});
tearDown(() async {
useSimpleLoginApp(session.serverpod, null);
await _deleteAll(sessionBuilder.build());
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
});
TestSessionBuilder signedIn(UuidValue person, UuidValue on) =>
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
{identified},
authId: on.uuid,
),
);
TestSessionBuilder onPhone() => signedIn(anna, annasPhone);
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
OidcIdp idp() => AuthServices.instance.oidcIdp;
Matcher refusedAsOidc(OidcRefusal reason) => throwsA(
isA<OidcRefusedException>().having((e) => e.reason, 'reason', reason),
);
Matcher refusedAsProfile(ProfileRefusal reason) => throwsA(
isA<ProfileRefusedException>().having(
(e) => e.reason,
'reason',
reason,
),
);
/// A SimpleLogin sign-in on [as], as [subject], just now: its attempt,
/// which stands for it ten minutes (D20).
Future<UuidValue> signInAgain(
TestSessionBuilder as,
String subject,
) async {
final start = await endpoints.simpleLogin.begin(
as,
back: SimpleLoginReturn.loopback,
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
);
await endpoints.simpleLogin.complete(
as,
attempt: start.attempt,
code: simpleLogin.authorize(start.authorizationUrl, subject: subject),
);
return start.attempt;
}
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
Future<Set<UuidValue>> sessionsOf(UuidValue person) async => {
for (final row in await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(person),
))
row.id!,
};
group('unlocking them', () {
test('gives the salt and costs, then the keys once the password '
'proved right', () async {
final challenge = await endpoints.keys.begin(onPhone());
expect(Uint8List.sublistView(challenge.salt), device.salt);
expect(challenge.costs.toJson(), newPasswordCosts.toJson());
expect(challenge.nonce.lengthInBytes, 32);
final keys = await endpoints.keys.unlock(
onPhone(),
await device.prove(challenge),
);
expect(
Uint8List.sublistView(keys.signingPublicKey),
device.signingPublicKey,
);
expect(
Uint8List.sublistView(keys.encryptionPublicKey),
device.encryptionPublicKey,
);
expect(keys.passwordKeys.toJson(), device.passwordKeys.toJson());
});
test('refuses a signature by another key: the recovery phrase, or '
"another password's", () async {
for (final keys in [
device.recoveryProofKeys,
(await KeysDevice.make(seed: 5)).passwordProofKeys,
]) {
final challenge = await endpoints.keys.begin(onPhone());
await expectLater(
endpoints.keys.unlock(
onPhone(),
await device.prove(challenge, keys: keys),
),
refused(PasswordRefusal.wrongCredentials),
);
}
});
test('refuses a proof made for another purpose or change', () async {
for (final (purpose, digest) in [
(KeyProofPurpose.deleteAccount, const <int>[]),
(KeyProofPurpose.unlockKeys, const [1]),
]) {
final challenge = await endpoints.keys.begin(onPhone());
await expectLater(
endpoints.keys.unlock(
onPhone(),
await device.prove(challenge, purpose: purpose, digest: digest),
),
refused(PasswordRefusal.wrongCredentials),
);
}
});
test('refuses a proof made for another instance, which a hostile '
'one could relay here', () async {
final challenge = await endpoints.keys.begin(onPhone());
await expectLater(
endpoints.keys.unlock(
onPhone(),
await device.prove(
challenge,
instanceUrl: 'https://evil.example/',
claimedUrl: testInstanceUrl,
),
),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
endpoints.keys.unlock(
onPhone(),
await device.prove(
await endpoints.keys.begin(onPhone()),
instanceUrl: 'https://evil.example/',
),
),
refused(PasswordRefusal.otherInstance),
);
});
test('takes each challenge once, and only from whom it was begun '
'for', () async {
final challenge = await endpoints.keys.begin(onPhone());
await endpoints.keys.unlock(
onPhone(),
await device.prove(challenge),
);
await expectLater(
endpoints.keys.unlock(onPhone(), await device.prove(challenge)),
refused(PasswordRefusal.challengeExpired),
);
final annas = await endpoints.keys.begin(onPhone());
await giveKeys(session, ben, await KeysDevice.make(seed: 3));
await expectLater(
endpoints.keys.unlock(
signedIn(ben, bensLaptop),
await device.prove(annas),
),
refused(PasswordRefusal.challengeExpired),
);
});
test('refuses an account without such keys: none, or a password '
'that signs in (ADR 0017)', () async {
await expectLater(
endpoints.keys.begin(signedIn(ben, bensLaptop)),
refused(PasswordRefusal.noPassword),
);
await AccountPassword.db.insertRow(
session,
passwordRowOf(
ben,
(await PasswordDevice.make()).setup(),
now: DateTime.now().toUtc(),
),
);
await expectLater(
endpoints.keys.begin(signedIn(ben, bensLaptop)),
refused(PasswordRefusal.noPassword),
);
});
test('takes no session at all', () async {
final notSignedIn = sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
await expectLater(
endpoints.keys.begin(notSignedIn),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
});
test("a session's wrong proofs count for it alone: the tenth in a "
"row ends it, the owner's other session unlocking as before, and "
'the owner is warned', () async {
final other = await KeysDevice.make(seed: 5);
for (var i = 0; i < 9; i++) {
await expectLater(
endpoints.keys.unlock(
onPhone(),
await other.prove(await endpoints.keys.begin(onPhone())),
),
refused(PasswordRefusal.wrongCredentials),
);
}
await expectLater(
endpoints.keys.unlock(
onPhone(),
await other.prove(await endpoints.keys.begin(onPhone())),
),
refused(PasswordRefusal.sessionEnded),
);
expect(await sessionsOf(anna), {annasLaptop});
expect(
(await endpoints.account.signInWays(
signedIn(anna, annasLaptop),
)).sessionEndedForProofsAt,
isNotNull,
);
final laptop = signedIn(anna, annasLaptop);
await endpoints.keys.unlock(
laptop,
await device.prove(await endpoints.keys.begin(laptop)),
);
});
test('a right proof clears the wrong ones before it', () async {
final other = await KeysDevice.make(seed: 5);
for (var round = 0; round < 2; round++) {
for (var i = 0; i < 9; i++) {
await expectLater(
endpoints.keys.unlock(
onPhone(),
await other.prove(await endpoints.keys.begin(onPhone())),
),
refused(PasswordRefusal.wrongCredentials),
);
}
await endpoints.keys.unlock(
onPhone(),
await device.prove(await endpoints.keys.begin(onPhone())),
);
}
expect(await sessionsOf(anna), {annasPhone, annasLaptop});
});
});
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
group('first keys, for an account SimpleLogin was added to (D20)', () {
late KeysDevice bens;
setUp(() async => bens = await KeysDevice.make(seed: 3));
TestSessionBuilder onBensLaptop() => signedIn(ben, bensLaptop);
Future<void> setWith(
UuidValue attempt, {
KeysSetup? setup,
String? handle = 'ben',
}) => endpoints.keys.set(
onBensLaptop(),
attempt: attempt,
setup: setup ?? bens.setup(),
handle: handle,
);
test('are set with a SimpleLogin sign-in of the last ten minutes, '
'taken, with a handle for an account without one, and then '
'unlock', () async {
final attempt = await signInAgain(onBensLaptop(), 'sl-ben');
await setWith(attempt, handle: 'Ben');
final row = (await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(ben),
))!;
expect(row.verifier, isNull);
expect(
Uint8List.sublistView(row.passwordProofKey!),
bens.passwordProofKey,
);
expect(
(await PublicProfile.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(ben),
))!.handle,
'Ben',
);
expect(
(await endpoints.account.signInWays(onBensLaptop())).keys,
isTrue,
);
expect(
await idp().isKept(session, attempt: attempt, caller: ben),
isFalse,
);
await endpoints.keys.unlock(
onBensLaptop(),
await bens.prove(await endpoints.keys.begin(onBensLaptop())),
);
});
test('keep the handle an account has, refusing another', () async {
await AccountRegister.of(session.serverpod).saveProfile(
session,
ben,
handle: 'Ben',
displayName: '',
bio: '',
links: const [],
);
final attempt = await signInAgain(onBensLaptop(), 'sl-ben');
await expectLater(
setWith(attempt, handle: 'Benjamin'),
refusedAsProfile(ProfileRefusal.handleInvalid),
);
await setWith(attempt, handle: null);
expect(
(await PublicProfile.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(ben),
))!.handle,
'Ben',
);
});
test(
'refuse a handle missing or refused as saving a profile refuses '
'it, and a setup out of range, the sign-in still standing',
() async {
await AccountRegister.of(session.serverpod).saveProfile(
session,
anna,
handle: 'Anna',
displayName: '',
bio: '',
links: const [],
);
final attempt = await signInAgain(onBensLaptop(), 'sl-ben');
for (final (handle, reason) in [
(null, ProfileRefusal.handleInvalid),
('ANNA', ProfileRefusal.handleTaken),
('admin', ProfileRefusal.handleReserved),
('ben!', ProfileRefusal.handleInvalid),
]) {
await expectLater(
setWith(attempt, handle: handle),
refusedAsProfile(reason),
reason: '$handle',
);
}
await expectLater(
setWith(
attempt,
setup: bens.setup().copyWith(salt: ByteData(15)),
),
refused(PasswordRefusal.invalidSetup),
);
await setWith(attempt);
},
);
test("count a new handle among the person's answers about handles "
'(30 an hour), the sign-in still standing when refused', () async {
final attempt = await signInAgain(onBensLaptop(), 'sl-ben');
for (var i = 0; i < handleChecksPerAsker; i++) {
await countHandleCheck(session, asker: ben.uuid, network: null);
}
await expectLater(
setWith(attempt),
refusedAsProfile(ProfileRefusal.tooManyHandleChecks),
);
expect(
await idp().isKept(session, attempt: attempt, caller: ben),
isTrue,
);
});
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
test('refuse an account with keys already, under either kind of '
'password', () async {
final annas = await signInAgain(onPhone(), 'sl-anna');
await expectLater(
endpoints.keys.set(onPhone(), attempt: annas, setup: bens.setup()),
refused(PasswordRefusal.passwordExists),
);
await AccountPassword.db.insertRow(
session,
passwordRowOf(
ben,
(await PasswordDevice.make()).setup(),
now: DateTime.now().toUtc(),
),
);
await expectLater(
setWith(await signInAgain(onBensLaptop(), 'sl-ben')),
refused(PasswordRefusal.passwordExists),
);
});
test("refuse without a SimpleLogin sign-in of the account's: none, "
"past its ten minutes, or another account's, which stands for "
'it still', () async {
final annas = await signInAgain(onPhone(), 'sl-anna');
final attempt = await signInAgain(onBensLaptop(), 'sl-ben');
await expectLater(
setWith(const Uuid().v4obj()),
refusedAsOidc(OidcRefusal.unknownAttempt),
);
await expectLater(
withClock(
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
() => setWith(attempt),
),
refusedAsOidc(OidcRefusal.unknownAttempt),
);
await expectLater(
setWith(annas),
refusedAsOidc(OidcRefusal.wrongCaller),
);
expect(
await idp().isKept(session, attempt: annas, caller: anna),
isTrue,
);
await setWith(attempt);
});
});
group('a new password (D20)', () {
late KeysDevice next;
setUp(() async => next = await KeysDevice.make(seed: 7));
/// The proof for a new password ([binding], else [next]'s) by
/// [keys] (the password's proof key unless given), with the
/// sign-in under [attempt].
Future<KeysOwnerProof> changeProof(
UuidValue attempt, {
KeysPassword? binding,
SimpleKeyPair? keys,
KeyProofPurpose purpose = KeyProofPurpose.newPassword,
KeysChallenge? challenge,
}) async => await device.ownerProof(
attempt,
challenge: challenge ?? await endpoints.keys.begin(onPhone()),
purpose: purpose,
digest: newKeysPasswordDigest(binding ?? next.password()),
keys: keys,
);
Future<void> changeWith(KeysOwnerProof proof, {KeysPassword? to}) =>
endpoints.keys.changePassword(
onPhone(),
proof: proof,
password: to ?? next.password(),
);
test("takes the old password's proof and a SimpleLogin sign-in: the "
'keys open with the new one alone, and every other session '
'ends', () async {
final attempt = await signInAgain(onPhone(), 'sl-anna');
await changeWith(await changeProof(attempt));
expect(await sessionsOf(anna), {annasPhone});
final challenge = await endpoints.keys.begin(onPhone());
expect(Uint8List.sublistView(challenge.salt), next.salt);
final keys = await endpoints.keys.unlock(
onPhone(),
await next.prove(challenge),
);
expect(keys.passwordKeys.toJson(), next.passwordKeys.toJson());
await expectLater(
endpoints.keys.unlock(
onPhone(),
await device.prove(await endpoints.keys.begin(onPhone())),
),
refused(PasswordRefusal.wrongCredentials),
);
});
test('refuses a proof by another key, for another password or '
'purpose, and a password out of range, the sign-in still '
'standing', () async {
final attempt = await signInAgain(onPhone(), 'sl-anna');
for (final proof in [
await changeProof(attempt, keys: device.recoveryProofKeys),
await changeProof(
attempt,
binding: (await KeysDevice.make(seed: 9)).password(),
),
await changeProof(attempt, purpose: KeyProofPurpose.newPhrase),
]) {
await expectLater(
changeWith(proof),
refused(PasswordRefusal.wrongCredentials),
);
}
await expectLater(
changeWith(
await changeProof(attempt),
to: next.password().copyWith(passwordProofKey: ByteData(31)),
),
refused(PasswordRefusal.invalidSetup),
);
expect(await sessionsOf(anna), {annasPhone, annasLaptop});
await changeWith(await changeProof(attempt));
});
test('refuses without a SimpleLogin sign-in of hers, checking no '
"proof: none, another account's, past its ten minutes, or one a "
'change took', () async {
final bens = await signInAgain(signedIn(ben, bensLaptop), 'sl-ben');
final challenge = await endpoints.keys.begin(onPhone());
for (final attempt in [const Uuid().v4obj(), bens]) {
await expectLater(
changeWith(await changeProof(attempt, challenge: challenge)),
refusedAsOidc(OidcRefusal.unknownAttempt),
);
}
final annas = await signInAgain(onPhone(), 'sl-anna');
await expectLater(
withClock(
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
() async => await changeWith(
await changeProof(annas, challenge: challenge),
),
),
refusedAsOidc(OidcRefusal.unknownAttempt),
);
// The challenge still answers: no proof was taken.
await changeWith(await changeProof(annas, challenge: challenge));
// One change for each sign-in.
await expectLater(
changeWith(
await next.ownerProof(
annas,
challenge: await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.newPassword,
digest: newKeysPasswordDigest(device.password()),
),
to: device.password(),
),
refusedAsOidc(OidcRefusal.unknownAttempt),
);
});
test("counts the session's wrong proofs, as unlocking does: the "
'tenth in a row ends it', () async {
final attempt = await signInAgain(onPhone(), 'sl-anna');
for (var i = 0; i < 10; i++) {
await expectLater(
changeWith(
await changeProof(attempt, keys: device.recoveryProofKeys),
),
refused(
i < 9
? PasswordRefusal.wrongCredentials
: PasswordRefusal.sessionEnded,
),
);
}
expect(await sessionsOf(anna), {annasLaptop});
});
});
group('a lost password (D20)', () {
late KeysDevice next;
setUp(() async => next = await KeysDevice.make(seed: 7));
Future<void> resetWith(KeysOwnerProof proof) => endpoints.keys
.resetPassword(onPhone(), proof: proof, password: next.password());
test("is set anew with the recovery phrase's proof and a SimpleLogin "
'sign-in, every other session ending', () async {
final phrase = await endpoints.keys.beginPhrase(onPhone());
expect(phrase.recoveryKeys.toJson(), device.recoveryKeys.toJson());
expect(
Uint8List.sublistView(phrase.signingPublicKey),
device.signingPublicKey,
);
expect(phrase.nonce.lengthInBytes, 32);
final attempt = await signInAgain(onPhone(), 'sl-anna');
await resetWith(
await device.ownerProof(
attempt,
phrase: phrase,
purpose: KeyProofPurpose.passwordReset,
digest: newKeysPasswordDigest(next.password()),
),
);
expect(await sessionsOf(anna), {annasPhone});
await endpoints.keys.unlock(
onPhone(),
await next.prove(await endpoints.keys.begin(onPhone())),
);
});
test("refuses the password's proof key, a challenge of keys.begin, "
'and a proof for another purpose', () async {
final attempt = await signInAgain(onPhone(), 'sl-anna');
final digest = newKeysPasswordDigest(next.password());
await expectLater(
resetWith(
await device.ownerProof(
attempt,
phrase: await endpoints.keys.beginPhrase(onPhone()),
keys: device.passwordProofKeys,
purpose: KeyProofPurpose.passwordReset,
digest: digest,
),
),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
resetWith(
await device.ownerProof(
attempt,
challenge: await endpoints.keys.begin(onPhone()),
keys: device.recoveryProofKeys,
purpose: KeyProofPurpose.passwordReset,
digest: digest,
),
),
refused(PasswordRefusal.challengeExpired),
);
await expectLater(
resetWith(
await device.ownerProof(
attempt,
phrase: await endpoints.keys.beginPhrase(onPhone()),
purpose: KeyProofPurpose.newPassword,
digest: digest,
),
),
refused(PasswordRefusal.wrongCredentials),
);
await resetWith(
await device.ownerProof(
attempt,
phrase: await endpoints.keys.beginPhrase(onPhone()),
purpose: KeyProofPurpose.passwordReset,
digest: digest,
),
);
});
test('is for an account with such keys alone', () async {
final bens = signedIn(ben, bensLaptop);
await expectLater(
endpoints.keys.beginPhrase(bens),
refused(PasswordRefusal.noPassword),
);
await AccountPassword.db.insertRow(
session,
passwordRowOf(
ben,
(await PasswordDevice.make()).setup(),
now: DateTime.now().toUtc(),
),
);
await expectLater(
endpoints.keys.beginPhrase(bens),
refused(PasswordRefusal.noPassword),
);
});
});
group('a new recovery phrase (D20)', () {
late KeysDevice next;
setUp(() async => next = await KeysDevice.make(seed: 7));
Future<void> setPhrase(
KeysOwnerProof proof, {
WrappedKeys? recoveryKeys,
}) => endpoints.keys.setRecoveryKeys(
onPhone(),
proof: proof,
recoveryKeys: recoveryKeys ?? next.recoveryKeys,
recoveryProofKey: ByteData.sublistView(next.recoveryProofKey),
);
Future<KeysOwnerProof> phraseProof(UuidValue attempt) async =>
await device.ownerProof(
attempt,
challenge: await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.newPhrase,
digest: newPhraseDigest(
next.recoveryKeys,
ByteData.sublistView(next.recoveryProofKey),
),
);
test("takes the password's proof and a SimpleLogin sign-in, the old "
'phrase proving nothing then, the sessions staying', () async {
await setPhrase(
await phraseProof(await signInAgain(onPhone(), 'sl-anna')),
);
final phrase = await endpoints.keys.beginPhrase(onPhone());
expect(phrase.recoveryKeys.toJson(), next.recoveryKeys.toJson());
expect(await sessionsOf(anna), {annasPhone, annasLaptop});
final again = await signInAgain(onPhone(), 'sl-anna');
final digest = newKeysPasswordDigest(next.password());
await expectLater(
endpoints.keys.resetPassword(
onPhone(),
proof: await device.ownerProof(
again,
phrase: phrase,
purpose: KeyProofPurpose.passwordReset,
digest: digest,
),
password: next.password(),
),
refused(PasswordRefusal.wrongCredentials),
);
await endpoints.keys.resetPassword(
onPhone(),
proof: await next.ownerProof(
again,
phrase: await endpoints.keys.beginPhrase(onPhone()),
purpose: KeyProofPurpose.passwordReset,
digest: digest,
),
password: next.password(),
);
});
test('refuses a phrase out of range, or a proof for another '
'one', () async {
final attempt = await signInAgain(onPhone(), 'sl-anna');
await expectLater(
setPhrase(
await phraseProof(attempt),
recoveryKeys: next.recoveryKeys.copyWith(nonce: ByteData(12)),
),
refused(PasswordRefusal.invalidSetup),
);
await expectLater(
setPhrase(
await phraseProof(attempt),
recoveryKeys: device.recoveryKeys,
),
refused(PasswordRefusal.wrongCredentials),
);
await setPhrase(await phraseProof(attempt));
});
});
group('deleting the account (D20)', () {
Future<KeysOwnerProof> deletionProof(
UuidValue attempt, {
KeyProofPurpose purpose = KeyProofPurpose.deleteAccount,
}) async => await device.ownerProof(
attempt,
challenge: await endpoints.keys.begin(onPhone()),
purpose: purpose,
);
test("takes the password's proof and a SimpleLogin sign-in", () async {
await endpoints.account.delete(
onPhone(),
keysProof: await deletionProof(
await signInAgain(onPhone(), 'sl-anna'),
),
);
expect(await AuthUser.db.findById(session, anna), isNull);
});
test('refuses a session alone, the proof of a password that signs '
'in, a proof for another purpose, and one without a sign-in, '
'keeping the account', () async {
await expectLater(
endpoints.account.delete(onPhone()),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
endpoints.account.delete(
onPhone(),
proof: DeletionProof(
challenge: const Uuid().v4obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
totpCode: '000000',
instanceUrl: testInstanceUrl,
),
),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
endpoints.account.delete(
onPhone(),
keysProof: await deletionProof(const Uuid().v4obj()),
),
refusedAsOidc(OidcRefusal.unknownAttempt),
);
await expectLater(
endpoints.account.delete(
onPhone(),
keysProof: await deletionProof(
await signInAgain(onPhone(), 'sl-anna'),
purpose: KeyProofPurpose.newPassword,
),
),
refused(PasswordRefusal.wrongCredentials),
);
expect(await AuthUser.db.findById(session, anna), isNotNull);
});
});
group('a reset, the password and the phrase lost (D31–D33)', () {
late KeysDevice next;
/// The tests' time, moved on by hand.
late DateTime now;
/// Anna's tablet, signed in with SimpleLogin a moment ago, without
/// her keys.
late UuidValue annasTablet;
setUp(() async {
next = await KeysDevice.make(seed: 11);
now = DateTime.now().toUtc();
annasTablet = await signInNewDevice(
session,
anna,
method: 'simplelogin',
);
});
/// [body] at the tests' time.
Future<T> atNow<T>(Future<T> Function() body) =>
withClock(Clock(() => now), body);
TestSessionBuilder onTablet() => signedIn(anna, annasTablet);
TestSessionBuilder onLaptop() => signedIn(anna, annasLaptop);
/// Asks for the reset on [as] (her tablet unless given), with a
/// SimpleLogin sign-in of hers just now.
Future<ResetPending> start({TestSessionBuilder? as}) => atNow(() async {
final device = as ?? onTablet();
return await endpoints.keys.startReset(
device,
attempt: await signInAgain(device, 'sl-anna'),
);
});
/// Carries it out on [as] (her tablet unless given) with [setup]
/// (the next keys unless given) and a SimpleLogin sign-in of hers
/// just now.
Future<void> complete({TestSessionBuilder? as, KeysSetup? setup}) =>
atNow(() async {
final device = as ?? onTablet();
await endpoints.keys.completeReset(
device,
attempt: await signInAgain(device, 'sl-anna'),
setup: setup ?? next.setup(),
);
});
Future<List<AccountReset>> resetsOf(UuidValue person) =>
AccountReset.db.find(
session,
where: (t) => t.authUserId.equals(person),
);
Future<AccountPassword> keysOf(UuidValue person) async =>
(await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(person),
))!;
test('is asked with a SimpleLogin sign-in alone, taken, and takes '
'effect 72 hours later; asked again meanwhile, it keeps its '
'time, which her devices and the next sign-in are told', () async {
final attempt = await atNow(() => signInAgain(onTablet(), 'sl-anna'));
final pending = await atNow(
() => endpoints.keys.startReset(onTablet(), attempt: attempt),
);
expect(pending.takesEffectAt, now.add(resetDelay));
expect(
(await resetsOf(anna)).single.kind,
AccountResetKind.account,
);
expect(
await idp().isKept(session, attempt: attempt, caller: anna),
isFalse,
);
now = now.add(const Duration(hours: 1));
expect((await start()).takesEffectAt, pending.takesEffectAt);
expect(
(await atNow(
() => endpoints.account.signInWays(onPhone()),
)).accountResetAt,
pending.takesEffectAt,
);
final signedOut = sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
final completed = await atNow(() async {
final begun = await endpoints.simpleLogin.begin(
signedOut,
back: SimpleLoginReturn.loopback,
);
return await endpoints.simpleLogin.complete(
signedOut,
attempt: begun.attempt,
code: simpleLogin.authorize(
begun.authorizationUrl,
subject: 'sl-anna',
),
);
});
expect(completed.accountResetAt, pending.takesEffectAt);
// Her keys stay as they were until it is carried out.
expect(
Uint8List.sublistView((await keysOf(anna)).passwordProofKey!),
device.passwordProofKey,
);
});
test('is carried out once due, with a new SimpleLogin sign-in: the '
'new keys in place of the old, her link staying, the reset gone '
'and every other session ended', () async {
final before = await keysOf(anna);
await start();
now = now.add(resetDelay);
await complete();
final row = await keysOf(anna);
expect(row.id, before.id);
expect(row.verifier, isNull);
expect(
Uint8List.sublistView(row.passwordProofKey!),
next.passwordProofKey,
);
expect(
Uint8List.sublistView(row.recoveryProofKey!),
next.recoveryProofKey,
);
expect(
Uint8List.sublistView(row.signingPublicKey),
next.signingPublicKey,
);
expect(await resetsOf(anna), isEmpty);
expect(await sessionsOf(anna), {annasTablet});
expect(
(await OidcIdentity.db.find(
session,
where: (t) => t.authUserId.equals(anna),
)).single.subject,
'sl-anna',
);
await expectLater(
endpoints.keys.unlock(
onTablet(),
await device.prove(await endpoints.keys.begin(onTablet())),
),
refused(PasswordRefusal.wrongCredentials),
);
final keys = await endpoints.keys.unlock(
onTablet(),
await next.prove(await endpoints.keys.begin(onTablet())),
);
expect(
Uint8List.sublistView(keys.signingPublicKey),
next.signingPublicKey,
);
});
test('keeps the keys a device of hers still holds, wrapped under the '
'new password and phrase (D33)', () async {
await start(as: onPhone());
now = now.add(resetDelay);
final rewrapped = next.setup().copyWith(
encryptionPublicKey: ByteData.sublistView(
device.encryptionPublicKey,
),
signingPublicKey: ByteData.sublistView(device.signingPublicKey),
);
await complete(as: onPhone(), setup: rewrapped);
final row = await keysOf(anna);
expect(
Uint8List.sublistView(row.signingPublicKey),
device.signingPublicKey,
);
expect(
Uint8List.sublistView(row.passwordProofKey!),
next.passwordProofKey,
);
expect(await sessionsOf(anna), {annasPhone});
});
test('refuses before its time, saying when; keys out of range; and '
'once it lapsed, or without one, nothing changing', () async {
final pending = await start();
now = pending.takesEffectAt.subtract(const Duration(minutes: 1));
await expectLater(
complete(),
throwsA(
isA<PasswordRefusedException>()
.having((e) => e.reason, 'reason', PasswordRefusal.notDue)
.having(
(e) => e.takesEffectAt,
'takesEffectAt',
pending.takesEffectAt,
),
),
);
now = pending.takesEffectAt;
await expectLater(
complete(
setup: next.setup().copyWith(salt: ByteData(3)),
),
refused(PasswordRefusal.invalidSetup),
);
expect(await resetsOf(anna), hasLength(1));
now = now.add(resetLapsesAfter);
await expectLater(complete(), refused(PasswordRefusal.noReset));
await AccountReset.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(anna),
);
await expectLater(complete(), refused(PasswordRefusal.noReset));
expect(
Uint8List.sublistView((await keysOf(anna)).passwordProofKey!),
device.passwordProofKey,
);
expect(
await sessionsOf(anna),
containsAll([annasPhone, annasLaptop]),
);
});
test("refuses without a SimpleLogin sign-in of hers: none, another's, "
'one run out or taken, asking for nothing then', () async {
final bens = await atNow(
() => signInAgain(signedIn(ben, bensLaptop), 'sl-ben'),
);
final ranOut = await atNow(() => signInAgain(onTablet(), 'sl-anna'));
now = now.add(const Duration(minutes: 11));
for (final attempt in [const Uuid().v4obj(), bens, ranOut]) {
await expectLater(
atNow(
() => endpoints.keys.startReset(onTablet(), attempt: attempt),
),
refusedAsOidc(OidcRefusal.unknownAttempt),
);
}
expect(await resetsOf(anna), isEmpty);
await start();
now = now.add(resetDelay);
final taken = await atNow(() => signInAgain(onTablet(), 'sl-anna'));
await atNow(
() => endpoints.keys.completeReset(
onTablet(),
attempt: taken,
setup: next.setup(),
),
);
await start();
now = now.add(resetDelay);
final other = (await KeysDevice.make(seed: 12)).setup();
await expectLater(
atNow(
() => endpoints.keys.completeReset(
onTablet(),
attempt: taken,
setup: other,
),
),
refusedAsOidc(OidcRefusal.unknownAttempt),
);
expect(await resetsOf(anna), hasLength(1));
});
test('is for an account with such keys alone: none, or a password '
'that signs in (ADR 0017, its reset by mail)', () async {
final bens = signedIn(ben, bensLaptop);
Future<void> startAndComplete() => atNow(() async {
await expectLater(
endpoints.keys.startReset(
bens,
attempt: await signInAgain(bens, 'sl-ben'),
),
refused(PasswordRefusal.noPassword),
);
await expectLater(
endpoints.keys.completeReset(
bens,
attempt: await signInAgain(bens, 'sl-ben'),
setup: next.setup(),
),
refused(PasswordRefusal.noPassword),
);
});
await startAndComplete();
await AccountPassword.db.insertRow(
session,
passwordRowOf(
ben,
(await PasswordDevice.make()).setup(),
now: DateTime.now().toUtc(),
),
);
await startAndComplete();
expect(await resetsOf(ben), isEmpty);
});
test("is called off by her password's proof on a signed-in device "
'(D32), never by another key, nor a proof for another '
'purpose', () async {
await start();
Future<void> callOff(KeysProof proof) =>
endpoints.keys.callOffResets(onPhone(), proof: proof);
for (final keys in [
next.passwordProofKeys,
device.recoveryProofKeys,
]) {
await expectLater(
callOff(
await device.prove(
await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.callOffResets,
keys: keys,
),
),
refused(PasswordRefusal.wrongCredentials),
);
}
await expectLater(
callOff(
await device.prove(
await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.newPhrase,
),
),
refused(PasswordRefusal.wrongCredentials),
);
expect(await resetsOf(anna), hasLength(1));
await callOff(
await device.prove(
await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.callOffResets,
),
);
expect(await resetsOf(anna), isEmpty);
await expectLater(
endpoints.keys.callOffResets(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
),
proof: await device.prove(
await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.callOffResets,
),
),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
});
test('is called off by any proof of her password or her phrase: '
'opening her keys on a device, or a lost password set '
'anew', () async {
await start();
await endpoints.keys.unlock(
onLaptop(),
await device.prove(await endpoints.keys.begin(onLaptop())),
);
expect(await resetsOf(anna), isEmpty);
await start();
final attempt = await atNow(() => signInAgain(onPhone(), 'sl-anna'));
await atNow(
() async => await endpoints.keys.resetPassword(
onPhone(),
proof: await device.ownerProof(
attempt,
phrase: await endpoints.keys.beginPhrase(onPhone()),
purpose: KeyProofPurpose.passwordReset,
digest: newKeysPasswordDigest(next.password()),
),
password: next.password(),
),
);
expect(await resetsOf(anna), isEmpty);
});
});
feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3) An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 21:49:44 +02:00
group('the ways in', () {
test('say the account has keys, and no password that signs in, nor '
'an authenticator to add', () async {
// Where password sign-in is offered, which takes mail too.
final pod = session.serverpod;
usePasswordKeys(pod, testPasswordKeys);
EmailCodes.of(pod).mailer = Mailbox();
addTearDown(() => EmailCodes.of(pod).mailer = null);
final ways = await endpoints.account.signInWays(onPhone());
expect(ways.keys, isTrue);
expect(ways.password, isFalse);
expect(ways.passwordOnly, isFalse);
await expectLater(
endpoints.authenticator.enrol(
onPhone(),
name: 'Phone',
label: 'anna',
),
refused(PasswordRefusal.noPassword),
);
});
});
},
);
}
feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20) Every change takes the password's proof by its own proof key (the recovery phrase's for a lost password) and a SimpleLogin sign-in of the last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR 0017's take the password and a code. The sign-in is checked before the proof and taken with the change, so a wrong proof leaves it standing and each sign-in serves one change. - `keys.set(attempt, setup, handle?)`: the first keys of an account that SimpleLogin was added to (anonymous before, or signing in with GitHub or a code), with the handle where it has none; the sign-in alone, as there is no password to prove yet. - `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a lost password, with the phrase): every other session ends, in every service, as ADR 0017's change has it. - `keys.setRecoveryKeys`: a new phrase. - `account.delete(keysProof)`: an account without SRP's verifier deletes itself with the proof and the sign-in; `requireOwnerToDelete` no longer asks such an account for SRP. - `newKeysPasswordDigest` on both sides, held to each other by a test; a challenge purpose of its own for the phrase's proof while signed in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the five new operations and the deletion's proof. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 22:11:49 +02:00
Future<void> _deleteAll(Session session) async {
await OidcAttempt.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
await deleteAll(session);
}