From 4bb60d0ccd288defb848dd28beac344295dfa602 Mon Sep 17 00:00:00 2001
From: Osmium Sorcerer <os@sof.beauty>
Date: Thu, 18 Jun 2026 04:25:21 +0000
Subject: 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.
---
 sof-dhcr-spec.adoc | 483 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 483 insertions(+)
 create mode 100644 sof-dhcr-spec.adoc

(limited to 'sof-dhcr-spec.adoc')

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

