Other spaces become a plain space and the text is composed (NFC) before Argon2id, so a password typed on another system, accents composed or not, unlocks the same keys; empty passwords and control characters are refused. The normalisation (unorm_dart) is pinned exactly, as it must never change once an account exists. ADR 0017 records both decisions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
50 lines
4.4 KiB
Markdown
50 lines
4.4 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 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`, `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`).
|