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