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
6 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(
|
||||
|
|
|
|||
|
|
@ -413,6 +413,7 @@ final class _Register implements AccountRegister {
|
|||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required WrappedKeys recoveryKeys,
|
||||
required ByteData recoveryProofKey,
|
||||
}) => _answer('setRecoveryKeys', () {});
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -754,6 +754,9 @@ void main() {
|
|||
clientProof: answer.clientProof,
|
||||
totpCode: codeOf(enrolment),
|
||||
recoveryKeys: fresh,
|
||||
recoveryProofKey: ByteData.sublistView(
|
||||
device.recoveryProofKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue