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:
parent
3cb2000927
commit
c9fedce258
5 changed files with 442 additions and 21 deletions
|
|
@ -77,6 +77,24 @@ final class const 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
|
||||
/// 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
|
||||
|
|
@ -184,4 +202,33 @@ abstract interface class AccountCrypto() {
|
|||
/// A new recovery phrase for [keys]: the keys wrapped under it, and the
|
||||
/// public half of its proof key.
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Future<NewPhraseMaterial> newPhrase(UnlockedAccountKeys keys) => Isolate.run(
|
||||
() => _guarded(() async {
|
||||
|
|
@ -343,6 +454,77 @@ NewAccountMaterial _material(
|
|||
String password,
|
||||
AccountKeys keys, {
|
||||
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);
|
||||
final salt = _own(newPasswordSalt(sodium));
|
||||
|
|
@ -355,27 +537,7 @@ NewAccountMaterial _material(
|
|||
throw const PasswordFailed(PasswordProblem.notAPhrase);
|
||||
}
|
||||
try {
|
||||
return 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),
|
||||
);
|
||||
return make(salt, unlocked, recoveryKey);
|
||||
} finally {
|
||||
unlocked.dispose();
|
||||
recoveryKey.dispose();
|
||||
|
|
|
|||
|
|
@ -68,6 +68,23 @@ class FakeAccountCrypto() implements AccountCrypto {
|
|||
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) =>
|
||||
phrase.trim().toLowerCase().split(RegExp(r'\s+')).join(' ');
|
||||
|
||||
|
|
@ -252,6 +269,60 @@ class FakeAccountCrypto() implements AccountCrypto {
|
|||
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
|
||||
Future<NewPhraseMaterial> newPhrase(UnlockedAccountKeys keys) async => (
|
||||
phrase: otherFakePhrase,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export 'src/password/password_config.dart';
|
|||
export 'src/password/passwords.dart'
|
||||
show
|
||||
gitHubSignInFreshFor,
|
||||
isKeysSetup,
|
||||
isNewPassword,
|
||||
isPasswordSetup,
|
||||
newPasswordCosts,
|
||||
|
|
|
|||
Loading…
Reference in a new issue