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
41 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;

View file

@ -189,8 +189,8 @@ void main() {
test(
"signs the key proof with the phrase's own proof key, whose public "
"half the setup carries, for the instance's host and the password it "
"sets: never the account's key, which a signed-in device keeps",
"half the setup carries, for the instance's base URL and the password "
"it sets: never the account's key, which a signed-in device keeps",
() async {
final challenge = protocol.KeyProofChallenge(
challenge: protocol.UuidValue.fromString(
@ -211,20 +211,20 @@ void main() {
final signature = await crypto.signKeyProof(
account.phrase,
challenge,
host: 'learn.halkyon.eu',
instanceUrl: 'https://learn.halkyon.eu/',
password: password,
);
final sodium = await SodiumSumoInit.init();
bool verifies(
Uint8List publicKey, {
String host = 'learn.halkyon.eu',
String instanceUrl = 'https://learn.halkyon.eu/',
List<int>? digest,
}) => sodium.crypto.sign.verifyDetached(
message: srp.keyProofMessage(
challenge.challenge.toBytes(),
List.filled(32, 9),
host: host,
instanceUrl: instanceUrl,
purpose: srp.KeyProofPurpose.passwordReset,
digest:
digest ??
@ -246,13 +246,16 @@ void main() {
final proofKey = Uint8List.sublistView(account.setup.recoveryProofKey);
expect(verifies(proofKey), isTrue);
expect(verifies(account.keys.signingPublicKey), isFalse);
expect(verifies(proofKey, host: 'learn.evil.example'), isFalse);
expect(
verifies(proofKey, instanceUrl: 'https://learn.evil.example/'),
isFalse,
);
expect(verifies(proofKey, digest: List.filled(32, 0)), isFalse);
await expectLater(
crypto.signKeyProof(
'not a phrase',
challenge,
host: 'learn.halkyon.eu',
instanceUrl: 'https://learn.halkyon.eu/',
),
throwsA(
isA<PasswordFailed>().having(
@ -298,7 +301,7 @@ void main() {
]),
),
),
host: 'learn.halkyon.eu',
instanceUrl: 'https://learn.halkyon.eu/',
);
expect(answer.clientValue.lengthInBytes, group.length);

View file

@ -66,7 +66,7 @@ class EndpointAccount extends _isc.EndpointRef {
/// data at once; an anonymous person is this service's alone. An account
/// with a password deletes itself only with [proof] (the password's proof
/// and a code, `requireOwnerToDelete`), which the register checks, its
/// host the one the device addressed here.
/// base URL the one the device addressed here.
_ida.Future<void> delete({_ifxctuoq.DeletionProof? proof}) =>
caller.callServerEndpoint<void>(
'halkyon_base.account',
@ -242,7 +242,7 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
String? code,
_isc.UuidValue? keyChallenge,
_idt.ByteData? signature,
required String host,
required String instanceUrl,
}) => caller.callServerEndpoint<_ippuf33k.ResetPending>(
'halkyon_base.accountRecovery',
'resetAuthenticator',
@ -256,7 +256,7 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
'code': code,
'keyChallenge': keyChallenge,
'signature': signature,
'host': host,
'instanceUrl': instanceUrl,
},
);
@ -274,8 +274,8 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
/// Sets a new [password] with the recovery phrase's [signature] for
/// [challenge] and a [totpCode] or [recoveryCode]; the keys stay. Every
/// session of the account ends, in every service of the instance, and
/// this one signs in. [host] is the host the device addressed this
/// instance by, which the phrase's proof signs; [address] and [language],
/// this one signs in. [instanceUrl] is the base URL the device addressed
/// this instance by, which the phrase's proof signs; [address] and [language],
/// as the device typed them, serve only to tell the account's owner
/// should codes come to wait. Refusals as `resetPasswordWithPhrase`'s.
_ida.Future<_iacc.AuthSuccess> resetPassword({
@ -284,7 +284,7 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
required _i95l60wq.NewPassword password,
String? totpCode,
String? recoveryCode,
required String host,
required String instanceUrl,
required String address,
required String language,
}) => caller.callServerEndpoint<_iacc.AuthSuccess>(
@ -296,7 +296,7 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
'password': password,
'totpCode': totpCode,
'recoveryCode': recoveryCode,
'host': host,
'instanceUrl': instanceUrl,
'address': address,
'language': language,
},
@ -558,8 +558,8 @@ class EndpointPasswordSignIn extends _isc.EndpointRef {
/// (or, for a setup never finished, a [code] mailed for [request] to the
/// address the sign-in began with). Signed in, SRP's M2 (which the device checks before trusting anything
/// else), the account's keys as the register keeps them, and the session.
/// [host] is the host the device addressed this instance by, which its
/// proof is bound to; [address] and [language], the address the sign-in
/// [instanceUrl] is the base URL the device addressed this instance by,
/// which its proof is bound to; [address] and [language], the address the sign-in
/// began with as the device typed it, serve only to tell its owner should
/// codes come to wait (the instance keeps no address). Refusals as
/// `completePasswordSignIn`'s.
@ -571,7 +571,7 @@ class EndpointPasswordSignIn extends _isc.EndpointRef {
String? recoveryCode,
_isc.UuidValue? request,
String? code,
required String host,
required String instanceUrl,
required String address,
required String language,
}) => caller.callServerEndpoint<_ix90myvx.PasswordSignedIn>(
@ -585,7 +585,7 @@ class EndpointPasswordSignIn extends _isc.EndpointRef {
'recoveryCode': recoveryCode,
'request': request,
'code': code,
'host': host,
'instanceUrl': instanceUrl,
'address': address,
'language': language,
},

View file

@ -15,7 +15,7 @@ import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// What deleting an account that has a password takes (ADR 0017; the second
/// reading): the password's SRP proof for a challenge `password.beginChange`
/// gave, a TOTP or recovery code, and the host the device addressed, as a
/// gave, a TOTP or recovery code, and the base URL the device addressed, as a
/// change of password does (`OwnerProof`). A session alone deletes only an
/// account without one.
abstract class DeletionProof
@ -28,7 +28,7 @@ abstract class DeletionProof
this.recoveryCode,
this.request,
this.code,
required this.host,
required this.instanceUrl,
});
factory DeletionProof({
@ -39,7 +39,7 @@ abstract class DeletionProof
String? recoveryCode,
_isc.UuidValue? request,
String? code,
required String host,
required String instanceUrl,
}) = _DeletionProofImpl;
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
@ -59,7 +59,7 @@ abstract class DeletionProof
? null
: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['request']),
code: jsonSerialization['code'] as String?,
host: jsonSerialization['host'] as String,
instanceUrl: jsonSerialization['instanceUrl'] as String,
);
}
@ -81,9 +81,10 @@ abstract class DeletionProof
String? code;
/// The host the device addressed the instance by: the server it reached
/// checks it is its own before the register binds the proof to it.
String host;
/// The instance's base URL the device addressed (scheme, host, port,
/// path): the server it reached checks it is its own before the
/// register binds the proof to it.
String instanceUrl;
/// Returns a shallow copy of this [DeletionProof]
/// with some or all fields replaced by the given arguments.
@ -96,7 +97,7 @@ abstract class DeletionProof
String? recoveryCode,
_isc.UuidValue? request,
String? code,
String? host,
String? instanceUrl,
});
@override
Map<String, dynamic> toJson() {
@ -109,7 +110,7 @@ abstract class DeletionProof
if (recoveryCode != null) 'recoveryCode': recoveryCode,
if (request != null) 'request': request?.toJson(),
if (code != null) 'code': code,
'host': host,
'instanceUrl': instanceUrl,
};
}
@ -124,7 +125,7 @@ abstract class DeletionProof
if (recoveryCode != null) 'recoveryCode': recoveryCode,
if (request != null) 'request': request?.toJson(),
if (code != null) 'code': code,
'host': host,
'instanceUrl': instanceUrl,
};
}
@ -145,7 +146,7 @@ class _DeletionProofImpl extends DeletionProof {
String? recoveryCode,
_isc.UuidValue? request,
String? code,
required String host,
required String instanceUrl,
}) : super._(
challenge: challenge,
clientValue: clientValue,
@ -154,7 +155,7 @@ class _DeletionProofImpl extends DeletionProof {
recoveryCode: recoveryCode,
request: request,
code: code,
host: host,
instanceUrl: instanceUrl,
);
/// Returns a shallow copy of this [DeletionProof]
@ -169,7 +170,7 @@ class _DeletionProofImpl extends DeletionProof {
Object? recoveryCode = _Undefined,
Object? request = _Undefined,
Object? code = _Undefined,
String? host,
String? instanceUrl,
}) {
return DeletionProof(
challenge: challenge ?? this.challenge,
@ -179,7 +180,7 @@ class _DeletionProofImpl extends DeletionProof {
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
request: request is _isc.UuidValue? ? request : this.request,
code: code is String? ? code : this.code,
host: host ?? this.host,
instanceUrl: instanceUrl ?? this.instanceUrl,
);
}
}

