feat(app): the keys of accounts that sign in with SimpleLogin, on the device (ADR 0020, D19)

AccountCrypto makes the keys of an account that signs in with SimpleLogin
(newKeys): new key pairs or an anonymous person's device keys, wrapped
under the password and a new recovery phrase, with the public half of the
password's proof key in place of SRP's verifier. It proves the password to
open them (proveKeysPassword: one Argon2id for the proof and the unwrapping)
and the recovery phrase to link a new SimpleLogin account in place of a
lost one (signMendProof, bound to the attempt). The interop tests check all
three against the register's own checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-24 00:00:38 +02:00
commit c9fedce258
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 442 additions and 21 deletions

View file

@ -77,6 +77,24 @@ final class const NewAccountMaterial({
String toString() => 'NewAccountMaterial(…)'; String toString() => 'NewAccountMaterial(…)';
} }
/// The keys of an account that signs in with SimpleLogin as the device
/// made them (ADR 0020, D19): the setup it sends, the recovery phrase it
/// shows once, and the keys it keeps unlocked.
final class const NewKeysMaterial({
required final protocol.KeysSetup setup,
required final String phrase,
required final UnlockedAccountKeys keys,
}) {
/// Never the phrase or the keys.
@override
String toString() => 'NewKeysMaterial(…)';
}
/// The proof of the password of an account that signs in with SimpleLogin
/// ([AccountCrypto.proveKeysPassword]), and what the password unlocked,
/// which opens the keys the register then hands over.
typedef KeysPasswordProof = ({protocol.KeysProof proof, PasswordUnlock unlock});
/// The device's answer to SRP's first step: A and M1 for the register, and /// The device's answer to SRP's first step: A and M1 for the register, and
/// whether the register's M2 is the one that proves it holds the verifier. /// whether the register's M2 is the one that proves it holds the verifier.
/// A new recovery phrase made on the device ([AccountCrypto.newPhrase]): the /// A new recovery phrase made on the device ([AccountCrypto.newPhrase]): the
@ -184,4 +202,33 @@ abstract interface class AccountCrypto() {
/// A new recovery phrase for [keys]: the keys wrapped under it, and the /// A new recovery phrase for [keys]: the keys wrapped under it, and the
/// public half of its proof key. /// public half of its proof key.
Future<NewPhraseMaterial> newPhrase(UnlockedAccountKeys keys); Future<NewPhraseMaterial> newPhrase(UnlockedAccountKeys keys);
/// The keys of an account that signs in with SimpleLogin (ADR 0020, D19):
/// [keys] (an anonymous person's device keys, ADR 0017, 9) or new key
/// pairs, wrapped under [password] and a new recovery phrase, with the
/// public half of the password's proof key in place of SRP's verifier.
Future<NewKeysMaterial> newKeys(String password, {UnlockedAccountKeys? keys});
/// The proof of [password] for [challenge] on [instanceUrl], signed by
/// the password's proof key for unlocking the keys
/// (`KeyProofPurpose.unlockKeys`), with what it unlocked for the
/// challenge's salt and costs: one Argon2id for both. The caller forgets
/// the unlock once the keys are open.
Future<KeysPasswordProof> proveKeysPassword(
String password,
protocol.KeysChallenge challenge, {
required String instanceUrl,
});
/// The signature proving the recovery [phrase] for [challenge] on
/// [instanceUrl], to link the SimpleLogin sign-in under [attempt] in
/// place of a lost one (ADR 0020, D7; `KeyProofPurpose.mendLink`): by the
/// phrase's own proof key, as [signKeyProof]'s. Refused
/// (`PasswordProblem.notAPhrase`) for what is no recovery phrase.
Future<ByteData> signMendProof(
String phrase,
protocol.KeyProofChallenge challenge, {
required String instanceUrl,
required protocol.UuidValue attempt,
});
} }

View file

