2026-10-19 23:10:08 +02:00
|
|
|
import 'dart:async';
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
|
|
|
|
import 'package:halkyon_base_server/src/auth/account.dart';
|
|
|
|
|
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
|
|
|
|
import 'package:halkyon_base_server/src/halkyon_base.dart';
|
|
|
|
|
import 'package:halkyon_base_server/src/register/accounts_mode.dart';
|
|
|
|
|
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
|
|
|
|
|
import 'package:halkyon_base_server/src/register/local_register.dart';
|
|
|
|
|
import 'package:halkyon_base_server/src/register/register_protocol.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';
|
2026-10-19 23:10:08 +02:00
|
|
|
import 'package:serverpod/serverpod.dart';
|
|
|
|
|
import 'package:serverpod_auth_idp_server/core.dart';
|
|
|
|
|
|
|
|
|
|
// The calls between an instance's services (ADR 0016), read into plain values
|
|
|
|
|
// so tests drive them without a web server; `instance_routes.dart` puts them
|
|
|
|
|
// on the web server. Every call carries the instance's shared secret: without
|
|
|
|
|
// it, or on a server without one, nothing is answered.
|
|
|
|
|
|
|
|
|
|
/// What a call between the instance's services is answered: an HTTP status
|
|
|
|
|
/// and a JSON body.
|
|
|
|
|
typedef InstanceAnswer = ({int status, Map<String, Object?> body});
|
|
|
|
|
|
|
|
|
|
const _unauthorized = (status: 401, body: <String, Object?>{'error': 'secret'});
|
|
|
|
|
const _notHere = (status: 404, body: <String, Object?>{'error': 'not here'});
|
|
|
|
|
const _unreadable = (status: 400, body: <String, Object?>{'error': 'request'});
|
|
|
|
|
|
|
|
|
|
/// Whether [authorization] (a request's header) carries the instance's
|
|
|
|
|
/// secret, which [pod] must have.
|
|
|
|
|
bool _carriesSecret(Serverpod pod, String? authorization) {
|
|
|
|
|
final secret = instanceSecretOf(pod);
|
|
|
|
|
if (secret == null || authorization == null) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const scheme = 'Bearer ';
|
|
|
|
|
return authorization.startsWith(scheme) &&
|
|
|
|
|
isInstanceSecret(authorization.substring(scheme.length), secret);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// [body] as a JSON object; null for anything else.
|
|
|
|
|
Map<String, Object?>? _arguments(String body) {
|
|
|
|
|
try {
|
|
|
|
|
return switch (jsonDecode(body)) {
|
|
|
|
|
final Map<String, Object?> arguments => arguments,
|
|
|
|
|
_ => null,
|
|
|
|
|
};
|
|
|
|
|
} on FormatException {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The register answering a member's [operation] (`register_protocol.dart`),
|
|
|
|
|
/// asked with [body] (JSON) and [authorization] (the request's header). On
|
|
|
|
|
/// a server that is a member itself, nothing is answered.
|
|
|
|
|
Future<InstanceAnswer> answerMember(
|
|
|
|
|
Session session,
|
|
|
|
|
String operation,
|
|
|
|
|
String body, {
|
|
|
|
|
required String? authorization,
|
|
|
|
|
}) async {
|
|
|
|
|
final pod = session.serverpod;
|
|
|
|
|
if (!_carriesSecret(pod, authorization)) {
|
|
|
|
|
return _unauthorized;
|
|
|
|
|
}
|
|
|
|
|
if (HalkyonBase.maybeOf(pod)?.accounts is MemberOfRegister) {
|
|
|
|
|
return _notHere;
|
|
|
|
|
}
|
|
|
|
|
final arguments = _arguments(body);
|
|
|
|
|
if (arguments == null) {
|
|
|
|
|
return _unreadable;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
final answer = await _answer(session, operation, _Arguments(arguments));
|
|
|
|
|
return answer == null ? _notHere : (status: 200, body: answer);
|
|
|
|
|
} on FormatException {
|
|
|
|
|
return _unreadable;
|
|
|
|
|
} on ProfileNotFoundException {
|
|
|
|
|
return (status: 404, body: <String, Object?>{'notFound': 'profile'});
|
|
|
|
|
} on Exception catch (error) {
|
|
|
|
|
if (refusalJson(error) case final refusal?) {
|
|
|
|
|
return (status: 409, body: <String, Object?>{'refusal': refusal});
|
|
|
|
|
}
|
|
|
|
|
rethrow;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// [operation]'s answer from this server's own register; null for an
|
|
|
|
|
/// operation it doesn't know.
|
|
|
|
|
Future<Map<String, Object?>?> _answer(
|
|
|
|
|
Session session,
|
|
|
|
|
String operation,
|
|
|
|
|
_Arguments arguments,
|
|
|
|
|
) async {
|
|
|
|
|
const register = LocalRegister();
|
|
|
|
|
switch (operation) {
|
|
|
|
|
case RegisterOperation.profileOf:
|
|
|
|
|
final profile = await register.profileOf(session, arguments.id('person'));
|
|
|
|
|
return {'profile': profile?.toJson()};
|
|
|
|
|
case RegisterOperation.profilesOf:
|
|
|
|
|
final profiles = await register.profilesOf(
|
|
|
|
|
session,
|
|
|
|
|
arguments.ids('people'),
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
'profiles': {
|
|
|
|
|
for (final MapEntry(:key, :value) in profiles.entries)
|
|
|
|
|
key.uuid: value.toJson(),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
case RegisterOperation.profileByHandle:
|
|
|
|
|
final profile = await register.profileByHandle(
|
|
|
|
|
session,
|
|
|
|
|
arguments.string('handle'),
|
|
|
|
|
);
|
|
|
|
|
return {'profile': profile?.toJson()};
|
|
|
|
|
case RegisterOperation.personWithHandle:
|
|
|
|
|
final person = await register.personWithHandle(
|
|
|
|
|
session,
|
|
|
|
|
arguments.string('handle'),
|
|
|
|
|
);
|
|
|
|
|
return {'person': person?.uuid};
|
|
|
|
|
case RegisterOperation.saveProfile:
|
|
|
|
|
final profile = await register.saveProfile(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
handle: arguments.string('handle'),
|
|
|
|
|
displayName: arguments.string('displayName'),
|
|
|
|
|
bio: arguments.string('bio'),
|
|
|
|
|
links: arguments.strings('links'),
|
|
|
|
|
);
|
|
|
|
|
return {'profile': profile.toJson()};
|
|
|
|
|
case RegisterOperation.checkHandle:
|
|
|
|
|
final refusal = await register.checkHandle(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
arguments.string('handle'),
|
|
|
|
|
);
|
|
|
|
|
return {'refusal': refusal?.name};
|
|
|
|
|
case RegisterOperation.checkLinks:
|
|
|
|
|
final profile = await register.checkLinks(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
);
|
|
|
|
|
return {'profile': profile.toJson()};
|
|
|
|
|
case RegisterOperation.setAvatar:
|
|
|
|
|
final Uint8List png;
|
|
|
|
|
try {
|
|
|
|
|
png = base64Decode(arguments.string('png'));
|
|
|
|
|
} on FormatException {
|
|
|
|
|
throw ProfileRefusedException(reason: ProfileRefusal.avatarInvalid);
|
|
|
|
|
}
|
|
|
|
|
final profile = await register.setAvatar(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
ByteData.sublistView(png),
|
|
|
|
|
);
|
|
|
|
|
return {'profile': profile.toJson()};
|
|
|
|
|
case RegisterOperation.removeAvatar:
|
|
|
|
|
final profile = await register.removeAvatar(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
);
|
|
|
|
|
return {'profile': profile.toJson()};
|
|
|
|
|
case RegisterOperation.removeProfile:
|
|
|
|
|
await register.removeProfile(session, arguments.id('person'));
|
|
|
|
|
return const {};
|
|
|
|
|
case RegisterOperation.fixHandle:
|
|
|
|
|
await register.fixHandle(session, arguments.id('person'));
|
|
|
|
|
return const {};
|
|
|
|
|
case RegisterOperation.emailSignInOffered:
|
|
|
|
|
return {'offered': await register.emailSignInOffered(session)};
|
|
|
|
|
case RegisterOperation.sendSignInCode:
|
|
|
|
|
final request = await register.sendSignInCode(
|
|
|
|
|
session,
|
|
|
|
|
address: arguments.string('address'),
|
|
|
|
|
language: arguments.string('language'),
|
|
|
|
|
network: arguments.string('network'),
|
|
|
|
|
);
|
|
|
|
|
return {'request': request.uuid};
|
|
|
|
|
case RegisterOperation.linkGitHub:
|
|
|
|
|
await register.linkGitHub(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
gitHubUserId: arguments.string('gitHubUserId'),
|
|
|
|
|
);
|
|
|
|
|
return const {};
|
|
|
|
|
case RegisterOperation.accountWithGitHub:
|
|
|
|
|
final account = await register.accountWithGitHub(
|
|
|
|
|
session,
|
|
|
|
|
arguments.string('gitHubUserId'),
|
|
|
|
|
);
|
|
|
|
|
return {'account': account?.uuid};
|
|
|
|
|
case RegisterOperation.signInWaysOf:
|
|
|
|
|
final ways = await register.signInWaysOf(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
);
|
|
|
|
|
return {'ways': ways.toJson()};
|
|
|
|
|
case RegisterOperation.deleteAccount:
|
2026-10-20 21:59:42 +02:00
|
|
|
await register.deleteAccount(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
proof: arguments.has('proof')
|
|
|
|
|
? arguments.model('proof', DeletionProof.fromJson)
|
|
|
|
|
: null,
|
|
|
|
|
);
|
2026-10-19 23:10:08 +02:00
|
|
|
return const {};
|
|
|
|
|
case RegisterOperation.knownAccounts:
|
|
|
|
|
final known = await register.knownAccounts(
|
|
|
|
|
session,
|
|
|
|
|
arguments.ids('people'),
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
'known': [for (final person in known) person.uuid],
|
|
|
|
|
};
|
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
|
|
|
case RegisterOperation.passwordSignInOffered:
|
|
|
|
|
return {'offered': await register.passwordSignInOffered(session)};
|
|
|
|
|
case RegisterOperation.setPassword:
|
|
|
|
|
await register.setPassword(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
setup: arguments.model('setup', PasswordSetup.fromJson),
|
|
|
|
|
request: arguments.optionalId('request'),
|
|
|
|
|
code: arguments.optionalString('code'),
|
|
|
|
|
);
|
|
|
|
|
return const {};
|
|
|
|
|
case RegisterOperation.beginPasswordSignIn:
|
|
|
|
|
final challenge = await register.beginPasswordSignIn(
|
|
|
|
|
session,
|
|
|
|
|
address: arguments.string('address'),
|
|
|
|
|
network: arguments.string('network'),
|
|
|
|
|
);
|
|
|
|
|
return {'challenge': challenge.toJson()};
|
|
|
|
|
case RegisterOperation.completePasswordSignIn:
|
|
|
|
|
final proof = await register.completePasswordSignIn(
|
|
|
|
|
session,
|
|
|
|
|
challenge: arguments.id('challenge'),
|
|
|
|
|
clientValue: arguments.bytes('clientValue'),
|
|
|
|
|
clientProof: arguments.bytes('clientProof'),
|
|
|
|
|
totpCode: arguments.optionalString('totpCode'),
|
|
|
|
|
recoveryCode: arguments.optionalString('recoveryCode'),
|
2026-10-20 14:41:16 +02:00
|
|
|
request: arguments.optionalId('request'),
|
|
|
|
|
code: arguments.optionalString('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
|
|
|
host: arguments.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
|
|
|
address: arguments.optionalString('address'),
|
|
|
|
|
language: arguments.optionalString('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
|
|
|
);
|
|
|
|
|
return {'proof': proof.toJson()};
|
2026-10-20 14:41:16 +02:00
|
|
|
case RegisterOperation.signUpWithCode:
|
|
|
|
|
final account = await register.signUpWithCode(
|
|
|
|
|
session,
|
|
|
|
|
request: arguments.id('request'),
|
|
|
|
|
code: arguments.string('code'),
|
|
|
|
|
setup: arguments.model('setup', PasswordSetup.fromJson),
|
|
|
|
|
);
|
|
|
|
|
return {'account': account.uuid};
|
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
|
|
|
case RegisterOperation.enrolAuthenticator:
|
|
|
|
|
final enrolment = await register.enrolAuthenticator(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
name: arguments.string('name'),
|
|
|
|
|
label: arguments.string('label'),
|
|
|
|
|
);
|
|
|
|
|
return {'enrolment': enrolment.toJson()};
|
|
|
|
|
case RegisterOperation.confirmAuthenticator:
|
|
|
|
|
final codes = await register.confirmAuthenticator(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
authenticator: arguments.id('authenticator'),
|
|
|
|
|
code: arguments.string('code'),
|
2026-10-20 14:12:48 +02:00
|
|
|
existingCode: arguments.optionalString('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
|
|
|
);
|
|
|
|
|
return {'recoveryCodes': codes};
|
|
|
|
|
case RegisterOperation.authenticatorsOf:
|
|
|
|
|
final authenticators = await register.authenticatorsOf(
|
|
|
|
|
session,
|
|
|
|
|
arguments.id('person'),
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
'authenticators': [
|
|
|
|
|
for (final authenticator in authenticators) authenticator.toJson(),
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
case RegisterOperation.removeAuthenticator:
|
|
|
|
|
await register.removeAuthenticator(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
authenticator: arguments.id('authenticator'),
|
|
|
|
|
code: arguments.optionalString('code'),
|
|
|
|
|
);
|
|
|
|
|
return const {};
|
|
|
|
|
case RegisterOperation.newRecoveryCodes:
|
|
|
|
|
final codes = await register.newRecoveryCodes(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
code: arguments.string('code'),
|
|
|
|
|
);
|
|
|
|
|
return {'recoveryCodes': codes};
|
|
|
|
|
case RegisterOperation.resetAuthenticator:
|
|
|
|
|
final pending = await register.resetAuthenticator(
|
|
|
|
|
session,
|
|
|
|
|
challenge: arguments.id('challenge'),
|
|
|
|
|
clientValue: arguments.bytes('clientValue'),
|
|
|
|
|
clientProof: arguments.bytes('clientProof'),
|
|
|
|
|
address: arguments.string('address'),
|
|
|
|
|
language: arguments.string('language'),
|
|
|
|
|
request: arguments.optionalId('request'),
|
|
|
|
|
code: arguments.optionalString('code'),
|
|
|
|
|
keyChallenge: arguments.optionalId('keyChallenge'),
|
|
|
|
|
signature: arguments.optionalBytes('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
|
|
|
host: arguments.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
|
|
|
);
|
|
|
|
|
return {'pending': pending.toJson()};
|
|
|
|
|
case RegisterOperation.beginKeyProof:
|
|
|
|
|
final challenge = await register.beginKeyProof(
|
|
|
|
|
session,
|
|
|
|
|
address: arguments.string('address'),
|
|
|
|
|
network: arguments.string('network'),
|
|
|
|
|
);
|
|
|
|
|
return {'challenge': challenge.toJson()};
|
|
|
|
|
case RegisterOperation.resetPasswordWithPhrase:
|
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
|
|
|
final ended = await register.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,
|
|
|
|
|
challenge: arguments.id('challenge'),
|
|
|
|
|
signature: arguments.bytes('signature'),
|
|
|
|
|
password: arguments.model('password', NewPassword.fromJson),
|
|
|
|
|
totpCode: arguments.optionalString('totpCode'),
|
|
|
|
|
recoveryCode: arguments.optionalString('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
|
|
|
host: arguments.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
|
|
|
address: arguments.optionalString('address'),
|
|
|
|
|
language: arguments.optionalString('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
|
|
|
);
|
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
|
|
|
return _sessionsEnded(ended);
|
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
|
|
|
case RegisterOperation.startAccountReset:
|
|
|
|
|
final pending = await register.startAccountReset(
|
|
|
|
|
session,
|
|
|
|
|
address: arguments.string('address'),
|
|
|
|
|
language: arguments.string('language'),
|
|
|
|
|
request: arguments.id('request'),
|
|
|
|
|
code: arguments.string('code'),
|
|
|
|
|
);
|
|
|
|
|
return {'pending': pending.toJson()};
|
|
|
|
|
case RegisterOperation.completeAccountReset:
|
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
|
|
|
final ended = await register.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,
|
|
|
|
|
address: arguments.string('address'),
|
|
|
|
|
request: arguments.id('request'),
|
|
|
|
|
code: arguments.string('code'),
|
|
|
|
|
setup: arguments.model('setup', PasswordSetup.fromJson),
|
|
|
|
|
);
|
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
|
|
|
return _sessionsEnded(ended);
|
|
|
|
|
case RegisterOperation.beginPasswordChange:
|
|
|
|
|
final challenge = await register.beginPasswordChange(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
);
|
|
|
|
|
return {'challenge': challenge.toJson()};
|
|
|
|
|
case RegisterOperation.changePassword:
|
|
|
|
|
await register.changePassword(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
|
|
|
|
challenge: arguments.id('challenge'),
|
|
|
|
|
clientValue: arguments.bytes('clientValue'),
|
|
|
|
|
clientProof: arguments.bytes('clientProof'),
|
|
|
|
|
totpCode: arguments.optionalString('totpCode'),
|
|
|
|
|
recoveryCode: arguments.optionalString('recoveryCode'),
|
|
|
|
|
password: arguments.model('password', NewPassword.fromJson),
|
|
|
|
|
keepSession: arguments.optionalId('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
|
|
|
host: arguments.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
|
|
|
);
|
|
|
|
|
return const {};
|
|
|
|
|
case RegisterOperation.setRecoveryKeys:
|
|
|
|
|
await register.setRecoveryKeys(
|
|
|
|
|
session,
|
|
|
|
|
person: arguments.id('person'),
|
2026-10-20 20:01:07 +02:00
|
|
|
challenge: arguments.id('challenge'),
|
|
|
|
|
clientValue: arguments.bytes('clientValue'),
|
|
|
|
|
clientProof: arguments.bytes('clientProof'),
|
|
|
|
|
totpCode: arguments.optionalString('totpCode'),
|
|
|
|
|
recoveryCode: arguments.optionalString('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
|
|
|
recoveryKeys: arguments.model('recoveryKeys', WrappedKeys.fromJson),
|
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
|
|
|
recoveryProofKey: arguments.bytes('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
|
|
|
host: arguments.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
|
|
|
);
|
|
|
|
|
return const {};
|
2026-10-19 23:10:08 +02:00
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/// [ended] as the register answers it.
|
|
|
|
|
Map<String, Object?> _sessionsEnded(AccountSessionsEnded ended) => {
|
|
|
|
|
'account': ended.account.uuid,
|
|
|
|
|
'endedBefore': ended.endedBefore.toUtc().toIso8601String(),
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
/// A member answering the register that [body]'s account is gone
|
|
|
|
|
/// ([accountGonePath]): that person's data here is deleted, here alone (the
|
|
|
|
|
/// register deleted the account), and the register told so. On the register
|
|
|
|
|
/// itself, nothing is answered.
|
|
|
|
|
Future<InstanceAnswer> answerRegister(
|
|
|
|
|
Session session,
|
|
|
|
|
String body, {
|
|
|
|
|
required String? authorization,
|
|
|
|
|
}) async {
|
|
|
|
|
final pod = session.serverpod;
|
|
|
|
|
if (!_carriesSecret(pod, authorization)) {
|
|
|
|
|
return _unauthorized;
|
|
|
|
|
}
|
|
|
|
|
if (HalkyonBase.maybeOf(pod)?.accounts is! MemberOfRegister) {
|
|
|
|
|
return _notHere;
|
|
|
|
|
}
|
|
|
|
|
final arguments = _arguments(body);
|
|
|
|
|
if (arguments == null) {
|
|
|
|
|
return _unreadable;
|
|
|
|
|
}
|
|
|
|
|
final UuidValue account;
|
|
|
|
|
try {
|
|
|
|
|
account = _Arguments(arguments).id('account');
|
|
|
|
|
} on FormatException {
|
|
|
|
|
return _unreadable;
|
|
|
|
|
}
|
|
|
|
|
// SELECT … FROM "serverpod_auth_core_user" WHERE "id" = $1
|
|
|
|
|
if (await AuthUser.db.findById(session, account) != null) {
|
|
|
|
|
await deletePerson(session, account);
|
|
|
|
|
await session.messages.authenticationRevoked(
|
|
|
|
|
account.uuid,
|
|
|
|
|
RevokedAuthenticationUser(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return (status: 200, body: const <String, Object?>{});
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/// A member answering the register that [body]'s account's sessions ended
|
|
|
|
|
/// ([sessionsEndedPath], ADR 0017): those here created before the moment it
|
|
|
|
|
/// names end, but the one it keeps, and the register is told so. On the
|
|
|
|
|
/// register itself, nothing is answered.
|
|
|
|
|
Future<InstanceAnswer> answerSessionsEnded(
|
|
|
|
|
Session session,
|
|
|
|
|
String body, {
|
|
|
|
|
required String? authorization,
|
|
|
|
|
}) async {
|
|
|
|
|
final pod = session.serverpod;
|
|
|
|
|
if (!_carriesSecret(pod, authorization)) {
|
|
|
|
|
return _unauthorized;
|
|
|
|
|
}
|
|
|
|
|
if (HalkyonBase.maybeOf(pod)?.accounts is! MemberOfRegister) {
|
|
|
|
|
return _notHere;
|
|
|
|
|
}
|
|
|
|
|
final arguments = _arguments(body);
|
|
|
|
|
if (arguments == null) {
|
|
|
|
|
return _unreadable;
|
|
|
|
|
}
|
|
|
|
|
final UuidValue account;
|
|
|
|
|
final DateTime endedBefore;
|
|
|
|
|
final UuidValue? keepSession;
|
|
|
|
|
try {
|
|
|
|
|
final read = _Arguments(arguments);
|
|
|
|
|
account = read.id('account');
|
|
|
|
|
endedBefore = DateTime.parse(read.string('endedBefore')).toUtc();
|
|
|
|
|
keepSession = read.optionalId('keepSession');
|
|
|
|
|
} on FormatException {
|
|
|
|
|
return _unreadable;
|
|
|
|
|
}
|
|
|
|
|
await endSessionsHere(
|
|
|
|
|
session,
|
|
|
|
|
account,
|
|
|
|
|
endedBefore: endedBefore,
|
|
|
|
|
keepSession: keepSession,
|
|
|
|
|
);
|
|
|
|
|
return (status: 200, body: const <String, Object?>{});
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
/// A request's arguments, each read as what it must be; a [FormatException]
|
|
|
|
|
/// for anything else.
|
|
|
|
|
extension type const _Arguments(Map<String, Object?> _map) {
|
|
|
|
|
String string(String name) => switch (_map[name]) {
|
|
|
|
|
final String value => value,
|
|
|
|
|
_ => throw FormatException('$name is no string'),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
List<String> strings(String name) => switch (_map[name]) {
|
|
|
|
|
final List<Object?> values when values.every((value) => value is String) =>
|
|
|
|
|
values.cast<String>(),
|
|
|
|
|
_ => throw FormatException('$name is no list of strings'),
|
|
|
|
|
};
|
|
|
|
|
|
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
|
|
|
String? optionalString(String name) => switch (_map[name]) {
|
|
|
|
|
null => null,
|
|
|
|
|
final String value => value,
|
|
|
|
|
_ => throw FormatException('$name is no string'),
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
UuidValue id(String name) => UuidValue.withValidation(string(name));
|
|
|
|
|
|
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
|
|
|
UuidValue? optionalId(String name) => switch (optionalString(name)) {
|
|
|
|
|
final value? => UuidValue.withValidation(value),
|
|
|
|
|
null => null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ByteData bytes(String name) => ByteData.sublistView(
|
|
|
|
|
base64Decode(string(name)),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
ByteData? optionalBytes(String name) =>
|
|
|
|
|
optionalString(name) == null ? null : bytes(name);
|
|
|
|
|
|
2026-10-20 21:59:42 +02:00
|
|
|
/// Whether the argument [name] holds anything (not null).
|
|
|
|
|
bool has(String name) => _map[name] != null;
|
|
|
|
|
|
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 model [read] makes of the object [name]; a [FormatException] for
|
|
|
|
|
/// what isn't one.
|
|
|
|
|
T model<T>(String name, T Function(Map<String, dynamic> json) read) {
|
|
|
|
|
final json = _map[name];
|
|
|
|
|
if (json is! Map<String, Object?>) {
|
|
|
|
|
throw FormatException('$name is no object');
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
return read(json);
|
|
|
|
|
} on FormatException {
|
|
|
|
|
rethrow;
|
|
|
|
|
} on Object catch (error) {
|
|
|
|
|
// A field missing or of another type throws a TypeError: the request
|
|
|
|
|
// is unreadable all the same.
|
|
|
|
|
throw FormatException('$name: ${error.runtimeType}');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-19 23:10:08 +02:00
|
|
|
Set<UuidValue> ids(String name) => {
|
|
|
|
|
for (final value in strings(name)) UuidValue.withValidation(value),
|
|
|
|
|
};
|
|
|
|
|
}
|