| 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 |
| 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` |