@ -270,6 +270,117 @@ class SodiumAccountCrypto() implements AccountCrypto {
}), }),
); );
@override
Future<NewKeysMaterial> newKeys(
String password, {
UnlockedAccountKeys? keys,
}) => Isolate.run(
() => _guarded(() async {
final sodium = await SodiumSumoInit.init();
final opened = keys == null
? AccountKeys.generate(sodium)
: _keysOf(sodium, keys);
try {
return _keysMaterial(
sodium,
password,
opened,
phrase: newRecoveryPhrase(sodium),
);
} finally {
opened.dispose();
}
}),
);
@override
Future<KeysPasswordProof> proveKeysPassword(
String password,
protocol.KeysChallenge challenge, {
required String instanceUrl,
}) => Isolate.run(
() => _guarded(() async {
final sodium = await SodiumSumoInit.init();
final salt = Uint8List.fromList(Uint8List.sublistView(challenge.salt));
final unlocked = derivePasswordKeys(
sodium,
password: password,
salt: salt,
kdf: _kdfOf(challenge.costs),
);
try {
final pair = unlocked.proofKeyPair();
final Uint8List signature;
try {
signature = _own(
sodium.crypto.sign.detached(
message: srp.keyProofMessage(
challenge.challenge.toBytes(),
Uint8List.sublistView(challenge.nonce),
instanceUrl: instanceUrl,
purpose: srp.KeyProofPurpose.unlockKeys,
),
secretKey: pair.secretKey,
),
);
} finally {
pair.secretKey.dispose();
}
return (
proof: protocol.KeysProof(
challenge: challenge.challenge,
signature: ByteData.sublistView(signature),
instanceUrl: instanceUrl,
),
unlock: PasswordUnlock(
salt: salt,
costs: challenge.costs,
srpSecret: unlocked.srpSecret.extractBytes(),
keyEncryptionKey: unlocked.keyEncryptionKey.extractBytes(),
),
);
} finally {
unlocked.dispose();
}
}),
);
@override
Future<ByteData> signMendProof(
String phrase,
protocol.KeyProofChallenge challenge, {
required String instanceUrl,
required protocol.UuidValue attempt,
}) => Isolate.run(
() => _guarded(() async {
final sodium = await SodiumSumoInit.init();
final KeyPair proof;
try {
proof = recoveryProofKeysOf(sodium, phrase);
} on KeyRefused {
throw const PasswordFailed(PasswordProblem.notAPhrase);
}
try {
return ByteData.sublistView(
_own(
sodium.crypto.sign.detached(
message: srp.keyProofMessage(
challenge.challenge.toBytes(),
Uint8List.sublistView(challenge.nonce),
instanceUrl: instanceUrl,
purpose: srp.KeyProofPurpose.mendLink,
digest: srp.mendLinkDigest(attempt.toBytes()),
),
secretKey: proof.secretKey,
),
),
);
} finally {
proof.secretKey.dispose();
}
}),
);
@override @override
Future<NewPhraseMaterial> newPhrase(UnlockedAccountKeys keys) => Isolate.run( Future<NewPhraseMaterial> newPhrase(UnlockedAccountKeys keys) => Isolate.run(
() => _guarded(() async { () => _guarded(() async {
@ -343,6 +454,77 @@ NewAccountMaterial _material(
String password, String password,
AccountKeys keys, { AccountKeys keys, {
required String phrase, required String phrase,
}) => _wrapping(
sodium,
password,
keys,
phrase: phrase,
(salt, unlocked, recoveryKey) => NewAccountMaterial(
setup: protocol.PasswordSetup(
salt: ByteData.sublistView(salt),
costs: _costsOf(PasswordKdf.current),
verifier: _verifierOf(unlocked),
encryptionPublicKey: ByteData.sublistView(_own(keys.encryptionPublicKey)),
signingPublicKey: ByteData.sublistView(_own(keys.signingPublicKey)),
passwordKeys: _wrappedOf(
wrapAccountKeys(sodium, keys, unlocked.keyEncryptionKey),
),
recoveryKeys: _wrappedOf(wrapAccountKeys(sodium, keys, recoveryKey)),
// Copied by recoveryProofPublicKeyOf already.
recoveryProofKey: ByteData.sublistView(
recoveryProofPublicKeyOf(sodium, phrase),
),
),
phrase: phrase,
keys: _unlockedOf(keys),
),
);
/// The keys of an account that signs in with SimpleLogin for [password]
/// around [keys], wrapped under the password and under [phrase], with the
/// password's proof key in place of SRP's verifier (ADR 0020, D19).
NewKeysMaterial _keysMaterial(
SodiumSumo sodium,
String password,
AccountKeys keys, {
required String phrase,
}) => _wrapping(
sodium,
password,
keys,
phrase: phrase,
(salt, unlocked, recoveryKey) => NewKeysMaterial(
setup: protocol.KeysSetup(
salt: ByteData.sublistView(salt),
costs: _costsOf(PasswordKdf.current),
// Copied by proofPublicKey already.
passwordProofKey: ByteData.sublistView(unlocked.proofPublicKey),
encryptionPublicKey: ByteData.sublistView(_own(keys.encryptionPublicKey)),
signingPublicKey: ByteData.sublistView(_own(keys.signingPublicKey)),
passwordKeys: _wrappedOf(
wrapAccountKeys(sodium, keys, unlocked.keyEncryptionKey),
),
recoveryKeys: _wrappedOf(wrapAccountKeys(sodium, keys, recoveryKey)),
// Copied by recoveryProofPublicKeyOf already.
recoveryProofKey: ByteData.sublistView(
recoveryProofPublicKeyOf(sodium, phrase),
),
),
phrase: phrase,
keys: _unlockedOf(keys),
),
);
/// What [make] builds of a new [password] for [keys] (checked, with a new
/// salt) and of [phrase]: the salt, the password's keys and the phrase's,
/// both wiped once it returns.
T _wrapping<T>(
SodiumSumo sodium,
String password,
AccountKeys keys,
T Function(Uint8List salt, PasswordKeys unlocked, SecureKey recoveryKey)
make, {
required String phrase,
}) { }) {
checkNewPassword(password); checkNewPassword(password);
final salt = _own(newPasswordSalt(sodium)); final salt = _own(newPasswordSalt(sodium));
@ -355,27 +537,7 @@ NewAccountMaterial _material(
throw const PasswordFailed(PasswordProblem.notAPhrase); throw const PasswordFailed(PasswordProblem.notAPhrase);
} }
try { try {
return NewAccountMaterial( return make(salt, unlocked, recoveryKey);
setup: protocol.PasswordSetup(
salt: ByteData.sublistView(salt),
costs: _costsOf(PasswordKdf.current),
verifier: _verifierOf(unlocked),
encryptionPublicKey: ByteData.sublistView(
_own(keys.encryptionPublicKey),
),
signingPublicKey: ByteData.sublistView(_own(keys.signingPublicKey)),
passwordKeys: _wrappedOf(
wrapAccountKeys(sodium, keys, unlocked.keyEncryptionKey),
),
recoveryKeys: _wrappedOf(wrapAccountKeys(sodium, keys, recoveryKey)),
// Copied by recoveryProofPublicKeyOf already.
recoveryProofKey: ByteData.sublistView(
recoveryProofPublicKeyOf(sodium, phrase),
),
),
phrase: phrase,
keys: _unlockedOf(keys),
);
} finally { } finally {
unlocked.dispose(); unlocked.dispose();
recoveryKey.dispose(); recoveryKey.dispose();

View file

@ -68,6 +68,23 @@ class FakeAccountCrypto() implements AccountCrypto {
key == 'proof key of ${signed.substring('signed by '.length)}'; key == 'proof key of ${signed.substring('signed by '.length)}';
} }
/// The public half of [password]'s proof key, as the fake makes it.
static ByteData passwordKeyOf(String password) =>
ByteData.sublistView(utf8.encode('password key of $password'));
/// Whether [proof] is signed by the password's proof key whose public
/// half is [passwordKey].
static bool provesPassword(protocol.KeysProof proof, ByteData? passwordKey) {
if (passwordKey == null) {
return false;
}
final signed = utf8.decode(Uint8List.sublistView(proof.signature));
final key = utf8.decode(Uint8List.sublistView(passwordKey));
return signed.startsWith('signed by password ') &&
key ==
'password key of ${signed.substring('signed by password '.length)}';
}
static String _words(String phrase) => static String _words(String phrase) =>
phrase.trim().toLowerCase().split(RegExp(r'\s+')).join(' '); phrase.trim().toLowerCase().split(RegExp(r'\s+')).join(' ');
@ -252,6 +269,60 @@ class FakeAccountCrypto() implements AccountCrypto {
return signatureOf(phrase); return signatureOf(phrase);
} }
@override
Future<NewKeysMaterial> newKeys(
String password, {
UnlockedAccountKeys? keys,
}) async {
_checkNew(password);
final kept = keys ?? keysNumbered(10 * ++_accounts);
return NewKeysMaterial(
setup: protocol.KeysSetup(
salt: ByteData(16),
costs: _costs,
passwordProofKey: passwordKeyOf(password),
encryptionPublicKey: ByteData.sublistView(kept.encryptionPublicKey),
signingPublicKey: ByteData.sublistView(kept.signingPublicKey),
passwordKeys: wrap(password, kept),
recoveryKeys: wrap(fakePhrase, kept),
recoveryProofKey: proofKeyOf(fakePhrase),
),
phrase: fakePhrase,
keys: kept,
);
}
@override
Future<KeysPasswordProof> proveKeysPassword(
String password,
protocol.KeysChallenge challenge, {
required String instanceUrl,
}) async {
final unlock = await this.unlock(
password,
salt: Uint8List.sublistView(challenge.salt),
costs: challenge.costs,
);
return (
proof: protocol.KeysProof(
challenge: challenge.challenge,
signature: ByteData.sublistView(
utf8.encode('signed by password $password'),
),
instanceUrl: instanceUrl,
),
unlock: unlock,
);
}
@override
Future<ByteData> signMendProof(
String phrase,
protocol.KeyProofChallenge challenge, {
required String instanceUrl,
required protocol.UuidValue attempt,
}) => signKeyProof(phrase, challenge, instanceUrl: instanceUrl);
@override @override
Future<NewPhraseMaterial> newPhrase(UnlockedAccountKeys keys) async => ( Future<NewPhraseMaterial> newPhrase(UnlockedAccountKeys keys) async => (
phrase: otherFakePhrase, phrase: otherFakePhrase,

View file

@ -184,4 +184,144 @@ void main() {
); );
}, },
); );
group('an account that signs in with SimpleLogin (ADR 0020, D19)', () {
late NewKeysMaterial keys;
late register.KeysSetup keysSetup;
setUpAll(() async {
keys = await crypto.newKeys(password);
keysSetup = register.KeysSetup.fromJson(keys.setup.toJson());
});
test('its keys are a setup the register takes, keeping the keys an '
"anonymous person's device made", () async {
expect(register.isKeysSetup(keysSetup), isTrue);
final deviceKeys = await crypto.newDeviceKeys();
final kept = await crypto.newKeys(password, keys: deviceKeys);
expect(
register.isKeysSetup(register.KeysSetup.fromJson(kept.setup.toJson())),
isTrue,
);
expect(kept.keys, deviceKeys);
expect(
Uint8List.sublistView(kept.setup.signingPublicKey),
deviceKeys.signingPublicKey,
);
});
test(
"unlocking: the password's proof checks against its own proof key, "
'bound to the instance and to what it is for; the keys the register '
'hands back open with what it unlocked',
() async {
final nonce = Uint8List.fromList(List.filled(32, 9));
final proven = await crypto.proveKeysPassword(
password,
protocol.KeysChallenge(
challenge: challengeId,
nonce: ByteData.sublistView(nonce),
salt: keys.setup.salt,
costs: keys.setup.costs,
),
instanceUrl: instanceUrl,
);
Future<bool> checks(
ByteData publicKey, {
String instance = 'https://learn.halkyon.eu/',
register.KeyProofPurpose purpose =
register.KeyProofPurpose.unlockKeys,
}) => register.isAccountSignature(
register.keyProofMessage(
challengeId,
nonce,
instanceUrl: instance,
purpose: purpose,
),
signature: Uint8List.sublistView(proven.proof.signature),
publicKey: Uint8List.sublistView(publicKey),
);
expect(proven.proof.challenge, challengeId);
expect(proven.proof.instanceUrl, instanceUrl);
expect(await checks(keysSetup.passwordProofKey), isTrue);
expect(await checks(keysSetup.signingPublicKey), isFalse);
expect(await checks(keysSetup.recoveryProofKey), isFalse);
expect(
await checks(
keysSetup.passwordProofKey,
instance: 'https://learn.evil.example/',
),
isFalse,
);
expect(
await checks(
keysSetup.passwordProofKey,
purpose: register.KeyProofPurpose.deleteAccount,
),
isFalse,
);
expect(
await crypto.openKeys(
proven.unlock,
keys.setup.passwordKeys,
encryptionPublicKey: Uint8List.sublistView(
keys.setup.encryptionPublicKey,
),
signingPublicKey: Uint8List.sublistView(
keys.setup.signingPublicKey,
),
),
keys.keys,
);
},
);
test(
"linking again: the phrase's proof checks against the phrase's own "
'key, bound to the sign-in it links',
() async {
final attempt = UuidValue.fromString(
'01994a3c-7b2e-7c00-8a1b-0123456789cd',
);
final nonce = Uint8List.fromList(List.filled(32, 5));
final signature = await crypto.signMendProof(
keys.phrase,
protocol.KeyProofChallenge(
challenge: challengeId,
nonce: ByteData.sublistView(nonce),
encryptionPublicKey: keys.setup.encryptionPublicKey,
signingPublicKey: keys.setup.signingPublicKey,
recoveryKeys: keys.setup.recoveryKeys,
),
instanceUrl: instanceUrl,
attempt: attempt,
);
Future<bool> checks(ByteData publicKey, UuidValue linked) =>
register.isAccountSignature(
register.keyProofMessage(
challengeId,
nonce,
instanceUrl: 'https://learn.halkyon.eu/',
purpose: register.KeyProofPurpose.mendLink,
digest: register.mendLinkDigest(linked),
),
signature: Uint8List.sublistView(signature),
publicKey: Uint8List.sublistView(publicKey),
);
expect(await checks(keysSetup.recoveryProofKey, attempt), isTrue);
expect(await checks(keysSetup.passwordProofKey, attempt), isFalse);
expect(
await checks(
keysSetup.recoveryProofKey,
UuidValue.fromString('01994a3c-7b2e-7c00-8a1b-0123456789ef'),
),
isFalse,
);
},
);
});
} }

View file

@ -47,6 +47,7 @@ export 'src/password/password_config.dart';
export 'src/password/passwords.dart' export 'src/password/passwords.dart'
show show
gitHubSignInFreshFor, gitHubSignInFreshFor,
isKeysSetup,
isNewPassword, isNewPassword,
isPasswordSetup, isPasswordSetup,
newPasswordCosts, newPasswordCosts,