fix(auth): a capped wait, a long instance secret, writes of their own columns

Three of the second reading's lows:
- A member waits at most 5 seconds (maxClockSkew) for the register's
  moment before making a session after a reset; a clock further behind
  is refused at once (unavailable, logged), rather than holding the
  request for as long as the clocks differ.
- instanceSecret, which lets a service set passwords and delete accounts
  for anyone, stops the server when set shorter than 32 characters, as
  the password keys do.
- A change of password, a reset keeping the keys and a new phrase each
  write their own columns alone (writeNewPassword, columns: in
  setRecoveryKeys), so one made from the row as it was before another
  never undoes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-20 21:30:19 +02:00
commit 5b9cb85b0f
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 186 additions and 38 deletions

View file

@ -146,24 +146,7 @@ Future<AccountSessionsEnded> resetPasswordWithPhrase(
recoveryCode: recoveryCode,
);
final endedBefore = await session.db.transaction((transaction) async {
// UPDATE "account_password" SET "salt" = $1, "kdfAlgorithm" = $2,
// "kdfOpsLimit" = $3, "kdfMemLimit" = $4, "verifier" = $5,
// "passwordKeysNonce" = $6, "passwordKeysCipherText" = $7,
// "updatedAt" = $8 WHERE "id" = $9
await AccountPassword.db.updateRow(
session,
row.copyWith(
salt: password.salt,
kdfAlgorithm: password.costs.algorithm,
kdfOpsLimit: password.costs.opsLimit,
kdfMemLimit: password.costs.memLimit,
verifier: password.verifier,
passwordKeysNonce: password.passwordKeys.nonce,
passwordKeysCipherText: password.passwordKeys.cipherText,
updatedAt: clock.now().toUtc(),
),
transaction: transaction,
);
await writeNewPassword(session, row, password, transaction: transaction);
return await endSessionsEverywhere(
session,
account,

View file

@ -56,24 +56,7 @@ Future<void> changePassword(
host: host,
);
await session.db.transaction((transaction) async {
// UPDATE "account_password" SET "salt" = $1, "kdfAlgorithm" = $2,
// "kdfOpsLimit" = $3, "kdfMemLimit" = $4, "verifier" = $5,
// "passwordKeysNonce" = $6, "passwordKeysCipherText" = $7,
// "updatedAt" = $8 WHERE "id" = $9
await AccountPassword.db.updateRow(
session,
row.copyWith(
salt: password.salt,
kdfAlgorithm: password.costs.algorithm,
kdfOpsLimit: password.costs.opsLimit,
kdfMemLimit: password.costs.memLimit,
verifier: password.verifier,
passwordKeysNonce: password.passwordKeys.nonce,
passwordKeysCipherText: password.passwordKeys.cipherText,
updatedAt: clock.now().toUtc(),
),
transaction: transaction,
);
await writeNewPassword(session, row, password, transaction: transaction);
await endSessionsEverywhere(
session,
person,
@ -125,6 +108,7 @@ Future<void> setRecoveryKeys(
// UPDATE "account_password" SET "recoveryKeysNonce" = $1,
// "recoveryKeysCipherText" = $2, "recoveryProofKey" = $3,
// "updatedAt" = $4 WHERE "id" = $5
// (Those columns alone: a change of password meanwhile stays.)
await AccountPassword.db.updateRow(
session,
row.copyWith(
@ -133,6 +117,12 @@ Future<void> setRecoveryKeys(
recoveryProofKey: recoveryProofKey,
updatedAt: clock.now().toUtc(),
),
columns: (t) => [
t.recoveryKeysNonce,
t.recoveryKeysCipherText,
t.recoveryProofKey,
t.updatedAt,
],
);
}

View file

@ -474,6 +474,45 @@ Future<UuidValue> signUpWithCode(
}
}
/// Writes [password] (a change, or a reset keeping the keys) over [row]'s
/// password alone: the salt, the costs, the verifier and the keys wrapped
/// under it. Never the row's other columns, which [row] may hold as they
/// were before a change made meanwhile (a new phrase) that must stay.
Future<void> writeNewPassword(
Session session,
AccountPassword row,
NewPassword password, {
Transaction? transaction,
}) =>
// UPDATE "account_password" SET "salt" = $1, "kdfAlgorithm" = $2,
// "kdfOpsLimit" = $3, "kdfMemLimit" = $4, "verifier" = $5,
// "passwordKeysNonce" = $6, "passwordKeysCipherText" = $7,
// "updatedAt" = $8 WHERE "id" = $9
AccountPassword.db.updateRow(
session,
row.copyWith(
salt: password.salt,
kdfAlgorithm: password.costs.algorithm,
kdfOpsLimit: password.costs.opsLimit,
kdfMemLimit: password.costs.memLimit,
verifier: password.verifier,
passwordKeysNonce: password.passwordKeys.nonce,
passwordKeysCipherText: password.passwordKeys.cipherText,
updatedAt: clock.now().toUtc(),
),
columns: (t) => [
t.salt,
t.kdfAlgorithm,
t.kdfOpsLimit,
t.kdfMemLimit,
t.verifier,
t.passwordKeysNonce,
t.passwordKeysCipherText,
t.updatedAt,
],
transaction: transaction,
);
/// The first step of signing in with a password: SRP's B for the account
/// with [address], with its salt and costs, all alike for an address nobody
/// has (see above). At most 10 sign-ins begin for an address and 30 from a

View file

@ -66,12 +66,25 @@ void addInstanceRoutes(Serverpod pod) {
);
}
/// The fewest characters the instance's shared secret has: it sets
/// passwords and deletes accounts for anyone, as the password keys
/// (`passwordKeyMinLength`) protect what they protect.
const instanceSecretMinLength = 32;
/// Throws a [StateError] where [pod] would call the instance's other
/// services without the shared secret (`instanceSecret`): a member asks its
/// register everything, and a register with members tells them when an
/// account goes.
/// account goes; and for a secret shorter than [instanceSecretMinLength].
void checkInstanceSecret(Serverpod pod) {
if (instanceSecretOf(pod) != null) {
final secret = instanceSecretOf(pod);
if (secret != null) {
if (secret.length < instanceSecretMinLength) {
throw StateError(
'INSTANCE_SECRET (instanceSecret in the passwords) is shorter than '
'$instanceSecretMinLength characters: it lets a service act for any '
'account of the instance. Generate one: openssl rand -hex 32',
);
}
return;
}
switch (HalkyonBase.maybeOf(pod)?.accounts) {

View file

@ -1,6 +1,7 @@
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/auth/identified.dart';
import 'package:halkyon_base_server/src/auth/scopes.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -81,12 +82,28 @@ Future<AuthSuccess> issueSessionAfter(
}) async {
await endSessionsHere(session, account, endedBefore: endedBefore);
final wait = endedBefore.difference(clock.now());
if (wait > maxClockSkew) {
// Its type of trouble only, never whose: the clocks need setting right.
session.log(
"This server's clock is ${wait.inSeconds} s behind the register's: "
'set both by NTP.',
level: LogLevel.warning,
);
throw PasswordRefusedException(reason: PasswordRefusal.unavailable);
}
if (!wait.isNegative) {
await Future<void>.delayed(wait + const Duration(milliseconds: 1));
}
return await issueSession(session, account, method: method);
}
/// How far behind the register's clock a member's may run for
/// [issueSessionAfter] to wait for it: further, the request would hang as
/// long, so it is refused. Clocks set by NTP differ by milliseconds; a
/// member's clock running ahead of the register's by up to this much
/// leaves sessions made in that gap, which the ADR accepts.
const maxClockSkew = Duration(seconds: 5);
/// What [person] becomes here once a way in is theirs, as linking makes them
/// on the register in the same transaction: `identified` (on their sessions
/// too), and no longer anonymous. A member does it after the register linked