fix(auth)!: proofs are bound to the instance the device addresses
Nothing tied a sign-in's proof to the instance it was made for: an instance a person was lured into adding could pass A, M1 and the code on to the real register and get a session there, and pass a phrase's proof on with a password of its own in place of the person's (the second reading's M6). SRP's identity is now a label, the challenge's ID and the host the device addressed (passwordIdentity, in halkyon_base_client and halkyon_base_server alike), and the phrase's proof signs that host, what it is for (an authenticator's reset or a new password) and the new password's digest (key proof v2). Every proving call carries the host; in production it must be the server's apiServer.publicHost (otherwise otherInstance, which the app shows as an instance it can't trust), in development any; a member forwards its own. The tests prove that a relayed sign-in, a proof for another host or purpose, and a swapped password are refused, nothing changing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
009970faab
commit
dab2c80524
8 changed files with 966 additions and 137 deletions
|
|
@ -96,11 +96,13 @@ abstract interface class AccountCrypto() {
|
|||
});
|
||||
|
||||
/// The answer to [challenge] (SRP's B and the account's salt) with what
|
||||
/// [unlock] holds.
|
||||
/// [unlock] holds, its identity bound to [host], the instance as the
|
||||
/// device addresses it.
|
||||
Future<SrpAnswer> answer(
|
||||
PasswordUnlock unlock,
|
||||
protocol.PasswordChallenge challenge,
|
||||
);
|
||||
protocol.PasswordChallenge challenge, {
|
||||
required String host,
|
||||
});
|
||||
|
||||
/// The account's keys [wrapped] under the password, opened with
|
||||
/// [unlock]'s key and checked against its public keys.
|
||||
|
|
@ -127,15 +129,18 @@ abstract interface class AccountCrypto() {
|
|||
required Uint8List signingPublicKey,
|
||||
});
|
||||
|
||||
/// 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.
|
||||
/// The signature proving the recovery [phrase] for [challenge] on
|
||||
/// [host], for the new [password] it sets (or, without one, for an
|
||||
/// authenticator's reset): 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(
|
||||
String phrase,
|
||||
protocol.KeyProofChallenge challenge,
|
||||
);
|
||||
protocol.KeyProofChallenge challenge, {
|
||||
required String host,
|
||||
protocol.NewPassword? password,
|
||||
});
|
||||
|
||||
/// A new recovery phrase for [keys]: the keys wrapped under it, and the
|
||||
/// public half of its proof key.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ typedef PasswordWays = ({
|
|||
/// up before a new sign-in is put in place). Every refusal is a
|
||||
/// `PasswordFailed`. Tests replace it with a fake.
|
||||
abstract interface class PasswordGateway() {
|
||||
/// The host the device addresses the instance by, which SRP's identity
|
||||
/// and the recovery phrase's proofs are bound to (ADR 0017): a hostile
|
||||
/// instance relaying them to the real one gets nothing the real one
|
||||
/// takes.
|
||||
String get host;
|
||||
|
||||
/// Whether the instance offers password sign-in, and until when GitHub
|
||||
/// sign-in lasts there (null for no end set).
|
||||
Future<({bool offered, DateTime? gitHubEnds})> offers();
|
||||
|
|
|
|||
|
|
@ -146,7 +146,11 @@ class PasswordRepository(
|
|||
String? code,
|
||||
}) async {
|
||||
final challenge = await _challengeOf(unlocked);
|
||||
final answer = await _crypto.answer(unlocked._unlock, challenge);
|
||||
final answer = await _crypto.answer(
|
||||
unlocked._unlock,
|
||||
challenge,
|
||||
host: _gateway.host,
|
||||
);
|
||||
final signedIn = await _gateway.complete(
|
||||
challenge,
|
||||
answer,
|
||||
|
|
@ -326,7 +330,7 @@ class PasswordRepository(
|
|||
salt: Uint8List.sublistView(challenge.salt),
|
||||
costs: challenge.costs,
|
||||
);
|
||||
final answer = await _crypto.answer(unlock, challenge);
|
||||
final answer = await _crypto.answer(unlock, challenge, host: _gateway.host);
|
||||
await _gateway.change(
|
||||
challenge,
|
||||
answer,
|
||||
|
|
@ -362,7 +366,7 @@ class PasswordRepository(
|
|||
);
|
||||
await _gateway.setRecoveryKeys(
|
||||
challenge,
|
||||
await _crypto.answer(unlock, challenge),
|
||||
await _crypto.answer(unlock, challenge, host: _gateway.host),
|
||||
totpCode: code.replaceAll(' ', ''),
|
||||
keys: phrase._recoveryKeys,
|
||||
proofKey: phrase._recoveryProofKey,
|
||||
|
|
@ -390,10 +394,18 @@ class PasswordRepository(
|
|||
// 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);
|
||||
signature = await _crypto.signKeyProof(
|
||||
phrase,
|
||||
keyChallenge,
|
||||
host: _gateway.host,
|
||||
);
|
||||
}
|
||||
final challenge = await _challengeOf(unlocked);
|
||||
final answer = await _crypto.answer(unlocked._unlock, challenge);
|
||||
final answer = await _crypto.answer(
|
||||
unlocked._unlock,
|
||||
challenge,
|
||||
host: _gateway.host,
|
||||
);
|
||||
return await _gateway.resetAuthenticator(
|
||||
challenge,
|
||||
answer,
|
||||
|
|
@ -419,11 +431,19 @@ class PasswordRepository(
|
|||
final typed = address.trim();
|
||||
final challenge = await _gateway.beginKeyProof(typed);
|
||||
final keys = await _openWithPhrase(challenge, phrase);
|
||||
final signature = await _crypto.signKeyProof(phrase, challenge);
|
||||
final password = await _crypto.newPassword(newPassword, keys);
|
||||
// The proof signs this password and this instance's host: nobody
|
||||
// between swaps in another, or relays it elsewhere.
|
||||
final signature = await _crypto.signKeyProof(
|
||||
phrase,
|
||||
challenge,
|
||||
host: _gateway.host,
|
||||
password: password,
|
||||
);
|
||||
final grant = await _gateway.resetPassword(
|
||||
challenge,
|
||||
signature,
|
||||
password: await _crypto.newPassword(newPassword, keys),
|
||||
password: password,
|
||||
totpCode: totpCode?.replaceAll(' ', ''),
|
||||
recoveryCode: recoveryCode,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ class ServerpodPasswordGateway(
|
|||
/// What the clients it makes for itself send through; tests give a fake.
|
||||
final http.Client Function()? _httpClient,
|
||||
}) implements PasswordGateway {
|
||||
@override
|
||||
String get host => Uri.parse(_client.host).host;
|
||||
|
||||
@override
|
||||
Future<({bool offered, DateTime? gitHubEnds})> offers() => _failing(() async {
|
||||
final basics = await _base.instance.basics();
|
||||
|
|
@ -66,6 +69,7 @@ class ServerpodPasswordGateway(
|
|||
recoveryCode: recoveryCode,
|
||||
request: _idOf(request),
|
||||
code: code,
|
||||
host: host,
|
||||
);
|
||||
return PasswordSignedIn(
|
||||
grant: _grantOf(
|
||||
|
|
@ -186,6 +190,7 @@ class ServerpodPasswordGateway(
|
|||
totpCode: totpCode,
|
||||
recoveryCode: recoveryCode,
|
||||
password: password,
|
||||
host: host,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -206,6 +211,7 @@ class ServerpodPasswordGateway(
|
|||
recoveryCode: recoveryCode,
|
||||
recoveryKeys: keys,
|
||||
recoveryProofKey: proofKey,
|
||||
host: host,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -230,6 +236,7 @@ class ServerpodPasswordGateway(
|
|||
code: code,
|
||||
keyChallenge: keyChallenge?.challenge,
|
||||
signature: signature,
|
||||
host: host,
|
||||
)).takesEffectAt,
|
||||
);
|
||||
|
||||
|
|
@ -254,6 +261,7 @@ class ServerpodPasswordGateway(
|
|||
password: password,
|
||||
totpCode: totpCode,
|
||||
recoveryCode: recoveryCode,
|
||||
host: host,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -390,6 +398,10 @@ PasswordProblem _problemOf(protocol.PasswordRefusal reason) => switch (reason) {
|
|||
protocol.PasswordRefusal.noReset => PasswordProblem.noReset,
|
||||
protocol.PasswordRefusal.wrongExistingCode =>
|
||||
PasswordProblem.wrongExistingCode,
|
||||
// The instance answers by another name than the device addressed it by:
|
||||
// what the device proved is bound to that name, so nothing it says can
|
||||
// be taken as the instance's.
|
||||
protocol.PasswordRefusal.otherInstance => PasswordProblem.instanceUnproven,
|
||||
};
|
||||
|
||||
/// Keeps one session in memory, for a client used once.
|
||||
|
|
|
|||
|
|
@ -85,13 +85,14 @@ class SodiumAccountCrypto() implements AccountCrypto {
|
|||
@override
|
||||
Future<SrpAnswer> answer(
|
||||
PasswordUnlock unlock,
|
||||
protocol.PasswordChallenge challenge,
|
||||
) async {
|
||||
protocol.PasswordChallenge challenge, {
|
||||
required String host,
|
||||
}) async {
|
||||
final group = srp.SrpGroup.rfc5054Group3072;
|
||||
final (:clientValue, :proof) = await Isolate.run(() {
|
||||
final client = srp.SrpClient(group);
|
||||
final step = client.respond(
|
||||
identity: challenge.challenge.toBytes(),
|
||||
identity: srp.passwordIdentity(challenge.challenge.toBytes(), host),
|
||||
salt: Uint8List.sublistView(challenge.salt),
|
||||
privateValue: _numberOf(unlock.srpSecret),
|
||||
serverValue: _numberOf(Uint8List.sublistView(challenge.serverValue)),
|
||||
|
|
@ -205,8 +206,10 @@ class SodiumAccountCrypto() implements AccountCrypto {
|
|||
@override
|
||||
Future<ByteData> signKeyProof(
|
||||
String phrase,
|
||||
protocol.KeyProofChallenge challenge,
|
||||
) => Isolate.run(
|
||||
protocol.KeyProofChallenge challenge, {
|
||||
required String host,
|
||||
protocol.NewPassword? password,
|
||||
}) => Isolate.run(
|
||||
() => _guarded(() async {
|
||||
final sodium = await SodiumSumoInit.init();
|
||||
final KeyPair proof;
|
||||
|
|
@ -222,6 +225,11 @@ class SodiumAccountCrypto() implements AccountCrypto {
|
|||
message: srp.keyProofMessage(
|
||||
challenge.challenge.toBytes(),
|
||||
Uint8List.sublistView(challenge.nonce),
|
||||
host: host,
|
||||
purpose: password == null
|
||||
? srp.KeyProofPurpose.authenticatorReset
|
||||
: srp.KeyProofPurpose.passwordReset,
|
||||
digest: password == null ? const [] : _digestOf(password),
|
||||
),
|
||||
secretKey: proof.secretKey,
|
||||
),
|
||||
|
|
@ -256,6 +264,17 @@ class SodiumAccountCrypto() implements AccountCrypto {
|
|||
);
|
||||
}
|
||||
|
||||
/// [password]'s digest, which a phrase's proof for it signs.
|
||||
Uint8List _digestOf(protocol.NewPassword password) => srp.newPasswordDigest(
|
||||
salt: Uint8List.sublistView(password.salt),
|
||||
algorithm: password.costs.algorithm,
|
||||
opsLimit: password.costs.opsLimit,
|
||||
memLimit: password.costs.memLimit,
|
||||
verifier: Uint8List.sublistView(password.verifier),
|
||||
keysNonce: Uint8List.sublistView(password.passwordKeys.nonce),
|
||||
keysCipherText: Uint8List.sublistView(password.passwordKeys.cipherText),
|
||||
);
|
||||
|
||||
/// Argon2id's [costs] as an instance named them, refused
|
||||
/// ([PasswordProblem.unsafeCosts]) outside the bounds every account is
|
||||
/// within, before anything is derived: below them guessing the password
|
||||
|
|
|
|||
|
|
@ -145,8 +145,9 @@ class FakeAccountCrypto() implements AccountCrypto {
|
|||
@override
|
||||
Future<SrpAnswer> answer(
|
||||
PasswordUnlock unlock,
|
||||
protocol.PasswordChallenge challenge,
|
||||
) async => SrpAnswer(
|
||||
protocol.PasswordChallenge challenge, {
|
||||
required String host,
|
||||
}) async => SrpAnswer(
|
||||
clientValue: ByteData(384),
|
||||
clientProof: ByteData.sublistView(unlock.srpSecret),
|
||||
verifies: (serverProof) =>
|
||||
|
|
@ -200,8 +201,10 @@ class FakeAccountCrypto() implements AccountCrypto {
|
|||
@override
|
||||
Future<ByteData> signKeyProof(
|
||||
String phrase,
|
||||
protocol.KeyProofChallenge challenge,
|
||||
) async {
|
||||
protocol.KeyProofChallenge challenge, {
|
||||
required String host,
|
||||
protocol.NewPassword? password,
|
||||
}) async {
|
||||
if (phrase.trim().split(RegExp(r'\s+')).length != 24) {
|
||||
throw const PasswordFailed(PasswordProblem.notAPhrase);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ final class FakePasswordAccount(
|
|||
/// passwords proved by [FakeAccountCrypto]'s answers. The signed-in person
|
||||
/// is [_auth]'s. [failing] makes every call fail as without a connection.
|
||||
class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
||||
@override
|
||||
String get host => 'halkyon.example';
|
||||
|
||||
/// Whether the instance offers password sign-in.
|
||||
bool offered = true;
|
||||
|
||||
|
|
|
|||
|
|
@ -161,48 +161,83 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test("signs the key proof with the phrase's own proof key, whose public "
|
||||
"half the setup carries: never the account's key, which a signed-in "
|
||||
'device keeps', () async {
|
||||
final challenge = protocol.KeyProofChallenge(
|
||||
challenge: protocol.UuidValue.fromString(
|
||||
'01994a3c-7b2e-7c00-8a1b-0123456789ab',
|
||||
),
|
||||
nonce: ByteData.sublistView(Uint8List.fromList(List.filled(32, 9))),
|
||||
encryptionPublicKey: ByteData.sublistView(
|
||||
account.keys.encryptionPublicKey,
|
||||
),
|
||||
signingPublicKey: ByteData.sublistView(account.keys.signingPublicKey),
|
||||
recoveryKeys: account.setup.recoveryKeys,
|
||||
);
|
||||
|
||||
final signature = await crypto.signKeyProof(account.phrase, challenge);
|
||||
|
||||
final sodium = await SodiumSumoInit.init();
|
||||
bool verifies(Uint8List publicKey) => sodium.crypto.sign.verifyDetached(
|
||||
message: srp.keyProofMessage(
|
||||
challenge.challenge.toBytes(),
|
||||
List.filled(32, 9),
|
||||
),
|
||||
signature: Uint8List.sublistView(signature),
|
||||
publicKey: publicKey,
|
||||
);
|
||||
expect(
|
||||
verifies(Uint8List.sublistView(account.setup.recoveryProofKey)),
|
||||
isTrue,
|
||||
);
|
||||
expect(verifies(account.keys.signingPublicKey), isFalse);
|
||||
await expectLater(
|
||||
crypto.signKeyProof('not a phrase', challenge),
|
||||
throwsA(
|
||||
isA<PasswordFailed>().having(
|
||||
(failed) => failed.problem,
|
||||
'problem',
|
||||
PasswordProblem.notAPhrase,
|
||||
test(
|
||||
"signs the key proof with the phrase's own proof key, whose public "
|
||||
"half the setup carries, for the instance's host and the password it "
|
||||
"sets: never the account's key, which a signed-in device keeps",
|
||||
() async {
|
||||
final challenge = protocol.KeyProofChallenge(
|
||||
challenge: protocol.UuidValue.fromString(
|
||||
'01994a3c-7b2e-7c00-8a1b-0123456789ab',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
nonce: ByteData.sublistView(Uint8List.fromList(List.filled(32, 9))),
|
||||
encryptionPublicKey: ByteData.sublistView(
|
||||
account.keys.encryptionPublicKey,
|
||||
),
|
||||
signingPublicKey: ByteData.sublistView(account.keys.signingPublicKey),
|
||||
recoveryKeys: account.setup.recoveryKeys,
|
||||
);
|
||||
|
||||
final password = await crypto.newPassword(
|
||||
'another battery staple',
|
||||
account.keys,
|
||||
);
|
||||
final signature = await crypto.signKeyProof(
|
||||
account.phrase,
|
||||
challenge,
|
||||
host: 'learn.halkyon.eu',
|
||||
password: password,
|
||||
);
|
||||
|
||||
final sodium = await SodiumSumoInit.init();
|
||||
bool verifies(
|
||||
Uint8List publicKey, {
|
||||
String host = 'learn.halkyon.eu',
|
||||
List<int>? digest,
|
||||
}) => sodium.crypto.sign.verifyDetached(
|
||||
message: srp.keyProofMessage(
|
||||
challenge.challenge.toBytes(),
|
||||
List.filled(32, 9),
|
||||
host: host,
|
||||
purpose: srp.KeyProofPurpose.passwordReset,
|
||||
digest:
|
||||
digest ??
|
||||
srp.newPasswordDigest(
|
||||
salt: Uint8List.sublistView(password.salt),
|
||||
algorithm: password.costs.algorithm,
|
||||
opsLimit: password.costs.opsLimit,
|
||||
memLimit: password.costs.memLimit,
|
||||
verifier: Uint8List.sublistView(password.verifier),
|
||||
keysNonce: Uint8List.sublistView(password.passwordKeys.nonce),
|
||||
keysCipherText: Uint8List.sublistView(
|
||||
password.passwordKeys.cipherText,
|
||||
),
|
||||
),
|
||||
),
|
||||
signature: Uint8List.sublistView(signature),
|
||||
publicKey: publicKey,
|
||||
);
|
||||
final proofKey = Uint8List.sublistView(account.setup.recoveryProofKey);
|
||||
expect(verifies(proofKey), isTrue);
|
||||
expect(verifies(account.keys.signingPublicKey), isFalse);
|
||||
expect(verifies(proofKey, host: 'learn.evil.example'), isFalse);
|
||||
expect(verifies(proofKey, digest: List.filled(32, 0)), isFalse);
|
||||
await expectLater(
|
||||
crypto.signKeyProof(
|
||||
'not a phrase',
|
||||
challenge,
|
||||
host: 'learn.halkyon.eu',
|
||||
),
|
||||
throwsA(
|
||||
isA<PasswordFailed>().having(
|
||||
(failed) => failed.problem,
|
||||
'problem',
|
||||
PasswordProblem.notAPhrase,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("a new phrase carries its proof key's public half, which its "
|
||||
'signatures verify under', () async {
|
||||
|
|
@ -237,6 +272,7 @@ void main() {
|
|||
]),
|
||||
),
|
||||
),
|
||||
host: 'learn.halkyon.eu',
|
||||
);
|
||||
|
||||
expect(answer.clientValue.lengthInBytes, group.length);
|
||||
|
|
|
|||
Loading…
Reference in a new issue