1/**
2 * Signs and verifies event-log session authentication payloads.
3 *
4 * Remote peers use this challenge-response flow to prove that they control a
5 * session signing key before sending session traffic. The signed payload
6 * includes the remote id, a short-lived challenge, the event-log public key, and
7 * the signing public key in a stable byte format.
8 *
9 * @stability unstable
10 * @since 4.0.0
11 */
12import * as Data from "../Data.ts"
13import * as Effect from "../Effect.ts"
14
15const textEncoder = new TextEncoder()
16const textDecoder = new TextDecoder("utf-8", { fatal: true })
17
18const constLengthPrefixBytes = 4
19
20/**
21 * Defines the domain-separation string embedded in canonical session
22 * authentication payloads.
23 *
24 * **When to use**
25 *
26 * Use when you need the domain-separation string used to build canonical
27 * event-log session authentication payloads.
28 *
29 * @stability unstable
30 * @category constants
31 * @since 4.0.0
32 */
33export const AuthPayloadContext = "eventlog-auth-v1"
34
35/**
36 * Defines the required byte length for raw Ed25519 public keys used in session
37 * authentication.
38 *
39 * **When to use**
40 *
41 * Use when implementing session-auth serialization or validation that must
42 * reject public keys with a non-canonical raw byte length.
43 *
44 * @stability unstable
45 * @category constants
46 * @since 4.0.0
47 */
48export const Ed25519PublicKeyLength = 32
49
50/**
51 * Defines the required byte length for Ed25519 signatures used in session authentication.
52 *
53 * **When to use**
54 *
55 * Use when implementing session-auth verification that must reject signatures
56 * with a non-canonical byte length before cryptographic checking.
57 *
58 * @stability unstable
59 * @category constants
60 * @since 4.0.0
61 */
62export const Ed25519SignatureLength = 64
63
64/**
65 * Defines the number of random bytes generated for a session authentication
66 * challenge.
67 *
68 * **When to use**
69 *
70 * Use when you need the challenge size for event-log session authentication.
71 *
72 * @stability unstable
73 * @category constants
74 * @since 4.0.0
75 */
76export const SessionAuthChallengeLength = 32
77
78/**
79 * Defines the time-to-live, in milliseconds, for a pending session
80 * authentication challenge.
81 *
82 * **When to use**
83 *
84 * Use when you need the timeout for pending event-log session authentication
85 * challenges.
86 *
87 * @stability unstable
88 * @category constants
89 * @since 4.0.0
90 */
91export const SessionAuthChallengeTimeToLiveMillis = 30_000
92
93/**
94 * Payload fields that are canonicalized and signed during session
95 * authentication.
96 *
97 * @stability unstable
98 * @category models
99 * @since 4.0.0
100 */
101export interface SessionAuthPayload {
102 readonly remoteId: string | Uint8Array
103 readonly challenge: Uint8Array
104 readonly publicKey: string
105 readonly signingPublicKey: Uint8Array
106}
107
108/**
109 * Error raised while encoding, decoding, signing, verifying, or generating
110 * session authentication challenges.
111 *
112 * @stability unstable
113 * @category errors
114 * @since 4.0.0
115 */
116export class EventLogSessionAuthError extends Data.TaggedError("EventLogSessionAuthError")<{
117 readonly reason:
118 | "InvalidPayload"
119 | "InvalidContext"
120 | "InvalidAlgorithm"
121 | "InvalidSigningPublicKeyLength"
122 | "InvalidSignatureLength"
123 | "InvalidSigningPrivateKey"
124 | "CryptoUnavailable"
125 | "CryptoFailure"
126 readonly message: string
127 readonly cause?: unknown
128}> {}
129
130const toArrayBuffer = (data: Uint8Array): ArrayBuffer => {
131 const copy = new Uint8Array(data.byteLength)
132 copy.set(data)
133 return copy.buffer
134}
135
136const decodeUtf8 = (bytes: Uint8Array) =>
137 Effect.try({
138 try: () => textDecoder.decode(bytes),
139 catch: (cause) =>
140 new EventLogSessionAuthError({
141 reason: "InvalidPayload",
142 message: "Session auth payload contains invalid UTF-8 bytes",
143 cause
144 })
145 })
146
147const assertSigningPublicKeyLength = (signingPublicKey: Uint8Array): Effect.Effect<void, EventLogSessionAuthError> => {
148 if (signingPublicKey.byteLength === Ed25519PublicKeyLength) return Effect.void
149 return Effect.fail(
150 new EventLogSessionAuthError({
151 reason: "InvalidSigningPublicKeyLength",
152 message:
153 `Expected signingPublicKey length to be ${Ed25519PublicKeyLength} bytes, received ${signingPublicKey.byteLength}`
154 })
155 )
156}
157
158const assertSignatureLength = (signature: Uint8Array): Effect.Effect<void, EventLogSessionAuthError> => {
159 if (signature.byteLength === Ed25519SignatureLength) return Effect.void
160 return Effect.fail(
161 new EventLogSessionAuthError({
162 reason: "InvalidSignatureLength",
163 message: `Expected signature length to be ${Ed25519SignatureLength} bytes, received ${signature.byteLength}`
164 })
165 )
166}
167
168const getSubtle = Effect.suspend(() => {
169 const subtle = globalThis.crypto?.subtle
170 if (subtle === undefined) {
171 return Effect.fail(
172 new EventLogSessionAuthError({
173 reason: "CryptoUnavailable",
174 message: "globalThis.crypto.subtle is not available"
175 })
176 )
177 }
178 return Effect.succeed(subtle)
179})
180
181const getCrypto = Effect.suspend(() => {
182 const crypto = globalThis.crypto
183 if (crypto === undefined) {
184 return Effect.fail(
185 new EventLogSessionAuthError({
186 reason: "CryptoUnavailable",
187 message: "globalThis.crypto is not available"
188 })
189 )
190 }
191 return Effect.succeed(crypto)
192})
193
194const writeLength = (
195 target: Uint8Array,
196 offset: number,
197 length: number
198): Effect.Effect<number, EventLogSessionAuthError> => {
199 if (length < 0 || length > 0xffff_ffff) {
200 return Effect.fail(
201 new EventLogSessionAuthError({
202 reason: "InvalidPayload",
203 message: `Invalid canonical field length: ${length}`
204 })
205 )
206 }
207
208 target[offset] = (length >>> 24) & 0xff
209 target[offset + 1] = (length >>> 16) & 0xff
210 target[offset + 2] = (length >>> 8) & 0xff
211 target[offset + 3] = length & 0xff
212
213 return Effect.succeed(offset + constLengthPrefixBytes)
214}
215
216const readLength = (source: Uint8Array, offset: number): number =>
217 (
218 (source[offset]! << 24) |
219 (source[offset + 1]! << 16) |
220 (source[offset + 2]! << 8) |
221 source[offset + 3]!
222 ) >>> 0
223
224const readField = (
225 payload: Uint8Array,
226 state: { offset: number }
227): Effect.Effect<Uint8Array, EventLogSessionAuthError> => {
228 if (state.offset + constLengthPrefixBytes > payload.byteLength) {
229 return Effect.fail(
230 new EventLogSessionAuthError({
231 reason: "InvalidPayload",
232 message: "Session auth payload is truncated before field length"
233 })
234 )
235 }
236
237 const length = readLength(payload, state.offset)
238 state.offset += constLengthPrefixBytes
239
240 if (state.offset + length > payload.byteLength) {
241 return Effect.fail(
242 new EventLogSessionAuthError({
243 reason: "InvalidPayload",
244 message: "Session auth payload is truncated inside a field"
245 })
246 )
247 }
248
249 const field = payload.slice(state.offset, state.offset + length)
250 state.offset += length
251 return Effect.succeed(field)
252}
253
254const bytesToHex = (bytes: Uint8Array): string => {
255 let hex = ""
256 for (const byte of bytes) {
257 hex += byte.toString(16).padStart(2, "0")
258 }
259 return hex
260}
261
262const encodeRemoteIdField = (remoteId: string | Uint8Array): Uint8Array =>
263 typeof remoteId === "string"
264 ? textEncoder.encode(remoteId)
265 : textEncoder.encode(bytesToHex(remoteId))
266
267/**
268 * Encodes a session authentication payload into the canonical byte format.
269 *
270 * **Details**
271 *
272 * The canonical payload format uses ordered big-endian length-prefixed fields:
273 *
274 * 1. context (fixed: eventlog-auth-v1)
275 * 2. remoteId
276 * 3. challenge bytes
277 * 4. publicKey
278 * 5. signingPublicKey bytes
279 *
280 * @stability unstable
281 * @category encoding
282 * @since 4.0.0
283 */
284export const encodeSessionAuthPayload = Effect.fnUntraced(function*(payload: SessionAuthPayload) {
285 yield* assertSigningPublicKeyLength(payload.signingPublicKey)
286
287 const fields = [
288 textEncoder.encode(AuthPayloadContext),
289 encodeRemoteIdField(payload.remoteId),
290 payload.challenge,
291 textEncoder.encode(payload.publicKey),
292 payload.signingPublicKey
293 ]
294
295 const totalLength = fields.reduce(
296 (total, field) => total + constLengthPrefixBytes + field.byteLength,
297 0
298 )
299 const encoded = new Uint8Array(totalLength)
300
301 let offset = 0
302 for (const field of fields) {
303 offset = yield* writeLength(encoded, offset, field.byteLength)
304 encoded.set(field, offset)
305 offset += field.byteLength
306 }
307
308 return encoded
309})
310
311/**
312 * Decodes a canonical session authentication payload.
313 *
314 * **Details**
315 *
316 * The decoder validates the context field, UTF-8 fields, signing public key
317 * length, and rejects truncated or trailing bytes.
318 *
319 * @stability unstable
320 * @category encoding
321 * @since 4.0.0
322 */
323export const decodeSessionAuthPayload = Effect.fnUntraced(
324 function*(payload: Uint8Array): Effect.fn.Return<SessionAuthPayload, EventLogSessionAuthError> {
325 const state = { offset: 0 }
326 const context = yield* decodeUtf8(yield* readField(payload, state))
327
328 if (context !== AuthPayloadContext) {
329 return yield* new EventLogSessionAuthError({
330 reason: "InvalidContext",
331 message: `Invalid session auth payload context: ${context}`
332 })
333 }
334
335 const remoteId = yield* decodeUtf8(yield* readField(payload, state))
336 const challenge = yield* readField(payload, state)
337 const publicKey = yield* decodeUtf8(yield* readField(payload, state))
338 const signingPublicKey = yield* readField(payload, state)
339 yield* assertSigningPublicKeyLength(signingPublicKey)
340
341 if (state.offset !== payload.byteLength) {
342 return yield* new EventLogSessionAuthError({
343 reason: "InvalidPayload",
344 message: "Session auth payload contains trailing bytes"
345 })
346 }
347
348 return {
349 remoteId,
350 challenge,
351 publicKey,
352 signingPublicKey
353 }
354 }
355)
356
357/**
358 * Creates a canonical session authentication signature with an Ed25519 private key.
359 *
360 * **Details**
361 *
362 * The private key must be PKCS#8-encoded bytes importable by `SubtleCrypto`.
363 *
364 * @stability unstable
365 * @category signing
366 * @since 4.0.0
367 */
368export const signSessionAuthPayloadBytes = Effect.fnUntraced(function*(options: {
369 readonly payload: Uint8Array
370 readonly signingPrivateKey: Uint8Array
371}): Effect.fn.Return<Uint8Array<ArrayBuffer>, EventLogSessionAuthError> {
372 yield* decodeSessionAuthPayload(options.payload)
373
374 const subtle = yield* getSubtle
375 let privateKey = yield* Effect.tryPromise({
376 try: () =>
377 subtle.importKey(
378 "pkcs8",
379 toArrayBuffer(options.signingPrivateKey),
380 "Ed25519",
381 false,
382 ["sign"]
383 ),
384 catch: (cause) =>
385 new EventLogSessionAuthError({
386 reason: "InvalidSigningPrivateKey",
387 message: "Failed to import Ed25519 signing private key (expected PKCS#8 bytes)",
388 cause
389 })
390 })
391
392 const signature = yield* Effect.tryPromise({
393 try: () => subtle.sign("Ed25519", privateKey, toArrayBuffer(options.payload)),
394 catch: (cause) =>
395 new EventLogSessionAuthError({
396 reason: "CryptoFailure",
397 message: "Failed to sign canonical session auth payload",
398 cause
399 })
400 })
401 return new Uint8Array(signature)
402})
403
404/**
405 * Verifies an Ed25519 signature for canonical session authentication payload
406 * bytes.
407 *
408 * **Details**
409 *
410 * The payload, signing public key, and signature lengths are validated before
411 * calling `SubtleCrypto.verify`.
412 *
413 * @stability unstable
414 * @category verification
415 * @since 4.0.0
416 */
417export const verifySessionAuthPayloadBytes = Effect.fnUntraced(function*(options: {
418 readonly payload: Uint8Array
419 readonly signingPublicKey: Uint8Array
420 readonly signature: Uint8Array
421}) {
422 yield* decodeSessionAuthPayload(options.payload)
423 yield* assertSigningPublicKeyLength(options.signingPublicKey)
424 yield* assertSignatureLength(options.signature)
425
426 const subtle = yield* getSubtle
427 const publicKey = yield* Effect.tryPromise({
428 try: () => subtle.importKey("raw", toArrayBuffer(options.signingPublicKey), "Ed25519", false, ["verify"]),
429 catch: (cause) =>
430 new EventLogSessionAuthError({
431 reason: "InvalidSigningPublicKeyLength",
432 message: "Failed to import Ed25519 signing public key",
433 cause
434 })
435 })
436
437 return yield* Effect.tryPromise({
438 try: () => subtle.verify("Ed25519", publicKey, toArrayBuffer(options.signature), toArrayBuffer(options.payload)),
439 catch: (cause) =>
440 new EventLogSessionAuthError({
441 reason: "CryptoFailure",
442 message: "Failed to verify canonical session auth payload signature",
443 cause
444 })
445 })
446})
447
448/**
449 * Encodes a session authentication payload in canonical form and signs it with an
450 * Ed25519 private key.
451 *
452 * @stability unstable
453 * @category signing
454 * @since 4.0.0
455 */
456export const signSessionAuthPayload = (
457 options: SessionAuthPayload & {
458 readonly signingPrivateKey: Uint8Array
459 }
460) =>
461 encodeSessionAuthPayload(options).pipe(
462 Effect.flatMap((payload) =>
463 signSessionAuthPayloadBytes({
464 payload,
465 signingPrivateKey: options.signingPrivateKey
466 })
467 )
468 )
469
470/**
471 * Encodes a session authentication payload in canonical form and verifies its
472 * Ed25519 signature.
473 *
474 * @stability unstable
475 * @category verification
476 * @since 4.0.0
477 */
478export const verifySessionAuthPayload = (
479 options: SessionAuthPayload & {
480 readonly signature: Uint8Array
481 }
482) =>
483 encodeSessionAuthPayload(options).pipe(
484 Effect.flatMap((payload) =>
485 verifySessionAuthPayloadBytes({
486 payload,
487 signingPublicKey: options.signingPublicKey,
488 signature: options.signature
489 })
490 )
491 )
492
493/**
494 * Generates a random session authentication challenge using `globalThis.crypto`.
495 *
496 * @stability unstable
497 * @category constructors
498 * @since 4.0.0
499 */
500export const makeSessionAuthChallenge: Effect.Effect<
501 Uint8Array<ArrayBuffer>,
502 EventLogSessionAuthError
503> = Effect.gen(function*() {
504 const crypto = yield* getCrypto
505 const challenge = new Uint8Array(SessionAuthChallengeLength)
506 crypto.getRandomValues(challenge)
507 return challenge
508})
509
510/**
511 * Verifies an authentication request by requiring the `Ed25519` algorithm and
512 * checking the signature over the canonical session authentication payload.
513 *
514 * @stability unstable
515 * @category verification
516 * @since 4.0.0
517 */
518export const verifySessionAuthenticateRequest = Effect.fnUntraced(function*(options: {
519 readonly remoteId: string | Uint8Array
520 readonly challenge: Uint8Array
521 readonly publicKey: string
522 readonly signingPublicKey: Uint8Array
523 readonly signature: Uint8Array
524 readonly algorithm: string
525}) {
526 if (options.algorithm !== "Ed25519") {
527 return yield* new EventLogSessionAuthError({
528 reason: "InvalidAlgorithm",
529 message: `Unsupported session auth algorithm: ${options.algorithm}`
530 })
531 }
532
533 return yield* verifySessionAuthPayload({
534 remoteId: options.remoteId,
535 challenge: options.challenge,
536 publicKey: options.publicKey,
537 signingPublicKey: options.signingPublicKey,
538 signature: options.signature
539 })
540})