feat(base): mending a broken SimpleLogin link with the handle and the recovery phrase (ADR 0020, D7)
Someone whose SimpleLogin identity is new to the register but who has an
account ("I already have one") names their handle and proves the
account's recovery phrase; the new identity takes the old one's place.
- `simpleLogin.beginMend(attempt, handle)`: the keys wrapped under the
phrase's key, the public keys and a nonce, for the account with that
handle (whatever its case). Only an account with keys and a SimpleLogin
identity is mended, a link it had, as nobody adds SimpleLogin signed
out (D10); a handle nobody has, or any other account's, gets a decoy
from `signInDecoyKey`, the same each time. At most 10 an hour per
handle and 30 per network.
- `simpleLogin.mend(attempt, proof)`: the phrase's proof for `mendLink`,
binding the attempt (`mendLinkDigest`, on both sides), so a proof
relayed from another sign-in links nobody's identity but the device's
own; checked for the waiting identity first, which leaves the proof
unspent. Every session of the account ends, in every service, and the
sign-in stands ten minutes for a change, such as a lost password.
- SimpleLogin needs `SIGN_IN_DECOY_KEY` alone of ADR 0017's three keys
(`signInDecoyKeyOf`); `addInstanceRoutes` stops a server with a
SimpleLogin app and without it (`checkSimpleLoginMending`).
- Members ask the register for both; `RemoteRegister` and the instance
API carry them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
7c58d23424
commit
e5b9578916
31 changed files with 1256 additions and 24 deletions
|
|
@ -86,6 +86,7 @@ EMAIL_FROM=
|
|||
# code (people make new ones once signed in).
|
||||
# SIGN_IN_DECOY_KEY makes the answers for addresses nobody has look like an account's:
|
||||
# changing it lets someone who watched those answers tell which addresses are unused.
|
||||
# SimpleLogin needs it alone (below), for handles nobody has.
|
||||
# Generate each: openssl rand -hex 32
|
||||
TOTP_KEY=
|
||||
RECOVERY_CODE_KEY=
|
||||
|
|
@ -96,6 +97,8 @@ SIGN_IN_DECOY_KEY=
|
|||
# (SimpleLogin → Settings → Developer → New website), registered with the callback
|
||||
# /simplelogin-callback on the server's web address (on learn.halkyon.eu's server
|
||||
# https://learn.halkyon.eu/simplelogin-callback), its forwarding page back to the apps.
|
||||
# Takes SIGN_IN_DECOY_KEY (above), or the server doesn't start: a broken link is mended
|
||||
# with the handle and the recovery phrase, a handle nobody has answered as an account's.
|
||||
SIMPLELOGIN_CLIENT_ID=
|
||||
SIMPLELOGIN_CLIENT_SECRET=
|
||||
# Another callback than this server's own page, as the app registered it (https only).
|
||||
|
|
|
|||
|
|
@ -44,7 +44,14 @@
|
|||
password, a lost one with the phrase (`keys.beginPhrase`, `keys.resetPassword`), a new
|
||||
phrase and the deletion (`account.delete`'s `keysProof`), each with the proof and the
|
||||
sign-in, which the change takes: one change for each sign-in. A new or lost password
|
||||
ends every other session, as ADR 0017's change does. The mending comes next.
|
||||
ends every other session, as ADR 0017's change does. Then mending a broken link
|
||||
(8, D7): `simpleLogin.beginMend(attempt, handle)` and `simpleLogin.mend(attempt,
|
||||
proof)`, the phrase's proof binding the attempt, so a relayed proof links nobody
|
||||
else's identity. Otherwise than proposed: only an account with a SimpleLogin identity
|
||||
is mended (D10: nobody adds SimpleLogin signed out), every other answered with a
|
||||
decoy; mending ends every session of the account, as whoever came in through the old
|
||||
link may still be in; and SimpleLogin needs `SIGN_IN_DECOY_KEY` alone of ADR 0017's
|
||||
three keys, a server with a SimpleLogin app and without it stopping as it starts.
|
||||
- **Builds on:** ADR 0010 (email sign-in), ADR 0011 (profile pages), ADR 0016 (the
|
||||
register), ADR 0017 (a password, TOTP and the keys).
|
||||
- **Changes:** ADR 0017's way in on an instance that offers SimpleLogin (its keys, their
|
||||
|
|
|
|||
|
|
@ -968,6 +968,49 @@ class EndpointSimpleLogin extends _isc.EndpointRef {
|
|||
'setup': setup,
|
||||
},
|
||||
);
|
||||
|
||||
/// The first step of mending your SimpleLogin link (ADR 0020, 8 and D7),
|
||||
/// for someone whose identity waits under [attempt] ([complete] said
|
||||
/// `newcomer`) who already has an account, with [handle]: the keys
|
||||
/// wrapped under its recovery phrase's key and its public keys, which
|
||||
/// your device opens with the phrase, and a nonce to sign with the
|
||||
/// phrase's proof key for [mend]; a handle nobody has gets a decoy alike.
|
||||
/// At most 10 for a handle and 30 from a network in an hour
|
||||
/// (`PasswordRefusal.tooManyAttempts`). Refused for a signed-in caller
|
||||
/// (`OidcRefusal.wrongCaller`), and as `beginSimpleLoginMend` refuses.
|
||||
_ida.Future<_ir3ecgdn.KeyProofChallenge> beginMend({
|
||||
required _isc.UuidValue attempt,
|
||||
required String handle,
|
||||
}) => caller.callServerEndpoint<_ir3ecgdn.KeyProofChallenge>(
|
||||
'halkyon_base.simpleLogin',
|
||||
'beginMend',
|
||||
{
|
||||
'attempt': attempt,
|
||||
'handle': handle,
|
||||
},
|
||||
);
|
||||
|
||||
/// Mends your SimpleLogin link with [proof], the recovery phrase's for a
|
||||
/// challenge of [beginMend], binding [attempt] (`mendLinkDigest`) and the
|
||||
/// base URL your device addressed here: the identity waiting under
|
||||
/// [attempt] takes your account's old one's place, every other session of
|
||||
/// the account ends, in every service, and this one signs in, as
|
||||
/// [complete] signs in (the sign-in standing ten minutes for a change,
|
||||
/// such as `keys.resetPassword`). Refused for a signed-in caller
|
||||
/// (`OidcRefusal.wrongCaller`), for a proof that proves nothing, alike for
|
||||
/// a handle nobody has (`PasswordRefusal.wrongCredentials`), and as
|
||||
/// `mendSimpleLogin` refuses.
|
||||
_ida.Future<_ianfx7qj.SimpleLoginCompleted> mend({
|
||||
required _isc.UuidValue attempt,
|
||||
required _iznbn55x.KeysProof proof,
|
||||
}) => caller.callServerEndpoint<_ianfx7qj.SimpleLoginCompleted>(
|
||||
'halkyon_base.simpleLogin',
|
||||
'mend',
|
||||
{
|
||||
'attempt': attempt,
|
||||
'proof': proof,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class Caller extends _isc.ModuleEndpointCaller {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,12 @@ enum SignInChallengePurpose implements _isc.SerializableModel {
|
|||
/// 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;
|
||||
phraseProof,
|
||||
|
||||
/// Proving the recovery phrase signed out, to mend a broken SimpleLogin
|
||||
/// link (Halkyon Learn's ADR 0020, 8): begun for a handle, a decoy's
|
||||
/// for one nobody has.
|
||||
mend;
|
||||
|
||||
static SignInChallengePurpose fromJson(String name) {
|
||||
switch (name) {
|
||||
|
|
@ -48,6 +53,8 @@ enum SignInChallengePurpose implements _isc.SerializableModel {
|
|||
return SignInChallengePurpose.passwordProof;
|
||||
case 'phraseProof':
|
||||
return SignInChallengePurpose.phraseProof;
|
||||
case 'mend':
|
||||
return SignInChallengePurpose.mend;
|
||||
default:
|
||||
throw ArgumentError(
|
||||
'Value "$name" cannot be converted to "SignInChallengePurpose"',
|
||||
|
|
|
|||
|
|
@ -194,6 +194,12 @@ Uint8List newPhraseDigest({
|
|||
proofKey,
|
||||
]);
|
||||
|
||||
/// The SHA-256 of a SimpleLogin attempt's ID (its 16 bytes), framed: what
|
||||
/// a proof that mends a SimpleLogin link binds, so a proof relayed from
|
||||
/// another sign-in links nobody's identity but the device's own.
|
||||
Uint8List mendLinkDigest(List<int> attempt) =>
|
||||
_sha256([utf8.encode('halkyon mend link v1'), attempt]);
|
||||
|
||||
/// The SHA-256 of an authenticator's ID (its 16 bytes), framed: what the
|
||||
/// owner's proof for confirming or removing it binds.
|
||||
Uint8List authenticatorDigest(List<int> id) =>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
|
|||
| `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. `signUp`: an account at the address a mailed code proves, with a password; 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, SimpleLogin added to the caller's account, who becomes `identified`; 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 | anyone |
|
||||
| `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, SimpleLogin added to the caller's account, who becomes `identified`; 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 |
|
||||
| `gitHubUpgrade` | `upgrade`: links a GitHub account to the caller, who becomes `identified`; refused where passwords or SimpleLogin are offered (deprecated) | 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 |
|
||||
|
|
@ -63,6 +63,16 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
|
|||
more, the account its caller: it confirms a change the device asks for next (D20),
|
||||
which takes it, so each sign-in serves one change. A change checks the sign-in before
|
||||
the proof, and takes it with the change, so a wrong proof leaves it standing.
|
||||
- Mending a broken link (Halkyon Learn's ADR 0020, 8 and D7): someone whose identity
|
||||
waits as a newcomer's names their handle (whatever its case); the register answers the
|
||||
keys wrapped under the account's recovery phrase and a nonce, which the device signs
|
||||
with the phrase's proof key for `mendLink`, binding the attempt (`mendLinkDigest`), so
|
||||
a proof relayed from another sign-in links nobody's identity but the device's own. The
|
||||
new identity takes the old one's place, every session of the account ends, in every
|
||||
service, and the sign-in stands ten minutes for a change (a lost password). Only an
|
||||
account with keys and a SimpleLogin identity is mended (nobody adds SimpleLogin signed
|
||||
out); a handle nobody has, or any other account's, gets a decoy from `signInDecoyKey`
|
||||
and the same refusal. At most 10 an hour for a handle and 30 per network.
|
||||
- SimpleLogin's forwarding page (Halkyon Learn's ADR 0020; `addSimpleLoginCallbackRoute`,
|
||||
on the register, on halkyon_oidc's `addOidcCallbackForwarding`, ADR 0023):
|
||||
`/simplelogin-callback`, the one address the instance's SimpleLogin
|
||||
|
|
@ -460,7 +470,9 @@ The passwords it reads: `serverSideSessionKeyHashPepper`; for GitHub `githubClie
|
|||
`totpKey`, `recoveryCodeKey` and `signInDecoyKey` (each at least 32 characters, or
|
||||
`addInstanceRoutes` stops the server; all three and email sign-in, or no password
|
||||
sign-in); for SimpleLogin, on the register, `simpleLoginClientId` and
|
||||
`simpleLoginClientSecret` (the instance's SimpleLogin app, both or none); between the
|
||||
`simpleLoginClientSecret` (the instance's SimpleLogin app, both or none), with
|
||||
`signInDecoyKey` alone of the three (`addInstanceRoutes` stops a server with the app
|
||||
and without it: mending answers decoys); between the
|
||||
instance's services `instanceSecret`. Never change either pepper
|
||||
once people sign in: every session or address hash would be lost; changing `totpKey`
|
||||
voids every authenticator, `recoveryCodeKey` every recovery code. A member needs no email settings of its
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export 'src/password/key_proof.dart'
|
|||
canonicalInstanceUrl,
|
||||
isAccountSignature,
|
||||
keyProofMessage,
|
||||
mendLinkDigest,
|
||||
newKeysPasswordDigest,
|
||||
newPasswordDigest,
|
||||
newPhraseDigest,
|
||||
|
|
|
|||
|
|
@ -1384,6 +1384,58 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
setup: params['setup'],
|
||||
),
|
||||
),
|
||||
'beginMend': _is.MethodConnector(
|
||||
name: 'beginMend',
|
||||
params: {
|
||||
'attempt': _is.ParameterDescription(
|
||||
name: 'attempt',
|
||||
type: _is.getType<_is.UuidValue>(),
|
||||
nullable: false,
|
||||
),
|
||||
'handle': _is.ParameterDescription(
|
||||
name: 'handle',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
},
|
||||
call:
|
||||
(
|
||||
_is.Session session,
|
||||
Map<String, dynamic> params,
|
||||
) async =>
|
||||
(endpoints['simpleLogin'] as _ia8c9alv.SimpleLoginEndpoint)
|
||||
.beginMend(
|
||||
session,
|
||||
attempt: params['attempt'],
|
||||
handle: params['handle'],
|
||||
),
|
||||
),
|
||||
'mend': _is.MethodConnector(
|
||||
name: 'mend',
|
||||
params: {
|
||||
'attempt': _is.ParameterDescription(
|
||||
name: 'attempt',
|
||||
type: _is.getType<_is.UuidValue>(),
|
||||
nullable: false,
|
||||
),
|
||||
'proof': _is.ParameterDescription(
|
||||
name: 'proof',
|
||||
type: _is.getType<_iy592cuk.KeysProof>(),
|
||||
nullable: false,
|
||||
),
|
||||
},
|
||||
call:
|
||||
(
|
||||
_is.Session session,
|
||||
Map<String, dynamic> params,
|
||||
) async =>
|
||||
(endpoints['simpleLogin'] as _ia8c9alv.SimpleLoginEndpoint)
|
||||
.mend(
|
||||
session,
|
||||
attempt: params['attempt'],
|
||||
proof: params['proof'],
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
modules['halkyon_oidc'] = _ipoqfiqr.Endpoints()
|
||||
|
|
|
|||
|
|
@ -34,7 +34,12 @@ enum SignInChallengePurpose implements _is.SerializableModel {
|
|||
/// 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;
|
||||
phraseProof,
|
||||
|
||||
/// Proving the recovery phrase signed out, to mend a broken SimpleLogin
|
||||
/// link (Halkyon Learn's ADR 0020, 8): begun for a handle, a decoy's
|
||||
/// for one nobody has.
|
||||
mend;
|
||||
|
||||
static SignInChallengePurpose fromJson(String name) {
|
||||
switch (name) {
|
||||
|
|
@ -48,6 +53,8 @@ enum SignInChallengePurpose implements _is.SerializableModel {
|
|||
return SignInChallengePurpose.passwordProof;
|
||||
case 'phraseProof':
|
||||
return SignInChallengePurpose.phraseProof;
|
||||
case 'mend':
|
||||
return SignInChallengePurpose.mend;
|
||||
default:
|
||||
throw ArgumentError(
|
||||
'Value "$name" cannot be converted to "SignInChallengePurpose"',
|
||||
|
|
|
|||
|
|
@ -57,3 +57,5 @@ simpleLogin:
|
|||
- complete:
|
||||
- checkHandle:
|
||||
- signUp:
|
||||
- beginMend:
|
||||
- mend:
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ abstract class SimpleLoginProof
|
|||
this.suggestedHandle,
|
||||
bool? hasKeys,
|
||||
this.handle,
|
||||
this.endedBefore,
|
||||
}) : hasKeys = hasKeys ?? false;
|
||||
|
||||
factory SimpleLoginProof({
|
||||
|
|
@ -33,6 +34,7 @@ abstract class SimpleLoginProof
|
|||
String? suggestedHandle,
|
||||
bool? hasKeys,
|
||||
String? handle,
|
||||
DateTime? endedBefore,
|
||||
}) = _SimpleLoginProofImpl;
|
||||
|
||||
factory SimpleLoginProof.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
|
|
@ -48,6 +50,11 @@ abstract class SimpleLoginProof
|
|||
? null
|
||||
: _is.BoolJsonExtension.fromJson(jsonSerialization['hasKeys']),
|
||||
handle: jsonSerialization['handle'] as String?,
|
||||
endedBefore: jsonSerialization['endedBefore'] == null
|
||||
? null
|
||||
: _is.DateTimeJsonExtension.fromJson(
|
||||
jsonSerialization['endedBefore'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +70,11 @@ abstract class SimpleLoginProof
|
|||
/// The account's handle; null for none.
|
||||
String? handle;
|
||||
|
||||
/// 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.
|
||||
DateTime? endedBefore;
|
||||
|
||||
/// Returns a shallow copy of this [SimpleLoginProof]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
|
|
@ -72,6 +84,7 @@ abstract class SimpleLoginProof
|
|||
String? suggestedHandle,
|
||||
bool? hasKeys,
|
||||
String? handle,
|
||||
DateTime? endedBefore,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
|
|
@ -82,6 +95,7 @@ abstract class SimpleLoginProof
|
|||
if (suggestedHandle != null) 'suggestedHandle': suggestedHandle,
|
||||
'hasKeys': hasKeys,
|
||||
if (handle != null) 'handle': handle,
|
||||
if (endedBefore != null) 'endedBefore': endedBefore?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -105,12 +119,14 @@ class _SimpleLoginProofImpl extends SimpleLoginProof {
|
|||
String? suggestedHandle,
|
||||
bool? hasKeys,
|
||||
String? handle,
|
||||
DateTime? endedBefore,
|
||||
}) : super._(
|
||||
outcome: outcome,
|
||||
account: account,
|
||||
suggestedHandle: suggestedHandle,
|
||||
hasKeys: hasKeys,
|
||||
handle: handle,
|
||||
endedBefore: endedBefore,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [SimpleLoginProof]
|
||||
|
|
@ -123,6 +139,7 @@ class _SimpleLoginProofImpl extends SimpleLoginProof {
|
|||
Object? suggestedHandle = _Undefined,
|
||||
bool? hasKeys,
|
||||
Object? handle = _Undefined,
|
||||
Object? endedBefore = _Undefined,
|
||||
}) {
|
||||
return SimpleLoginProof(
|
||||
outcome: outcome ?? this.outcome,
|
||||
|
|
@ -132,6 +149,7 @@ class _SimpleLoginProofImpl extends SimpleLoginProof {
|
|||
: this.suggestedHandle,
|
||||
hasKeys: hasKeys ?? this.hasKeys,
|
||||
handle: handle is String? ? handle : this.handle,
|
||||
endedBefore: endedBefore is DateTime? ? endedBefore : this.endedBefore,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,6 +186,13 @@ Uint8List newPhraseDigest(WrappedKeys recoveryKeys, ByteData proofKey) =>
|
|||
Uint8List.sublistView(proofKey),
|
||||
]);
|
||||
|
||||
/// The SHA-256 of [attempt] (its 16 bytes), framed: what a proof that
|
||||
/// mends a SimpleLogin link binds (Halkyon Learn's ADR 0020, 8), the
|
||||
/// attempt whose identity takes the old one's place, so a proof relayed
|
||||
/// from another sign-in links nobody's identity but the device's own.
|
||||
Uint8List mendLinkDigest(UuidValue attempt) =>
|
||||
_sha256([utf8.encode('halkyon mend link v1'), attempt.toBytes()]);
|
||||
|
||||
/// The SHA-256 of authenticator [id] (its 16 bytes), framed: what the
|
||||
/// owner's proof for confirming or removing it binds.
|
||||
Uint8List authenticatorDigest(UuidValue id) =>
|
||||
|
|
|
|||
|
|
@ -42,29 +42,41 @@ void checkPasswordKeys(Serverpod pod) => passwordKeysOf(pod);
|
|||
/// (Compose passes unset variables on as empty strings). Throws a
|
||||
/// [StateError] for a key set but shorter than [passwordKeyMinLength].
|
||||
PasswordKeys? passwordKeysFrom(String? Function(String name) password) {
|
||||
String? key(String name) {
|
||||
final value = password(name) ?? '';
|
||||
if (value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (value.length < passwordKeyMinLength) {
|
||||
throw StateError(
|
||||
'The password $name is ${value.length} characters long: it must be '
|
||||
'at least $passwordKeyMinLength (e.g. `openssl rand -hex 32`).',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
final totp = key(_totpKey);
|
||||
final recovery = key(_recoveryCodeKey);
|
||||
final decoy = key(_decoyKey);
|
||||
final totp = _keyFrom(password, _totpKey);
|
||||
final recovery = _keyFrom(password, _recoveryCodeKey);
|
||||
final decoy = _keyFrom(password, _decoyKey);
|
||||
if (totp == null || recovery == null || decoy == null) {
|
||||
return null;
|
||||
}
|
||||
return (totpKey: totp, recoveryCodeKey: recovery, decoyKey: decoy);
|
||||
}
|
||||
|
||||
/// [pod]'s key for decoys alone (`signInDecoyKey`, `SIGN_IN_DECOY_KEY`):
|
||||
/// what mending a SimpleLogin link needs of these keys (Halkyon Learn's ADR
|
||||
/// 0020, 8), so an instance offering SimpleLogin alone sets it without
|
||||
/// TOTP's. Null while unset. Throws a [StateError] for one too short.
|
||||
String? signInDecoyKeyOf(Serverpod pod) => switch (_keysOf[pod]) {
|
||||
(:final keys) => keys?.decoyKey,
|
||||
null => _keyFrom(pod.getPassword, _decoyKey),
|
||||
};
|
||||
|
||||
/// The key [password] holds under [name]; null for none, empty counting as
|
||||
/// unset. Throws a [StateError] for one shorter than
|
||||
/// [passwordKeyMinLength].
|
||||
String? _keyFrom(String? Function(String name) password, String name) {
|
||||
final value = password(name) ?? '';
|
||||
if (value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (value.length < passwordKeyMinLength) {
|
||||
throw StateError(
|
||||
'The password $name is ${value.length} characters long: it must be '
|
||||
'at least $passwordKeyMinLength (e.g. `openssl rand -hex 32`).',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Whether people sign in with a password and TOTP on [pod]: it holds the
|
||||
/// register's keys, and email sign-in's pepper and mailer, since the
|
||||
/// addresses' hashes are made with the one and codes and notices of resets
|
||||
|
|
|
|||
|
|
@ -1666,6 +1666,94 @@ Future<AccountPassword> _proveOwnKey(
|
|||
return password;
|
||||
}
|
||||
|
||||
/// The first step of proving the recovery phrase of the account [password]
|
||||
/// is (null: nobody's, a decoy made of [decoyKey] and [decoyFor]) signed
|
||||
/// out, to mend its SimpleLogin link (Halkyon Learn's ADR 0020, 8), as
|
||||
/// [beginKeyProof] begins one for an address: the keys wrapped under the
|
||||
/// phrase's key and the public keys, a decoy's alike, and a nonce. The
|
||||
/// decoy is computed for an account too, so both take as long.
|
||||
Future<KeyProofChallenge> beginMendKeyProof(
|
||||
Session session, {
|
||||
required AccountPassword? password,
|
||||
required String decoyKey,
|
||||
required String decoyFor,
|
||||
}) async {
|
||||
final decoy = await _decoyKeys(decoyKey, decoyFor);
|
||||
final nonce = _randomBytes(32);
|
||||
// INSERT INTO "sign_in_challenge" (…) VALUES (…)
|
||||
final kept = await SignInChallenge.db.insertRow(
|
||||
session,
|
||||
SignInChallenge(
|
||||
authUserId: password?.authUserId,
|
||||
purpose: SignInChallengePurpose.mend,
|
||||
// What the decoy is made of, so its proof key checks the proof alike.
|
||||
addressHash: decoyFor,
|
||||
secret: _hex(nonce),
|
||||
createdAt: clock.now().toUtc(),
|
||||
),
|
||||
);
|
||||
return KeyProofChallenge(
|
||||
challenge: kept.id!,
|
||||
nonce: ByteData.sublistView(nonce),
|
||||
encryptionPublicKey:
|
||||
password?.encryptionPublicKey ??
|
||||
ByteData.sublistView(decoy.encryptionPublicKey),
|
||||
signingPublicKey:
|
||||
password?.signingPublicKey ??
|
||||
ByteData.sublistView(decoy.signingPublicKey),
|
||||
recoveryKeys: password == null
|
||||
? WrappedKeys(
|
||||
nonce: ByteData.sublistView(decoy.recoveryKeysNonce),
|
||||
cipherText: ByteData.sublistView(decoy.recoveryKeysCipherText),
|
||||
)
|
||||
: WrappedKeys(
|
||||
nonce: password.recoveryKeysNonce,
|
||||
cipherText: password.recoveryKeysCipherText,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// ([PasswordRefusal.wrongCredentials]).
|
||||
Future<AccountPassword> proveMendKeyProof(
|
||||
Session session, {
|
||||
required KeysProof proof,
|
||||
required String decoyKey,
|
||||
required List<int> digest,
|
||||
}) async {
|
||||
final kept = await takeChallenge(
|
||||
session,
|
||||
proof.challenge,
|
||||
SignInChallengePurpose.mend,
|
||||
);
|
||||
final decoy = await _decoyKeys(decoyKey, kept.addressHash);
|
||||
final password = await _passwordOf(session, kept.authUserId);
|
||||
final proofKey = password?.recoveryProofKey;
|
||||
final signed = await isAccountSignature(
|
||||
keyProofMessage(
|
||||
proof.challenge,
|
||||
_unhex(kept.secret),
|
||||
instanceUrl: proof.instanceUrl,
|
||||
purpose: KeyProofPurpose.mendLink,
|
||||
digest: digest,
|
||||
),
|
||||
signature: Uint8List.sublistView(proof.signature),
|
||||
publicKey: proofKey == null
|
||||
? decoy.recoveryProofKey
|
||||
: Uint8List.sublistView(proofKey),
|
||||
);
|
||||
if (!signed || password == null || proofKey == null) {
|
||||
throw refusedFor(PasswordRefusal.wrongCredentials);
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
/// [proof]'s key proof alone, without the SimpleLogin sign-in it names.
|
||||
KeysProof keyProofOf(KeysOwnerProof proof) => KeysProof(
|
||||
challenge: proof.challenge,
|
||||
|
|
|
|||
|
|
@ -19,3 +19,7 @@ values:
|
|||
### 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
|
||||
### Proving the recovery phrase signed out, to mend a broken SimpleLogin
|
||||
### link (Halkyon Learn's ADR 0020, 8): begun for a handle, a decoy's
|
||||
### for one nobody has.
|
||||
- mend
|
||||
|
|
|
|||
|
|
@ -179,6 +179,26 @@ abstract interface class AccountRegister {
|
|||
required KeysSetup setup,
|
||||
});
|
||||
|
||||
/// The first step of mending the SimpleLogin link of the account with
|
||||
/// [handle] for someone whose identity waits under [attempt], the begins
|
||||
/// from [network] counted. Refusals as `beginSimpleLoginMend`'s.
|
||||
Future<KeyProofChallenge> beginSimpleLoginMend(
|
||||
Session session, {
|
||||
required UuidValue attempt,
|
||||
required String handle,
|
||||
required String network,
|
||||
});
|
||||
|
||||
/// Mends the SimpleLogin link of the account whose recovery phrase
|
||||
/// [proof] proved, with the identity waiting under [attempt]: the account
|
||||
/// signed in to, which the member issues its own session for, after
|
||||
/// [SimpleLoginProof.endedBefore]. Refusals as `mendSimpleLogin`'s.
|
||||
Future<SimpleLoginProof> mendSimpleLogin(
|
||||
Session session, {
|
||||
required UuidValue attempt,
|
||||
required KeysProof proof,
|
||||
});
|
||||
|
||||
/// The first step of [person] proving their password with its proof key
|
||||
/// (an account that signs in with SimpleLogin). Refusals as
|
||||
/// `beginPasswordKeyProof`'s.
|
||||
|
|
|
|||
|
|
@ -227,6 +227,21 @@ Future<Map<String, Object?>?> _answer(
|
|||
setup: arguments.model('setup', KeysSetup.fromJson),
|
||||
);
|
||||
return {'account': account.uuid};
|
||||
case RegisterOperation.beginSimpleLoginMend:
|
||||
final challenge = await register.beginSimpleLoginMend(
|
||||
session,
|
||||
attempt: arguments.id('attempt'),
|
||||
handle: arguments.string('handle'),
|
||||
network: arguments.string('network'),
|
||||
);
|
||||
return {'challenge': challenge.toJson()};
|
||||
case RegisterOperation.mendSimpleLogin:
|
||||
final proof = await register.mendSimpleLogin(
|
||||
session,
|
||||
attempt: arguments.id('attempt'),
|
||||
proof: arguments.model('proof', KeysProof.fromJson),
|
||||
);
|
||||
return {'proof': proof.toJson()};
|
||||
case RegisterOperation.beginPasswordKeyProof:
|
||||
final challenge = await register.beginPasswordKeyProof(
|
||||
session,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import 'package:serverpod/serverpod.dart';
|
|||
/// instance's secret. Caddy never forwards them: the services reach each
|
||||
/// other on the instance's own network. Throws as [checkInstanceSecret],
|
||||
/// [checkPasswordKeys], [gitHubSignInEnds], [offeredWaysOf],
|
||||
/// [simpleLoginAppOf] and [trustedProxies] do, so a server set up so stops
|
||||
/// [simpleLoginAppOf], [checkSimpleLoginMending] and [trustedProxies] do, so a server set up so stops
|
||||
/// as it starts.
|
||||
void addInstanceRoutes(Serverpod pod) {
|
||||
checkInstanceSecret(pod);
|
||||
|
|
@ -33,6 +33,8 @@ void addInstanceRoutes(Serverpod pod) {
|
|||
// SimpleLogin app with no callback SimpleLogin takes (ADR 0020).
|
||||
offeredWaysOf(pod);
|
||||
simpleLoginAppOf(pod);
|
||||
// And a SimpleLogin app without the key for mending's decoys.
|
||||
checkSimpleLoginMending(pod);
|
||||
// And proxies that are no addresses: counted by the proxy's address, every
|
||||
// visitor would share each per-network limit.
|
||||
trustedProxies();
|
||||
|
|
|
|||
|
|
@ -425,6 +425,26 @@ final class LocalRegister implements AccountRegister {
|
|||
setup: setup,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<KeyProofChallenge> beginSimpleLoginMend(
|
||||
Session session, {
|
||||
required UuidValue attempt,
|
||||
required String handle,
|
||||
required String network,
|
||||
}) => simplelogin.beginSimpleLoginMend(
|
||||
session,
|
||||
attempt: attempt,
|
||||
handle: handle,
|
||||
network: network,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<SimpleLoginProof> mendSimpleLogin(
|
||||
Session session, {
|
||||
required UuidValue attempt,
|
||||
required KeysProof proof,
|
||||
}) => simplelogin.mendSimpleLogin(session, attempt: attempt, proof: proof);
|
||||
|
||||
@override
|
||||
Future<KeysChallenge> beginPasswordKeyProof(
|
||||
Session session,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ abstract final class RegisterOperation {
|
|||
static const completeSimpleLogin = 'complete-simplelogin';
|
||||
static const checkSimpleLoginHandle = 'check-simplelogin-handle';
|
||||
static const signUpWithSimpleLogin = 'sign-up-with-simplelogin';
|
||||
static const beginSimpleLoginMend = 'begin-simplelogin-mend';
|
||||
static const mendSimpleLogin = 'mend-simplelogin';
|
||||
static const beginPasswordKeyProof = 'begin-password-key-proof';
|
||||
static const unlockKeys = 'unlock-keys';
|
||||
static const setFirstKeys = 'set-first-keys';
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue