refactor(profile): handles and profiles move into halkyon_base
People's public profiles are the instance's register's (ADR 0016), so
the module now holds them: the public_profile table (unchanged, so
Learn's migration records versions only; devices get the table empty,
as they hold every module's tables), the profile and profiles
endpoints under `base`, the handle rules, the link checks and the page
at /@handle, whose foot names the app as the host's configuration says
(profilePageNote). Deleting a person deletes their profile in the
module's core.
Hosts read profiles through the module (profileOf, profilesOf,
personWithHandle, fixHandle; saveProfile for their tests), behind an
AccountRegister that is this server's own database for now. Learn's
feedback, discussions and publishing ask it; publishing fixes the
handle just before it writes the release, outside its transaction.
Explore still joins the table in its search, so Learn holds the
register wherever it runs. Learn's app calls the module's endpoints;
a profile not found is ProfileNotFoundException. Todo's database gets
the table too (it was never deployed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 22:20:27 +02:00
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
|
|
|
|
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
2026-10-19 23:10:08 +02:00
|
|
|
import 'package:halkyon_base_server/src/halkyon_base.dart';
|
|
|
|
|
import 'package:halkyon_base_server/src/register/accounts_mode.dart';
|
refactor(profile): handles and profiles move into halkyon_base
People's public profiles are the instance's register's (ADR 0016), so
the module now holds them: the public_profile table (unchanged, so
Learn's migration records versions only; devices get the table empty,
as they hold every module's tables), the profile and profiles
endpoints under `base`, the handle rules, the link checks and the page
at /@handle, whose foot names the app as the host's configuration says
(profilePageNote). Deleting a person deletes their profile in the
module's core.
Hosts read profiles through the module (profileOf, profilesOf,
personWithHandle, fixHandle; saveProfile for their tests), behind an
AccountRegister that is this server's own database for now. Learn's
feedback, discussions and publishing ask it; publishing fixes the
handle just before it writes the release, outside its transaction.
Explore still joins the table in its search, so Learn holds the
register wherever it runs. Learn's app calls the module's endpoints;
a profile not found is ProfileNotFoundException. Todo's database gets
the table too (it was never deployed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 22:20:27 +02:00
|
|
|
import 'package:halkyon_base_server/src/register/local_register.dart';
|
2026-10-19 23:10:08 +02:00
|
|
|
import 'package:halkyon_base_server/src/register/remote_register.dart';
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
|
refactor(profile): handles and profiles move into halkyon_base
People's public profiles are the instance's register's (ADR 0016), so
the module now holds them: the public_profile table (unchanged, so
Learn's migration records versions only; devices get the table empty,
as they hold every module's tables), the profile and profiles
endpoints under `base`, the handle rules, the link checks and the page
at /@handle, whose foot names the app as the host's configuration says
(profilePageNote). Deleting a person deletes their profile in the
module's core.
Hosts read profiles through the module (profileOf, profilesOf,
personWithHandle, fixHandle; saveProfile for their tests), behind an
AccountRegister that is this server's own database for now. Learn's
feedback, discussions and publishing ask it; publishing fixes the
handle just before it writes the release, outside its transaction.
Explore still joins the table in its search, so Learn holds the
register wherever it runs. Learn's app calls the module's endpoints;
a profile not found is ProfileNotFoundException. Todo's database gets
the table too (it was never deployed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 22:20:27 +02:00
|
|
|
import 'package:serverpod/serverpod.dart';
|
|
|
|
|
|
|
|
|
|
/// The instance's register of accounts (ADR 0016): what one person is across
|
|
|
|
|
/// every service of the instance, kept by the service its operator names.
|
|
|
|
|
/// This server asks it here for everything the register keeps, so the same
|
|
|
|
|
/// call works whether this server holds the register itself
|
|
|
|
|
/// ([LocalRegister]) or is one of its members.
|
|
|
|
|
///
|
|
|
|
|
/// A person is named by their account ID, which is their `AuthUser`'s ID in
|
|
|
|
|
/// every service of the instance.
|
|
|
|
|
abstract interface class AccountRegister {
|
2026-10-19 23:10:08 +02:00
|
|
|
/// The register [pod] asks: its own database while it holds the register
|
|
|
|
|
/// (also unconfigured, as tests of a host's own code run), else the
|
|
|
|
|
/// register's server ([RemoteRegister]), which needs the instance's shared
|
|
|
|
|
/// secret.
|
refactor(profile): handles and profiles move into halkyon_base
People's public profiles are the instance's register's (ADR 0016), so
the module now holds them: the public_profile table (unchanged, so
Learn's migration records versions only; devices get the table empty,
as they hold every module's tables), the profile and profiles
endpoints under `base`, the handle rules, the link checks and the page
at /@handle, whose foot names the app as the host's configuration says
(profilePageNote). Deleting a person deletes their profile in the
module's core.
Hosts read profiles through the module (profileOf, profilesOf,
personWithHandle, fixHandle; saveProfile for their tests), behind an
AccountRegister that is this server's own database for now. Learn's
feedback, discussions and publishing ask it; publishing fixes the
handle just before it writes the release, outside its transaction.
Explore still joins the table in its search, so Learn holds the
register wherever it runs. Learn's app calls the module's endpoints;
a profile not found is ProfileNotFoundException. Todo's database gets
the table too (it was never deployed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 22:20:27 +02:00
|
|
|
static AccountRegister of(Serverpod pod) =>
|
2026-10-19 23:10:08 +02:00
|
|
|
_ofPod[pod] ??
|
|
|
|
|
switch (HalkyonBase.maybeOf(pod)?.accounts) {
|
|
|
|
|
MemberOfRegister(:final register) => RemoteRegister(
|
|
|
|
|
register,
|
|
|
|
|
secret:
|
|
|
|
|
instanceSecretOf(pod) ??
|
|
|
|
|
(throw StateError(
|
|
|
|
|
'A member of an instance needs its shared secret: set '
|
|
|
|
|
'instanceSecret in the passwords (INSTANCE_SECRET).',
|
|
|
|
|
)),
|
|
|
|
|
),
|
|
|
|
|
HoldsRegister() || null => const LocalRegister(),
|
|
|
|
|
};
|
refactor(profile): handles and profiles move into halkyon_base
People's public profiles are the instance's register's (ADR 0016), so
the module now holds them: the public_profile table (unchanged, so
Learn's migration records versions only; devices get the table empty,
as they hold every module's tables), the profile and profiles
endpoints under `base`, the handle rules, the link checks and the page
at /@handle, whose foot names the app as the host's configuration says
(profilePageNote). Deleting a person deletes their profile in the
module's core.
Hosts read profiles through the module (profileOf, profilesOf,
personWithHandle, fixHandle; saveProfile for their tests), behind an
AccountRegister that is this server's own database for now. Learn's
feedback, discussions and publishing ask it; publishing fixes the
handle just before it writes the release, outside its transaction.
Explore still joins the table in its search, so Learn holds the
register wherever it runs. Learn's app calls the module's endpoints;
a profile not found is ProfileNotFoundException. Todo's database gets
the table too (it was never deployed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 22:20:27 +02:00
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
/// Puts [register] in place of the one [pod] asks, or with null the one its
|
|
|
|
|
/// configuration names back: tests do.
|
|
|
|
|
static void use(Serverpod pod, AccountRegister? register) =>
|
refactor(profile): handles and profiles move into halkyon_base
People's public profiles are the instance's register's (ADR 0016), so
the module now holds them: the public_profile table (unchanged, so
Learn's migration records versions only; devices get the table empty,
as they hold every module's tables), the profile and profiles
endpoints under `base`, the handle rules, the link checks and the page
at /@handle, whose foot names the app as the host's configuration says
(profilePageNote). Deleting a person deletes their profile in the
module's core.
Hosts read profiles through the module (profileOf, profilesOf,
personWithHandle, fixHandle; saveProfile for their tests), behind an
AccountRegister that is this server's own database for now. Learn's
feedback, discussions and publishing ask it; publishing fixes the
handle just before it writes the release, outside its transaction.
Explore still joins the table in its search, so Learn holds the
register wherever it runs. Learn's app calls the module's endpoints;
a profile not found is ProfileNotFoundException. Todo's database gets
the table too (it was never deployed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 22:20:27 +02:00
|
|
|
_ofPod[pod] = register;
|
|
|
|
|
|
|
|
|
|
static final _ofPod = Expando<AccountRegister>('Account register');
|
|
|
|
|
|
|
|
|
|
/// [person]'s public profile, as apps see it; null for none.
|
|
|
|
|
Future<Profile?> profileOf(Session session, UuidValue person);
|
|
|
|
|
|
|
|
|
|
/// The public profiles of those of [people] who have one, by person.
|
|
|
|
|
Future<Map<UuidValue, Profile>> profilesOf(
|
|
|
|
|
Session session,
|
|
|
|
|
Set<UuidValue> people,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/// The public profile whose handle is [handle], whatever its case; null for
|
|
|
|
|
/// none.
|
|
|
|
|
Future<Profile?> profileByHandle(Session session, String handle);
|
|
|
|
|
|
|
|
|
|
/// The person whose handle is [handle], whatever its case; null for none.
|
|
|
|
|
/// For a host's own code (who is blocked, who wrote), never for apps:
|
|
|
|
|
/// profiles never say whose they are.
|
|
|
|
|
Future<UuidValue?> personWithHandle(Session session, String handle);
|
|
|
|
|
|
|
|
|
|
/// Saves [person]'s public profile, making it if they have none. Throws
|
|
|
|
|
/// `ProfileRefusedException` when it can't be saved (see
|
|
|
|
|
/// `ProfileRefusal`): a handle someone else has, whatever its case, or a
|
|
|
|
|
/// new handle once theirs is fixed.
|
|
|
|
|
Future<Profile> saveProfile(
|
|
|
|
|
Session session,
|
|
|
|
|
UuidValue person, {
|
|
|
|
|
required String handle,
|
|
|
|
|
required String displayName,
|
|
|
|
|
required String bio,
|
|
|
|
|
required List<String> links,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// Why [handle] couldn't be [person]'s, as saving it would refuse it; null
|
|
|
|
|
/// when it can.
|
|
|
|
|
Future<ProfileRefusal?> checkHandle(
|
|
|
|
|
Session session,
|
|
|
|
|
UuidValue person,
|
|
|
|
|
String handle,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/// Checks which of [person]'s links' pages link back to their profile's
|
|
|
|
|
/// page with `rel="me"`, and keeps the answer; at most 10 times an hour.
|
|
|
|
|
/// Throws `ProfileNotFoundException` while they have no profile.
|
|
|
|
|
Future<Profile> checkLinks(Session session, UuidValue person);
|
|
|
|
|
|
|
|
|
|
/// Shows [png] as [person]'s avatar: a PNG of at most 1 MB. Throws
|
|
|
|
|
/// `ProfileRefusedException` for anything else, and
|
|
|
|
|
/// `ProfileNotFoundException` while they have no profile.
|
|
|
|
|
Future<Profile> setAvatar(Session session, UuidValue person, ByteData png);
|
|
|
|
|
|
|
|
|
|
/// Shows no avatar for [person] any more. Throws
|
|
|
|
|
/// `ProfileNotFoundException` while they have no profile.
|
|
|
|
|
Future<Profile> removeAvatar(Session session, UuidValue person);
|
|
|
|
|
|
|
|
|
|
/// Removes [person]'s public profile, freeing its handle; nothing when they
|
|
|
|
|
/// have none. Throws `ProfileRefusedException` once their handle is fixed.
|
|
|
|
|
Future<void> removeProfile(Session session, UuidValue person);
|
|
|
|
|
|
|
|
|
|
/// Fixes [person]'s handle, as others now refer to it (Halkyon Learn: their
|
|
|
|
|
/// first publish); nothing when it is fixed already or they have no
|
|
|
|
|
/// profile.
|
|
|
|
|
Future<void> fixHandle(Session session, UuidValue person);
|
2026-10-19 23:10:08 +02:00
|
|
|
|
|
|
|
|
/// Whether people can sign in with an email address: the register mails
|
|
|
|
|
/// the codes.
|
|
|
|
|
Future<bool> emailSignInOffered(Session session);
|
|
|
|
|
|
|
|
|
|
/// Mails a sign-in code to [address] in [language], the codes asked for
|
|
|
|
|
/// from [network] counted; the request it answers. Refusals as
|
|
|
|
|
/// `sendSignInCode`'s.
|
|
|
|
|
Future<UuidValue> sendSignInCode(
|
|
|
|
|
Session session, {
|
|
|
|
|
required String address,
|
|
|
|
|
required String language,
|
|
|
|
|
required String network,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// Links the GitHub account [gitHubUserId] to [person] (an account of the
|
|
|
|
|
/// register, under their ID). Refusals as `linkGitHubAccount`'s.
|
|
|
|
|
Future<void> linkGitHub(
|
|
|
|
|
Session session, {
|
|
|
|
|
required UuidValue person,
|
|
|
|
|
required String gitHubUserId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// The account that signs in with the GitHub account [gitHubUserId]; null
|
|
|
|
|
/// for none.
|
|
|
|
|
Future<UuidValue?> accountWithGitHub(Session session, String gitHubUserId);
|
|
|
|
|
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
/// How [person] signs in: with GitHub, an email address, a password and
|
|
|
|
|
/// whether an authenticator is confirmed.
|
2026-10-19 23:10:08 +02:00
|
|
|
Future<SignInWays> signInWaysOf(Session session, UuidValue person);
|
|
|
|
|
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
/// Whether people sign in with a password and TOTP (ADR 0017): the
|
|
|
|
|
/// register holds the keys it needs.
|
|
|
|
|
Future<bool> passwordSignInOffered(Session session);
|
|
|
|
|
|
|
|
|
|
/// Gives [person] the password [setup] describes, with the address [code]
|
|
|
|
|
/// proves for [request] unless they have one. Refusals as `setPassword`'s.
|
|
|
|
|
Future<void> setPassword(
|
|
|
|
|
Session session, {
|
|
|
|
|
required UuidValue person,
|
|
|
|
|
required PasswordSetup setup,
|
|
|
|
|
UuidValue? request,
|
|
|
|
|
String? code,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// A password sign-in's first step for [address], the sign-ins begun from
|
|
|
|
|
/// [network] counted. Refusals as `beginPasswordSignIn`'s.
|
|
|
|
|
Future<PasswordChallenge> beginPasswordSignIn(
|
|
|
|
|
Session session, {
|
|
|
|
|
required String address,
|
|
|
|
|
required String network,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// A password sign-in's second step: the account it proved, for the
|
2026-10-20 14:41:16 +02:00
|
|
|
/// caller's session; a [code] mailed for [request] in place of the second
|
|
|
|
|
/// factor of a setup never finished. Refusals as
|
|
|
|
|
/// `completePasswordSignIn`'s.
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
Future<PasswordProof> completePasswordSignIn(
|
|
|
|
|
Session session, {
|
|
|
|
|
required UuidValue challenge,
|
|
|
|
|
required ByteData clientValue,
|
|
|
|
|
required ByteData clientProof,
|
|
|
|
|
String? totpCode,
|
|
|
|
|
String? recoveryCode,
|
2026-10-20 14:41:16 +02:00
|
|
|
UuidValue? request,
|
|
|
|
|
String? code,
|
fix(auth)!: proofs are bound to the instance the device addresses
Nothing tied a sign-in's proof to the instance it was made for: an
instance a person was lured into adding could pass A, M1 and the code on
to the real register and get a session there, and pass a phrase's proof
on with a password of its own in place of the person's (the second
reading's M6). SRP's identity is now a label, the challenge's ID and
the host the device addressed (passwordIdentity, in halkyon_base_client
and halkyon_base_server alike), and the phrase's proof signs that host,
what it is for (an authenticator's reset or a new password) and the new
password's digest (key proof v2). Every proving call carries the host;
in production it must be the server's apiServer.publicHost (otherwise
otherInstance, which the app shows as an instance it can't trust), in
development any; a member forwards its own. The tests prove that a
relayed sign-in, a proof for another host or purpose, and a swapped
password are refused, nothing changing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:20:12 +02:00
|
|
|
required String host,
|
fix(auth): codes wait after ten wrong in a row, and the owner is told
Whoever knew the password could guess codes at 10 an hour forever,
unnoticed, each guess checked against every authenticator (the second
reading's M4). Every code an account's second factor refuses after a
right password (sign-in, a phrase's reset, a change) or from a signed-in
device (removing an authenticator, new recovery codes, the code from one
confirmed before) now counts, per account, from every network; after
every tenth in a row no code is taken (codesWait, saying until when), an
hour, then twice as long each time, a day at the most (longestCodeWait,
second_factor_waits.dart): at a day, ten guesses a day still have about a
1 % chance a year against one authenticator, so the first wait mails the
owner that someone knows their password. The instance keeps no address,
so sign-in and the phrase's reset now send the address the device typed
(and its language), used only for that mail after its hash is checked
against the account's. A code accepted clears it all.
Migration (module, Learn's and Todo's servers, Learn's device schema):
CREATE TABLE "second_factor_failures" ("id" bigserial PRIMARY KEY,
"authUserId" uuid NOT NULL, "failures" bigint NOT NULL, "waits"
bigint NOT NULL, "waitUntil" timestamp, "updatedAt" timestamp NOT
NULL) with a unique index on "authUserId": a new table, nothing
touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:47:38 +02:00
|
|
|
String? address,
|
|
|
|
|
String? language,
|
2026-10-20 14:41:16 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// Signs up with the address [code] proves for [request] and the password
|
|
|
|
|
/// [setup] describes: the account, new or one with the address and no
|
|
|
|
|
/// password, for the caller's session. Refusals as `signUpWithCode`'s.
|
|
|
|
|
Future<UuidValue> signUpWithCode(
|
|
|
|
|
Session session, {
|
|
|
|
|
required UuidValue request,
|
|
|
|
|
required String code,
|
|
|
|
|
required PasswordSetup setup,
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// A new authenticator of [person]. Refusals as `enrolAuthenticator`'s.
|
|
|
|
|
Future<AuthenticatorEnrolment> enrolAuthenticator(
|
|
|
|
|
Session session, {
|
|
|
|
|
required UuidValue person,
|
|
|
|
|
required String name,
|
|
|
|
|
required String label,
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-20 14:12:48 +02:00
|
|
|
/// Confirms [person]'s [authenticator], with [existingCode] from one
|
|
|
|
|
/// confirmed before where the account has one; the first one's recovery
|
|
|
|
|
/// codes. Refusals as `confirmAuthenticator`'s.
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
Future<List<String>> confirmAuthenticator(
|
|
|
|
|
Session session, {
|
|
|
|
|
required UuidValue person,
|
|
|
|
|
required UuidValue authenticator,
|
|
|
|
|
required String code,
|
2026-10-20 14:12:48 +02:00
|
|
|
String? existingCode,
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// [person]'s authenticators, the oldest first.
|
|
|
|
|
Future<List<Authenticator>> authenticatorsOf(
|
|
|
|
|
Session session,
|
|
|
|
|
UuidValue person,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/// Removes [person]'s [authenticator]. Refusals as
|
|
|
|
|
/// `removeAuthenticator`'s.
|
|
|
|
|
Future<void> removeAuthenticator(
|
|
|
|
|
Session session, {
|
|
|
|
|
required UuidValue person,
|
|
|
|
|
required UuidValue authenticator,
|
|
|
|
|
String? code,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// New recovery codes for [person]. Refusals as `newRecoveryCodes`'.
|
|
|
|
|
Future<List<String>> newRecoveryCodes(
|
|
|
|
|
Session session, {
|
|
|
|
|
required UuidValue person,
|
|
|
|
|
required String code,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// Asks for the reset of a lost authenticator. Refusals as
|
|
|
|
|
/// `resetAuthenticator`'s.
|
|
|
|
|
Future<ResetPending> resetAuthenticator(
|
|
|
|
|
Session session, {
|
|
|
|
|
required UuidValue challenge,
|
|
|
|
|
required ByteData clientValue,
|
|
|
|
|
required ByteData clientProof,
|
|
|
|
|
required String address,
|
|
|
|
|
required String language,
|
|
|
|
|
UuidValue? request,
|
|
|
|
|
String? code,
|
|
|
|
|
UuidValue? keyChallenge,
|
|
|
|
|
ByteData? signature,
|
fix(auth)!: proofs are bound to the instance the device addresses
Nothing tied a sign-in's proof to the instance it was made for: an
instance a person was lured into adding could pass A, M1 and the code on
to the real register and get a session there, and pass a phrase's proof
on with a password of its own in place of the person's (the second
reading's M6). SRP's identity is now a label, the challenge's ID and
the host the device addressed (passwordIdentity, in halkyon_base_client
and halkyon_base_server alike), and the phrase's proof signs that host,
what it is for (an authenticator's reset or a new password) and the new
password's digest (key proof v2). Every proving call carries the host;
in production it must be the server's apiServer.publicHost (otherwise
otherInstance, which the app shows as an instance it can't trust), in
development any; a member forwards its own. The tests prove that a
relayed sign-in, a proof for another host or purpose, and a swapped
password are refused, nothing changing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:20:12 +02:00
|
|
|
required String host,
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// The first step of proving the recovery phrase for [address], the
|
|
|
|
|
/// proofs begun from [network] counted. Refusals as `beginKeyProof`'s.
|
|
|
|
|
Future<KeyProofChallenge> beginKeyProof(
|
|
|
|
|
Session session, {
|
|
|
|
|
required String address,
|
|
|
|
|
required String network,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// Sets a new password with the recovery phrase's proof; the account, for
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
/// the caller's session made after its sessions ended. Refusals as
|
|
|
|
|
/// `resetPasswordWithPhrase`'s.
|
|
|
|
|
Future<AccountSessionsEnded> resetPasswordWithPhrase(
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
Session session, {
|
|
|
|
|
required UuidValue challenge,
|
|
|
|
|
required ByteData signature,
|
|
|
|
|
required NewPassword password,
|
|
|
|
|
String? totpCode,
|
|
|
|
|
String? recoveryCode,
|
fix(auth)!: proofs are bound to the instance the device addresses
Nothing tied a sign-in's proof to the instance it was made for: an
instance a person was lured into adding could pass A, M1 and the code on
to the real register and get a session there, and pass a phrase's proof
on with a password of its own in place of the person's (the second
reading's M6). SRP's identity is now a label, the challenge's ID and
the host the device addressed (passwordIdentity, in halkyon_base_client
and halkyon_base_server alike), and the phrase's proof signs that host,
what it is for (an authenticator's reset or a new password) and the new
password's digest (key proof v2). Every proving call carries the host;
in production it must be the server's apiServer.publicHost (otherwise
otherInstance, which the app shows as an instance it can't trust), in
development any; a member forwards its own. The tests prove that a
relayed sign-in, a proof for another host or purpose, and a swapped
password are refused, nothing changing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:20:12 +02:00
|
|
|
required String host,
|
fix(auth): codes wait after ten wrong in a row, and the owner is told
Whoever knew the password could guess codes at 10 an hour forever,
unnoticed, each guess checked against every authenticator (the second
reading's M4). Every code an account's second factor refuses after a
right password (sign-in, a phrase's reset, a change) or from a signed-in
device (removing an authenticator, new recovery codes, the code from one
confirmed before) now counts, per account, from every network; after
every tenth in a row no code is taken (codesWait, saying until when), an
hour, then twice as long each time, a day at the most (longestCodeWait,
second_factor_waits.dart): at a day, ten guesses a day still have about a
1 % chance a year against one authenticator, so the first wait mails the
owner that someone knows their password. The instance keeps no address,
so sign-in and the phrase's reset now send the address the device typed
(and its language), used only for that mail after its hash is checked
against the account's. A code accepted clears it all.
Migration (module, Learn's and Todo's servers, Learn's device schema):
CREATE TABLE "second_factor_failures" ("id" bigserial PRIMARY KEY,
"authUserId" uuid NOT NULL, "failures" bigint NOT NULL, "waits"
bigint NOT NULL, "waitUntil" timestamp, "updatedAt" timestamp NOT
NULL) with a unique index on "authUserId": a new table, nothing
touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:47:38 +02:00
|
|
|
String? address,
|
|
|
|
|
String? language,
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// Asks for the reset of a whole account. Refusals as
|
|
|
|
|
/// `startAccountReset`'s.
|
|
|
|
|
Future<ResetPending> startAccountReset(
|
|
|
|
|
Session session, {
|
|
|
|
|
required String address,
|
|
|
|
|
required String language,
|
|
|
|
|
required UuidValue request,
|
|
|
|
|
required String code,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// Carries out the reset of a whole account; the account, for the
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
/// caller's session made after its sessions ended. Refusals as
|
|
|
|
|
/// `completeAccountReset`'s.
|
|
|
|
|
Future<AccountSessionsEnded> completeAccountReset(
|
feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:
- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
and the private keys wrapped twice, all made on the device; with the
address a mailed code proves, an anonymous person becomes identified
under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
recovery code. An address nobody has is answered from a decoy key as
an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
to their row), each code accepted once, ten recovery codes with the
first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
password and a mailed code or the recovery phrase ask, a lost password
set anew with the phrase's signature, or the account reset with new
keys 72 hours after a mailed code asks; a mail at each start, a
sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
GitHub; no new GitHub account is linked where passwords are offered,
and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.
Five tables, added by the module's migration; deletePerson deletes
their rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 13:32:12 +02:00
|
|
|
Session session, {
|
|
|
|
|
required String address,
|
|
|
|
|
required UuidValue request,
|
|
|
|
|
required String code,
|
|
|
|
|
required PasswordSetup setup,
|
|
|
|
|
});
|
|
|
|
|
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
/// The first step of [person] proving their password to change it.
|
|
|
|
|
/// Refusals as `beginPasswordChange`'s.
|
|
|
|
|
Future<PasswordChallenge> beginPasswordChange(
|
|
|
|
|
Session session, {
|
|
|
|
|
required UuidValue person,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// Changes [person]'s password, ending their sessions everywhere but
|
|
|
|
|
/// [keepSession]. Refusals as `changePassword`'s.
|
|
|
|
|
Future<void> changePassword(
|
|
|
|
|
Session session, {
|
|
|
|
|
required UuidValue person,
|
|
|
|
|
required UuidValue challenge,
|
|
|
|
|
required ByteData clientValue,
|
|
|
|
|
required ByteData clientProof,
|
|
|
|
|
String? totpCode,
|
|
|
|
|
String? recoveryCode,
|
|
|
|
|
required NewPassword password,
|
|
|
|
|
UuidValue? keepSession,
|
fix(auth)!: proofs are bound to the instance the device addresses
Nothing tied a sign-in's proof to the instance it was made for: an
instance a person was lured into adding could pass A, M1 and the code on
to the real register and get a session there, and pass a phrase's proof
on with a password of its own in place of the person's (the second
reading's M6). SRP's identity is now a label, the challenge's ID and
the host the device addressed (passwordIdentity, in halkyon_base_client
and halkyon_base_server alike), and the phrase's proof signs that host,
what it is for (an authenticator's reset or a new password) and the new
password's digest (key proof v2). Every proving call carries the host;
in production it must be the server's apiServer.publicHost (otherwise
otherInstance, which the app shows as an instance it can't trust), in
development any; a member forwards its own. The tests prove that a
relayed sign-in, a proof for another host or purpose, and a swapped
password are refused, nothing changing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:20:12 +02:00
|
|
|
required String host,
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
});
|
|
|
|
|
|
fix(auth)!: the recovery phrase is proved by a key of its own
A key proof was a signature by the account's signing key, which every
signed-in device keeps in its keyring: whoever could read one, with a
code from its authenticator, set a new password without the password or
the phrase (the second reading's H1). The phrase now proves itself with
an Ed25519 key derived from its bits alone (halkyon_keys'
recoveryProofKeysOf, KDF context hlkrprf1, a sibling of the recovery
key), whose public half every setup, new phrase and account reset sends
and the register keeps (account_password.recoveryProofKey); the device
derives the secret half only while the phrase is typed. An account
without one (none exist outside development) proves no phrase until it
sets a new one; decoys check against a decoy proof key alike.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "account_password" ADD COLUMN "recoveryProofKey" bytea;
nullable, so rows made before keep working and no data is touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:03:15 +02:00
|
|
|
/// Replaces the wrapping of [person]'s keys under their recovery phrase
|
|
|
|
|
/// and the phrase's proof key, with the password's proof and a code.
|
|
|
|
|
/// Refusals as `setRecoveryKeys`'.
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
Future<void> setRecoveryKeys(
|
|
|
|
|
Session session, {
|
|
|
|
|
required UuidValue person,
|
2026-10-20 20:01:07 +02:00
|
|
|
required UuidValue challenge,
|
|
|
|
|
required ByteData clientValue,
|
|
|
|
|
required ByteData clientProof,
|
|
|
|
|
String? totpCode,
|
|
|
|
|
String? recoveryCode,
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
required WrappedKeys recoveryKeys,
|
fix(auth)!: the recovery phrase is proved by a key of its own
A key proof was a signature by the account's signing key, which every
signed-in device keeps in its keyring: whoever could read one, with a
code from its authenticator, set a new password without the password or
the phrase (the second reading's H1). The phrase now proves itself with
an Ed25519 key derived from its bits alone (halkyon_keys'
recoveryProofKeysOf, KDF context hlkrprf1, a sibling of the recovery
key), whose public half every setup, new phrase and account reset sends
and the register keeps (account_password.recoveryProofKey); the device
derives the secret half only while the phrase is typed. An account
without one (none exist outside development) proves no phrase until it
sets a new one; decoys check against a decoy proof key alike.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "account_password" ADD COLUMN "recoveryProofKey" bytea;
nullable, so rows made before keep working and no data is touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:03:15 +02:00
|
|
|
required ByteData recoveryProofKey,
|
fix(auth)!: proofs are bound to the instance the device addresses
Nothing tied a sign-in's proof to the instance it was made for: an
instance a person was lured into adding could pass A, M1 and the code on
to the real register and get a session there, and pass a phrase's proof
on with a password of its own in place of the person's (the second
reading's M6). SRP's identity is now a label, the challenge's ID and
the host the device addressed (passwordIdentity, in halkyon_base_client
and halkyon_base_server alike), and the phrase's proof signs that host,
what it is for (an authenticator's reset or a new password) and the new
password's digest (key proof v2). Every proving call carries the host;
in production it must be the server's apiServer.publicHost (otherwise
otherInstance, which the app shows as an instance it can't trust), in
development any; a member forwards its own. The tests prove that a
relayed sign-in, a proof for another host or purpose, and a swapped
password are refused, nothing changing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 21:20:12 +02:00
|
|
|
required String host,
|
feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.
A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.
A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-20 14:53:58 +02:00
|
|
|
});
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
/// Deletes the account [person] with everything the register's server has
|
2026-10-20 21:59:42 +02:00
|
|
|
/// of them, and has every member of the instance delete theirs: one with a
|
|
|
|
|
/// password only with [proof] (`requireOwnerToDelete`).
|
|
|
|
|
Future<void> deleteAccount(
|
|
|
|
|
Session session,
|
|
|
|
|
UuidValue person, {
|
|
|
|
|
DeletionProof? proof,
|
|
|
|
|
});
|
2026-10-19 23:10:08 +02:00
|
|
|
|
|
|
|
|
/// Those of [people] that are accounts of the register.
|
|
|
|
|
Future<Set<UuidValue>> knownAccounts(
|
|
|
|
|
Session session,
|
|
|
|
|
Set<UuidValue> people,
|
|
|
|
|
);
|
refactor(profile): handles and profiles move into halkyon_base
People's public profiles are the instance's register's (ADR 0016), so
the module now holds them: the public_profile table (unchanged, so
Learn's migration records versions only; devices get the table empty,
as they hold every module's tables), the profile and profiles
endpoints under `base`, the handle rules, the link checks and the page
at /@handle, whose foot names the app as the host's configuration says
(profilePageNote). Deleting a person deletes their profile in the
module's core.
Hosts read profiles through the module (profileOf, profilesOf,
personWithHandle, fixHandle; saveProfile for their tests), behind an
AccountRegister that is this server's own database for now. Learn's
feedback, discussions and publishing ask it; publishing fixes the
handle just before it writes the release, outside its transaction.
Explore still joins the table in its search, so Learn holds the
register wherever it runs. Learn's app calls the module's endpoints;
a profile not found is ProfileNotFoundException. Todo's database gets
the table too (it was never deployed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-19 22:20:27 +02:00
|
|
|
}
|