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
1 changed files with 966 additions and 137 deletions
|
|
@ -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