halkyon/packages/halkyon_keys
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Tizian 「ティツィアーン」 0c9bc3e007
fix(app): what a password unlocked is wiped once done with
The reading of the full reading's fixes noted that the main isolate's
copies of SRP's x and the key-encryption key, Dart's own bytes, stayed
for as long as the sign-in sheet's cubit lived, and after every change's
proof.

PasswordUnlock.forget zeroes them: the sign-in cubit wipes what the
password unlocked once signed in, once another password replaced it, and
as it closes; a change's or deletion's proof wipes what it unlocked once
its answer is made. The fake crypto's proof copies its bytes, as a real
one is new bytes. halkyon_keys' README says what is wiped and what isn't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:01 +02:00
..
lib fix(keys): no copy of a key left on the way, and the README says what is wiped 2026-10-23 14:12:58 +02:00
test fix(app): no sheet is left busy by an error nothing expected 2026-10-23 14:12:56 +02:00
.gitignore feat(keys): halkyon_keys, an account's keys on the device 2026-10-22 18:11:09 +02:00
analysis_options.yaml feat(keys): halkyon_keys, an account's keys on the device 2026-10-22 18:11:09 +02:00
BIP39-LICENSE feat(keys): halkyon_keys, an account's keys on the device 2026-10-22 18:11:09 +02:00
LICENSE feat(keys): halkyon_keys, an account's keys on the device 2026-10-22 18:11:09 +02:00
pubspec.yaml feat(keys): passwords taken as RFC 8265's OpaqueString 2026-10-22 18:11:10 +02:00
README.md fix(app): what a password unlocked is wiped once done with 2026-10-23 14:13:01 +02:00

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) 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) and the key-encryption key (subkey 2)
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
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, both 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 (base64 Strings).

What never leaves the device

The password, the master key, SRP's secret and x, the key-encryption 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 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), the word list's SHA-256, round trips, and every refusal.

Licence

GNU Affero General Public License, version 3 or any later version (LICENSE). The English word list is BIP-39's, under the MIT License (BIP39-LICENSE).