View file

@ -15,8 +15,8 @@ import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// What a signed-in owner proves to change how their account is secured
/// (ADR 0017; the third reading, as Proton has it): the password's SRP
/// proof for a challenge `password.beginChange` gave, bound to the host the
/// device addressed, and a TOTP code from one of the account's confirmed
/// proof for a challenge `password.beginChange` gave, bound to the base URL
/// the device addressed, and a TOTP code from one of the account's confirmed
/// authenticators or one of its recovery codes. Adding an authenticator once
/// one is confirmed, removing a confirmed one, new recovery codes, a new
/// recovery phrase and a new password take it: a session alone never gets a
@ -27,7 +27,7 @@ abstract class OwnerProof
required this.challenge,
required this.clientValue,
required this.clientProof,
required this.host,
required this.instanceUrl,
this.totpCode,
this.recoveryCode,
});
@ -36,7 +36,7 @@ abstract class OwnerProof
required _isc.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
required String host,
required String instanceUrl,
String? totpCode,
String? recoveryCode,
}) = _OwnerProofImpl;
@ -52,7 +52,7 @@ abstract class OwnerProof
clientProof: _isc.ByteDataJsonExtension.fromJson(
jsonSerialization['clientProof'],
),
host: jsonSerialization['host'] as String,
instanceUrl: jsonSerialization['instanceUrl'] as String,
totpCode: jsonSerialization['totpCode'] as String?,
recoveryCode: jsonSerialization['recoveryCode'] as String?,
);
@ -64,9 +64,10 @@ abstract class OwnerProof
_idt.ByteData clientProof;
/// The host the device addressed the instance by: the server it reached
/// checks it is its own before the register binds the proof to it.
String host;
/// The instance's base URL the device addressed (scheme, host, port,
/// path): the server it reached checks it is its own before the
/// register binds the proof to it.
String instanceUrl;
String? totpCode;
@ -79,7 +80,7 @@ abstract class OwnerProof
_isc.UuidValue? challenge,
_idt.ByteData? clientValue,
_idt.ByteData? clientProof,
String? host,
String? instanceUrl,
String? totpCode,
String? recoveryCode,
});
@ -90,7 +91,7 @@ abstract class OwnerProof
'challenge': challenge.toJson(),
'clientValue': clientValue.toJson(),
'clientProof': clientProof.toJson(),
'host': host,
'instanceUrl': instanceUrl,
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
};
@ -103,7 +104,7 @@ abstract class OwnerProof
'challenge': challenge.toJson(),
'clientValue': clientValue.toJson(),
'clientProof': clientProof.toJson(),
'host': host,
'instanceUrl': instanceUrl,
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
};
@ -122,14 +123,14 @@ class _OwnerProofImpl extends OwnerProof {
required _isc.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
required String host,
required String instanceUrl,
String? totpCode,
String? recoveryCode,
}) : super._(
challenge: challenge,
clientValue: clientValue,
clientProof: clientProof,
host: host,
instanceUrl: instanceUrl,
totpCode: totpCode,
recoveryCode: recoveryCode,
);
@ -142,7 +143,7 @@ class _OwnerProofImpl extends OwnerProof {
_isc.UuidValue? challenge,
_idt.ByteData? clientValue,
_idt.ByteData? clientProof,
String? host,
String? instanceUrl,
Object? totpCode = _Undefined,
Object? recoveryCode = _Undefined,
}) {
@ -150,7 +151,7 @@ class _OwnerProofImpl extends OwnerProof {
challenge: challenge ?? this.challenge,
clientValue: clientValue ?? this.clientValue.clone(),
clientProof: clientProof ?? this.clientProof.clone(),
host: host ?? this.host,
instanceUrl: instanceUrl ?? this.instanceUrl,
totpCode: totpCode is String? ? totpCode : this.totpCode,
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
);

View file

@ -4,21 +4,90 @@ import 'dart:typed_data';
import 'package:crypto/crypto.dart';
// What the device proves and signs with, bound to the instance it addresses
// (ADR 0017; the second reading's M6): a hostile instance a person was lured
// into adding can't relay a sign-in or a phrase's proof to the real one,
// which takes them only for its own host, nor swap in a password of its
// own. halkyon_base_server's `key_proof.dart` makes the same bytes.
// and to what the proof is for (ADR 0017; the second reading's M6, the full
// reading's device medium 1): a hostile instance a person was lured into
// adding can't relay a sign-in or a phrase's proof to the real one, which
// takes them only for its own base URL; nobody between the device and the
// register can swap in a password, a phrase or an authenticator of their
// own around the owner's proof, nor take a proof made for one change to
// make another. halkyon_base_server's `key_proof.dart` makes the same bytes.
/// SRP's identity I for the sign-in [challenge] (its 16 bytes) on [host],
/// the instance as the device addresses it, in lower case. SRP's x comes
/// from Argon2id over the password and salt alone, so I only binds the
/// proof: to this sign-in and to this instance.
Uint8List passwordIdentity(List<int> challenge, String host) => _framed([
utf8.encode('halkyon srp identity v1'),
/// What a proof of the password is made for: the words SRP's identity
/// carries ([label]). The register takes a proof only for the purpose it
/// works out from the call that brings it, so a proof made for one never
/// counts for another.
enum PasswordProofPurpose(final String label) {
/// Signing in.
signIn('sign in'),
/// Asking for the reset of a lost authenticator.
authenticatorReset('authenticator reset'),
/// A new password while signed in.
newPassword('new password'),
/// A new recovery phrase while signed in.
newPhrase('new phrase'),
/// New recovery codes in place of the old.
newRecoveryCodes('new recovery codes'),
/// Confirming the account's first authenticator.
firstAuthenticator('first authenticator'),
/// Confirming another authenticator.
addAuthenticator('add authenticator'),
/// Removing a confirmed authenticator.
removeAuthenticator('remove authenticator'),
/// Calling off the account's resets waiting.
callOffResets('call off resets'),
/// Deleting the account.
deleteAccount('delete account'),
}
/// SRP's identity I for the [challenge] (its 16 bytes) on [instanceUrl],
/// the instance's base URL as the device addresses it
/// ([canonicalInstanceUrl]), for [purpose], with the [digest] of what the
/// proof sets (none where it sets nothing). SRP's x comes from Argon2id
/// over the password and salt alone, so I only binds the proof: to this
/// challenge, this instance, this purpose and this change.
Uint8List passwordIdentity(
List<int> challenge,
String instanceUrl, {
PasswordProofPurpose purpose = PasswordProofPurpose.signIn,
List<int> digest = const [],
}) => _framed([
utf8.encode('halkyon srp identity v2'),
challenge,
utf8.encode(host.toLowerCase()),
utf8.encode(canonicalInstanceUrl(instanceUrl)),
utf8.encode(purpose.label),
digest,
]);
/// [url], an instance's base URL, as proofs bind it: the scheme and host in
/// lower case, the port only where it isn't the scheme's own, the path
/// ending in `/`, nothing after it. Refused ([FormatException]) for what is
/// no HTTP or HTTPS address of a host, or names someone in it.
String canonicalInstanceUrl(String url) {
final uri = Uri.tryParse(url.trim());
final scheme = uri?.scheme.toLowerCase();
if (uri == null ||
(scheme != 'https' && scheme != 'http') ||
uri.host.isEmpty ||
uri.userInfo.isNotEmpty) {
throw FormatException("Not an instance's base URL", url);
}
final host = uri.host.toLowerCase();
final shownHost = host.contains(':') ? '[$host]' : host;
final ownPort = scheme == 'https' ? 443 : 80;
final port = uri.hasPort && uri.port != ownPort ? ':${uri.port}' : '';
final path = uri.path.endsWith('/') ? uri.path : '${uri.path}/';
return '$scheme://$shownHost$port$path';
}
/// What proving the recovery phrase is for, by the words the message
/// carries ([label]).
enum KeyProofPurpose(final String label) {
@ -31,29 +100,29 @@ enum KeyProofPurpose(final String label) {
/// What the device signs with the recovery phrase's proof key to prove the
/// phrase: a label naming the format, the challenge's ID (its 16 bytes),
/// the register's nonce, the [host] the device addresses, the [purpose],
/// and the [digest] of what it sets ([newPasswordDigest] for a new
/// password; none for a reset of the authenticators), each framed by its
/// length.
/// the register's nonce, the [instanceUrl] the device addresses
/// ([canonicalInstanceUrl]), the [purpose], and the [digest] of what it
/// sets ([newPasswordDigest] for a new password; none for a reset of the
/// authenticators), each framed by its length.
Uint8List keyProofMessage(
List<int> challenge,
List<int> nonce, {
required String host,
required String instanceUrl,
required KeyProofPurpose purpose,
List<int> digest = const [],
}) => _framed([
utf8.encode('halkyon key proof v2'),
utf8.encode('halkyon key proof v3'),
challenge,
nonce,
utf8.encode(host.toLowerCase()),
utf8.encode(canonicalInstanceUrl(instanceUrl)),
utf8.encode(purpose.label),
digest,
]);
/// The SHA-256 of a new password as the device sends it (the salt,
/// Argon2id's costs, SRP's verifier, the keys wrapped under it), framed:
/// what a phrase's proof for a new password signs, so nobody between the
/// device and the register can swap in another.
/// what a proof for a new password binds, so nobody between the device and
/// the register can swap in another.
Uint8List newPasswordDigest({
required List<int> salt,
required String algorithm,
@ -62,10 +131,7 @@ Uint8List newPasswordDigest({
required List<int> verifier,
required List<int> keysNonce,
required List<int> keysCipherText,
}) => Uint8List.fromList(
sha256
.convert(
_framed([
}) => _sha256([
utf8.encode('halkyon new password v1'),
salt,
utf8.encode(algorithm),
@ -74,10 +140,29 @@ Uint8List newPasswordDigest({
verifier,
keysNonce,
keysCipherText,
]),
)
.bytes,
);
]);
/// The SHA-256 of a new recovery phrase as the device sends it (the keys
/// wrapped under it and its proof key's public half), framed: what the
/// owner's proof for it binds.
Uint8List newPhraseDigest({
required List<int> keysNonce,
required List<int> keysCipherText,
required List<int> proofKey,
}) => _sha256([
utf8.encode('halkyon new phrase v1'),
keysNonce,
keysCipherText,
proofKey,
]);
/// The SHA-256 of an authenticator's ID (its 16 bytes), framed: what the
/// owner's proof for confirming or removing it binds.
Uint8List authenticatorDigest(List<int> id) =>
_sha256([utf8.encode('halkyon authenticator v1'), id]);
Uint8List _sha256(List<List<int>> parts) =>
Uint8List.fromList(sha256.convert(_framed(parts)).bytes);
/// [parts], each after its length in four bytes, big-endian, so no part
/// can run into the next.

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)),
);
});
}

View file

@ -28,7 +28,16 @@ export 'src/password/authenticators.dart'
maxAuthenticators,
unconfirmedAuthenticatorLifetime;
export 'src/password/key_proof.dart'
show KeyProofPurpose, keyProofMessage, newPasswordDigest, passwordIdentity;
show
KeyProofPurpose,
PasswordProofPurpose,
authenticatorDigest,
canonicalInstanceUrl,
keyProofMessage,
newPasswordDigest,
newPhraseDigest,
passwordIdentity,
publicInstanceUrlOf;
export 'src/password/password_config.dart';
export 'src/password/passwords.dart'
show

View file

@ -21,11 +21,13 @@ class AccountEndpoint extends Endpoint {
/// data at once; an anonymous person is this service's alone. An account
/// with a password deletes itself only with [proof] (the password's proof
/// and a code, `requireOwnerToDelete`), which the register checks, its
/// host the one the device addressed here.
/// base URL the one the device addressed here.
Future<void> delete(Session session, {DeletionProof? proof}) async {
final pod = session.serverpod;
final person = session.authenticated!.authUserId;
final bound = proof?.copyWith(host: deviceHostOn(pod, proof.host));
final bound = proof?.copyWith(
instanceUrl: deviceInstanceUrlOn(pod, proof.instanceUrl),
);
// The proof counts for this session (`asOwnerProofTry`), as every
// change of how the account is secured does.
await asOwnerProofTry(session, bound, () async {

View file

@ -360,8 +360,8 @@ class Endpoints extends _is.EndpointDispatch {
type: _is.getType<_idt.ByteData?>(),
nullable: true,
),
'host': _is.ParameterDescription(
name: 'host',
'instanceUrl': _is.ParameterDescription(
name: 'instanceUrl',
type: _is.getType<String>(),
nullable: false,
),
@ -384,7 +384,7 @@ class Endpoints extends _is.EndpointDispatch {
code: params['code'],
keyChallenge: params['keyChallenge'],
signature: params['signature'],
host: params['host'],
instanceUrl: params['instanceUrl'],
),
),
'beginKeyProof': _is.MethodConnector(
@ -436,8 +436,8 @@ class Endpoints extends _is.EndpointDispatch {
type: _is.getType<String?>(),
nullable: true,
),
'host': _is.ParameterDescription(
name: 'host',
'instanceUrl': _is.ParameterDescription(
name: 'instanceUrl',
type: _is.getType<String>(),
nullable: false,
),
@ -466,7 +466,7 @@ class Endpoints extends _is.EndpointDispatch {
password: params['password'],
totpCode: params['totpCode'],
recoveryCode: params['recoveryCode'],
host: params['host'],
instanceUrl: params['instanceUrl'],
address: params['address'],
language: params['language'],
),
@ -872,8 +872,8 @@ class Endpoints extends _is.EndpointDispatch {
type: _is.getType<String?>(),
nullable: true,
),
'host': _is.ParameterDescription(
name: 'host',
'instanceUrl': _is.ParameterDescription(
name: 'instanceUrl',
type: _is.getType<String>(),
nullable: false,
),
@ -904,7 +904,7 @@ class Endpoints extends _is.EndpointDispatch {
recoveryCode: params['recoveryCode'],
request: params['request'],
code: params['code'],
host: params['host'],
instanceUrl: params['instanceUrl'],
address: params['address'],
language: params['language'],
),

View file

@ -15,7 +15,7 @@ import 'package:serverpod/serverpod.dart' as _is;
/// What deleting an account that has a password takes (ADR 0017; the second
/// reading): the password's SRP proof for a challenge `password.beginChange`
/// gave, a TOTP or recovery code, and the host the device addressed, as a
/// gave, a TOTP or recovery code, and the base URL the device addressed, as a
/// change of password does (`OwnerProof`). A session alone deletes only an
/// account without one.
abstract class DeletionProof
@ -28,7 +28,7 @@ abstract class DeletionProof
this.recoveryCode,
this.request,
this.code,
required this.host,
required this.instanceUrl,
});
factory DeletionProof({
@ -39,7 +39,7 @@ abstract class DeletionProof
String? recoveryCode,
_is.UuidValue? request,
String? code,
required String host,
required String instanceUrl,
}) = _DeletionProofImpl;
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
@ -59,7 +59,7 @@ abstract class DeletionProof
? null
: _is.UuidValueJsonExtension.fromJson(jsonSerialization['request']),
code: jsonSerialization['code'] as String?,
host: jsonSerialization['host'] as String,
instanceUrl: jsonSerialization['instanceUrl'] as String,
);
}
@ -81,9 +81,10 @@ abstract class DeletionProof
String? code;
/// The host the device addressed the instance by: the server it reached
/// checks it is its own before the register binds the proof to it.
String host;
/// The instance's base URL the device addressed (scheme, host, port,
/// path): the server it reached checks it is its own before the
/// register binds the proof to it.
String instanceUrl;
/// Returns a shallow copy of this [DeletionProof]
/// with some or all fields replaced by the given arguments.
@ -96,7 +97,7 @@ abstract class DeletionProof
String? recoveryCode,
_is.UuidValue? request,
String? code,
String? host,
String? instanceUrl,
});
@override
Map<String, dynamic> toJson() {
@ -109,7 +110,7 @@ abstract class DeletionProof
if (recoveryCode != null) 'recoveryCode': recoveryCode,
if (request != null) 'request': request?.toJson(),
if (code != null) 'code': code,
'host': host,
'instanceUrl': instanceUrl,
};
}
@ -124,7 +125,7 @@ abstract class DeletionProof
if (recoveryCode != null) 'recoveryCode': recoveryCode,
if (request != null) 'request': request?.toJson(),
if (code != null) 'code': code,
'host': host,
'instanceUrl': instanceUrl,
};
}
@ -145,7 +146,7 @@ class _DeletionProofImpl extends DeletionProof {
String? recoveryCode,
_is.UuidValue? request,
String? code,
required String host,
required String instanceUrl,
}) : super._(
challenge: challenge,
clientValue: clientValue,
@ -154,7 +155,7 @@ class _DeletionProofImpl extends DeletionProof {
recoveryCode: recoveryCode,
request: request,
code: code,
host: host,
instanceUrl: instanceUrl,
);
/// Returns a shallow copy of this [DeletionProof]
@ -169,7 +170,7 @@ class _DeletionProofImpl extends DeletionProof {
Object? recoveryCode = _Undefined,
Object? request = _Undefined,
Object? code = _Undefined,
String? host,
String? instanceUrl,
}) {
return DeletionProof(
challenge: challenge ?? this.challenge,
@ -179,7 +180,7 @@ class _DeletionProofImpl extends DeletionProof {
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
request: request is _is.UuidValue? ? request : this.request,
code: code is String? ? code : this.code,
host: host ?? this.host,
instanceUrl: instanceUrl ?? this.instanceUrl,
);
}
}

