feat(keys): the password's own proof key (ADR 0020, D19)

A third subkey of the password's Argon2id (context `hlkpass1`, subkey 3)
seeds an Ed25519 pair: `PasswordKeys.proofKeyPair` and `proofPublicKey`.
An account that keeps no SRP verifier proves its password by signing the
register's nonce with it, as it proves the recovery phrase; neither SRP's
verifier nor the unwrapped account keys a signed-in device keeps can make
it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-23 21:20:56 +02:00
commit e8953d1560
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -11,7 +11,7 @@ builds or tests a package depending on this one needs a C compiler and `make`.
| 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'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 |
@ -23,7 +23,7 @@ builds or tests a package depending on this one needs a C compiler and `make`.
| 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
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.
@ -45,9 +45,10 @@ What leaves guarded memory, exactly:
## 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
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.