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

@ -18,7 +18,7 @@ Uint8List _hex(String hex) => Uint8List.fromList([
Uint8List _message(UuidValue challenge, List<int> nonce) => keyProofMessage(
challenge,
nonce,
host: 'learn.halkyon.eu',
instanceUrl: 'https://learn.halkyon.eu/',
purpose: KeyProofPurpose.authenticatorReset,
);
@ -61,14 +61,14 @@ void main() {
keyProofMessage(
challenge,
nonce,
host: 'Learn.Halkyon.EU',
instanceUrl: 'HTTPS://Learn.Halkyon.EU:443',
purpose: KeyProofPurpose.passwordReset,
digest: digest,
),
device.keyProofMessage(
challenge.toBytes(),
nonce,
host: 'learn.halkyon.eu',
instanceUrl: 'https://learn.halkyon.eu/',
purpose: device.KeyProofPurpose.passwordReset,
digest: digest,
),
@ -77,59 +77,146 @@ void main() {
keyProofMessage(
challenge,
nonce,
host: 'learn.halkyon.eu',
instanceUrl: 'https://learn.halkyon.eu/',
purpose: KeyProofPurpose.authenticatorReset,
),
device.keyProofMessage(
challenge.toBytes(),
nonce,
host: 'learn.halkyon.eu',
instanceUrl: 'https://learn.halkyon.eu/',
purpose: device.KeyProofPurpose.authenticatorReset,
),
);
});
test("binds SRP's identity as devices do, for every purpose, with the "
'digest of what it sets', () {
final challenge = UuidValue.fromString(
'01994a3c-7b2e-7c00-8a1b-0123456789ab',
);
final digest = List.generate(32, (i) => 255 - i);
expect(
passwordIdentity(challenge, 'learn.halkyon.eu'),
device.passwordIdentity(challenge.toBytes(), 'LEARN.halkyon.eu'),
PasswordProofPurpose.values.map((purpose) => purpose.label),
device.PasswordProofPurpose.values.map((purpose) => purpose.label),
);
for (final purpose in PasswordProofPurpose.values) {
expect(
passwordIdentity(
challenge,
'https://learn.halkyon.eu',
purpose: purpose,
digest: digest,
),
device.passwordIdentity(
challenge.toBytes(),
'https://LEARN.halkyon.eu/',
purpose: device.PasswordProofPurpose.values.byName(purpose.name),
digest: digest,
),
reason: purpose.name,
);
}
expect(
passwordIdentity(challenge, 'https://learn.halkyon.eu/'),
device.passwordIdentity(
challenge.toBytes(),
'https://learn.halkyon.eu/',
),
);
});
test("takes the host a device names only where it is the instance's "
'own, but in development', () {
test("digests a new phrase and an authenticator's ID as devices do", () {
final keys = WrappedKeys(
nonce: ByteData.sublistView(Uint8List.fromList([1, 2])),
cipherText: ByteData.sublistView(Uint8List.fromList([3, 4, 5])),
);
final proofKey = ByteData.sublistView(Uint8List.fromList([6, 7]));
final id = UuidValue.fromString('01994a3c-7b2e-7c00-8a1b-0123456789ab');
expect(
deviceHost(
runMode: ServerpodRunMode.production,
publicHost: 'learn.halkyon.eu',
claimed: ' Learn.Halkyon.eu ',
newPhraseDigest(keys, proofKey),
device.newPhraseDigest(
keysNonce: [1, 2],
keysCipherText: [3, 4, 5],
proofKey: [6, 7],
),
'learn.halkyon.eu',
);
expect(authenticatorDigest(id), device.authenticatorDigest(id.toBytes()));
});
test('writes base URLs as devices do', () {
for (final url in [
'https://learn.halkyon.eu/',
'HTTPS://Learn.Halkyon.EU:443',
'http://localhost:0',
'http://localhost:80/',
'https://example.org:8443/learn?x#y',
'http://[::1]:8080/',
]) {
expect(
canonicalInstanceUrl(url),
device.canonicalInstanceUrl(url),
reason: url,
);
}
});
test("takes the base URL a device names only where it is the instance's "
'own, written any way, but in development', () {
expect(
deviceInstanceUrl(
runMode: ServerpodRunMode.production,
publicUrl: 'https://learn.halkyon.eu:443/',
claimed: ' HTTPS://Learn.Halkyon.eu ',
),
'https://learn.halkyon.eu/',
);
for (final runMode in [
ServerpodRunMode.production,
ServerpodRunMode.staging,
ServerpodRunMode.test,
]) {
expect(
() => deviceHost(
runMode: runMode,
publicHost: 'learn.halkyon.eu',
claimed: 'learn.evil.example',
),
throwsA(
isA<PasswordRefusedException>().having(
(refused) => refused.reason,
'reason',
PasswordRefusal.otherInstance,
for (final claimed in [
'https://learn.evil.example/',
'https://learn.halkyon.eu:8443/',
'https://learn.halkyon.eu/evil/',
'http://learn.halkyon.eu/',
'learn.halkyon.eu',
'not a url at all',
]) {
expect(
() => deviceInstanceUrl(
runMode: runMode,
publicUrl: 'https://learn.halkyon.eu:443/',
claimed: claimed,
),
),
);
throwsA(
isA<PasswordRefusedException>().having(
(refused) => refused.reason,
'reason',
PasswordRefusal.otherInstance,
),
),
reason: '$runMode $claimed',
);
}
}
expect(
deviceHost(
deviceInstanceUrl(
runMode: ServerpodRunMode.development,
publicHost: 'localhost',
publicUrl: 'http://localhost:8080/',
claimed: 'http://10.0.2.2:8080',
),
'http://10.0.2.2:8080/',
);
expect(
() => deviceInstanceUrl(
runMode: ServerpodRunMode.development,
publicUrl: 'http://localhost:8080/',
claimed: '10.0.2.2',
),
'10.0.2.2',
throwsA(isA<PasswordRefusedException>()),
);
});