feat(keys): halkyon_keys, an account's keys on the device
ADR 0017's step 1, on libsodium (sodium 4.1.0+1, whose build hook compiles the libsodium 1.0.22 it ships: a C compiler and make wherever it builds): - Argon2id (64 MiB, 3 passes, kept with the account and never below that) makes a master key, which libsodium's KDF splits into SRP's secret (x) and the key-encryption key. - The account's X25519 and Ed25519 key pairs, their private halves wrapped with XChaCha20-Poly1305 under the KEK and under the recovery key, bound to their public keys. - A 24-word BIP-39 recovery phrase (the English list, MIT) and the recovery key derived from it. - Data keys sealed to the account with crypto_box_seal, bound to their IDs; content in ADR 0002's envelope, encrypted with a data key and signed with Ed25519 (detached), bound to a context the caller names. - Secrets in libsodium's guarded memory where its API takes them. - Tests: libsodium's Argon2id known answers, BIP-39's English vectors, round trips and every refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
470a0b633c
commit
3bc0bae6e3
8 changed files with 4495 additions and 0 deletions
23
packages/halkyon_keys/lib/halkyon_keys.dart
Normal file
23
packages/halkyon_keys/lib/halkyon_keys.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/// An account's keys on the device (ADR 0017): what the password unlocks
|
||||
/// (Argon2id, split into SRP's x and the key-encryption key), the account's
|
||||
/// key pairs and their wrapping, the recovery phrase and its key, data keys
|
||||
/// sealed to the account, and content sealed and signed in ADR 0002's
|
||||
/// envelope. libsodium (the `sodium` package, its sumo API for Argon2id)
|
||||
/// does every primitive; nothing here is ever sent but what is wrapped,
|
||||
/// sealed or public.
|
||||
library;
|
||||
|
||||
export 'src/account_keys.dart'
|
||||
show AccountKeys, WrappedAccountKeys, unwrapAccountKeys, wrapAccountKeys;
|
||||
export 'src/data_keys.dart' show DataKey, openDataKey, sealDataKey;
|
||||
export 'src/key_refused.dart';
|
||||
export 'src/password_keys.dart'
|
||||
show PasswordKdf, PasswordKeys, derivePasswordKeys, newPasswordSalt;
|
||||
export 'src/recovery_phrase.dart'
|
||||
show
|
||||
entropyOfRecoveryPhrase,
|
||||
newRecoveryPhrase,
|
||||
recoveryEntropyBytes,
|
||||
recoveryKeyOf,
|
||||
recoveryPhraseOf;
|
||||
export 'src/sealed_content.dart' show SealedContent, openContent, sealContent;
|
||||
173
packages/halkyon_keys/lib/src/account_keys.dart
Normal file
173
packages/halkyon_keys/lib/src/account_keys.dart
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_keys/src/key_refused.dart';
|
||||
import 'package:sodium/sodium_sumo.dart';
|
||||
|
||||
/// An account's key pairs (ADR 0017): X25519 to encrypt to (data keys are
|
||||
/// sealed to it), Ed25519 to sign with (what the account writes carries its
|
||||
/// signature). Made on the device; their private halves leave it only
|
||||
/// wrapped ([wrapAccountKeys]), under the password's key-encryption key and
|
||||
/// again under the recovery key, so the register keeps them and can never
|
||||
/// use them.
|
||||
|
||||
/// What the wrapping binds itself to besides the public keys: a version, so
|
||||
/// a later format can't be mistaken for this one.
|
||||
final _wrappingLabel = utf8.encode('halkyon account keys v1');
|
||||
|
||||
/// The account's two key pairs, their private halves in libsodium's guarded
|
||||
/// memory until [dispose].
|
||||
final class AccountKeys._(
|
||||
/// X25519, to encrypt to.
|
||||
final KeyPair encryption,
|
||||
|
||||
/// Ed25519, to sign with.
|
||||
final KeyPair signing,
|
||||
) {
|
||||
/// New key pairs, made at sign-up (or, for someone anonymous, when the
|
||||
/// device first needs them).
|
||||
factory generate(SodiumSumo sodium) =>
|
||||
AccountKeys._(sodium.crypto.box.keyPair(), sodium.crypto.sign.keyPair());
|
||||
|
||||
/// The public key data keys are sealed to; the register keeps and hands it
|
||||
/// out.
|
||||
Uint8List get encryptionPublicKey => encryption.publicKey;
|
||||
|
||||
/// The public key the account's signatures check against.
|
||||
Uint8List get signingPublicKey => signing.publicKey;
|
||||
|
||||
/// Wipes and frees both private keys, which may not be read afterwards.
|
||||
void dispose() {
|
||||
encryption.dispose();
|
||||
signing.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// An account's private keys sealed under one key (the password's
|
||||
/// key-encryption key, or the recovery key): what the register keeps.
|
||||
final class const WrappedAccountKeys({
|
||||
/// XChaCha20-Poly1305's nonce, random.
|
||||
required final Uint8List nonce,
|
||||
|
||||
/// Both private keys, encrypted and authenticated.
|
||||
required final Uint8List cipherText,
|
||||
}) {
|
||||
/// As [toJson] wrote it; a [FormatException] otherwise.
|
||||
factory fromJson(Map<String, Object?> json) => switch (json) {
|
||||
{'nonce': final String nonce, 'cipherText': final String cipherText} =>
|
||||
WrappedAccountKeys(
|
||||
nonce: base64Decode(nonce),
|
||||
cipherText: base64Decode(cipherText),
|
||||
),
|
||||
_ => throw const FormatException('Not wrapped account keys'),
|
||||
};
|
||||
|
||||
/// As kept with the account.
|
||||
Map<String, String> toJson() => {
|
||||
'nonce': base64Encode(nonce),
|
||||
'cipherText': base64Encode(cipherText),
|
||||
};
|
||||
}
|
||||
|
||||
/// [keys]' private halves sealed under [wrappingKey] (XChaCha20-Poly1305,
|
||||
/// a random nonce), bound to their public keys, so they open only as these
|
||||
/// keys of this account.
|
||||
WrappedAccountKeys wrapAccountKeys(
|
||||
SodiumSumo sodium,
|
||||
AccountKeys keys,
|
||||
SecureKey wrappingKey,
|
||||
) {
|
||||
final aead = sodium.crypto.aeadXChaCha20Poly1305IETF;
|
||||
final nonce = sodium.randombytes.buf(aead.nonceBytes);
|
||||
// libsodium encrypts a plain buffer: the private keys are copied out of
|
||||
// guarded memory for this moment, and the copy wiped right after.
|
||||
final plainText = Uint8List.fromList([
|
||||
...keys.encryption.secretKey.extractBytes(),
|
||||
...keys.signing.secretKey.extractBytes(),
|
||||
]);
|
||||
try {
|
||||
return WrappedAccountKeys(
|
||||
nonce: nonce,
|
||||
cipherText: aead.encrypt(
|
||||
message: plainText,
|
||||
nonce: nonce,
|
||||
key: wrappingKey,
|
||||
additionalData: _boundTo(
|
||||
keys.encryptionPublicKey,
|
||||
keys.signingPublicKey,
|
||||
),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
plainText.fillRange(0, plainText.length, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The account's keys [wrapped] holds, opened with [wrappingKey]; refused
|
||||
/// ([KeyRefused]) for the wrong key, anything changed, or keys that aren't
|
||||
/// those of the public keys given (the register's record of the account).
|
||||
AccountKeys unwrapAccountKeys(
|
||||
SodiumSumo sodium,
|
||||
WrappedAccountKeys wrapped,
|
||||
SecureKey wrappingKey, {
|
||||
required Uint8List encryptionPublicKey,
|
||||
required Uint8List signingPublicKey,
|
||||
}) {
|
||||
if (encryptionPublicKey.length != sodium.crypto.box.publicKeyBytes ||
|
||||
signingPublicKey.length != sodium.crypto.sign.publicKeyBytes) {
|
||||
throw const KeyRefused(KeyRefusal.notTheAccountsKeys);
|
||||
}
|
||||
final Uint8List plainText;
|
||||
try {
|
||||
plainText = sodium.crypto.aeadXChaCha20Poly1305IETF.decrypt(
|
||||
cipherText: wrapped.cipherText,
|
||||
nonce: wrapped.nonce,
|
||||
key: wrappingKey,
|
||||
additionalData: _boundTo(encryptionPublicKey, signingPublicKey),
|
||||
);
|
||||
} on SodiumException {
|
||||
throw const KeyRefused(KeyRefusal.wrongKeyOrTampered);
|
||||
}
|
||||
final boxSecretBytes = sodium.crypto.box.secretKeyBytes;
|
||||
final signSecretBytes = sodium.crypto.sign.secretKeyBytes;
|
||||
try {
|
||||
if (plainText.length != boxSecretBytes + signSecretBytes) {
|
||||
throw const KeyRefused(KeyRefusal.notTheAccountsKeys);
|
||||
}
|
||||
final encryptionSecret = sodium.secureCopy(
|
||||
Uint8List.sublistView(plainText, 0, boxSecretBytes),
|
||||
);
|
||||
final signingSecret = sodium.secureCopy(
|
||||
Uint8List.sublistView(plainText, boxSecretBytes),
|
||||
);
|
||||
final keys = AccountKeys._(
|
||||
KeyPair(publicKey: encryptionPublicKey, secretKey: encryptionSecret),
|
||||
KeyPair(publicKey: signingPublicKey, secretKey: signingSecret),
|
||||
);
|
||||
// The authenticated data binds the public keys already; checking them
|
||||
// against the private halves as well costs nothing and catches a
|
||||
// wrapping made from mismatched keys.
|
||||
final derivedEncryption = sodium.crypto.scalarmult.base(
|
||||
n: encryptionSecret,
|
||||
);
|
||||
final derivedSigning = Uint8List.sublistView(
|
||||
plainText,
|
||||
plainText.length - sodium.crypto.sign.publicKeyBytes,
|
||||
);
|
||||
if (!sodium.memcmp(derivedEncryption, encryptionPublicKey) ||
|
||||
!sodium.memcmp(derivedSigning, signingPublicKey)) {
|
||||
keys.dispose();
|
||||
throw const KeyRefused(KeyRefusal.notTheAccountsKeys);
|
||||
}
|
||||
return keys;
|
||||
} finally {
|
||||
plainText.fillRange(0, plainText.length, 0);
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List _boundTo(Uint8List encryptionPublicKey, Uint8List signingPublicKey) =>
|
||||
Uint8List.fromList([
|
||||
..._wrappingLabel,
|
||||
...encryptionPublicKey,
|
||||
...signingPublicKey,
|
||||
]);
|
||||
2056
packages/halkyon_keys/lib/src/bip39_english.dart
Normal file
2056
packages/halkyon_keys/lib/src/bip39_english.dart
Normal file
File diff suppressed because it is too large
Load diff
106
packages/halkyon_keys/lib/src/data_keys.dart
Normal file
106
packages/halkyon_keys/lib/src/data_keys.dart
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_keys/src/account_keys.dart';
|
||||
import 'package:halkyon_keys/src/key_refused.dart';
|
||||
import 'package:sodium/sodium_sumo.dart';
|
||||
|
||||
/// Data keys (ADR 0017): a random key per store (Todo's agenda, a private
|
||||
/// studyset), which encrypts its content (`sealContent`) and is itself
|
||||
/// sealed to the account's public key, so every device that opens the
|
||||
/// account opens the store. Sharing a store later seals its key to the other
|
||||
/// person's key too.
|
||||
|
||||
/// A store's key, in libsodium's guarded memory until [dispose].
|
||||
final class DataKey._(
|
||||
/// Its ID, which the content it seals names (ADR 0002's `keyId`).
|
||||
final String keyId,
|
||||
|
||||
/// The key itself (XChaCha20-Poly1305's 256 bits).
|
||||
final SecureKey key,
|
||||
) {
|
||||
/// A new key named [keyId].
|
||||
factory generate(SodiumSumo sodium, String keyId) => DataKey._(
|
||||
_checkedId(keyId),
|
||||
sodium.crypto.aeadXChaCha20Poly1305IETF.keygen(),
|
||||
);
|
||||
|
||||
/// Wipes and frees the key, which may not be read afterwards.
|
||||
void dispose() => key.dispose();
|
||||
}
|
||||
|
||||
/// [dataKey] sealed to the account whose [encryptionPublicKey] it is
|
||||
/// (libsodium's sealed box: X25519 and XSalsa20-Poly1305, anonymous), its ID
|
||||
/// sealed with it, so it opens only as the key it was.
|
||||
Uint8List sealDataKey(
|
||||
SodiumSumo sodium,
|
||||
DataKey dataKey,
|
||||
Uint8List encryptionPublicKey,
|
||||
) {
|
||||
final id = utf8.encode(dataKey.keyId);
|
||||
// As account keys are wrapped, the key leaves guarded memory for this
|
||||
// moment only.
|
||||
final message = Uint8List.fromList([
|
||||
id.length,
|
||||
...id,
|
||||
...dataKey.key.extractBytes(),
|
||||
]);
|
||||
try {
|
||||
return sodium.crypto.box.seal(
|
||||
message: message,
|
||||
publicKey: encryptionPublicKey,
|
||||
);
|
||||
} finally {
|
||||
message.fillRange(0, message.length, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The data key named [keyId] that [sealed] holds, opened with the
|
||||
/// account's [keys]; refused ([KeyRefused]) for another account's keys,
|
||||
/// anything changed, or a key sealed under another ID.
|
||||
DataKey openDataKey(
|
||||
SodiumSumo sodium,
|
||||
Uint8List sealed, {
|
||||
required String keyId,
|
||||
required AccountKeys keys,
|
||||
}) {
|
||||
final Uint8List message;
|
||||
try {
|
||||
message = sodium.crypto.box.sealOpen(
|
||||
cipherText: sealed,
|
||||
publicKey: keys.encryptionPublicKey,
|
||||
secretKey: keys.encryption.secretKey,
|
||||
);
|
||||
} on SodiumException {
|
||||
throw const KeyRefused(KeyRefusal.wrongKeyOrTampered);
|
||||
}
|
||||
try {
|
||||
final idLength = message.isEmpty ? -1 : message.first;
|
||||
final keyBytes = sodium.crypto.aeadXChaCha20Poly1305IETF.keyBytes;
|
||||
if (idLength < 0 || message.length != 1 + idLength + keyBytes) {
|
||||
throw const KeyRefused(KeyRefusal.wrongKeyOrTampered);
|
||||
}
|
||||
// The ID's bytes compared as sealed, not decoded: no two IDs match.
|
||||
final sealedId = Uint8List.sublistView(message, 1, 1 + idLength);
|
||||
final id = utf8.encode(keyId);
|
||||
if (sealedId.length != id.length || !sodium.memcmp(sealedId, id)) {
|
||||
throw const KeyRefused(KeyRefusal.wrongDataKey);
|
||||
}
|
||||
return DataKey._(
|
||||
keyId,
|
||||
sodium.secureCopy(Uint8List.sublistView(message, 1 + idLength)),
|
||||
);
|
||||
} finally {
|
||||
message.fillRange(0, message.length, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// [keyId] if it is one: 1 to 255 bytes of UTF-8, as a sealed key carries
|
||||
/// its ID's length in one byte.
|
||||
String _checkedId(String keyId) {
|
||||
final length = utf8.encode(keyId).length;
|
||||
if (length == 0 || length > 255) {
|
||||
throw ArgumentError.value(keyId, 'keyId', 'must be 1 to 255 bytes');
|
||||
}
|
||||
return keyId;
|
||||
}
|
||||
43
packages/halkyon_keys/lib/src/key_refused.dart
Normal file
43
packages/halkyon_keys/lib/src/key_refused.dart
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/// Why something to do with an account's keys was refused: thrown, as
|
||||
/// nothing that follows may go on with a key that didn't open or a text
|
||||
/// that didn't check.
|
||||
final class const KeyRefused(
|
||||
/// Why.
|
||||
final KeyRefusal reason,
|
||||
) implements Exception {
|
||||
@override
|
||||
String toString() => 'KeyRefused(${reason.name})';
|
||||
}
|
||||
|
||||
/// Why an account's keys or what they protect were refused.
|
||||
enum KeyRefusal() {
|
||||
/// What was to be opened didn't open: the wrong key (a wrong password, a
|
||||
/// wrong recovery phrase, another account's), or it was changed since it
|
||||
/// was sealed. The two can't be told apart, by design.
|
||||
wrongKeyOrTampered,
|
||||
|
||||
/// The private keys opened, but aren't those of the public keys the
|
||||
/// account has.
|
||||
notTheAccountsKeys,
|
||||
|
||||
/// Content sealed with one data key was opened with another.
|
||||
wrongDataKey,
|
||||
|
||||
/// The author's signature doesn't check: the content was changed, or
|
||||
/// isn't the author's.
|
||||
forgedSignature,
|
||||
|
||||
/// Argon2id's costs named for an account are below what any account may
|
||||
/// have, or of another algorithm.
|
||||
weakPasswordKdf,
|
||||
|
||||
/// A recovery phrase of another length than BIP-39's.
|
||||
phraseLength,
|
||||
|
||||
/// A word of a recovery phrase isn't on BIP-39's English list.
|
||||
phraseWord,
|
||||
|
||||
/// A recovery phrase's checksum doesn't match: a word mistyped or out of
|
||||
/// order.
|
||||
phraseChecksum,
|
||||
}
|
||||
139
packages/halkyon_keys/lib/src/password_keys.dart
Normal file
139
packages/halkyon_keys/lib/src/password_keys.dart
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_keys/src/key_refused.dart';
|
||||
import 'package:sodium/sodium_sumo.dart';
|
||||
|
||||
/// The password's keys (ADR 0017): Argon2id over the password and the
|
||||
/// account's salt makes one master key, which libsodium's KDF splits into
|
||||
/// SRP's secret (from which x is made, the one value SRP takes in place of
|
||||
/// the password) and the key-encryption key that wraps the account's private
|
||||
/// keys. The password, the master key and both halves never leave the
|
||||
/// device.
|
||||
|
||||
/// libsodium's KDF context for the password's subkeys (exactly 8 bytes).
|
||||
const _passwordContext = 'hlkpass1';
|
||||
|
||||
/// The subkey that becomes SRP's x.
|
||||
final _srpSubkey = BigInt.one;
|
||||
|
||||
/// The subkey that wraps the account's private keys.
|
||||
final _keyEncryptionSubkey = BigInt.two;
|
||||
|
||||
/// How long each derived key is.
|
||||
const _keyBytes = 32;
|
||||
|
||||
/// How costly Argon2id is for an account: kept with the account (beside its
|
||||
/// salt), so the costs can be raised for new passwords without locking
|
||||
/// anyone out.
|
||||
final class const PasswordKdf({
|
||||
/// Argon2id's passes.
|
||||
required final int opsLimit,
|
||||
|
||||
/// Argon2id's memory, in bytes.
|
||||
required final int memLimit,
|
||||
}) {
|
||||
/// The costs [json] names; refused ([KeyRefused]) for another algorithm or
|
||||
/// costs below [minimum].
|
||||
factory fromJson(Map<String, Object?> json) => switch (json) {
|
||||
{
|
||||
'algorithm': 'argon2id13',
|
||||
'opsLimit': final int opsLimit,
|
||||
'memLimit': final int memLimit,
|
||||
}
|
||||
when opsLimit >= minimum.opsLimit && memLimit >= minimum.memLimit =>
|
||||
PasswordKdf(opsLimit: opsLimit, memLimit: memLimit),
|
||||
_ => throw const KeyRefused(KeyRefusal.weakPasswordKdf),
|
||||
};
|
||||
|
||||
/// The floor no account's costs may go below: a register sending lower
|
||||
/// costs would make every guess cheaper. Raised only once no account is
|
||||
/// below it any more, or those accounts no longer open.
|
||||
static const minimum = PasswordKdf(opsLimit: 3, memLimit: 64 * 1024 * 1024);
|
||||
|
||||
/// What new passwords take: 64 MiB and 3 passes, a second or so on a
|
||||
/// phone. Raising it leaves accounts made before at their own costs, at
|
||||
/// or above [minimum].
|
||||
static const current = minimum;
|
||||
|
||||
/// The costs as kept with the account.
|
||||
Map<String, Object> toJson() => {
|
||||
'algorithm': 'argon2id13',
|
||||
'opsLimit': opsLimit,
|
||||
'memLimit': memLimit,
|
||||
};
|
||||
}
|
||||
|
||||
/// A new salt for an account's password: libsodium's 16 random bytes.
|
||||
Uint8List newPasswordSalt(SodiumSumo sodium) =>
|
||||
sodium.randombytes.buf(sodium.crypto.pwhash.saltBytes);
|
||||
|
||||
/// What the password unlocks, held in libsodium's guarded memory until
|
||||
/// [dispose].
|
||||
final class PasswordKeys._(
|
||||
/// The subkey SRP's x is made of ([srpPrivateValue]).
|
||||
final SecureKey srpSecret,
|
||||
|
||||
/// The key that wraps the account's private keys.
|
||||
final SecureKey keyEncryptionKey,
|
||||
) {
|
||||
/// The number x SRP takes in place of the password: the SRP subkey's 256
|
||||
/// bits, big-endian. It is the password's Argon2id in all but name, so a
|
||||
/// verifier made from it costs an Argon2id per guess.
|
||||
BigInt get srpPrivateValue => srpSecret.runUnlockedSync((bytes) {
|
||||
var value = BigInt.zero;
|
||||
for (final byte in bytes) {
|
||||
value = (value << 8) | BigInt.from(byte);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
|
||||
/// Wipes and frees both keys; neither may be read afterwards (libsodium
|
||||
/// has freed their memory).
|
||||
void dispose() {
|
||||
srpSecret.dispose();
|
||||
keyEncryptionKey.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys [password] unlocks for the account with [salt], at the costs
|
||||
/// [kdf] names (the account's own; [PasswordKdf.current] for a new one).
|
||||
PasswordKeys derivePasswordKeys(
|
||||
SodiumSumo sodium, {
|
||||
required String password,
|
||||
required Uint8List salt,
|
||||
PasswordKdf kdf = PasswordKdf.current,
|
||||
}) {
|
||||
if (salt.length != sodium.crypto.pwhash.saltBytes) {
|
||||
throw ArgumentError.value(
|
||||
salt.length,
|
||||
'salt',
|
||||
'must be ${sodium.crypto.pwhash.saltBytes} bytes',
|
||||
);
|
||||
}
|
||||
final masterKey = sodium.crypto.pwhash.callStr(
|
||||
outLen: _keyBytes,
|
||||
password: password,
|
||||
salt: salt,
|
||||
opsLimit: kdf.opsLimit,
|
||||
memLimit: kdf.memLimit,
|
||||
alg: CryptoPwhashAlgorithm.argon2id13,
|
||||
);
|
||||
try {
|
||||
return PasswordKeys._(
|
||||
sodium.crypto.kdf.deriveFromKey(
|
||||
masterKey: masterKey,
|
||||
context: _passwordContext,
|
||||
subkeyId: _srpSubkey,
|
||||
subkeyLen: _keyBytes,
|
||||
),
|
||||
sodium.crypto.kdf.deriveFromKey(
|
||||
masterKey: masterKey,
|
||||
context: _passwordContext,
|
||||
subkeyId: _keyEncryptionSubkey,
|
||||
subkeyLen: _keyBytes,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
masterKey.dispose();
|
||||
}
|
||||
}
|
||||
153
packages/halkyon_keys/lib/src/recovery_phrase.dart
Normal file
153
packages/halkyon_keys/lib/src/recovery_phrase.dart
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_keys/src/bip39_english.dart';
|
||||
import 'package:halkyon_keys/src/key_refused.dart';
|
||||
import 'package:sodium/sodium_sumo.dart';
|
||||
|
||||
/// The recovery phrase (ADR 0017): 24 words of BIP-39's English list for
|
||||
/// 256 random bits and an 8-bit checksum, written down by the person at
|
||||
/// sign-up. Its bits make the recovery key, which wraps the account's
|
||||
/// private keys a second time: with the phrase, a lost password keeps
|
||||
/// everything. The phrase never leaves the device it is typed into.
|
||||
|
||||
/// libsodium's KDF context for the recovery key (exactly 8 bytes).
|
||||
const _recoveryContext = 'hlkrcvy1';
|
||||
|
||||
/// How many random bytes a new phrase stands for: 256 bits, 24 words.
|
||||
const recoveryEntropyBytes = 32;
|
||||
|
||||
/// A new phrase from [recoveryEntropyBytes] random bytes.
|
||||
String newRecoveryPhrase(SodiumSumo sodium) {
|
||||
final entropy = sodium.randombytes.buf(recoveryEntropyBytes);
|
||||
try {
|
||||
return recoveryPhraseOf(entropy);
|
||||
} finally {
|
||||
entropy.fillRange(0, entropy.length, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// BIP-39's mnemonic for [entropy] (16 to 32 bytes, a multiple of 4): the
|
||||
/// entropy, then the first entropy-bits ÷ 32 bits of its SHA-256, read as
|
||||
/// 11-bit indexes into the English list.
|
||||
String recoveryPhraseOf(List<int> entropy) {
|
||||
if (entropy.length < 16 || entropy.length > 32 || entropy.length % 4 != 0) {
|
||||
throw ArgumentError.value(
|
||||
entropy.length,
|
||||
'entropy',
|
||||
'must be 16 to 32 bytes, a multiple of 4',
|
||||
);
|
||||
}
|
||||
final checksumBits = entropy.length * 8 ~/ 32;
|
||||
final bits = [
|
||||
..._bitsOf(entropy),
|
||||
..._bitsOf(sha256.convert(entropy).bytes).take(checksumBits),
|
||||
];
|
||||
return [
|
||||
for (var i = 0; i < bits.length; i += 11)
|
||||
bip39English[bits
|
||||
.sublist(i, i + 11)
|
||||
.fold(0, (index, bit) => (index << 1) | bit)],
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/// The entropy [phrase] stands for, its checksum checked; refused
|
||||
/// ([KeyRefused]) for a length BIP-39 doesn't have, a word not on the
|
||||
/// English list, or a checksum that doesn't match. Case and the spaces
|
||||
/// between words don't matter.
|
||||
Uint8List entropyOfRecoveryPhrase(String phrase) {
|
||||
final words = phrase.trim().toLowerCase().split(RegExp(r'\s+'));
|
||||
if (!const {12, 15, 18, 21, 24}.contains(words.length)) {
|
||||
throw const KeyRefused(KeyRefusal.phraseLength);
|
||||
}
|
||||
final bits = <int>[];
|
||||
for (final word in words) {
|
||||
final index = _indexOf(word);
|
||||
if (index == null) {
|
||||
throw const KeyRefused(KeyRefusal.phraseWord);
|
||||
}
|
||||
for (var shift = 10; shift >= 0; shift--) {
|
||||
bits.add((index >> shift) & 1);
|
||||
}
|
||||
}
|
||||
final checksumBits = bits.length ~/ 33;
|
||||
final entropyBits = bits.length - checksumBits;
|
||||
final entropy = Uint8List(entropyBits ~/ 8);
|
||||
for (var i = 0; i < entropy.length; i++) {
|
||||
entropy[i] = bits
|
||||
.sublist(i * 8, i * 8 + 8)
|
||||
.fold(0, (byte, bit) => (byte << 1) | bit);
|
||||
}
|
||||
final expected = _bitsOf(sha256.convert(entropy).bytes).take(checksumBits);
|
||||
if (!_sameBits(bits.sublist(entropyBits), expected.toList())) {
|
||||
entropy.fillRange(0, entropy.length, 0);
|
||||
throw const KeyRefused(KeyRefusal.phraseChecksum);
|
||||
}
|
||||
return entropy;
|
||||
}
|
||||
|
||||
/// The recovery key [phrase] stands for: its 256 random bits through
|
||||
/// libsodium's KDF, in guarded memory. Refused as
|
||||
/// [entropyOfRecoveryPhrase] refuses, and for a phrase of fewer than 256
|
||||
/// bits (recovery phrases are 24 words).
|
||||
SecureKey recoveryKeyOf(SodiumSumo sodium, String phrase) {
|
||||
final entropy = entropyOfRecoveryPhrase(phrase);
|
||||
try {
|
||||
if (entropy.length != recoveryEntropyBytes) {
|
||||
throw const KeyRefused(KeyRefusal.phraseLength);
|
||||
}
|
||||
final master = sodium.secureCopy(entropy);
|
||||
try {
|
||||
return sodium.crypto.kdf.deriveFromKey(
|
||||
masterKey: master,
|
||||
context: _recoveryContext,
|
||||
subkeyId: BigInt.one,
|
||||
subkeyLen: 32,
|
||||
);
|
||||
} finally {
|
||||
master.dispose();
|
||||
}
|
||||
} finally {
|
||||
entropy.fillRange(0, entropy.length, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// [bytes] as bits, most significant first.
|
||||
Iterable<int> _bitsOf(List<int> bytes) sync* {
|
||||
for (final byte in bytes) {
|
||||
for (var shift = 7; shift >= 0; shift--) {
|
||||
yield (byte >> shift) & 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _sameBits(List<int> a, List<int> b) {
|
||||
if (a.length != b.length) {
|
||||
return false;
|
||||
}
|
||||
var difference = 0;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
difference |= a[i] ^ b[i];
|
||||
}
|
||||
return difference == 0;
|
||||
}
|
||||
|
||||
/// [word]'s index in the English list (sorted, so found by halving), or
|
||||
/// null.
|
||||
int? _indexOf(String word) {
|
||||
var low = 0;
|
||||
var high = bip39English.length - 1;
|
||||
while (low <= high) {
|
||||
final middle = (low + high) ~/ 2;
|
||||
final order = bip39English[middle].compareTo(word);
|
||||
if (order == 0) {
|
||||
return middle;
|
||||
}
|
||||
if (order < 0) {
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
123
packages/halkyon_keys/lib/src/sealed_content.dart
Normal file
123
packages/halkyon_keys/lib/src/sealed_content.dart
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_keys/src/account_keys.dart';
|
||||
import 'package:halkyon_keys/src/data_keys.dart';
|
||||
import 'package:halkyon_keys/src/key_refused.dart';
|
||||
import 'package:sodium/sodium_sumo.dart';
|
||||
|
||||
/// Content sealed for the people who hold a store's data key (ADR 0017), in
|
||||
/// ADR 0002's envelope: the key's ID, a random nonce, and the encrypted
|
||||
/// payload (XChaCha20-Poly1305), with its author's Ed25519 signature beside.
|
||||
///
|
||||
/// The authenticated data and the signature both take a `context` the
|
||||
/// caller names: what the content is (its table and row, say), so a server
|
||||
/// can't move one row's sealed content into another's place unnoticed.
|
||||
|
||||
/// The version both the authenticated data and the signature start with.
|
||||
final _contentLabel = utf8.encode('halkyon content v1');
|
||||
|
||||
/// What a store's content becomes: ADR 0002's `keyId`, `nonce` and
|
||||
/// `payload`, and the author's [signature].
|
||||
final class const SealedContent({
|
||||
/// The data key's ID.
|
||||
required final String keyId,
|
||||
|
||||
/// XChaCha20-Poly1305's nonce, random for every sealing.
|
||||
required final Uint8List nonce,
|
||||
|
||||
/// The encrypted content.
|
||||
required final Uint8List payload,
|
||||
|
||||
/// The author's detached Ed25519 signature over all of it.
|
||||
required final Uint8List signature,
|
||||
});
|
||||
|
||||
/// [plainText] sealed with [dataKey], signed by [author], for the
|
||||
/// [context] it is stored in.
|
||||
SealedContent sealContent(
|
||||
SodiumSumo sodium, {
|
||||
required Uint8List plainText,
|
||||
required DataKey dataKey,
|
||||
required AccountKeys author,
|
||||
required List<int> context,
|
||||
}) {
|
||||
final aead = sodium.crypto.aeadXChaCha20Poly1305IETF;
|
||||
final nonce = sodium.randombytes.buf(aead.nonceBytes);
|
||||
final payload = aead.encrypt(
|
||||
message: plainText,
|
||||
nonce: nonce,
|
||||
key: dataKey.key,
|
||||
additionalData: _framed([utf8.encode(dataKey.keyId), context]),
|
||||
);
|
||||
return SealedContent(
|
||||
keyId: dataKey.keyId,
|
||||
nonce: nonce,
|
||||
payload: payload,
|
||||
signature: sodium.crypto.sign.detached(
|
||||
message: _signed(dataKey.keyId, nonce, payload, context),
|
||||
secretKey: author.signing.secretKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// What [sealed] holds, its signature checked against the author's
|
||||
/// [authorSigningPublicKey] first, then opened with [dataKey] for the
|
||||
/// [context] it is stored in; refused ([KeyRefused]) for another data key,
|
||||
/// a signature that doesn't check (changed, or someone else's), or content
|
||||
/// that doesn't open.
|
||||
Uint8List openContent(
|
||||
SodiumSumo sodium,
|
||||
SealedContent sealed, {
|
||||
required DataKey dataKey,
|
||||
required Uint8List authorSigningPublicKey,
|
||||
required List<int> context,
|
||||
}) {
|
||||
if (sealed.keyId != dataKey.keyId) {
|
||||
throw const KeyRefused(KeyRefusal.wrongDataKey);
|
||||
}
|
||||
if (sealed.signature.length != sodium.crypto.sign.bytes ||
|
||||
authorSigningPublicKey.length != sodium.crypto.sign.publicKeyBytes ||
|
||||
!sodium.crypto.sign.verifyDetached(
|
||||
message: _signed(sealed.keyId, sealed.nonce, sealed.payload, context),
|
||||
signature: sealed.signature,
|
||||
publicKey: authorSigningPublicKey,
|
||||
)) {
|
||||
throw const KeyRefused(KeyRefusal.forgedSignature);
|
||||
}
|
||||
final aead = sodium.crypto.aeadXChaCha20Poly1305IETF;
|
||||
if (sealed.nonce.length != aead.nonceBytes) {
|
||||
throw const KeyRefused(KeyRefusal.wrongKeyOrTampered);
|
||||
}
|
||||
try {
|
||||
return aead.decrypt(
|
||||
cipherText: sealed.payload,
|
||||
nonce: sealed.nonce,
|
||||
key: dataKey.key,
|
||||
additionalData: _framed([utf8.encode(sealed.keyId), context]),
|
||||
);
|
||||
} on SodiumException {
|
||||
throw const KeyRefused(KeyRefusal.wrongKeyOrTampered);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the author signs: the label, then the key's ID, the nonce, the
|
||||
/// payload and the context, each with its length.
|
||||
Uint8List _signed(
|
||||
String keyId,
|
||||
Uint8List nonce,
|
||||
Uint8List payload,
|
||||
List<int> context,
|
||||
) => _framed([utf8.encode(keyId), nonce, payload, context]);
|
||||
|
||||
/// [parts] after the label, each led by its length in four bytes, so no two
|
||||
/// sequences of parts frame alike.
|
||||
Uint8List _framed(List<List<int>> parts) {
|
||||
final out = BytesBuilder(copy: false)..add(_contentLabel);
|
||||
for (final part in parts) {
|
||||
out
|
||||
..add((ByteData(4)..setUint32(0, part.length)).buffer.asUint8List())
|
||||
..add(part);
|
||||
}
|
||||
return out.toBytes();
|
||||
}
|
||||
Loading…
Reference in a new issue