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:
parent
38bb090575
commit
aea0c447fc
2 changed files with 40952 additions and 93 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue