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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-20 21:03:15 +02:00
commit aea0c447fc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 40952 additions and 93 deletions

View file

@ -1,6 +1,7 @@
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:cryptography/cryptography.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -320,6 +321,16 @@ void main() {
nonce: ByteData(24),
cipherText: ByteData(wrappedKeysLength),
);
// The new phrase's proof key.
late SimpleKeyPair freshProof;
late ByteData freshProofKey;
setUp(() async {
freshProof = await Ed25519().newKeyPairFromSeed(List.filled(32, 3));
freshProofKey = ByteData.sublistView(
Uint8List.fromList((await freshProof.extractPublicKey()).bytes),
);
});
/// Gives Anna's keys a new phrase's wrapping ([fresh]) from her
/// phone, the old password proved by [privateValue] (hers by
@ -331,6 +342,7 @@ void main() {
String? totpCode,
bool withoutCode = false,
WrappedKeys? recoveryKeys,
ByteData? recoveryProofKey,
TestSessionBuilder? as,
}) => atNow(() async {
final challenge = await endpoints.password.beginChange(onPhone());
@ -344,6 +356,7 @@ void main() {
? null
: totpCode ?? codeOf(enrolment, at: now),
recoveryKeys: recoveryKeys ?? fresh,
recoveryProofKey: recoveryProofKey ?? freshProofKey,
);
});
@ -353,11 +366,41 @@ void main() {
address: 'anna@example.org',
)).recoveryKeys;
test('takes new wrapped keys with the old password and a code: the '
"old phrase's wrapping goes", () async {
/// Whether a reset of the password with the phrase takes [keys]'
/// signature as its proof.
Future<bool> proves(SimpleKeyPair keys) async {
final proof = await endpoints.accountRecovery.beginKeyProof(
notSignedIn(),
address: 'anna@example.org',
);
nextStep();
final signature = await device.sign(proof, keys: keys);
try {
await atNow(
() => endpoints.accountRecovery.resetPassword(
notSignedIn(),
challenge: proof.challenge,
signature: signature,
password: device.newPassword(),
totpCode: codeOf(enrolment, at: now),
),
);
return true;
} on PasswordRefusedException catch (refusal) {
expect(refusal.reason, PasswordRefusal.wrongCredentials);
return false;
}
}
test('takes new wrapped keys and a proof key with the old password '
"and a code: the old phrase's wrapping goes, and its proof key "
'proves nothing any more', () async {
final old = device.proofKeys;
await newPhrase();
expect((await wrapping()).toJson(), fresh.toJson());
expect(await proves(old), isFalse);
expect(await proves(freshProof), isTrue);
});
test("is refused alike without the password's proof, for a wrong "
@ -405,6 +448,10 @@ void main() {
),
refused(PasswordRefusal.invalidSetup),
);
await expectLater(
newPhrase(recoveryProofKey: ByteData(31)),
refused(PasswordRefusal.invalidSetup),
);
expect(
(await wrapping()).toJson(),

View file

@ -1399,6 +1399,7 @@ void main() {
String? totpCode,
NewPassword? password,
bool otherKey = false,
bool accountsKey = false,
}) => atNow(() async {
final challenge = await endpoints.accountRecovery.beginKeyProof(
notSignedIn(),
@ -1409,7 +1410,11 @@ void main() {
challenge: challenge.challenge,
signature: await device.sign(
challenge,
keys: otherKey ? await otherSigningKeys() : null,
keys: otherKey
? await otherSigningKeys()
: accountsKey
? device.signingKeys
: null,
),
password: password ?? device.newPassword(),
totpCode: totpCode,
@ -1573,6 +1578,45 @@ void main() {
);
});
test("takes no signature by the account's own key: every signed-in "
'device keeps it, and with a code it would set a password '
"without the phrase's holder", () async {
final complete = await annaComplete();
final kept = (await AccountPassword.db.findFirstRow(session))!;
await expectLater(
resetWithPhrase(
accountsKey: true,
totpCode: codeOf(complete.enrolment, at: now),
),
refused(PasswordRefusal.wrongCredentials),
);
expect(
(await AccountPassword.db.findFirstRow(session))!.toJson(),
kept.toJson(),
);
expect(
Uint8List.sublistView(kept.recoveryProofKey!),
device.recoveryProofKey,
);
});
test('proves nothing for an account that keeps no proof key (a '
'password set up before it was kept), as for nobody', () async {
final complete = await annaComplete();
// UPDATE "account_password" SET "recoveryProofKey" = NULL
await AccountPassword.db.updateWhere(
session,
columnValues: (t) => [t.recoveryProofKey(null)],
where: (t) => t.authUserId.equals(anna),
);
await expectLater(
resetWithPhrase(totpCode: codeOf(complete.enrolment, at: now)),
refused(PasswordRefusal.wrongCredentials),
);
});
test('counts at most 5 proofs begun for an address an hour', () async {
for (var i = 0; i < 5; i++) {
await atNow(

View file

@ -413,6 +413,7 @@ final class _Register implements AccountRegister {
String? totpCode,
String? recoveryCode,
required WrappedKeys recoveryKeys,
required ByteData recoveryProofKey,
}) => _answer('setRecoveryKeys', () {});
@override

View file

@ -754,6 +754,9 @@ void main() {
clientProof: answer.clientProof,
totpCode: codeOf(enrolment),
recoveryKeys: fresh,
recoveryProofKey: ByteData.sublistView(
device.recoveryProofKey,
),
);
}

View file

@ -15,13 +15,18 @@ const testPasswordKeys = (
);
/// A device with an account's password, as halkyon_keys makes one, without
/// libsodium: SRP's x and the account's keys random, the signing key a real
/// Ed25519 pair, so it can prove the recovery phrase. What the register
/// checks is all here; what only a device opens (the wrapped keys) is
/// random bytes of the right length.
/// libsodium: SRP's x and the account's keys random, the signing key and the
/// recovery phrase's proof key real Ed25519 pairs, so it can sign and prove
/// the phrase. What the register checks is all here; what only a device
/// opens (the wrapped keys) is random bytes of the right length.
final class PasswordDevice {
PasswordDevice._(this._random, this.signingKeys, this.signingPublicKey)
: salt = _bytes(_random, 16),
PasswordDevice._(
this._random,
this.signingKeys,
this.signingPublicKey,
this.proofKeys,
this.recoveryProofKey,
) : salt = _bytes(_random, 16),
privateValue = numberOf(_bytes(_random, 32)),
encryptionPublicKey = _bytes(_random, 32);
@ -30,16 +35,29 @@ final class PasswordDevice {
final random = Random(seed);
final keys = await Ed25519().newKeyPairFromSeed(_bytes(random, 32));
final public = await keys.extractPublicKey();
return PasswordDevice._(random, keys, Uint8List.fromList(public.bytes));
final proof = await Ed25519().newKeyPairFromSeed(_bytes(random, 32));
final proofPublic = await proof.extractPublicKey();
return PasswordDevice._(
random,
keys,
Uint8List.fromList(public.bytes),
proof,
Uint8List.fromList(proofPublic.bytes),
);
}
final Random _random;
/// The account's signing key pair, which the recovery phrase opens.
/// The account's signing key pair, which the password and the recovery
/// phrase open, and every signed-in device keeps.
final SimpleKeyPair signingKeys;
final Uint8List signingPublicKey;
final Uint8List encryptionPublicKey;
/// The recovery phrase's own proof key, from the phrase alone.
SimpleKeyPair proofKeys;
Uint8List recoveryProofKey;
/// The password's salt and x, as Argon2id would make them.
Uint8List salt;
BigInt privateValue;
@ -72,8 +90,23 @@ final class PasswordDevice {
signingPublicKey: ByteData.sublistView(signingPublicKey),
passwordKeys: passwordKeys,
recoveryKeys: recoveryKeys,
recoveryProofKey: ByteData.sublistView(recoveryProofKey),
);
/// A new recovery phrase for the same keys: its wrapping of them and its
/// proof key, which become the device's.
Future<({WrappedKeys recoveryKeys, ByteData recoveryProofKey})>
newPhrase() async {
proofKeys = await Ed25519().newKeyPairFromSeed(_bytes(_random, 32));
recoveryProofKey = Uint8List.fromList(
(await proofKeys.extractPublicKey()).bytes,
);
return (
recoveryKeys: _wrapped(),
recoveryProofKey: ByteData.sublistView(recoveryProofKey),
);
}
/// A new password for the same keys: the device's salt and x change.
NewPassword newPassword() {
salt = _bytes(_random, 16);
@ -113,8 +146,8 @@ final class PasswordDevice {
);
}
/// The signature proving the recovery phrase for [challenge]: the
/// account's own, or [keys]' where given.
/// The signature proving the recovery phrase for [challenge]: by the
/// phrase's proof key, or [keys]' where given.
Future<ByteData> sign(
KeyProofChallenge challenge, {
SimpleKeyPair? keys,
@ -124,7 +157,7 @@ final class PasswordDevice {
challenge.challenge,
Uint8List.sublistView(challenge.nonce),
),
keyPair: keys ?? signingKeys,
keyPair: keys ?? proofKeys,
);
return ByteData.sublistView(Uint8List.fromList(signature.bytes));
}

View file

@ -1071,6 +1071,7 @@ class _PasswordEndpoint {
String? totpCode,
String? recoveryCode,
required _iji0wmu8.WrappedKeys recoveryKeys,
required _idt.ByteData recoveryProofKey,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
@ -1090,6 +1091,7 @@ class _PasswordEndpoint {
'totpCode': totpCode,
'recoveryCode': recoveryCode,
'recoveryKeys': recoveryKeys,
'recoveryProofKey': recoveryProofKey,
}),
serializationManager: _serializationManager,
);