View file

@ -15,8 +15,8 @@ import 'package:serverpod/serverpod.dart' as _is;
/// What a signed-in owner proves to change how their account is secured
/// (ADR 0017; the third reading, as Proton has it): the password's SRP
/// proof for a challenge `password.beginChange` gave, bound to the host the
/// device addressed, and a TOTP code from one of the account's confirmed
/// proof for a challenge `password.beginChange` gave, bound to the base URL
/// the device addressed, and a TOTP code from one of the account's confirmed
/// authenticators or one of its recovery codes. Adding an authenticator once
/// one is confirmed, removing a confirmed one, new recovery codes, a new
/// recovery phrase and a new password take it: a session alone never gets a
@ -27,7 +27,7 @@ abstract class OwnerProof
required this.challenge,
required this.clientValue,
required this.clientProof,
required this.host,
required this.instanceUrl,
this.totpCode,
this.recoveryCode,
});
@ -36,7 +36,7 @@ abstract class OwnerProof
required _is.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
required String host,
required String instanceUrl,
String? totpCode,
String? recoveryCode,
}) = _OwnerProofImpl;
@ -52,7 +52,7 @@ abstract class OwnerProof
clientProof: _is.ByteDataJsonExtension.fromJson(
jsonSerialization['clientProof'],
),
host: jsonSerialization['host'] as String,
instanceUrl: jsonSerialization['instanceUrl'] as String,
totpCode: jsonSerialization['totpCode'] as String?,
recoveryCode: jsonSerialization['recoveryCode'] as String?,
);
@ -64,9 +64,10 @@ abstract class OwnerProof
_idt.ByteData clientProof;
/// The host the device addressed the instance by: the server it reached
/// checks it is its own before the register binds the proof to it.
String host;
/// The instance's base URL the device addressed (scheme, host, port,
/// path): the server it reached checks it is its own before the
/// register binds the proof to it.
String instanceUrl;
String? totpCode;
@ -79,7 +80,7 @@ abstract class OwnerProof
_is.UuidValue? challenge,
_idt.ByteData? clientValue,
_idt.ByteData? clientProof,
String? host,
String? instanceUrl,
String? totpCode,
String? recoveryCode,
});
@ -90,7 +91,7 @@ abstract class OwnerProof
'challenge': challenge.toJson(),
'clientValue': clientValue.toJson(),
'clientProof': clientProof.toJson(),
'host': host,
'instanceUrl': instanceUrl,
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
};
@ -103,7 +104,7 @@ abstract class OwnerProof
'challenge': challenge.toJson(),
'clientValue': clientValue.toJson(),
'clientProof': clientProof.toJson(),
'host': host,
'instanceUrl': instanceUrl,
if (totpCode != null) 'totpCode': totpCode,
if (recoveryCode != null) 'recoveryCode': recoveryCode,
};
@ -122,14 +123,14 @@ class _OwnerProofImpl extends OwnerProof {
required _is.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
required String host,
required String instanceUrl,
String? totpCode,
String? recoveryCode,
}) : super._(
challenge: challenge,
clientValue: clientValue,
clientProof: clientProof,
host: host,
instanceUrl: instanceUrl,
totpCode: totpCode,
recoveryCode: recoveryCode,
);
@ -142,7 +143,7 @@ class _OwnerProofImpl extends OwnerProof {
_is.UuidValue? challenge,
_idt.ByteData? clientValue,
_idt.ByteData? clientProof,
String? host,
String? instanceUrl,
Object? totpCode = _Undefined,
Object? recoveryCode = _Undefined,
}) {
@ -150,7 +151,7 @@ class _OwnerProofImpl extends OwnerProof {
challenge: challenge ?? this.challenge,
clientValue: clientValue ?? this.clientValue.clone(),
clientProof: clientProof ?? this.clientProof.clone(),
host: host ?? this.host,
instanceUrl: instanceUrl ?? this.instanceUrl,
totpCode: totpCode is String? ? totpCode : this.totpCode,
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
);

View file

@ -52,7 +52,7 @@ Future<ResetPending> resetAuthenticator(
String? code,
UuidValue? keyChallenge,
ByteData? signature,
required String host,
required String instanceUrl,
}) async {
final pod = session.serverpod;
final keys = requirePasswordKeys(pod);
@ -62,7 +62,7 @@ Future<ResetPending> resetAuthenticator(
challenge: challenge,
clientValue: clientValue,
clientProof: clientProof,
host: host,
instanceUrl: instanceUrl,
);
final to = normalizedAddress(address);
if (to == null || addressHash(pod, to) != proved.addressHash) {
@ -85,7 +85,7 @@ Future<ResetPending> resetAuthenticator(
keys,
challenge: keyChallenge,
signature: signature,
host: host,
instanceUrl: instanceUrl,
purpose: KeyProofPurpose.authenticatorReset,
);
if (owner.authUserId != account) {
@ -127,7 +127,7 @@ Future<AccountSessionsEnded> resetPasswordWithPhrase(
required NewPassword password,
String? totpCode,
String? recoveryCode,
required String host,
required String instanceUrl,
required String address,
required String language,
}) async {
@ -140,7 +140,7 @@ Future<AccountSessionsEnded> resetPasswordWithPhrase(
keys,
challenge: challenge,
signature: signature,
host: host,
instanceUrl: instanceUrl,
purpose: KeyProofPurpose.passwordReset,
digest: newPasswordDigest(password),
);

View file

@ -31,7 +31,7 @@ class AccountRecoveryEndpoint extends Endpoint {
String? code,
UuidValue? keyChallenge,
ByteData? signature,
required String host,
required String instanceUrl,
}) => AccountRegister.of(session.serverpod).resetAuthenticator(
session,
challenge: challenge,
@ -43,7 +43,7 @@ class AccountRecoveryEndpoint extends Endpoint {
code: code,
keyChallenge: keyChallenge,
signature: signature,
host: deviceHostOn(session.serverpod, host),
instanceUrl: deviceInstanceUrlOn(session.serverpod, instanceUrl),
);
/// The first step of proving the recovery phrase for [address]: the
@ -59,8 +59,8 @@ class AccountRecoveryEndpoint extends Endpoint {
/// Sets a new [password] with the recovery phrase's [signature] for
/// [challenge] and a [totpCode] or [recoveryCode]; the keys stay. Every
/// session of the account ends, in every service of the instance, and
/// this one signs in. [host] is the host the device addressed this
/// instance by, which the phrase's proof signs; [address] and [language],
/// this one signs in. [instanceUrl] is the base URL the device addressed
/// this instance by, which the phrase's proof signs; [address] and [language],
/// as the device typed them, serve only to tell the account's owner
/// should codes come to wait. Refusals as `resetPasswordWithPhrase`'s.
Future<AuthSuccess> resetPassword(
@ -70,7 +70,7 @@ class AccountRecoveryEndpoint extends Endpoint {
required NewPassword password,
String? totpCode,
String? recoveryCode,
required String host,
required String instanceUrl,
required String address,
required String language,
}) async {
@ -82,7 +82,7 @@ class AccountRecoveryEndpoint extends Endpoint {
password: password,
totpCode: totpCode,
recoveryCode: recoveryCode,
host: deviceHostOn(session.serverpod, host),
instanceUrl: deviceInstanceUrlOn(session.serverpod, instanceUrl),
address: address,
language: language,
);

Some files were not shown because too many files have changed in this diff Show more