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:
parent
8ec2743bfa
commit
1ad5d84110
7 changed files with 912 additions and 448 deletions
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue