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>
49 lines
4.1 KiB
Markdown
49 lines
4.1 KiB
Markdown
# 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 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`, `weakPasswordKdf`, `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 and data keys. Wrapping, sealing a data key and reading x copy key bytes
|
|
to the Dart heap for a moment, wiped afterwards. `dispose` wipes and frees a holder's
|
|
keys, which may not be read afterwards.
|
|
|
|
## 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`).
|