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
7 changed files with 912 additions and 448 deletions

View file

@ -96,12 +96,12 @@ abstract interface class AccountCrypto() {
});
/// The answer to [challenge] (SRP's B and the account's salt) with what
/// [unlock] holds, its identity bound to [host], the instance as the
/// device addresses it.
/// [unlock] holds, its identity bound to [instanceUrl], the instance's
/// base URL as the device addresses it.
Future<SrpAnswer> answer(
PasswordUnlock unlock,
protocol.PasswordChallenge challenge, {
required String host,
required String instanceUrl,
});
/// The account's keys [wrapped] under the password, opened with
@ -130,7 +130,7 @@ abstract interface class AccountCrypto() {
});
/// The signature proving the recovery [phrase] for [challenge] on
/// [host], for the new [password] it sets (or, without one, for an
/// [instanceUrl], 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
@ -138,7 +138,7 @@ abstract interface class AccountCrypto() {
Future<ByteData> signKeyProof(
String phrase,
protocol.KeyProofChallenge challenge, {
required String host,
required String instanceUrl,
protocol.NewPassword? password,
});

View file

@ -50,11 +50,11 @@ typedef OwnerAnswer = ({
/// 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;
/// The instance's base URL as the device addresses it (scheme, host,
/// port, path), 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 instanceUrl;
/// Whether the instance offers password sign-in, and until when GitHub
/// sign-in lasts there (null for no end set).

View file

@ -160,7 +160,7 @@ class PasswordRepository(
final answer = await _crypto.answer(
unlocked._unlock,
challenge,
host: _gateway.host,
instanceUrl: _gateway.instanceUrl,
);
final signedIn = await _gateway.complete(
challenge,
@ -365,7 +365,11 @@ class PasswordRepository(
};
return (
challenge: challenge,
answer: await _crypto.answer(unlock, challenge, host: _gateway.host),
answer: await _crypto.answer(
unlock,
challenge,
instanceUrl: _gateway.instanceUrl,
),
totpCode: totpCode?.replaceAll(' ', ''),
recoveryCode: recoveryCode,
);
@ -450,7 +454,11 @@ class PasswordRepository(
);
await _gateway.deleteAccount(
challenge,
await _crypto.answer(unlock, challenge, host: _gateway.host),
await _crypto.answer(
unlock,
challenge,
instanceUrl: _gateway.instanceUrl,
),
totpCode: totpCode?.replaceAll(' ', ''),
recoveryCode: recoveryCode,
request: request,
@ -529,14 +537,14 @@ class PasswordRepository(
signature = await _crypto.signKeyProof(
phrase,
keyChallenge,
host: _gateway.host,
instanceUrl: _gateway.instanceUrl,
);
}
final challenge = await _challengeOf(unlocked);
final answer = await _crypto.answer(
unlocked._unlock,
challenge,
host: _gateway.host,
instanceUrl: _gateway.instanceUrl,
);
return await _gateway.resetAuthenticator(
challenge,
@ -565,12 +573,12 @@ class PasswordRepository(
final challenge = await _gateway.beginKeyProof(typed);
final keys = await _openWithPhrase(challenge, phrase);
final password = await _crypto.newPassword(newPassword, keys);
// The proof signs this password and this instance's host: nobody
// The proof signs this password and this instance's base URL: nobody
// between swaps in another, or relays it elsewhere.
final signature = await _crypto.signKeyProof(
phrase,
challenge,
host: _gateway.host,
instanceUrl: _gateway.instanceUrl,
password: password,
);
final grant = await _gateway.resetPassword(

View file

@ -9,6 +9,7 @@ import 'package:halkyon_app/src/password/model/account_authenticator.dart';
import 'package:halkyon_app/src/password/model/mail_code_purpose.dart';
import 'package:halkyon_app/src/password/model/password_failed.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_base_client/srp.dart' as srp;
import 'package:http/http.dart' as http;
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
@ -26,7 +27,7 @@ class ServerpodPasswordGateway(
final http.Client Function()? _httpClient,
}) implements PasswordGateway {
@override
String get host => Uri.parse(_client.host).host;
String get instanceUrl => srp.canonicalInstanceUrl(_client.host);
@override
Future<({bool offered, DateTime? gitHubEnds})> offers() => _failing(() async {
@ -85,7 +86,7 @@ class ServerpodPasswordGateway(
recoveryCode: recoveryCode,
request: _idOf(request),
code: code,
host: host,
instanceUrl: instanceUrl,
address: address,
language: language,
);
@ -187,7 +188,8 @@ class ServerpodPasswordGateway(
() => _base.password.callOffResets(proof: _proofOf(owner)!),
);
/// [owner] as the instance takes it, bound to the [host] addressed.
/// [owner] as the instance takes it, bound to the [instanceUrl]
/// addressed.
protocol.OwnerProof? _proofOf(OwnerAnswer? owner) => switch (owner) {
null => null,
(:final challenge, :final answer, :final totpCode, :final recoveryCode) =>
@ -195,7 +197,7 @@ class ServerpodPasswordGateway(
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
host: host,
instanceUrl: instanceUrl,
totpCode: totpCode,
recoveryCode: recoveryCode,
),
@ -242,7 +244,7 @@ class ServerpodPasswordGateway(
recoveryCode: recoveryCode,
request: _idOf(request),
code: code,
host: host,
instanceUrl: instanceUrl,
),
);
// The server ended every session of the person; forget this device's
@ -292,7 +294,7 @@ class ServerpodPasswordGateway(
code: code,
keyChallenge: keyChallenge?.challenge,
signature: signature,
host: host,
instanceUrl: instanceUrl,
)).takesEffectAt,
);
@ -319,7 +321,7 @@ class ServerpodPasswordGateway(
password: password,
totpCode: totpCode,
recoveryCode: recoveryCode,
host: host,
instanceUrl: instanceUrl,
address: address,
language: language,
),

View file

@ -86,13 +86,16 @@ class SodiumAccountCrypto() implements AccountCrypto {
Future<SrpAnswer> answer(
PasswordUnlock unlock,
protocol.PasswordChallenge challenge, {
required String host,
required String instanceUrl,
}) async {
final group = srp.SrpGroup.rfc5054Group3072;
final (:clientValue, :proof) = await Isolate.run(() {
final client = srp.SrpClient(group);
final step = client.respond(
identity: srp.passwordIdentity(challenge.challenge.toBytes(), host),
identity: srp.passwordIdentity(
challenge.challenge.toBytes(),
instanceUrl,
),
salt: Uint8List.sublistView(challenge.salt),
privateValue: _numberOf(unlock.srpSecret),
serverValue: _numberOf(Uint8List.sublistView(challenge.serverValue)),
@ -208,7 +211,7 @@ class SodiumAccountCrypto() implements AccountCrypto {
Future<ByteData> signKeyProof(
String phrase,
protocol.KeyProofChallenge challenge, {
required String host,
required String instanceUrl,
protocol.NewPassword? password,
}) => Isolate.run(
() => _guarded(() async {
@ -226,7 +229,7 @@ class SodiumAccountCrypto() implements AccountCrypto {
message: srp.keyProofMessage(
challenge.challenge.toBytes(),
Uint8List.sublistView(challenge.nonce),
host: host,
instanceUrl: instanceUrl,
purpose: password == null
? srp.KeyProofPurpose.authenticatorReset
: srp.KeyProofPurpose.passwordReset,

View file

@ -154,7 +154,7 @@ class FakeAccountCrypto() implements AccountCrypto {
Future<SrpAnswer> answer(
PasswordUnlock unlock,
protocol.PasswordChallenge challenge, {
required String host,
required String instanceUrl,
}) async => SrpAnswer(
clientValue: ByteData(384),
clientProof: ByteData.sublistView(unlock.srpSecret),
@ -210,7 +210,7 @@ class FakeAccountCrypto() implements AccountCrypto {
Future<ByteData> signKeyProof(
String phrase,
protocol.KeyProofChallenge challenge, {
required String host,
required String instanceUrl,
protocol.NewPassword? password,
}) async {
if (phrase.trim().split(RegExp(r'\s+')).length != 24) {

View file

@ -65,7 +65,7 @@ final class FakePasswordAccount(
/// 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';
String get instanceUrl => 'https://halkyon.example/';
/// Whether the instance offers password sign-in.
bool offered = true;