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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-23 21:49:44 +02:00
commit 090d901e92
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
81 changed files with 46254 additions and 78 deletions

View file

@ -0,0 +1,41 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "alterTable",
"alterTable": {
"__className__": "serverpod.TableMigration",
"name": "account_password",
"schema": "public",
"addColumns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "passwordProofKey",
"fieldName": "passwordProofKey",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"deleteColumns": [],
"modifyColumns": [
{
"__className__": "serverpod.ColumnMigration",
"columnName": "verifier",
"addNullable": true,
"removeNullable": false,
"changeDefault": false
}
],
"addIndexes": [],
"deleteIndexes": [],
"addForeignKeys": [],
"deleteForeignKeys": [],
"warnings": []
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -29,6 +29,7 @@ part '20261021110159867-second-factor-paths/migration.dart';
part '20261021134803749-reset-hold-outs/migration.dart';
part '20261021184601197-private-studysets/migration.dart';
part '20261023181810112-oidc-module/migration.dart';
part '20261023192913178-simplelogin-keys/migration.dart';
/// Migration registry for the client-side database.
class MigrationRegistry {
@ -60,6 +61,7 @@ class MigrationRegistry {
_Migration20261021134803749_reset_hold_outs(),
_Migration20261021184601197_private_studysets(),
_Migration20261023181810112_oidc_module(),
_Migration20261023192913178_simplelogin_keys(),
];
/// List of all client-side database migration versions.

View file

@ -0,0 +1,41 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "alterTable",
"alterTable": {
"__className__": "serverpod.TableMigration",
"name": "account_password",
"schema": "public",
"addColumns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "passwordProofKey",
"fieldName": "passwordProofKey",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"deleteColumns": [],
"modifyColumns": [
{
"__className__": "serverpod.ColumnMigration",
"columnName": "verifier",
"addNullable": true,
"removeNullable": false,
"changeDefault": false
}
],
"addIndexes": [],
"deleteIndexes": [],
"addForeignKeys": [],
"deleteForeignKeys": [],
"warnings": []
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,66 @@
BEGIN;
--
-- ACTION ALTER TABLE
--
ALTER TABLE "account_password" ADD COLUMN "passwordProofKey" bytea;
ALTER TABLE "account_password" ALTER COLUMN "verifier" DROP NOT NULL;
--
-- MIGRATION VERSION FOR halkyon_learn
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_learn', '20261023192913178-simplelogin-keys', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261023192913178-simplelogin-keys', "timestamp" = now();
--
-- MIGRATION VERSION FOR halkyon_base
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_base', '20261023192912859-simplelogin-keys', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261023192912859-simplelogin-keys', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod', '20260824182259319', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260824182259319', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_idp
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_idp', '20260924105404509', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105404509', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_offline_sync
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_offline_sync', '20260914143806119', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260914143806119', "timestamp" = now();
--
-- MIGRATION VERSION FOR halkyon_oidc
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_oidc', '20261023180644988', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261023180644988', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_core
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_core', '20260924105232991', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105232991', "timestamp" = now();
COMMIT;

View file

@ -47,3 +47,4 @@
20261021184601197-private-studysets
20261023131636437
20261023181810112-oidc-module
20261023192913178-simplelogin-keys

View file

@ -0,0 +1,34 @@
{
"__className__": "serverpod.DatabaseDefinition",
"schemaVersion": 2,
"moduleName": "halkyon_todo",
"tables": [],
"installedModules": [
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "halkyon_base",
"version": "20261023192912859-simplelogin-keys"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod",
"version": "20260824182259319"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod_auth_idp",
"version": "20260924105404509"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "halkyon_oidc",
"version": "20261023180644988"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod_auth_core",
"version": "20260924105232991"
}
],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,41 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "alterTable",
"alterTable": {
"__className__": "serverpod.TableMigration",
"name": "account_password",
"schema": "public",
"addColumns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "passwordProofKey",
"fieldName": "passwordProofKey",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"deleteColumns": [],
"modifyColumns": [
{
"__className__": "serverpod.ColumnMigration",
"columnName": "verifier",
"addNullable": true,
"removeNullable": false,
"changeDefault": false
}
],
"addIndexes": [],
"deleteIndexes": [],
"addForeignKeys": [],
"deleteForeignKeys": [],
"warnings": []
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,58 @@
BEGIN;
--
-- ACTION ALTER TABLE
--
ALTER TABLE "account_password" ADD COLUMN "passwordProofKey" bytea;
ALTER TABLE "account_password" ALTER COLUMN "verifier" DROP NOT NULL;
--
-- MIGRATION VERSION FOR halkyon_todo
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_todo', '20261023192913500-simplelogin-keys', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261023192913500-simplelogin-keys', "timestamp" = now();
--
-- MIGRATION VERSION FOR halkyon_base
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_base', '20261023192912859-simplelogin-keys', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261023192912859-simplelogin-keys', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod', '20260824182259319', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260824182259319', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_idp
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_idp', '20260924105404509', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105404509', "timestamp" = now();
--
-- MIGRATION VERSION FOR halkyon_oidc
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_oidc', '20261023180644988', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261023180644988', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_core
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_core', '20260924105232991', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105232991', "timestamp" = now();
COMMIT;

View file

@ -17,3 +17,4 @@
20261021110200318-second-factor-paths
20261021134804476-reset-hold-outs
20261023181810436-oidc-module
20261023192913500-simplelogin-keys

View file

@ -32,6 +32,14 @@
no account yet: their identity waits (D16), with a
handle suggested (D17), for the sign-up of step 3. halkyon_oidc's `testing.dart` gives
hosts a fake SimpleLogin signing real ID tokens, which the module's tests use too.
- **Built (23 Oct 2026), step 3 so far:** the password's own proof key (`halkyon_keys`'
subkey 3 of the master key, D19), which `account_password` keeps in place of SRP's
verifier for an account that signs in with SimpleLogin (the verifier becomes nullable:
a migration that drops nothing); `simpleLogin.signUp` in one call (D18) and
`simpleLogin.checkHandle`, both for someone new's identity alone; `keys.begin` and
`keys.unlock`, the proof counted as ADR 0017's; every sign-in into an account kept ten
minutes under its attempt as D20's confirmation. The changes (first keys, a new or
lost password, a new phrase, the deletion) and the mending come next.
- **Builds on:** ADR 0010 (email sign-in), ADR 0011 (profile pages), ADR 0016 (the
register), ADR 0017 (a password, TOTP and the keys).
- **Changes:** ADR 0017's way in on an instance that offers SimpleLogin (its keys, their

View file

@ -20,6 +20,7 @@ abstract class SignInWays
required this.email,
bool? simpleLogin,
bool? password,
bool? keys,
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
@ -28,6 +29,7 @@ abstract class SignInWays
this.accountResetAt,
}) : simpleLogin = simpleLogin ?? false,
password = password ?? false,
keys = keys ?? false,
authenticator = authenticator ?? false,
passwordOnly = passwordOnly ?? false,
secondFactorWorks = secondFactorWorks ?? false;
@ -37,6 +39,7 @@ abstract class SignInWays
required bool email,
bool? simpleLogin,
bool? password,
bool? keys,
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
@ -55,6 +58,9 @@ abstract class SignInWays
password: jsonSerialization['password'] == null
? null
: _isc.BoolJsonExtension.fromJson(jsonSerialization['password']),
keys: jsonSerialization['keys'] == null
? null
: _isc.BoolJsonExtension.fromJson(jsonSerialization['keys']),
authenticator: jsonSerialization['authenticator'] == null
? null
: _isc.BoolJsonExtension.fromJson(jsonSerialization['authenticator']),
@ -93,9 +99,14 @@ abstract class SignInWays
/// instance's SimpleLogin app.
bool simpleLogin;
/// A password (ADR 0017), which an address needs for resets.
/// A password that signs in (ADR 0017, SRP's), which an address needs
/// for resets.
bool password;
/// The account's keys, under a password that signs in (ADR 0017) or one
/// that only opens them, as for SimpleLogin (ADR 0020, D19).
bool keys;
/// A confirmed authenticator: the password signs in only with one.
bool authenticator;
@ -130,6 +141,7 @@ abstract class SignInWays
bool? email,
bool? simpleLogin,
bool? password,
bool? keys,
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
@ -145,6 +157,7 @@ abstract class SignInWays
'email': email,
'simpleLogin': simpleLogin,
'password': password,
'keys': keys,
'authenticator': authenticator,
'passwordOnly': passwordOnly,
'secondFactorWorks': secondFactorWorks,
@ -164,6 +177,7 @@ abstract class SignInWays
'email': email,
'simpleLogin': simpleLogin,
'password': password,
'keys': keys,
'authenticator': authenticator,
'passwordOnly': passwordOnly,
'secondFactorWorks': secondFactorWorks,
@ -189,6 +203,7 @@ class _SignInWaysImpl extends SignInWays {
required bool email,
bool? simpleLogin,
bool? password,
bool? keys,
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
@ -200,6 +215,7 @@ class _SignInWaysImpl extends SignInWays {
email: email,
simpleLogin: simpleLogin,
password: password,
keys: keys,
authenticator: authenticator,
passwordOnly: passwordOnly,
secondFactorWorks: secondFactorWorks,
@ -217,6 +233,7 @@ class _SignInWaysImpl extends SignInWays {
bool? email,
bool? simpleLogin,
bool? password,
bool? keys,
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
@ -229,6 +246,7 @@ class _SignInWaysImpl extends SignInWays {
email: email ?? this.email,
simpleLogin: simpleLogin ?? this.simpleLogin,
password: password ?? this.password,
keys: keys ?? this.keys,
authenticator: authenticator ?? this.authenticator,
passwordOnly: passwordOnly ?? this.passwordOnly,
secondFactorWorks: secondFactorWorks ?? this.secondFactorWorks,

View file

@ -18,6 +18,14 @@ import 'package:halkyon_base_client/src/protocol/auth/sign_in_ways.dart'
as _ifld8s4r;
import 'package:halkyon_base_client/src/protocol/instance/instance_basics.dart'
as _isnyur2i;
import 'package:halkyon_base_client/src/protocol/keys/device_keys.dart'
as _invv3u3l;
import 'package:halkyon_base_client/src/protocol/keys/keys_challenge.dart'
as _ivkzjgkp;
import 'package:halkyon_base_client/src/protocol/keys/keys_proof.dart'
as _iznbn55x;
import 'package:halkyon_base_client/src/protocol/keys/keys_setup.dart'
as _iec2fqr0;
import 'package:halkyon_base_client/src/protocol/password/authenticator.dart'
as _ivfmflch;
import 'package:halkyon_base_client/src/protocol/password/authenticator_enrolment.dart'
@ -222,6 +230,43 @@ class EndpointInstance extends _isc.EndpointRef {
);
}
/// The keys of an account that signs in with SimpleLogin (Halkyon Learn's
/// ADR 0020, 7 and D19), for its signed-in devices: the password proven
/// with its own proof key, which the instance's register checks (ADR 0016).
/// Refusals are `PasswordRefusedException`s.
/// {@category Endpoint}
class EndpointKeys extends _isc.EndpointRef {
EndpointKeys(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'halkyon_base.keys';
/// The first step of proving your password: the salt and costs your
/// device derives the password's keys with, and a nonce to sign with its
/// proof key, within two minutes, once. Refused for an account without
/// such keys (`PasswordRefusal.noPassword`).
_ida.Future<_ivkzjgkp.KeysChallenge> begin() =>
caller.callServerEndpoint<_ivkzjgkp.KeysChallenge>(
'halkyon_base.keys',
'begin',
{},
);
/// Your keys for this device, once [proof] proved your password: your
/// public keys and your private keys wrapped under the password's key,
/// which the device opens. The proof is bound to the base URL the device
/// addressed here, and counts against this session: the tenth wrong in a
/// row ends it (`PasswordRefusal.sessionEnded`), and you are warned.
/// Refused for a wrong proof (`PasswordRefusal.wrongCredentials`) and a
/// challenge unknown, used or too old (`PasswordRefusal.challengeExpired`).
_ida.Future<_invv3u3l.DeviceKeys> unlock(_iznbn55x.KeysProof proof) =>
caller.callServerEndpoint<_invv3u3l.DeviceKeys>(
'halkyon_base.keys',
'unlock',
{'proof': proof},
);
}
/// Getting back into an account (ADR 0017): a lost authenticator, a lost
/// password with the recovery phrase, a lost password without it. Called
/// without a session, as the person can't sign in. The instance's register
@ -761,10 +806,14 @@ class EndpointSimpleLogin extends _isc.EndpointRef {
/// Ends [attempt] with SimpleLogin's [code], as the caller began it:
/// signed out, a session for the account with that identity, or for
/// someone new the handle suggested (their account comes next); signed
/// in, SimpleLogin added to the caller's account. Serverpod issues no
/// session for another person inside a signed-in call, and nobody is
/// switched to another account. Refusals as `completeSimpleLogin`'s.
/// someone new the handle suggested (their account comes with [signUp]);
/// signed in, SimpleLogin added to the caller's account. Serverpod issues
/// no session for another person inside a signed-in call, and nobody is
/// switched to another account. For an account, whether it has its keys
/// (`keys.unlock` opens them; one without makes them) and its handle; and
/// for ten minutes the attempt's ID stands for this sign-in, for a change
/// the device asks for (ADR 0020, D20). Refusals as
/// `completeSimpleLogin`'s.
_ida.Future<_ianfx7qj.SimpleLoginCompleted> complete({
required _isc.UuidValue attempt,
required String code,
@ -776,6 +825,48 @@ class EndpointSimpleLogin extends _isc.EndpointRef {
'code': code,
},
);
/// Whether someone new, whose identity waits under [attempt] ([complete]
/// said `newcomer`), could take [handle]: null where they could, else why
/// not, as saving a profile would say. For the app to say so while it is
/// typed; [signUp] checks again. At most 30 for an attempt and 60 from a
/// network in an hour (`ProfileRefusal.tooManyHandleChecks`, thrown):
/// each answer tells which handles exist. Refused
/// (`OidcRefusal.unknownAttempt`) where no identity waits.
_ida.Future<_ih722tf9.ProfileRefusal?> checkHandle({
required _isc.UuidValue attempt,
required String handle,
}) => caller.callServerEndpoint<_ih722tf9.ProfileRefusal?>(
'halkyon_base.simpleLogin',
'checkHandle',
{
'attempt': attempt,
'handle': handle,
},
);
/// Signs up someone new, whose identity waits under [attempt] for half an
/// hour after [complete] said `newcomer` (ADR 0020, D16 and D18): an
/// account with [handle], private until its person goes public, and the
/// keys [setup] describes, all made on the device beforehand; a session
/// for it here. Refused, the identity still waiting: for a setup out of
/// range (`PasswordRefusal.invalidSetup`) and a handle refused
/// (`ProfileRefusedException`, as saving a profile refuses it); for an
/// attempt unknown, taken or past its time (`OidcRefusal.unknownAttempt`),
/// and called signed in (`OidcRefusal.wrongCaller`).
_ida.Future<_iacc.AuthSuccess> signUp({
required _isc.UuidValue attempt,
required String handle,
required _iec2fqr0.KeysSetup setup,
}) => caller.callServerEndpoint<_iacc.AuthSuccess>(
'halkyon_base.simpleLogin',
'signUp',
{
'attempt': attempt,
'handle': handle,
'setup': setup,
},
);
}
class Caller extends _isc.ModuleEndpointCaller {
@ -786,6 +877,7 @@ class Caller extends _isc.ModuleEndpointCaller {
gitHubSignIn = EndpointGitHubSignIn(this);
gitHubUpgrade = EndpointGitHubUpgrade(this);
instance = EndpointInstance(this);
keys = EndpointKeys(this);
accountRecovery = EndpointAccountRecovery(this);
authenticator = EndpointAuthenticator(this);
password = EndpointPassword(this);
@ -807,6 +899,8 @@ class Caller extends _isc.ModuleEndpointCaller {
late final EndpointInstance instance;
late final EndpointKeys keys;
late final EndpointAccountRecovery accountRecovery;
late final EndpointAuthenticator authenticator;
@ -829,6 +923,7 @@ class Caller extends _isc.ModuleEndpointCaller {
'halkyon_base.gitHubSignIn': gitHubSignIn,
'halkyon_base.gitHubUpgrade': gitHubUpgrade,
'halkyon_base.instance': instance,
'halkyon_base.keys': keys,
'halkyon_base.accountRecovery': accountRecovery,
'halkyon_base.authenticator': authenticator,
'halkyon_base.password': password,

Some files were not shown because too many files have changed in this diff Show more