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
1 changed files with 40952 additions and 93 deletions
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue