feat(base): a reset of the keys without the phrase, and the keys' proof key for a password that signs in (ADR 0020, D31–D34)

An account that signs in with SimpleLogin alone, its password and its
recovery phrase both lost, asks for new keys with a SimpleLogin sign-in
(`keys.startReset`); another sign-in carries it out 72 hours later
(`keys.completeReset`, `notDue` with when before), with the same keys
rewrapped where the device still holds them (D33). No mail: every
signed-in device shows it, and any right proof of the password's or the
phrase's key, or a mend, calls it off; `keys.callOffResets` takes the
password's proof alone (D32). The row is ADR 0017's account reset of kind
`account`, so no migration; ADR 0017's reset by mail now takes only an
account whose password signs in.

An account with ADR 0017's password that added SimpleLogin had keys but
no proof key, so no new device signed in with SimpleLogin could open
them: `password.setProofKey` sets the one that same password makes, with
the owner's proof binding it (D34), and ADR 0017's new password takes it
away. Only an account that signs in with SimpleLogin alone changes
through `keys`. `simpleLogin.complete` says whether the password signs
in, whether the keys have the proof key, and when a reset takes effect.

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-24 21:51:00 +02:00
commit fe2ef02c92
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
29 changed files with 1973 additions and 52 deletions

View file

@ -14,12 +14,12 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
| Endpoint | Methods | Who may call |
|---|---|---|
| `password` | `set`: the caller's password, with the address a mailed code proves unless they have one, and their first handle where they have none (Halkyon Learn's ADR 0020, D28); `beginChange`, `change`: a new password with the old one's proof and a code, every other session ending; `setRecoveryKeys`: a new recovery phrase's wrapping, with the old password's proof and a code | signed in |
| `password` | `set`: the caller's password, with the address a mailed code proves unless they have one, and their first handle where they have none (Halkyon Learn's ADR 0020, D28); `beginChange`, `change`: a new password with the old one's proof and a code, every other session ending; `setRecoveryKeys`: a new recovery phrase's wrapping, with the old password's proof and a code; `setProofKey`: the keys' proof key the same password makes, with its proof and a code, so a device signed in with SimpleLogin opens the keys with the password (Halkyon Learn's ADR 0020, D34; a new password takes it away) | signed in |
| `passwordSignIn` | `begin`, `complete`: SRP-6a's two steps with a code from an authenticator (or a recovery code; a setup never finished, a code by mail); a session. `checkHandle(request, handle)`: whether a sign-up could take a handle, while its mailed code waits (ADR 0020, D29); `signUp`: an account at the address a mailed code proves, with a password and a handle (an account there keeps its own where it has one); a session | without a session |
| `authenticator` | `enrol`, `confirm` (the first brings ten recovery codes; another takes a code from one confirmed before), `list`, `remove`, `newRecoveryCodes` | signed in |
| `accountRecovery` | `resetAuthenticator`, `beginKeyProof`, `resetPassword` (with the recovery phrase), `startAccountReset`, `completeAccountReset` | without a session |
| `simpleLogin` | `begin(platform)`: an attempt and SimpleLogin's address to open, at most 30 an hour per network; `complete(attempt, code)`: signed out, a session for the account with that SimpleLogin ID, or for someone new the handle suggested from their name; signed in (`identified` only, `wrongCaller` otherwise), SimpleLogin added to the caller's account; for an account, whether it has its keys and its handle, the attempt standing ten minutes for the sign-in; `checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network; `signUp(attempt, handle, setup)`, without a session: someone new's account, `identified`, with the handle and the keys the device made; a session; `beginMend(attempt, handle)`, `mend(attempt, proof)`, without a session: a broken link mended with the handle and the recovery phrase, the new identity in the old one's place; a session | anyone |
| `keys` | for an account that signs in with SimpleLogin (Halkyon Learn's ADR 0020, D19 and D20): `begin`, `unlock`: the caller's keys for their device, once the password is proven with its own proof key; `set`: the first keys (and handle) of an account SimpleLogin was added to, with a SimpleLogin sign-in of the last ten minutes; `changePassword`, `beginPhrase` and `resetPassword` (a lost password, with the recovery phrase), `setRecoveryKeys`: each with the password's proof (the phrase's) and such a sign-in (`KeysOwnerProof`), a new password ending every other session; the tenth wrong proof in a row ends the session, as ADR 0017's do | signed in |
| `simpleLogin` | `begin(platform)`: an attempt and SimpleLogin's address to open, at most 30 an hour per network; `complete(attempt, code)`: signed out, a session for the account with that SimpleLogin ID, or for someone new the handle suggested from their name; signed in (`identified` only, `wrongCaller` otherwise), SimpleLogin added to the caller's account; for an account, whether it has its keys, whether its password signs in too and has the keys' proof key (D34), when a reset of its keys takes effect (D31) and its handle, the attempt standing ten minutes for the sign-in; `checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network; `signUp(attempt, handle, setup)`, without a session: someone new's account, `identified`, with the handle and the keys the device made; a session; `beginMend(attempt, handle)`, `mend(attempt, proof)`, without a session: a broken link mended with the handle and the recovery phrase, the new identity in the old one's place; a session | anyone |
| `keys` | for an account that signs in with SimpleLogin (Halkyon Learn's ADR 0020, D19 and D20): `begin`, `unlock`: the caller's keys for their device, once the password is proven with its own proof key; `set`: the first keys (and handle) of an account SimpleLogin was added to, with a SimpleLogin sign-in of the last ten minutes; `changePassword`, `beginPhrase` and `resetPassword` (a lost password, with the recovery phrase), `setRecoveryKeys`: each with the password's proof (the phrase's) and such a sign-in (`KeysOwnerProof`), a new password ending every other session; the password and the phrase lost (D31–D33), `startReset` with such a sign-in, `completeReset` 72 hours later with another (new keys, every other session ending), `callOffResets` with the password's proof, which any proof of the password or the phrase also does; only an account that signs in with SimpleLogin alone changes here, one whose password signs in only unlocking (D34); the tenth wrong proof in a row ends the session, as ADR 0017's do | signed in |
| `gitHubSignIn` | `signIn`: a session for whoever has that GitHub account, unless they sign in with a password; none once `GITHUB_SIGN_IN_ENDS` has come | without a session |
| `emailCode` | `sendCode`: a code by mail, which proves the address and signs nobody in | anyone |
| `account` | `delete` (with the password's proof where the account has one, `keysProof` for one that signs in with SimpleLogin), `signInWays` | signed in |

View file

@ -42,6 +42,7 @@ export 'src/password/key_proof.dart'
newPasswordDigest,
newPhraseDigest,
passwordIdentity,
proofKeyDigest,
publicInstanceUrlOf;
export 'src/password/password_config.dart';
export 'src/password/passwords.dart'

View file

@ -413,6 +413,69 @@ class Endpoints extends _is.EndpointDispatch {
recoveryProofKey: params['recoveryProofKey'],
),
),
'startReset': _is.MethodConnector(
name: 'startReset',
params: {
'attempt': _is.ParameterDescription(
name: 'attempt',
type: _is.getType<_is.UuidValue>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['keys'] as _icowoccn.KeysEndpoint).startReset(
session,
attempt: params['attempt'],
),
),
'completeReset': _is.MethodConnector(
name: 'completeReset',
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,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['keys'] as _icowoccn.KeysEndpoint).completeReset(
session,
attempt: params['attempt'],
setup: params['setup'],
),
),
'callOffResets': _is.MethodConnector(
name: 'callOffResets',
params: {
'proof': _is.ParameterDescription(
name: 'proof',
type: _is.getType<_iy592cuk.KeysProof>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['keys'] as _icowoccn.KeysEndpoint).callOffResets(
session,
proof: params['proof'],
),
),
},
);
connectors['accountRecovery'] = _is.EndpointConnector(
@ -879,6 +942,31 @@ class Endpoints extends _is.EndpointDispatch {
proof: params['proof'],
),
),
'setProofKey': _is.MethodConnector(
name: 'setProofKey',
params: {
'proof': _is.ParameterDescription(
name: 'proof',
type: _is.getType<_ifhod73s.OwnerProof>(),
nullable: false,
),
'proofKey': _is.ParameterDescription(
name: 'proofKey',
type: _is.getType<_idt.ByteData>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['password'] as _ig7fyg1b.PasswordEndpoint)
.setProofKey(
session,
proof: params['proof'],
proofKey: params['proofKey'],
),
),
'setRecoveryKeys': _is.MethodConnector(
name: 'setRecoveryKeys',
params: {

View file

@ -15,6 +15,9 @@ keys:
- changePassword:
- resetPassword:
- setRecoveryKeys:
- startReset:
- completeReset:
- callOffResets:
accountRecovery:
- resetAuthenticator:
- beginKeyProof:
@ -32,6 +35,7 @@ password:
- beginChange:
- change:
- callOffResets:
- setProofKey:
- setRecoveryKeys:
- dismissSessionEndedWarning:
passwordSignIn:

View file

@ -28,7 +28,12 @@ abstract class SimpleLoginCompleted
this.suggestedHandle,
bool? hasKeys,
this.handle,
}) : hasKeys = hasKeys ?? false;
bool? passwordSignsIn,
bool? hasProofKey,
this.accountResetAt,
}) : hasKeys = hasKeys ?? false,
passwordSignsIn = passwordSignsIn ?? false,
hasProofKey = hasProofKey ?? false;
factory SimpleLoginCompleted({
required _i2lb2chw.SimpleLoginOutcome outcome,
@ -36,6 +41,9 @@ abstract class SimpleLoginCompleted
String? suggestedHandle,
bool? hasKeys,
String? handle,
bool? passwordSignsIn,
bool? hasProofKey,
DateTime? accountResetAt,
}) = _SimpleLoginCompletedImpl;
factory SimpleLoginCompleted.fromJson(
@ -55,6 +63,19 @@ abstract class SimpleLoginCompleted
? null
: _is.BoolJsonExtension.fromJson(jsonSerialization['hasKeys']),
handle: jsonSerialization['handle'] as String?,
passwordSignsIn: jsonSerialization['passwordSignsIn'] == null
? null
: _is.BoolJsonExtension.fromJson(
jsonSerialization['passwordSignsIn'],
),
hasProofKey: jsonSerialization['hasProofKey'] == null
? null
: _is.BoolJsonExtension.fromJson(jsonSerialization['hasProofKey']),
accountResetAt: jsonSerialization['accountResetAt'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['accountResetAt'],
),
);
}
@ -73,6 +94,22 @@ abstract class SimpleLoginCompleted
/// keys).
String? handle;
/// Whether the account's password signs in too (ADR 0017's, with SRP
/// and a code): its changes and resets are the password sign-in's, never
/// `keys`' (D34).
bool passwordSignsIn;
/// Whether `keys.unlock` opens the keys: they have the password's proof
/// key (D19). A password that signs in gets it once its password and a
/// code set it (`password.setProofKey`, D34): on adding SimpleLogin, and
/// again after a new password, as the key is made of the password.
bool hasProofKey;
/// When a reset of the keys waiting (or due, not carried out) takes
/// effect (D31): the password or the phrase opening them calls it off;
/// due, `keys.completeReset` carries it out. Null for none.
DateTime? accountResetAt;
/// Returns a shallow copy of this [SimpleLoginCompleted]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@ -82,6 +119,9 @@ abstract class SimpleLoginCompleted
String? suggestedHandle,
bool? hasKeys,
String? handle,
bool? passwordSignsIn,
bool? hasProofKey,
DateTime? accountResetAt,
});
@override
Map<String, dynamic> toJson() {
@ -92,6 +132,9 @@ abstract class SimpleLoginCompleted
if (suggestedHandle != null) 'suggestedHandle': suggestedHandle,
'hasKeys': hasKeys,
if (handle != null) 'handle': handle,
'passwordSignsIn': passwordSignsIn,
'hasProofKey': hasProofKey,
if (accountResetAt != null) 'accountResetAt': accountResetAt?.toJson(),
};
}
@ -104,6 +147,9 @@ abstract class SimpleLoginCompleted
if (suggestedHandle != null) 'suggestedHandle': suggestedHandle,
'hasKeys': hasKeys,
if (handle != null) 'handle': handle,
'passwordSignsIn': passwordSignsIn,
'hasProofKey': hasProofKey,
if (accountResetAt != null) 'accountResetAt': accountResetAt?.toJson(),
};
}
@ -122,12 +168,18 @@ class _SimpleLoginCompletedImpl extends SimpleLoginCompleted {
String? suggestedHandle,
bool? hasKeys,
String? handle,
bool? passwordSignsIn,
bool? hasProofKey,
DateTime? accountResetAt,
}) : super._(
outcome: outcome,
session: session,
suggestedHandle: suggestedHandle,
hasKeys: hasKeys,
handle: handle,
passwordSignsIn: passwordSignsIn,
hasProofKey: hasProofKey,
accountResetAt: accountResetAt,
);
/// Returns a shallow copy of this [SimpleLoginCompleted]
@ -140,6 +192,9 @@ class _SimpleLoginCompletedImpl extends SimpleLoginCompleted {
Object? suggestedHandle = _Undefined,
bool? hasKeys,
Object? handle = _Undefined,
bool? passwordSignsIn,
bool? hasProofKey,
Object? accountResetAt = _Undefined,
}) {
return SimpleLoginCompleted(
outcome: outcome ?? this.outcome,
@ -151,6 +206,11 @@ class _SimpleLoginCompletedImpl extends SimpleLoginCompleted {
: this.suggestedHandle,
hasKeys: hasKeys ?? this.hasKeys,
handle: handle is String? ? handle : this.handle,
passwordSignsIn: passwordSignsIn ?? this.passwordSignsIn,
hasProofKey: hasProofKey ?? this.hasProofKey,
accountResetAt: accountResetAt is DateTime?
? accountResetAt
: this.accountResetAt,
);
}
}

View file

@ -25,8 +25,13 @@ abstract class SimpleLoginProof
this.suggestedHandle,
bool? hasKeys,
this.handle,
bool? passwordSignsIn,
bool? hasProofKey,
this.accountResetAt,
this.endedBefore,
}) : hasKeys = hasKeys ?? false;
}) : hasKeys = hasKeys ?? false,
passwordSignsIn = passwordSignsIn ?? false,
hasProofKey = hasProofKey ?? false;
factory SimpleLoginProof({
required _i2lb2chw.SimpleLoginOutcome outcome,
@ -34,6 +39,9 @@ abstract class SimpleLoginProof
String? suggestedHandle,
bool? hasKeys,
String? handle,
bool? passwordSignsIn,
bool? hasProofKey,
DateTime? accountResetAt,
DateTime? endedBefore,
}) = _SimpleLoginProofImpl;
@ -50,6 +58,19 @@ abstract class SimpleLoginProof
? null
: _is.BoolJsonExtension.fromJson(jsonSerialization['hasKeys']),
handle: jsonSerialization['handle'] as String?,
passwordSignsIn: jsonSerialization['passwordSignsIn'] == null
? null
: _is.BoolJsonExtension.fromJson(
jsonSerialization['passwordSignsIn'],
),
hasProofKey: jsonSerialization['hasProofKey'] == null
? null
: _is.BoolJsonExtension.fromJson(jsonSerialization['hasProofKey']),
accountResetAt: jsonSerialization['accountResetAt'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['accountResetAt'],
),
endedBefore: jsonSerialization['endedBefore'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
@ -70,6 +91,15 @@ abstract class SimpleLoginProof
/// The account's handle; null for none.
String? handle;
/// Whether the account's password signs in too (ADR 0017's).
bool passwordSignsIn;
/// Whether the keys have the password's proof key (D19, D34).
bool hasProofKey;
/// When a reset of the keys waiting (or due) takes effect (D31).
DateTime? accountResetAt;
/// For a link mended (8): the moment every session of the account made
/// before ended, which the session issued for it comes after
/// (`issueSessionAfter`); null otherwise.
@ -84,6 +114,9 @@ abstract class SimpleLoginProof
String? suggestedHandle,
bool? hasKeys,
String? handle,
bool? passwordSignsIn,
bool? hasProofKey,
DateTime? accountResetAt,
DateTime? endedBefore,
});
@override
@ -95,6 +128,9 @@ abstract class SimpleLoginProof
if (suggestedHandle != null) 'suggestedHandle': suggestedHandle,
'hasKeys': hasKeys,
if (handle != null) 'handle': handle,
'passwordSignsIn': passwordSignsIn,
'hasProofKey': hasProofKey,
if (accountResetAt != null) 'accountResetAt': accountResetAt?.toJson(),
if (endedBefore != null) 'endedBefore': endedBefore?.toJson(),
};
}
@ -119,6 +155,9 @@ class _SimpleLoginProofImpl extends SimpleLoginProof {
String? suggestedHandle,
bool? hasKeys,
String? handle,
bool? passwordSignsIn,
bool? hasProofKey,
DateTime? accountResetAt,
DateTime? endedBefore,
}) : super._(
outcome: outcome,
@ -126,6 +165,9 @@ class _SimpleLoginProofImpl extends SimpleLoginProof {
suggestedHandle: suggestedHandle,
hasKeys: hasKeys,
handle: handle,
passwordSignsIn: passwordSignsIn,
hasProofKey: hasProofKey,
accountResetAt: accountResetAt,
endedBefore: endedBefore,
);
@ -139,6 +181,9 @@ class _SimpleLoginProofImpl extends SimpleLoginProof {
Object? suggestedHandle = _Undefined,
bool? hasKeys,
Object? handle = _Undefined,
bool? passwordSignsIn,
bool? hasProofKey,
Object? accountResetAt = _Undefined,
Object? endedBefore = _Undefined,
}) {
return SimpleLoginProof(
@ -149,6 +194,11 @@ class _SimpleLoginProofImpl extends SimpleLoginProof {
: this.suggestedHandle,
hasKeys: hasKeys ?? this.hasKeys,
handle: handle is String? ? handle : this.handle,
passwordSignsIn: passwordSignsIn ?? this.passwordSignsIn,
hasProofKey: hasProofKey ?? this.hasProofKey,
accountResetAt: accountResetAt is DateTime?
? accountResetAt
: this.accountResetAt,
endedBefore: endedBefore is DateTime? ? endedBefore : this.endedBefore,
);
}

View file

@ -2,6 +2,7 @@ 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/account_resets.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/profile/first_handle.dart';
@ -14,8 +15,13 @@ import 'package:serverpod/serverpod.dart';
// 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.
// as ADR 0017's take the password and a code; and, the password and the
// phrase lost, a reset to new keys after 72 hours (D31 to D33). Only an
// account that signs in with SimpleLogin alone changes here (D34): one
// whose password signs in too unlocks here, once it set the keys' proof
// key, but changes as ADR 0017 has it, which keeps SRP's verifier in step.
// 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
@ -138,9 +144,11 @@ Future<void> setFirstKeys(
/// [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.
/// ([PasswordRefusal.invalidSetup]), an account whose password signs in
/// too or that has no keys ([PasswordRefusal.noPassword], D34), 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,
@ -202,6 +210,7 @@ Future<void> _newPassword(
if (!isKeysPassword(password)) {
throw refusedFor(PasswordRefusal.invalidSetup);
}
await _keysAloneOf(session, person);
await requireSimpleLoginConfirmation(
session,
person: person,
@ -274,9 +283,10 @@ Future<void> _newPassword(
/// (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.
/// of range ([PasswordRefusal.invalidSetup]), as [changeKeysPassword]
/// refuses an account ([PasswordRefusal.noPassword]), and as
/// `provePasswordKey` refuses; the sign-in's refusals as
/// [changeKeysPassword]'s. Nothing changes then.
Future<void> setKeysRecovery(
Session session, {
required UuidValue person,
@ -287,6 +297,7 @@ Future<void> setKeysRecovery(
if (!isWrappedKeys(recoveryKeys) || !isPublicKey(recoveryProofKey)) {
throw refusedFor(PasswordRefusal.invalidSetup);
}
await _keysAloneOf(session, person);
await requireSimpleLoginConfirmation(
session,
person: person,
@ -364,3 +375,187 @@ Future<void> requireKeysOwnerToDelete(
),
);
}
/// Asks for the reset of [person]'s keys, their password and their recovery
/// phrase lost (Halkyon Learn's ADR 0020, D31), with their SimpleLogin
/// sign-in under [attempt] of the last ten minutes, taken (D20): it takes
/// effect [resetDelay] later, when new keys made on a device take the old
/// ones' place ([completeKeysReset]). There is no mail: their signed-in
/// devices show it (`SignInWays.accountResetAt`), as the next SimpleLogin
/// sign-in does, and any proof of the password or the phrase calls it off
/// (`provePasswordKey`, [callOffKeysResets]; D32), so whoever took over the
/// SimpleLogin account gets new keys only where its owner neither unlocked
/// nor said no for 72 hours. Asked again while it waits, it keeps its time;
/// once it lapsed (`resetLapsed`), it waits anew.
///
/// Refused ([PasswordRefusedException]) for an account without such keys
/// ([PasswordRefusal.noPassword]: one whose password signs in resets as ADR
/// 0017 has it, with a code by mail); the sign-in's refusals as
/// [requireSimpleLoginConfirmation]'s and [takeSimpleLoginConfirmation]'s.
/// Nothing is asked then.
Future<ResetPending> startKeysReset(
Session session, {
required UuidValue person,
required UuidValue attempt,
}) async {
await _keysAloneOf(session, person);
await requireSimpleLoginConfirmation(
session,
person: person,
attempt: attempt,
);
final now = clock.now().toUtc();
return await session.db.transaction((transaction) async {
await takeSimpleLoginConfirmation(
session,
person: person,
attempt: attempt,
transaction: transaction,
);
// SELECT * FROM "account_reset" WHERE "authUserId" = $1
// AND "kind" = 'account'
final waiting = await AccountReset.db.findFirstRow(
session,
where: (t) =>
t.authUserId.equals(person) & t.kind.equals(AccountResetKind.account),
transaction: transaction,
);
if (waiting != null) {
if (!resetLapsed(waiting, now)) {
return ResetPending(takesEffectAt: waiting.takesEffectAt);
}
// DELETE FROM "account_reset" WHERE "id" = $1
await AccountReset.db.deleteRow(
session,
waiting,
transaction: transaction,
);
}
// INSERT INTO "account_reset" ("authUserId", "kind", "takesEffectAt",
// "createdAt") VALUES ($1, 'account', $2, $3)
final reset = await AccountReset.db.insertRow(
session,
AccountReset(
authUserId: person,
kind: AccountResetKind.account,
takesEffectAt: now.add(resetDelay),
createdAt: now,
),
transaction: transaction,
);
return ResetPending(takesEffectAt: reset.takesEffectAt);
});
}
/// Carries out [person]'s reset of keys, asked with [startKeysReset] and
/// due now, with their SimpleLogin sign-in under [attempt] of the last ten
/// minutes, taken: [setup] (a new password, keys and recovery phrase, all
/// made on the device, which wraps the old keys anew where it still holds
/// them, D33) replaces the old, and every other session of the account
/// ends, in every service of the instance; [keepSession], the one asking,
/// stays. What was encrypted under keys no device holds any more is lost;
/// the handle, the profile and the SimpleLogin link stay.
///
/// Refused ([PasswordRefusedException]) for a setup out of range
/// ([PasswordRefusal.invalidSetup]), as [startKeysReset] refuses an account
/// ([PasswordRefusal.noPassword]), without such a reset or with one that
/// lapsed ([PasswordRefusal.noReset]), and before its time
/// ([PasswordRefusal.notDue], with when); the sign-in's refusals as
/// [startKeysReset]'s. Nothing changes then.
Future<void> completeKeysReset(
Session session, {
required UuidValue person,
required UuidValue attempt,
required KeysSetup setup,
UuidValue? keepSession,
}) async {
if (!isKeysSetup(setup)) {
throw refusedFor(PasswordRefusal.invalidSetup);
}
final row = await _keysAloneOf(session, person);
await requireSimpleLoginConfirmation(
session,
person: person,
attempt: attempt,
);
final now = clock.now().toUtc();
await session.db.transaction((transaction) async {
// DELETE FROM "account_reset" WHERE "authUserId" = $1
// AND "kind" = 'account' RETURNING *
final reset = (await AccountReset.db.deleteWhere(
session,
where: (t) =>
t.authUserId.equals(person) & t.kind.equals(AccountResetKind.account),
transaction: transaction,
)).firstOrNull;
// A week after it took effect, it lapsed: as never asked, its row left
// to the pruning (the refusal rolls the deletion back).
if (reset == null || resetLapsed(reset, now)) {
throw refusedFor(PasswordRefusal.noReset);
}
if (reset.takesEffectAt.isAfter(now)) {
throw PasswordRefusedException(
reason: PasswordRefusal.notDue,
takesEffectAt: reset.takesEffectAt,
);
}
await takeSimpleLoginConfirmation(
session,
person: person,
attempt: attempt,
transaction: transaction,
);
// UPDATE "account_password" SET … WHERE "id" = $1 (the account's row,
// when it was made kept)
await AccountPassword.db.updateRow(
session,
keysRowOf(
person,
setup,
now: now,
).copyWith(id: row.id, createdAt: row.createdAt),
transaction: transaction,
);
await endSessionsEverywhere(
session,
person,
keepSession: keepSession,
transaction: transaction,
);
});
await announceSessionsEnded(session, person);
}
/// Calls off [person]'s reset of keys waiting with [proof], their
/// password's proof for it ([KeyProofPurpose.callOffResets]) from a
/// signed-in device, which shows the reset (Halkyon Learn's ADR 0020, D32):
/// whoever took over their SimpleLogin account asked for it, and the
/// owner's password says no; a session alone, which a thief may hold too,
/// says nothing. Refusals as `provePasswordKey`'s, which calls it off.
Future<void> callOffKeysResets(
Session session, {
required UuidValue person,
required KeysProof proof,
}) async {
await provePasswordKey(
session,
person: person,
proof: proof,
purpose: KeyProofPurpose.callOffResets,
);
}
/// [person]'s keys, refused ([PasswordRefusal.noPassword]) unless they are
/// an account's that signs in with SimpleLogin alone (`opensKeysAlone`):
/// only those change here (D34).
Future<AccountPassword> _keysAloneOf(Session session, UuidValue person) async {
// SELECT * FROM "account_password" WHERE "authUserId" = $1 LIMIT 1
final row = await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(person),
);
if (row == null || !opensKeysAlone(row)) {
throw refusedFor(PasswordRefusal.noPassword);
}
return row;
}

View file

@ -11,9 +11,11 @@ import 'package:serverpod_auth_idp_server/core.dart';
/// The keys of an account that signs in with SimpleLogin (Halkyon Learn'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.
/// 0016), every change with that proof and a SimpleLogin sign-in of the
/// last ten minutes on the device (`KeysOwnerProof`), and, the password and
/// the phrase lost, a reset to new keys 72 hours later (D31 to D33).
/// Refusals are `PasswordRefusedException`s; the sign-in's,
/// `OidcRefusedException`s.
class KeysEndpoint extends Endpoint {
@override
bool get requireLogin => true;
@ -157,6 +159,67 @@ class KeysEndpoint extends Endpoint {
);
}
/// Asks for the reset of your keys, your password and your recovery
/// phrase lost (D31), with your SimpleLogin sign-in of the last ten
/// minutes on this device, under [attempt]: it takes effect 72 hours
/// later, which this answers, when [completeReset] gives you new keys.
/// Your signed-in devices show it meanwhile, and your password or your
/// phrase opening your keys anywhere calls it off, as [callOffResets]
/// does. Asked again while it waits, it keeps its time. Refused for an
/// account without such keys (`PasswordRefusal.noPassword`; one whose
/// password signs in resets as `accountRecovery` has it) and without that
/// sign-in (`OidcRefusal.unknownAttempt`).
Future<ResetPending> startReset(
Session session, {
required UuidValue attempt,
}) => AccountRegister.of(session.serverpod).startKeysReset(
session,
person: session.authenticated!.authUserId,
attempt: attempt,
);
/// Carries out the reset of your keys, due now, with your SimpleLogin
/// sign-in of the last ten minutes on this device, under [attempt]:
/// [setup], all made on your device (your old keys wrapped anew where it
/// still holds them, D33), replaces your password, keys and phrase. What
/// was encrypted under keys no device holds any more is lost; your
/// handle, profile and SimpleLogin stay. Every other session of yours
/// ends, in every service of the instance; this one stays. Refused before
/// its time (`PasswordRefusal.notDue`, with when), without such a reset
/// or once it lapsed (`PasswordRefusal.noReset`), for a setup out of range
/// (`PasswordRefusal.invalidSetup`), and as [startReset] refuses.
Future<void> completeReset(
Session session, {
required UuidValue attempt,
required KeysSetup setup,
}) => AccountRegister.of(session.serverpod).completeKeysReset(
session,
person: session.authenticated!.authUserId,
attempt: attempt,
setup: setup,
keepSession: _asking(session),
);
/// Calls off a reset of your keys waiting (D32), once [proof] proved your
/// password for it, for a challenge of [begin]: whoever asked for it took
/// over your SimpleLogin account. The proof counts against this session,
/// as [unlock]'s. Refusals as [unlock]'s.
Future<void> callOffResets(Session session, {required KeysProof proof}) {
final pod = session.serverpod;
final bound = proof.copyWith(
instanceUrl: deviceInstanceUrlOn(pod, proof.instanceUrl),
);
return asOwnerProofTry(
session,
bound,
() => AccountRegister.of(pod).callOffKeysResets(
session,
person: session.authenticated!.authUserId,
proof: bound,
),
);
}
/// [proof] bound to the base URL the device addressed here.
static KeysOwnerProof _bound(Session session, KeysOwnerProof proof) =>
proof.copyWith(

View file

@ -334,21 +334,25 @@ Future<AccountSessionsEnded> completeAccountReset(
return (account: account, endedBefore: endedBefore);
}
/// The account whose address's hash is [hash], with a password; refused
/// ([PasswordRefusal.noPassword]) for none.
/// The account whose address's hash is [hash], with a password that signs
/// in; refused ([PasswordRefusal.noPassword]) for none, and for one whose
/// password only opens its keys, as SimpleLogin's do (Halkyon Learn's ADR
/// 0020, D31): their SimpleLogin sign-in asks for their reset, which a
/// mailbox alone mustn't, nor carry out with a password that signs in.
Future<UuidValue> _accountWithPassword(Session session, String hash) async {
// SELECT * FROM "email_identity" WHERE "addressHash" = $1
final identity = await EmailIdentity.db.findFirstRow(
session,
where: (t) => t.addressHash.equals(hash),
);
if (identity == null ||
// SELECT count(*) FROM "account_password" WHERE "authUserId" = $1
await AccountPassword.db.count(
session,
where: (t) => t.authUserId.equals(identity.authUserId),
) ==
0) {
// SELECT * FROM "account_password" WHERE "authUserId" = $1 LIMIT 1
final password = identity == null
? null
: await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(identity.authUserId),
);
if (identity == null || password?.verifier == null) {
throw refusedFor(PasswordRefusal.noPassword);
}
return identity.authUserId;

View file

@ -50,7 +50,11 @@ enum PasswordProofPurpose {
callOffResets('call off resets'),
/// Deleting the account.
deleteAccount('delete account');
deleteAccount('delete account'),
/// Setting the keys' proof key, made of the same password, for devices
/// signed in with SimpleLogin (Halkyon Learn's ADR 0020, D34).
proofKey('keys proof key');
PasswordProofPurpose(this.label);
@ -119,7 +123,11 @@ enum KeyProofPurpose {
deleteAccount('delete account'),
/// Linking a new SimpleLogin ID in place of a lost one, by the phrase.
mendLink('mend link');
mendLink('mend link'),
/// Calling off a reset of the keys waiting, by the password (Halkyon
/// Learn's ADR 0020, D32).
callOffResets('call off resets');
KeyProofPurpose(this.label);
@ -198,6 +206,14 @@ Uint8List mendLinkDigest(UuidValue attempt) =>
Uint8List authenticatorDigest(UuidValue id) =>
_sha256([utf8.encode('halkyon authenticator v1'), id.toBytes()]);
/// The SHA-256 of [proofKey], the public half of the keys' proof key a
/// password makes (Halkyon Learn's ADR 0020, D34), framed: what the owner's
/// proof for setting it binds.
Uint8List proofKeyDigest(ByteData proofKey) => _sha256([
utf8.encode('halkyon keys proof key v1'),
Uint8List.sublistView(proofKey),
]);
Uint8List _sha256(List<List<int>> parts) =>
Uint8List.fromList(hashes.sha256.convert(_framed(parts)).bytes);

View file

@ -135,6 +135,45 @@ Future<void> setRecoveryKeys(
);
}
/// Sets the keys' proof key of [person], whose password signs in, to
/// [proofKey] (its public half, made on the device of that same password
/// beside SRP's x), with the owner's [proof] ([proveOwner], binding the
/// key): their keys then open with the password alone on a device signed in
/// with SimpleLogin (`keys.unlock`; Halkyon Learn's ADR 0020, D34), as they
/// open when the password signs in. A new password takes the key away
/// (`writeNewPassword`), as it was made of the old one.
///
/// Refused ([PasswordRefusedException]) for a key out of range
/// ([PasswordRefusal.invalidSetup]), and as [proveOwner]. Nothing changes
/// then.
Future<void> setPasswordProofKey(
Session session, {
required UuidValue person,
required OwnerProof proof,
required ByteData proofKey,
}) async {
final keys = requirePasswordKeys(session.serverpod);
if (!isPublicKey(proofKey)) {
throw refusedFor(PasswordRefusal.invalidSetup);
}
final row = await proveOwner(
session,
keys,
person: person,
proof: proof,
purpose: PasswordProofPurpose.proofKey,
digest: proofKeyDigest(proofKey),
);
// UPDATE "account_password" SET "passwordProofKey" = $1, "updatedAt" = $2
// WHERE "id" = $3
// (Those columns alone: a change of phrase meanwhile stays.)
await AccountPassword.db.updateRow(
session,
row.copyWith(passwordProofKey: proofKey, updatedAt: clock.now().toUtc()),
columns: (t) => [t.passwordProofKey, t.updatedAt],
);
}
/// Proves [person] may delete their account: one with a password only
/// with [proof] (the password's proof bound to the base URL the device
/// addressed and a code, as a change of password takes: a session alone,

View file

@ -133,6 +133,33 @@ class PasswordEndpoint extends Endpoint {
);
}
/// Sets [proofKey], the public half of the keys' proof key the caller's
/// password makes on the device, with the owner's [proof] binding it: the
/// keys then open with the password alone on a device signed in with
/// SimpleLogin (`keys.unlock`; Halkyon Learn's ADR 0020, D34). A new
/// password takes it away, as it is made of the password. Counted against
/// this session as every owner's proof is. Refusals as
/// `setPasswordProofKey`'s.
Future<void> setProofKey(
Session session, {
required OwnerProof proof,
required ByteData proofKey,
}) {
final bound = proof.copyWith(
instanceUrl: deviceInstanceUrlOn(session.serverpod, proof.instanceUrl),
);
return asOwnerProofTry(
session,
bound,
() => AccountRegister.of(session.serverpod).setPasswordProofKey(
session,
person: session.authenticated!.authUserId,
proof: bound,
proofKey: proofKey,
),
);
}
/// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under
/// it on the device, replace the old wrapping, and [recoveryProofKey], the
/// public half of its proof key, the old one, with the owner's [proof], as

View file

@ -807,8 +807,11 @@ Future<ProfileRefusal?> checkSignUpHandle(
/// Writes [password] (a change, or a reset keeping the keys) over [row]'s
/// password alone: the salt, the costs, the verifier and the keys wrapped
/// under it. Never the row's other columns, which [row] may hold as they
/// were before a change made meanwhile (a new phrase) that must stay.
/// under it, the keys' proof key going (Halkyon Learn's ADR 0020, D34: it
/// was made of the old password; the owner sets the new one's with
/// `setPasswordProofKey`). Never the row's other columns, which [row] may
/// hold as they were before a change made meanwhile (a new phrase) that
/// must stay.
Future<void> writeNewPassword(
Session session,
AccountPassword row,
@ -817,8 +820,9 @@ Future<void> writeNewPassword(
}) =>
// UPDATE "account_password" SET "salt" = $1, "kdfAlgorithm" = $2,
// "kdfOpsLimit" = $3, "kdfMemLimit" = $4, "verifier" = $5,
// "passwordKeysNonce" = $6, "passwordKeysCipherText" = $7,
// "sessionEndedForProofsAt" = NULL, "updatedAt" = $8 WHERE "id" = $9
// "passwordProofKey" = NULL, "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).
AccountPassword.db.updateRow(
@ -829,11 +833,14 @@ Future<void> writeNewPassword(
kdfOpsLimit: password.costs.opsLimit,
kdfMemLimit: password.costs.memLimit,
verifier: password.verifier,
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
passwordProofKey: null,
passwordKeysNonce: password.passwordKeys.nonce,
passwordKeysCipherText: password.passwordKeys.cipherText,
// As above.
// ignore: avoid_redundant_argument_values
sessionEndedForProofsAt: null,
updatedAt: clock.now().toUtc(),
),
@ -843,6 +850,7 @@ Future<void> writeNewPassword(
t.kdfOpsLimit,
t.kdfMemLimit,
t.verifier,
t.passwordProofKey,
t.passwordKeysNonce,
t.passwordKeysCipherText,
t.sessionEndedForProofsAt,
@ -1473,7 +1481,9 @@ Future<DateTime> _carryOutAuthenticatorReset(
/// Calls off the reset of [account] that a code by mail asked for: its
/// owner signed in with the password alone, once a reset of the
/// authenticators took effect. That reset's row stays: until an
/// authenticator is confirmed, the password alone signs in.
/// authenticator is confirmed, the password alone signs in. Also the reset
/// of keys a SimpleLogin sign-in asked for, once the password or the phrase
/// proved its owner (Halkyon Learn's ADR 0020, D31).
Future<void> _callOffAccountReset(Session session, UuidValue account) =>
// DELETE FROM "account_reset" WHERE "authUserId" = $1 AND "kind" = $2
AccountReset.db.deleteWhere(
@ -1668,13 +1678,13 @@ Future<AccountPassword> provePasswordKey(
/// 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).
/// password as ADR 0017 has it, its keys' proof key set or not, D34).
Future<KeyProofChallenge> beginPhraseKeyProof(
Session session, {
required UuidValue person,
}) async {
final password = await _passwordOf(session, person);
if (password == null || password.passwordProofKey == null) {
if (password == null || !opensKeysAlone(password)) {
throw refusedFor(PasswordRefusal.noPassword);
}
final nonce = _randomBytes(32);
@ -1717,7 +1727,7 @@ Future<AccountPassword> provePhraseKey(
proof: proof,
begunFor: SignInChallengePurpose.phraseProof,
keyOf: (password) =>
password.passwordProofKey == null ? null : password.recoveryProofKey,
opensKeysAlone(password) ? password.recoveryProofKey : null,
purpose: purpose,
digest: digest,
);
@ -1725,7 +1735,8 @@ Future<AccountPassword> provePhraseKey(
/// [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.
/// check. Proved, it calls off a reset of the account waiting (Halkyon
/// Learn's ADR 0020, D31): the owner knows the password or the phrase.
Future<AccountPassword> _proveOwnKey(
Session session, {
required UuidValue person,
@ -1756,6 +1767,7 @@ Future<AccountPassword> _proveOwnKey(
)) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
await _callOffAccountReset(session, person);
return password;
}
@ -1809,7 +1821,7 @@ Future<KeyProofChallenge> beginMendKeyProof(
/// Takes the challenge [proof] answers (once, one of [beginMendKeyProof])
/// and checks its signature by the recovery phrase's proof key, for
/// [KeyProofPurpose.mendLink] and [digest] (`mendLinkDigest`): the
/// account's password row. Refused ([PasswordRefusedException]) for a
/// account's password row, a reset of it waiting called off. Refused ([PasswordRefusedException]) for a
/// challenge unknown, used or too old ([PasswordRefusal.challengeExpired]),
/// and alike for a signature by another key and a handle nobody has, checked
/// against the decoy's key so it takes as long
@ -1844,6 +1856,8 @@ Future<AccountPassword> proveMendKeyProof(
if (!signed || password == null || proofKey == null) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
// The phrase proved its owner (Halkyon Learn's ADR 0020, D31).
await _callOffAccountReset(session, password.authUserId);
return password;
}
@ -1854,6 +1868,14 @@ KeysProof keyProofOf(KeysOwnerProof proof) => KeysProof(
instanceUrl: proof.instanceUrl,
);
/// Whether [password], an account's row, is one of an account that signs
/// in with SimpleLogin alone (Halkyon Learn's ADR 0020, D19): its password
/// only opens its keys, proven with its own proof key, with no SRP
/// verifier. Only such rows change through `keys` (D34): one whose password
/// signs in changes as ADR 0017 has it, which keeps its verifier in step.
bool opensKeysAlone(AccountPassword password) =>
password.verifier == null && password.passwordProofKey != null;
/// Whether [password] is a new password for keys this register takes.
bool isKeysPassword(KeysPassword password) =>
password.salt.lengthInBytes == _saltLength &&

View file

@ -268,6 +268,44 @@ abstract interface class AccountRegister {
required ByteData recoveryProofKey,
});
/// Asks for the reset of [person]'s keys, their password and phrase lost,
/// with their SimpleLogin sign-in under [attempt]. Refusals as
/// `startKeysReset`'s.
Future<ResetPending> startKeysReset(
Session session, {
required UuidValue person,
required UuidValue attempt,
});
/// Carries out [person]'s reset of keys with [setup] and their SimpleLogin
/// sign-in under [attempt]: every other session ends but [keepSession], in
/// every service. Refusals as `completeKeysReset`'s.
Future<void> completeKeysReset(
Session session, {
required UuidValue person,
required UuidValue attempt,
required KeysSetup setup,
UuidValue? keepSession,
});
/// Calls off [person]'s reset of keys waiting, with their password's
/// [proof]. Refusals as `callOffKeysResets`'.
Future<void> callOffKeysResets(
Session session, {
required UuidValue person,
required KeysProof proof,
});
/// Sets the keys' proof key of [person], whose password signs in, to
/// [proofKey], with the owner's [proof]. Refusals as
/// `setPasswordProofKey`'s.
Future<void> setPasswordProofKey(
Session session, {
required UuidValue person,
required OwnerProof proof,
required ByteData proofKey,
});
/// 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.

View file

@ -297,6 +297,37 @@ Future<Map<String, Object?>?> _answer(
recoveryProofKey: arguments.bytes('recoveryProofKey'),
);
return const {};
case RegisterOperation.startKeysReset:
final pending = await register.startKeysReset(
session,
person: arguments.id('person'),
attempt: arguments.id('attempt'),
);
return {'pending': pending.toJson()};
case RegisterOperation.completeKeysReset:
await register.completeKeysReset(
session,
person: arguments.id('person'),
attempt: arguments.id('attempt'),
setup: arguments.model('setup', KeysSetup.fromJson),
keepSession: arguments.optionalId('keepSession'),
);
return const {};
case RegisterOperation.callOffKeysResets:
await register.callOffKeysResets(
session,
person: arguments.id('person'),
proof: arguments.model('proof', KeysProof.fromJson),
);
return const {};
case RegisterOperation.setPasswordProofKey:
await register.setPasswordProofKey(
session,
person: arguments.id('person'),
proof: arguments.model('proof', OwnerProof.fromJson),
proofKey: arguments.bytes('proofKey'),
);
return const {};
case RegisterOperation.signInWaysOf:
final ways = await register.signInWaysOf(
session,

View file

@ -545,6 +545,48 @@ final class LocalRegister implements AccountRegister {
recoveryProofKey: recoveryProofKey,
);
@override
Future<ResetPending> startKeysReset(
Session session, {
required UuidValue person,
required UuidValue attempt,
}) => keys.startKeysReset(session, person: person, attempt: attempt);
@override
Future<void> completeKeysReset(
Session session, {
required UuidValue person,
required UuidValue attempt,
required KeysSetup setup,
UuidValue? keepSession,
}) => keys.completeKeysReset(
session,
person: person,
attempt: attempt,
setup: setup,
keepSession: keepSession,
);
@override
Future<void> callOffKeysResets(
Session session, {
required UuidValue person,
required KeysProof proof,
}) => keys.callOffKeysResets(session, person: person, proof: proof);
@override
Future<void> setPasswordProofKey(
Session session, {
required UuidValue person,
required OwnerProof proof,
required ByteData proofKey,
}) => change.setPasswordProofKey(
session,
person: person,
proof: proof,
proofKey: proofKey,
);
@override
Future<SignInWays> signInWaysOf(Session session, UuidValue person) async {
// SELECT * FROM "account_password" WHERE "authUserId" = $1

View file

@ -42,6 +42,10 @@ abstract final class RegisterOperation {
static const changeKeysPassword = 'change-keys-password';
static const resetKeysPassword = 'reset-keys-password';
static const setKeysRecovery = 'set-keys-recovery';
static const startKeysReset = 'start-keys-reset';
static const completeKeysReset = 'complete-keys-reset';
static const callOffKeysResets = 'call-off-keys-resets';
static const setPasswordProofKey = 'set-password-proof-key';
static const signInWaysOf = 'sign-in-ways-of';
static const noteSessionEndedForProofs = 'note-session-ended-for-proofs';
static const dismissSessionEndedWarning = 'dismiss-session-ended-warning';

View file

@ -469,6 +469,57 @@ final class RemoteRegister implements AccountRegister {
'recoveryProofKey': _bytes(recoveryProofKey),
});
@override
Future<ResetPending> startKeysReset(
Session session, {
required UuidValue person,
required UuidValue attempt,
}) async => _model<ResetPending>(
RegisterOperation.startKeysReset,
await _ask(RegisterOperation.startKeysReset, {
'person': person.uuid,
'attempt': attempt.uuid,
}),
'pending',
ResetPending.fromJson,
);
@override
Future<void> completeKeysReset(
Session session, {
required UuidValue person,
required UuidValue attempt,
required KeysSetup setup,
UuidValue? keepSession,
}) => _ask(RegisterOperation.completeKeysReset, {
'person': person.uuid,
'attempt': attempt.uuid,
'setup': setup.toJson(),
'keepSession': keepSession?.uuid,
});
@override
Future<void> callOffKeysResets(
Session session, {
required UuidValue person,
required KeysProof proof,
}) => _ask(RegisterOperation.callOffKeysResets, {
'person': person.uuid,
'proof': proof.toJson(),
});
@override
Future<void> setPasswordProofKey(
Session session, {
required UuidValue person,
required OwnerProof proof,
required ByteData proofKey,
}) => _ask(RegisterOperation.setPasswordProofKey, {
'person': person.uuid,
'proof': proof.toJson(),
'proofKey': _bytes(proofKey),
});
@override
Future<SignInWays> signInWaysOf(Session session, UuidValue person) async =>
switch ((await _ask(RegisterOperation.signInWaysOf, {

View file

@ -2,6 +2,7 @@ import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/auth/identified.dart';
import 'package:halkyon_base_server/src/auth/scopes.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/account_resets.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';
@ -138,7 +139,8 @@ Future<SimpleLoginProof> completeSimpleLogin(
/// [account] signed in with [identity] ([outcome]): the sign-in kept for a
/// change under [attempt] for [confirmationLasts], and the proof with the
/// account's keys and handle.
/// account's keys (whether its password signs in too and has their proof
/// key, D34; when their reset takes effect, D31) and handle.
Future<SimpleLoginProof> _signedInAs(
Session session,
OidcIdp idp, {
@ -158,8 +160,8 @@ Future<SimpleLoginProof> _signedInAs(
),
keepFor: confirmationLasts,
);
// SELECT count(*) FROM "account_password" WHERE "authUserId" = $1
final keys = await AccountPassword.db.count(
// SELECT * FROM "account_password" WHERE "authUserId" = $1 LIMIT 1
final keys = await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(account),
);
@ -171,8 +173,11 @@ Future<SimpleLoginProof> _signedInAs(
return SimpleLoginProof(
outcome: outcome,
account: account,
hasKeys: keys > 0,
hasKeys: keys != null,
handle: profile?.handle,
passwordSignsIn: keys?.verifier != null,
hasProofKey: keys?.passwordProofKey != null,
accountResetAt: (await waitingResets(session, account)).account,
);
}

View file

@ -14,3 +14,16 @@ fields:
### The account's handle; null for one without (it chooses one with its
### keys).
handle: String?
### Whether the account's password signs in too (ADR 0017's, with SRP
### and a code): its changes and resets are the password sign-in's, never
### `keys`' (D34).
passwordSignsIn: bool, default=false
### Whether `keys.unlock` opens the keys: they have the password's proof
### key (D19). A password that signs in gets it once its password and a
### code set it (`password.setProofKey`, D34): on adding SimpleLogin, and
### again after a new password, as the key is made of the password.
hasProofKey: bool, default=false
### When a reset of the keys waiting (or due, not carried out) takes
### effect (D31): the password or the phrase opening them calls it off;
### due, `keys.completeReset` carries it out. Null for none.
accountResetAt: DateTime?

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