fix(auth)!: proofs are bound to the instance's whole base URL

SRP's identity and the recovery phrase's proof carried the host alone, so
instances on one host under another port, path or scheme could relay each
other's proofs (the full reading's device low 4). Both now carry the base
URL the device addresses, written alike on both sides
(canonicalInstanceUrl: scheme and host in lower case, the port only where
it isn't the scheme's own, the path ending in /); the register takes its
own public scheme, host and port (Serverpod serves at the root), a member
the URL it names, development anything.

The identity also gains a purpose and the digest of what a proof sets,
both empty so far (signing in), with the framing for a new phrase's and an
authenticator's digest beside the new password's: the owner's proofs bind
them next. Cross-tests hold both sides to the same bytes. OwnerProof's and
DeletionProof's host, and every endpoint's, become instanceUrl.

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-21 13:59:32 +02:00
commit 1ad5d84110
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -19,40 +19,132 @@ void main() {
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', () {
test("SRP's identity is the label, the challenge, the instance's base URL "
'the device addresses, what the proof is for and the digest of what it '
'sets, each framed by its length', () {
final digest = List.generate(32, (i) => i * 5);
expect(
passwordIdentity(challenge, 'Learn.Halkyon.EU'),
_framed([
utf8.encode('halkyon srp identity v1'),
passwordIdentity(
challenge,
utf8.encode('learn.halkyon.eu'),
'https://learn.halkyon.eu/',
purpose: PasswordProofPurpose.newPassword,
digest: digest,
),
_framed([
utf8.encode('halkyon srp identity v2'),
challenge,
utf8.encode('https://learn.halkyon.eu/'),
utf8.encode('new password'),
digest,
]),
);
expect(
passwordIdentity(challenge, 'learn.halkyon.eu'),
isNot(passwordIdentity(challenge, 'learn.halkyon.eu.evil.example')),
passwordIdentity(challenge, 'https://learn.halkyon.eu/'),
_framed([
utf8.encode('halkyon srp identity v2'),
challenge,
utf8.encode('https://learn.halkyon.eu/'),
utf8.encode('sign in'),
<int>[],
]),
);
});
test('every purpose has a label of its own', () {
final labels = {
for (final purpose in PasswordProofPurpose.values) purpose.label,
};
expect(labels, hasLength(PasswordProofPurpose.values.length));
});
test('an identity differs for another instance, purpose or digest', () {
final identity = passwordIdentity(
challenge,
'https://learn.halkyon.eu/',
purpose: PasswordProofPurpose.newPassword,
digest: [1],
);
for (final other in [
passwordIdentity(
challenge,
'https://learn.halkyon.eu:8443/',
purpose: PasswordProofPurpose.newPassword,
digest: [1],
),
passwordIdentity(
challenge,
'https://learn.halkyon.eu/other/',
purpose: PasswordProofPurpose.newPassword,
digest: [1],
),
passwordIdentity(
challenge,
'https://learn.halkyon.eu/',
purpose: PasswordProofPurpose.newPhrase,
digest: [1],
),
passwordIdentity(
challenge,
'https://learn.halkyon.eu/',
purpose: PasswordProofPurpose.newPassword,
digest: [2],
),
]) {
expect(other, isNot(identity));
}
});
group("an instance's base URL", () {
for (final (typed, canonical) in [
('https://learn.halkyon.eu/', 'https://learn.halkyon.eu/'),
('https://Learn.Halkyon.EU', 'https://learn.halkyon.eu/'),
('HTTPS://learn.halkyon.eu:443/', 'https://learn.halkyon.eu/'),
('http://localhost:80/', 'http://localhost/'),
('http://localhost:8080', 'http://localhost:8080/'),
('http://localhost:0/', 'http://localhost:0/'),
('https://example.org:8443/learn', 'https://example.org:8443/learn/'),
('https://example.org/learn/?q=1#top', 'https://example.org/learn/'),
('http://[::1]:8080/', 'http://[::1]:8080/'),
(' https://learn.halkyon.eu/ ', 'https://learn.halkyon.eu/'),
]) {
test('$typed is $canonical', () {
expect(canonicalInstanceUrl(typed), canonical);
});
}
for (final typed in [
'learn.halkyon.eu',
'ftp://learn.halkyon.eu/',
'https:///',
'https://someone@learn.halkyon.eu/',
]) {
test('$typed is none', () {
expect(() => canonicalInstanceUrl(typed), throwsFormatException);
});
}
});
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', () {
"instance's base URL, what it proves the phrase for and what that "
'sets, framed, as the register checks it', () {
final digest = List.generate(32, (i) => i * 3);
expect(
keyProofMessage(
challenge,
nonce,
host: 'learn.halkyon.eu',
instanceUrl: 'https://learn.halkyon.eu/',
purpose: KeyProofPurpose.passwordReset,
digest: digest,
),
_framed([
utf8.encode('halkyon key proof v2'),
utf8.encode('halkyon key proof v3'),
challenge,
nonce,
utf8.encode('learn.halkyon.eu'),
utf8.encode('https://learn.halkyon.eu/'),
utf8.encode('password reset'),
digest,
]),
@ -61,14 +153,14 @@ void main() {
keyProofMessage(
challenge,
nonce,
host: 'learn.halkyon.eu',
instanceUrl: 'https://learn.halkyon.eu/',
purpose: KeyProofPurpose.authenticatorReset,
),
_framed([
utf8.encode('halkyon key proof v2'),
utf8.encode('halkyon key proof v3'),
challenge,
nonce,
utf8.encode('learn.halkyon.eu'),
utf8.encode('https://learn.halkyon.eu/'),
utf8.encode('authenticator reset'),
<int>[],
]),
@ -117,4 +209,49 @@ void main() {
isNot(digest),
);
});
test("a new phrase's digest is SHA-256 over the keys' wrapping under it "
'and its proof key, framed', () {
final digest = newPhraseDigest(
keysNonce: [1, 2],
keysCipherText: [3, 4, 5],
proofKey: [6, 7],
);
expect(
digest,
sha256
.convert(
_framed([
utf8.encode('halkyon new phrase v1'),
[1, 2],
[3, 4, 5],
[6, 7],
]),
)
.bytes,
);
expect(
newPhraseDigest(
keysNonce: [1, 2],
keysCipherText: [3, 4, 5],
proofKey: [6, 8],
),
isNot(digest),
);
});
test("an authenticator's digest is SHA-256 over its ID, framed", () {
final id = List.generate(16, (i) => 100 + i);
expect(
authenticatorDigest(id),
sha256
.convert(_framed([utf8.encode('halkyon authenticator v1'), id]))
.bytes,
);
expect(
authenticatorDigest([...id.take(15), 0]),
isNot(authenticatorDigest(id)),
);
});
}