diff options
| author | Osmium Sorcerer <os@sof.beauty> | 2026-06-18 04:25:21 +0000 |
|---|---|---|
| committer | Osmium Sorcerer <os@sof.beauty> | 2026-06-18 04:25:21 +0000 |
| commit | 4bb60d0ccd288defb848dd28beac344295dfa602 (patch) | |
| tree | 45547b3421355cfd4dc71597e6bcd9ae7e4cd0b1 | |
| parent | c48736a18976a8d1c62fec3dbfa5c8c4dce38bc6 (diff) | |
Add the specification
While the symbolic model is useful to demonstrate security properties, a
concrete, self-contained specification that said model proves and can
be referenced is essential.
This repository shall hold everything related to the protocol.
| -rw-r--r-- | sof-dhcr-spec.adoc | 483 |
1 files changed, 483 insertions, 0 deletions
diff --git a/sof-dhcr-spec.adoc b/sof-dhcr-spec.adoc new file mode 100644 index 0000000..9421b5e --- /dev/null +++ b/sof-dhcr-spec.adoc @@ -0,0 +1,483 @@ += Diffie-Hellman challenge-response authentication +Osmium Sorcerer +1, 2026-03-22 +:sectanchors: +:sectnums: +:toc: right + +This document speicifies a challenge-response protocol for one-way +authentication of the client to the server using public-key cryptography. + +== Introduction + +This protocol lets a server authenticate a client based on possession of a +static private key associated with its identity. + +Authentication is based on X25519 key agreement. Cryptographic hash over the +transcript is used as a proof of possession. The transcript includes the derived +shared secret, server's ephemeral key, client's public key, and the username. + +The server verifies the proof by deriving the shared secret using its ephemeral +private key and the client's public key, reconstructing the transcript, and +comparing the resulting hash value to the client's proof. + +Client public keys are provisioned and pinned to usernames out-of-band on the +server. + +The protocol does not rely on passwords, shared secrets, or static server key +pairs. Instead, server only uses a freshly generated ephemeral key pair for each +authentication attempt. Client private keys are never transmitted to the server. + +=== Conventions + +The key words **must**, **must not**, **should**, and **should not** in this +document are to be interpreted as described in +https://www.rfc-editor.org/info/rfc2119[RFC 2119]. + +=== Cryptographic dependencies + +X25519 as specified in https://www.rfc-editor.org/rfc/rfc7748[RFC 7748]. + +BLAKE2b-256 is BLAKE2b with 256-bit output as specified in +https://www.rfc-editor.org/rfc/rfc7693[RFC 7693]. + +== Protocol + +Overview: + +. Client initiates authentication by sending `Request(username)`. +. Server sends `Challenge(server_ephemeral_public_key)`. +. Client sends `Response(proof)`, where `proof` is derived from the + _authentication transcript_. +. Server independently derives the expected proof from the transcript and + accepts the authentication attempt if it matches the proof in the client's + response. + +Request, Challenge, and Response here denote abstract messages carrying the +relevant data. The protocol is transport-agnostic and doesn't define a specific +wire format or serialization. + +The _authentication transcript_ is the following structure: + +---- +Transcript { + domain_label (16) = "Einsof-Auth-DHCR", + shared_secret (32), + server_ephemeral_public_key (32), + client_public_key (32), + username (..), +} +---- + +The transcript is formed by concatenating all fields in the order shown above. +No additional framing, padding, or delimiters are used. The length of each field +is given in bytes. + +* `domain_label` is the ASCII string `"Einsof-Auth-DHCR"` (16 bytes) used for + domain separation. +* `shared_secret` is a 32-byte X25519 shared secret derived from the client's + static key and server's ephemeral key. +* `server_ephemeral_public_key` is a 32-byte X25519 public key that's randomly + generated by the server for each authentication attempt and sent as the + challenge. +* `client_public_key` is a 32-byte X25519 public key associated with the client + identity. +* `username` is an opaque byte string identifying the client profile. + +Implementations *should* encode usernames as UTF-8 text strings for ease of +configuration and manual user input. + +=== Setup + +Server is provisioned out-of-band with authorized client identities. Each client +identity consists of `username` and the static `client_public_key`. + +Server stores username-key mappings locally and uses them to authenticate +clients. For example, a server operator might manually specify a client profile +in a configuration file, associating the username with a client-provided public +key. + +At startup, the server additionally generates a random X25519 key pair and +discards the private key. The corresponding public key, referred to as +`rejection_public_key`, is used exclusively to process authentication attempts +for nonexistent users and ensure their indistinguishability from processing of +valid users. Server *must not* persist, serialize, or reuse the rejection key +beyond the lifetime of the process. + +=== Request + +Client sends `Request(username)`. + +=== Challenge + +Server generates a fresh ephemeral X25519 key pair and sends +`Challenge(server_ephemeral_public_key)`. + +This key pair *must* be freshly randomly generated for each authentication +attempt and *must not* be reused. + +Authentication attempts *must* have a short lifetime. Server **must** expire +challenges after a bounded period of time and reject responses received after +expiration. The timeout *should not* be greater than 30 seconds. + +=== Response + +Upon receiving the server's challenge, the client derives the X25519 shared +secret using its private key and the server's ephemeral public key. + +Client constructs the _authentication transcript_ and computes: + +[listing] +proof = BLAKE2b-256(Transcript) + +Client then sends `Response(proof)`. + +=== Verification + +Upon receiving the client's response, the server checks whether the challenge +corresponding to this attempt has expired. If the challenge has expired, the +server *must* reject the authentication attempt. + +For the _authentication transcript_, the server determines the value of +`client_public_key` as follows: + +* If `username` corresponds to an authorized client, use `client_public_key` + associated with that username. +* Otherwise, replace it with `rejection_public_key`. + +Server derives the X25519 shared secret using its ephemeral private key and the +selected client public key. + +Server *must* reject the authentication attempt if the shared secret is the +all-zero value (identity element), as described in +https://www.rfc-editor.org/info/rfc7748/#section-7[Section 7 of RFC 7748]. + +Server independently constructs the _authentication transcript_ and computes: + +[listing] +expected_proof = BLAKE2b-256(Transcript) + +If `expected_proof` is equal to `proof` that the client has sent in the +response, the server accepts the authentication attempt. Otherwise, the server +rejects. + +Because `rejection_public_key` is not associated with any private key, for +nonexistent users, client- and server-derived shared secrets will differ (except +for negligible probability), resulting in rejection. + +== Security considerations + +Trust model: manual key pinning. The protocol assumes that the configuration of +users and their associtated keys is correct, and the keys are provisioned +securely by a server operator. + +Replay protection (in general, injective agreement) is provided by the server's +ephemeral key pair, which is freshly randomly generated for each authentication +attempt, has a short lifetime, and bound to the transcript. Servers **must** use +a cryptographically secure random number generator for ephemeral key pairs to +ensure their unpredictability. Servers **should** discard them as soon as +they're no longer required. + +Client private keys **must** remain confidential. Compromise of the private key +enables full impersonation of the client. Implementations **should** protect +keys at rest and minimize the duration for which keys remain in memory. For +example, encrypt keys using authenticated encryption, with the encryption key +derived from a user-provided passphrase using a memory-hard key derivation +function, such as Argon2id. The key is decrypted for an authentication attempt +and immediately erased from memory after deriving the shared secret. + +The protocol assumes a secure channel to be already established between the +client and the server. Authentication state, then, is bound to the established +connection. While the protocol itself is secure against both network +eavesdropping (due to not ever transmitting secrets and freshly generated +challenges) and active tampering (due to strict transcript binding), an attacker +capable of intercepting the connection can modify and inject packets, including +privileged commands, after the client had authenticated. + +Due to the secure channel assumption, the server isn't authenticated, its +identity isn't considered in the protocol and thus not bound to the transcript. +The client is implied to know and trust the identity of the server it's +connected to. + +Every attempt requires the server to generate a random key pair and perform an +X25519 calculation. Server implementations should consider rate limiting the +authentication attempts to prevent resource exhaustion. + +Revocation is assumed to be handled by server operators manually deleting +compromised, superseded, or no longer authorized public keys. Implementations +might add an additional revocation mechanism, for example, to invalidate the key +without an operator's involvement or without having to restart the server +process. If such mechanism is added, servers **must not** introduce observable +differences in processing, timing, and error reporting between different +authentication failures (invalid username, invalid transcript hash, revoked +key), for example, by using `rejection_public_key` for both nonexistent users +and revoked keys and responding with a generic "`Authentication failure`" +message in all cases. + +== Design rationale + +The transcript contains: + +* `domain_label` for domain separation. It ensures the BLAKE2b instantiated for + this protocol is distinct from other uses of the same function elsewhere. +* `shared_secret` to prove possession of the client's private key without + revealing it. +* `server_ephemeral_public_key` to bind authentication attempt to a specific + server challenge, ensure freshness, and prevent replay. +* `client_public_key` to bind the proof to the expected client identity and add + contribution from the client static key. +* `username` to bind the proof to the requested profile. + +All protocol inputs relevant to authentication are thus covered and bound. + +Strictly speaking, inclusion of the username isn't necessary for the +verification security assuming correct one-to-one association of a username to a +public key on the server. Nevertheless, committing to all relevant protocol data +is straightforward and makes reasoning simpler as opposed to arguing why +something was excluded. Inclusion of the username in particular provides binding +to the exact claimed identity name. This avoids assumptions about implicit +server-side lookup semantics and makes the proof statement self-contained. + +The fields are concatenated. This encoding is canonical in our case: all fields +preceding `username` have fixed lengths, thus, the only variable-length part +after the fixed prefix is always the username at the end, avoiding ambiguity +that otherwise arises from the concatenation of arbitrary-length byte strings. + +BLAKE2b is used to hash the transcript directly as opposed to seprate KDF and +HMAC constructions. Assuming BLAKE2b is a cryptographic hash function, these +would only increase complexity without providing a clear security benefit. +Despite having a separate keyed construction intended to be used as a MAC and +PRF, the protocol uses unkeyed BLAKE2b for simplicity as it doesn't require a +MAC key and avoids a separate key derivation step. Direct hashing is viable +because BLAKE2b is invulnerable to length extension attacks. + +While short-lived server ephemeral keys are essential in the challenge-response +flow (public key is the server's challenge, preventing active attacks) and +provide injective agreement (freshness and uniqueness of attempts, preventing +replay), the protocol deliberately avoids static server keys or server +identities. Its scope is identification of a known client to the server, not a +mutual key agreement, which is left to the transport layer. + +_Rejection public key_ prevents enumeration: revealing which profiles exist, are +in use, revoked, what changes happen to credentials, when new profiles appear, +and other information about configured identities on the server. Consider the +server rejecting an invalid username as soon as it's requested as opposed to a +valid username initiating the full protocol flow, immediately revealing whether +the profile exists. Using the rejection public key for transcripts of all +invalid usersnames ensures the same observable behavior as all protocol steps +are executed in all authentication attempts, and every verification follows the +same computational path on the server: always perform X25519, derive the shared +secret, construct the transcript, hash, compare, and respond. + +Typically, public-key authentication involves digital signatures, with the +client signing the server's challenge, and the server verifying the signature +using the client's public key. This design instead uses interactive +identification protocol based on a Diffie-Hellman key exchange. Verification is +bound to a single interactive challenge-response session, and the exchange is +valid only within the context of that interaction between the client (prover) +and the server (verifier). Unlike signatures, which produce non-interactive, +universal, publicly-verifiable proofs, this design produces a proof that is +inherently session-bound and is only meaningful within a protocol run. So, we +leverage implicit authentication directly from the key exchange primitive, +rather than transforming it into a complex non-interactive signature scheme only +to force it back into an interactive setting. + +== License + +This document is dedicated to the public domain under +https://creativecommons.org/publicdomain/zero/1.0/[CC0 1.0]. + +[appendix] +== Examples + +=== Honest protocol run + +This is an example of a recorded transcript. Authorized client authenticates on +the server with a valid proof. Byte strings are hex-encoded. Text strings are +enclosed in quotation marks. + +Suppose a user possesses the following private key: +`36390a2fb445ce5dd8fcc5ab24b145d9b5f58d4441e9486284c312ab63e8257d`. + +The user is registered on the server with the following credentials: + +* Username: `"hellebore"` +* Public key: `78a7ede2bbb66b6de9d205eba690da991d9dae57cc007e9394dba07704f34e22` + +The client sends `Request("hellebore")`. + +The server generates an ephemeral keypair: + +* Private key: + `e63018beea356bc9625f1c37ca97e52a9e2655f3fb2207e58fa467edfc34e18a` +* Public key (challenge): + `53c7a9fc02027268ee5aeab85fb21d46a5b6b41c11f6b2482c7cb756f1bb8978` + +The server sends +`Challenge(53c7a9fc02027268ee5aeab85fb21d46a5b6b41c11f6b2482c7cb756f1bb8978)`. + +After receiving the challenge, the client derives the X25519 shared secret, +constructs the authentication transcript, and computes its BLAKE2b-256 hash. + +The transcript value in this exchange is as follows. + +.Transcript +---- +45696e736f662d417574682d444843520b58a8ce541bbfa1418dba8a3e6264bd5d35177a9a51b6a953f6524aaf17541853c7a9fc02027268ee5aeab85fb21d46a5b6b41c11f6b2482c7cb756f1bb897878a7ede2bbb66b6de9d205eba690da991d9dae57cc007e9394dba07704f34e2268656c6c65626f7265 +---- + +.Transcript breakdown +---- +45696e736f662d417574682d44484352 + # "Einsof-Auth-DHCR" + +0b58a8ce541bbfa1418dba8a3e6264bd5d35177a9a51b6a953f6524aaf175418 + # Shared secret + +53c7a9fc02027268ee5aeab85fb21d46a5b6b41c11f6b2482c7cb756f1bb8978 + # Server's ephemeral public key (challenge) + +78a7ede2bbb66b6de9d205eba690da991d9dae57cc007e9394dba07704f34e22 + # Client's public key + +68656c6c65626f7265 + # "hellebore", UTF-8 +---- + +.Expected proof (BLAKE2b-256 hash of the above transcript) +---- +c05d90bf11e04538252a9b63ade52daa6ae1561382cc35d609d520a182e575eb +---- + +The client sends +`Response(c05d90bf11e04538252a9b63ade52daa6ae1561382cc35d609d520a182e575eb)`. + +The server independently constructs the transcript, computes the same hash, +compares it with the client response, and accepts the authentication attempt. + +Any modification of any value in this exchange will result in rejection. + +=== Credential configuration + +The server needs to associate usernames with public keys. One straightforward +way is to store authorized clients in a text file, similar to OpenSSH's +`authorized_keys` file. In this example, username and base64-encoded public key +are provided on a single line per user. + +.Plain text example +---- +hellebore eKft4ru2a23p0gXrppDamR2drlfMAH6TlNugdwTzTiI +---- + +SoF implementation uses TOML configuration file `staff.toml`, where each user +profile gets its own section. This lets the operators specify user attributes, +privileges, additonal or alternate authentication methods, and other +profile-specific settings. + +.SoF example, TOML +[, toml] +---- +[hellebore] +auth.certificate = "Jnin7eK7tmtt6dIF66aQ2pkdna5XzAB-k5TboHcE804i" +---- + +A byte `0x26` is prepended to the public key to identify the credential type as +X25519. The resulting 33-byte sequence is endoded using +https://www.rfc-editor.org/info/rfc4648/#section-5[base64url] without padding. + +All credentials in the SoF implementation format are prefixed with a one-byte +type identifier. This provides unambiguous type separation and clear +identifcation. + +=== Client key protection + +The following example shows how to store client private keys in a +passphrase-protected encrypted format. + +---- +EncryptedPrivateKey { + version (1) = 0x01, + argon2_salt (16), + argon2_passes (4), + argon2_memory (4), + argon2_variant (1), + ciphertext (32), + mac (16), +} +---- + +Version defines the exact layout of the structure. This example is version 1. + +The next four fields are inputs to +https://www.rfc-editor.org/rfc/rfc9106.html#name-argon2-algorithm[Argon2], the +password-based key derivation function, that define how to derive the key from +the passphrase. + +Salt is a 16-byte sequence securely randomly generated for each new key. Passes +and memory are encoded as big-endian unsigned 32-bit integers and define how +many iterations and memory (in bytes), respectively, to use for the key +derivation. Variant defines the type of Argon2 and should be Argon2id. + +SoF implementation, as of version 6, uses 3 passes over 1 GiB of memory by +default. The variant is equal to 2 and corresponds to the version 1.3 of the +Argon2id algorithm. The variant flexibility is only provided in case an +algorithm better than Argon2id is developed. + +Ciphertext holds the actual private key. MAC is an authentication tag computed +over all previous fields in an AEAD construction. The key to AEAD is derived +from the supplied passphrase and Argon2 parameters from the header. + +SoF implementation uses +https://www.rfc-editor.org/info/rfc8439[ChaCha20-Poly1305] for the AEAD. Private +key is the plaintext, the nonce is an all-zero byte sequence, and all header +fields, from `version` up to and including `argon2_variant`, are additional +authenticated data. The result is the payload consisting of `ciphertext` and +`mac`. + +For an authentication attempt, the client supplies the passphrase, and the +application derives an AEAD key to decrypt the private key. If the decryption is +successful, the private key is used to derive an X25519 shared secret with the +server's ephemeral public key. Then, the application erases the raw private key +from memory. + +Example configuration: + +- Password: "example-key-encryption-passphrase-f2kjcbx5iyd8" +- Salt: 38b330583ce8fde21626256f888a2fb8 +- Parallelism: 1 lane +- Passes: 3 +- Memory: 1073741824 bytes (1 GiB) +- Variant: Argon2id (2) + +.Example encrypted key +---- +0138b330583ce8fde21626256f888a2fb80000000340000000027be63b9ee05b2286cd4ba8aaa49bf8f4fe17d3f07f73dfaa7a74d5279c6dc9566f9f05c8c5ebd553474dcf68f6f6e45e +---- + +.Encrypted key breakdown +---- +01 + # Key format version + +38b330583ce8fde21626256f888a2fb8 + # Argon2 salt + +00000003 + # Argon2 passes (3, big-endian) + +40000000 + # Argon2 memory (1073741824 bytes, big-endian) + +02 + # Argon2 variant (Argon2id) + +7be63b9ee05b2286cd4ba8aaa49bf8f4fe17d3f07f73dfaa7a74d5279c6dc956 + # ChaCha20 ciphertext (encrypted private key) + +6f9f05c8c5ebd553474dcf68f6f6e45e + # Poly1305 authentication tag +---- + +This example successfully decrypts to the private key of the user `"hellebore"` +described in the earlier protocol example: +`36390a2fb445ce5dd8fcc5ab24b145d9b5f58d4441e9486284c312ab63e8257d`. |
