feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20)

Every change takes the password's proof by its own proof key (the
recovery phrase's for a lost password) and a SimpleLogin sign-in of the
last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR
0017's take the password and a code. The sign-in is checked before the
proof and taken with the change, so a wrong proof leaves it standing and
each sign-in serves one change.

- `keys.set(attempt, setup, handle?)`: the first keys of an account that
  SimpleLogin was added to (anonymous before, or signing in with GitHub
  or a code), with the handle where it has none; the sign-in alone, as
  there is no password to prove yet.
- `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a
  lost password, with the phrase): every other session ends, in every
  service, as ADR 0017's change has it.
- `keys.setRecoveryKeys`: a new phrase.
- `account.delete(keysProof)`: an account without SRP's verifier deletes
  itself with the proof and the sign-in; `requireOwnerToDelete` no longer
  asks such an account for SRP.
- `newKeysPasswordDigest` on both sides, held to each other by a test; a
  challenge purpose of its own for the phrase's proof while signed in.
- Members ask the register for all of it; `RemoteRegister` and the
  instance API carry the five new operations and the deletion's proof.

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-23 22:11:49 +02:00
commit 7c58d23424
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
24 changed files with 3163 additions and 78 deletions

View file

@ -37,6 +37,7 @@ export 'src/password/key_proof.dart'
canonicalInstanceUrl,
isAccountSignature,
keyProofMessage,
newKeysPasswordDigest,
newPasswordDigest,
newPhraseDigest,
passwordIdentity,

View file

@ -21,14 +21,25 @@ class AccountEndpoint extends Endpoint {
/// register deletes it and tells every member, this one deleting its own
/// 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
/// base URL the one the device addressed here.
Future<void> delete(Session session, {DeletionProof? proof}) async {
/// and a code, `requireOwnerToDelete`), one that signs in with SimpleLogin
/// only with [keysProof] (the password's proof for a challenge of
/// `keys.begin` and a SimpleLogin sign-in of the last ten minutes on the
/// device, Halkyon Learn's ADR 0020, D20), which the register checks,
/// their base URL the one the device addressed here.
Future<void> delete(
Session session, {
DeletionProof? proof,
KeysOwnerProof? keysProof,
}) async {
final pod = session.serverpod;
final person = session.authenticated!.authUserId;
final bound = proof?.copyWith(
final boundPassword = proof?.copyWith(
instanceUrl: deviceInstanceUrlOn(pod, proof.instanceUrl),
);
final boundKeys = keysProof?.copyWith(
instanceUrl: deviceInstanceUrlOn(pod, keysProof.instanceUrl),
);
final bound = boundPassword ?? boundKeys;
// The proof counts for this session (`asOwnerProofTry`), as every
// change of how the account is secured does: only around what checks
// it, so asking the register whose account it is spends no try (the
@ -37,14 +48,24 @@ class AccountEndpoint extends Endpoint {
await asOwnerProofTry(
session,
bound,
() => requireOwnerToDelete(session, person, bound),
() => requireOwnerToDelete(
session,
person,
boundPassword,
keysProof: boundKeys,
),
);
} else if (await _registersAccount(session, person)) {
final register = AccountRegister.of(pod);
await asOwnerProofTry(
session,
bound,
() => register.deleteAccount(session, person, proof: bound),
() => register.deleteAccount(
session,
person,
proof: boundPassword,
keysProof: boundKeys,
),
);
}
await deletePerson(session, person);

View file

@ -13,6 +13,10 @@
import 'dart:typed_data' as _idt;
import 'package:halkyon_base_server/src/generated/auth/email_code_purpose.dart'
as _i5775c0l;
import 'package:halkyon_base_server/src/generated/keys/keys_owner_proof.dart'
as _i561ypzv;
import 'package:halkyon_base_server/src/generated/keys/keys_password.dart'
as _iri6zrp4;
import 'package:halkyon_base_server/src/generated/keys/keys_proof.dart'
as _iy592cuk;
import 'package:halkyon_base_server/src/generated/keys/keys_setup.dart'
@ -150,6 +154,11 @@ class Endpoints extends _is.EndpointDispatch {
type: _is.getType<_i619l6vv.DeletionProof?>(),
nullable: true,
),
'keysProof': _is.ParameterDescription(
name: 'keysProof',
type: _is.getType<_i561ypzv.KeysOwnerProof?>(),
nullable: true,
),
},
call:
(
@ -159,6 +168,7 @@ class Endpoints extends _is.EndpointDispatch {
(endpoints['account'] as _ii6ycf6v.AccountEndpoint).delete(
session,
proof: params['proof'],
keysProof: params['keysProof'],
),
),
'signInWays': _is.MethodConnector(
@ -361,6 +371,127 @@ class Endpoints extends _is.EndpointDispatch {
params['proof'],
),
),
'set': _is.MethodConnector(
name: 'set',
params: {
'attempt': _is.ParameterDescription(
name: 'attempt',
type: _is.getType<_is.UuidValue>(),
nullable: false,
),
'setup': _is.ParameterDescription(
name: 'setup',
type: _is.getType<_ii64ejy6.KeysSetup>(),
nullable: false,
),
'handle': _is.ParameterDescription(
name: 'handle',
type: _is.getType<String?>(),
nullable: true,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['keys'] as _icowoccn.KeysEndpoint).set(
session,
attempt: params['attempt'],
setup: params['setup'],
handle: params['handle'],
),
),
'beginPhrase': _is.MethodConnector(
name: 'beginPhrase',
params: {},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['keys'] as _icowoccn.KeysEndpoint)
.beginPhrase(session),
),
'changePassword': _is.MethodConnector(
name: 'changePassword',
params: {
'proof': _is.ParameterDescription(
name: 'proof',
type: _is.getType<_i561ypzv.KeysOwnerProof>(),
nullable: false,
),
'password': _is.ParameterDescription(
name: 'password',
type: _is.getType<_iri6zrp4.KeysPassword>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['keys'] as _icowoccn.KeysEndpoint).changePassword(
session,
proof: params['proof'],
password: params['password'],
),
),
'resetPassword': _is.MethodConnector(
name: 'resetPassword',
params: {
'proof': _is.ParameterDescription(
name: 'proof',
type: _is.getType<_i561ypzv.KeysOwnerProof>(),
nullable: false,
),
'password': _is.ParameterDescription(
name: 'password',
type: _is.getType<_iri6zrp4.KeysPassword>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['keys'] as _icowoccn.KeysEndpoint).resetPassword(
session,
proof: params['proof'],
password: params['password'],
),
),
'setRecoveryKeys': _is.MethodConnector(
name: 'setRecoveryKeys',
params: {
'proof': _is.ParameterDescription(
name: 'proof',
type: _is.getType<_i561ypzv.KeysOwnerProof>(),
nullable: false,
),
'recoveryKeys': _is.ParameterDescription(
name: 'recoveryKeys',
type: _is.getType<_iji0wmu8.WrappedKeys>(),
nullable: false,
),
'recoveryProofKey': _is.ParameterDescription(
name: 'recoveryProofKey',
type: _is.getType<_idt.ByteData>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['keys'] as _icowoccn.KeysEndpoint).setRecoveryKeys(
session,
proof: params['proof'],
recoveryKeys: params['recoveryKeys'],
recoveryProofKey: params['recoveryProofKey'],
),
),
},
);
connectors['accountRecovery'] = _is.EndpointConnector(

View file

@ -0,0 +1,134 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:serverpod/serverpod.dart' as _is;
/// What a change of an account that signs in with SimpleLogin takes
/// (Halkyon Learn's ADR 0020, D20), as ADR 0017's `OwnerProof` takes the
/// password and a code: a key proof for a challenge of `keys.begin` (the
/// password's proof key), or of `keys.beginPhrase` for a lost password
/// (the recovery phrase's), and a SimpleLogin sign-in of the last ten
/// minutes on this device, the attempt it ended (`simpleLogin.complete`),
/// taken by the change.
abstract class KeysOwnerProof
implements _is.SerializableModel, _is.ProtocolSerialization {
KeysOwnerProof._({
required this.challenge,
required this.signature,
required this.instanceUrl,
required this.attempt,
});
factory KeysOwnerProof({
required _is.UuidValue challenge,
required _idt.ByteData signature,
required String instanceUrl,
required _is.UuidValue attempt,
}) = _KeysOwnerProofImpl;
factory KeysOwnerProof.fromJson(Map<String, dynamic> jsonSerialization) {
return KeysOwnerProof(
challenge: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['challenge'],
),
signature: _is.ByteDataJsonExtension.fromJson(
jsonSerialization['signature'],
),
instanceUrl: jsonSerialization['instanceUrl'] as String,
attempt: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['attempt'],
),
);
}
_is.UuidValue challenge;
/// Ed25519's signature over `keyProofMessage`, 64 bytes.
_idt.ByteData signature;
/// 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;
/// The SimpleLogin sign-in's attempt.
_is.UuidValue attempt;
/// Returns a shallow copy of this [KeysOwnerProof]
/// with some or all fields replaced by the given arguments.
@_is.useResult
KeysOwnerProof copyWith({
_is.UuidValue? challenge,
_idt.ByteData? signature,
String? instanceUrl,
_is.UuidValue? attempt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.KeysOwnerProof',
'challenge': challenge.toJson(),
'signature': signature.toJson(),
'instanceUrl': instanceUrl,
'attempt': attempt.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.KeysOwnerProof',
'challenge': challenge.toJson(),
'signature': signature.toJson(),
'instanceUrl': instanceUrl,
'attempt': attempt.toJson(),
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _KeysOwnerProofImpl extends KeysOwnerProof {
_KeysOwnerProofImpl({
required _is.UuidValue challenge,
required _idt.ByteData signature,
required String instanceUrl,
required _is.UuidValue attempt,
}) : super._(
challenge: challenge,
signature: signature,
instanceUrl: instanceUrl,
attempt: attempt,
);
/// Returns a shallow copy of this [KeysOwnerProof]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
KeysOwnerProof copyWith({
_is.UuidValue? challenge,
_idt.ByteData? signature,
String? instanceUrl,
_is.UuidValue? attempt,
}) {
return KeysOwnerProof(
challenge: challenge ?? this.challenge,
signature: signature ?? this.signature.clone(),
instanceUrl: instanceUrl ?? this.instanceUrl,
attempt: attempt ?? this.attempt,
);
}
}

View file

@ -0,0 +1,132 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:halkyon_base_server/src/generated/protocol.dart' as _ibewtqgw;
import 'package:serverpod/serverpod.dart' as _is;
import '../password/password_costs.dart' as _ioe6tior;
import '../password/wrapped_keys.dart' as _i2rxumte;
/// A new password for the keys of an account that signs in with SimpleLogin
/// (Halkyon Learn's ADR 0020, 7 and D19), all made on the device, which
/// holds the keys: the salt and costs it derived the password's keys with,
/// the public half of the password's proof key, and the account's private
/// keys wrapped under the password's key. As ADR 0017's `NewPassword`,
/// without SRP's verifier.
abstract class KeysPassword
implements _is.SerializableModel, _is.ProtocolSerialization {
KeysPassword._({
required this.salt,
required this.costs,
required this.passwordProofKey,
required this.passwordKeys,
});
factory KeysPassword({
required _idt.ByteData salt,
required _ioe6tior.PasswordCosts costs,
required _idt.ByteData passwordProofKey,
required _i2rxumte.WrappedKeys passwordKeys,
}) = _KeysPasswordImpl;
factory KeysPassword.fromJson(Map<String, dynamic> jsonSerialization) {
return KeysPassword(
salt: _is.ByteDataJsonExtension.fromJson(jsonSerialization['salt']),
costs: _ibewtqgw.Protocol().deserialize<_ioe6tior.PasswordCosts>(
jsonSerialization['costs'],
),
passwordProofKey: _is.ByteDataJsonExtension.fromJson(
jsonSerialization['passwordProofKey'],
),
passwordKeys: _ibewtqgw.Protocol().deserialize<_i2rxumte.WrappedKeys>(
jsonSerialization['passwordKeys'],
),
);
}
_idt.ByteData salt;
_ioe6tior.PasswordCosts costs;
/// The public half of the new password's proof key (Ed25519).
_idt.ByteData passwordProofKey;
_i2rxumte.WrappedKeys passwordKeys;
/// Returns a shallow copy of this [KeysPassword]
/// with some or all fields replaced by the given arguments.
@_is.useResult
KeysPassword copyWith({
_idt.ByteData? salt,
_ioe6tior.PasswordCosts? costs,
_idt.ByteData? passwordProofKey,
_i2rxumte.WrappedKeys? passwordKeys,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.KeysPassword',
'salt': salt.toJson(),
'costs': costs.toJson(),
'passwordProofKey': passwordProofKey.toJson(),
'passwordKeys': passwordKeys.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.KeysPassword',
'salt': salt.toJson(),
'costs': costs.toJsonForProtocol(),
'passwordProofKey': passwordProofKey.toJson(),
'passwordKeys': passwordKeys.toJsonForProtocol(),
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _KeysPasswordImpl extends KeysPassword {
_KeysPasswordImpl({
required _idt.ByteData salt,
required _ioe6tior.PasswordCosts costs,
required _idt.ByteData passwordProofKey,
required _i2rxumte.WrappedKeys passwordKeys,
}) : super._(
salt: salt,
costs: costs,
passwordProofKey: passwordProofKey,
passwordKeys: passwordKeys,
);
/// Returns a shallow copy of this [KeysPassword]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
KeysPassword copyWith({
_idt.ByteData? salt,
_ioe6tior.PasswordCosts? costs,
_idt.ByteData? passwordProofKey,
_i2rxumte.WrappedKeys? passwordKeys,
}) {
return KeysPassword(
salt: salt ?? this.salt.clone(),
costs: costs ?? this.costs.copyWith(),
passwordProofKey: passwordProofKey ?? this.passwordProofKey.clone(),
passwordKeys: passwordKeys ?? this.passwordKeys.copyWith(),
);
}
}

View file

@ -29,7 +29,12 @@ enum SignInChallengePurpose implements _is.SerializableModel {
/// by signing a nonce with the password's proof key (Halkyon Learn's ADR
/// 0020, D19): to get the keys on a device, or for a change. Counted per
/// session (`asOwnerProofTry`).
passwordProof;
passwordProof,
/// Proving the recovery phrase of an account without SRP's verifier,
/// signed in, by signing a nonce with the phrase's proof key, for a lost
/// password (Halkyon Learn's ADR 0020, D20). Counted per session.
phraseProof;
static SignInChallengePurpose fromJson(String name) {
switch (name) {
@ -41,6 +46,8 @@ enum SignInChallengePurpose implements _is.SerializableModel {
return SignInChallengePurpose.change;
case 'passwordProof':
return SignInChallengePurpose.passwordProof;
case 'phraseProof':
return SignInChallengePurpose.phraseProof;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "SignInChallengePurpose"',

View file

@ -31,6 +31,8 @@ import 'auth/sign_in_ways.dart' as _ie6tbh85;
import 'instance/instance_basics.dart' as _i8jixqx6;
import 'keys/device_keys.dart' as _iz50cwqw;
import 'keys/keys_challenge.dart' as _itebbsfc;
import 'keys/keys_owner_proof.dart' as _iis161ma;
import 'keys/keys_password.dart' as _i70xpmnz;
import 'keys/keys_proof.dart' as _iviximyh;
import 'keys/keys_setup.dart' as _iqdhlx3t;
import 'password/account_password.dart' as _ip37g5t3;
@ -80,6 +82,8 @@ export 'auth/sign_in_ways.dart';
export 'instance/instance_basics.dart';
export 'keys/device_keys.dart';
export 'keys/keys_challenge.dart';
export 'keys/keys_owner_proof.dart';
export 'keys/keys_password.dart';
export 'keys/keys_proof.dart';
export 'keys/keys_setup.dart';
export 'password/account_password.dart';
@ -1101,6 +1105,12 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _itebbsfc.KeysChallenge) {
return _itebbsfc.KeysChallenge.fromJson(data) as T;
}
if (t == _iis161ma.KeysOwnerProof) {
return _iis161ma.KeysOwnerProof.fromJson(data) as T;
}
if (t == _i70xpmnz.KeysPassword) {
return _i70xpmnz.KeysPassword.fromJson(data) as T;
}
if (t == _iviximyh.KeysProof) {
return _iviximyh.KeysProof.fromJson(data) as T;
}
@ -1263,6 +1273,13 @@ class Protocol extends _is.DatabaseSerializationManager {
return (data != null ? _itebbsfc.KeysChallenge.fromJson(data) : null)
as T;
}
if (t == _is.getType<_iis161ma.KeysOwnerProof?>()) {
return (data != null ? _iis161ma.KeysOwnerProof.fromJson(data) : null)
as T;
}
if (t == _is.getType<_i70xpmnz.KeysPassword?>()) {
return (data != null ? _i70xpmnz.KeysPassword.fromJson(data) : null) as T;
}
if (t == _is.getType<_iviximyh.KeysProof?>()) {
return (data != null ? _iviximyh.KeysProof.fromJson(data) : null) as T;
}
@ -1477,6 +1494,8 @@ class Protocol extends _is.DatabaseSerializationManager {
_i8jixqx6.InstanceBasics => 'InstanceBasics',
_iz50cwqw.DeviceKeys => 'DeviceKeys',
_itebbsfc.KeysChallenge => 'KeysChallenge',
_iis161ma.KeysOwnerProof => 'KeysOwnerProof',
_i70xpmnz.KeysPassword => 'KeysPassword',
_iviximyh.KeysProof => 'KeysProof',
_iqdhlx3t.KeysSetup => 'KeysSetup',
_ip37g5t3.AccountPassword => 'AccountPassword',
@ -1554,6 +1573,10 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'DeviceKeys';
case _itebbsfc.KeysChallenge():
return 'KeysChallenge';
case _iis161ma.KeysOwnerProof():
return 'KeysOwnerProof';
case _i70xpmnz.KeysPassword():
return 'KeysPassword';
case _iviximyh.KeysProof():
return 'KeysProof';
case _iqdhlx3t.KeysSetup():
@ -1677,6 +1700,12 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'KeysChallenge') {
return deserialize<_itebbsfc.KeysChallenge>(data['data']);
}
if (dataClassName == 'KeysOwnerProof') {
return deserialize<_iis161ma.KeysOwnerProof>(data['data']);
}
if (dataClassName == 'KeysPassword') {
return deserialize<_i70xpmnz.KeysPassword>(data['data']);
}
if (dataClassName == 'KeysProof') {
return deserialize<_iviximyh.KeysProof>(data['data']);
}

View file

@ -14,6 +14,11 @@ instance:
keys:
- begin:
- unlock:
- set:
- beginPhrase:
- changePassword:
- resetPassword:
- setRecoveryKeys:
accountRecovery:
- resetAuthenticator:
- beginKeyProof:

View file

@ -66,7 +66,7 @@ abstract class SimpleLoginCompleted
/// Whether the account signed in to, or SimpleLogin was added to, has its
/// keys (`keys.unlock` opens them); one without (an account of before,
/// adding SimpleLogin) makes them next.
/// adding SimpleLogin) makes them next (`keys.set`).
bool hasKeys;
/// The account's handle; null for one without (it chooses one with its

View file

@ -1,12 +1,20 @@
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:halkyon_base_server/src/password/passwords.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
import 'package:halkyon_base_server/src/simplelogin/simplelogin.dart';
import 'package:serverpod/serverpod.dart';
// The keys of an account that signs in with SimpleLogin, as the instance's
// register keeps them (Halkyon Learn's ADR 0020, 6, 7 and D19): ADR 0017's
// keys and their wrappings, the password proven with its own proof key in
// place of SRP. Refusals are `PasswordRefusedException`s.
// register keeps them (Halkyon Learn's ADR 0020, 6, 7, D19 and D20): ADR
// 0017's keys and their wrappings, the password proven with its own proof
// key in place of SRP, and every change taking that proof (the phrase's,
// for a lost password) with a SimpleLogin sign-in of the last ten minutes,
// as ADR 0017's take the password and a code. Refusals are
// `PasswordRefusedException`s; the sign-in's, `OidcRefusedException`s.
/// [person]'s keys for a device, once [proof] proved their password for it
/// ([KeyProofPurpose.unlockKeys]): their public keys and their private keys
@ -33,3 +41,324 @@ Future<DeviceKeys> unlockKeys(
),
);
}
/// Gives [person], an account without keys that SimpleLogin was added to
/// (one of before: anonymous, or signing in with GitHub or a code), the
/// keys [setup] describes, and [handle] where it has none (ADR 0020, 6 and
/// D5), with their SimpleLogin sign-in under [attempt] of the last ten
/// minutes, taken (D20). Nothing else proves the owner: the account has no
/// password yet, and nothing encrypted under keys of its own.
///
/// Refused ([PasswordRefusedException]) for a setup out of range
/// ([PasswordRefusal.invalidSetup]) and an account with keys already, under
/// either kind of password ([PasswordRefusal.passwordExists]);
/// ([ProfileRefusedException]) for a handle refused as saving a profile
/// refuses it, and one missing where the account has none or given where it
/// has one ([ProfileRefusal.handleInvalid]); the sign-in's refusals as
/// [takeSimpleLoginConfirmation]'s. Nothing changes then.
Future<void> setFirstKeys(
Session session, {
required UuidValue person,
required UuidValue attempt,
required KeysSetup setup,
String? handle,
}) async {
if (!isKeysSetup(setup)) {
throw refusedFor(PasswordRefusal.invalidSetup);
}
// SELECT count(*) FROM "account_password" WHERE "authUserId" = $1
if (await AccountPassword.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0) {
throw refusedFor(PasswordRefusal.passwordExists);
}
// SELECT count(*) FROM "public_profile" WHERE "authUserId" = $1
final hasHandle =
await PublicProfile.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0;
if (hasHandle == (handle != null)) {
throw ProfileRefusedException(reason: ProfileRefusal.handleInvalid);
}
try {
await session.db.transaction((transaction) async {
if (handle != null) {
if (await refusalOfNewHandle(session, handle, transaction: transaction)
case final reason?) {
throw ProfileRefusedException(reason: reason);
}
}
await takeSimpleLoginConfirmation(
session,
person: person,
attempt: attempt,
transaction: transaction,
);
final now = clock.now().toUtc();
if (handle != null) {
await insertFirstProfile(
session,
person,
handle,
now: now,
transaction: transaction,
);
}
// INSERT INTO "account_password" (…) VALUES (…)
await AccountPassword.db.insertRow(
session,
keysRowOf(person, setup, now: now),
transaction: transaction,
);
});
} on DatabaseQueryException catch (error) {
// A device of theirs set keys, or someone took the handle, between the
// checks and the writes: a unique index refused it (unique_violation),
// the sign-in given back.
if (error.code == '23505') {
throw error.constraintName == 'account_password_person_idx'
? refusedFor(PasswordRefusal.passwordExists)
: ProfileRefusedException(reason: ProfileRefusal.handleTaken);
}
rethrow;
}
}
/// Changes [person]'s password to [password] (the same keys, wrapped under
/// the new password's key on the device), with [proof]: the old password's
/// proof for it ([KeyProofPurpose.newPassword], binding the new password)
/// and their SimpleLogin sign-in of the last ten minutes, taken (D20). Every
/// other session of the account ends, in every service of the instance;
/// [keepSession], the one asking, stays.
///
/// Refused ([PasswordRefusedException]) for a new password out of range
/// ([PasswordRefusal.invalidSetup]), and as `provePasswordKey` refuses; the
/// sign-in's refusals as [requireSimpleLoginConfirmation]'s and
/// [takeSimpleLoginConfirmation]'s. Nothing changes then.
Future<void> changeKeysPassword(
Session session, {
required UuidValue person,
required KeysOwnerProof proof,
required KeysPassword password,
UuidValue? keepSession,
}) => _newPassword(
session,
person: person,
proof: proof,
password: password,
keepSession: keepSession,
prove: provePasswordKey,
purpose: KeyProofPurpose.newPassword,
);
/// Sets a new [password] for [person], who lost theirs, with [proof]: the
/// recovery phrase's proof for it, for a challenge of `beginPhraseKeyProof`
/// ([KeyProofPurpose.passwordReset], binding the new password), and their
/// SimpleLogin sign-in of the last ten minutes, taken (D20); the device
/// opened the keys with the phrase and wrapped them under the new
/// password's key. Every other session of the account ends, as
/// [changeKeysPassword] has it. Refusals as its, the phrase's as
/// `provePhraseKey`'s.
Future<void> resetKeysPassword(
Session session, {
required UuidValue person,
required KeysOwnerProof proof,
required KeysPassword password,
UuidValue? keepSession,
}) => _newPassword(
session,
person: person,
proof: proof,
password: password,
keepSession: keepSession,
prove: provePhraseKey,
purpose: KeyProofPurpose.passwordReset,
);
/// [changeKeysPassword] and [resetKeysPassword], the proof checked by
/// [prove] for [purpose].
Future<void> _newPassword(
Session session, {
required UuidValue person,
required KeysOwnerProof proof,
required KeysPassword password,
required UuidValue? keepSession,
required Future<AccountPassword> Function(
Session session, {
required UuidValue person,
required KeysProof proof,
required KeyProofPurpose purpose,
List<int> digest,
})
prove,
required KeyProofPurpose purpose,
}) async {
if (!isKeysPassword(password)) {
throw refusedFor(PasswordRefusal.invalidSetup);
}
await requireSimpleLoginConfirmation(
session,
person: person,
attempt: proof.attempt,
);
final row = await prove(
session,
person: person,
proof: keyProofOf(proof),
purpose: purpose,
digest: newKeysPasswordDigest(password),
);
await session.db.transaction((transaction) async {
await takeSimpleLoginConfirmation(
session,
person: person,
attempt: proof.attempt,
transaction: transaction,
);
// UPDATE "account_password" SET "salt" = $1, "kdfAlgorithm" = $2,
// "kdfOpsLimit" = $3, "kdfMemLimit" = $4, "passwordProofKey" = $5,
// "passwordKeysNonce" = $6, "passwordKeysCipherText" = $7,
// "sessionEndedForProofsAt" = NULL, "updatedAt" = $8 WHERE "id" = $9
// (a new password answers the warning of a session ended for its
// proofs: whoever tried the old one no longer knows it).
await AccountPassword.db.updateRow(
session,
row.copyWith(
salt: password.salt,
kdfAlgorithm: password.costs.algorithm,
kdfOpsLimit: password.costs.opsLimit,
kdfMemLimit: password.costs.memLimit,
passwordProofKey: password.passwordProofKey,
passwordKeysNonce: password.passwordKeys.nonce,
passwordKeysCipherText: password.passwordKeys.cipherText,
// A generated copyWith tells an explicit null (clear it) from no
// argument (keep it), which the lint can't see.
// ignore: avoid_redundant_argument_values
sessionEndedForProofsAt: null,
updatedAt: clock.now().toUtc(),
),
columns: (t) => [
t.salt,
t.kdfAlgorithm,
t.kdfOpsLimit,
t.kdfMemLimit,
t.passwordProofKey,
t.passwordKeysNonce,
t.passwordKeysCipherText,
t.sessionEndedForProofsAt,
t.updatedAt,
],
transaction: transaction,
);
await endSessionsEverywhere(
session,
person,
keepSession: keepSession,
transaction: transaction,
);
});
await announceSessionsEnded(session, person);
}
/// Replaces the wrapping of [person]'s keys under their recovery phrase
/// with [recoveryKeys] (a new phrase, made on the device, which holds the
/// keys) and the phrase's proof key with [recoveryProofKey], with [proof]:
/// the password's proof for it ([KeyProofPurpose.newPhrase], binding the
/// new phrase) and their SimpleLogin sign-in of the last ten minutes, taken
/// (D20). The old phrase opens and proves nothing any more.
///
/// Refused ([PasswordRefusedException]) for wrapped keys or a proof key out
/// of range ([PasswordRefusal.invalidSetup]), and as `provePasswordKey`
/// refuses; the sign-in's refusals as [changeKeysPassword]'s. Nothing
/// changes then.
Future<void> setKeysRecovery(
Session session, {
required UuidValue person,
required KeysOwnerProof proof,
required WrappedKeys recoveryKeys,
required ByteData recoveryProofKey,
}) async {
if (!isWrappedKeys(recoveryKeys) || !isPublicKey(recoveryProofKey)) {
throw refusedFor(PasswordRefusal.invalidSetup);
}
await requireSimpleLoginConfirmation(
session,
person: person,
attempt: proof.attempt,
);
final row = await provePasswordKey(
session,
person: person,
proof: keyProofOf(proof),
purpose: KeyProofPurpose.newPhrase,
digest: newPhraseDigest(recoveryKeys, recoveryProofKey),
);
await session.db.transaction((transaction) async {
await takeSimpleLoginConfirmation(
session,
person: person,
attempt: proof.attempt,
transaction: transaction,
);
// UPDATE "account_password" SET "recoveryKeysNonce" = $1,
// "recoveryKeysCipherText" = $2, "recoveryProofKey" = $3,
// "updatedAt" = $4 WHERE "id" = $5
// (Those columns alone: a change of password meanwhile stays.)
await AccountPassword.db.updateRow(
session,
row.copyWith(
recoveryKeysNonce: recoveryKeys.nonce,
recoveryKeysCipherText: recoveryKeys.cipherText,
recoveryProofKey: recoveryProofKey,
updatedAt: clock.now().toUtc(),
),
columns: (t) => [
t.recoveryKeysNonce,
t.recoveryKeysCipherText,
t.recoveryProofKey,
t.updatedAt,
],
transaction: transaction,
);
});
}
/// Proves [person], an account that signs in with SimpleLogin, may delete
/// itself: with [proof], the password's proof for it
/// ([KeyProofPurpose.deleteAccount]) and their SimpleLogin sign-in of the
/// last ten minutes, taken (D20); a session alone would otherwise delete the
/// account and everything encrypted under its keys. Refused without [proof]
/// ([PasswordRefusal.wrongCredentials]), and as [changeKeysPassword]
/// refuses.
Future<void> requireKeysOwnerToDelete(
Session session,
UuidValue person,
KeysOwnerProof? proof,
) async {
if (proof == null) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
await requireSimpleLoginConfirmation(
session,
person: person,
attempt: proof.attempt,
);
await provePasswordKey(
session,
person: person,
proof: keyProofOf(proof),
purpose: KeyProofPurpose.deleteAccount,
);
await session.db.transaction(
(transaction) => takeSimpleLoginConfirmation(
session,
person: person,
attempt: proof.attempt,
transaction: transaction,
),
);
}

View file

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:halkyon_base_server/src/password/owner_proof_tries.dart';
@ -6,9 +8,11 @@ import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// The keys of an account that signs in with SimpleLogin (Halkyon Learn's
/// ADR 0020, 7 and D19), for its signed-in devices: the password proven
/// with its own proof key, which the instance's register checks (ADR 0016).
/// Refusals are `PasswordRefusedException`s.
/// ADR 0020, 6, 7, D19 and D20), for its signed-in devices: the password
/// proven with its own proof key, which the instance's register checks (ADR
/// 0016), and every change with that proof and a SimpleLogin sign-in of the
/// last ten minutes on the device (`KeysOwnerProof`). Refusals are
/// `PasswordRefusedException`s; the sign-in's, `OidcRefusedException`s.
class KeysEndpoint extends Endpoint {
@override
bool get requireLogin => true;
@ -45,4 +49,123 @@ class KeysEndpoint extends Endpoint {
),
);
}
/// Your first keys, for an account SimpleLogin was added to that has none
/// (`simpleLogin.complete` says `hasKeys`), all made on your device and
/// described by [setup]; [handle] where the account has none (it said no
/// `handle`), null otherwise. Takes your SimpleLogin sign-in of the last
/// ten minutes on this device, under [attempt]. Refused for an account
/// with keys (`PasswordRefusal.passwordExists`), a setup out of range
/// (`PasswordRefusal.invalidSetup`), a handle refused or missing
/// (`ProfileRefusedException`), and without that sign-in
/// (`OidcRefusal.unknownAttempt`).
Future<void> set(
Session session, {
required UuidValue attempt,
required KeysSetup setup,
String? handle,
}) => AccountRegister.of(session.serverpod).setFirstKeys(
session,
person: session.authenticated!.authUserId,
attempt: attempt,
setup: setup,
handle: handle,
);
/// The first step of proving your recovery phrase, for a password you
/// lost: your keys wrapped under the phrase's key and your public keys,
/// which your device opens with the phrase, and a nonce to sign with its
/// proof key, within two minutes, once. Refused for an account without
/// such keys (`PasswordRefusal.noPassword`).
Future<KeyProofChallenge> beginPhrase(Session session) =>
AccountRegister.of(session.serverpod).beginPhraseKeyProof(
session,
session.authenticated!.authUserId,
);
/// Changes your password to [password] (the same keys, wrapped under the
/// new password's key on your device), with [proof]: the old password's
/// for a challenge of [begin], binding the new password, and your
/// SimpleLogin sign-in of the last ten minutes on this device. Every
/// other session of yours ends, in every service of the instance; this
/// one stays. The proof counts against this session, as [unlock]'s.
Future<void> changePassword(
Session session, {
required KeysOwnerProof proof,
required KeysPassword password,
}) {
final bound = _bound(session, proof);
return asOwnerProofTry(
session,
bound,
() => AccountRegister.of(session.serverpod).changeKeysPassword(
session,
person: session.authenticated!.authUserId,
proof: bound,
password: password,
keepSession: _asking(session),
),
);
}
/// Sets a new [password] for the one you lost, with [proof]: your
/// recovery phrase's for a challenge of [beginPhrase], binding the new
/// password, and your SimpleLogin sign-in of the last ten minutes on this
/// device. Every other session of yours ends, as [changePassword] has it.
Future<void> resetPassword(
Session session, {
required KeysOwnerProof proof,
required KeysPassword password,
}) {
final bound = _bound(session, proof);
return asOwnerProofTry(
session,
bound,
() => AccountRegister.of(session.serverpod).resetKeysPassword(
session,
person: session.authenticated!.authUserId,
proof: bound,
password: password,
keepSession: _asking(session),
),
);
}
/// A new recovery phrase: [recoveryKeys], your keys wrapped under it on
/// your device, replace the old wrapping, and [recoveryProofKey], the
/// public half of its proof key, the old one, with [proof] as
/// [changePassword] takes it, binding the new phrase.
Future<void> setRecoveryKeys(
Session session, {
required KeysOwnerProof proof,
required WrappedKeys recoveryKeys,
required ByteData recoveryProofKey,
}) {
final bound = _bound(session, proof);
return asOwnerProofTry(
session,
bound,
() => AccountRegister.of(session.serverpod).setKeysRecovery(
session,
person: session.authenticated!.authUserId,
proof: bound,
recoveryKeys: recoveryKeys,
recoveryProofKey: recoveryProofKey,
),
);
}
/// [proof] bound to the base URL the device addressed here.
static KeysOwnerProof _bound(Session session, KeysOwnerProof proof) =>
proof.copyWith(
instanceUrl: deviceInstanceUrlOn(session.serverpod, proof.instanceUrl),
);
/// The session asking: a server-side session's ID.
static UuidValue? _asking(Session session) {
final authId = session.authenticated!.authId;
return Uuid.isValidUUID(fromString: authId)
? UuidValue.fromString(authId)
: null;
}
}

View file

@ -0,0 +1,18 @@
### What a change of an account that signs in with SimpleLogin takes
### (Halkyon Learn's ADR 0020, D20), as ADR 0017's `OwnerProof` takes the
### password and a code: a key proof for a challenge of `keys.begin` (the
### password's proof key), or of `keys.beginPhrase` for a lost password
### (the recovery phrase's), and a SimpleLogin sign-in of the last ten
### minutes on this device, the attempt it ended (`simpleLogin.complete`),
### taken by the change.
class: KeysOwnerProof
fields:
challenge: UuidValue
### Ed25519's signature over `keyProofMessage`, 64 bytes.
signature: ByteData
### 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.
instanceUrl: String
### The SimpleLogin sign-in's attempt.
attempt: UuidValue

View file

@ -0,0 +1,13 @@
### A new password for the keys of an account that signs in with SimpleLogin
### (Halkyon Learn's ADR 0020, 7 and D19), all made on the device, which
### holds the keys: the salt and costs it derived the password's keys with,
### the public half of the password's proof key, and the account's private
### keys wrapped under the password's key. As ADR 0017's `NewPassword`,
### without SRP's verifier.
class: KeysPassword
fields:
salt: ByteData
costs: PasswordCosts
### The public half of the new password's proof key (Ed25519).
passwordProofKey: ByteData
passwordKeys: WrappedKeys

View file

@ -161,6 +161,20 @@ Uint8List newPasswordDigest(NewPassword password) => _sha256([
Uint8List.sublistView(password.passwordKeys.cipherText),
]);
/// The SHA-256 of [password], a new password for the keys of an account
/// that signs in with SimpleLogin, as the device sent it, framed: what a
/// proof for it binds (the old password's, or the phrase's).
Uint8List newKeysPasswordDigest(KeysPassword password) => _sha256([
utf8.encode('halkyon new keys password v1'),
Uint8List.sublistView(password.salt),
utf8.encode(password.costs.algorithm),
utf8.encode('${password.costs.opsLimit}'),
utf8.encode('${password.costs.memLimit}'),
Uint8List.sublistView(password.passwordProofKey),
Uint8List.sublistView(password.passwordKeys.nonce),
Uint8List.sublistView(password.passwordKeys.cipherText),
]);
/// The SHA-256 of a new recovery phrase as the device sent it (the keys
/// wrapped under it, [recoveryKeys], and its proof key's public half,
/// [proofKey]), framed: what the owner's proof for it binds.

View file

@ -3,6 +3,7 @@ import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/auth/email.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/keys/keys.dart';
import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:halkyon_base_server/src/password/password_config.dart';
import 'package:halkyon_base_server/src/password/passwords.dart';
@ -150,6 +151,10 @@ Future<void> setRecoveryKeys(
/// reset taken effect; the re-review's eighth finding: such accounts
/// couldn't delete themselves at all), which `SignInWays` tells the app.
///
/// An account that signs in with SimpleLogin, its keys under a password
/// without SRP's verifier, deletes itself with [keysProof] alone
/// (`requireKeysOwnerToDelete`, Halkyon Learn's ADR 0020, D20).
///
/// Refused as [proveOwner] refuses, without [proof] where one is needed,
/// and without the code its account takes
/// ([PasswordRefusal.wrongCredentials]); a code by mail's own refusals as
@ -157,16 +162,20 @@ Future<void> setRecoveryKeys(
Future<void> requireOwnerToDelete(
Session session,
UuidValue person,
DeletionProof? proof,
) async {
// SELECT count(*) FROM "account_password" WHERE "authUserId" = $1
if (await AccountPassword.db.count(
session,
where: (t) => t.authUserId.equals(person),
) ==
0) {
DeletionProof? proof, {
KeysOwnerProof? keysProof,
}) async {
// SELECT * FROM "account_password" WHERE "authUserId" = $1 LIMIT 1
final password = await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(person),
);
if (password == null) {
return;
}
if (password.verifier == null) {
return await requireKeysOwnerToDelete(session, person, keysProof);
}
final keys = requirePasswordKeys(session.serverpod);
if (proof == null) {
throw refusedFor(PasswordRefusal.wrongCredentials);

View file

@ -1557,17 +1557,97 @@ Future<AccountPassword> provePasswordKey(
required KeysProof proof,
required KeyProofPurpose purpose,
List<int> digest = const [],
}) => _proveOwnKey(
session,
person: person,
proof: proof,
begunFor: SignInChallengePurpose.passwordProof,
keyOf: (password) => password.passwordProofKey,
purpose: purpose,
digest: digest,
);
/// The first step of proving the recovery phrase of [person], an account
/// that signs in with SimpleLogin, signed in, for a lost password (Halkyon
/// Learn's ADR 0020, D20): the account's keys wrapped under the phrase's key
/// and its public keys, which the device opens with the phrase, and a nonce
/// it signs with the phrase's proof key. A session holds the account
/// already, so there is no decoy; the proofs that fail count against it
/// (`asOwnerProofTry`). Refused for an account without such keys
/// ([PasswordRefusal.noPassword]: one with SRP's verifier resets its
/// password as ADR 0017 has it).
Future<KeyProofChallenge> beginPhraseKeyProof(
Session session, {
required UuidValue person,
}) async {
final kept = await takeChallenge(
final password = await _passwordOf(session, person);
if (password == null || password.passwordProofKey == null) {
throw refusedFor(PasswordRefusal.noPassword);
}
final nonce = _randomBytes(32);
// INSERT INTO "sign_in_challenge" (…) VALUES (…)
final kept = await SignInChallenge.db.insertRow(
session,
proof.challenge,
SignInChallengePurpose.passwordProof,
SignInChallenge(
authUserId: person,
purpose: SignInChallengePurpose.phraseProof,
// Begun for an account, not an address: counted toward none.
addressHash: '',
secret: _hex(nonce),
createdAt: clock.now().toUtc(),
),
);
return KeyProofChallenge(
challenge: kept.id!,
nonce: ByteData.sublistView(nonce),
encryptionPublicKey: password.encryptionPublicKey,
signingPublicKey: password.signingPublicKey,
recoveryKeys: WrappedKeys(
nonce: password.recoveryKeysNonce,
cipherText: password.recoveryKeysCipherText,
),
);
}
/// [provePasswordKey] for the recovery phrase: the challenge one of
/// [beginPhraseKeyProof], the signature by the phrase's proof key, for an
/// account that signs in with SimpleLogin alone. Refusals alike.
Future<AccountPassword> provePhraseKey(
Session session, {
required UuidValue person,
required KeysProof proof,
required KeyProofPurpose purpose,
List<int> digest = const [],
}) => _proveOwnKey(
session,
person: person,
proof: proof,
begunFor: SignInChallengePurpose.phraseProof,
keyOf: (password) =>
password.passwordProofKey == null ? null : password.recoveryProofKey,
purpose: purpose,
digest: digest,
);
/// [proof] checked for [person] by the key [keyOf] picks from their
/// password row (none: [PasswordRefusal.wrongCredentials]), its challenge
/// one begun [begunFor] them: [provePasswordKey]'s and [provePhraseKey]'s
/// check.
Future<AccountPassword> _proveOwnKey(
Session session, {
required UuidValue person,
required KeysProof proof,
required SignInChallengePurpose begunFor,
required ByteData? Function(AccountPassword password) keyOf,
required KeyProofPurpose purpose,
required List<int> digest,
}) async {
final kept = await takeChallenge(session, proof.challenge, begunFor);
if (kept.authUserId != person) {
throw refusedFor(PasswordRefusal.challengeExpired);
}
final password = await _passwordOf(session, person);
final proofKey = password?.passwordProofKey;
final proofKey = password == null ? null : keyOf(password);
if (password == null ||
proofKey == null ||
!await isAccountSignature(
@ -1586,6 +1666,20 @@ Future<AccountPassword> provePasswordKey(
return password;
}
/// [proof]'s key proof alone, without the SimpleLogin sign-in it names.
KeysProof keyProofOf(KeysOwnerProof proof) => KeysProof(
challenge: proof.challenge,
signature: proof.signature,
instanceUrl: proof.instanceUrl,
);
/// Whether [password] is a new password for keys this register takes.
bool isKeysPassword(KeysPassword password) =>
password.salt.lengthInBytes == _saltLength &&
_areCosts(password.costs) &&
password.passwordProofKey.lengthInBytes == _publicKeyLength &&
_areWrappedKeys(password.passwordKeys);
/// The costs [password] was derived with.
PasswordCosts costsOf(AccountPassword password) => PasswordCosts(
algorithm: password.kdfAlgorithm,

View file

@ -15,3 +15,7 @@ values:
### 0020, D19): to get the keys on a device, or for a change. Counted per
### session (`asOwnerProofTry`).
- passwordProof
### Proving the recovery phrase of an account without SRP's verifier,
### signed in, by signing a nonce with the phrase's proof key, for a lost
### password (Halkyon Learn's ADR 0020, D20). Counted per session.
- phraseProof

View file

@ -195,6 +195,57 @@ abstract interface class AccountRegister {
KeysProof proof,
);
/// Gives [person], an account without keys that SimpleLogin was added to,
/// the keys [setup] describes and [handle] where it has none, with their
/// SimpleLogin sign-in under [attempt]. Refusals as `setFirstKeys`'.
Future<void> setFirstKeys(
Session session, {
required UuidValue person,
required UuidValue attempt,
required KeysSetup setup,
String? handle,
});
/// The first step of [person] proving their recovery phrase, signed in,
/// for a lost password (an account that signs in with SimpleLogin).
/// Refusals as `beginPhraseKeyProof`'s.
Future<KeyProofChallenge> beginPhraseKeyProof(
Session session,
UuidValue person,
);
/// Changes [person]'s password for their keys to [password], with
/// [proof]: every other session ends but [keepSession], in every service.
/// Refusals as `changeKeysPassword`'s.
Future<void> changeKeysPassword(
Session session, {
required UuidValue person,
required KeysOwnerProof proof,
required KeysPassword password,
UuidValue? keepSession,
});
/// Sets [person]'s new [password] for their keys with the recovery
/// phrase's [proof], as [changeKeysPassword] does. Refusals as
/// `resetKeysPassword`'s.
Future<void> resetKeysPassword(
Session session, {
required UuidValue person,
required KeysOwnerProof proof,
required KeysPassword password,
UuidValue? keepSession,
});
/// Replaces [person]'s recovery phrase's wrapping and proof key, with
/// [proof]. Refusals as `setKeysRecovery`'.
Future<void> setKeysRecovery(
Session session, {
required UuidValue person,
required KeysOwnerProof proof,
required WrappedKeys recoveryKeys,
required ByteData recoveryProofKey,
});
/// How [person] signs in: with GitHub, an email address, a password and
/// whether an authenticator is confirmed; and their warning that a session
/// was ended for its wrong proofs, if any.
@ -411,11 +462,13 @@ abstract interface class AccountRegister {
/// Deletes the account [person] with everything the register's server has
/// of them, and has every member of the instance delete theirs: one with a
/// password only with [proof] (`requireOwnerToDelete`).
/// password only with [proof], one that signs in with SimpleLogin only
/// with [keysProof] (`requireOwnerToDelete`).
Future<void> deleteAccount(
Session session,
UuidValue person, {
DeletionProof? proof,
KeysOwnerProof? keysProof,
});
/// Those of [people] that are accounts of the register.

View file

@ -240,6 +240,48 @@ Future<Map<String, Object?>?> _answer(
arguments.model('proof', KeysProof.fromJson),
);
return {'keys': keys.toJson()};
case RegisterOperation.setFirstKeys:
await register.setFirstKeys(
session,
person: arguments.id('person'),
attempt: arguments.id('attempt'),
setup: arguments.model('setup', KeysSetup.fromJson),
handle: arguments.optionalString('handle'),
);
return const {};
case RegisterOperation.beginPhraseKeyProof:
final challenge = await register.beginPhraseKeyProof(
session,
arguments.id('person'),
);
return {'challenge': challenge.toJson()};
case RegisterOperation.changeKeysPassword:
await register.changeKeysPassword(
session,
person: arguments.id('person'),
proof: arguments.model('proof', KeysOwnerProof.fromJson),
password: arguments.model('password', KeysPassword.fromJson),
keepSession: arguments.optionalId('keepSession'),
);
return const {};
case RegisterOperation.resetKeysPassword:
await register.resetKeysPassword(
session,
person: arguments.id('person'),
proof: arguments.model('proof', KeysOwnerProof.fromJson),
password: arguments.model('password', KeysPassword.fromJson),
keepSession: arguments.optionalId('keepSession'),
);
return const {};
case RegisterOperation.setKeysRecovery:
await register.setKeysRecovery(
session,
person: arguments.id('person'),
proof: arguments.model('proof', KeysOwnerProof.fromJson),
recoveryKeys: arguments.model('recoveryKeys', WrappedKeys.fromJson),
recoveryProofKey: arguments.bytes('recoveryProofKey'),
);
return const {};
case RegisterOperation.signInWaysOf:
final ways = await register.signInWaysOf(
session,
@ -264,6 +306,9 @@ Future<Map<String, Object?>?> _answer(
proof: arguments.has('proof')
? arguments.model('proof', DeletionProof.fromJson)
: null,
keysProof: arguments.has('keysProof')
? arguments.model('keysProof', KeysOwnerProof.fromJson)
: null,
);
return const {};
case RegisterOperation.knownAccounts:

View file

@ -438,6 +438,72 @@ final class LocalRegister implements AccountRegister {
KeysProof proof,
) => keys.unlockKeys(session, person: person, proof: proof);
@override
Future<void> setFirstKeys(
Session session, {
required UuidValue person,
required UuidValue attempt,
required KeysSetup setup,
String? handle,
}) => keys.setFirstKeys(
session,
person: person,
attempt: attempt,
setup: setup,
handle: handle,
);
@override
Future<KeyProofChallenge> beginPhraseKeyProof(
Session session,
UuidValue person,
) => passwords.beginPhraseKeyProof(session, person: person);
@override
Future<void> changeKeysPassword(
Session session, {
required UuidValue person,
required KeysOwnerProof proof,
required KeysPassword password,
UuidValue? keepSession,
}) => keys.changeKeysPassword(
session,
person: person,
proof: proof,
password: password,
keepSession: keepSession,
);
@override
Future<void> resetKeysPassword(
Session session, {
required UuidValue person,
required KeysOwnerProof proof,
required KeysPassword password,
UuidValue? keepSession,
}) => keys.resetKeysPassword(
session,
person: person,
proof: proof,
password: password,
keepSession: keepSession,
);
@override
Future<void> setKeysRecovery(
Session session, {
required UuidValue person,
required KeysOwnerProof proof,
required WrappedKeys recoveryKeys,
required ByteData recoveryProofKey,
}) => keys.setKeysRecovery(
session,
person: person,
proof: proof,
recoveryKeys: recoveryKeys,
recoveryProofKey: recoveryProofKey,
);
@override
Future<SignInWays> signInWaysOf(Session session, UuidValue person) async {
// SELECT * FROM "account_password" WHERE "authUserId" = $1
@ -766,8 +832,14 @@ final class LocalRegister implements AccountRegister {
Session session,
UuidValue person, {
DeletionProof? proof,
KeysOwnerProof? keysProof,
}) async {
await change.requireOwnerToDelete(session, person, proof);
await change.requireOwnerToDelete(
session,
person,
proof,
keysProof: keysProof,
);
// SELECT … FROM "serverpod_auth_core_user" WHERE "id" = $1
if (await AuthUser.db.findById(session, person) != null) {
await deletePerson(session, person);

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