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
41 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
|
/// 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
|
/// [unlock] holds, its identity bound to [instanceUrl], the instance's
|
||||||
/// device addresses it.
|
/// base URL as the device addresses it.
|
||||||
Future<SrpAnswer> answer(
|
Future<SrpAnswer> answer(
|
||||||
PasswordUnlock unlock,
|
PasswordUnlock unlock,
|
||||||
protocol.PasswordChallenge challenge, {
|
protocol.PasswordChallenge challenge, {
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The account's keys [wrapped] under the password, opened with
|
/// 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
|
/// 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
|
/// 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
|
/// alone while it is typed and kept nowhere, never the account's signing
|
||||||
/// key, which every signed-in device keeps. Refused
|
/// key, which every signed-in device keeps. Refused
|
||||||
|
|
@ -138,7 +138,7 @@ abstract interface class AccountCrypto() {
|
||||||
Future<ByteData> signKeyProof(
|
Future<ByteData> signKeyProof(
|
||||||
String phrase,
|
String phrase,
|
||||||
protocol.KeyProofChallenge challenge, {
|
protocol.KeyProofChallenge challenge, {
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
protocol.NewPassword? password,
|
protocol.NewPassword? password,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,11 @@ typedef OwnerAnswer = ({
|
||||||
/// up before a new sign-in is put in place). Every refusal is a
|
/// up before a new sign-in is put in place). Every refusal is a
|
||||||
/// `PasswordFailed`. Tests replace it with a fake.
|
/// `PasswordFailed`. Tests replace it with a fake.
|
||||||
abstract interface class PasswordGateway() {
|
abstract interface class PasswordGateway() {
|
||||||
/// The host the device addresses the instance by, which SRP's identity
|
/// The instance's base URL as the device addresses it (scheme, host,
|
||||||
/// and the recovery phrase's proofs are bound to (ADR 0017): a hostile
|
/// port, path), which SRP's identity and the recovery phrase's proofs are
|
||||||
/// instance relaying them to the real one gets nothing the real one
|
/// bound to (ADR 0017): a hostile instance relaying them to the real one
|
||||||
/// takes.
|
/// gets nothing the real one takes.
|
||||||
String get host;
|
String get instanceUrl;
|
||||||
|
|
||||||
/// Whether the instance offers password sign-in, and until when GitHub
|
/// Whether the instance offers password sign-in, and until when GitHub
|
||||||
/// sign-in lasts there (null for no end set).
|
/// sign-in lasts there (null for no end set).
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ class PasswordRepository(
|
||||||
final answer = await _crypto.answer(
|
final answer = await _crypto.answer(
|
||||||
unlocked._unlock,
|
unlocked._unlock,
|
||||||
challenge,
|
challenge,
|
||||||
host: _gateway.host,
|
instanceUrl: _gateway.instanceUrl,
|
||||||
);
|
);
|
||||||
final signedIn = await _gateway.complete(
|
final signedIn = await _gateway.complete(
|
||||||
challenge,
|
challenge,
|
||||||
|
|
@ -365,7 +365,11 @@ class PasswordRepository(
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
answer: await _crypto.answer(unlock, challenge, host: _gateway.host),
|
answer: await _crypto.answer(
|
||||||
|
unlock,
|
||||||
|
challenge,
|
||||||
|
instanceUrl: _gateway.instanceUrl,
|
||||||
|
),
|
||||||
totpCode: totpCode?.replaceAll(' ', ''),
|
totpCode: totpCode?.replaceAll(' ', ''),
|
||||||
recoveryCode: recoveryCode,
|
recoveryCode: recoveryCode,
|
||||||
);
|
);
|
||||||
|
|
@ -450,7 +454,11 @@ class PasswordRepository(
|
||||||
);
|
);
|
||||||
await _gateway.deleteAccount(
|
await _gateway.deleteAccount(
|
||||||
challenge,
|
challenge,
|
||||||
await _crypto.answer(unlock, challenge, host: _gateway.host),
|
await _crypto.answer(
|
||||||
|
unlock,
|
||||||
|
challenge,
|
||||||
|
instanceUrl: _gateway.instanceUrl,
|
||||||
|
),
|
||||||
totpCode: totpCode?.replaceAll(' ', ''),
|
totpCode: totpCode?.replaceAll(' ', ''),
|
||||||
recoveryCode: recoveryCode,
|
recoveryCode: recoveryCode,
|
||||||
request: request,
|
request: request,
|
||||||
|
|
@ -529,14 +537,14 @@ class PasswordRepository(
|
||||||
signature = await _crypto.signKeyProof(
|
signature = await _crypto.signKeyProof(
|
||||||
phrase,
|
phrase,
|
||||||
keyChallenge,
|
keyChallenge,
|
||||||
host: _gateway.host,
|
instanceUrl: _gateway.instanceUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final challenge = await _challengeOf(unlocked);
|
final challenge = await _challengeOf(unlocked);
|
||||||
final answer = await _crypto.answer(
|
final answer = await _crypto.answer(
|
||||||
unlocked._unlock,
|
unlocked._unlock,
|
||||||
challenge,
|
challenge,
|
||||||
host: _gateway.host,
|
instanceUrl: _gateway.instanceUrl,
|
||||||
);
|
);
|
||||||
return await _gateway.resetAuthenticator(
|
return await _gateway.resetAuthenticator(
|
||||||
challenge,
|
challenge,
|
||||||
|
|
@ -565,12 +573,12 @@ class PasswordRepository(
|
||||||
final challenge = await _gateway.beginKeyProof(typed);
|
final challenge = await _gateway.beginKeyProof(typed);
|
||||||
final keys = await _openWithPhrase(challenge, phrase);
|
final keys = await _openWithPhrase(challenge, phrase);
|
||||||
final password = await _crypto.newPassword(newPassword, keys);
|
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.
|
// between swaps in another, or relays it elsewhere.
|
||||||
final signature = await _crypto.signKeyProof(
|
final signature = await _crypto.signKeyProof(
|
||||||
phrase,
|
phrase,
|
||||||
challenge,
|
challenge,
|
||||||
host: _gateway.host,
|
instanceUrl: _gateway.instanceUrl,
|
||||||
password: password,
|
password: password,
|
||||||
);
|
);
|
||||||
final grant = await _gateway.resetPassword(
|
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/mail_code_purpose.dart';
|
||||||
import 'package:halkyon_app/src/password/model/password_failed.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/halkyon_base_client.dart' as protocol;
|
||||||
|
import 'package:halkyon_base_client/srp.dart' as srp;
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
||||||
|
|
||||||
|
|
@ -26,7 +27,7 @@ class ServerpodPasswordGateway(
|
||||||
final http.Client Function()? _httpClient,
|
final http.Client Function()? _httpClient,
|
||||||
}) implements PasswordGateway {
|
}) implements PasswordGateway {
|
||||||
@override
|
@override
|
||||||
String get host => Uri.parse(_client.host).host;
|
String get instanceUrl => srp.canonicalInstanceUrl(_client.host);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<({bool offered, DateTime? gitHubEnds})> offers() => _failing(() async {
|
Future<({bool offered, DateTime? gitHubEnds})> offers() => _failing(() async {
|
||||||
|
|
@ -85,7 +86,7 @@ class ServerpodPasswordGateway(
|
||||||
recoveryCode: recoveryCode,
|
recoveryCode: recoveryCode,
|
||||||
request: _idOf(request),
|
request: _idOf(request),
|
||||||
code: code,
|
code: code,
|
||||||
host: host,
|
instanceUrl: instanceUrl,
|
||||||
address: address,
|
address: address,
|
||||||
language: language,
|
language: language,
|
||||||
);
|
);
|
||||||
|
|
@ -187,7 +188,8 @@ class ServerpodPasswordGateway(
|
||||||
() => _base.password.callOffResets(proof: _proofOf(owner)!),
|
() => _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) {
|
protocol.OwnerProof? _proofOf(OwnerAnswer? owner) => switch (owner) {
|
||||||
null => null,
|
null => null,
|
||||||
(:final challenge, :final answer, :final totpCode, :final recoveryCode) =>
|
(:final challenge, :final answer, :final totpCode, :final recoveryCode) =>
|
||||||
|
|
@ -195,7 +197,7 @@ class ServerpodPasswordGateway(
|
||||||
challenge: challenge.challenge,
|
challenge: challenge.challenge,
|
||||||
clientValue: answer.clientValue,
|
clientValue: answer.clientValue,
|
||||||
clientProof: answer.clientProof,
|
clientProof: answer.clientProof,
|
||||||
host: host,
|
instanceUrl: instanceUrl,
|
||||||
totpCode: totpCode,
|
totpCode: totpCode,
|
||||||
recoveryCode: recoveryCode,
|
recoveryCode: recoveryCode,
|
||||||
),
|
),
|
||||||
|
|
@ -242,7 +244,7 @@ class ServerpodPasswordGateway(
|
||||||
recoveryCode: recoveryCode,
|
recoveryCode: recoveryCode,
|
||||||
request: _idOf(request),
|
request: _idOf(request),
|
||||||
code: code,
|
code: code,
|
||||||
host: host,
|
instanceUrl: instanceUrl,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// The server ended every session of the person; forget this device's
|
// The server ended every session of the person; forget this device's
|
||||||
|
|
@ -292,7 +294,7 @@ class ServerpodPasswordGateway(
|
||||||
code: code,
|
code: code,
|
||||||
keyChallenge: keyChallenge?.challenge,
|
keyChallenge: keyChallenge?.challenge,
|
||||||
signature: signature,
|
signature: signature,
|
||||||
host: host,
|
instanceUrl: instanceUrl,
|
||||||
)).takesEffectAt,
|
)).takesEffectAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -319,7 +321,7 @@ class ServerpodPasswordGateway(
|
||||||
password: password,
|
password: password,
|
||||||
totpCode: totpCode,
|
totpCode: totpCode,
|
||||||
recoveryCode: recoveryCode,
|
recoveryCode: recoveryCode,
|
||||||
host: host,
|
instanceUrl: instanceUrl,
|
||||||
address: address,
|
address: address,
|
||||||
language: language,
|
language: language,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -86,13 +86,16 @@ class SodiumAccountCrypto() implements AccountCrypto {
|
||||||
Future<SrpAnswer> answer(
|
Future<SrpAnswer> answer(
|
||||||
PasswordUnlock unlock,
|
PasswordUnlock unlock,
|
||||||
protocol.PasswordChallenge challenge, {
|
protocol.PasswordChallenge challenge, {
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
}) async {
|
}) async {
|
||||||
final group = srp.SrpGroup.rfc5054Group3072;
|
final group = srp.SrpGroup.rfc5054Group3072;
|
||||||
final (:clientValue, :proof) = await Isolate.run(() {
|
final (:clientValue, :proof) = await Isolate.run(() {
|
||||||
final client = srp.SrpClient(group);
|
final client = srp.SrpClient(group);
|
||||||
final step = client.respond(
|
final step = client.respond(
|
||||||
identity: srp.passwordIdentity(challenge.challenge.toBytes(), host),
|
identity: srp.passwordIdentity(
|
||||||
|
challenge.challenge.toBytes(),
|
||||||
|
instanceUrl,
|
||||||
|
),
|
||||||
salt: Uint8List.sublistView(challenge.salt),
|
salt: Uint8List.sublistView(challenge.salt),
|
||||||
privateValue: _numberOf(unlock.srpSecret),
|
privateValue: _numberOf(unlock.srpSecret),
|
||||||
serverValue: _numberOf(Uint8List.sublistView(challenge.serverValue)),
|
serverValue: _numberOf(Uint8List.sublistView(challenge.serverValue)),
|
||||||
|
|
@ -208,7 +211,7 @@ class SodiumAccountCrypto() implements AccountCrypto {
|
||||||
Future<ByteData> signKeyProof(
|
Future<ByteData> signKeyProof(
|
||||||
String phrase,
|
String phrase,
|
||||||
protocol.KeyProofChallenge challenge, {
|
protocol.KeyProofChallenge challenge, {
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
protocol.NewPassword? password,
|
protocol.NewPassword? password,
|
||||||
}) => Isolate.run(
|
}) => Isolate.run(
|
||||||
() => _guarded(() async {
|
() => _guarded(() async {
|
||||||
|
|
@ -226,7 +229,7 @@ class SodiumAccountCrypto() implements AccountCrypto {
|
||||||
message: srp.keyProofMessage(
|
message: srp.keyProofMessage(
|
||||||
challenge.challenge.toBytes(),
|
challenge.challenge.toBytes(),
|
||||||
Uint8List.sublistView(challenge.nonce),
|
Uint8List.sublistView(challenge.nonce),
|
||||||
host: host,
|
instanceUrl: instanceUrl,
|
||||||
purpose: password == null
|
purpose: password == null
|
||||||
? srp.KeyProofPurpose.authenticatorReset
|
? srp.KeyProofPurpose.authenticatorReset
|
||||||
: srp.KeyProofPurpose.passwordReset,
|
: srp.KeyProofPurpose.passwordReset,
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ class FakeAccountCrypto() implements AccountCrypto {
|
||||||
Future<SrpAnswer> answer(
|
Future<SrpAnswer> answer(
|
||||||
PasswordUnlock unlock,
|
PasswordUnlock unlock,
|
||||||
protocol.PasswordChallenge challenge, {
|
protocol.PasswordChallenge challenge, {
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
}) async => SrpAnswer(
|
}) async => SrpAnswer(
|
||||||
clientValue: ByteData(384),
|
clientValue: ByteData(384),
|
||||||
clientProof: ByteData.sublistView(unlock.srpSecret),
|
clientProof: ByteData.sublistView(unlock.srpSecret),
|
||||||
|
|
@ -210,7 +210,7 @@ class FakeAccountCrypto() implements AccountCrypto {
|
||||||
Future<ByteData> signKeyProof(
|
Future<ByteData> signKeyProof(
|
||||||
String phrase,
|
String phrase,
|
||||||
protocol.KeyProofChallenge challenge, {
|
protocol.KeyProofChallenge challenge, {
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
protocol.NewPassword? password,
|
protocol.NewPassword? password,
|
||||||
}) async {
|
}) async {
|
||||||
if (phrase.trim().split(RegExp(r'\s+')).length != 24) {
|
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.
|
/// is [_auth]'s. [failing] makes every call fail as without a connection.
|
||||||
class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
||||||
@override
|
@override
|
||||||
String get host => 'halkyon.example';
|
String get instanceUrl => 'https://halkyon.example/';
|
||||||
|
|
||||||
/// Whether the instance offers password sign-in.
|
/// Whether the instance offers password sign-in.
|
||||||
bool offered = true;
|
bool offered = true;
|
||||||
|
|
|
||||||
|
|
@ -189,8 +189,8 @@ void main() {
|
||||||
|
|
||||||
test(
|
test(
|
||||||
"signs the key proof with the phrase's own proof key, whose public "
|
"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 "
|
"half the setup carries, for the instance's base URL and the password "
|
||||||
"sets: never the account's key, which a signed-in device keeps",
|
"it sets: never the account's key, which a signed-in device keeps",
|
||||||
() async {
|
() async {
|
||||||
final challenge = protocol.KeyProofChallenge(
|
final challenge = protocol.KeyProofChallenge(
|
||||||
challenge: protocol.UuidValue.fromString(
|
challenge: protocol.UuidValue.fromString(
|
||||||
|
|
@ -211,20 +211,20 @@ void main() {
|
||||||
final signature = await crypto.signKeyProof(
|
final signature = await crypto.signKeyProof(
|
||||||
account.phrase,
|
account.phrase,
|
||||||
challenge,
|
challenge,
|
||||||
host: 'learn.halkyon.eu',
|
instanceUrl: 'https://learn.halkyon.eu/',
|
||||||
password: password,
|
password: password,
|
||||||
);
|
);
|
||||||
|
|
||||||
final sodium = await SodiumSumoInit.init();
|
final sodium = await SodiumSumoInit.init();
|
||||||
bool verifies(
|
bool verifies(
|
||||||
Uint8List publicKey, {
|
Uint8List publicKey, {
|
||||||
String host = 'learn.halkyon.eu',
|
String instanceUrl = 'https://learn.halkyon.eu/',
|
||||||
List<int>? digest,
|
List<int>? digest,
|
||||||
}) => sodium.crypto.sign.verifyDetached(
|
}) => sodium.crypto.sign.verifyDetached(
|
||||||
message: srp.keyProofMessage(
|
message: srp.keyProofMessage(
|
||||||
challenge.challenge.toBytes(),
|
challenge.challenge.toBytes(),
|
||||||
List.filled(32, 9),
|
List.filled(32, 9),
|
||||||
host: host,
|
instanceUrl: instanceUrl,
|
||||||
purpose: srp.KeyProofPurpose.passwordReset,
|
purpose: srp.KeyProofPurpose.passwordReset,
|
||||||
digest:
|
digest:
|
||||||
digest ??
|
digest ??
|
||||||
|
|
@ -246,13 +246,16 @@ void main() {
|
||||||
final proofKey = Uint8List.sublistView(account.setup.recoveryProofKey);
|
final proofKey = Uint8List.sublistView(account.setup.recoveryProofKey);
|
||||||
expect(verifies(proofKey), isTrue);
|
expect(verifies(proofKey), isTrue);
|
||||||
expect(verifies(account.keys.signingPublicKey), isFalse);
|
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);
|
expect(verifies(proofKey, digest: List.filled(32, 0)), isFalse);
|
||||||
await expectLater(
|
await expectLater(
|
||||||
crypto.signKeyProof(
|
crypto.signKeyProof(
|
||||||
'not a phrase',
|
'not a phrase',
|
||||||
challenge,
|
challenge,
|
||||||
host: 'learn.halkyon.eu',
|
instanceUrl: 'https://learn.halkyon.eu/',
|
||||||
),
|
),
|
||||||
throwsA(
|
throwsA(
|
||||||
isA<PasswordFailed>().having(
|
isA<PasswordFailed>().having(
|
||||||
|
|
@ -298,7 +301,7 @@ void main() {
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
host: 'learn.halkyon.eu',
|
instanceUrl: 'https://learn.halkyon.eu/',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(answer.clientValue.lengthInBytes, group.length);
|
expect(answer.clientValue.lengthInBytes, group.length);
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class EndpointAccount extends _isc.EndpointRef {
|
||||||
/// data at once; an anonymous person is this service's alone. An account
|
/// 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
|
/// with a password deletes itself only with [proof] (the password's proof
|
||||||
/// and a code, `requireOwnerToDelete`), which the register checks, its
|
/// 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}) =>
|
_ida.Future<void> delete({_ifxctuoq.DeletionProof? proof}) =>
|
||||||
caller.callServerEndpoint<void>(
|
caller.callServerEndpoint<void>(
|
||||||
'halkyon_base.account',
|
'halkyon_base.account',
|
||||||
|
|
@ -242,7 +242,7 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
|
||||||
String? code,
|
String? code,
|
||||||
_isc.UuidValue? keyChallenge,
|
_isc.UuidValue? keyChallenge,
|
||||||
_idt.ByteData? signature,
|
_idt.ByteData? signature,
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
}) => caller.callServerEndpoint<_ippuf33k.ResetPending>(
|
}) => caller.callServerEndpoint<_ippuf33k.ResetPending>(
|
||||||
'halkyon_base.accountRecovery',
|
'halkyon_base.accountRecovery',
|
||||||
'resetAuthenticator',
|
'resetAuthenticator',
|
||||||
|
|
@ -256,7 +256,7 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
|
||||||
'code': code,
|
'code': code,
|
||||||
'keyChallenge': keyChallenge,
|
'keyChallenge': keyChallenge,
|
||||||
'signature': signature,
|
'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
|
/// Sets a new [password] with the recovery phrase's [signature] for
|
||||||
/// [challenge] and a [totpCode] or [recoveryCode]; the keys stay. Every
|
/// [challenge] and a [totpCode] or [recoveryCode]; the keys stay. Every
|
||||||
/// session of the account ends, in every service of the instance, and
|
/// session of the account ends, in every service of the instance, and
|
||||||
/// this one signs in. [host] is the host the device addressed this
|
/// this one signs in. [instanceUrl] is the base URL the device addressed
|
||||||
/// instance by, which the phrase's proof signs; [address] and [language],
|
/// 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
|
/// as the device typed them, serve only to tell the account's owner
|
||||||
/// should codes come to wait. Refusals as `resetPasswordWithPhrase`'s.
|
/// should codes come to wait. Refusals as `resetPasswordWithPhrase`'s.
|
||||||
_ida.Future<_iacc.AuthSuccess> resetPassword({
|
_ida.Future<_iacc.AuthSuccess> resetPassword({
|
||||||
|
|
@ -284,7 +284,7 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
|
||||||
required _i95l60wq.NewPassword password,
|
required _i95l60wq.NewPassword password,
|
||||||
String? totpCode,
|
String? totpCode,
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
required String address,
|
required String address,
|
||||||
required String language,
|
required String language,
|
||||||
}) => caller.callServerEndpoint<_iacc.AuthSuccess>(
|
}) => caller.callServerEndpoint<_iacc.AuthSuccess>(
|
||||||
|
|
@ -296,7 +296,7 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
|
||||||
'password': password,
|
'password': password,
|
||||||
'totpCode': totpCode,
|
'totpCode': totpCode,
|
||||||
'recoveryCode': recoveryCode,
|
'recoveryCode': recoveryCode,
|
||||||
'host': host,
|
'instanceUrl': instanceUrl,
|
||||||
'address': address,
|
'address': address,
|
||||||
'language': language,
|
'language': language,
|
||||||
},
|
},
|
||||||
|
|
@ -558,8 +558,8 @@ class EndpointPasswordSignIn extends _isc.EndpointRef {
|
||||||
/// (or, for a setup never finished, a [code] mailed for [request] to the
|
/// (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
|
/// 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.
|
/// 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
|
/// [instanceUrl] is the base URL the device addressed this instance by,
|
||||||
/// proof is bound to; [address] and [language], the address the sign-in
|
/// 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
|
/// 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
|
/// codes come to wait (the instance keeps no address). Refusals as
|
||||||
/// `completePasswordSignIn`'s.
|
/// `completePasswordSignIn`'s.
|
||||||
|
|
@ -571,7 +571,7 @@ class EndpointPasswordSignIn extends _isc.EndpointRef {
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
_isc.UuidValue? request,
|
_isc.UuidValue? request,
|
||||||
String? code,
|
String? code,
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
required String address,
|
required String address,
|
||||||
required String language,
|
required String language,
|
||||||
}) => caller.callServerEndpoint<_ix90myvx.PasswordSignedIn>(
|
}) => caller.callServerEndpoint<_ix90myvx.PasswordSignedIn>(
|
||||||
|
|
@ -585,7 +585,7 @@ class EndpointPasswordSignIn extends _isc.EndpointRef {
|
||||||
'recoveryCode': recoveryCode,
|
'recoveryCode': recoveryCode,
|
||||||
'request': request,
|
'request': request,
|
||||||
'code': code,
|
'code': code,
|
||||||
'host': host,
|
'instanceUrl': instanceUrl,
|
||||||
'address': address,
|
'address': address,
|
||||||
'language': language,
|
'language': language,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// What deleting an account that has a password takes (ADR 0017; the second
|
||||||
/// reading): the password's SRP proof for a challenge `password.beginChange`
|
/// 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
|
/// change of password does (`OwnerProof`). A session alone deletes only an
|
||||||
/// account without one.
|
/// account without one.
|
||||||
abstract class DeletionProof
|
abstract class DeletionProof
|
||||||
|
|
@ -28,7 +28,7 @@ abstract class DeletionProof
|
||||||
this.recoveryCode,
|
this.recoveryCode,
|
||||||
this.request,
|
this.request,
|
||||||
this.code,
|
this.code,
|
||||||
required this.host,
|
required this.instanceUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory DeletionProof({
|
factory DeletionProof({
|
||||||
|
|
@ -39,7 +39,7 @@ abstract class DeletionProof
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
_isc.UuidValue? request,
|
_isc.UuidValue? request,
|
||||||
String? code,
|
String? code,
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
}) = _DeletionProofImpl;
|
}) = _DeletionProofImpl;
|
||||||
|
|
||||||
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
|
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
|
@ -59,7 +59,7 @@ abstract class DeletionProof
|
||||||
? null
|
? null
|
||||||
: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['request']),
|
: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['request']),
|
||||||
code: jsonSerialization['code'] as String?,
|
code: jsonSerialization['code'] as String?,
|
||||||
host: jsonSerialization['host'] as String,
|
instanceUrl: jsonSerialization['instanceUrl'] as String,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,9 +81,10 @@ abstract class DeletionProof
|
||||||
|
|
||||||
String? code;
|
String? code;
|
||||||
|
|
||||||
/// The host the device addressed the instance by: the server it reached
|
/// The instance's base URL the device addressed (scheme, host, port,
|
||||||
/// checks it is its own before the register binds the proof to it.
|
/// path): the server it reached checks it is its own before the
|
||||||
String host;
|
/// register binds the proof to it.
|
||||||
|
String instanceUrl;
|
||||||
|
|
||||||
/// Returns a shallow copy of this [DeletionProof]
|
/// Returns a shallow copy of this [DeletionProof]
|
||||||
/// with some or all fields replaced by the given arguments.
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
|
@ -96,7 +97,7 @@ abstract class DeletionProof
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
_isc.UuidValue? request,
|
_isc.UuidValue? request,
|
||||||
String? code,
|
String? code,
|
||||||
String? host,
|
String? instanceUrl,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
|
@ -109,7 +110,7 @@ abstract class DeletionProof
|
||||||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||||
if (request != null) 'request': request?.toJson(),
|
if (request != null) 'request': request?.toJson(),
|
||||||
if (code != null) 'code': code,
|
if (code != null) 'code': code,
|
||||||
'host': host,
|
'instanceUrl': instanceUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,7 +125,7 @@ abstract class DeletionProof
|
||||||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||||
if (request != null) 'request': request?.toJson(),
|
if (request != null) 'request': request?.toJson(),
|
||||||
if (code != null) 'code': code,
|
if (code != null) 'code': code,
|
||||||
'host': host,
|
'instanceUrl': instanceUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,7 +146,7 @@ class _DeletionProofImpl extends DeletionProof {
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
_isc.UuidValue? request,
|
_isc.UuidValue? request,
|
||||||
String? code,
|
String? code,
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
}) : super._(
|
}) : super._(
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
clientValue: clientValue,
|
clientValue: clientValue,
|
||||||
|
|
@ -154,7 +155,7 @@ class _DeletionProofImpl extends DeletionProof {
|
||||||
recoveryCode: recoveryCode,
|
recoveryCode: recoveryCode,
|
||||||
request: request,
|
request: request,
|
||||||
code: code,
|
code: code,
|
||||||
host: host,
|
instanceUrl: instanceUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Returns a shallow copy of this [DeletionProof]
|
/// Returns a shallow copy of this [DeletionProof]
|
||||||
|
|
@ -169,7 +170,7 @@ class _DeletionProofImpl extends DeletionProof {
|
||||||
Object? recoveryCode = _Undefined,
|
Object? recoveryCode = _Undefined,
|
||||||
Object? request = _Undefined,
|
Object? request = _Undefined,
|
||||||
Object? code = _Undefined,
|
Object? code = _Undefined,
|
||||||
String? host,
|
String? instanceUrl,
|
||||||
}) {
|
}) {
|
||||||
return DeletionProof(
|
return DeletionProof(
|
||||||
challenge: challenge ?? this.challenge,
|
challenge: challenge ?? this.challenge,
|
||||||
|
|
@ -179,7 +180,7 @@ class _DeletionProofImpl extends DeletionProof {
|
||||||
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
|
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
|
||||||
request: request is _isc.UuidValue? ? request : this.request,
|
request: request is _isc.UuidValue? ? request : this.request,
|
||||||
code: code is String? ? code : this.code,
|
code: code is String? ? code : this.code,
|
||||||
host: host ?? this.host,
|
instanceUrl: instanceUrl ?? this.instanceUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// 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
|
/// (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
|
/// proof for a challenge `password.beginChange` gave, bound to the base URL
|
||||||
/// device addressed, and a TOTP code from one of the account's confirmed
|
/// 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
|
/// authenticators or one of its recovery codes. Adding an authenticator once
|
||||||
/// one is confirmed, removing a confirmed one, new recovery codes, a new
|
/// 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
|
/// 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.challenge,
|
||||||
required this.clientValue,
|
required this.clientValue,
|
||||||
required this.clientProof,
|
required this.clientProof,
|
||||||
required this.host,
|
required this.instanceUrl,
|
||||||
this.totpCode,
|
this.totpCode,
|
||||||
this.recoveryCode,
|
this.recoveryCode,
|
||||||
});
|
});
|
||||||
|
|
@ -36,7 +36,7 @@ abstract class OwnerProof
|
||||||
required _isc.UuidValue challenge,
|
required _isc.UuidValue challenge,
|
||||||
required _idt.ByteData clientValue,
|
required _idt.ByteData clientValue,
|
||||||
required _idt.ByteData clientProof,
|
required _idt.ByteData clientProof,
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
String? totpCode,
|
String? totpCode,
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
}) = _OwnerProofImpl;
|
}) = _OwnerProofImpl;
|
||||||
|
|
@ -52,7 +52,7 @@ abstract class OwnerProof
|
||||||
clientProof: _isc.ByteDataJsonExtension.fromJson(
|
clientProof: _isc.ByteDataJsonExtension.fromJson(
|
||||||
jsonSerialization['clientProof'],
|
jsonSerialization['clientProof'],
|
||||||
),
|
),
|
||||||
host: jsonSerialization['host'] as String,
|
instanceUrl: jsonSerialization['instanceUrl'] as String,
|
||||||
totpCode: jsonSerialization['totpCode'] as String?,
|
totpCode: jsonSerialization['totpCode'] as String?,
|
||||||
recoveryCode: jsonSerialization['recoveryCode'] as String?,
|
recoveryCode: jsonSerialization['recoveryCode'] as String?,
|
||||||
);
|
);
|
||||||
|
|
@ -64,9 +64,10 @@ abstract class OwnerProof
|
||||||
|
|
||||||
_idt.ByteData clientProof;
|
_idt.ByteData clientProof;
|
||||||
|
|
||||||
/// The host the device addressed the instance by: the server it reached
|
/// The instance's base URL the device addressed (scheme, host, port,
|
||||||
/// checks it is its own before the register binds the proof to it.
|
/// path): the server it reached checks it is its own before the
|
||||||
String host;
|
/// register binds the proof to it.
|
||||||
|
String instanceUrl;
|
||||||
|
|
||||||
String? totpCode;
|
String? totpCode;
|
||||||
|
|
||||||
|
|
@ -79,7 +80,7 @@ abstract class OwnerProof
|
||||||
_isc.UuidValue? challenge,
|
_isc.UuidValue? challenge,
|
||||||
_idt.ByteData? clientValue,
|
_idt.ByteData? clientValue,
|
||||||
_idt.ByteData? clientProof,
|
_idt.ByteData? clientProof,
|
||||||
String? host,
|
String? instanceUrl,
|
||||||
String? totpCode,
|
String? totpCode,
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
});
|
});
|
||||||
|
|
@ -90,7 +91,7 @@ abstract class OwnerProof
|
||||||
'challenge': challenge.toJson(),
|
'challenge': challenge.toJson(),
|
||||||
'clientValue': clientValue.toJson(),
|
'clientValue': clientValue.toJson(),
|
||||||
'clientProof': clientProof.toJson(),
|
'clientProof': clientProof.toJson(),
|
||||||
'host': host,
|
'instanceUrl': instanceUrl,
|
||||||
if (totpCode != null) 'totpCode': totpCode,
|
if (totpCode != null) 'totpCode': totpCode,
|
||||||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||||
};
|
};
|
||||||
|
|
@ -103,7 +104,7 @@ abstract class OwnerProof
|
||||||
'challenge': challenge.toJson(),
|
'challenge': challenge.toJson(),
|
||||||
'clientValue': clientValue.toJson(),
|
'clientValue': clientValue.toJson(),
|
||||||
'clientProof': clientProof.toJson(),
|
'clientProof': clientProof.toJson(),
|
||||||
'host': host,
|
'instanceUrl': instanceUrl,
|
||||||
if (totpCode != null) 'totpCode': totpCode,
|
if (totpCode != null) 'totpCode': totpCode,
|
||||||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||||
};
|
};
|
||||||
|
|
@ -122,14 +123,14 @@ class _OwnerProofImpl extends OwnerProof {
|
||||||
required _isc.UuidValue challenge,
|
required _isc.UuidValue challenge,
|
||||||
required _idt.ByteData clientValue,
|
required _idt.ByteData clientValue,
|
||||||
required _idt.ByteData clientProof,
|
required _idt.ByteData clientProof,
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
String? totpCode,
|
String? totpCode,
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
}) : super._(
|
}) : super._(
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
clientValue: clientValue,
|
clientValue: clientValue,
|
||||||
clientProof: clientProof,
|
clientProof: clientProof,
|
||||||
host: host,
|
instanceUrl: instanceUrl,
|
||||||
totpCode: totpCode,
|
totpCode: totpCode,
|
||||||
recoveryCode: recoveryCode,
|
recoveryCode: recoveryCode,
|
||||||
);
|
);
|
||||||
|
|
@ -142,7 +143,7 @@ class _OwnerProofImpl extends OwnerProof {
|
||||||
_isc.UuidValue? challenge,
|
_isc.UuidValue? challenge,
|
||||||
_idt.ByteData? clientValue,
|
_idt.ByteData? clientValue,
|
||||||
_idt.ByteData? clientProof,
|
_idt.ByteData? clientProof,
|
||||||
String? host,
|
String? instanceUrl,
|
||||||
Object? totpCode = _Undefined,
|
Object? totpCode = _Undefined,
|
||||||
Object? recoveryCode = _Undefined,
|
Object? recoveryCode = _Undefined,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -150,7 +151,7 @@ class _OwnerProofImpl extends OwnerProof {
|
||||||
challenge: challenge ?? this.challenge,
|
challenge: challenge ?? this.challenge,
|
||||||
clientValue: clientValue ?? this.clientValue.clone(),
|
clientValue: clientValue ?? this.clientValue.clone(),
|
||||||
clientProof: clientProof ?? this.clientProof.clone(),
|
clientProof: clientProof ?? this.clientProof.clone(),
|
||||||
host: host ?? this.host,
|
instanceUrl: instanceUrl ?? this.instanceUrl,
|
||||||
totpCode: totpCode is String? ? totpCode : this.totpCode,
|
totpCode: totpCode is String? ? totpCode : this.totpCode,
|
||||||
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
|
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,90 @@ import 'dart:typed_data';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
||||||
// What the device proves and signs with, bound to the instance it addresses
|
// 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
|
// and to what the proof is for (ADR 0017; the second reading's M6, the full
|
||||||
// into adding can't relay a sign-in or a phrase's proof to the real one,
|
// reading's device medium 1): a hostile instance a person was lured into
|
||||||
// which takes them only for its own host, nor swap in a password of its
|
// adding can't relay a sign-in or a phrase's proof to the real one, which
|
||||||
// own. halkyon_base_server's `key_proof.dart` makes the same bytes.
|
// 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],
|
/// What a proof of the password is made for: the words SRP's identity
|
||||||
/// the instance as the device addresses it, in lower case. SRP's x comes
|
/// carries ([label]). The register takes a proof only for the purpose it
|
||||||
/// from Argon2id over the password and salt alone, so I only binds the
|
/// works out from the call that brings it, so a proof made for one never
|
||||||
/// proof: to this sign-in and to this instance.
|
/// counts for another.
|
||||||
Uint8List passwordIdentity(List<int> challenge, String host) => _framed([
|
enum PasswordProofPurpose(final String label) {
|
||||||
utf8.encode('halkyon srp identity v1'),
|
/// 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,
|
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
|
/// What proving the recovery phrase is for, by the words the message
|
||||||
/// carries ([label]).
|
/// carries ([label]).
|
||||||
enum KeyProofPurpose(final String 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
|
/// 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),
|
/// phrase: a label naming the format, the challenge's ID (its 16 bytes),
|
||||||
/// the register's nonce, the [host] the device addresses, the [purpose],
|
/// the register's nonce, the [instanceUrl] the device addresses
|
||||||
/// and the [digest] of what it sets ([newPasswordDigest] for a new
|
/// ([canonicalInstanceUrl]), the [purpose], and the [digest] of what it
|
||||||
/// password; none for a reset of the authenticators), each framed by its
|
/// sets ([newPasswordDigest] for a new password; none for a reset of the
|
||||||
/// length.
|
/// authenticators), each framed by its length.
|
||||||
Uint8List keyProofMessage(
|
Uint8List keyProofMessage(
|
||||||
List<int> challenge,
|
List<int> challenge,
|
||||||
List<int> nonce, {
|
List<int> nonce, {
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
required KeyProofPurpose purpose,
|
required KeyProofPurpose purpose,
|
||||||
List<int> digest = const [],
|
List<int> digest = const [],
|
||||||
}) => _framed([
|
}) => _framed([
|
||||||
utf8.encode('halkyon key proof v2'),
|
utf8.encode('halkyon key proof v3'),
|
||||||
challenge,
|
challenge,
|
||||||
nonce,
|
nonce,
|
||||||
utf8.encode(host.toLowerCase()),
|
utf8.encode(canonicalInstanceUrl(instanceUrl)),
|
||||||
utf8.encode(purpose.label),
|
utf8.encode(purpose.label),
|
||||||
digest,
|
digest,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/// The SHA-256 of a new password as the device sends it (the salt,
|
/// 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:
|
/// 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
|
/// what a proof for a new password binds, so nobody between the device and
|
||||||
/// device and the register can swap in another.
|
/// the register can swap in another.
|
||||||
Uint8List newPasswordDigest({
|
Uint8List newPasswordDigest({
|
||||||
required List<int> salt,
|
required List<int> salt,
|
||||||
required String algorithm,
|
required String algorithm,
|
||||||
|
|
@ -62,22 +131,38 @@ Uint8List newPasswordDigest({
|
||||||
required List<int> verifier,
|
required List<int> verifier,
|
||||||
required List<int> keysNonce,
|
required List<int> keysNonce,
|
||||||
required List<int> keysCipherText,
|
required List<int> keysCipherText,
|
||||||
}) => Uint8List.fromList(
|
}) => _sha256([
|
||||||
sha256
|
utf8.encode('halkyon new password v1'),
|
||||||
.convert(
|
salt,
|
||||||
_framed([
|
utf8.encode(algorithm),
|
||||||
utf8.encode('halkyon new password v1'),
|
utf8.encode('$opsLimit'),
|
||||||
salt,
|
utf8.encode('$memLimit'),
|
||||||
utf8.encode(algorithm),
|
verifier,
|
||||||
utf8.encode('$opsLimit'),
|
keysNonce,
|
||||||
utf8.encode('$memLimit'),
|
keysCipherText,
|
||||||
verifier,
|
]);
|
||||||
keysNonce,
|
|
||||||
keysCipherText,
|
/// 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.
|
||||||
.bytes,
|
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
|
/// [parts], each after its length in four bytes, big-endian, so no part
|
||||||
/// can run into the next.
|
/// can run into the next.
|
||||||
|
|
|
||||||
|
|
@ -19,40 +19,132 @@ void main() {
|
||||||
final challenge = List.generate(16, (i) => i);
|
final challenge = List.generate(16, (i) => i);
|
||||||
final nonce = List.generate(32, (i) => 200 + i % 50);
|
final nonce = List.generate(32, (i) => 200 + i % 50);
|
||||||
|
|
||||||
test("SRP's identity is the label, the challenge and the host the device "
|
test("SRP's identity is the label, the challenge, the instance's base URL "
|
||||||
'addresses, each framed by its length, the host in lower case', () {
|
'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(
|
expect(
|
||||||
passwordIdentity(challenge, 'Learn.Halkyon.EU'),
|
passwordIdentity(
|
||||||
_framed([
|
|
||||||
utf8.encode('halkyon srp identity v1'),
|
|
||||||
challenge,
|
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(
|
expect(
|
||||||
passwordIdentity(challenge, 'learn.halkyon.eu'),
|
passwordIdentity(challenge, 'https://learn.halkyon.eu/'),
|
||||||
isNot(passwordIdentity(challenge, 'learn.halkyon.eu.evil.example')),
|
_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 '
|
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 '
|
"instance's base URL, what it proves the phrase for and what that "
|
||||||
'the register checks it', () {
|
'sets, framed, as the register checks it', () {
|
||||||
final digest = List.generate(32, (i) => i * 3);
|
final digest = List.generate(32, (i) => i * 3);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
keyProofMessage(
|
keyProofMessage(
|
||||||
challenge,
|
challenge,
|
||||||
nonce,
|
nonce,
|
||||||
host: 'learn.halkyon.eu',
|
instanceUrl: 'https://learn.halkyon.eu/',
|
||||||
purpose: KeyProofPurpose.passwordReset,
|
purpose: KeyProofPurpose.passwordReset,
|
||||||
digest: digest,
|
digest: digest,
|
||||||
),
|
),
|
||||||
_framed([
|
_framed([
|
||||||
utf8.encode('halkyon key proof v2'),
|
utf8.encode('halkyon key proof v3'),
|
||||||
challenge,
|
challenge,
|
||||||
nonce,
|
nonce,
|
||||||
utf8.encode('learn.halkyon.eu'),
|
utf8.encode('https://learn.halkyon.eu/'),
|
||||||
utf8.encode('password reset'),
|
utf8.encode('password reset'),
|
||||||
digest,
|
digest,
|
||||||
]),
|
]),
|
||||||
|
|
@ -61,14 +153,14 @@ void main() {
|
||||||
keyProofMessage(
|
keyProofMessage(
|
||||||
challenge,
|
challenge,
|
||||||
nonce,
|
nonce,
|
||||||
host: 'learn.halkyon.eu',
|
instanceUrl: 'https://learn.halkyon.eu/',
|
||||||
purpose: KeyProofPurpose.authenticatorReset,
|
purpose: KeyProofPurpose.authenticatorReset,
|
||||||
),
|
),
|
||||||
_framed([
|
_framed([
|
||||||
utf8.encode('halkyon key proof v2'),
|
utf8.encode('halkyon key proof v3'),
|
||||||
challenge,
|
challenge,
|
||||||
nonce,
|
nonce,
|
||||||
utf8.encode('learn.halkyon.eu'),
|
utf8.encode('https://learn.halkyon.eu/'),
|
||||||
utf8.encode('authenticator reset'),
|
utf8.encode('authenticator reset'),
|
||||||
<int>[],
|
<int>[],
|
||||||
]),
|
]),
|
||||||
|
|
@ -117,4 +209,49 @@ void main() {
|
||||||
isNot(digest),
|
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)),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,16 @@ export 'src/password/authenticators.dart'
|
||||||
maxAuthenticators,
|
maxAuthenticators,
|
||||||
unconfirmedAuthenticatorLifetime;
|
unconfirmedAuthenticatorLifetime;
|
||||||
export 'src/password/key_proof.dart'
|
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/password_config.dart';
|
||||||
export 'src/password/passwords.dart'
|
export 'src/password/passwords.dart'
|
||||||
show
|
show
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,13 @@ class AccountEndpoint extends Endpoint {
|
||||||
/// data at once; an anonymous person is this service's alone. An account
|
/// 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
|
/// with a password deletes itself only with [proof] (the password's proof
|
||||||
/// and a code, `requireOwnerToDelete`), which the register checks, its
|
/// 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 {
|
Future<void> delete(Session session, {DeletionProof? proof}) async {
|
||||||
final pod = session.serverpod;
|
final pod = session.serverpod;
|
||||||
final person = session.authenticated!.authUserId;
|
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
|
// The proof counts for this session (`asOwnerProofTry`), as every
|
||||||
// change of how the account is secured does.
|
// change of how the account is secured does.
|
||||||
await asOwnerProofTry(session, bound, () async {
|
await asOwnerProofTry(session, bound, () async {
|
||||||
|
|
|
||||||
|
|
@ -360,8 +360,8 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
type: _is.getType<_idt.ByteData?>(),
|
type: _is.getType<_idt.ByteData?>(),
|
||||||
nullable: true,
|
nullable: true,
|
||||||
),
|
),
|
||||||
'host': _is.ParameterDescription(
|
'instanceUrl': _is.ParameterDescription(
|
||||||
name: 'host',
|
name: 'instanceUrl',
|
||||||
type: _is.getType<String>(),
|
type: _is.getType<String>(),
|
||||||
nullable: false,
|
nullable: false,
|
||||||
),
|
),
|
||||||
|
|
@ -384,7 +384,7 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
code: params['code'],
|
code: params['code'],
|
||||||
keyChallenge: params['keyChallenge'],
|
keyChallenge: params['keyChallenge'],
|
||||||
signature: params['signature'],
|
signature: params['signature'],
|
||||||
host: params['host'],
|
instanceUrl: params['instanceUrl'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
'beginKeyProof': _is.MethodConnector(
|
'beginKeyProof': _is.MethodConnector(
|
||||||
|
|
@ -436,8 +436,8 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
type: _is.getType<String?>(),
|
type: _is.getType<String?>(),
|
||||||
nullable: true,
|
nullable: true,
|
||||||
),
|
),
|
||||||
'host': _is.ParameterDescription(
|
'instanceUrl': _is.ParameterDescription(
|
||||||
name: 'host',
|
name: 'instanceUrl',
|
||||||
type: _is.getType<String>(),
|
type: _is.getType<String>(),
|
||||||
nullable: false,
|
nullable: false,
|
||||||
),
|
),
|
||||||
|
|
@ -466,7 +466,7 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
password: params['password'],
|
password: params['password'],
|
||||||
totpCode: params['totpCode'],
|
totpCode: params['totpCode'],
|
||||||
recoveryCode: params['recoveryCode'],
|
recoveryCode: params['recoveryCode'],
|
||||||
host: params['host'],
|
instanceUrl: params['instanceUrl'],
|
||||||
address: params['address'],
|
address: params['address'],
|
||||||
language: params['language'],
|
language: params['language'],
|
||||||
),
|
),
|
||||||
|
|
@ -872,8 +872,8 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
type: _is.getType<String?>(),
|
type: _is.getType<String?>(),
|
||||||
nullable: true,
|
nullable: true,
|
||||||
),
|
),
|
||||||
'host': _is.ParameterDescription(
|
'instanceUrl': _is.ParameterDescription(
|
||||||
name: 'host',
|
name: 'instanceUrl',
|
||||||
type: _is.getType<String>(),
|
type: _is.getType<String>(),
|
||||||
nullable: false,
|
nullable: false,
|
||||||
),
|
),
|
||||||
|
|
@ -904,7 +904,7 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
recoveryCode: params['recoveryCode'],
|
recoveryCode: params['recoveryCode'],
|
||||||
request: params['request'],
|
request: params['request'],
|
||||||
code: params['code'],
|
code: params['code'],
|
||||||
host: params['host'],
|
instanceUrl: params['instanceUrl'],
|
||||||
address: params['address'],
|
address: params['address'],
|
||||||
language: params['language'],
|
language: params['language'],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import 'package:serverpod/serverpod.dart' as _is;
|
||||||
|
|
||||||
/// What deleting an account that has a password takes (ADR 0017; the second
|
/// What deleting an account that has a password takes (ADR 0017; the second
|
||||||
/// reading): the password's SRP proof for a challenge `password.beginChange`
|
/// 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
|
/// change of password does (`OwnerProof`). A session alone deletes only an
|
||||||
/// account without one.
|
/// account without one.
|
||||||
abstract class DeletionProof
|
abstract class DeletionProof
|
||||||
|
|
@ -28,7 +28,7 @@ abstract class DeletionProof
|
||||||
this.recoveryCode,
|
this.recoveryCode,
|
||||||
this.request,
|
this.request,
|
||||||
this.code,
|
this.code,
|
||||||
required this.host,
|
required this.instanceUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory DeletionProof({
|
factory DeletionProof({
|
||||||
|
|
@ -39,7 +39,7 @@ abstract class DeletionProof
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
_is.UuidValue? request,
|
_is.UuidValue? request,
|
||||||
String? code,
|
String? code,
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
}) = _DeletionProofImpl;
|
}) = _DeletionProofImpl;
|
||||||
|
|
||||||
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
|
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
|
@ -59,7 +59,7 @@ abstract class DeletionProof
|
||||||
? null
|
? null
|
||||||
: _is.UuidValueJsonExtension.fromJson(jsonSerialization['request']),
|
: _is.UuidValueJsonExtension.fromJson(jsonSerialization['request']),
|
||||||
code: jsonSerialization['code'] as String?,
|
code: jsonSerialization['code'] as String?,
|
||||||
host: jsonSerialization['host'] as String,
|
instanceUrl: jsonSerialization['instanceUrl'] as String,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,9 +81,10 @@ abstract class DeletionProof
|
||||||
|
|
||||||
String? code;
|
String? code;
|
||||||
|
|
||||||
/// The host the device addressed the instance by: the server it reached
|
/// The instance's base URL the device addressed (scheme, host, port,
|
||||||
/// checks it is its own before the register binds the proof to it.
|
/// path): the server it reached checks it is its own before the
|
||||||
String host;
|
/// register binds the proof to it.
|
||||||
|
String instanceUrl;
|
||||||
|
|
||||||
/// Returns a shallow copy of this [DeletionProof]
|
/// Returns a shallow copy of this [DeletionProof]
|
||||||
/// with some or all fields replaced by the given arguments.
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
|
@ -96,7 +97,7 @@ abstract class DeletionProof
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
_is.UuidValue? request,
|
_is.UuidValue? request,
|
||||||
String? code,
|
String? code,
|
||||||
String? host,
|
String? instanceUrl,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
|
@ -109,7 +110,7 @@ abstract class DeletionProof
|
||||||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||||
if (request != null) 'request': request?.toJson(),
|
if (request != null) 'request': request?.toJson(),
|
||||||
if (code != null) 'code': code,
|
if (code != null) 'code': code,
|
||||||
'host': host,
|
'instanceUrl': instanceUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,7 +125,7 @@ abstract class DeletionProof
|
||||||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||||
if (request != null) 'request': request?.toJson(),
|
if (request != null) 'request': request?.toJson(),
|
||||||
if (code != null) 'code': code,
|
if (code != null) 'code': code,
|
||||||
'host': host,
|
'instanceUrl': instanceUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,7 +146,7 @@ class _DeletionProofImpl extends DeletionProof {
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
_is.UuidValue? request,
|
_is.UuidValue? request,
|
||||||
String? code,
|
String? code,
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
}) : super._(
|
}) : super._(
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
clientValue: clientValue,
|
clientValue: clientValue,
|
||||||
|
|
@ -154,7 +155,7 @@ class _DeletionProofImpl extends DeletionProof {
|
||||||
recoveryCode: recoveryCode,
|
recoveryCode: recoveryCode,
|
||||||
request: request,
|
request: request,
|
||||||
code: code,
|
code: code,
|
||||||
host: host,
|
instanceUrl: instanceUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Returns a shallow copy of this [DeletionProof]
|
/// Returns a shallow copy of this [DeletionProof]
|
||||||
|
|
@ -169,7 +170,7 @@ class _DeletionProofImpl extends DeletionProof {
|
||||||
Object? recoveryCode = _Undefined,
|
Object? recoveryCode = _Undefined,
|
||||||
Object? request = _Undefined,
|
Object? request = _Undefined,
|
||||||
Object? code = _Undefined,
|
Object? code = _Undefined,
|
||||||
String? host,
|
String? instanceUrl,
|
||||||
}) {
|
}) {
|
||||||
return DeletionProof(
|
return DeletionProof(
|
||||||
challenge: challenge ?? this.challenge,
|
challenge: challenge ?? this.challenge,
|
||||||
|
|
@ -179,7 +180,7 @@ class _DeletionProofImpl extends DeletionProof {
|
||||||
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
|
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
|
||||||
request: request is _is.UuidValue? ? request : this.request,
|
request: request is _is.UuidValue? ? request : this.request,
|
||||||
code: code is String? ? code : this.code,
|
code: code is String? ? code : this.code,
|
||||||
host: host ?? this.host,
|
instanceUrl: instanceUrl ?? this.instanceUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ import 'package:serverpod/serverpod.dart' as _is;
|
||||||
|
|
||||||
/// What a signed-in owner proves to change how their account is secured
|
/// 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
|
/// (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
|
/// proof for a challenge `password.beginChange` gave, bound to the base URL
|
||||||
/// device addressed, and a TOTP code from one of the account's confirmed
|
/// 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
|
/// authenticators or one of its recovery codes. Adding an authenticator once
|
||||||
/// one is confirmed, removing a confirmed one, new recovery codes, a new
|
/// 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
|
/// 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.challenge,
|
||||||
required this.clientValue,
|
required this.clientValue,
|
||||||
required this.clientProof,
|
required this.clientProof,
|
||||||
required this.host,
|
required this.instanceUrl,
|
||||||
this.totpCode,
|
this.totpCode,
|
||||||
this.recoveryCode,
|
this.recoveryCode,
|
||||||
});
|
});
|
||||||
|
|
@ -36,7 +36,7 @@ abstract class OwnerProof
|
||||||
required _is.UuidValue challenge,
|
required _is.UuidValue challenge,
|
||||||
required _idt.ByteData clientValue,
|
required _idt.ByteData clientValue,
|
||||||
required _idt.ByteData clientProof,
|
required _idt.ByteData clientProof,
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
String? totpCode,
|
String? totpCode,
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
}) = _OwnerProofImpl;
|
}) = _OwnerProofImpl;
|
||||||
|
|
@ -52,7 +52,7 @@ abstract class OwnerProof
|
||||||
clientProof: _is.ByteDataJsonExtension.fromJson(
|
clientProof: _is.ByteDataJsonExtension.fromJson(
|
||||||
jsonSerialization['clientProof'],
|
jsonSerialization['clientProof'],
|
||||||
),
|
),
|
||||||
host: jsonSerialization['host'] as String,
|
instanceUrl: jsonSerialization['instanceUrl'] as String,
|
||||||
totpCode: jsonSerialization['totpCode'] as String?,
|
totpCode: jsonSerialization['totpCode'] as String?,
|
||||||
recoveryCode: jsonSerialization['recoveryCode'] as String?,
|
recoveryCode: jsonSerialization['recoveryCode'] as String?,
|
||||||
);
|
);
|
||||||
|
|
@ -64,9 +64,10 @@ abstract class OwnerProof
|
||||||
|
|
||||||
_idt.ByteData clientProof;
|
_idt.ByteData clientProof;
|
||||||
|
|
||||||
/// The host the device addressed the instance by: the server it reached
|
/// The instance's base URL the device addressed (scheme, host, port,
|
||||||
/// checks it is its own before the register binds the proof to it.
|
/// path): the server it reached checks it is its own before the
|
||||||
String host;
|
/// register binds the proof to it.
|
||||||
|
String instanceUrl;
|
||||||
|
|
||||||
String? totpCode;
|
String? totpCode;
|
||||||
|
|
||||||
|
|
@ -79,7 +80,7 @@ abstract class OwnerProof
|
||||||
_is.UuidValue? challenge,
|
_is.UuidValue? challenge,
|
||||||
_idt.ByteData? clientValue,
|
_idt.ByteData? clientValue,
|
||||||
_idt.ByteData? clientProof,
|
_idt.ByteData? clientProof,
|
||||||
String? host,
|
String? instanceUrl,
|
||||||
String? totpCode,
|
String? totpCode,
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
});
|
});
|
||||||
|
|
@ -90,7 +91,7 @@ abstract class OwnerProof
|
||||||
'challenge': challenge.toJson(),
|
'challenge': challenge.toJson(),
|
||||||
'clientValue': clientValue.toJson(),
|
'clientValue': clientValue.toJson(),
|
||||||
'clientProof': clientProof.toJson(),
|
'clientProof': clientProof.toJson(),
|
||||||
'host': host,
|
'instanceUrl': instanceUrl,
|
||||||
if (totpCode != null) 'totpCode': totpCode,
|
if (totpCode != null) 'totpCode': totpCode,
|
||||||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||||
};
|
};
|
||||||
|
|
@ -103,7 +104,7 @@ abstract class OwnerProof
|
||||||
'challenge': challenge.toJson(),
|
'challenge': challenge.toJson(),
|
||||||
'clientValue': clientValue.toJson(),
|
'clientValue': clientValue.toJson(),
|
||||||
'clientProof': clientProof.toJson(),
|
'clientProof': clientProof.toJson(),
|
||||||
'host': host,
|
'instanceUrl': instanceUrl,
|
||||||
if (totpCode != null) 'totpCode': totpCode,
|
if (totpCode != null) 'totpCode': totpCode,
|
||||||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||||
};
|
};
|
||||||
|
|
@ -122,14 +123,14 @@ class _OwnerProofImpl extends OwnerProof {
|
||||||
required _is.UuidValue challenge,
|
required _is.UuidValue challenge,
|
||||||
required _idt.ByteData clientValue,
|
required _idt.ByteData clientValue,
|
||||||
required _idt.ByteData clientProof,
|
required _idt.ByteData clientProof,
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
String? totpCode,
|
String? totpCode,
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
}) : super._(
|
}) : super._(
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
clientValue: clientValue,
|
clientValue: clientValue,
|
||||||
clientProof: clientProof,
|
clientProof: clientProof,
|
||||||
host: host,
|
instanceUrl: instanceUrl,
|
||||||
totpCode: totpCode,
|
totpCode: totpCode,
|
||||||
recoveryCode: recoveryCode,
|
recoveryCode: recoveryCode,
|
||||||
);
|
);
|
||||||
|
|
@ -142,7 +143,7 @@ class _OwnerProofImpl extends OwnerProof {
|
||||||
_is.UuidValue? challenge,
|
_is.UuidValue? challenge,
|
||||||
_idt.ByteData? clientValue,
|
_idt.ByteData? clientValue,
|
||||||
_idt.ByteData? clientProof,
|
_idt.ByteData? clientProof,
|
||||||
String? host,
|
String? instanceUrl,
|
||||||
Object? totpCode = _Undefined,
|
Object? totpCode = _Undefined,
|
||||||
Object? recoveryCode = _Undefined,
|
Object? recoveryCode = _Undefined,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -150,7 +151,7 @@ class _OwnerProofImpl extends OwnerProof {
|
||||||
challenge: challenge ?? this.challenge,
|
challenge: challenge ?? this.challenge,
|
||||||
clientValue: clientValue ?? this.clientValue.clone(),
|
clientValue: clientValue ?? this.clientValue.clone(),
|
||||||
clientProof: clientProof ?? this.clientProof.clone(),
|
clientProof: clientProof ?? this.clientProof.clone(),
|
||||||
host: host ?? this.host,
|
instanceUrl: instanceUrl ?? this.instanceUrl,
|
||||||
totpCode: totpCode is String? ? totpCode : this.totpCode,
|
totpCode: totpCode is String? ? totpCode : this.totpCode,
|
||||||
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
|
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ Future<ResetPending> resetAuthenticator(
|
||||||
String? code,
|
String? code,
|
||||||
UuidValue? keyChallenge,
|
UuidValue? keyChallenge,
|
||||||
ByteData? signature,
|
ByteData? signature,
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
}) async {
|
}) async {
|
||||||
final pod = session.serverpod;
|
final pod = session.serverpod;
|
||||||
final keys = requirePasswordKeys(pod);
|
final keys = requirePasswordKeys(pod);
|
||||||
|
|
@ -62,7 +62,7 @@ Future<ResetPending> resetAuthenticator(
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
clientValue: clientValue,
|
clientValue: clientValue,
|
||||||
clientProof: clientProof,
|
clientProof: clientProof,
|
||||||
host: host,
|
instanceUrl: instanceUrl,
|
||||||
);
|
);
|
||||||
final to = normalizedAddress(address);
|
final to = normalizedAddress(address);
|
||||||
if (to == null || addressHash(pod, to) != proved.addressHash) {
|
if (to == null || addressHash(pod, to) != proved.addressHash) {
|
||||||
|
|
@ -85,7 +85,7 @@ Future<ResetPending> resetAuthenticator(
|
||||||
keys,
|
keys,
|
||||||
challenge: keyChallenge,
|
challenge: keyChallenge,
|
||||||
signature: signature,
|
signature: signature,
|
||||||
host: host,
|
instanceUrl: instanceUrl,
|
||||||
purpose: KeyProofPurpose.authenticatorReset,
|
purpose: KeyProofPurpose.authenticatorReset,
|
||||||
);
|
);
|
||||||
if (owner.authUserId != account) {
|
if (owner.authUserId != account) {
|
||||||
|
|
@ -127,7 +127,7 @@ Future<AccountSessionsEnded> resetPasswordWithPhrase(
|
||||||
required NewPassword password,
|
required NewPassword password,
|
||||||
String? totpCode,
|
String? totpCode,
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
required String address,
|
required String address,
|
||||||
required String language,
|
required String language,
|
||||||
}) async {
|
}) async {
|
||||||
|
|
@ -140,7 +140,7 @@ Future<AccountSessionsEnded> resetPasswordWithPhrase(
|
||||||
keys,
|
keys,
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
signature: signature,
|
signature: signature,
|
||||||
host: host,
|
instanceUrl: instanceUrl,
|
||||||
purpose: KeyProofPurpose.passwordReset,
|
purpose: KeyProofPurpose.passwordReset,
|
||||||
digest: newPasswordDigest(password),
|
digest: newPasswordDigest(password),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ class AccountRecoveryEndpoint extends Endpoint {
|
||||||
String? code,
|
String? code,
|
||||||
UuidValue? keyChallenge,
|
UuidValue? keyChallenge,
|
||||||
ByteData? signature,
|
ByteData? signature,
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
}) => AccountRegister.of(session.serverpod).resetAuthenticator(
|
}) => AccountRegister.of(session.serverpod).resetAuthenticator(
|
||||||
session,
|
session,
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
|
|
@ -43,7 +43,7 @@ class AccountRecoveryEndpoint extends Endpoint {
|
||||||
code: code,
|
code: code,
|
||||||
keyChallenge: keyChallenge,
|
keyChallenge: keyChallenge,
|
||||||
signature: signature,
|
signature: signature,
|
||||||
host: deviceHostOn(session.serverpod, host),
|
instanceUrl: deviceInstanceUrlOn(session.serverpod, instanceUrl),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// The first step of proving the recovery phrase for [address]: the
|
/// 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
|
/// Sets a new [password] with the recovery phrase's [signature] for
|
||||||
/// [challenge] and a [totpCode] or [recoveryCode]; the keys stay. Every
|
/// [challenge] and a [totpCode] or [recoveryCode]; the keys stay. Every
|
||||||
/// session of the account ends, in every service of the instance, and
|
/// session of the account ends, in every service of the instance, and
|
||||||
/// this one signs in. [host] is the host the device addressed this
|
/// this one signs in. [instanceUrl] is the base URL the device addressed
|
||||||
/// instance by, which the phrase's proof signs; [address] and [language],
|
/// 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
|
/// as the device typed them, serve only to tell the account's owner
|
||||||
/// should codes come to wait. Refusals as `resetPasswordWithPhrase`'s.
|
/// should codes come to wait. Refusals as `resetPasswordWithPhrase`'s.
|
||||||
Future<AuthSuccess> resetPassword(
|
Future<AuthSuccess> resetPassword(
|
||||||
|
|
@ -70,7 +70,7 @@ class AccountRecoveryEndpoint extends Endpoint {
|
||||||
required NewPassword password,
|
required NewPassword password,
|
||||||
String? totpCode,
|
String? totpCode,
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
required String host,
|
required String instanceUrl,
|
||||||
required String address,
|
required String address,
|
||||||
required String language,
|
required String language,
|
||||||
}) async {
|
}) async {
|
||||||
|
|
@ -82,7 +82,7 @@ class AccountRecoveryEndpoint extends Endpoint {
|
||||||
password: password,
|
password: password,
|
||||||
totpCode: totpCode,
|
totpCode: totpCode,
|
||||||
recoveryCode: recoveryCode,
|
recoveryCode: recoveryCode,
|
||||||
host: deviceHostOn(session.serverpod, host),
|
instanceUrl: deviceInstanceUrlOn(session.serverpod, instanceUrl),
|
||||||
address: address,
|
address: address,
|
||||||
language: language,
|
language: language,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue