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:
Tizian 「ティツィアーン」 2026-10-20 12:01:52 +02:00
commit 3bc0bae6e3
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 4495 additions and 0 deletions

View 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;

View 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,
]);

File diff suppressed because it is too large Load diff

View 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;
}

View 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,
}

View 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();
}
}

View 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;
}

View 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();
}