aboutsummaryrefslogtreecommitdiff
path: root/sof-dhcr-spec.adoc
blob: 9421b5e56e790917faab963d07675fbad68aa749 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
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`.