fix(auth): a new recovery phrase takes the password too
password.setRecoveryKeys took a session and a TOTP code alone, so a phone signed in with its authenticator beside it could set a phrase of its holder's own and, through the phrase's reset, a new password, never knowing the old one. It now takes the old password's SRP proof (from beginChange, the caller's own) and a TOTP or recovery code, as changing the password does: one owner proof for both (_proveOwner), forwarded by members, asked for in the account section's New recovery phrase. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
4f7b123ad4
commit
5fb28bd817
23 changed files with 447 additions and 169 deletions
|
|
@ -176,7 +176,10 @@ released, stays at 1), after the second reading.
|
||||||
- **Changing while signed in:** the password (`password.beginChange`, `password.change`:
|
- **Changing while signed in:** the password (`password.beginChange`, `password.change`:
|
||||||
the old password's SRP proof, a TOTP or recovery code, the same keys rewrapped on the
|
the old password's SRP proof, a TOTP or recovery code, the same keys rewrapped on the
|
||||||
device; every other session ends) and the recovery phrase (`password.setRecoveryKeys`:
|
device; every other session ends) and the recovery phrase (`password.setRecoveryKeys`:
|
||||||
the keys wrapped under a new phrase, for a code). A session alone changes neither.
|
the keys wrapped under a new phrase, with the old password's proof and a code, as a
|
||||||
|
change of password takes). A session alone changes neither, nor a session with the
|
||||||
|
authenticator beside it (a phone signed in): its holder's own phrase would set a new
|
||||||
|
password through `resetPasswordWithPhrase` (found at step 3's review, 20 Oct).
|
||||||
- **Sessions end in every service:** a change of password ends every other session of
|
- **Sessions end in every service:** a change of password ends every other session of
|
||||||
the account, a reset of the password or the account all of them, on the register and
|
the account, a reset of the password or the account all of them, on the register and
|
||||||
on every member, by a notice kept until each confirms (`sessions_ended_notice`, a new
|
on every member, by a notice kept until each confirms (`sessions_ended_notice`, a new
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@ enum NewPhraseStep() {
|
||||||
/// Shown, to write down.
|
/// Shown, to write down.
|
||||||
shown,
|
shown,
|
||||||
|
|
||||||
/// Three words typed back, and a code.
|
/// Three words typed back, the password and a code.
|
||||||
check,
|
check,
|
||||||
|
|
||||||
/// Kept: the old one opens nothing any more.
|
/// Kept: the old one opens nothing any more.
|
||||||
|
|
@ -228,7 +228,7 @@ final class const NewPhraseState({
|
||||||
|
|
||||||
/// A new recovery phrase for the signed-in person's keys (on this device):
|
/// A new recovery phrase for the signed-in person's keys (on this device):
|
||||||
/// made, shown, three of its words typed back, then kept by the instance
|
/// made, shown, three of its words typed back, then kept by the instance
|
||||||
/// for a code from the authenticator.
|
/// for the password and a code from the authenticator.
|
||||||
class NewPhraseCubit(final PasswordRepository _passwords)
|
class NewPhraseCubit(final PasswordRepository _passwords)
|
||||||
extends Cubit<NewPhraseState> {
|
extends Cubit<NewPhraseState> {
|
||||||
this : super(const NewPhraseState());
|
this : super(const NewPhraseState());
|
||||||
|
|
@ -263,15 +263,20 @@ class NewPhraseCubit(final PasswordRepository _passwords)
|
||||||
void shownAgain() =>
|
void shownAgain() =>
|
||||||
emit(NewPhraseState(step: NewPhraseStep.shown, phrase: state.phrase));
|
emit(NewPhraseState(step: NewPhraseStep.shown, phrase: state.phrase));
|
||||||
|
|
||||||
/// The words typed back, and a [code]: the instance keeps it.
|
/// The words typed back, the [password] and a [code]: the instance keeps
|
||||||
Future<void> checked(List<String> words, String code) async {
|
/// it.
|
||||||
|
Future<void> checked(
|
||||||
|
List<String> words, {
|
||||||
|
required String password,
|
||||||
|
required String code,
|
||||||
|
}) async {
|
||||||
if (!checksPhrase(state.phrase, state.checked, words)) {
|
if (!checksPhrase(state.phrase, state.checked, words)) {
|
||||||
emit(_again(phraseWrong: true));
|
emit(_again(phraseWrong: true));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emit(_again(busy: true));
|
emit(_again(busy: true));
|
||||||
try {
|
try {
|
||||||
await _passwords.keepPhrase(_made!, code);
|
await _passwords.keepPhrase(_made!, password: password, code: code);
|
||||||
if (!isClosed) {
|
if (!isClosed) {
|
||||||
emit(NewPhraseState(step: NewPhraseStep.done, phrase: state.phrase));
|
emit(NewPhraseState(step: NewPhraseStep.done, phrase: state.phrase));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,8 +104,16 @@ abstract interface class PasswordGateway() {
|
||||||
required protocol.NewPassword password,
|
required protocol.NewPassword password,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// A new recovery phrase's wrapping of the keys, for a [code].
|
/// A new recovery phrase's wrapping of the keys: [answer] to [challenge]
|
||||||
Future<void> setRecoveryKeys(String code, protocol.WrappedKeys keys);
|
/// ([beginChange]) proves the password, with a [totpCode] or
|
||||||
|
/// [recoveryCode].
|
||||||
|
Future<void> setRecoveryKeys(
|
||||||
|
protocol.PasswordChallenge challenge,
|
||||||
|
SrpAnswer answer, {
|
||||||
|
String? totpCode,
|
||||||
|
String? recoveryCode,
|
||||||
|
required protocol.WrappedKeys keys,
|
||||||
|
});
|
||||||
|
|
||||||
/// Asks for the reset of a lost authenticator: [answer] to [challenge]
|
/// Asks for the reset of a lost authenticator: [answer] to [challenge]
|
||||||
/// proves the password, and a code mailed for [request] or the recovery
|
/// proves the password, and a code mailed for [request] or the recovery
|
||||||
|
|
|
||||||
|
|
@ -338,12 +338,27 @@ class PasswordRepository(
|
||||||
return NewPhrase(made.phrase, made.recoveryKeys);
|
return NewPhrase(made.phrase, made.recoveryKeys);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [phrase] in place of the old recovery phrase, for a [code]: the old
|
/// [phrase] in place of the old recovery phrase, with the [password] and
|
||||||
/// one opens nothing any more.
|
/// a [code] from an authenticator, as changing the password takes: the old
|
||||||
Future<void> keepPhrase(NewPhrase phrase, String code) async {
|
/// phrase opens nothing any more. (A session and a code alone would let
|
||||||
|
/// whoever holds a phone signed in set a phrase of their own, and with it
|
||||||
|
/// a new password.)
|
||||||
|
Future<void> keepPhrase(
|
||||||
|
NewPhrase phrase, {
|
||||||
|
required String password,
|
||||||
|
required String code,
|
||||||
|
}) async {
|
||||||
|
final challenge = await _gateway.beginChange();
|
||||||
|
final unlock = await _crypto.unlock(
|
||||||
|
password,
|
||||||
|
salt: Uint8List.sublistView(challenge.salt),
|
||||||
|
costs: challenge.costs,
|
||||||
|
);
|
||||||
await _gateway.setRecoveryKeys(
|
await _gateway.setRecoveryKeys(
|
||||||
code.replaceAll(' ', ''),
|
challenge,
|
||||||
phrase._recoveryKeys,
|
await _crypto.answer(unlock, challenge),
|
||||||
|
totpCode: code.replaceAll(' ', ''),
|
||||||
|
keys: phrase._recoveryKeys,
|
||||||
);
|
);
|
||||||
_changed();
|
_changed();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -190,9 +190,21 @@ class ServerpodPasswordGateway(
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setRecoveryKeys(String code, protocol.WrappedKeys keys) =>
|
Future<void> setRecoveryKeys(
|
||||||
_failing(
|
protocol.PasswordChallenge challenge,
|
||||||
() => _base.password.setRecoveryKeys(code: code, recoveryKeys: keys),
|
SrpAnswer answer, {
|
||||||
|
String? totpCode,
|
||||||
|
String? recoveryCode,
|
||||||
|
required protocol.WrappedKeys keys,
|
||||||
|
}) => _failing(
|
||||||
|
() => _base.password.setRecoveryKeys(
|
||||||
|
challenge: challenge.challenge,
|
||||||
|
clientValue: answer.clientValue,
|
||||||
|
clientProof: answer.clientProof,
|
||||||
|
totpCode: totpCode,
|
||||||
|
recoveryCode: recoveryCode,
|
||||||
|
recoveryKeys: keys,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -570,6 +570,7 @@ class const _NewPhrase() extends StatefulWidget {
|
||||||
|
|
||||||
class _NewPhraseState() extends State<_NewPhrase> {
|
class _NewPhraseState() extends State<_NewPhrase> {
|
||||||
final _words = [for (var i = 0; i < 3; i++) TextEditingController()];
|
final _words = [for (var i = 0; i < 3; i++) TextEditingController()];
|
||||||
|
final _password = TextEditingController();
|
||||||
final _code = TextEditingController();
|
final _code = TextEditingController();
|
||||||
late final NewPhraseCubit _cubit;
|
late final NewPhraseCubit _cubit;
|
||||||
|
|
||||||
|
|
@ -584,6 +585,7 @@ class _NewPhraseState() extends State<_NewPhrase> {
|
||||||
for (final word in _words) {
|
for (final word in _words) {
|
||||||
word.dispose();
|
word.dispose();
|
||||||
}
|
}
|
||||||
|
_password.dispose();
|
||||||
_code.dispose();
|
_code.dispose();
|
||||||
_cubit.close();
|
_cubit.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|
@ -594,7 +596,11 @@ class _NewPhraseState() extends State<_NewPhrase> {
|
||||||
case NewPhraseStep.shown:
|
case NewPhraseStep.shown:
|
||||||
_cubit.writtenDown();
|
_cubit.writtenDown();
|
||||||
case NewPhraseStep.check:
|
case NewPhraseStep.check:
|
||||||
_cubit.checked([for (final word in _words) word.text], _code.text);
|
_cubit.checked(
|
||||||
|
[for (final word in _words) word.text],
|
||||||
|
password: _password.text,
|
||||||
|
code: _code.text,
|
||||||
|
);
|
||||||
case NewPhraseStep.making || NewPhraseStep.done:
|
case NewPhraseStep.making || NewPhraseStep.done:
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
|
|
@ -611,6 +617,7 @@ class _NewPhraseState() extends State<_NewPhrase> {
|
||||||
for (final word in _words) {
|
for (final word in _words) {
|
||||||
word.clear();
|
word.clear();
|
||||||
}
|
}
|
||||||
|
_password.clear();
|
||||||
_code.clear();
|
_code.clear();
|
||||||
}
|
}
|
||||||
if (state.step == NewPhraseStep.done) {
|
if (state.step == NewPhraseStep.done) {
|
||||||
|
|
@ -653,6 +660,14 @@ class _NewPhraseState() extends State<_NewPhrase> {
|
||||||
? l10n.passwordPhraseWrong
|
? l10n.passwordPhraseWrong
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
// The password too, as changing it takes: a phone signed in
|
||||||
|
// with its authenticator must not set a phrase of its own.
|
||||||
|
PasswordField(
|
||||||
|
controller: _password,
|
||||||
|
label: l10n.passwordCurrent,
|
||||||
|
enabled: !state.busy,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
CodeField(
|
CodeField(
|
||||||
controller: _code,
|
controller: _code,
|
||||||
kind: CodeKind.totp,
|
kind: CodeKind.totp,
|
||||||
|
|
|
||||||
|
|
@ -496,6 +496,20 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
required protocol.NewPassword password,
|
required protocol.NewPassword password,
|
||||||
}) => _call('change', () {
|
}) => _call('change', () {
|
||||||
|
_ownerProved(challenge, answer, totpCode, recoveryCode)
|
||||||
|
..password = utf8.decode(Uint8List.sublistView(password.verifier))
|
||||||
|
..passwordKeys = password.passwordKeys;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The signed-in person's account, once [answer] to [challenge] proved
|
||||||
|
/// its password and a code came: refused as the register refuses a
|
||||||
|
/// change.
|
||||||
|
FakePasswordAccount _ownerProved(
|
||||||
|
protocol.PasswordChallenge challenge,
|
||||||
|
SrpAnswer answer,
|
||||||
|
String? totpCode,
|
||||||
|
String? recoveryCode,
|
||||||
|
) {
|
||||||
final account = _proved(challenge, answer);
|
final account = _proved(challenge, answer);
|
||||||
if (account.person != _signedIn() ||
|
if (account.person != _signedIn() ||
|
||||||
((totpCode ?? '').isEmpty && (recoveryCode ?? '').isEmpty)) {
|
((totpCode ?? '').isEmpty && (recoveryCode ?? '').isEmpty)) {
|
||||||
|
|
@ -508,19 +522,18 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
||||||
)) {
|
)) {
|
||||||
throw const PasswordFailed(PasswordProblem.setupIncomplete);
|
throw const PasswordFailed(PasswordProblem.setupIncomplete);
|
||||||
}
|
}
|
||||||
account
|
return account;
|
||||||
..password = utf8.decode(Uint8List.sublistView(password.verifier))
|
}
|
||||||
..passwordKeys = password.passwordKeys;
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setRecoveryKeys(String code, protocol.WrappedKeys keys) =>
|
Future<void> setRecoveryKeys(
|
||||||
_call('setRecoveryKeys', () {
|
protocol.PasswordChallenge challenge,
|
||||||
final account = _accountAs(null);
|
SrpAnswer answer, {
|
||||||
if (code != fakeTotpCode) {
|
String? totpCode,
|
||||||
throw const PasswordFailed(PasswordProblem.wrongCode);
|
String? recoveryCode,
|
||||||
}
|
required protocol.WrappedKeys keys,
|
||||||
account.recoveryKeys = keys;
|
}) => _call('setRecoveryKeys', () {
|
||||||
|
_ownerProved(challenge, answer, totpCode, recoveryCode).recoveryKeys = keys;
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -302,15 +302,33 @@ void main() {
|
||||||
expect(anna.password, 'correct horse');
|
expect(anna.password, 'correct horse');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('makes a new recovery phrase, which the instance takes for a '
|
test('makes a new recovery phrase, which the instance takes for the '
|
||||||
'code', () async {
|
'password and a code alone', () async {
|
||||||
final phrase = await passwords.newPhrase();
|
final phrase = await passwords.newPhrase();
|
||||||
|
final before = anna.recoveryKeys;
|
||||||
await expectLater(
|
await expectLater(
|
||||||
passwords.keepPhrase(phrase, '000000'),
|
passwords.keepPhrase(
|
||||||
failsWith(PasswordProblem.wrongCode),
|
phrase,
|
||||||
|
password: 'correct horse',
|
||||||
|
code: '000000',
|
||||||
|
),
|
||||||
|
failsWith(PasswordProblem.wrongCredentials),
|
||||||
);
|
);
|
||||||
|
await expectLater(
|
||||||
|
passwords.keepPhrase(
|
||||||
|
phrase,
|
||||||
|
password: 'wrong',
|
||||||
|
code: fakeTotpCode,
|
||||||
|
),
|
||||||
|
failsWith(PasswordProblem.wrongCredentials),
|
||||||
|
);
|
||||||
|
expect(anna.recoveryKeys, same(before));
|
||||||
|
|
||||||
await passwords.keepPhrase(phrase, fakeTotpCode);
|
await passwords.keepPhrase(
|
||||||
|
phrase,
|
||||||
|
password: 'correct horse',
|
||||||
|
code: fakeTotpCode,
|
||||||
|
);
|
||||||
|
|
||||||
await auth.signOut();
|
await auth.signOut();
|
||||||
final pending = await passwords.resetPassword(
|
final pending = await passwords.resetPassword(
|
||||||
|
|
|
||||||
|
|
@ -352,5 +352,53 @@ void main() {
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('makes a new recovery phrase only with the password and a '
|
||||||
|
'code', (tester) async {
|
||||||
|
final anna = dependencies.passwordGateway.addAccount(
|
||||||
|
'anna@example.org',
|
||||||
|
password: 'correct horse',
|
||||||
|
crypto: dependencies.crypto,
|
||||||
|
);
|
||||||
|
final passwords = dependencies.passwords;
|
||||||
|
await passwords.enter(
|
||||||
|
await passwords.signIn(
|
||||||
|
await passwords.unlock('anna@example.org', 'correct horse'),
|
||||||
|
totpCode: fakeTotpCode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final before = anna.recoveryKeys;
|
||||||
|
await show(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('New recovery phrase'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.textContaining(wordsOf(otherFakePhrase).first),
|
||||||
|
findsWidgets,
|
||||||
|
);
|
||||||
|
await tapButton(tester, "I've written it down");
|
||||||
|
await typeCheckedWords(tester, otherFakePhrase);
|
||||||
|
// A code alone, from a phone signed in with its authenticator.
|
||||||
|
await tester.enterText(field('Current password'), 'wrong');
|
||||||
|
await tester.enterText(
|
||||||
|
field('Code from your authenticator app'),
|
||||||
|
fakeTotpCode,
|
||||||
|
);
|
||||||
|
await tapButton(tester, 'Continue');
|
||||||
|
expect(
|
||||||
|
find.text("The address, the password or the code isn't right."),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(anna.recoveryKeys, same(before));
|
||||||
|
|
||||||
|
await tester.enterText(field('Current password'), 'correct horse');
|
||||||
|
await tapButton(tester, 'Continue');
|
||||||
|
|
||||||
|
expect(anna.recoveryKeys, isNot(same(before)));
|
||||||
|
expect(
|
||||||
|
find.text('New recovery phrase set. The old one no longer works.'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -459,16 +459,25 @@ class EndpointPassword extends _isc.EndpointRef {
|
||||||
);
|
);
|
||||||
|
|
||||||
/// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under
|
/// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under
|
||||||
/// it on the device, replace the old wrapping, for a [code] from one of
|
/// it on the device, replace the old wrapping, with the password's proof
|
||||||
/// their authenticators. Refusals as `setRecoveryKeys`'.
|
/// for [challenge] ([beginChange]) and a [totpCode] or [recoveryCode], as
|
||||||
|
/// [change] takes. Refusals as `setRecoveryKeys`'.
|
||||||
_ida.Future<void> setRecoveryKeys({
|
_ida.Future<void> setRecoveryKeys({
|
||||||
required String code,
|
required _isc.UuidValue challenge,
|
||||||
|
required _idt.ByteData clientValue,
|
||||||
|
required _idt.ByteData clientProof,
|
||||||
|
String? totpCode,
|
||||||
|
String? recoveryCode,
|
||||||
required _i3so7e0p.WrappedKeys recoveryKeys,
|
required _i3so7e0p.WrappedKeys recoveryKeys,
|
||||||
}) => caller.callServerEndpoint<void>(
|
}) => caller.callServerEndpoint<void>(
|
||||||
'halkyon_base.password',
|
'halkyon_base.password',
|
||||||
'setRecoveryKeys',
|
'setRecoveryKeys',
|
||||||
{
|
{
|
||||||
'code': code,
|
'challenge': challenge,
|
||||||
|
'clientValue': clientValue,
|
||||||
|
'clientProof': clientProof,
|
||||||
|
'totpCode': totpCode,
|
||||||
|
'recoveryCode': recoveryCode,
|
||||||
'recoveryKeys': recoveryKeys,
|
'recoveryKeys': recoveryKeys,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
|
||||||
| Endpoint | Methods | Who may call |
|
| Endpoint | Methods | Who may call |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `anonymousIdp` | `login` (Serverpod's anonymous sign-in, at most 30 new accounts per network and hour) | anyone |
|
| `anonymousIdp` | `login` (Serverpod's anonymous sign-in, at most 30 new accounts per network and hour) | anyone |
|
||||||
| `password` | `set`: the caller's password, with the address a mailed code proves unless they have one; `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, for a code | signed in |
|
| `password` | `set`: the caller's password, with the address a mailed code proves unless they have one; `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 |
|
||||||
| `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 |
|
| `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 |
|
| `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 |
|
| `accountRecovery` | `resetAuthenticator`, `beginKeyProof`, `resetPassword` (with the recovery phrase), `startAccountReset`, `completeAccountReset` | without a session |
|
||||||
|
|
@ -216,8 +216,9 @@ Built on the three parts above, on the register (members forward every step thro
|
||||||
password, new keys, no authenticators.
|
password, new keys, no authenticators.
|
||||||
- **Changing while signed in:** `password.beginChange` and `password.change` take the old
|
- **Changing while signed in:** `password.beginChange` and `password.change` take the old
|
||||||
password's SRP proof, a TOTP or recovery code and the same keys wrapped under the new
|
password's SRP proof, a TOTP or recovery code and the same keys wrapped under the new
|
||||||
password; `password.setRecoveryKeys` replaces the phrase's wrapping for a code. A
|
password; `password.setRecoveryKeys` replaces the phrase's wrapping with the same
|
||||||
session alone changes neither.
|
proof and code (a phrase of one's own would otherwise set a new password through the
|
||||||
|
phrase's reset). A session alone changes neither, nor a session and a code.
|
||||||
- **Sessions end everywhere:** a change of password ends every other session of the
|
- **Sessions end everywhere:** a change of password ends every other session of the
|
||||||
account; a reset of the password or the account ends all. The register ends its own
|
account; a reset of the password or the account ends all. The register ends its own
|
||||||
and keeps a `sessions_ended_notice` per member (told at once and hourly until it
|
and keeps a `sessions_ended_notice` per member (told at once and hourly until it
|
||||||
|
|
|
||||||
|
|
@ -729,11 +729,31 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
'setRecoveryKeys': _is.MethodConnector(
|
'setRecoveryKeys': _is.MethodConnector(
|
||||||
name: 'setRecoveryKeys',
|
name: 'setRecoveryKeys',
|
||||||
params: {
|
params: {
|
||||||
'code': _is.ParameterDescription(
|
'challenge': _is.ParameterDescription(
|
||||||
name: 'code',
|
name: 'challenge',
|
||||||
type: _is.getType<String>(),
|
type: _is.getType<_is.UuidValue>(),
|
||||||
nullable: false,
|
nullable: false,
|
||||||
),
|
),
|
||||||
|
'clientValue': _is.ParameterDescription(
|
||||||
|
name: 'clientValue',
|
||||||
|
type: _is.getType<_idt.ByteData>(),
|
||||||
|
nullable: false,
|
||||||
|
),
|
||||||
|
'clientProof': _is.ParameterDescription(
|
||||||
|
name: 'clientProof',
|
||||||
|
type: _is.getType<_idt.ByteData>(),
|
||||||
|
nullable: false,
|
||||||
|
),
|
||||||
|
'totpCode': _is.ParameterDescription(
|
||||||
|
name: 'totpCode',
|
||||||
|
type: _is.getType<String?>(),
|
||||||
|
nullable: true,
|
||||||
|
),
|
||||||
|
'recoveryCode': _is.ParameterDescription(
|
||||||
|
name: 'recoveryCode',
|
||||||
|
type: _is.getType<String?>(),
|
||||||
|
nullable: true,
|
||||||
|
),
|
||||||
'recoveryKeys': _is.ParameterDescription(
|
'recoveryKeys': _is.ParameterDescription(
|
||||||
name: 'recoveryKeys',
|
name: 'recoveryKeys',
|
||||||
type: _is.getType<_iji0wmu8.WrappedKeys>(),
|
type: _is.getType<_iji0wmu8.WrappedKeys>(),
|
||||||
|
|
@ -747,7 +767,11 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
) async => (endpoints['password'] as _ig7fyg1b.PasswordEndpoint)
|
) async => (endpoints['password'] as _ig7fyg1b.PasswordEndpoint)
|
||||||
.setRecoveryKeys(
|
.setRecoveryKeys(
|
||||||
session,
|
session,
|
||||||
code: params['code'],
|
challenge: params['challenge'],
|
||||||
|
clientValue: params['clientValue'],
|
||||||
|
clientProof: params['clientProof'],
|
||||||
|
totpCode: params['totpCode'],
|
||||||
|
recoveryCode: params['recoveryCode'],
|
||||||
recoveryKeys: params['recoveryKeys'],
|
recoveryKeys: params['recoveryKeys'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -272,7 +272,7 @@ Future<void> removeAuthenticator(
|
||||||
if (confirmed.length < 2) {
|
if (confirmed.length < 2) {
|
||||||
throw refusedFor(PasswordRefusal.lastAuthenticator);
|
throw refusedFor(PasswordRefusal.lastAuthenticator);
|
||||||
}
|
}
|
||||||
await requireAuthenticatorCode(session, keys, person, confirmed, code);
|
await _requireAuthenticatorCode(session, keys, person, confirmed, code);
|
||||||
// Serializable: two removals at once must not take both of the last two.
|
// Serializable: two removals at once must not take both of the last two.
|
||||||
await session.db.transaction(
|
await session.db.transaction(
|
||||||
(transaction) async {
|
(transaction) async {
|
||||||
|
|
@ -318,7 +318,7 @@ Future<List<String>> newRecoveryCodes(
|
||||||
if (confirmed.isEmpty) {
|
if (confirmed.isEmpty) {
|
||||||
throw refusedFor(PasswordRefusal.setupIncomplete);
|
throw refusedFor(PasswordRefusal.setupIncomplete);
|
||||||
}
|
}
|
||||||
await requireAuthenticatorCode(session, keys, person, confirmed, code);
|
await _requireAuthenticatorCode(session, keys, person, confirmed, code);
|
||||||
return await session.db.transaction(
|
return await session.db.transaction(
|
||||||
(transaction) =>
|
(transaction) =>
|
||||||
_replaceRecoveryCodes(session, keys, person, transaction: transaction),
|
_replaceRecoveryCodes(session, keys, person, transaction: transaction),
|
||||||
|
|
@ -360,7 +360,7 @@ Future<void> _countTry(Session session, UuidValue person) async {
|
||||||
/// authenticators, each try counted (10 an hour,
|
/// authenticators, each try counted (10 an hour,
|
||||||
/// [PasswordRefusal.tooManyAttempts]); refused otherwise
|
/// [PasswordRefusal.tooManyAttempts]); refused otherwise
|
||||||
/// ([PasswordRefusal.wrongCode]).
|
/// ([PasswordRefusal.wrongCode]).
|
||||||
Future<void> requireAuthenticatorCode(
|
Future<void> _requireAuthenticatorCode(
|
||||||
Session session,
|
Session session,
|
||||||
PasswordKeys keys,
|
PasswordKeys keys,
|
||||||
UuidValue person,
|
UuidValue person,
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,18 @@ import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||||
import 'package:halkyon_base_server/src/password/authenticators.dart';
|
import 'package:halkyon_base_server/src/password/password_config.dart';
|
||||||
import 'package:halkyon_base_server/src/password/passwords.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/register/sessions_ended.dart';
|
||||||
import 'package:serverpod/serverpod.dart';
|
import 'package:serverpod/serverpod.dart';
|
||||||
|
|
||||||
// Changing what keeps an account while signed in (ADR 0017), on the
|
// Changing what keeps an account while signed in (ADR 0017), on the
|
||||||
// register: the password, with the old password's proof and a code from an
|
// register: the password, and the recovery phrase's wrapping of the keys,
|
||||||
// authenticator, and the recovery phrase's wrapping of the keys, with a code.
|
// each with the old password's proof and a code from an authenticator (or a
|
||||||
// A session alone changes neither: whoever took one over would otherwise
|
// recovery code). A session alone changes neither, nor a session with the
|
||||||
// lock the owner out of their keys.
|
// authenticator beside it (a phone signed in): a phrase of the holder's own
|
||||||
|
// would set a new password through `resetPasswordWithPhrase`, so the phrase
|
||||||
|
// takes the password too.
|
||||||
|
|
||||||
/// Changes [person]'s password to [password] (the same keys, wrapped under
|
/// Changes [person]'s password to [password] (the same keys, wrapped under
|
||||||
/// the new password's key on the device): the old password's proof for
|
/// the new password's key on the device): the old password's proof for
|
||||||
|
|
@ -41,32 +43,16 @@ Future<void> changePassword(
|
||||||
if (!isNewPassword(password)) {
|
if (!isNewPassword(password)) {
|
||||||
throw refusedFor(PasswordRefusal.invalidSetup);
|
throw refusedFor(PasswordRefusal.invalidSetup);
|
||||||
}
|
}
|
||||||
final proved = await provePassword(
|
final row = await _proveOwner(
|
||||||
session,
|
session,
|
||||||
keys,
|
keys,
|
||||||
|
person: person,
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
clientValue: clientValue,
|
clientValue: clientValue,
|
||||||
clientProof: clientProof,
|
clientProof: clientProof,
|
||||||
);
|
|
||||||
final row = proved.password;
|
|
||||||
if (row.authUserId != person) {
|
|
||||||
throw refusedFor(PasswordRefusal.wrongCredentials);
|
|
||||||
}
|
|
||||||
if ((totpCode?.trim() ?? '').isEmpty &&
|
|
||||||
(recoveryCode?.trim() ?? '').isEmpty) {
|
|
||||||
throw refusedFor(PasswordRefusal.wrongCredentials);
|
|
||||||
}
|
|
||||||
// With a code: the code is checked, or, where the authenticators' reset
|
|
||||||
// took effect and none is confirmed, it says so (true).
|
|
||||||
if (await secondFactor(
|
|
||||||
session,
|
|
||||||
keys,
|
|
||||||
person,
|
|
||||||
totpCode: totpCode,
|
totpCode: totpCode,
|
||||||
recoveryCode: recoveryCode,
|
recoveryCode: recoveryCode,
|
||||||
)) {
|
);
|
||||||
throw refusedFor(PasswordRefusal.setupIncomplete);
|
|
||||||
}
|
|
||||||
await session.db.transaction((transaction) async {
|
await session.db.transaction((transaction) async {
|
||||||
// UPDATE "account_password" SET "salt" = $1, "kdfAlgorithm" = $2,
|
// UPDATE "account_password" SET "salt" = $1, "kdfAlgorithm" = $2,
|
||||||
// "kdfOpsLimit" = $3, "kdfMemLimit" = $4, "verifier" = $5,
|
// "kdfOpsLimit" = $3, "kdfMemLimit" = $4, "verifier" = $5,
|
||||||
|
|
@ -98,38 +84,37 @@ Future<void> changePassword(
|
||||||
|
|
||||||
/// Replaces the wrapping of [person]'s keys under their recovery phrase
|
/// Replaces the wrapping of [person]'s keys under their recovery phrase
|
||||||
/// with [recoveryKeys] (a new phrase, made on the device, which holds the
|
/// with [recoveryKeys] (a new phrase, made on the device, which holds the
|
||||||
/// keys), for a [code] from one of their authenticators: the old phrase opens
|
/// keys): the old password's proof for [challenge] (`beginPasswordChange`)
|
||||||
/// nothing any more.
|
/// and a [totpCode] or [recoveryCode] from the account, as changing the
|
||||||
|
/// password takes. The old phrase opens nothing any more.
|
||||||
///
|
///
|
||||||
/// Refused ([PasswordRefusedException]) for wrapped keys out of range
|
/// Refused ([PasswordRefusedException]) for wrapped keys out of range
|
||||||
/// ([PasswordRefusal.invalidSetup]), without a password
|
/// ([PasswordRefusal.invalidSetup]), and otherwise as [changePassword].
|
||||||
/// ([PasswordRefusal.noPassword]) or a confirmed authenticator
|
/// Nothing changes then.
|
||||||
/// ([PasswordRefusal.setupIncomplete]), and for a wrong code
|
|
||||||
/// ([PasswordRefusal.wrongCode]; 10 tries an hour,
|
|
||||||
/// [PasswordRefusal.tooManyAttempts]). Nothing changes then.
|
|
||||||
Future<void> setRecoveryKeys(
|
Future<void> setRecoveryKeys(
|
||||||
Session session, {
|
Session session, {
|
||||||
required UuidValue person,
|
required UuidValue person,
|
||||||
required String code,
|
required UuidValue challenge,
|
||||||
|
required ByteData clientValue,
|
||||||
|
required ByteData clientProof,
|
||||||
|
String? totpCode,
|
||||||
|
String? recoveryCode,
|
||||||
required WrappedKeys recoveryKeys,
|
required WrappedKeys recoveryKeys,
|
||||||
}) async {
|
}) async {
|
||||||
final keys = requirePasswordKeys(session.serverpod);
|
final keys = requirePasswordKeys(session.serverpod);
|
||||||
if (!isWrappedKeys(recoveryKeys)) {
|
if (!isWrappedKeys(recoveryKeys)) {
|
||||||
throw refusedFor(PasswordRefusal.invalidSetup);
|
throw refusedFor(PasswordRefusal.invalidSetup);
|
||||||
}
|
}
|
||||||
// SELECT * FROM "account_password" WHERE "authUserId" = $1
|
final row = await _proveOwner(
|
||||||
final row = await AccountPassword.db.findFirstRow(
|
|
||||||
session,
|
session,
|
||||||
where: (t) => t.authUserId.equals(person),
|
keys,
|
||||||
|
person: person,
|
||||||
|
challenge: challenge,
|
||||||
|
clientValue: clientValue,
|
||||||
|
clientProof: clientProof,
|
||||||
|
totpCode: totpCode,
|
||||||
|
recoveryCode: recoveryCode,
|
||||||
);
|
);
|
||||||
if (row == null) {
|
|
||||||
throw refusedFor(PasswordRefusal.noPassword);
|
|
||||||
}
|
|
||||||
final confirmed = await confirmedAuthenticatorsOf(session, person);
|
|
||||||
if (confirmed.isEmpty) {
|
|
||||||
throw refusedFor(PasswordRefusal.setupIncomplete);
|
|
||||||
}
|
|
||||||
await requireAuthenticatorCode(session, keys, person, confirmed, code);
|
|
||||||
// UPDATE "account_password" SET "recoveryKeysNonce" = $1,
|
// UPDATE "account_password" SET "recoveryKeysNonce" = $1,
|
||||||
// "recoveryKeysCipherText" = $2, "updatedAt" = $3 WHERE "id" = $4
|
// "recoveryKeysCipherText" = $2, "updatedAt" = $3 WHERE "id" = $4
|
||||||
await AccountPassword.db.updateRow(
|
await AccountPassword.db.updateRow(
|
||||||
|
|
@ -141,3 +126,51 @@ Future<void> setRecoveryKeys(
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [person]'s password row, once they proved owning the account for a
|
||||||
|
/// change: the old password's proof for [challenge], [person]'s own, and a
|
||||||
|
/// [totpCode] or [recoveryCode] ([secondFactor], which also calls off a reset
|
||||||
|
/// waiting). Refused ([PasswordRefusedException]) for a wrong password, a
|
||||||
|
/// challenge that isn't [person]'s, or a wrong, used or missing code, alike
|
||||||
|
/// ([PasswordRefusal.wrongCredentials]); a challenge unknown, used or too old
|
||||||
|
/// ([PasswordRefusal.challengeExpired]); and, once the password proved right,
|
||||||
|
/// for an account without a confirmed authenticator
|
||||||
|
/// ([PasswordRefusal.setupIncomplete]).
|
||||||
|
Future<AccountPassword> _proveOwner(
|
||||||
|
Session session,
|
||||||
|
PasswordKeys keys, {
|
||||||
|
required UuidValue person,
|
||||||
|
required UuidValue challenge,
|
||||||
|
required ByteData clientValue,
|
||||||
|
required ByteData clientProof,
|
||||||
|
String? totpCode,
|
||||||
|
String? recoveryCode,
|
||||||
|
}) async {
|
||||||
|
final proved = await provePassword(
|
||||||
|
session,
|
||||||
|
keys,
|
||||||
|
challenge: challenge,
|
||||||
|
clientValue: clientValue,
|
||||||
|
clientProof: clientProof,
|
||||||
|
);
|
||||||
|
final row = proved.password;
|
||||||
|
if (row.authUserId != person) {
|
||||||
|
throw refusedFor(PasswordRefusal.wrongCredentials);
|
||||||
|
}
|
||||||
|
if ((totpCode?.trim() ?? '').isEmpty &&
|
||||||
|
(recoveryCode?.trim() ?? '').isEmpty) {
|
||||||
|
throw refusedFor(PasswordRefusal.wrongCredentials);
|
||||||
|
}
|
||||||
|
// With a code: the code is checked, or, where the authenticators' reset
|
||||||
|
// took effect and none is confirmed, it says so (true).
|
||||||
|
if (await secondFactor(
|
||||||
|
session,
|
||||||
|
keys,
|
||||||
|
person,
|
||||||
|
totpCode: totpCode,
|
||||||
|
recoveryCode: recoveryCode,
|
||||||
|
)) {
|
||||||
|
throw refusedFor(PasswordRefusal.setupIncomplete);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,16 +79,25 @@ class PasswordEndpoint extends Endpoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under
|
/// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under
|
||||||
/// it on the device, replace the old wrapping, for a [code] from one of
|
/// it on the device, replace the old wrapping, with the password's proof
|
||||||
/// their authenticators. Refusals as `setRecoveryKeys`'.
|
/// for [challenge] ([beginChange]) and a [totpCode] or [recoveryCode], as
|
||||||
|
/// [change] takes. Refusals as `setRecoveryKeys`'.
|
||||||
Future<void> setRecoveryKeys(
|
Future<void> setRecoveryKeys(
|
||||||
Session session, {
|
Session session, {
|
||||||
required String code,
|
required UuidValue challenge,
|
||||||
|
required ByteData clientValue,
|
||||||
|
required ByteData clientProof,
|
||||||
|
String? totpCode,
|
||||||
|
String? recoveryCode,
|
||||||
required WrappedKeys recoveryKeys,
|
required WrappedKeys recoveryKeys,
|
||||||
}) => AccountRegister.of(session.serverpod).setRecoveryKeys(
|
}) => AccountRegister.of(session.serverpod).setRecoveryKeys(
|
||||||
session,
|
session,
|
||||||
person: session.authenticated!.authUserId,
|
person: session.authenticated!.authUserId,
|
||||||
code: code,
|
challenge: challenge,
|
||||||
|
clientValue: clientValue,
|
||||||
|
clientProof: clientProof,
|
||||||
|
totpCode: totpCode,
|
||||||
|
recoveryCode: recoveryCode,
|
||||||
recoveryKeys: recoveryKeys,
|
recoveryKeys: recoveryKeys,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -300,12 +300,16 @@ abstract interface class AccountRegister {
|
||||||
UuidValue? keepSession,
|
UuidValue? keepSession,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Replaces the wrapping of [person]'s keys under their recovery phrase.
|
/// Replaces the wrapping of [person]'s keys under their recovery phrase,
|
||||||
/// Refusals as `setRecoveryKeys`'.
|
/// with the password's proof and a code. Refusals as `setRecoveryKeys`'.
|
||||||
Future<void> setRecoveryKeys(
|
Future<void> setRecoveryKeys(
|
||||||
Session session, {
|
Session session, {
|
||||||
required UuidValue person,
|
required UuidValue person,
|
||||||
required String code,
|
required UuidValue challenge,
|
||||||
|
required ByteData clientValue,
|
||||||
|
required ByteData clientProof,
|
||||||
|
String? totpCode,
|
||||||
|
String? recoveryCode,
|
||||||
required WrappedKeys recoveryKeys,
|
required WrappedKeys recoveryKeys,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -360,7 +360,11 @@ Future<Map<String, Object?>?> _answer(
|
||||||
await register.setRecoveryKeys(
|
await register.setRecoveryKeys(
|
||||||
session,
|
session,
|
||||||
person: arguments.id('person'),
|
person: arguments.id('person'),
|
||||||
code: arguments.string('code'),
|
challenge: arguments.id('challenge'),
|
||||||
|
clientValue: arguments.bytes('clientValue'),
|
||||||
|
clientProof: arguments.bytes('clientProof'),
|
||||||
|
totpCode: arguments.optionalString('totpCode'),
|
||||||
|
recoveryCode: arguments.optionalString('recoveryCode'),
|
||||||
recoveryKeys: arguments.model('recoveryKeys', WrappedKeys.fromJson),
|
recoveryKeys: arguments.model('recoveryKeys', WrappedKeys.fromJson),
|
||||||
);
|
);
|
||||||
return const {};
|
return const {};
|
||||||
|
|
|
||||||
|
|
@ -620,12 +620,20 @@ final class LocalRegister implements AccountRegister {
|
||||||
Future<void> setRecoveryKeys(
|
Future<void> setRecoveryKeys(
|
||||||
Session session, {
|
Session session, {
|
||||||
required UuidValue person,
|
required UuidValue person,
|
||||||
required String code,
|
required UuidValue challenge,
|
||||||
|
required ByteData clientValue,
|
||||||
|
required ByteData clientProof,
|
||||||
|
String? totpCode,
|
||||||
|
String? recoveryCode,
|
||||||
required WrappedKeys recoveryKeys,
|
required WrappedKeys recoveryKeys,
|
||||||
}) => change.setRecoveryKeys(
|
}) => change.setRecoveryKeys(
|
||||||
session,
|
session,
|
||||||
person: person,
|
person: person,
|
||||||
code: code,
|
challenge: challenge,
|
||||||
|
clientValue: clientValue,
|
||||||
|
clientProof: clientProof,
|
||||||
|
totpCode: totpCode,
|
||||||
|
recoveryCode: recoveryCode,
|
||||||
recoveryKeys: recoveryKeys,
|
recoveryKeys: recoveryKeys,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -584,11 +584,19 @@ final class RemoteRegister implements AccountRegister {
|
||||||
Future<void> setRecoveryKeys(
|
Future<void> setRecoveryKeys(
|
||||||
Session session, {
|
Session session, {
|
||||||
required UuidValue person,
|
required UuidValue person,
|
||||||
required String code,
|
required UuidValue challenge,
|
||||||
|
required ByteData clientValue,
|
||||||
|
required ByteData clientProof,
|
||||||
|
String? totpCode,
|
||||||
|
String? recoveryCode,
|
||||||
required WrappedKeys recoveryKeys,
|
required WrappedKeys recoveryKeys,
|
||||||
}) => _ask(RegisterOperation.setRecoveryKeys, {
|
}) => _ask(RegisterOperation.setRecoveryKeys, {
|
||||||
'person': person.uuid,
|
'person': person.uuid,
|
||||||
'code': code,
|
'challenge': challenge.uuid,
|
||||||
|
'clientValue': _bytes(clientValue),
|
||||||
|
'clientProof': _bytes(clientProof),
|
||||||
|
'totpCode': totpCode,
|
||||||
|
'recoveryCode': recoveryCode,
|
||||||
'recoveryKeys': recoveryKeys.toJson(),
|
'recoveryKeys': recoveryKeys.toJson(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -316,77 +316,98 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
group('the recovery phrase', () {
|
group('the recovery phrase', () {
|
||||||
test("takes new wrapped keys for a code: the old phrase's wrapping "
|
|
||||||
'goes', () async {
|
|
||||||
final fresh = WrappedKeys(
|
final fresh = WrappedKeys(
|
||||||
nonce: ByteData(24),
|
nonce: ByteData(24),
|
||||||
cipherText: ByteData(wrappedKeysLength),
|
cipherText: ByteData(wrappedKeysLength),
|
||||||
);
|
);
|
||||||
|
|
||||||
await atNow(
|
/// Gives Anna's keys a new phrase's wrapping ([fresh]) from her
|
||||||
() => endpoints.password.setRecoveryKeys(
|
/// phone, the old password proved by [privateValue] (hers by
|
||||||
onPhone(),
|
/// default) or a made-up proof ([withoutProof]), with [totpCode] (her
|
||||||
code: codeOf(enrolment, at: now),
|
/// authenticator's now by default).
|
||||||
recoveryKeys: fresh,
|
Future<void> newPhrase({
|
||||||
),
|
BigInt? privateValue,
|
||||||
|
bool withoutProof = false,
|
||||||
|
String? totpCode,
|
||||||
|
bool withoutCode = false,
|
||||||
|
WrappedKeys? recoveryKeys,
|
||||||
|
TestSessionBuilder? as,
|
||||||
|
}) => atNow(() async {
|
||||||
|
final challenge = await endpoints.password.beginChange(onPhone());
|
||||||
|
final answer = device.answer(challenge, privateValue: privateValue);
|
||||||
|
await endpoints.password.setRecoveryKeys(
|
||||||
|
as ?? onPhone(),
|
||||||
|
challenge: challenge.challenge,
|
||||||
|
clientValue: answer.clientValue,
|
||||||
|
clientProof: withoutProof ? ByteData(32) : answer.clientProof,
|
||||||
|
totpCode: withoutCode
|
||||||
|
? null
|
||||||
|
: totpCode ?? codeOf(enrolment, at: now),
|
||||||
|
recoveryKeys: recoveryKeys ?? fresh,
|
||||||
);
|
);
|
||||||
|
|
||||||
final proof = await endpoints.accountRecovery.beginKeyProof(
|
|
||||||
notSignedIn(),
|
|
||||||
address: 'anna@example.org',
|
|
||||||
);
|
|
||||||
expect(proof.recoveryKeys.toJson(), fresh.toJson());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('is refused for a wrong code, keys out of range, and without a '
|
Future<WrappedKeys> wrapping() async =>
|
||||||
'password, keeping the wrapping', () async {
|
(await endpoints.accountRecovery.beginKeyProof(
|
||||||
final fresh = WrappedKeys(
|
notSignedIn(),
|
||||||
nonce: ByteData(24),
|
address: 'anna@example.org',
|
||||||
cipherText: ByteData(wrappedKeysLength),
|
)).recoveryKeys;
|
||||||
|
|
||||||
|
test('takes new wrapped keys with the old password and a code: the '
|
||||||
|
"old phrase's wrapping goes", () async {
|
||||||
|
await newPhrase();
|
||||||
|
|
||||||
|
expect((await wrapping()).toJson(), fresh.toJson());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is refused alike without the password's proof, for a wrong "
|
||||||
|
"one, another person's, and a missing or wrong code, keeping "
|
||||||
|
'the wrapping', () async {
|
||||||
|
// A session and a code alone: whoever holds a phone signed in with
|
||||||
|
// its authenticator would otherwise set a phrase of their own, and
|
||||||
|
// with it a new password.
|
||||||
|
await expectLater(
|
||||||
|
newPhrase(withoutProof: true),
|
||||||
|
refused(PasswordRefusal.wrongCredentials),
|
||||||
);
|
);
|
||||||
await expectLater(
|
await expectLater(
|
||||||
atNow(
|
newPhrase(privateValue: BigInt.from(4242)),
|
||||||
() => endpoints.password.setRecoveryKeys(
|
refused(PasswordRefusal.wrongCredentials),
|
||||||
onPhone(),
|
|
||||||
code: '000000',
|
|
||||||
recoveryKeys: fresh,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
refused(PasswordRefusal.wrongCode),
|
|
||||||
);
|
);
|
||||||
await expectLater(
|
await expectLater(
|
||||||
atNow(
|
newPhrase(withoutCode: true),
|
||||||
() => endpoints.password.setRecoveryKeys(
|
refused(PasswordRefusal.wrongCredentials),
|
||||||
onPhone(),
|
);
|
||||||
code: codeOf(enrolment, at: now),
|
await expectLater(
|
||||||
|
newPhrase(totpCode: '000000'),
|
||||||
|
refused(PasswordRefusal.wrongCredentials),
|
||||||
|
);
|
||||||
|
final ben = await newAnonymousPerson(session);
|
||||||
|
final bensLaptop = await signInNewDevice(session, ben);
|
||||||
|
await expectLater(
|
||||||
|
newPhrase(
|
||||||
|
as: sessionBuilder.copyWith(
|
||||||
|
authentication: AuthenticationOverride.authenticationInfo(
|
||||||
|
ben.uuid,
|
||||||
|
{identified},
|
||||||
|
authId: bensLaptop.uuid,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
refused(PasswordRefusal.wrongCredentials),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
newPhrase(
|
||||||
recoveryKeys: WrappedKeys(
|
recoveryKeys: WrappedKeys(
|
||||||
nonce: ByteData(24),
|
nonce: ByteData(24),
|
||||||
cipherText: ByteData(8),
|
cipherText: ByteData(8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
refused(PasswordRefusal.invalidSetup),
|
refused(PasswordRefusal.invalidSetup),
|
||||||
);
|
);
|
||||||
final ben = await newAnonymousPerson(session);
|
|
||||||
await expectLater(
|
|
||||||
endpoints.password.setRecoveryKeys(
|
|
||||||
sessionBuilder.copyWith(
|
|
||||||
authentication: AuthenticationOverride.authenticationInfo(
|
|
||||||
ben.uuid,
|
|
||||||
{},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
code: '123456',
|
|
||||||
recoveryKeys: fresh,
|
|
||||||
),
|
|
||||||
refused(PasswordRefusal.noPassword),
|
|
||||||
);
|
|
||||||
final proof = await endpoints.accountRecovery.beginKeyProof(
|
|
||||||
notSignedIn(),
|
|
||||||
address: 'anna@example.org',
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
proof.recoveryKeys.toJson(),
|
(await wrapping()).toJson(),
|
||||||
device.recoveryKeys.toJson(),
|
device.recoveryKeys.toJson(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue