feat(app): sign in with a password and TOTP on the device
ADR 0017, step 3: halkyon_app's side, which Learn and Todo take. - The account's cryptography on the device (`SodiumAccountCrypto`): halkyon_keys and SRP's client half, each step on an isolate of its own. sodium hands out results as views of native memory its finalizers free once the isolate that made them ends, so everything crossing back is copied first: a view read freed memory and crashed the process. - `PasswordGateway` on the module's endpoints (steps without a session through a client holding none; an authenticator set up with a session granted but not yet the device's), and `PasswordRepository` running the flows: signing in (M2 checked, the keys opened before anything is trusted), signing up, a password for someone signed in, an account never finished, the authenticators, recovery codes, changing the password, a new recovery phrase, and getting back in. - The unlocked keys in the system's keyring per instance and person (`SecureAccountKeyStore`), gone once another person signs in or nobody, and with the instance's removal. - The screens: the start screen's Sign in and Create an account (and when GitHub sign-in ends), the sign-in sheet (codes, recovery codes, an account never finished, a lost authenticator), the account's setup (the recovery phrase shown once, three random words typed back before anything reaches the instance; the authenticator's QR code, drawn with pub.dev's qr; the recovery codes), getting back in, and the account section (`AccountSecuritySection`). - `FileSaver` moves from Learn into halkyon_app, for the phrase and the codes in Todo too; `AppDependencies` names it and `passwords`. - halkyon_keys rebuilds account keys from the secret keys a device kept; the key proof's message is in halkyon_base_client, checked against the register's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
1daa1b649a
commit
a4819c3d39
2 changed files with 9456 additions and 35 deletions
|
|
@ -0,0 +1,480 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
|
||||
Matcher failsWith(PasswordProblem problem) => throwsA(
|
||||
isA<PasswordFailed>().having((failed) => failed.problem, 'problem', problem),
|
||||
);
|
||||
|
||||
void main() {
|
||||
late FakeAuthGateway auth;
|
||||
late FakePasswordGateway gateway;
|
||||
late FakeAccountCrypto crypto;
|
||||
late MemoryAccountKeyStore keys;
|
||||
late MemorySignInAddresses addresses;
|
||||
late PasswordRepository passwords;
|
||||
|
||||
setUp(() {
|
||||
auth = FakeAuthGateway();
|
||||
gateway = FakePasswordGateway(auth);
|
||||
crypto = FakeAccountCrypto();
|
||||
keys = MemoryAccountKeyStore();
|
||||
addresses = MemorySignInAddresses();
|
||||
passwords = PasswordRepository(gateway, crypto, keys, auth, addresses);
|
||||
});
|
||||
tearDown(() => passwords.dispose());
|
||||
|
||||
/// Lets the keys' forgetting, which follows the status, run.
|
||||
Future<void> settle() => Future<void>.delayed(Duration.zero);
|
||||
|
||||
group('signing in', () {
|
||||
late FakePasswordAccount anna;
|
||||
|
||||
setUp(() {
|
||||
anna = gateway.addAccount(
|
||||
'anna@example.org',
|
||||
password: 'correct horse',
|
||||
crypto: crypto,
|
||||
);
|
||||
});
|
||||
|
||||
test('with the password and a code opens the keys, and entering puts the '
|
||||
'session, the keys and the address in place', () async {
|
||||
final unlocked = await passwords.unlock(
|
||||
' anna@example.org',
|
||||
'correct horse',
|
||||
);
|
||||
final pending = await passwords.signIn(
|
||||
unlocked,
|
||||
totpCode: fakeTotpCode,
|
||||
);
|
||||
|
||||
expect(pending.needsAuthenticator, isFalse);
|
||||
expect(pending.grant.authUserId, anna.person);
|
||||
expect(auth.status, const SignedOut());
|
||||
expect(keys.kept, isEmpty);
|
||||
|
||||
await passwords.enter(pending);
|
||||
await settle();
|
||||
|
||||
expect(
|
||||
auth.status,
|
||||
SignedIn(authUserId: anna.person, isIdentified: true),
|
||||
);
|
||||
expect(keys.kept.keys, [anna.person]);
|
||||
expect(keys.kept[anna.person], pending.keys);
|
||||
expect(addresses.addresses[anna.person], 'anna@example.org');
|
||||
});
|
||||
|
||||
test('is refused alike for a wrong password or code, signing nobody in, '
|
||||
'and begins a new challenge for the next try', () async {
|
||||
final wrong = await passwords.unlock('anna@example.org', 'wrong');
|
||||
await expectLater(
|
||||
passwords.signIn(wrong, totpCode: fakeTotpCode),
|
||||
failsWith(PasswordProblem.wrongCredentials),
|
||||
);
|
||||
final right = await passwords.unlock(
|
||||
'anna@example.org',
|
||||
'correct horse',
|
||||
);
|
||||
await expectLater(
|
||||
passwords.signIn(right, totpCode: '000000'),
|
||||
failsWith(PasswordProblem.wrongCredentials),
|
||||
);
|
||||
|
||||
await passwords.signIn(right, totpCode: fakeTotpCode);
|
||||
|
||||
expect(gateway.calls.where((call) => call == 'begin'), hasLength(3));
|
||||
expect(auth.status, const SignedOut());
|
||||
expect(keys.kept, isEmpty);
|
||||
});
|
||||
|
||||
test('begins a new challenge once the old one has grown old', () async {
|
||||
final unlocked = await withClock(
|
||||
Clock.fixed(DateTime.utc(2026, 10, 20, 12)),
|
||||
() => passwords.unlock('anna@example.org', 'correct horse'),
|
||||
);
|
||||
|
||||
await withClock(
|
||||
Clock.fixed(DateTime.utc(2026, 10, 20, 12, 2)),
|
||||
() => passwords.signIn(unlocked, totpCode: fakeTotpCode),
|
||||
);
|
||||
|
||||
expect(gateway.calls, ['begin', 'begin', 'complete']);
|
||||
});
|
||||
|
||||
test('trusts nothing of an instance that cannot prove it holds the '
|
||||
"password's verifier", () async {
|
||||
gateway.provesPassword = false;
|
||||
final unlocked = await passwords.unlock(
|
||||
'anna@example.org',
|
||||
'correct horse',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
passwords.signIn(unlocked, totpCode: fakeTotpCode),
|
||||
failsWith(PasswordProblem.instanceUnproven),
|
||||
);
|
||||
});
|
||||
|
||||
test('of a setup never finished takes a code by mail, an authenticator '
|
||||
'then set up with the pending session', () async {
|
||||
final carla = gateway.addAccount(
|
||||
'carla@example.org',
|
||||
password: 'battery staple',
|
||||
crypto: crypto,
|
||||
finished: false,
|
||||
);
|
||||
final unlocked = await passwords.unlock(
|
||||
'carla@example.org',
|
||||
'battery staple',
|
||||
);
|
||||
await expectLater(
|
||||
passwords.signIn(unlocked),
|
||||
failsWith(PasswordProblem.setupIncomplete),
|
||||
);
|
||||
final request = await passwords.sendCode(
|
||||
'carla@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
|
||||
final pending = await passwords.signIn(
|
||||
unlocked,
|
||||
request: request,
|
||||
code: gateway.codeFor('carla@example.org'),
|
||||
);
|
||||
expect(pending.needsAuthenticator, isTrue);
|
||||
final setup = await passwords.enrol(
|
||||
name: 'Phone',
|
||||
label: 'carla@example.org',
|
||||
pending: pending,
|
||||
);
|
||||
final codes = await passwords.confirm(
|
||||
setup.id,
|
||||
'123 456',
|
||||
pending: pending,
|
||||
);
|
||||
|
||||
expect(codes, hasLength(10));
|
||||
expect(carla.finished, isTrue);
|
||||
expect(auth.status, const SignedOut());
|
||||
});
|
||||
});
|
||||
|
||||
group('signing up', () {
|
||||
test('makes the account with the address a code proves, pending until '
|
||||
'its authenticator is set up', () async {
|
||||
final material = await passwords.makePassword('a new password');
|
||||
final request = await passwords.sendCode(
|
||||
'ben@example.org',
|
||||
language: 'de',
|
||||
);
|
||||
|
||||
final pending = await passwords.signUp(
|
||||
material,
|
||||
address: 'ben@example.org',
|
||||
request: request,
|
||||
code: gateway.codeFor('ben@example.org'),
|
||||
);
|
||||
|
||||
expect(pending.needsAuthenticator, isTrue);
|
||||
expect(pending.keys, material.keys);
|
||||
expect(gateway.mailed.single.language, 'de');
|
||||
expect(
|
||||
gateway.accounts['ben@example.org']!.password,
|
||||
'a new password',
|
||||
);
|
||||
});
|
||||
|
||||
test('is refused for an address with a password already', () async {
|
||||
gateway.addAccount('anna@example.org', password: 'x', crypto: crypto);
|
||||
final request = await passwords.sendCode(
|
||||
'anna@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
passwords.signUp(
|
||||
await passwords.makePassword('another'),
|
||||
address: 'anna@example.org',
|
||||
request: request,
|
||||
code: gateway.codeFor('anna@example.org'),
|
||||
),
|
||||
failsWith(PasswordProblem.passwordExists),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('signed in', () {
|
||||
test('an anonymous person given a password becomes identified, the keys '
|
||||
'and the address kept', () async {
|
||||
await auth.signInAnonymously();
|
||||
final request = await passwords.sendCode(
|
||||
'dana@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
|
||||
final material = await passwords.makePassword('her password');
|
||||
await passwords.setPassword(
|
||||
material,
|
||||
address: 'dana@example.org',
|
||||
request: request,
|
||||
code: gateway.codeFor('dana@example.org'),
|
||||
);
|
||||
|
||||
expect(
|
||||
auth.status,
|
||||
const SignedIn(authUserId: 'anonymous-1', isIdentified: true),
|
||||
);
|
||||
expect(keys.kept['anonymous-1'], material.keys);
|
||||
expect(addresses.addresses['anonymous-1'], 'dana@example.org');
|
||||
});
|
||||
|
||||
test("keeps only the signed-in person's keys: another signing in, or "
|
||||
'signing out, takes the others away', () async {
|
||||
await auth.signInAnonymously();
|
||||
await keys.keep('anonymous-1', crypto.keysNumbered(1));
|
||||
await keys.keep('someone-else', crypto.keysNumbered(2));
|
||||
|
||||
await auth.signOut();
|
||||
await settle();
|
||||
|
||||
expect(keys.kept, isEmpty);
|
||||
});
|
||||
|
||||
group('with a password and an authenticator', () {
|
||||
late FakePasswordAccount anna;
|
||||
|
||||
setUp(() async {
|
||||
anna = gateway.addAccount(
|
||||
'anna@example.org',
|
||||
password: 'correct horse',
|
||||
crypto: crypto,
|
||||
);
|
||||
final pending = await passwords.signIn(
|
||||
await passwords.unlock('anna@example.org', 'correct horse'),
|
||||
totpCode: fakeTotpCode,
|
||||
);
|
||||
await passwords.enter(pending);
|
||||
await settle();
|
||||
});
|
||||
|
||||
test('changes the password with the old one and a code, the keys '
|
||||
'staying', () async {
|
||||
await passwords.changePassword(
|
||||
oldPassword: 'correct horse',
|
||||
newPassword: 'better horse',
|
||||
totpCode: fakeTotpCode,
|
||||
);
|
||||
|
||||
expect(anna.password, 'better horse');
|
||||
final unlocked = await passwords.unlock(
|
||||
'anna@example.org',
|
||||
'better horse',
|
||||
);
|
||||
final again = await passwords.signIn(
|
||||
unlocked,
|
||||
totpCode: fakeTotpCode,
|
||||
);
|
||||
expect(again.keys, keys.kept[anna.person]);
|
||||
});
|
||||
|
||||
test('refuses a change with a wrong old password, and without the '
|
||||
'keys on the device', () async {
|
||||
await expectLater(
|
||||
passwords.changePassword(
|
||||
oldPassword: 'wrong',
|
||||
newPassword: 'better horse',
|
||||
totpCode: fakeTotpCode,
|
||||
),
|
||||
failsWith(PasswordProblem.wrongCredentials),
|
||||
);
|
||||
keys.kept.clear();
|
||||
await expectLater(
|
||||
passwords.changePassword(
|
||||
oldPassword: 'correct horse',
|
||||
newPassword: 'better horse',
|
||||
totpCode: fakeTotpCode,
|
||||
),
|
||||
failsWith(PasswordProblem.keysMissing),
|
||||
);
|
||||
expect(anna.password, 'correct horse');
|
||||
});
|
||||
|
||||
test('makes a new recovery phrase, which the instance takes for a '
|
||||
'code', () async {
|
||||
final phrase = await passwords.newPhrase();
|
||||
await expectLater(
|
||||
passwords.keepPhrase(phrase, '000000'),
|
||||
failsWith(PasswordProblem.wrongCode),
|
||||
);
|
||||
|
||||
await passwords.keepPhrase(phrase, fakeTotpCode);
|
||||
|
||||
await auth.signOut();
|
||||
final pending = await passwords.resetPassword(
|
||||
address: 'anna@example.org',
|
||||
phrase: otherFakePhrase,
|
||||
newPassword: 'after the phrase',
|
||||
totpCode: fakeTotpCode,
|
||||
);
|
||||
expect(pending.grant.authUserId, anna.person);
|
||||
});
|
||||
|
||||
test('says what keeps the account', () async {
|
||||
final security = await passwords.security();
|
||||
|
||||
expect(security.offered, isTrue);
|
||||
expect(security.hasPassword, isTrue);
|
||||
expect(security.finished, isTrue);
|
||||
expect(security.keysHere, isTrue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('getting back in', () {
|
||||
late FakePasswordAccount anna;
|
||||
|
||||
setUp(() {
|
||||
anna = gateway.addAccount(
|
||||
'anna@example.org',
|
||||
password: 'correct horse',
|
||||
crypto: crypto,
|
||||
);
|
||||
});
|
||||
|
||||
test('with the recovery phrase sets a new password, the keys '
|
||||
'staying; a wrong phrase opens nothing', () async {
|
||||
await expectLater(
|
||||
passwords.resetPassword(
|
||||
address: 'anna@example.org',
|
||||
phrase: otherFakePhrase,
|
||||
newPassword: 'new',
|
||||
totpCode: fakeTotpCode,
|
||||
),
|
||||
failsWith(PasswordProblem.phraseDoesNotFit),
|
||||
);
|
||||
await expectLater(
|
||||
passwords.resetPassword(
|
||||
address: 'anna@example.org',
|
||||
phrase: 'too short',
|
||||
newPassword: 'new',
|
||||
totpCode: fakeTotpCode,
|
||||
),
|
||||
failsWith(PasswordProblem.notAPhrase),
|
||||
);
|
||||
|
||||
final pending = await passwords.resetPassword(
|
||||
address: 'anna@example.org',
|
||||
phrase: fakePhrase,
|
||||
newPassword: 'new',
|
||||
totpCode: fakeTotpCode,
|
||||
);
|
||||
|
||||
expect(anna.password, 'new');
|
||||
expect(pending.keys, crypto.keysNumbered(200));
|
||||
});
|
||||
|
||||
test('a lost authenticator: the password and a code by mail ask, 72 '
|
||||
'hours on', () async {
|
||||
final now = DateTime.utc(2026, 10, 20, 12);
|
||||
final unlocked = await passwords.unlock(
|
||||
'anna@example.org',
|
||||
'correct horse',
|
||||
);
|
||||
final request = await passwords.sendCode(
|
||||
'anna@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
|
||||
final at = await withClock(
|
||||
Clock.fixed(now),
|
||||
() => passwords.resetAuthenticator(
|
||||
unlocked,
|
||||
language: 'en',
|
||||
request: request,
|
||||
code: gateway.codeFor('anna@example.org'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(at, now.add(fakeResetDelay));
|
||||
await expectLater(
|
||||
withClock(Clock.fixed(now), () => passwords.signIn(unlocked)),
|
||||
throwsA(
|
||||
isA<PasswordFailed>()
|
||||
.having((f) => f.problem, 'problem', PasswordProblem.notDue)
|
||||
.having((f) => f.takesEffectAt, 'takesEffectAt', at),
|
||||
),
|
||||
);
|
||||
final pending = await withClock(
|
||||
Clock.fixed(at),
|
||||
() => passwords.signIn(unlocked),
|
||||
);
|
||||
expect(pending.needsAuthenticator, isTrue);
|
||||
});
|
||||
|
||||
test('a lost password without the phrase: the account reset, 72 hours '
|
||||
'on, with new keys or, with the phrase, the old', () async {
|
||||
final now = DateTime.utc(2026, 10, 20, 12);
|
||||
Future<({String request, String code})> mailed() async {
|
||||
final request = await passwords.sendCode(
|
||||
'anna@example.org',
|
||||
language: 'en',
|
||||
);
|
||||
return (request: request, code: gateway.codeFor('anna@example.org'));
|
||||
}
|
||||
|
||||
final start = await mailed();
|
||||
final at = await withClock(
|
||||
Clock.fixed(now),
|
||||
() => passwords.startAccountReset(
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
request: start.request,
|
||||
code: start.code,
|
||||
),
|
||||
);
|
||||
final kept = await passwords.keepKeys(
|
||||
address: 'anna@example.org',
|
||||
phrase: fakePhrase,
|
||||
newPassword: 'after the reset',
|
||||
);
|
||||
final early = await mailed();
|
||||
await expectLater(
|
||||
withClock(
|
||||
Clock.fixed(now),
|
||||
() => passwords.completeAccountReset(
|
||||
kept,
|
||||
address: 'anna@example.org',
|
||||
request: early.request,
|
||||
code: early.code,
|
||||
),
|
||||
),
|
||||
throwsA(
|
||||
isA<PasswordFailed>().having(
|
||||
(f) => f.takesEffectAt,
|
||||
'takesEffectAt',
|
||||
at,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final due = await mailed();
|
||||
final pending = await withClock(
|
||||
Clock.fixed(at),
|
||||
() => passwords.completeAccountReset(
|
||||
kept,
|
||||
address: 'anna@example.org',
|
||||
request: due.request,
|
||||
code: due.code,
|
||||
),
|
||||
);
|
||||
|
||||
expect(pending.needsAuthenticator, isTrue);
|
||||
expect(pending.keys, crypto.keysNumbered(200));
|
||||
expect(anna.password, 'after the reset');
|
||||
expect(anna.finished, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
|
||||
import 'package:halkyon_base_client/srp.dart' as srp;
|
||||
import 'package:sodium/sodium_sumo.dart';
|
||||
|
||||
Matcher failsWith(PasswordProblem problem) => throwsA(
|
||||
isA<PasswordFailed>().having((failed) => failed.problem, 'problem', problem),
|
||||
);
|
||||
|
||||
BigInt _numberOf(List<int> bytes) =>
|
||||
bytes.fold(BigInt.zero, (value, byte) => (value << 8) | BigInt.from(byte));
|
||||
|
||||
/// The account's cryptography on the device, with libsodium as its build
|
||||
/// hook compiles it: Argon2id at the costs new passwords take, so each
|
||||
/// derivation takes a moment.
|
||||
void main() {
|
||||
final crypto = SodiumAccountCrypto();
|
||||
late NewAccountMaterial account;
|
||||
|
||||
setUpAll(() async {
|
||||
account = await crypto.newAccount('correct horse battery staple');
|
||||
});
|
||||
|
||||
Future<PasswordUnlock> unlock(String password, [protocol.PasswordSetup? of]) {
|
||||
final setup = of ?? account.setup;
|
||||
return crypto.unlock(
|
||||
password,
|
||||
salt: Uint8List.sublistView(setup.salt),
|
||||
costs: setup.costs,
|
||||
);
|
||||
}
|
||||
|
||||
Future<UnlockedAccountKeys> open(
|
||||
PasswordUnlock unlocked,
|
||||
protocol.WrappedKeys wrapped,
|
||||
) => crypto.openKeys(
|
||||
unlocked,
|
||||
wrapped,
|
||||
encryptionPublicKey: account.keys.encryptionPublicKey,
|
||||
signingPublicKey: account.keys.signingPublicKey,
|
||||
);
|
||||
|
||||
test('a new account: what the register takes, its keys opening with the '
|
||||
'password on another device, the verifier made of the same x', () async {
|
||||
final setup = account.setup;
|
||||
expect(setup.salt.lengthInBytes, 16);
|
||||
expect(setup.costs.algorithm, 'argon2id13');
|
||||
expect(setup.costs.opsLimit, 3);
|
||||
expect(setup.costs.memLimit, 64 * 1024 * 1024);
|
||||
expect(setup.verifier.lengthInBytes, 384);
|
||||
expect(setup.passwordKeys.nonce.lengthInBytes, 24);
|
||||
expect(setup.passwordKeys.cipherText.lengthInBytes, 112);
|
||||
expect(setup.recoveryKeys.cipherText.lengthInBytes, 112);
|
||||
expect(account.phrase.split(' '), hasLength(24));
|
||||
|
||||
final unlocked = await unlock('correct horse battery staple');
|
||||
expect(await open(unlocked, setup.passwordKeys), account.keys);
|
||||
final group = srp.SrpGroup.rfc5054Group3072;
|
||||
expect(
|
||||
_numberOf(Uint8List.sublistView(setup.verifier)),
|
||||
srp.srpVerifier(group, _numberOf(unlocked.srpSecret)),
|
||||
);
|
||||
});
|
||||
|
||||
test('a wrong password opens nothing; an empty one is refused', () async {
|
||||
await expectLater(
|
||||
open(
|
||||
await unlock('correct horse battery stable'),
|
||||
account.setup.passwordKeys,
|
||||
),
|
||||
failsWith(PasswordProblem.instanceUnproven),
|
||||
);
|
||||
await expectLater(unlock(''), failsWith(PasswordProblem.passwordUnusable));
|
||||
});
|
||||
|
||||
test('the recovery phrase opens the keys; another phrase, or no phrase, '
|
||||
'opens nothing', () async {
|
||||
final opened = await crypto.openWithPhrase(
|
||||
account.phrase.toUpperCase(),
|
||||
account.setup.recoveryKeys,
|
||||
encryptionPublicKey: account.keys.encryptionPublicKey,
|
||||
signingPublicKey: account.keys.signingPublicKey,
|
||||
);
|
||||
expect(opened, account.keys);
|
||||
|
||||
final other = (await crypto.newPhrase(account.keys)).phrase;
|
||||
await expectLater(
|
||||
crypto.openWithPhrase(
|
||||
other,
|
||||
account.setup.recoveryKeys,
|
||||
encryptionPublicKey: account.keys.encryptionPublicKey,
|
||||
signingPublicKey: account.keys.signingPublicKey,
|
||||
),
|
||||
failsWith(PasswordProblem.phraseDoesNotFit),
|
||||
);
|
||||
await expectLater(
|
||||
crypto.openWithPhrase(
|
||||
'not a phrase at all',
|
||||
account.setup.recoveryKeys,
|
||||
encryptionPublicKey: account.keys.encryptionPublicKey,
|
||||
signingPublicKey: account.keys.signingPublicKey,
|
||||
),
|
||||
failsWith(PasswordProblem.notAPhrase),
|
||||
);
|
||||
});
|
||||
|
||||
test('a new password wraps the same keys; keeping the keys through a reset '
|
||||
'keeps the phrase too', () async {
|
||||
final changed = await crypto.newPassword('a better one', account.keys);
|
||||
final unlocked = await crypto.unlock(
|
||||
'a better one',
|
||||
salt: Uint8List.sublistView(changed.salt),
|
||||
costs: changed.costs,
|
||||
);
|
||||
expect(await open(unlocked, changed.passwordKeys), account.keys);
|
||||
|
||||
final reset = await crypto.keepingKeys(
|
||||
'after a reset',
|
||||
account.keys,
|
||||
phrase: account.phrase,
|
||||
);
|
||||
expect(reset.keys, account.keys);
|
||||
expect(
|
||||
await crypto.openWithPhrase(
|
||||
account.phrase,
|
||||
reset.setup.recoveryKeys,
|
||||
encryptionPublicKey: account.keys.encryptionPublicKey,
|
||||
signingPublicKey: account.keys.signingPublicKey,
|
||||
),
|
||||
account.keys,
|
||||
);
|
||||
});
|
||||
|
||||
test("signs the key proof with the account's key, as the register checks "
|
||||
'it', () async {
|
||||
final challenge = protocol.KeyProofChallenge(
|
||||
challenge: protocol.UuidValue.fromString(
|
||||
'01994a3c-7b2e-7c00-8a1b-0123456789ab',
|
||||
),
|
||||
nonce: ByteData.sublistView(Uint8List.fromList(List.filled(32, 9))),
|
||||
encryptionPublicKey: ByteData.sublistView(
|
||||
account.keys.encryptionPublicKey,
|
||||
),
|
||||
signingPublicKey: ByteData.sublistView(account.keys.signingPublicKey),
|
||||
recoveryKeys: account.setup.recoveryKeys,
|
||||
);
|
||||
|
||||
final signature = await crypto.signKeyProof(account.keys, challenge);
|
||||
|
||||
final sodium = await SodiumSumoInit.init();
|
||||
expect(
|
||||
sodium.crypto.sign.verifyDetached(
|
||||
message: srp.keyProofMessage(
|
||||
challenge.challenge.toBytes(),
|
||||
List.filled(32, 9),
|
||||
),
|
||||
signature: Uint8List.sublistView(signature),
|
||||
publicKey: account.keys.signingPublicKey,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test("answers SRP's first step with A as long as N", () async {
|
||||
final unlocked = await unlock('correct horse battery staple');
|
||||
final group = srp.SrpGroup.rfc5054Group3072;
|
||||
final answer = await crypto.answer(
|
||||
unlocked,
|
||||
protocol.PasswordChallenge(
|
||||
challenge: protocol.UuidValue.fromString(
|
||||
'01994a3c-7b2e-7c00-8a1b-0123456789ab',
|
||||
),
|
||||
salt: account.setup.salt,
|
||||
costs: account.setup.costs,
|
||||
// g^5: any value of the group stands in for B here.
|
||||
serverValue: ByteData.sublistView(
|
||||
Uint8List.fromList([
|
||||
...List.filled(383, 0),
|
||||
32,
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(answer.clientValue.lengthInBytes, group.length);
|
||||
expect(answer.clientProof.lengthInBytes, 32);
|
||||
expect(answer.verifiesServer(ByteData(32)), isFalse);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue