@kagal/taistamp 0.0.5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,8 @@
1
- # @kagal/taistamp
1
+ # @kagal/taistamp — HTTP handler for Ed25519-signed TAI64N timestamps
2
+
3
+ [![jsDocs.io][jsdocs-badge]][jsdocs-url]
4
+ [![npm version][npm-badge]][npm-url]
5
+ [![Licence: MIT][mit-badge]][mit]
2
6
 
3
7
  Platform-neutral handler for `/.well-known/taistamp` —
4
8
  serves signed [TAI64N][tai64n] timestamps over HTTP for
@@ -6,13 +10,32 @@ clients that need authenticated wall-clock time without
6
10
  running an NTP stack or trusting an unauthenticated TLS
7
11
  handshake clock.
8
12
 
13
+ Runs anywhere with `crypto.subtle` — modern browsers,
14
+ Node ≥ 20, Cloudflare Workers, Deno, and Bun.
15
+
16
+ ## Specification
17
+
18
+ Implements [`draft-mery-nagy-taistamp`][draft], the
19
+ IETF Internet-Draft for signed TAI64N timestamps over
20
+ HTTP. Working version: [`karasz/rfc-taistamp`][rfc-repo].
21
+ Inline `spec §N` citations in this README resolve
22
+ against that document.
23
+
9
24
  ## Install
10
25
 
26
+ ```sh
27
+ npm install @kagal/taistamp
28
+ ```
29
+
30
+ ```sh
31
+ yarn add @kagal/taistamp
32
+ ```
33
+
11
34
  ```sh
12
35
  pnpm add @kagal/taistamp
13
36
  ```
14
37
 
15
- ## Handler
38
+ ## Usage
16
39
 
17
40
  ```typescript
18
41
  import { newTaistampHandler, TAISTAMP_PATH } from '@kagal/taistamp';
@@ -41,7 +64,7 @@ with `Allow: GET, HEAD, OPTIONS`; other methods return
41
64
  `405` with the same `Allow`. A `TAI-Nonce` that is
42
65
  missing, empty, duplicated, not a valid sf-binary
43
66
  value, or outside the 14–174 octet range is treated as
44
- absent (no echo, no signature) per spec §5.2.
67
+ absent (no echo, no signature) per [spec §5.4][spec-nonce].
45
68
 
46
69
  Response headers on success:
47
70
 
@@ -98,7 +121,7 @@ answered with `200` and
98
121
  (RFC 9110 §9.3.7) is independent of cross-origin
99
122
  policy.
100
123
 
101
- ## Signing
124
+ ## Signing the response
102
125
 
103
126
  ```typescript
