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:
Tizian 「ティツィアーン」 2026-10-20 21:20:12 +02:00
commit dab2c80524
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,20 +1,120 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:halkyon_base_client/srp.dart';
import 'package:test/test.dart';
/// [parts], each after its length in four bytes, big-endian.
List<int> _framed(List<List<int>> parts) => [
for (final part in parts) ...[
(part.length >> 24) & 0xff,
(part.length >> 16) & 0xff,
(part.length >> 8) & 0xff,
part.length & 0xff,
...part,
],
];
void main() {
test('the key proof message is the label, the challenge and the nonce, as '
final challenge = List.generate(16, (i) => i);
final nonce = List.generate(32, (i) => 200 + i % 50);
test("SRP's identity is the label, the challenge and the host the device "
'addresses, each framed by its length, the host in lower case', () {
expect(
passwordIdentity(challenge, 'Learn.Halkyon.EU'),
_framed([
utf8.encode('halkyon srp identity v1'),
challenge,
utf8.encode('learn.halkyon.eu'),
]),
);
expect(
passwordIdentity(challenge, 'learn.halkyon.eu'),
isNot(passwordIdentity(challenge, 'learn.halkyon.eu.evil.example')),
);
});
test('the key proof message is the label, the challenge, the nonce, the '
'host, what it proves the phrase for and what that sets, framed, as '
'the register checks it', () {
final challenge = List.generate(16, (i) => i);
final nonce = List.generate(32, (i) => 200 + i % 50);
final digest = List.generate(32, (i) => i * 3);
final message = keyProofMessage(challenge, nonce);
expect(
keyProofMessage(
challenge,
nonce,
host: 'learn.halkyon.eu',
purpose: KeyProofPurpose.passwordReset,
digest: digest,
),
_framed([
utf8.encode('halkyon key proof v2'),
challenge,
nonce,
utf8.encode('learn.halkyon.eu'),
utf8.encode('password reset'),
digest,
]),
);
expect(
keyProofMessage(
challenge,
nonce,
host: 'learn.halkyon.eu',
purpose: KeyProofPurpose.authenticatorReset,
),
_framed([
utf8.encode('halkyon key proof v2'),
challenge,
nonce,
utf8.encode('learn.halkyon.eu'),
utf8.encode('authenticator reset'),
<int>[],
]),
);
});
expect(message, [
...utf8.encode('halkyon key proof v1|'),
...challenge,
...nonce,
]);
test("a new password's digest is SHA-256 over its parts, framed: the "
'salt, the costs, the verifier and the wrapping', () {
final digest = newPasswordDigest(
salt: [1, 2],
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 67108864,
verifier: [3, 4, 5],
keysNonce: [6],
keysCipherText: [7, 8],
);
expect(
digest,
sha256
.convert(
_framed([
utf8.encode('halkyon new password v1'),
[1, 2],
utf8.encode('argon2id13'),
utf8.encode('3'),
utf8.encode('67108864'),
[3, 4, 5],
[6],
[7, 8],
]),
)
.bytes,
);
expect(
newPasswordDigest(
salt: [1, 2],
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 67108864,
verifier: [3, 4, 5],
keysNonce: [6],
keysCipherText: [7, 9],
),
isNot(digest),
);
});
}