| Filename | Latest commit message | Latest commit date |
|---|---|---|
Step 3 of ADR 0044 (D3, D18, D19). A shared space's data keys are sealed on a device to each member's public keys; the instance keeps what devices sealed and never opens it. - halkyon_keys: the safety code of two accounts' public keys (`safetyCode`), 60 digits, the same on both sides. - halkyon_base: the register hands out accounts' public keys (`public-keys-of`, local and remote); the table `shared_space_key` and the endpoint `sharedSpaceKeys` (memberKeys, keysNeeded, seal, mine). Only the Owner and Editors seal, the first key sealed to a member stays, and a member's keys go with their membership. Every host's migration creates the table. - halkyon_app: `ContentCipher` seals a key to another account and opens one sealed by another; `SharedKeys` shares a space's key with its members, seals keys to newcomers and opens the person's own, holding each member to the public keys `KeyPins` pinned on first sight (`key_pins.json` in the person's folder), a change refused until trusted anew. Learn takes them into its `Privacy` in step 4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me> |
||
| .. | ||
| lib | ||
| test | ||
| .gitignore | ||
| analysis_options.yaml | ||
| LICENSE | ||
| pubspec.yaml | ||
| README.md | ||
halkyon_keys
An account's keys on the device (ADR 0017), in pure Dart on libsodium (the sodium
package, its sumo API). Every primitive is libsodium's; this package only puts them
together. Callers pass a SodiumSumo from await SodiumSumoInit.init().
sodium's build hook compiles the libsodium it ships (1.0.22), so every machine that
builds or tests a package depending on this one needs a C compiler and make.
What it provides
| Piece | API | Parameters |
|---|---|---|
| The password's keys | newPasswordSalt, derivePasswordKeys → PasswordKeys (srpSecret, srpPrivateValue, keyEncryptionKey, proofKeyPair, proofPublicKey) |
Argon2id v1.3, 64 MiB, 3 passes (PasswordKdf.current), a 16-byte salt, a 32-byte master key; libsodium's KDF (context hlkpass1) splits it into SRP's secret (subkey 1, whose 256 bits big-endian are SRP's x), the key-encryption key (subkey 2) and the seed of the password's Ed25519 proof key (subkey 3, Halkyon Learn's ADR 0020, D19), with which an account that keeps no SRP verifier proves the password |
| The password as typed | normalizePassword (PasswordRefused) |
RFC 8265's OpaqueString: other spaces to U+0020, Unicode NFC (unorm_dart, pinned exactly), empty or control characters refused; derivePasswordKeys applies it, so composed and decomposed accents unlock alike |
| The costs kept with an account | PasswordKdf (toJson, fromJson) |
kept beside the salt, so they can be raised for new passwords; fromJson refuses another algorithm or costs below PasswordKdf.minimum, kept apart from the current costs so raising those never locks out an older account |
| The account's key pairs | AccountKeys.generate |
X25519 to encrypt to, Ed25519 to sign with |
| Their wrapping | wrapAccountKeys, unwrapAccountKeys → WrappedAccountKeys (nonce, cipherText) |
XChaCha20-Poly1305, a random 24-byte nonce, the private halves (32 + 64 bytes) as plaintext, both public keys and a version label as additional data; unwrapping also rebuilds both public keys from the private halves and compares them with those given |
| The recovery phrase | newRecoveryPhrase, recoveryPhraseOf, entropyOfRecoveryPhrase |
BIP-39, English list: 24 words for 256 random bits and an 8-bit checksum; case and spacing as typed don't count |
| The recovery key | recoveryKeyOf |
libsodium's KDF over the phrase's 256 bits (context hlkrcvy1, subkey 1); a 12- to 21-word BIP-39 phrase is refused |
| Data keys | DataKey.generate, sealDataKey, openDataKey |
a random XChaCha20-Poly1305 key per store with an ID (1 to 255 bytes of UTF-8), sealed with its ID to the account's X25519 key (crypto_box_seal); it opens only under the ID it was sealed with |
| Content | sealContent, openContent → SealedContent (keyId, nonce, payload, signature) |
ADR 0002's envelope: XChaCha20-Poly1305 under the data key, the key's ID and a caller's context (its table and row) as additional data; the author's Ed25519 signature, detached, over the ID, nonce, payload and context, each framed by its length; the signature is checked before anything is decrypted |
| Row IDs keyed by the account | keyedRowId |
BLAKE2b (crypto_generichash) keyed with the account's X25519 secret key, over a version label, a zero byte and the row's name, 16 bytes as a UUID of version 8 (RFC 9562): a row every device of the account names alike without asking each other (Halkyon Train's day by its date, its ADR 0030, D57), its name unreadable to the instance, which could hash a name in the open itself |
| Refusals | KeyRefused with a KeyRefusal |
wrongKeyOrTampered, notTheAccountsKeys, wrongDataKey, forgedSignature, malformed (bytes from elsewhere of another length, checked before libsodium sees them), weakPasswordKdf, excessivePasswordKdf, phraseLength, phraseWord, phraseChecksum |
Secrets stay in libsodium's guarded memory (SecureKey) where its API takes one:
the master key, the three password subkeys, the private halves of the key pairs, the
recovery key, the phrase's proof key and data keys. dispose wipes and frees a
holder's keys, which may not be read afterwards.
What leaves guarded memory, exactly:
- Wiped after use: wrapping the account's keys and sealing a data key copy the
keys straight from guarded memory into the one buffer libsodium encrypts, wiped
right after; opening them, the decrypted buffer, wiped once its keys are copied
into guarded memory; a phrase's entropy once its key is derived; the seed checked
against a signing key (disposed); and on the device (halkyon_app) the main
isolate's bytes of x and the key-encryption key (
PasswordUnlock.forget) once the sign-in is done, another password replaced them, its sheet closed, or a change's or deletion's proof was made. - Not wiped, as Dart can't: SRP's x as a
BigInt(immutable, gone when the garbage collector takes it); the password and the phrase as typed (Strings); the copies of x and the key-encryption key that crossed between isolates (each isolate holds its own, gone with it); and the unlocked keys as the keyring returns them (base64Strings).
What never leaves the device
The password, the master key, SRP's secret and x, the key-encryption key, the private half of the password's proof key, the recovery phrase and its key, the private halves of the key pairs, data keys and content in the clear. What the register keeps is the salt and the costs, both public keys, the public half of the password's proof key (for an account without SRP's verifier), the private keys wrapped twice (under the key-encryption key and under the recovery key), data keys sealed to the account, and sealed content: none of it opens without the password or the phrase.
Tests
dart test: libsodium's own Argon2id known answers (its pwhash_argon2id test, so the
compiled libsodium is the one these keys were made with), BIP-39's English vectors
(trezor/python-mnemonic's vectors.json, MIT), round trips, and every refusal; the
word list's SHA-256 is checked in halkyon_core.
Licence
GNU Affero General Public License, version 3 or any later version (LICENSE). The
English word list is BIP-39's, in halkyon_core, under the MIT License (its
BIP39-LICENSE).