104
127
  import {
@@ -122,12 +145,13 @@ package directly.
122
145
  `signer` and `selector` are co-required: pass both to
123
146
  sign, neither for an unsigned handler. Construction
124
147
  throws if only one is supplied, or if `selector` does
125
- not match `[A-Za-z][A-Za-z0-9_-]{0,62}` (a single
126
- DNS-safe label that starts with a letter and is also a
127
- valid Structured Field token).
148
+ not match `/^[A-Za-z](?:[\dA-Za-z_-]{0,61}[\dA-Za-z])?$/`
149
+ (a single DNS-safe label that starts with a letter,
150
+ ends with a letter or digit, and is also a valid
151
+ Structured Field token).
128
152
 
129
153
  When the request is a `GET` carrying a valid
130
- `TAI-Nonce` (see Handler section for the
154
+ `TAI-Nonce` (see Usage section for the
131
155
  "treat as absent" rules) *and* a signer is configured,
132
156
  the response gains:
133
157
 
@@ -157,8 +181,11 @@ The framed payload is:
157
181
  length-prefixed by a single byte, so a downgrade
158
182
  attacker cannot rewrite `TAI-Key-Selector` without
159
183
  invalidating the signature.
160
- - `nonceBytes` — the request nonce, verbatim
161
- (including any sf-binary `:` framing).
184
+ - `nonceBytes` — the octet sequence obtained by
185
+ decoding the `TAI-Nonce` field value as an
186
+ sf-binary item per RFC 9651. The textual
187
+ `:base64:` framing is not part of the signed
188
+ input (spec §6.1).
162
189
 
163
190
  `newEd25519Signer(key: CryptoKey)` is the built-in
164
191
  signer — pass an Ed25519 private `CryptoKey` with
@@ -199,10 +226,10 @@ then removing the old TXT once cached responses have
199
226
  expired. Verifiers cache by selector, so old
200
227
  signatures stay verifiable until their TXT is removed.
201
228
 
202
- ## Verifying
229
+ ## Verifying a signature
203
230
 
204
- Spec §7 requires verifiers to use the RFC 8032
205
- §5.1.7 strict verification procedure (cofactor
231
+ [Spec §9][spec-verify] requires verifiers to use the
232
+ RFC 8032 §5.1.7 strict verification procedure (cofactor
206
233
  handling, signature-malleability resistance).
207
234
  WebCrypto's `Ed25519 verify` is specified to apply
208
235
  strict verification; confirm your runtime conforms,
@@ -223,7 +250,7 @@ const label = await response.text();
223
250
  const selector = response.headers.get('TAI-Key-Selector')!;
224
251
  const sigSf = response.headers.get('TAI-Signature')!;
225
252
 
226
- // Spec §5.1: a `TAI-Leap-Seconds` value outside the
253
+ // Spec §5.3: a `TAI-Leap-Seconds` value outside the
227
254
  // signed-payload u32 range MUST be treated as unsigned.
228
255
  // `extractLeapSeconds` returns `undefined` whenever
229
256
  // the field is missing, empty, non-numeric, non-integer,
@@ -237,9 +264,9 @@ if (leap === undefined) {
237
264
 
238
265
  // Brand the recorded nonce so it can flow into the
239
266
  // signing path. `asNonce` returns `undefined` for any
240
- // value that fails sf-binary syntax or the 14..174
241
- // octet range — the same "treat as absent" verdict
242
- // the server applied.
267
+ // value that fails sf-binary syntax or the wire
268
+ // length range — the same "treat as absent" verdict
269
+ // the server applied per spec §5.4.
243
270
  const nonce = asNonce(clientNonce);
244
271
  if (nonce === undefined) {
245
272
  throw new Error('client nonce is not a valid sf-binary item');
@@ -273,16 +300,68 @@ branded `LeapSeconds` — obtain one from
273
300
  `asLeapSeconds(number)` (when you already have the
274
301
  value). Both return `undefined` for out-of-range
275
302
  input, collapsing every "treat as unsigned" case in
276
- spec §5.1 into one verdict. `nonce` must be a branded
303
+ [spec §5.3][spec-leap] into one verdict. `nonce` must be a branded
277
304
  `Nonce` — wrap the recorded client nonce with
278
305
  `asNonce(value)`, which returns `undefined` for any
279
306
  value that would have been treated as absent on the
280
- server (missing, empty, malformed sf-binary, or
281
- outside 14..174 octets). Comparing the verifier's
282
- recorded nonce against the response's `TAI-Nonce`
283
- defends against replay.
284
-
285
- ## TAI64N helpers
307
+ server (missing, empty, malformed sf-binary, or out
308
+ of length range see [spec §5.4][spec-nonce]).
309
+ Comparing the verifier's recorded nonce against the
310
+ response's `TAI-Nonce` defends against replay.
311
+
312
+ ## API
313
+
314
+ - `VERSION` — package version string, mirrors
315
+ `package.json#version`.
316
+
317
+ ### Handler
318
+
319
+ - `newTaistampHandler(config?)` — async fetch
320
+ handler for `/.well-known/taistamp`. See
321
+ [Usage](#usage) above for behaviour,
322
+ [Signing the response](#signing-the-response) for
323
+ signed responses, and
324
+ [CORS](#cors) for cross-origin policy.
325
+ - `TaistampHandlerConfig` — `{ cors?, selector?,
326
+ signer? }`. `cors` accepts `'*'` (default), a
327
+ specific origin string, or `false`; `signer` and
328
+ `selector` are co-required.
329
+
330
+ ### Signer
331
+
332
+ Re-exported from `@kagal/ed25519-secret`:
333
+
334
+ - `Signer` — `{ sign: (message: BufferSource) =>
335
+ Promise<ArrayBuffer> }`.
336
+ - `newEd25519Signer(key)` — WebCrypto Ed25519
337
+ signer factory. Pass an Ed25519 private
338
+ `CryptoKey` with `'sign'` in `usages`.
339
+
340
+ ### Verification helpers
341
+
342
+ For verifier-side validation of a signed response
343
+ (see [Verifying a signature](#verifying-a-signature)):
344
+
345
+ - `composeSignaturePayload(label, leapSeconds,
346
+ selector, nonce)` — reconstructs the exact byte
347
+ sequence the server signed.
348
+ - `asLeapSeconds(number)` — brand a numeric
349
+ leap-second count; returns `undefined` for
350
+ out-of-range input.
351
+ - `extractLeapSeconds(headers)` — parse
352
+ `TAI-Leap-Seconds` from response headers; returns
353
+ `undefined` if missing, non-numeric, non-integer,
354
+ negative, or out of range.
355
+ - `LeapSeconds` — branded leap-second count
356
+ accepted by `composeSignaturePayload`.
357
+ - `asNonce(value)` — brand a recorded nonce;
358
+ returns `undefined` for any value that fails
359
+ sf-binary syntax or the length range checked
360
+ per [spec §5.4][spec-nonce].
361
+ - `Nonce` — branded sf-binary nonce accepted by
362
+ `composeSignaturePayload`.
363
+
364
+ ### TAI64N helpers
286
365
 
287
366
  The handler uses these primitives internally; they
288
367
  are re-exported for callers that need raw TAI64N
@@ -301,7 +380,7 @@ spanning a leap-second boundary need caller-side
301
380
  adjustment — the constant tracks the present, not
302
381
  history.
303
382
 
304
- ## Constants
383
+ ### Constants
305
384
 
306
385
  | Name | Value |
307
386
  |------|-------|
@@ -321,5 +400,15 @@ history.
321
400
  [MIT][mit]
322
401
 
323
402
  <!-- references -->
403
+ [draft]: https://datatracker.ietf.org/doc/draft-mery-nagy-taistamp/
404
+ [jsdocs-badge]: https://img.shields.io/badge/jsDocs.io-reference-blue
405
+ [jsdocs-url]: https://www.jsdocs.io/package/@kagal/taistamp
324
406
  [mit]: ../../LICENCE.txt
407
+ [mit-badge]: https://img.shields.io/badge/Licence-MIT-blue.svg
408
+ [npm-badge]: https://img.shields.io/npm/v/@kagal/taistamp.svg
409
+ [npm-url]: https://www.npmjs.com/package/@kagal/taistamp
410
+ [rfc-repo]: https://github.com/karasz/rfc-taistamp
411
+ [spec-leap]: https://datatracker.ietf.org/doc/html/draft-mery-nagy-taistamp-00#section-5.3
412
+ [spec-nonce]: https://datatracker.ietf.org/doc/html/draft-mery-nagy-taistamp-00#section-5.4
413
+ [spec-verify]: https://datatracker.ietf.org/doc/html/draft-mery-nagy-taistamp-00#section-9
325
414
  [tai64n]: https://cr.yp.to/libtai/tai64.html
package/dist/index.d.mts CHANGED
@@ -15,7 +15,7 @@ declare const TAI64_EPOCH_HI = 1073741824;
15
15
  * big-endian unsigned integer, so any input outside
16
16
  * `[0, 2^32-1]` cannot be represented. Verifiers MUST
17
17
  * treat an out-of-range `TAI-Leap-Seconds` response
18
- * header as unsigned, per spec §5.1.
18
+ * header as unsigned, per spec §5.3.
19
19
  */
20
20
  declare const TAI_LEAP_SECONDS_MAX = 4294967295;
21
21
  declare const LeapSecondsBrand: unique symbol;
@@ -54,19 +54,21 @@ declare const TAI_LEAP_SECONDS: LeapSeconds;
54
54
  * headers. Returns `undefined` when the
55
55
  * `TAI-Leap-Seconds` field is missing, empty,
56
56
  * non-numeric, non-integer, negative, or out-of-range
57
- * — every "treat as unsigned" case in spec §5.1
57
+ * — every "treat as unsigned" case in spec §5.3
58
58
  * collapsed into one verdict.
59
59
  */
60
60
  declare const extractLeapSeconds: (headers: Headers) => LeapSeconds | undefined;
61
61
  declare const NonceBrand: unique symbol;
62
62
  /**
63
63
  * `string` that has been confirmed to satisfy the
64
- * sf-binary syntax of RFC 9651 §3.3.5 and the
65
- * `[NONCE_MIN_OCTETS, NONCE_MAX_OCTETS]` length range
66
- * required by spec §5.2. Construct only via
67
- * {@link asNonce} or {@link extractNonce}; the brand
68
- * prevents arbitrary strings from reaching the
69
- * signing path.
64
+ * sf-binary syntax of RFC 9651 §3.3.5 and to fall
65
+ * inside the wire-form length range
66
+ * `[NONCE_MIN_OCTETS, NONCE_MAX_OCTETS]` the
67
+ * pre-decode form of spec §5.4's normative
68
+ * decoded-length bound of 7..129 octets. Construct
69
+ * only via {@link asNonce} or {@link extractNonce};
70
+ * the brand prevents arbitrary strings from reaching
71
+ * the signing path.
70
72
  */
71
73
  type Nonce = string & {
72
74
  readonly [NonceBrand]: never;
@@ -74,9 +76,11 @@ type Nonce = string & {
74
76
  /**
75
77
  * Brand `value` as a {@link Nonce} when it satisfies
76
78
  * sf-binary syntax (RFC 9651 §3.3.5) and falls inside
77
- * `[NONCE_MIN_OCTETS, NONCE_MAX_OCTETS]`. Returns
79
+ * `[NONCE_MIN_OCTETS, NONCE_MAX_OCTETS]` — the wire
80
+ * range equivalent to spec §5.4's normative
81
+ * decoded-length bound of 7..129 octets. Returns
78
82
  * `undefined` for anything else — every "treat as
79
- * absent" case in spec §5.2 collapsed into one
83
+ * absent" case in spec §5.4 collapsed into one
80
84
  * verdict.
81
85
  */
82
86
  declare const asNonce: (value: string) => Nonce | undefined;
@@ -101,7 +105,9 @@ declare const asNonce: (value: string) => Nonce | undefined;
101
105
  * trailing NUL byte), then the label bytes, then
102
106
  * the leap-seconds count as a 4-byte big-endian
103
107
  * unsigned integer, then a 1-byte selector length,
104
- * then the selector bytes, then the nonce bytes.
108
+ * then the selector bytes, then the decoded sf-binary
109
+ * octets of the nonce (spec §6.1 — the wire
110
+ * `:base64:` framing is not signed).
105
111
  *
106
112
  * @remarks
107
113
  * Binding the selector into the signed payload stops a
@@ -155,7 +161,7 @@ interface TaistampHandlerConfig {
155
161
  * gains `Access-Control-Allow-Origin`; pre-flight
156
162
  * `OPTIONS` also carries `-Allow-Methods`,
157
163
  * `-Allow-Headers`, `-Expose-Headers`, and
158
- * `-Max-Age: 600` per spec §4.2; success
164
+ * `-Max-Age: 600` per spec §5.2; success
159
165
  * `GET` / `HEAD` carry `-Expose-Headers` so browser
160
166
  * JS can read the `TAI-*` response headers. A
161
167
  * non-`'*'` value adds `Vary: Origin` so caches can
@@ -197,11 +203,11 @@ interface TaistampHandlerConfig {
197
203
  * `Allow: GET, HEAD, OPTIONS`.
198
204
  * - Request `TAI-Nonce` — on `GET`, the value is echoed
199
205
  * verbatim in the response. A missing, empty,
200
- * duplicated, structurally malformed, or out-of-range
201
- * (14..174 octets) field is treated as absent (no
202
- * echo, no signature) per spec §5.2 — see
206
+ * duplicated, structurally malformed, or
207
+ * length-out-of-range field is treated as absent (no
208
+ * echo, no signature) per spec §5.4 — see
203
209
  * {@link extractNonce}. `HEAD`, `OPTIONS`, and `405`
204
- * responses never carry `TAI-Nonce` per spec §4.1.
210
+ * responses never carry `TAI-Nonce` per spec §5.1.
205
211
  * - Request `TAI-Nonce` *and* `signer` configured *and*
206
212
  * the request method is `GET` — adds
207
213
  * `TAI-Key-Selector` and `TAI-Signature` (sf-binary)
@@ -0,0 +1,244 @@
1
+ import { Signer, Signer as Signer$1, newSigner as newEd25519Signer } from "@kagal/ed25519-secret";
2
+ declare const TAISTAMP_PATH = "/.well-known/taistamp";
3
+ /** @deprecated Renamed to {@link TAISTAMP_PATH}. */
4
+ declare const TAI64N_PATH = "/.well-known/taistamp";
5
+ declare const TAI64N_CONTENT_TYPE = "application/tai64n";
6
+ declare const TAI64N_CONTENT_LENGTH: number;
7
+ declare const TAI64N_HEADER_KEY_SELECTOR = "TAI-Key-Selector";
8
+ declare const TAI64N_HEADER_LEAP_SECONDS = "TAI-Leap-Seconds";
9
+ declare const TAI64N_HEADER_NONCE = "TAI-Nonce";
10
+ declare const TAI64N_HEADER_SIGNATURE = "TAI-Signature";
11
+ declare const TAI64_EPOCH_HI = 1073741824;
12
+ /**
13
+ * Upper bound for `leapSeconds` in the taistamp signed
14
+ * payload. The framing encodes the value as a 4-byte
15
+ * big-endian unsigned integer, so any input outside
16
+ * `[0, 2^32-1]` cannot be represented. Verifiers MUST
17
+ * treat an out-of-range `TAI-Leap-Seconds` response
18
+ * header as unsigned, per spec §5.3.
19
+ */
20
+ declare const TAI_LEAP_SECONDS_MAX = 4294967295;
21
+ declare const LeapSecondsBrand: unique symbol;
22
+ /**
23
+ * `number` that has been confirmed to fit the
24
+ * `[0, TAI_LEAP_SECONDS_MAX]` u32be range required by
25
+ * the taistamp signed-payload framing. Construct only
26
+ * via {@link extractLeapSeconds} or {@link asLeapSeconds};
27
+ * the brand prevents an arbitrary number from reaching
28
+ * the signing path.
29
+ */
30
+ type LeapSeconds = number & {
31
+ readonly [LeapSecondsBrand]: never;
32
+ };
33
+ /**
34
+ * Coerce a `number` to a {@link LeapSeconds}. Returns
35
+ * `undefined` when `value` is non-integer, negative,
36
+ * or exceeds {@link TAI_LEAP_SECONDS_MAX}.
37
+ */
38
+ declare const asLeapSeconds: (value: number) => LeapSeconds | undefined;
39
+ /**
40
+ * Current TAI − UTC offset in whole seconds, used by
41
+ * `fromUTC()` and emitted in the `TAI-Leap-Seconds`
42
+ * response header. The value 37 has been in force
43
+ * since 2017-01-01; update on the next IERS leap-second
44
+ * announcement.
45
+ *
46
+ * @remarks
47
+ * Stays a single `LeapSeconds` until a leap-seconds
48
+ * table is added so the offset can be computed for any
49
+ * TAI second; this constant becomes redundant then.
50
+ */
51
+ declare const TAI_LEAP_SECONDS: LeapSeconds;
52
+ /**
53
+ * Extract a usable leap-seconds count from response
54
+ * headers. Returns `undefined` when the
55
+ * `TAI-Leap-Seconds` field is missing, empty,
56
+ * non-numeric, non-integer, negative, or out-of-range
57
+ * — every "treat as unsigned" case in spec §5.3
58
+ * collapsed into one verdict.
59
+ */
60
+ declare const extractLeapSeconds: (headers: Headers) => LeapSeconds | undefined;
61
+ declare const NonceBrand: unique symbol;
62
+ /**
63
+ * `string` that has been confirmed to satisfy the
64
+ * sf-binary syntax of RFC 9651 §3.3.5 and to fall
65
+ * inside the wire-form length range
66
+ * `[NONCE_MIN_OCTETS, NONCE_MAX_OCTETS]` — the
67
+ * pre-decode form of spec §5.4's normative
68
+ * decoded-length bound of 7..129 octets. Construct
69
+ * only via {@link asNonce} or {@link extractNonce};
70
+ * the brand prevents arbitrary strings from reaching
71
+ * the signing path.
72
+ */
73
+ type Nonce = string & {
74
+ readonly [NonceBrand]: never;
75
+ };
76
+ /**
77
+ * Brand `value` as a {@link Nonce} when it satisfies
78
+ * sf-binary syntax (RFC 9651 §3.3.5) and falls inside
79
+ * `[NONCE_MIN_OCTETS, NONCE_MAX_OCTETS]` — the wire
80
+ * range equivalent to spec §5.4's normative
81
+ * decoded-length bound of 7..129 octets. Returns
82
+ * `undefined` for anything else — every "treat as
83
+ * absent" case in spec §5.4 collapsed into one
84
+ * verdict.
85
+ */
86
+ declare const asNonce: (value: string) => Nonce | undefined;
87
+ /**
88
+ * Compose the byte sequence covered by a TAI-Signature.
89
+ *
90
+ * @param label - the 25-byte TAI64N label string the
91
+ * server is returning
92
+ * @param leapSeconds - the leap-seconds count the server
93
+ * advertises in `TAI-Leap-Seconds`
94
+ * @param selector - the key selector the server
95
+ * advertises in `TAI-Key-Selector`; verifiers use
96
+ * this to look up the public key in DNS at
97
+ * `<selector>._taistamp.<host>`
98
+ * @param nonce - the client-supplied nonce, echoed
99
+ * verbatim in `TAI-Nonce`; brand a verifier-side
100
+ * string with {@link asNonce} before passing it in
101
+ * @returns the byte sequence verifiers reconstruct
102
+ * from the response and pass to their public-key
103
+ * verify routine. The framing is the
104
+ * domain-separation tag (`taistamp-v1` plus a
105
+ * trailing NUL byte), then the label bytes, then
106
+ * the leap-seconds count as a 4-byte big-endian
107
+ * unsigned integer, then a 1-byte selector length,
108
+ * then the selector bytes, then the decoded sf-binary
109
+ * octets of the nonce (spec §6.1 — the wire
110
+ * `:base64:` framing is not signed).
111
+ *
112
+ * @remarks
113
+ * Binding the selector into the signed payload stops a
114
+ * downgrade attacker from rewriting `TAI-Key-Selector`
115
+ * to point at a compromised or weaker key — the
116
+ * signature would no longer verify under that key.
117
+ * `leapSeconds` is encoded as a 4-byte big-endian
118
+ * unsigned integer; the selector is length-prefixed by
119
+ * a single byte (selectors are ≤ 63 chars per
120
+ * {@link newTaistampHandler}'s validation).
121
+ */
122
+ declare const composeSignaturePayload: (label: string, leapSeconds: LeapSeconds, selector: string, nonce: Nonce) => ArrayBuffer;
123
+ /**
124
+ * Configuration for {@link newTaistampHandler}.
125
+ *
126
+ * @remarks
127
+ * `signer` and `selector` are co-required: pass both
128
+ * to enable authenticated responses, or neither for
129
+ * an unsigned handler. Passing only one is rejected
130
+ * at construction time — without the selector
131
+ * verifiers cannot find the key in DNS, and a
132
+ * selector without a signer is a misconfiguration.
133
+ */
134
+ interface TaistampHandlerConfig {
135
+ /**
136
+ * Key selector advertised in the `TAI-Key-Selector`
137
+ * response header and bound into the signed payload.
138
+ * Verifiers look up the public key at
139
+ * `<selector>._taistamp.<host>` in DNS.
140
+ *
141
+ * Must match `[A-Za-z][A-Za-z0-9_-]{0,62}` (a single
142
+ * DNS label starting with a letter, using
143
+ * DKIM-compatible characters and a valid sf-token);
144
+ * rotate by changing the selector and publishing a
145
+ * new TXT record.
146
+ */
147
+ selector?: string;
148
+ /**
149
+ * {@link Signer} that produces `TAI-Signature` over
150
+ * the framed payload from {@link composeSignaturePayload}.
151
+ * Without a signer the nonce is still echoed but the
152
+ * response is unsigned.
153
+ */
154
+ signer?: Signer$1;
155
+ /**
156
+ * CORS origin policy. Defaults to `'*'`; pass `false`
157
+ * to disable CORS entirely, or a specific origin
158
+ * (e.g. `'https://example.com'`) to scope the policy.
159
+ *
160
+ * Every response (`GET` / `HEAD` / `OPTIONS` / `405`)
161
+ * gains `Access-Control-Allow-Origin`; pre-flight
162
+ * `OPTIONS` also carries `-Allow-Methods`,
163
+ * `-Allow-Headers`, `-Expose-Headers`, and
164
+ * `-Max-Age: 600` per spec §5.2; success
165
+ * `GET` / `HEAD` carry `-Expose-Headers` so browser
166
+ * JS can read the `TAI-*` response headers. A
167
+ * non-`'*'` value adds `Vary: Origin` so caches can
168
+ * keep per-origin variants distinct.
169
+ *
170
+ * Disabling CORS does not affect method discovery:
171
+ * `OPTIONS` is still answered with `200` and
172
+ * `Allow: GET, HEAD, OPTIONS` per RFC 9110 §9.3.7.
173
+ */
174
+ cors?: false | string;
175
+ }
176
+ /**
177
+ * Build a handler for `/.well-known/taistamp`.
178
+ *
179
+ * @param config - optional {@link TaistampHandlerConfig}
180
+ * @returns an `async (request) => Response` callable
181
+ * directly as a Web `fetch` handler or as a Hono
182
+ * route handler.
183
+ *
184
+ * @throws TypeError if `signer` and `selector` are not
185
+ * both set or both unset, or if `selector` does not
186
+ * match `[A-Za-z][A-Za-z0-9_-]{0,62}`.
187
+ *
188
+ * @remarks
189
+ * Behaviour:
190
+ *
191
+ * - `GET` / `HEAD` — body is a fresh 25-byte TAI64N
192
+ * label (`HEAD` omits the body). Response headers:
193
+ * Content-Type `application/tai64n`, Content-Length
194
+ * `25`, Cache-Control `no-store`, plus
195
+ * `TAI-Leap-Seconds` carrying the current count.
196
+ * - `OPTIONS` — `200` with `Allow: GET, HEAD, OPTIONS`.
197
+ * When CORS is enabled (the default) the response
198
+ * also carries `Access-Control-Allow-*` and
199
+ * `-Expose-Headers` per
200
+ * {@link TaistampHandlerConfig.cors}. `OPTIONS` is
201
+ * never signed.
202
+ * - Any other method — `405 Method Not Allowed` with
203
+ * `Allow: GET, HEAD, OPTIONS`.
204
+ * - Request `TAI-Nonce` — on `GET`, the value is echoed
205
+ * verbatim in the response. A missing, empty,
206
+ * duplicated, structurally malformed, or
207
+ * length-out-of-range field is treated as absent (no
208
+ * echo, no signature) per spec §5.4 — see
209
+ * {@link extractNonce}. `HEAD`, `OPTIONS`, and `405`
210
+ * responses never carry `TAI-Nonce` per spec §5.1.
211
+ * - Request `TAI-Nonce` *and* `signer` configured *and*
212
+ * the request method is `GET` — adds
213
+ * `TAI-Key-Selector` and `TAI-Signature` (sf-binary)
214
+ * over the bytes produced by
215
+ * {@link composeSignaturePayload}. The
216
+ * domain-separation tag means the same key cannot
217
+ * be tricked into producing valid signatures for
218
+ * other protocols. `HEAD`, `OPTIONS`, and `405`
219
+ * responses are never signed.
220
+ *
221
+ * The corresponding public key is expected to be
222
+ * published out-of-band as a DNS TXT record at
223
+ * `<selector>._taistamp.<host>` — verifiers fetch the
224
+ * key by selector so the operator can rotate keys by
225
+ * publishing a new selector while the old one is
226
+ * still cached.
227
+ *
228
+ * @see {@link https://cr.yp.to/libtai/tai64.html} for
229
+ * TAI64N format
230
+ */
231
+ declare const newTaistampHandler: (config?: TaistampHandlerConfig) => ((request: Request) => Promise<Response>);
232
+ type timestamp = {
233
+ nano: number;
234
+ sec: number;
235
+ offset?: number;
236
+ };
237
+ declare const fromUTC: (utc: number) => timestamp;
238
+ declare const now: () => timestamp;
239
+ declare const tai64nLabel: (value?: timestamp) => string;
240
+ declare const tai64nLabelFromUTC: (utc: number) => string;
241
+ /** Package version from package.json. */
242
+ declare const VERSION: string;
243
+ export { type LeapSeconds, type Nonce, type Signer, TAI64N_CONTENT_LENGTH, TAI64N_CONTENT_TYPE, TAI64N_HEADER_KEY_SELECTOR, TAI64N_HEADER_LEAP_SECONDS, TAI64N_HEADER_NONCE, TAI64N_HEADER_SIGNATURE, TAI64N_PATH, TAI64_EPOCH_HI, TAISTAMP_PATH, TAI_LEAP_SECONDS, TAI_LEAP_SECONDS_MAX, type TaistampHandlerConfig, VERSION, asLeapSeconds, asNonce, composeSignaturePayload, extractLeapSeconds, fromUTC, newEd25519Signer, newTaistampHandler, now, tai64nLabel, tai64nLabelFromUTC };
244
+ //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { assertValidSelector, newSigner as newEd25519Signer } from "@kagal/ed25519-secret";
2
- var version = "0.0.5";
1
+ import { assertValidSelector, decodeBase64, encodeBase64, newSigner as newEd25519Signer } from "@kagal/ed25519-secret";
2
+ var version = "0.1.0";
3
3
  const TAISTAMP_PATH = "/.well-known/taistamp";
4
4
  const TAI64N_PATH = TAISTAMP_PATH;
5
5
  const TAI64N_CONTENT_TYPE = "application/tai64n";
@@ -88,19 +88,10 @@ const u32Range = 4294967296;
88
88
  const ALLOW_HEADER = "GET, HEAD, OPTIONS";
89
89
  const textEncoder = new TextEncoder();
90
90
  const DOMAIN_SEPARATOR = textEncoder.encode("taistamp-v1\0");
91
- const asBytes = (source) => {
92
- if (source instanceof Uint8Array) return source;
93
- if (ArrayBuffer.isView(source)) return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
94
- return new Uint8Array(source);
95
- };
96
- const encodeStructuredBinary = (source) => {
97
- const bytes = asBytes(source);
98
- return `:${btoa(String.fromCodePoint(...bytes))}:`;
99
- };
100
91
  const composeSignaturePayload = (label, leapSeconds, selector, nonce) => {
101
92
  const labelBytes = textEncoder.encode(label);
102
93
  const selectorBytes = textEncoder.encode(selector);
103
- const nonceBytes = textEncoder.encode(nonce);
94
+ const nonceBytes = decodeBase64(nonce.slice(1, -1));
104
95
  const buffer = new ArrayBuffer(DOMAIN_SEPARATOR.length + labelBytes.length + 4 + 1 + selectorBytes.length + nonceBytes.length);
105
96
  const view = new Uint8Array(buffer);
106
97
  let offset = 0;
@@ -132,7 +123,7 @@ const fromHandlerConfig = (config) => {
132
123
  const payload = composeSignaturePayload(label, 37, selector, nonce);
133
124
  const signature = await signer.sign(payload);
134
125
  headers.set(TAI64N_HEADER_KEY_SELECTOR, selector);
135
- headers.set(TAI64N_HEADER_SIGNATURE, encodeStructuredBinary(signature));
126
+ headers.set(TAI64N_HEADER_SIGNATURE, `:${encodeBase64(new Uint8Array(signature))}:`);
136
127
  } : void 0,
137
128
  corsHeaders
138
129
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["pkg.version"],"sources":["../package.json","../src/const.ts","../src/cors.ts","../src/leap-seconds.ts","../src/nonce.ts","../src/utils.ts","../src/handler.ts","../src/index.ts"],"sourcesContent":["","export const TAISTAMP_PATH = '/.well-known/taistamp';\n\n/** @deprecated Renamed to {@link TAISTAMP_PATH}. */\nexport const TAI64N_PATH = TAISTAMP_PATH;\n\nexport const TAI64N_CONTENT_TYPE = 'application/tai64n';\nexport const TAI64N_CONTENT_LENGTH = 1 + 16 + 8; // '@' + sec (16 hex chars) + nano (8 hex chars)\n\nexport const TAI64N_HEADER_KEY_SELECTOR = 'TAI-Key-Selector';\nexport const TAI64N_HEADER_LEAP_SECONDS = 'TAI-Leap-Seconds';\nexport const TAI64N_HEADER_NONCE = 'TAI-Nonce';\nexport const TAI64N_HEADER_SIGNATURE = 'TAI-Signature';\n\nexport const TAI64_EPOCH_HI = 0x40_00_00_00;\n","import {\n TAI64N_HEADER_KEY_SELECTOR,\n TAI64N_HEADER_LEAP_SECONDS,\n TAI64N_HEADER_NONCE,\n TAI64N_HEADER_SIGNATURE,\n} from './const';\n\n// `Access-Control-Allow-Methods` (Fetch) is the list\n// of methods JS would ever preflight, so `OPTIONS` is\n// omitted. This is intentionally narrower than the\n// `Allow` header (RFC 9110 §9.3.7 method discovery,\n// `GET, HEAD, OPTIONS`) the handler itself emits.\nconst CORS_ALLOW_METHODS = 'GET, HEAD';\nconst CORS_ALLOW_HEADERS = TAI64N_HEADER_NONCE;\nconst CORS_EXPOSE_HEADERS = [\n TAI64N_HEADER_LEAP_SECONDS,\n TAI64N_HEADER_NONCE,\n TAI64N_HEADER_KEY_SELECTOR,\n TAI64N_HEADER_SIGNATURE,\n].join(', ');\n// Spec §4.2 SHOULDs at least 600s; 10 minutes is the\n// floor the spec example uses and keeps high-traffic\n// cross-origin clients off a pre-flight per fetch.\nconst CORS_MAX_AGE = '600';\n\n/**\n * The three CORS header maps the handler splices into\n * responses, keyed by response kind.\n *\n * - `preflight` — added to `OPTIONS 200` replies.\n * - `response` — added to successful `GET` / `HEAD`\n * replies; carries `Access-Control-Expose-Headers`\n * so browser JS can read the `TAI-*` headers.\n * - `error` — added to `405` replies; just the origin\n * header (and `Vary` when scoped).\n */\nexport type CORSHeaderSets = {\n error: Record<string, string>\n preflight: Record<string, string>\n response: Record<string, string>\n};\n\n/**\n * Pre-bake the three CORS header maps the handler\n * splices into responses, keyed by response kind.\n * `cors === false` collapses every map to `{}` so the\n * spread is a no-op; missing or empty input falls back\n * to `'*'`; `cors === '*'` skips `Vary: Origin` (a\n * wildcard does not vary by origin); a scoped origin\n * adds `Vary: Origin` so caches can keep per-origin\n * variants distinct.\n */\nexport const buildCORSHeaders = (\n cors: false | string | undefined,\n): CORSHeaderSets => {\n if (cors === false) {\n return { error: {}, preflight: {}, response: {} };\n }\n const origin = cors || '*';\n const vary: Record<string, string> =\n origin === '*' ? {} : { vary: 'Origin' };\n return {\n error: {\n 'access-control-allow-origin': origin,\n ...vary,\n },\n preflight: {\n 'access-control-allow-origin': origin,\n 'access-control-allow-methods': CORS_ALLOW_METHODS,\n 'access-control-allow-headers': CORS_ALLOW_HEADERS,\n 'access-control-expose-headers': CORS_EXPOSE_HEADERS,\n 'access-control-max-age': CORS_MAX_AGE,\n ...vary,\n },\n response: {\n 'access-control-allow-origin': origin,\n 'access-control-expose-headers': CORS_EXPOSE_HEADERS,\n ...vary,\n },\n };\n};\n","// cspell:words IERS\n\nimport { TAI64N_HEADER_LEAP_SECONDS } from './const';\n\n/**\n * Upper bound for `leapSeconds` in the taistamp signed\n * payload. The framing encodes the value as a 4-byte\n * big-endian unsigned integer, so any input outside\n * `[0, 2^32-1]` cannot be represented. Verifiers MUST\n * treat an out-of-range `TAI-Leap-Seconds` response\n * header as unsigned, per spec §5.1.\n */\nexport const TAI_LEAP_SECONDS_MAX = 0xFF_FF_FF_FF;\n\ndeclare const LeapSecondsBrand: unique symbol;\n\n/**\n * `number` that has been confirmed to fit the\n * `[0, TAI_LEAP_SECONDS_MAX]` u32be range required by\n * the taistamp signed-payload framing. Construct only\n * via {@link extractLeapSeconds} or {@link asLeapSeconds};\n * the brand prevents an arbitrary number from reaching\n * the signing path.\n */\nexport type LeapSeconds = number & { readonly [LeapSecondsBrand]: never };\n\n/**\n * Coerce a `number` to a {@link LeapSeconds}. Returns\n * `undefined` when `value` is non-integer, negative,\n * or exceeds {@link TAI_LEAP_SECONDS_MAX}.\n */\nexport const asLeapSeconds = (\n value: number,\n): LeapSeconds | undefined => {\n if (\n !Number.isInteger(value) ||\n value < 0 ||\n value > TAI_LEAP_SECONDS_MAX\n ) return undefined;\n return value as LeapSeconds;\n};\n\n/**\n * Current TAI − UTC offset in whole seconds, used by\n * `fromUTC()` and emitted in the `TAI-Leap-Seconds`\n * response header. The value 37 has been in force\n * since 2017-01-01; update on the next IERS leap-second\n * announcement.\n *\n * @remarks\n * Stays a single `LeapSeconds` until a leap-seconds\n * table is added so the offset can be computed for any\n * TAI second; this constant becomes redundant then.\n */\nexport const TAI_LEAP_SECONDS: LeapSeconds = 37 as LeapSeconds;\n\n/**\n * Strict decimal integer: a single `0` or a non-zero\n * leading digit followed by digits. Rejects hex\n * (`0x25`), float-style integers (`37.0`), signs,\n * whitespace, exponential notation, and leading zeros\n * — every input `Number()` would silently coerce to\n * an integer despite not being a canonical decimal.\n */\nconst DECIMAL_INTEGER = /^(?:0|[1-9]\\d*)$/;\n\n/**\n * Extract a usable leap-seconds count from response\n * headers. Returns `undefined` when the\n * `TAI-Leap-Seconds` field is missing, empty,\n * non-numeric, non-integer, negative, or out-of-range\n * — every \"treat as unsigned\" case in spec §5.1\n * collapsed into one verdict.\n */\nexport const extractLeapSeconds = (\n headers: Headers,\n): LeapSeconds | undefined => {\n const raw = headers.get(TAI64N_HEADER_LEAP_SECONDS);\n if (!raw || !DECIMAL_INTEGER.test(raw)) return undefined;\n return asLeapSeconds(Number(raw));\n};\n","import { TAI64N_HEADER_NONCE } from './const';\n\n/**\n * Lower bound on `TAI-Nonce` field-value octets. A\n * field shorter than this is treated as absent (spec\n * §5.2). sf-binary is ASCII-only — the string length\n * equals the octet count.\n */\nexport const NONCE_MIN_OCTETS = 14;\n\n/**\n * Upper bound on `TAI-Nonce` field-value octets. A\n * field longer than this is treated as absent (spec\n * §5.2).\n */\nexport const NONCE_MAX_OCTETS = 174;\n\n/**\n * sf-binary item per RFC 9651 §3.3.5: standard base64\n * with `=` padding, wrapped in a leading and trailing\n * colon. The empty payload (`::`) is excluded — a\n * zero-length nonce is treated as absent per spec\n * §5.2. The alphabet contains no `,`, so a duplicated\n * field (joined by the Web `Headers` API with `,`)\n * fails the same check.\n */\nconst SF_BINARY_PATTERN =\n /^:(?:[\\d+/A-Za-z]{4})*(?:[\\d+/A-Za-z]{4}|[\\d+/A-Za-z]{3}=|[\\d+/A-Za-z]{2}==):$/;\n\ndeclare const NonceBrand: unique symbol;\n\n/**\n * `string` that has been confirmed to satisfy the\n * sf-binary syntax of RFC 9651 §3.3.5 and the\n * `[NONCE_MIN_OCTETS, NONCE_MAX_OCTETS]` length range\n * required by spec §5.2. Construct only via\n * {@link asNonce} or {@link extractNonce}; the brand\n * prevents arbitrary strings from reaching the\n * signing path.\n */\nexport type Nonce = string & { readonly [NonceBrand]: never };\n\n/**\n * Brand `value` as a {@link Nonce} when it satisfies\n * sf-binary syntax (RFC 9651 §3.3.5) and falls inside\n * `[NONCE_MIN_OCTETS, NONCE_MAX_OCTETS]`. Returns\n * `undefined` for anything else — every \"treat as\n * absent\" case in spec §5.2 collapsed into one\n * verdict.\n */\nexport const asNonce = (value: string): Nonce | undefined => {\n if (\n !value ||\n value.length < NONCE_MIN_OCTETS ||\n value.length > NONCE_MAX_OCTETS ||\n !SF_BINARY_PATTERN.test(value)\n ) return undefined;\n return value as Nonce;\n};\n\n/**\n * Extract a usable `TAI-Nonce` from request headers.\n * Returns `undefined` when the field is missing or\n * fails {@link asNonce} validation.\n */\nexport const extractNonce = (headers: Headers): Nonce | undefined => {\n const value = headers.get(TAI64N_HEADER_NONCE);\n return value === null ? undefined : asNonce(value);\n};\n","import { TAI64_EPOCH_HI } from './const';\nimport { TAI_LEAP_SECONDS } from './leap-seconds';\n\ntype timestamp = {\n nano: number\n sec: number\n\n offset?: number\n};\n\nexport const fromUTC = (utc: number): timestamp => {\n // TODO: leap seconds table\n const sec = Math.floor(utc / 1000) + TAI_LEAP_SECONDS;\n const nano = (utc % 1000) * 1e6;\n return { sec, nano, offset: TAI_LEAP_SECONDS };\n};\n\nexport const now = (): timestamp => {\n const utc = Date.now();\n return fromUTC(utc);\n};\n\nexport const tai64nLabel = (value?: timestamp): string => {\n const { sec, nano } = value ?? now();\n\n const secHi = Math.trunc(sec / u32Range) + TAI64_EPOCH_HI;\n const secLo = sec % u32Range;\n\n const secHiHex = secHi.toString(16).padStart(8, '0');\n const secLoHex = secLo.toString(16).padStart(8, '0');\n const nanoHex = nano.toString(16).padStart(8, '0');\n\n return `@${secHiHex}${secLoHex}${nanoHex}`;\n};\n\nexport const tai64nLabelFromUTC = (utc: number): string => tai64nLabel(fromUTC(utc));\n\nconst u32Range = 0x1_00_00_00_00;\n","import { assertValidSelector, type Signer } from '@kagal/ed25519-secret';\n\nimport {\n TAI64N_CONTENT_LENGTH,\n TAI64N_CONTENT_TYPE,\n TAI64N_HEADER_KEY_SELECTOR,\n TAI64N_HEADER_LEAP_SECONDS,\n TAI64N_HEADER_NONCE,\n TAI64N_HEADER_SIGNATURE,\n} from './const';\nimport { buildCORSHeaders } from './cors';\nimport { type LeapSeconds, TAI_LEAP_SECONDS } from './leap-seconds';\nimport { extractNonce, type Nonce } from './nonce';\nimport { tai64nLabel } from './utils';\n\nconst ALLOW_HEADER = 'GET, HEAD, OPTIONS';\n\nconst textEncoder = new TextEncoder();\n\n/**\n * Domain-separation tag prepended to every signed\n * payload. Versioned so a v2 protocol can use the same\n * key without colliding with v1 signatures, and\n * NUL-terminated so the boundary between tag and\n * label is unambiguous.\n */\nconst DOMAIN_SEPARATOR = textEncoder.encode('taistamp-v1\\0');\n\nconst asBytes = (source: BufferSource): Uint8Array => {\n if (source instanceof Uint8Array) {\n return source;\n }\n if (ArrayBuffer.isView(source)) {\n return new Uint8Array(\n source.buffer,\n source.byteOffset,\n source.byteLength,\n );\n }\n return new Uint8Array(source);\n};\n\n/**\n * Encode `source` as a Structured Field Value sf-binary\n * item per [RFC 9651 §3.3.5]: standard base64 with `=`\n * padding, wrapped in a leading and trailing colon.\n *\n * @see {@link https://www.rfc-editor.org/rfc/rfc9651#name-byte-sequences}\n */\nconst encodeStructuredBinary = (source: BufferSource): string => {\n // Spread is safe for the 64-byte signatures handled\n // here; revisit if larger payloads ever land.\n const bytes = asBytes(source);\n const standard = btoa(String.fromCodePoint(...bytes));\n return `:${standard}:`;\n};\n\n/**\n * Compose the byte sequence covered by a TAI-Signature.\n *\n * @param label - the 25-byte TAI64N label string the\n * server is returning\n * @param leapSeconds - the leap-seconds count the server\n * advertises in `TAI-Leap-Seconds`\n * @param selector - the key selector the server\n * advertises in `TAI-Key-Selector`; verifiers use\n * this to look up the public key in DNS at\n * `<selector>._taistamp.<host>`\n * @param nonce - the client-supplied nonce, echoed\n * verbatim in `TAI-Nonce`; brand a verifier-side\n * string with {@link asNonce} before passing it in\n * @returns the byte sequence verifiers reconstruct\n * from the response and pass to their public-key\n * verify routine. The framing is the\n * domain-separation tag (`taistamp-v1` plus a\n * trailing NUL byte), then the label bytes, then\n * the leap-seconds count as a 4-byte big-endian\n * unsigned integer, then a 1-byte selector length,\n * then the selector bytes, then the nonce bytes.\n *\n * @remarks\n * Binding the selector into the signed payload stops a\n * downgrade attacker from rewriting `TAI-Key-Selector`\n * to point at a compromised or weaker key — the\n * signature would no longer verify under that key.\n * `leapSeconds` is encoded as a 4-byte big-endian\n * unsigned integer; the selector is length-prefixed by\n * a single byte (selectors are ≤ 63 chars per\n * {@link newTaistampHandler}'s validation).\n */\nexport const composeSignaturePayload = (\n label: string,\n leapSeconds: LeapSeconds,\n selector: string,\n nonce: Nonce,\n): ArrayBuffer => {\n const labelBytes = textEncoder.encode(label);\n const selectorBytes = textEncoder.encode(selector);\n const nonceBytes = textEncoder.encode(nonce);\n\n const buffer = new ArrayBuffer(\n DOMAIN_SEPARATOR.length +\n labelBytes.length +\n 4 +\n 1 +\n selectorBytes.length +\n nonceBytes.length,\n );\n const view = new Uint8Array(buffer);\n\n let offset = 0;\n view.set(DOMAIN_SEPARATOR, offset);\n offset += DOMAIN_SEPARATOR.length;\n view.set(labelBytes, offset);\n offset += labelBytes.length;\n new DataView(buffer).setUint32(offset, leapSeconds, false);\n offset += 4;\n view[offset] = selectorBytes.length;\n offset += 1;\n view.set(selectorBytes, offset);\n offset += selectorBytes.length;\n view.set(nonceBytes, offset);\n\n return buffer;\n};\n\n/**\n * Configuration for {@link newTaistampHandler}.\n *\n * @remarks\n * `signer` and `selector` are co-required: pass both\n * to enable authenticated responses, or neither for\n * an unsigned handler. Passing only one is rejected\n * at construction time — without the selector\n * verifiers cannot find the key in DNS, and a\n * selector without a signer is a misconfiguration.\n */\nexport interface TaistampHandlerConfig {\n /**\n * Key selector advertised in the `TAI-Key-Selector`\n * response header and bound into the signed payload.\n * Verifiers look up the public key at\n * `<selector>._taistamp.<host>` in DNS.\n *\n * Must match `[A-Za-z][A-Za-z0-9_-]{0,62}` (a single\n * DNS label starting with a letter, using\n * DKIM-compatible characters and a valid sf-token);\n * rotate by changing the selector and publishing a\n * new TXT record.\n */\n selector?: string\n\n /**\n * {@link Signer} that produces `TAI-Signature` over\n * the framed payload from {@link composeSignaturePayload}.\n * Without a signer the nonce is still echoed but the\n * response is unsigned.\n */\n signer?: Signer\n\n /**\n * CORS origin policy. Defaults to `'*'`; pass `false`\n * to disable CORS entirely, or a specific origin\n * (e.g. `'https://example.com'`) to scope the policy.\n *\n * Every response (`GET` / `HEAD` / `OPTIONS` / `405`)\n * gains `Access-Control-Allow-Origin`; pre-flight\n * `OPTIONS` also carries `-Allow-Methods`,\n * `-Allow-Headers`, `-Expose-Headers`, and\n * `-Max-Age: 600` per spec §4.2; success\n * `GET` / `HEAD` carry `-Expose-Headers` so browser\n * JS can read the `TAI-*` response headers. A\n * non-`'*'` value adds `Vary: Origin` so caches can\n * keep per-origin variants distinct.\n *\n * Disabling CORS does not affect method discovery:\n * `OPTIONS` is still answered with `200` and\n * `Allow: GET, HEAD, OPTIONS` per RFC 9110 §9.3.7.\n */\n cors?: false | string\n}\n\n/**\n * Validate a {@link TaistampHandlerConfig} and return\n * it unchanged when every field is well-formed.\n * Throws `TypeError` otherwise so misconfiguration\n * surfaces at handler construction rather than on the\n * first request.\n *\n * @throws TypeError if `signer` and `selector` are not\n * both set or both unset, or if `selector` does not\n * match `[A-Za-z][A-Za-z0-9_-]{0,62}`.\n */\nconst validateHandlerConfig = (\n config: TaistampHandlerConfig,\n): TaistampHandlerConfig => {\n const { cors, selector, signer } = config;\n\n if ((signer === undefined) !== (selector === undefined)) {\n throw new TypeError(\n 'newTaistampHandler: signer and selector must be set together',\n );\n }\n if (cors !== undefined && cors !== false && typeof cors !== 'string') {\n throw new TypeError(\n 'newTaistampHandler: cors must be false or a string origin',\n );\n }\n if (selector !== undefined) {\n assertValidSelector(selector, 'newTaistampHandler');\n }\n\n return config;\n};\n\n/**\n * Validate a {@link TaistampHandlerConfig} and derive\n * the construction-time state the handler closure\n * captures: the pre-baked CORS header maps and an\n * `addSignature` helper that mutates a response\n * `Headers` to carry `TAI-Key-Selector` and\n * `TAI-Signature` over the framed payload, present\n * only when both `signer` and `selector` are\n * configured. Validation is delegated to\n * {@link validateHandlerConfig}.\n *\n * @throws TypeError per {@link validateHandlerConfig}.\n */\nconst fromHandlerConfig = (config: TaistampHandlerConfig) => {\n const { cors, selector, signer } = validateHandlerConfig(config);\n\n const corsHeaders = buildCORSHeaders(cors);\n\n const addSignature = selector !== undefined && signer !== undefined ?\n async (\n headers: Headers,\n label: string,\n nonce: Nonce,\n ): Promise<void> => {\n const payload = composeSignaturePayload(\n label, TAI_LEAP_SECONDS, selector, nonce,\n );\n const signature = await signer.sign(payload);\n headers.set(TAI64N_HEADER_KEY_SELECTOR, selector);\n headers.set(\n TAI64N_HEADER_SIGNATURE,\n encodeStructuredBinary(signature),\n );\n } :\n undefined;\n\n return { addSignature, corsHeaders };\n};\n\n/**\n * Build a handler for `/.well-known/taistamp`.\n *\n * @param config - optional {@link TaistampHandlerConfig}\n * @returns an `async (request) => Response` callable\n * directly as a Web `fetch` handler or as a Hono\n * route handler.\n *\n * @throws TypeError if `signer` and `selector` are not\n * both set or both unset, or if `selector` does not\n * match `[A-Za-z][A-Za-z0-9_-]{0,62}`.\n *\n * @remarks\n * Behaviour:\n *\n * - `GET` / `HEAD` — body is a fresh 25-byte TAI64N\n * label (`HEAD` omits the body). Response headers:\n * Content-Type `application/tai64n`, Content-Length\n * `25`, Cache-Control `no-store`, plus\n * `TAI-Leap-Seconds` carrying the current count.\n * - `OPTIONS` — `200` with `Allow: GET, HEAD, OPTIONS`.\n * When CORS is enabled (the default) the response\n * also carries `Access-Control-Allow-*` and\n * `-Expose-Headers` per\n * {@link TaistampHandlerConfig.cors}. `OPTIONS` is\n * never signed.\n * - Any other method — `405 Method Not Allowed` with\n * `Allow: GET, HEAD, OPTIONS`.\n * - Request `TAI-Nonce` — on `GET`, the value is echoed\n * verbatim in the response. A missing, empty,\n * duplicated, structurally malformed, or out-of-range\n * (14..174 octets) field is treated as absent (no\n * echo, no signature) per spec §5.2 — see\n * {@link extractNonce}. `HEAD`, `OPTIONS`, and `405`\n * responses never carry `TAI-Nonce` per spec §4.1.\n * - Request `TAI-Nonce` *and* `signer` configured *and*\n * the request method is `GET` — adds\n * `TAI-Key-Selector` and `TAI-Signature` (sf-binary)\n * over the bytes produced by\n * {@link composeSignaturePayload}. The\n * domain-separation tag means the same key cannot\n * be tricked into producing valid signatures for\n * other protocols. `HEAD`, `OPTIONS`, and `405`\n * responses are never signed.\n *\n * The corresponding public key is expected to be\n * published out-of-band as a DNS TXT record at\n * `<selector>._taistamp.<host>` — verifiers fetch the\n * key by selector so the operator can rotate keys by\n * publishing a new selector while the old one is\n * still cached.\n *\n * @see {@link https://cr.yp.to/libtai/tai64.html} for\n * TAI64N format\n */\nexport const newTaistampHandler = (\n config: TaistampHandlerConfig = {},\n): ((request: Request) => Promise<Response>) => {\n const { addSignature, corsHeaders } = fromHandlerConfig(config);\n\n return async (request) => {\n if (request.method === 'OPTIONS') {\n return new Response(undefined, {\n status: 200,\n headers: { allow: ALLOW_HEADER, ...corsHeaders.preflight },\n });\n }\n\n if (request.method !== 'GET' && request.method !== 'HEAD') {\n return new Response(undefined, {\n status: 405,\n headers: { allow: ALLOW_HEADER, ...corsHeaders.error },\n });\n }\n\n const nonce = extractNonce(request.headers);\n const label = tai64nLabel();\n\n const headers = new Headers({\n 'cache-control': 'no-store',\n 'content-length': String(TAI64N_CONTENT_LENGTH),\n 'content-type': TAI64N_CONTENT_TYPE,\n [TAI64N_HEADER_LEAP_SECONDS]: String(TAI_LEAP_SECONDS),\n ...corsHeaders.response,\n });\n\n if (nonce && request.method === 'GET') {\n headers.set(TAI64N_HEADER_NONCE, nonce);\n if (addSignature) {\n await addSignature(headers, label, nonce);\n }\n }\n\n const body = request.method === 'HEAD' ? undefined : label;\n return new Response(body, { status: 200, headers });\n };\n};\n","import pkg from '../package.json' with { type: 'json' };\n\n/** Package version from package.json. */\nexport const VERSION: string = pkg.version;\n\nexport {\n newSigner as newEd25519Signer,\n type Signer,\n} from '@kagal/ed25519-secret';\n\nexport * from './const';\nexport {\n composeSignaturePayload,\n newTaistampHandler,\n type TaistampHandlerConfig,\n} from './handler';\nexport {\n asLeapSeconds,\n extractLeapSeconds,\n type LeapSeconds,\n TAI_LEAP_SECONDS,\n TAI_LEAP_SECONDS_MAX,\n} from './leap-seconds';\nexport {\n asNonce,\n type Nonce,\n} from './nonce';\nexport {\n fromUTC,\n now,\n tai64nLabel,\n tai64nLabelFromUTC,\n} from './utils';\n"],"mappings":";;ACAA,MAAa,gBAAgB;AAG7B,MAAa,cAAc;AAE3B,MAAa,sBAAsB;AACnC,MAAa,wBAAwB;AAErC,MAAa,6BAA6B;AAC1C,MAAa,6BAA6B;AAC1C,MAAa,sBAAsB;AACnC,MAAa,0BAA0B;AAEvC,MAAa,iBAAiB;ACD9B,MAAM,qBAAqB;AAC3B,MAAM,qBAAqB;AAC3B,MAAM,sBAAsB;CAC1B;CACA;CACA;CACA;AACF,EAAE,KAAK,IAAI;AAIX,MAAM,eAAe;;;;;;;;;;EA6BrB,OAAa;GAGX,+BACS;GAAE,GAAA;EAAW;EAAe,WAAW;GAAE,+BAAA;GAElD,gCAAuB;GACvB,gCACoB;GACpB,iCAAO;GACL,0BAAO;GACL,GAAA;;EAEF,UAAA;GACA,+BAAW;GACT,iCAA+B;GAC/B,GAAA;;;;MAMF,uBAAU;MAER,iBAAA,UAAA;KACA,CAAG,OAAA,UAAA,KAAA,KAAA,QAAA,KAAA,QAAA,YAAA,OAAA,KAAA;QACL;;;;;;;;;AC/CJ,MAAa,oBACX;MAOA,WAAO,UAAA;CACT,IAAA,CAAA,SAAA,MAAA,SAAA,MAAA,MAAA,SAAA,OAAA,CAAA,kBAAA,KAAA,KAAA,GAAA,OAAA,KAAA;;;;;;;;;;;EAcA,QAAa;;;;;;;;;CAUb,MAAM,QAAA,MAAA;;;;;;;AAUN,MAAa,mBAAA,YACX,OAC4B,eAAA;MAC5B,WAAY,WAAY;CACxB,IAAI,kBAAS,YAAqB,OAAM;CACxC,IAAA,YAAO,OAAc,MAAO,GAAI,OAAA,IAAA,WAAA,OAAA,QAAA,OAAA,YAAA,OAAA,UAAA;CAClC,OAAA,IAAA,WAAA,MAAA;;;;;;ACxEA,MAAa,2BAAmB,OAAA,aAAA,UAAA,UAAA;;;;;;CAOhC,IAAA,SAAa;;;;;;;;;;CAWb,UAAM,cAAA;;;;;;;;CAwBN,IAAA,aAAwB,KAAA,GAAA,oBAAqC,UAAA,oBAAA;CAC3D,OACG;;;;;;;GAaL,MAAa,UAAA,wBAAwD,OAAA,IAAA,UAAA,KAAA;GACnE,MAAM,YAAQ,MAAY,OAAA,KAAA,OAAmB;GAC7C,QAAO,IAAA,4BAAqC,QAAK;GACnD,QAAA,IAAA,yBAAA,uBAAA,SAAA,CAAA;;EC1DA;CAIE;;MAAoB,sBAAA,SAAA,CAAA,MAAA;CAAyB,MAAA,EAAA,cAAA,gBAAA,kBAAA,MAAA;CAC/C,OAAA,OAAA,YAAA;EAEA,IAAa,QAAA,WAAuB,WAAA,OAAA,IAAA,SAAA,KAAA,GAAA;GAElC,QAAO;GACT,SAAA;IAEA,OAAa;IACX,GAAM,YAAO;GAEb;EACA,CAAA;EAMA,IAAA,QAJiB,WAAM,SAAa,QAAS,WAC5B,QAAM,OAAW,IAAE,SAAY,KAGnB,GAFb;GAGlB,QAAA;GAEA,SAAa;IAEb,OAAM;;GCtBN;EAEA,CAAA;;;;;;;;GASA,GAAM,YAAA;EAEN,CAAA;EACE,IAAI,SAAA,QAAkB,WAAA,OACpB;GAEF,QAAI,IAAA,qBACF,KAAO;GAMT,IAAA,cAAW,MAAW,aAAM,SAAA,OAAA,KAAA;EAC9B;;;;;;;;MAcE,UADiB"}
1
+ {"version":3,"file":"index.mjs","names":["pkg.version"],"sources":["../package.json","../src/const.ts","../src/cors.ts","../src/leap-seconds.ts","../src/nonce.ts","../src/utils.ts","../src/handler.ts","../src/index.ts"],"sourcesContent":["","export const TAISTAMP_PATH = '/.well-known/taistamp';\n\n/** @deprecated Renamed to {@link TAISTAMP_PATH}. */\nexport const TAI64N_PATH = TAISTAMP_PATH;\n\nexport const TAI64N_CONTENT_TYPE = 'application/tai64n';\nexport const TAI64N_CONTENT_LENGTH = 1 + 16 + 8; // '@' + sec (16 hex chars) + nano (8 hex chars)\n\nexport const TAI64N_HEADER_KEY_SELECTOR = 'TAI-Key-Selector';\nexport const TAI64N_HEADER_LEAP_SECONDS = 'TAI-Leap-Seconds';\nexport const TAI64N_HEADER_NONCE = 'TAI-Nonce';\nexport const TAI64N_HEADER_SIGNATURE = 'TAI-Signature';\n\nexport const TAI64_EPOCH_HI = 0x40_00_00_00;\n","import {\n TAI64N_HEADER_KEY_SELECTOR,\n TAI64N_HEADER_LEAP_SECONDS,\n TAI64N_HEADER_NONCE,\n TAI64N_HEADER_SIGNATURE,\n} from './const';\n\n// `Access-Control-Allow-Methods` (Fetch) is the list\n// of methods JS would ever preflight, so `OPTIONS` is\n// omitted. This is intentionally narrower than the\n// `Allow` header (RFC 9110 §9.3.7 method discovery,\n// `GET, HEAD, OPTIONS`) the handler itself emits.\nconst CORS_ALLOW_METHODS = 'GET, HEAD';\nconst CORS_ALLOW_HEADERS = TAI64N_HEADER_NONCE;\nconst CORS_EXPOSE_HEADERS = [\n TAI64N_HEADER_LEAP_SECONDS,\n TAI64N_HEADER_NONCE,\n TAI64N_HEADER_KEY_SELECTOR,\n TAI64N_HEADER_SIGNATURE,\n].join(', ');\n// Spec §5.2 SHOULDs at least 600s; 10 minutes is the\n// floor the spec example uses and keeps high-traffic\n// cross-origin clients off a pre-flight per fetch.\nconst CORS_MAX_AGE = '600';\n\n/**\n * The three CORS header maps the handler splices into\n * responses, keyed by response kind.\n *\n * - `preflight` — added to `OPTIONS 200` replies.\n * - `response` — added to successful `GET` / `HEAD`\n * replies; carries `Access-Control-Expose-Headers`\n * so browser JS can read the `TAI-*` headers.\n * - `error` — added to `405` replies; just the origin\n * header (and `Vary` when scoped).\n */\nexport type CORSHeaderSets = {\n error: Record<string, string>\n preflight: Record<string, string>\n response: Record<string, string>\n};\n\n/**\n * Pre-bake the three CORS header maps the handler\n * splices into responses, keyed by response kind.\n * `cors === false` collapses every map to `{}` so the\n * spread is a no-op; missing or empty input falls back\n * to `'*'`; `cors === '*'` skips `Vary: Origin` (a\n * wildcard does not vary by origin); a scoped origin\n * adds `Vary: Origin` so caches can keep per-origin\n * variants distinct.\n */\nexport const buildCORSHeaders = (\n cors: false | string | undefined,\n): CORSHeaderSets => {\n if (cors === false) {\n return { error: {}, preflight: {}, response: {} };\n }\n const origin = cors || '*';\n const vary: Record<string, string> =\n origin === '*' ? {} : { vary: 'Origin' };\n return {\n error: {\n 'access-control-allow-origin': origin,\n ...vary,\n },\n preflight: {\n 'access-control-allow-origin': origin,\n 'access-control-allow-methods': CORS_ALLOW_METHODS,\n 'access-control-allow-headers': CORS_ALLOW_HEADERS,\n 'access-control-expose-headers': CORS_EXPOSE_HEADERS,\n 'access-control-max-age': CORS_MAX_AGE,\n ...vary,\n },\n response: {\n 'access-control-allow-origin': origin,\n 'access-control-expose-headers': CORS_EXPOSE_HEADERS,\n ...vary,\n },\n };\n};\n","// cspell:words IERS\n\nimport { TAI64N_HEADER_LEAP_SECONDS } from './const';\n\n/**\n * Upper bound for `leapSeconds` in the taistamp signed\n * payload. The framing encodes the value as a 4-byte\n * big-endian unsigned integer, so any input outside\n * `[0, 2^32-1]` cannot be represented. Verifiers MUST\n * treat an out-of-range `TAI-Leap-Seconds` response\n * header as unsigned, per spec §5.3.\n */\nexport const TAI_LEAP_SECONDS_MAX = 0xFF_FF_FF_FF;\n\ndeclare const LeapSecondsBrand: unique symbol;\n\n/**\n * `number` that has been confirmed to fit the\n * `[0, TAI_LEAP_SECONDS_MAX]` u32be range required by\n * the taistamp signed-payload framing. Construct only\n * via {@link extractLeapSeconds} or {@link asLeapSeconds};\n * the brand prevents an arbitrary number from reaching\n * the signing path.\n */\nexport type LeapSeconds = number & { readonly [LeapSecondsBrand]: never };\n\n/**\n * Coerce a `number` to a {@link LeapSeconds}. Returns\n * `undefined` when `value` is non-integer, negative,\n * or exceeds {@link TAI_LEAP_SECONDS_MAX}.\n */\nexport const asLeapSeconds = (\n value: number,\n): LeapSeconds | undefined => {\n if (\n !Number.isInteger(value) ||\n value < 0 ||\n value > TAI_LEAP_SECONDS_MAX\n ) return undefined;\n return value as LeapSeconds;\n};\n\n/**\n * Current TAI − UTC offset in whole seconds, used by\n * `fromUTC()` and emitted in the `TAI-Leap-Seconds`\n * response header. The value 37 has been in force\n * since 2017-01-01; update on the next IERS leap-second\n * announcement.\n *\n * @remarks\n * Stays a single `LeapSeconds` until a leap-seconds\n * table is added so the offset can be computed for any\n * TAI second; this constant becomes redundant then.\n */\nexport const TAI_LEAP_SECONDS: LeapSeconds = 37 as LeapSeconds;\n\n/**\n * Strict decimal integer: a single `0` or a non-zero\n * leading digit followed by digits. Rejects hex\n * (`0x25`), float-style integers (`37.0`), signs,\n * whitespace, exponential notation, and leading zeros\n * — every input `Number()` would silently coerce to\n * an integer despite not being a canonical decimal.\n */\nconst DECIMAL_INTEGER = /^(?:0|[1-9]\\d*)$/;\n\n/**\n * Extract a usable leap-seconds count from response\n * headers. Returns `undefined` when the\n * `TAI-Leap-Seconds` field is missing, empty,\n * non-numeric, non-integer, negative, or out-of-range\n * — every \"treat as unsigned\" case in spec §5.3\n * collapsed into one verdict.\n */\nexport const extractLeapSeconds = (\n headers: Headers,\n): LeapSeconds | undefined => {\n const raw = headers.get(TAI64N_HEADER_LEAP_SECONDS);\n if (!raw || !DECIMAL_INTEGER.test(raw)) return undefined;\n return asLeapSeconds(Number(raw));\n};\n","import { TAI64N_HEADER_NONCE } from './const';\n\n/**\n * Wire-form lower bound on `TAI-Nonce`. Spec §5.4 sets\n * the normative bound on decoded length (≥ 7 octets);\n * 14 is the smallest wire form (`:` + 12 base64 chars\n * + `:`) that can decode to 7 octets, so the wire\n * check rejects undersize input before base64 decoding.\n * sf-binary is ASCII-only — the string length equals\n * the octet count.\n */\nexport const NONCE_MIN_OCTETS = 14;\n\n/**\n * Wire-form upper bound on `TAI-Nonce`. Spec §5.4 sets\n * the normative bound on decoded length (≤ 129 octets);\n * 174 is the longest wire form (`:` + 172 base64 chars\n * + `:`) whose decoded payload stays within 129 octets,\n * so the wire check rejects oversize input before\n * base64 decoding.\n */\nexport const NONCE_MAX_OCTETS = 174;\n\n/**\n * sf-binary item per RFC 9651 §3.3.5: standard base64\n * with `=` padding, wrapped in a leading and trailing\n * colon. The empty payload (`::`) is excluded — a\n * zero-length nonce is treated as absent per spec\n * §5.4. The alphabet contains no `,`, so a duplicated\n * field (joined by the Web `Headers` API with `,`)\n * fails the same check.\n */\nconst SF_BINARY_PATTERN =\n /^:(?:[\\d+/A-Za-z]{4})*(?:[\\d+/A-Za-z]{4}|[\\d+/A-Za-z]{3}=|[\\d+/A-Za-z]{2}==):$/;\n\ndeclare const NonceBrand: unique symbol;\n\n/**\n * `string` that has been confirmed to satisfy the\n * sf-binary syntax of RFC 9651 §3.3.5 and to fall\n * inside the wire-form length range\n * `[NONCE_MIN_OCTETS, NONCE_MAX_OCTETS]` — the\n * pre-decode form of spec §5.4's normative\n * decoded-length bound of 7..129 octets. Construct\n * only via {@link asNonce} or {@link extractNonce};\n * the brand prevents arbitrary strings from reaching\n * the signing path.\n */\nexport type Nonce = string & { readonly [NonceBrand]: never };\n\n/**\n * Brand `value` as a {@link Nonce} when it satisfies\n * sf-binary syntax (RFC 9651 §3.3.5) and falls inside\n * `[NONCE_MIN_OCTETS, NONCE_MAX_OCTETS]` — the wire\n * range equivalent to spec §5.4's normative\n * decoded-length bound of 7..129 octets. Returns\n * `undefined` for anything else — every \"treat as\n * absent\" case in spec §5.4 collapsed into one\n * verdict.\n */\nexport const asNonce = (value: string): Nonce | undefined => {\n if (\n !value ||\n value.length < NONCE_MIN_OCTETS ||\n value.length > NONCE_MAX_OCTETS ||\n !SF_BINARY_PATTERN.test(value)\n ) return undefined;\n return value as Nonce;\n};\n\n/**\n * Extract a usable `TAI-Nonce` from request headers.\n * Returns `undefined` when the field is missing or\n * fails {@link asNonce} validation.\n */\nexport const extractNonce = (headers: Headers): Nonce | undefined => {\n const value = headers.get(TAI64N_HEADER_NONCE);\n return value === null ? undefined : asNonce(value);\n};\n","import { TAI64_EPOCH_HI } from './const';\nimport { TAI_LEAP_SECONDS } from './leap-seconds';\n\ntype timestamp = {\n nano: number\n sec: number\n\n offset?: number\n};\n\nexport const fromUTC = (utc: number): timestamp => {\n // TODO: leap seconds table\n const sec = Math.floor(utc / 1000) + TAI_LEAP_SECONDS;\n const nano = (utc % 1000) * 1e6;\n return { sec, nano, offset: TAI_LEAP_SECONDS };\n};\n\nexport const now = (): timestamp => {\n const utc = Date.now();\n return fromUTC(utc);\n};\n\nexport const tai64nLabel = (value?: timestamp): string => {\n const { sec, nano } = value ?? now();\n\n const secHi = Math.trunc(sec / u32Range) + TAI64_EPOCH_HI;\n const secLo = sec % u32Range;\n\n const secHiHex = secHi.toString(16).padStart(8, '0');\n const secLoHex = secLo.toString(16).padStart(8, '0');\n const nanoHex = nano.toString(16).padStart(8, '0');\n\n return `@${secHiHex}${secLoHex}${nanoHex}`;\n};\n\nexport const tai64nLabelFromUTC = (utc: number): string => tai64nLabel(fromUTC(utc));\n\nconst u32Range = 0x1_00_00_00_00;\n","import {\n assertValidSelector,\n decodeBase64,\n encodeBase64,\n type Signer,\n} from '@kagal/ed25519-secret';\n\nimport {\n TAI64N_CONTENT_LENGTH,\n TAI64N_CONTENT_TYPE,\n TAI64N_HEADER_KEY_SELECTOR,\n TAI64N_HEADER_LEAP_SECONDS,\n TAI64N_HEADER_NONCE,\n TAI64N_HEADER_SIGNATURE,\n} from './const';\nimport { buildCORSHeaders } from './cors';\nimport { type LeapSeconds, TAI_LEAP_SECONDS } from './leap-seconds';\nimport { extractNonce, type Nonce } from './nonce';\nimport { tai64nLabel } from './utils';\n\nconst ALLOW_HEADER = 'GET, HEAD, OPTIONS';\n\nconst textEncoder = new TextEncoder();\n\n/**\n * Domain-separation tag prepended to every signed\n * payload. Versioned so a v2 protocol can use the same\n * key without colliding with v1 signatures, and\n * NUL-terminated so the boundary between tag and\n * label is unambiguous.\n */\nconst DOMAIN_SEPARATOR = textEncoder.encode('taistamp-v1\\0');\n\n/**\n * Compose the byte sequence covered by a TAI-Signature.\n *\n * @param label - the 25-byte TAI64N label string the\n * server is returning\n * @param leapSeconds - the leap-seconds count the server\n * advertises in `TAI-Leap-Seconds`\n * @param selector - the key selector the server\n * advertises in `TAI-Key-Selector`; verifiers use\n * this to look up the public key in DNS at\n * `<selector>._taistamp.<host>`\n * @param nonce - the client-supplied nonce, echoed\n * verbatim in `TAI-Nonce`; brand a verifier-side\n * string with {@link asNonce} before passing it in\n * @returns the byte sequence verifiers reconstruct\n * from the response and pass to their public-key\n * verify routine. The framing is the\n * domain-separation tag (`taistamp-v1` plus a\n * trailing NUL byte), then the label bytes, then\n * the leap-seconds count as a 4-byte big-endian\n * unsigned integer, then a 1-byte selector length,\n * then the selector bytes, then the decoded sf-binary\n * octets of the nonce (spec §6.1 — the wire\n * `:base64:` framing is not signed).\n *\n * @remarks\n * Binding the selector into the signed payload stops a\n * downgrade attacker from rewriting `TAI-Key-Selector`\n * to point at a compromised or weaker key — the\n * signature would no longer verify under that key.\n * `leapSeconds` is encoded as a 4-byte big-endian\n * unsigned integer; the selector is length-prefixed by\n * a single byte (selectors are ≤ 63 chars per\n * {@link newTaistampHandler}'s validation).\n */\nexport const composeSignaturePayload = (\n label: string,\n leapSeconds: LeapSeconds,\n selector: string,\n nonce: Nonce,\n): ArrayBuffer => {\n const labelBytes = textEncoder.encode(label);\n const selectorBytes = textEncoder.encode(selector);\n const nonceBytes = decodeBase64(nonce.slice(1, -1));\n\n const buffer = new ArrayBuffer(\n DOMAIN_SEPARATOR.length +\n labelBytes.length +\n 4 +\n 1 +\n selectorBytes.length +\n nonceBytes.length,\n );\n const view = new Uint8Array(buffer);\n\n let offset = 0;\n view.set(DOMAIN_SEPARATOR, offset);\n offset += DOMAIN_SEPARATOR.length;\n view.set(labelBytes, offset);\n offset += labelBytes.length;\n new DataView(buffer).setUint32(offset, leapSeconds, false);\n offset += 4;\n view[offset] = selectorBytes.length;\n offset += 1;\n view.set(selectorBytes, offset);\n offset += selectorBytes.length;\n view.set(nonceBytes, offset);\n\n return buffer;\n};\n\n/**\n * Configuration for {@link newTaistampHandler}.\n *\n * @remarks\n * `signer` and `selector` are co-required: pass both\n * to enable authenticated responses, or neither for\n * an unsigned handler. Passing only one is rejected\n * at construction time — without the selector\n * verifiers cannot find the key in DNS, and a\n * selector without a signer is a misconfiguration.\n */\nexport interface TaistampHandlerConfig {\n /**\n * Key selector advertised in the `TAI-Key-Selector`\n * response header and bound into the signed payload.\n * Verifiers look up the public key at\n * `<selector>._taistamp.<host>` in DNS.\n *\n * Must match `[A-Za-z][A-Za-z0-9_-]{0,62}` (a single\n * DNS label starting with a letter, using\n * DKIM-compatible characters and a valid sf-token);\n * rotate by changing the selector and publishing a\n * new TXT record.\n */\n selector?: string\n\n /**\n * {@link Signer} that produces `TAI-Signature` over\n * the framed payload from {@link composeSignaturePayload}.\n * Without a signer the nonce is still echoed but the\n * response is unsigned.\n */\n signer?: Signer\n\n /**\n * CORS origin policy. Defaults to `'*'`; pass `false`\n * to disable CORS entirely, or a specific origin\n * (e.g. `'https://example.com'`) to scope the policy.\n *\n * Every response (`GET` / `HEAD` / `OPTIONS` / `405`)\n * gains `Access-Control-Allow-Origin`; pre-flight\n * `OPTIONS` also carries `-Allow-Methods`,\n * `-Allow-Headers`, `-Expose-Headers`, and\n * `-Max-Age: 600` per spec §5.2; success\n * `GET` / `HEAD` carry `-Expose-Headers` so browser\n * JS can read the `TAI-*` response headers. A\n * non-`'*'` value adds `Vary: Origin` so caches can\n * keep per-origin variants distinct.\n *\n * Disabling CORS does not affect method discovery:\n * `OPTIONS` is still answered with `200` and\n * `Allow: GET, HEAD, OPTIONS` per RFC 9110 §9.3.7.\n */\n cors?: false | string\n}\n\n/**\n * Validate a {@link TaistampHandlerConfig} and return\n * it unchanged when every field is well-formed.\n * Throws `TypeError` otherwise so misconfiguration\n * surfaces at handler construction rather than on the\n * first request.\n *\n * @throws TypeError if `signer` and `selector` are not\n * both set or both unset, or if `selector` does not\n * match `[A-Za-z][A-Za-z0-9_-]{0,62}`.\n */\nconst validateHandlerConfig = (\n config: TaistampHandlerConfig,\n): TaistampHandlerConfig => {\n const { cors, selector, signer } = config;\n\n if ((signer === undefined) !== (selector === undefined)) {\n throw new TypeError(\n 'newTaistampHandler: signer and selector must be set together',\n );\n }\n if (cors !== undefined && cors !== false && typeof cors !== 'string') {\n throw new TypeError(\n 'newTaistampHandler: cors must be false or a string origin',\n );\n }\n if (selector !== undefined) {\n assertValidSelector(selector, 'newTaistampHandler');\n }\n\n return config;\n};\n\n/**\n * Validate a {@link TaistampHandlerConfig} and derive\n * the construction-time state the handler closure\n * captures: the pre-baked CORS header maps and an\n * `addSignature` helper that mutates a response\n * `Headers` to carry `TAI-Key-Selector` and\n * `TAI-Signature` over the framed payload, present\n * only when both `signer` and `selector` are\n * configured. Validation is delegated to\n * {@link validateHandlerConfig}.\n *\n * @throws TypeError per {@link validateHandlerConfig}.\n */\nconst fromHandlerConfig = (config: TaistampHandlerConfig) => {\n const { cors, selector, signer } = validateHandlerConfig(config);\n\n const corsHeaders = buildCORSHeaders(cors);\n\n const addSignature = selector !== undefined && signer !== undefined ?\n async (\n headers: Headers,\n label: string,\n nonce: Nonce,\n ): Promise<void> => {\n const payload = composeSignaturePayload(\n label, TAI_LEAP_SECONDS, selector, nonce,\n );\n const signature = await signer.sign(payload);\n headers.set(TAI64N_HEADER_KEY_SELECTOR, selector);\n headers.set(\n TAI64N_HEADER_SIGNATURE,\n `:${encodeBase64(new Uint8Array(signature))}:`,\n );\n } :\n undefined;\n\n return { addSignature, corsHeaders };\n};\n\n/**\n * Build a handler for `/.well-known/taistamp`.\n *\n * @param config - optional {@link TaistampHandlerConfig}\n * @returns an `async (request) => Response` callable\n * directly as a Web `fetch` handler or as a Hono\n * route handler.\n *\n * @throws TypeError if `signer` and `selector` are not\n * both set or both unset, or if `selector` does not\n * match `[A-Za-z][A-Za-z0-9_-]{0,62}`.\n *\n * @remarks\n * Behaviour:\n *\n * - `GET` / `HEAD` — body is a fresh 25-byte TAI64N\n * label (`HEAD` omits the body). Response headers:\n * Content-Type `application/tai64n`, Content-Length\n * `25`, Cache-Control `no-store`, plus\n * `TAI-Leap-Seconds` carrying the current count.\n * - `OPTIONS` — `200` with `Allow: GET, HEAD, OPTIONS`.\n * When CORS is enabled (the default) the response\n * also carries `Access-Control-Allow-*` and\n * `-Expose-Headers` per\n * {@link TaistampHandlerConfig.cors}. `OPTIONS` is\n * never signed.\n * - Any other method — `405 Method Not Allowed` with\n * `Allow: GET, HEAD, OPTIONS`.\n * - Request `TAI-Nonce` — on `GET`, the value is echoed\n * verbatim in the response. A missing, empty,\n * duplicated, structurally malformed, or\n * length-out-of-range field is treated as absent (no\n * echo, no signature) per spec §5.4 — see\n * {@link extractNonce}. `HEAD`, `OPTIONS`, and `405`\n * responses never carry `TAI-Nonce` per spec §5.1.\n * - Request `TAI-Nonce` *and* `signer` configured *and*\n * the request method is `GET` — adds\n * `TAI-Key-Selector` and `TAI-Signature` (sf-binary)\n * over the bytes produced by\n * {@link composeSignaturePayload}. The\n * domain-separation tag means the same key cannot\n * be tricked into producing valid signatures for\n * other protocols. `HEAD`, `OPTIONS`, and `405`\n * responses are never signed.\n *\n * The corresponding public key is expected to be\n * published out-of-band as a DNS TXT record at\n * `<selector>._taistamp.<host>` — verifiers fetch the\n * key by selector so the operator can rotate keys by\n * publishing a new selector while the old one is\n * still cached.\n *\n * @see {@link https://cr.yp.to/libtai/tai64.html} for\n * TAI64N format\n */\nexport const newTaistampHandler = (\n config: TaistampHandlerConfig = {},\n): ((request: Request) => Promise<Response>) => {\n const { addSignature, corsHeaders } = fromHandlerConfig(config);\n\n return async (request) => {\n if (request.method === 'OPTIONS') {\n return new Response(undefined, {\n status: 200,\n headers: { allow: ALLOW_HEADER, ...corsHeaders.preflight },\n });\n }\n\n if (request.method !== 'GET' && request.method !== 'HEAD') {\n return new Response(undefined, {\n status: 405,\n headers: { allow: ALLOW_HEADER, ...corsHeaders.error },\n });\n }\n\n const nonce = extractNonce(request.headers);\n const label = tai64nLabel();\n\n const headers = new Headers({\n 'cache-control': 'no-store',\n 'content-length': String(TAI64N_CONTENT_LENGTH),\n 'content-type': TAI64N_CONTENT_TYPE,\n [TAI64N_HEADER_LEAP_SECONDS]: String(TAI_LEAP_SECONDS),\n ...corsHeaders.response,\n });\n\n if (nonce && request.method === 'GET') {\n headers.set(TAI64N_HEADER_NONCE, nonce);\n if (addSignature) {\n await addSignature(headers, label, nonce);\n }\n }\n\n const body = request.method === 'HEAD' ? undefined : label;\n return new Response(body, { status: 200, headers });\n };\n};\n","import pkg from '../package.json' with { type: 'json' };\n\n/** Package version from package.json. */\nexport const VERSION: string = pkg.version;\n\nexport {\n newSigner as newEd25519Signer,\n type Signer,\n} from '@kagal/ed25519-secret';\n\nexport * from './const';\nexport {\n composeSignaturePayload,\n newTaistampHandler,\n type TaistampHandlerConfig,\n} from './handler';\nexport {\n asLeapSeconds,\n extractLeapSeconds,\n type LeapSeconds,\n TAI_LEAP_SECONDS,\n TAI_LEAP_SECONDS_MAX,\n} from './leap-seconds';\nexport {\n asNonce,\n type Nonce,\n} from './nonce';\nexport {\n fromUTC,\n now,\n tai64nLabel,\n tai64nLabelFromUTC,\n} from './utils';\n"],"mappings":";;ACAA,MAAa,gBAAgB;AAG7B,MAAa,cAAc;AAE3B,MAAa,sBAAsB;AACnC,MAAa,wBAAwB;AAErC,MAAa,6BAA6B;AAC1C,MAAa,6BAA6B;AAC1C,MAAa,sBAAsB;AACnC,MAAa,0BAA0B;AAEvC,MAAa,iBAAiB;ACD9B,MAAM,qBAAqB;AAC3B,MAAM,qBAAqB;AAC3B,MAAM,sBAAsB;CAC1B;CACA;CACA;CACA;AACF,EAAE,KAAK,IAAI;AAIX,MAAM,eAAe;;;;;;;;;;EA6BrB,OAAa;GAGX,+BACS;GAAE,GAAA;EAAW;EAAe,WAAW;GAAE,+BAAA;GAElD,gCAAuB;GACvB,gCACoB;GACpB,iCAAO;GACL,0BAAO;GACL,GAAA;;EAEF,UAAA;GACA,+BAAW;GACT,iCAA+B;GAC/B,GAAA;;;;MAMF,uBAAU;MAER,iBAAA,UAAA;KACA,CAAG,OAAA,UAAA,KAAA,KAAA,QAAA,KAAA,QAAA,YAAA,OAAA,KAAA;QACL;;;;;;;;;AC/CJ,MAAa,oBACX;MAOA,WAAO,UAAA;CACT,IAAA,CAAA,SAAA,MAAA,SAAA,MAAA,MAAA,SAAA,OAAA,CAAA,kBAAA,KAAA,KAAA,GAAA,OAAA,KAAA;;;;;;;;;;;EAcA,QAAa;;;;;;;;;CAUb,MAAM,QAAA,MAAA;;;;;;;AAUN,MAAa,mBAAA,YACX,OAC4B,eAAA;MAEvB,2BAAwB,OAAQ,aAAU,UAAA,UAAA;CAC/C,MAAA,aAAO,YAAwB,OAAC,KAAA;CAClC,MAAA,gBAAA,YAAA,OAAA,QAAA;;;;;;;;;;;CCrEA,KAAa,UAAA,cAAmB;;;;;;;;CAUhC,MAAa,EAAA,MAAA,UAAmB,WAAA;;;;;;;;;CAWhC,OAAM;;;;;;;;;;AA4BN,MAAa,sBAAgD,SAAA,CAAA,MAAA;CAC3D,MACG,EAAA,cACK,gBACN,kBAAM,MACL;CAEH,OAAO,OAAA,YAAA;EACT,IAAA,QAAA,WAAA,WAAA,OAAA,IAAA,SAAA,KAAA,GAAA;;;;;;EAOA,CAAA;EACE,IAAA,QAAM,WAAgB,SAAI,QAAA,WAAmB,QAAA,OAAA,IAAA,SAAA,KAAA,GAAA;GAC7C,QAAO;GACT,SAAA;;ICpEA,GAAa,YAAW;GAItB;EAAS,CAAA;EAAK,MADA,QAAM,aAAQ,QAAA,OAAA;EACR,MAAA,QAAA,YAAA;EAAyB,MAAA,UAAA,IAAA,QAAA;GAC/C,iBAAA;GAEA,kBAAoC,OAAA,EAAA;GAElC,gBADY;IAEd,6BAAA,OAAA,EAAA;GAEA,GAAa,YAAA;EACX,CAAA;EAEA,IAAA,SAAc,QAAK,WAAY,OAAQ;GACvC,QAAM,IAAQ,qBAAM,KAAA;GAMpB,IAAA,cAJuB,MAAS,aAAa,SAI3B,OAHK,KAAA;EAIzB;EAEA,MAAa,OAAA,QAAA,WAAsB,SAAwB,KAAY,IAAA;EAEvE,OAAM,IAAA,SAAW,MAAA;;GCjBjB;EAEA,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kagal/taistamp",
3
- "version": "0.0.5",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "description": "Signed TAI64N timestamps over HTTP",
6
6
  "author": "Apptly Software Ltd <oss@apptly.co>",
@@ -16,9 +16,19 @@
16
16
  },
17
17
  "keywords": [
18
18
  "kagal",
19
+ "cryptography",
20
+ "ed25519",
21
+ "eddsa",
22
+ "handler",
23
+ "http",
24
+ "nonce",
25
+ "rfc8032",
26
+ "signing",
19
27
  "tai64n",
20
28
  "taistamp",
21
- "typescript"
29
+ "timestamp",
30
+ "typescript",
31
+ "webcrypto"
22
32
  ],
23
33
  "types": "./dist/index.d.mts",
24
34
  "exports": {
@@ -31,7 +41,7 @@
31
41
  "dist"
32
42
  ],
33
43
  "dependencies": {
34
- "@kagal/ed25519-secret": "^0.1.2"
44
+ "@kagal/ed25519-secret": "^0.2.0"
35
45
  },
36
46
  "devDependencies": {
37
47
  "@cloudflare/vitest-pool-workers": "^0.13.1",
@@ -43,7 +53,7 @@
43
53
  "@vitest/coverage-istanbul": "^4.1.6",
44
54
  "eslint": "^9.39.4",
45
55
  "npm-run-all2": "^8.0.4",
46
- "obuild": "^0.4.34",
56
+ "obuild": "^0.4.35",
47
57
  "publint": "^0.3.21",
48
58
  "rimraf": "^6.1.3",
49
59
  "typescript": "^6.0.3",