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
7 changed files with 40952 additions and 93 deletions
|
|
@ -52,6 +52,15 @@ final class const NewAccountMaterial({
|
|||
|
||||
/// The device's answer to SRP's first step: A and M1 for the register, and
|
||||
/// whether the register's M2 is the one that proves it holds the verifier.
|
||||
/// A new recovery phrase made on the device ([AccountCrypto.newPhrase]): the
|
||||
/// phrase to show, the account's keys wrapped under it, and the public half
|
||||
/// of its proof key, which the register keeps.
|
||||
typedef NewPhraseMaterial = ({
|
||||
String phrase,
|
||||
protocol.WrappedKeys recoveryKeys,
|
||||
ByteData recoveryProofKey,
|
||||
});
|
||||
|
||||
final class const SrpAnswer({
|
||||
required final ByteData clientValue,
|
||||
required final ByteData clientProof,
|
||||
|
|
@ -118,14 +127,17 @@ abstract interface class AccountCrypto() {
|
|||
required Uint8List signingPublicKey,
|
||||
});
|
||||
|
||||
/// [keys]' signature proving the recovery phrase for [challenge].
|
||||
/// The signature proving the recovery [phrase] for [challenge]: by the
|
||||
/// phrase's own proof key, derived from it alone while it is typed and
|
||||
/// kept nowhere, never the account's signing key, which every signed-in
|
||||
/// device keeps. Refused (`PasswordProblem.notAPhrase`) for what is no
|
||||
/// recovery phrase.
|
||||
Future<ByteData> signKeyProof(
|
||||
UnlockedAccountKeys keys,
|
||||
String phrase,
|
||||
protocol.KeyProofChallenge challenge,
|
||||
);
|
||||
|
||||
/// A new recovery phrase for [keys], and the keys wrapped under it.
|
||||
Future<({String phrase, protocol.WrappedKeys recoveryKeys})> newPhrase(
|
||||
UnlockedAccountKeys keys,
|
||||
);
|
||||
/// A new recovery phrase for [keys]: the keys wrapped under it, and the
|
||||
/// public half of its proof key.
|
||||
Future<NewPhraseMaterial> newPhrase(UnlockedAccountKeys keys);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,15 +104,16 @@ abstract interface class PasswordGateway() {
|
|||
required protocol.NewPassword password,
|
||||
});
|
||||
|
||||
/// A new recovery phrase's wrapping of the keys: [answer] to [challenge]
|
||||
/// ([beginChange]) proves the password, with a [totpCode] or
|
||||
/// [recoveryCode].
|
||||
/// A new recovery phrase's wrapping of the keys and its [proofKey]'s
|
||||
/// public half: [answer] to [challenge] ([beginChange]) proves the
|
||||
/// password, with a [totpCode] or [recoveryCode].
|
||||
Future<void> setRecoveryKeys(
|
||||
protocol.PasswordChallenge challenge,
|
||||
SrpAnswer answer, {
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required protocol.WrappedKeys keys,
|
||||
required ByteData proofKey,
|
||||
});
|
||||
|
||||
/// Asks for the reset of a lost authenticator: [answer] to [challenge]
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ final class const PendingSignIn({
|
|||
final class const NewPhrase(
|
||||
final String phrase,
|
||||
final protocol.WrappedKeys _recoveryKeys,
|
||||
final ByteData _recoveryProofKey,
|
||||
) {
|
||||
@override
|
||||
String toString() => 'NewPhrase(…)';
|
||||
|
|
@ -340,7 +341,7 @@ class PasswordRepository(
|
|||
/// instance takes it with [keepPhrase].
|
||||
Future<NewPhrase> newPhrase() async {
|
||||
final made = await _crypto.newPhrase(await _keysHere());
|
||||
return NewPhrase(made.phrase, made.recoveryKeys);
|
||||
return NewPhrase(made.phrase, made.recoveryKeys, made.recoveryProofKey);
|
||||
}
|
||||
|
||||
/// [phrase] in place of the old recovery phrase, with the [password] and
|
||||
|
|
@ -364,6 +365,7 @@ class PasswordRepository(
|
|||
await _crypto.answer(unlock, challenge),
|
||||
totpCode: code.replaceAll(' ', ''),
|
||||
keys: phrase._recoveryKeys,
|
||||
proofKey: phrase._recoveryProofKey,
|
||||
);
|
||||
_changed();
|
||||
}
|
||||
|
|
@ -385,8 +387,10 @@ class PasswordRepository(
|
|||
ByteData? signature;
|
||||
if (phrase != null) {
|
||||
keyChallenge = await _gateway.beginKeyProof(unlocked.address);
|
||||
final keys = await _openWithPhrase(keyChallenge, phrase);
|
||||
signature = await _crypto.signKeyProof(keys, keyChallenge);
|
||||
// Opened only to say here, rather than on the instance, that the
|
||||
// phrase doesn't fit.
|
||||
await _openWithPhrase(keyChallenge, phrase);
|
||||
signature = await _crypto.signKeyProof(phrase, keyChallenge);
|
||||
}
|
||||
final challenge = await _challengeOf(unlocked);
|
||||
final answer = await _crypto.answer(unlocked._unlock, challenge);
|
||||
|
|
@ -415,7 +419,7 @@ class PasswordRepository(
|
|||
final typed = address.trim();
|
||||
final challenge = await _gateway.beginKeyProof(typed);
|
||||
final keys = await _openWithPhrase(challenge, phrase);
|
||||
final signature = await _crypto.signKeyProof(keys, challenge);
|
||||
final signature = await _crypto.signKeyProof(phrase, challenge);
|
||||
final grant = await _gateway.resetPassword(
|
||||
challenge,
|
||||
signature,
|
||||
|
|
|
|||
|
|
@ -196,6 +196,7 @@ class ServerpodPasswordGateway(
|
|||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required protocol.WrappedKeys keys,
|
||||
required ByteData proofKey,
|
||||
}) => _failing(
|
||||
() => _base.password.setRecoveryKeys(
|
||||
challenge: challenge.challenge,
|
||||
|
|
@ -204,6 +205,7 @@ class ServerpodPasswordGateway(
|
|||
totpCode: totpCode,
|
||||
recoveryCode: recoveryCode,
|
||||
recoveryKeys: keys,
|
||||
recoveryProofKey: proofKey,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -204,12 +204,17 @@ class SodiumAccountCrypto() implements AccountCrypto {
|
|||
|
||||
@override
|
||||
Future<ByteData> signKeyProof(
|
||||
UnlockedAccountKeys keys,
|
||||
String phrase,
|
||||
protocol.KeyProofChallenge challenge,
|
||||
) => Isolate.run(
|
||||
() => _guarded(() async {
|
||||
final sodium = await SodiumSumoInit.init();
|
||||
final opened = _keysOf(sodium, keys);
|
||||
final KeyPair proof;
|
||||
try {
|
||||
proof = recoveryProofKeysOf(sodium, phrase);
|
||||
} on KeyRefused {
|
||||
throw const PasswordFailed(PasswordProblem.notAPhrase);
|
||||
}
|
||||
try {
|
||||
return ByteData.sublistView(
|
||||
_own(
|
||||
|
|
@ -218,20 +223,18 @@ class SodiumAccountCrypto() implements AccountCrypto {
|
|||
challenge.challenge.toBytes(),
|
||||
Uint8List.sublistView(challenge.nonce),
|
||||
),
|
||||
secretKey: opened.signing.secretKey,
|
||||
secretKey: proof.secretKey,
|
||||
),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
opened.dispose();
|
||||
proof.secretKey.dispose();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<({String phrase, protocol.WrappedKeys recoveryKeys})> newPhrase(
|
||||
UnlockedAccountKeys keys,
|
||||
) => Isolate.run(
|
||||
Future<NewPhraseMaterial> newPhrase(UnlockedAccountKeys keys) => Isolate.run(
|
||||
() => _guarded(() async {
|
||||
final sodium = await SodiumSumoInit.init();
|
||||
final opened = _keysOf(sodium, keys);
|
||||
|
|
@ -241,6 +244,9 @@ class SodiumAccountCrypto() implements AccountCrypto {
|
|||
return (
|
||||
phrase: phrase,
|
||||
recoveryKeys: _wrappedOf(wrapAccountKeys(sodium, opened, key)),
|
||||
recoveryProofKey: ByteData.sublistView(
|
||||
recoveryProofPublicKeyOf(sodium, phrase),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
key.dispose();
|
||||
|
|
@ -307,6 +313,10 @@ NewAccountMaterial _material(
|
|||
wrapAccountKeys(sodium, keys, unlocked.keyEncryptionKey),
|
||||
),
|
||||
recoveryKeys: _wrappedOf(wrapAccountKeys(sodium, keys, recoveryKey)),
|
||||
// Copied by recoveryProofPublicKeyOf already.
|
||||
recoveryProofKey: ByteData.sublistView(
|
||||
recoveryProofPublicKeyOf(sodium, phrase),
|
||||
),
|
||||
),
|
||||
phrase: phrase,
|
||||
keys: _unlockedOf(keys),
|
||||
|
|
|
|||
|
|
@ -42,6 +42,30 @@ class FakeAccountCrypto() implements AccountCrypto {
|
|||
),
|
||||
);
|
||||
|
||||
/// The public half of [phrase]'s proof key, as the fake makes it.
|
||||
static ByteData proofKeyOf(String phrase) => ByteData.sublistView(
|
||||
utf8.encode('proof key of ${_words(phrase)}'),
|
||||
);
|
||||
|
||||
/// The signature of [phrase]'s proof key, as the fake makes it.
|
||||
static ByteData signatureOf(String phrase) =>
|
||||
ByteData.sublistView(utf8.encode('signed by ${_words(phrase)}'));
|
||||
|
||||
/// Whether [signature] is by the proof key whose public half is
|
||||
/// [proofKey].
|
||||
static bool proves(ByteData signature, ByteData? proofKey) {
|
||||
if (proofKey == null) {
|
||||
return false;
|
||||
}
|
||||
final signed = utf8.decode(Uint8List.sublistView(signature));
|
||||
final key = utf8.decode(Uint8List.sublistView(proofKey));
|
||||
return signed.startsWith('signed by ') &&
|
||||
key == 'proof key of ${signed.substring('signed by '.length)}';
|
||||
}
|
||||
|
||||
static String _words(String phrase) =>
|
||||
phrase.trim().toLowerCase().split(RegExp(r'\s+')).join(' ');
|
||||
|
||||
static UnlockedAccountKeys _open(
|
||||
String opener,
|
||||
protocol.WrappedKeys wrapped,
|
||||
|
|
@ -81,6 +105,7 @@ class FakeAccountCrypto() implements AccountCrypto {
|
|||
signingPublicKey: ByteData.sublistView(keys.signingPublicKey),
|
||||
passwordKeys: wrap(password, keys),
|
||||
recoveryKeys: wrap(phrase, keys),
|
||||
recoveryProofKey: proofKeyOf(phrase),
|
||||
),
|
||||
phrase: phrase,
|
||||
keys: keys,
|
||||
|
|
@ -174,13 +199,19 @@ class FakeAccountCrypto() implements AccountCrypto {
|
|||
|
||||
@override
|
||||
Future<ByteData> signKeyProof(
|
||||
UnlockedAccountKeys keys,
|
||||
String phrase,
|
||||
protocol.KeyProofChallenge challenge,
|
||||
) async => ByteData.sublistView(keys.signingSecretKey);
|
||||
) async {
|
||||
if (phrase.trim().split(RegExp(r'\s+')).length != 24) {
|
||||
throw const PasswordFailed(PasswordProblem.notAPhrase);
|
||||
}
|
||||
return signatureOf(phrase);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<({String phrase, protocol.WrappedKeys recoveryKeys})> newPhrase(
|
||||
UnlockedAccountKeys keys,
|
||||
) async =>
|
||||
(phrase: otherFakePhrase, recoveryKeys: wrap(otherFakePhrase, keys));
|
||||
Future<NewPhraseMaterial> newPhrase(UnlockedAccountKeys keys) async => (
|
||||
phrase: otherFakePhrase,
|
||||
recoveryKeys: wrap(otherFakePhrase, keys),
|
||||
recoveryProofKey: proofKeyOf(otherFakePhrase),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ final class FakePasswordAccount(
|
|||
String? password;
|
||||
protocol.WrappedKeys? passwordKeys;
|
||||
protocol.WrappedKeys? recoveryKeys;
|
||||
|
||||
/// The public half of the recovery phrase's proof key.
|
||||
ByteData? recoveryProofKey;
|
||||
ByteData encryptionPublicKey = ByteData(32);
|
||||
ByteData signingPublicKey = ByteData(32);
|
||||
final authenticators = <AccountAuthenticator>[];
|
||||
|
|
@ -105,6 +108,7 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
..password = password
|
||||
..passwordKeys = FakeAccountCrypto.wrap(password, keys)
|
||||
..recoveryKeys = FakeAccountCrypto.wrap(phrase, keys)
|
||||
..recoveryProofKey = FakeAccountCrypto.proofKeyOf(phrase)
|
||||
..encryptionPublicKey = ByteData.sublistView(keys.encryptionPublicKey)
|
||||
..signingPublicKey = ByteData.sublistView(keys.signingPublicKey);
|
||||
if (finished) {
|
||||
|
|
@ -314,6 +318,7 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
..password = utf8.decode(Uint8List.sublistView(setup.verifier))
|
||||
..passwordKeys = setup.passwordKeys
|
||||
..recoveryKeys = setup.recoveryKeys
|
||||
..recoveryProofKey = setup.recoveryProofKey
|
||||
..encryptionPublicKey = setup.encryptionPublicKey
|
||||
..signingPublicKey = setup.signingPublicKey;
|
||||
}
|
||||
|
|
@ -532,8 +537,11 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required protocol.WrappedKeys keys,
|
||||
required ByteData proofKey,
|
||||
}) => _call('setRecoveryKeys', () {
|
||||
_ownerProved(challenge, answer, totpCode, recoveryCode).recoveryKeys = keys;
|
||||
_ownerProved(challenge, answer, totpCode, recoveryCode)
|
||||
..recoveryKeys = keys
|
||||
..recoveryProofKey = proofKey;
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -552,7 +560,10 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
if (_proven(request, code) != account.address) {
|
||||
throw const PasswordFailed(PasswordProblem.wrongCredentials);
|
||||
}
|
||||
} else if (signature == null) {
|
||||
} else if (keyChallenge == null ||
|
||||
signature == null ||
|
||||
accounts[_keyProofs.remove(keyChallenge.challenge.uuid)] != account ||
|
||||
!FakeAccountCrypto.proves(signature, account.recoveryProofKey)) {
|
||||
throw const PasswordFailed(PasswordProblem.wrongCredentials);
|
||||
}
|
||||
return account.authenticatorReset ??= clock.now().add(fakeResetDelay);
|
||||
|
|
@ -592,13 +603,14 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
}) => _call('resetPassword', () {
|
||||
// The phrase opened the keys on the device (the fake crypto refuses a
|
||||
// wrong one); the challenge names the account.
|
||||
// The challenge names the account, whose phrase's proof key must have
|
||||
// signed: never the account's own keys.
|
||||
final account = accounts[_keyProofs.remove(challenge.challenge.uuid)];
|
||||
if (account?.password == null) {
|
||||
if (account?.password == null ||
|
||||
!FakeAccountCrypto.proves(signature, account!.recoveryProofKey)) {
|
||||
throw const PasswordFailed(PasswordProblem.wrongCredentials);
|
||||
}
|
||||
_secondFactor(account!, totpCode: totpCode, recoveryCode: recoveryCode);
|
||||
_secondFactor(account, totpCode: totpCode, recoveryCode: recoveryCode);
|
||||
account
|
||||
..password = utf8.decode(Uint8List.sublistView(password.verifier))
|
||||
..passwordKeys = password.passwordKeys;
|
||||
|
|
|
|||
Loading…
Reference in a new issue