@kagal/taistamp 0.1.2 → 0.2.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 +214 -53
- package/dist/_chunks/time.d.mts +144 -0
- package/dist/_chunks/time.mjs +68 -0
- package/dist/_chunks/time.mjs.map +1 -0
- package/dist/index.api.json +2094 -1546
- package/dist/index.d.mts +66 -83
- package/dist/index.d.ts +66 -83
- package/dist/index.mjs +52 -72
- package/dist/index.mjs.map +1 -1
- package/dist/utils.api.json +825 -0
- package/dist/utils.d.mts +29 -0
- package/dist/utils.d.ts +29 -0
- package/dist/utils.mjs +2 -0
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -72,6 +72,7 @@ Response headers on success:
|
|
|
72
72
|
| ------ | ----- |
|
|
73
73
|
| `Content-Type` | `application/tai64n` |
|
|
74
74
|
| `Content-Length` | `25` |
|
|
75
|
+
| `Content-Disposition` | `inline` — browsers render the label in place rather than download it |
|
|
75
76
|
| `Cache-Control` | `no-store` |
|
|
76
77
|
| `TAI-Leap-Seconds` | decimal count (e.g. `37`), always present |
|
|
77
78
|
|
|
@@ -94,13 +95,19 @@ entirely.
|
|
|
94
95
|
newTaistampHandler(); // cors: '*' (default)
|
|
95
96
|
newTaistampHandler({ cors: 'https://example.com' }); // scoped origin
|
|
96
97
|
newTaistampHandler({ cors: false }); // CORS-specific headers off
|
|
98
|
+
newTaistampHandler({ corsMaxAge: 86400 }); // pre-flight cache 24h
|
|
97
99
|
```
|
|
98
100
|
|
|
101
|
+
`corsMaxAge` (seconds, default `600`) sets the pre-flight
|
|
102
|
+
`Access-Control-Max-Age`. The spec §5.2 floor is 600, so a
|
|
103
|
+
smaller value clamps up to it; the field is ignored when
|
|
104
|
+
`cors` is `false`.
|
|
105
|
+
|
|
99
106
|
When CORS is enabled, responses carry:
|
|
100
107
|
|
|
101
108
|
| Response | CORS headers added | `Vary: Origin` (scoped origin only) |
|
|
102
109
|
| -------- | ------------------ | ----------------------------------- |
|
|
103
|
-
| `OPTIONS` 200 | `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods: GET, HEAD`, `Access-Control-Allow-Headers: TAI-Nonce`, `Access-Control-Expose-Headers: TAI-Leap-Seconds, TAI-Nonce, TAI-Key-Selector, TAI-Signature`, `Access-Control-Max-Age
|
|
110
|
+
| `OPTIONS` 200 | `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods: GET, HEAD`, `Access-Control-Allow-Headers: TAI-Nonce`, `Access-Control-Expose-Headers: TAI-Leap-Seconds, TAI-Nonce, TAI-Key-Selector, TAI-Signature`, `Access-Control-Max-Age` (default `600`, see `corsMaxAge`) | yes |
|
|
104
111
|
| `GET` / `HEAD` 200 | `Access-Control-Allow-Origin`, `Access-Control-Expose-Headers` (so browser JS can read the `TAI-*` headers) | yes |
|
|
105
112
|
| `405` | `Access-Control-Allow-Origin` | yes |
|
|
106
113
|
|
|
@@ -142,10 +149,27 @@ factory are re-exported from
|
|
|
142
149
|
package and don't need to depend on the underlying
|
|
143
150
|
package directly.
|
|
144
151
|
|
|
152
|
+
Operators usually hold a `selector:base64` seed
|
|
153
|
+
secret rather than a `CryptoKey`. `parseSecretToKey`
|
|
154
|
+
(also re-exported) parses it into a `KeyConfig` whose
|
|
155
|
+
`selector` and `signer` plug straight into the
|
|
156
|
+
handler config:
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
import {
|
|
160
|
+
newTaistampHandler,
|
|
161
|
+
parseSecretToKey,
|
|
162
|
+
} from '@kagal/taistamp';
|
|
163
|
+
|
|
164
|
+
const { selector, signer } =
|
|
165
|
+
await parseSecretToKey(env.TAISTAMP_SECRET);
|
|
166
|
+
const taistamp = newTaistampHandler({ selector, signer });
|
|
167
|
+
```
|
|
168
|
+
|
|
145
169
|
`signer` and `selector` are co-required: pass both to
|
|
146
170
|
sign, neither for an unsigned handler. Construction
|
|
147
171
|
throws if only one is supplied, or if `selector` does
|
|
148
|
-
not match
|
|
172
|
+
not match `[A-Za-z]([A-Za-z0-9_-]{0,61}[A-Za-z0-9])?`
|
|
149
173
|
(a single DNS-safe label that starts with a letter,
|
|
150
174
|
ends with a letter or digit, and is also a valid
|
|
151
175
|
Structured Field token).
|
|
@@ -238,22 +262,42 @@ or fall back to a strict-verify library such as
|
|
|
238
262
|
|
|
239
263
|
```typescript
|
|
240
264
|
import {
|
|
241
|
-
asNonce,
|
|
242
265
|
composeSignaturePayload,
|
|
243
266
|
extractLeapSeconds,
|
|
267
|
+
extractNonce,
|
|
268
|
+
extractSignature,
|
|
269
|
+
newNonce,
|
|
270
|
+
parseRecordToVerifier,
|
|
244
271
|
readLabel,
|
|
272
|
+
tai64nLabelToUTC,
|
|
245
273
|
} from '@kagal/taistamp';
|
|
246
274
|
|
|
275
|
+
// Mint the request nonce. `newNonce` returns a branded
|
|
276
|
+
// `Nonce` — conformant sf-binary by construction — so
|
|
277
|
+
// the same value flows into the signing path below.
|
|
278
|
+
const nonce = newNonce();
|
|
247
279
|
const response = await fetch(taistampURL, {
|
|
248
|
-
headers: { 'TAI-Nonce':
|
|
280
|
+
headers: { 'TAI-Nonce': nonce },
|
|
249
281
|
});
|
|
250
282
|
// `application/tai64n` is octet-typed, not text. `readLabel`
|
|
251
283
|
// reads the raw body and decodes the 25-octet ASCII label;
|
|
252
284
|
// never `response.text()`, which UTF-8-decodes and would
|
|
253
285
|
// mangle a non-ASCII octet instead of surfacing it.
|
|
254
286
|
const label = await readLabel(response);
|
|
255
|
-
const selector = response.headers.get('TAI-Key-Selector')
|
|
256
|
-
|
|
287
|
+
const selector = response.headers.get('TAI-Key-Selector');
|
|
288
|
+
if (!selector) {
|
|
289
|
+
throw new Error('TAI-Key-Selector missing');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Spec §6: `TAI-Signature` carries the raw 64-octet
|
|
293
|
+
// Ed25519 signature as an sf-binary item.
|
|
294
|
+
// `extractSignature` returns the decoded bytes, or
|
|
295
|
+
// `undefined` whenever the field is missing,
|
|
296
|
+
// duplicated, malformed, or the wrong length.
|
|
297
|
+
const signature = extractSignature(response.headers);
|
|
298
|
+
if (signature === undefined) {
|
|
299
|
+
throw new Error('TAI-Signature missing or malformed');
|
|
300
|
+
}
|
|
257
301
|
|
|
258
302
|
// Spec §5.3: a `TAI-Leap-Seconds` value outside the
|
|
259
303
|
// signed-payload u32 range MUST be treated as unsigned.
|
|
@@ -267,20 +311,33 @@ if (leap === undefined) {
|
|
|
267
311
|
throw new Error('TAI-Leap-Seconds out of range; treat as unsigned');
|
|
268
312
|
}
|
|
269
313
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
314
|
+
// The response must echo `TAI-Nonce`. With no echo there
|
|
315
|
+
// is no proof of freshness — the response may be a replay
|
|
316
|
+
// of an earlier exchange (Plain, trust level 0, spec §8),
|
|
317
|
+
// and any signature present MUST be ignored. This is
|
|
318
|
+
// strictly worse than a fresh-but-unsigned response, not
|
|
319
|
+
// equivalent to it.
|
|
320
|
+
const echo = extractNonce(response.headers);
|
|
321
|
+
if (echo === undefined) {
|
|
322
|
+
throw new Error('no TAI-Nonce echo: response is unverifiable');
|
|
323
|
+
}
|
|
324
|
+
// A present echo that differs from what we sent binds the
|
|
325
|
+
// response to a different exchange (Inconsistent, level
|
|
326
|
+
// -1) and MUST be rejected.
|
|
327
|
+
if (echo !== nonce) {
|
|
328
|
+
throw new Error('TAI-Nonce echo does not match the nonce sent');
|
|
278
329
|
}
|
|
279
330
|
|
|
280
|
-
// Look up the
|
|
281
|
-
// `${selector}._taistamp.${host}`
|
|
282
|
-
//
|
|
283
|
-
|
|
331
|
+
// Look up the TXT record in DNS at
|
|
332
|
+
// `${selector}._taistamp.${host}`; `parseRecordToVerifier`
|
|
333
|
+
// parses it and imports the published key into a
|
|
334
|
+
// ready-to-use `Verifier` in `p`. A revoked record
|
|
335
|
+
// (empty `p=`) carries through as `p: undefined`.
|
|
336
|
+
const txtValue = await loadTXT(host, selector);
|
|
337
|
+
const record = await parseRecordToVerifier(txtValue);
|
|
338
|
+
if (record.p === undefined) {
|
|
339
|
+
throw new Error('key record is revoked');
|
|
340
|
+
}
|
|
284
341
|
|
|
285
342
|
const payload = composeSignaturePayload(
|
|
286
343
|
label,
|
|
@@ -288,31 +345,46 @@ const payload = composeSignaturePayload(
|
|
|
288
345
|
selector,
|
|
289
346
|
nonce,
|
|
290
347
|
);
|
|
291
|
-
const valid = await
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
348
|
+
const valid = await record.p.verify(signature, payload);
|
|
349
|
+
if (!valid) {
|
|
350
|
+
throw new Error('signature does not verify');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// The signature checks out, so the label is trustworthy.
|
|
354
|
+
// `tai64nLabelToUTC` decodes it to `Date.now()`-shaped
|
|
355
|
+
// milliseconds — the server's signed time — honouring the
|
|
356
|
+
// leap count the response declared.
|
|
357
|
+
const verifiedAt = tai64nLabelToUTC(label, leap);
|
|
358
|
+
if (verifiedAt === undefined) {
|
|
359
|
+
throw new Error('TAI64N label malformed');
|
|
360
|
+
}
|
|
297
361
|
```
|
|
298
362
|
|
|
299
363
|
`composeSignaturePayload(label, leapSeconds, selector,
|
|
300
364
|
nonce)` reconstructs the exact byte sequence the
|
|
301
|
-
server signed; the verifier supplies only the
|
|
302
|
-
|
|
365
|
+
server signed; the verifier supplies only the DNS
|
|
366
|
+
lookup — `extractSignature` hands the raw signature
|
|
367
|
+
bytes straight to the `Verifier`. `leapSeconds` must be a
|
|
303
368
|
branded `LeapSeconds` — obtain one from
|
|
304
369
|
`extractLeapSeconds(headers)` (the verifier path) or
|
|
305
|
-
`asLeapSeconds(number)` (when you already have the
|
|
370
|
+
`asLeapSeconds(number | undefined)` (when you already have the
|
|
306
371
|
value). Both return `undefined` for out-of-range
|
|
307
372
|
input, collapsing every "treat as unsigned" case in
|
|
308
373
|
[spec §5.3][spec-leap] into one verdict. `nonce` must be a branded
|
|
309
|
-
`Nonce` —
|
|
374
|
+
`Nonce` — `newNonce()` mints one directly; a nonce
|
|
375
|
+
recorded as a plain string is branded with
|
|
310
376
|
`asNonce(value)`, which returns `undefined` for any
|
|
311
377
|
value that would have been treated as absent on the
|
|
312
378
|
server (missing, empty, malformed sf-binary, or out
|
|
313
379
|
of length range — see [spec §5.4][spec-nonce]).
|
|
314
|
-
|
|
315
|
-
|
|
380
|
+
`extractNonce(response.headers)` reads the echoed
|
|
381
|
+
`TAI-Nonce`. With no echo the response carries no proof
|
|
382
|
+
of freshness — it may be a replay of an earlier exchange,
|
|
383
|
+
so any signature MUST be ignored; this is weaker than a
|
|
384
|
+
fresh-but-unsigned response, not equivalent to it. A
|
|
385
|
+
present echo that differs from the nonce you sent binds
|
|
386
|
+
the response to a different exchange and MUST be
|
|
387
|
+
rejected.
|
|
316
388
|
|
|
317
389
|
## API
|
|
318
390
|
|
|
@@ -327,12 +399,14 @@ response's `TAI-Nonce` defends against replay.
|
|
|
327
399
|
[Signing the response](#signing-the-response) for
|
|
328
400
|
signed responses, and
|
|
329
401
|
[CORS](#cors) for cross-origin policy.
|
|
330
|
-
- `TaistampHandlerConfig` — `{ cors?,
|
|
331
|
-
signer? }`. `cors` accepts `'*'`
|
|
332
|
-
specific origin string, or `false`;
|
|
333
|
-
`
|
|
402
|
+
- `TaistampHandlerConfig` — `{ cors?, corsMaxAge?,
|
|
403
|
+
selector?, signer? }`. `cors` accepts `'*'`
|
|
404
|
+
(default), a specific origin string, or `false`;
|
|
405
|
+
`corsMaxAge` is the pre-flight `Access-Control-Max-Age`
|
|
406
|
+
in seconds (default `600`, clamped up to a 600 floor);
|
|
407
|
+
`signer` and `selector` are co-required.
|
|
334
408
|
|
|
335
|
-
### Signer
|
|
409
|
+
### Signer and verifier
|
|
336
410
|
|
|
337
411
|
Re-exported from `@kagal/ed25519-secret`:
|
|
338
412
|
|
|
@@ -341,6 +415,26 @@ Re-exported from `@kagal/ed25519-secret`:
|
|
|
341
415
|
- `newEd25519Signer(key)` — WebCrypto Ed25519
|
|
342
416
|
signer factory. Pass an Ed25519 private
|
|
343
417
|
`CryptoKey` with `'sign'` in `usages`.
|
|
418
|
+
- `parseSecretToKey(secret, context?)` — parse a
|
|
419
|
+
`selector:base64` seed secret into a `KeyConfig`;
|
|
420
|
+
its `selector` and `signer` plug straight into
|
|
421
|
+
`newTaistampHandler`.
|
|
422
|
+
- `parseSecretsToKeys(secrets, strict?, context?)` —
|
|
423
|
+
the same for a string carrying several secrets
|
|
424
|
+
separated by whitespace or punctuation. Strict mode
|
|
425
|
+
(the default) rejects the whole call on a malformed
|
|
426
|
+
entry; lenient mode skips it.
|
|
427
|
+
- `KeyConfig` — parsed secret: the `selector`,
|
|
428
|
+
ready-to-use `signer` / `verifier`, and the
|
|
429
|
+
underlying key material.
|
|
430
|
+
- `parseRecordToVerifier(record, context?)` — parse a
|
|
431
|
+
DNS TXT key record into a `KeyRecord` whose `p` is
|
|
432
|
+
a ready-to-use `Verifier`, or `undefined` for a
|
|
433
|
+
revoked record (empty `p=`).
|
|
434
|
+
- `KeyRecord` — parsed TXT record: the tag-value
|
|
435
|
+
pairs with `p` upgraded to the parse target.
|
|
436
|
+
- `Verifier` — `{ verify: (signature, message) =>
|
|
437
|
+
Promise<boolean> }`.
|
|
344
438
|
|
|
345
439
|
### Verification helpers
|
|
346
440
|
|
|
@@ -360,9 +454,9 @@ For verifier-side validation of a signed response
|
|
|
360
454
|
- `composeSignaturePayload(label, leapSeconds,
|
|
361
455
|
selector, nonce)` — reconstructs the exact byte
|
|
362
456
|
sequence the server signed.
|
|
363
|
-
- `asLeapSeconds(number)` — brand a numeric
|
|
364
|
-
leap-second count; returns `undefined` for
|
|
365
|
-
out-of-range input.
|
|
457
|
+
- `asLeapSeconds(number | undefined)` — brand a numeric
|
|
458
|
+
leap-second count; returns `undefined` for an absent
|
|
459
|
+
or out-of-range input.
|
|
366
460
|
- `extractLeapSeconds(headers)` — parse
|
|
367
461
|
`TAI-Leap-Seconds` from response headers; returns
|
|
368
462
|
`undefined` if missing, non-numeric, non-integer,
|
|
@@ -373,42 +467,107 @@ For verifier-side validation of a signed response
|
|
|
373
467
|
returns `undefined` for any value that fails
|
|
374
468
|
sf-binary syntax or the length range checked
|
|
375
469
|
per [spec §5.4][spec-nonce].
|
|
470
|
+
- `extractNonce(headers)` — read and brand the
|
|
471
|
+
`TAI-Nonce` echo from a response (or the request,
|
|
472
|
+
server-side); returns `undefined` when the field is
|
|
473
|
+
missing or fails `asNonce`. On the verifying side
|
|
474
|
+
`undefined` means no proof of freshness — the response
|
|
475
|
+
may be a replay and any signature MUST be ignored,
|
|
476
|
+
weaker than a fresh-but-unsigned response; a present
|
|
477
|
+
echo must match the nonce you sent.
|
|
478
|
+
- `newNonce(byteLength?, context?)` — mint a fresh
|
|
479
|
+
client nonce: random bytes framed as an sf-binary
|
|
480
|
+
item, returned already branded — conformant by
|
|
481
|
+
construction, no `asNonce` round-trip. `byteLength`
|
|
482
|
+
defaults to 16; throws `TypeError` outside
|
|
483
|
+
[spec §5.4][spec-nonce]'s 7..129 decoded octets.
|
|
376
484
|
- `Nonce` — branded sf-binary nonce accepted by
|
|
377
485
|
`composeSignaturePayload`.
|
|
486
|
+
- `asSignature(value)` — decode a recorded
|
|
487
|
+
`TAI-Signature` value ([spec §6][spec-sign]) to the
|
|
488
|
+
raw 64-octet Ed25519 signature; returns `undefined`
|
|
489
|
+
for any value that fails sf-binary syntax or does
|
|
490
|
+
not decode to exactly 64 octets.
|
|
491
|
+
- `extractSignature(headers)` — read `TAI-Signature`
|
|
492
|
+
from response headers; returns `undefined` if the
|
|
493
|
+
field is missing or fails `asSignature` validation.
|
|
494
|
+
- `tai64nLabelFromUTC(utc)` — the TAI64N label for a
|
|
495
|
+
UTC millisecond timestamp. Labels are fixed-width
|
|
496
|
+
hex and order lexicographically, so a received
|
|
497
|
+
label can be freshness-checked between the labels
|
|
498
|
+
of `Date.now() - skew` and `Date.now() + skew`
|
|
499
|
+
without decoding it.
|
|
500
|
+
- `tai64nLabelToUTC(label, leapSeconds?)` — the
|
|
501
|
+
inverse: recover the `Date.now()`-shaped UTC
|
|
502
|
+
milliseconds behind a verified label, so a checked
|
|
503
|
+
response yields a usable time. Returns `undefined`
|
|
504
|
+
for a malformed label; `leapSeconds` defaults to the
|
|
505
|
+
current `TAI_LEAP_SECONDS`.
|
|
506
|
+
|
|
507
|
+
### sf-binary helpers
|
|
508
|
+
|
|
509
|
+
`TAI-Nonce` and `TAI-Signature` travel as sf-binary
|
|
510
|
+
items ([RFC 9651 §3.3.5][sf-binary]) — standard base64
|
|
511
|
+
wrapped in colons. The `@kagal/taistamp/utils` subpath
|
|
512
|
+
exposes the framing helpers the handler uses:
|
|
513
|
+
|
|
514
|
+
```typescript
|
|
515
|
+
import { decodeSFBinary } from '@kagal/taistamp/utils';
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
| Export | Description |
|
|
519
|
+
| ------ | ----------- |
|
|
520
|
+
| `SF_BINARY_PATTERN` | `RegExp` matching the full sf-binary syntax |
|
|
521
|
+
| `encodeSFBinary(bytes)` | Bytes → `:base64:` item |
|
|
522
|
+
| `decodeSFBinary(value, context?)` | `:base64:` item → bytes; throws `TypeError` on invalid syntax; `context` prefixes the message |
|
|
523
|
+
|
|
524
|
+
`decodeSFBinary` enforces the full syntax — anything
|
|
525
|
+
`SF_BINARY_PATTERN` rejects throws — so decoding a
|
|
526
|
+
wire value needs no separate validation step; the
|
|
527
|
+
pattern stands alone for validating without decoding.
|
|
378
528
|
|
|
379
529
|
### TAI64N helpers
|
|
380
530
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
531
|
+
Constructing TAI64N timestamps and their labels is
|
|
532
|
+
a core capability of the package — the
|
|
533
|
+
`@kagal/taistamp/utils` subpath exposes it for
|
|
534
|
+
direct use, and the handler builds on the same
|
|
535
|
+
primitives:
|
|
536
|
+
|
|
537
|
+
```typescript
|
|
538
|
+
import { tai64nLabel } from '@kagal/taistamp/utils';
|
|
539
|
+
```
|
|
384
540
|
|
|
385
541
|
| Export | Description |
|
|
386
542
|
| ------ | ----------- |
|
|
387
543
|
| `now()` | Current TAI as `{ sec, nano, offset }` |
|
|
388
544
|
| `fromUTC(utc)` | `Date.now()`-shaped milliseconds → TAI timestamp |
|
|
389
545
|
| `tai64nLabel(t?)` | 25-byte label string for a timestamp (or `now()`) |
|
|
390
|
-
| `tai64nLabelFromUTC(utc)` | Shortcut for `tai64nLabel(fromUTC(utc))
|
|
546
|
+
| `tai64nLabelFromUTC(utc)` | Shortcut for `tai64nLabel(fromUTC(utc))`; also on the main export |
|
|
547
|
+
| `tai64nLabelToUTC(label, leapSeconds?)` | Inverse of `tai64nLabelFromUTC` — label → `Date.now()`-shaped ms, or `undefined` if malformed; also on the main export |
|
|
548
|
+
| `TAI64N_LABEL_PATTERN` | `RegExp` matching the TAI64N label wire form (`@` + 24 hex digits) |
|
|
549
|
+
| `TAI64N_LABEL_LENGTH` | `25` — byte length of a TAI64N label |
|
|
550
|
+
| `TAI64N_CONTENT_TYPE` | `application/tai64n` — media type of a label body |
|
|
551
|
+
| `TAI64N_EPOCH_HI` | `0x40000000` — TAI64 epoch high word folded into a label's seconds field |
|
|
391
552
|
|
|
392
553
|
`fromUTC` applies the constant `TAI_LEAP_SECONDS`
|
|
393
|
-
(currently 37 seconds).
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
history.
|
|
554
|
+
(currently 37 seconds). These helpers deal in the
|
|
555
|
+
current time: the offset tracks the present, and
|
|
556
|
+
anything before the unix epoch is out of scope.
|
|
397
557
|
|
|
398
558
|
### Constants
|
|
399
559
|
|
|
400
560
|
| Name | Value |
|
|
401
561
|
| ---- | ----- |
|
|
402
562
|
| `TAISTAMP_PATH` | `/.well-known/taistamp` |
|
|
403
|
-
| `
|
|
404
|
-
| `
|
|
405
|
-
| `
|
|
406
|
-
| `
|
|
407
|
-
| `
|
|
408
|
-
| `
|
|
563
|
+
| `TAISTAMP_CONTENT_TYPE` | `application/tai64n` |
|
|
564
|
+
| `TAISTAMP_CONTENT_LENGTH` | `25` |
|
|
565
|
+
| `TAISTAMP_HEADER_KEY_SELECTOR` | `TAI-Key-Selector` |
|
|
566
|
+
| `TAISTAMP_HEADER_LEAP_SECONDS` | `TAI-Leap-Seconds` |
|
|
567
|
+
| `TAISTAMP_HEADER_NONCE` | `TAI-Nonce` |
|
|
568
|
+
| `TAISTAMP_HEADER_SIGNATURE` | `TAI-Signature` |
|
|
409
569
|
| `TAI_LEAP_SECONDS` | `37` (current TAI − UTC offset) |
|
|
410
570
|
| `TAI_LEAP_SECONDS_MAX` | `0xFFFFFFFF` (signed-payload u32 cap) |
|
|
411
|
-
| `TAI64_EPOCH_HI` | `0x40000000` |
|
|
412
571
|
|
|
413
572
|
## Licence
|
|
414
573
|
|
|
@@ -423,7 +582,9 @@ history.
|
|
|
423
582
|
[npm-badge]: https://img.shields.io/npm/v/@kagal/taistamp.svg
|
|
424
583
|
[npm-url]: https://www.npmjs.com/package/@kagal/taistamp
|
|
425
584
|
[rfc-repo]: https://github.com/karasz/rfc-taistamp
|
|
585
|
+
[sf-binary]: https://datatracker.ietf.org/doc/html/rfc9651#section-3.3.5
|
|
426
586
|
[spec-leap]: https://datatracker.ietf.org/doc/html/draft-mery-nagy-taistamp-00#section-5.3
|
|
427
587
|
[spec-nonce]: https://datatracker.ietf.org/doc/html/draft-mery-nagy-taistamp-00#section-5.4
|
|
588
|
+
[spec-sign]: https://datatracker.ietf.org/doc/html/draft-mery-nagy-taistamp-00#section-6
|
|
428
589
|
[spec-verify]: https://datatracker.ietf.org/doc/html/draft-mery-nagy-taistamp-00#section-9
|
|
429
590
|
[tai64n]: https://cr.yp.to/libtai/tai64.html
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/** `@` followed by 24 hex digits — the TAI64N label wire form. */
|
|
2
|
+
declare const TAI64N_LABEL_PATTERN: RegExp;
|
|
3
|
+
/** Byte length of a TAI64N label: `@` + 16 sec + 8 nano hex digits. */
|
|
4
|
+
declare const TAI64N_LABEL_LENGTH: number;
|
|
5
|
+
/** Media type of a TAI64N label body. */
|
|
6
|
+
declare const TAI64N_CONTENT_TYPE = "application/tai64n";
|
|
7
|
+
/** High 32 bits of the TAI64 second count at the unix epoch. */
|
|
8
|
+
declare const TAI64N_EPOCH_HI = 1073741824;
|
|
9
|
+
/** @deprecated Renamed to {@link TAI64N_EPOCH_HI}. */
|
|
10
|
+
declare const TAI64_EPOCH_HI = 1073741824;
|
|
11
|
+
declare const TAISTAMP_PATH = "/.well-known/taistamp";
|
|
12
|
+
/** The taistamp response `Content-Type`. */
|
|
13
|
+
declare const TAISTAMP_CONTENT_TYPE = "application/tai64n";
|
|
14
|
+
/** The taistamp response `Content-Length`. */
|
|
15
|
+
declare const TAISTAMP_CONTENT_LENGTH: number;
|
|
16
|
+
declare const TAISTAMP_HEADER_KEY_SELECTOR = "TAI-Key-Selector";
|
|
17
|
+
declare const TAISTAMP_HEADER_LEAP_SECONDS = "TAI-Leap-Seconds";
|
|
18
|
+
declare const TAISTAMP_HEADER_NONCE = "TAI-Nonce";
|
|
19
|
+
declare const TAISTAMP_HEADER_SIGNATURE = "TAI-Signature";
|
|
20
|
+
/** @deprecated Renamed to {@link TAISTAMP_PATH}. */
|
|
21
|
+
declare const TAI64N_PATH = "/.well-known/taistamp";
|
|
22
|
+
/** @deprecated Renamed to {@link TAISTAMP_CONTENT_LENGTH}. */
|
|
23
|
+
declare const TAI64N_CONTENT_LENGTH: number;
|
|
24
|
+
/** @deprecated Renamed to {@link TAISTAMP_HEADER_KEY_SELECTOR}. */
|
|
25
|
+
declare const TAI64N_HEADER_KEY_SELECTOR = "TAI-Key-Selector";
|
|
26
|
+
/** @deprecated Renamed to {@link TAISTAMP_HEADER_LEAP_SECONDS}. */
|
|
27
|
+
declare const TAI64N_HEADER_LEAP_SECONDS = "TAI-Leap-Seconds";
|
|
28
|
+
/** @deprecated Renamed to {@link TAISTAMP_HEADER_NONCE}. */
|
|
29
|
+
declare const TAI64N_HEADER_NONCE = "TAI-Nonce";
|
|
30
|
+
/** @deprecated Renamed to {@link TAISTAMP_HEADER_SIGNATURE}. */
|
|
31
|
+
declare const TAI64N_HEADER_SIGNATURE = "TAI-Signature";
|
|
32
|
+
/**
|
|
33
|
+
* Upper bound for `leapSeconds` in the taistamp signed
|
|
34
|
+
* payload. The framing encodes the value as a 4-byte
|
|
35
|
+
* big-endian unsigned integer, so any input outside
|
|
36
|
+
* `[0, 2^32-1]` cannot be represented. Verifiers MUST
|
|
37
|
+
* treat an out-of-range `TAI-Leap-Seconds` response
|
|
38
|
+
* header as unsigned, per spec §5.3.
|
|
39
|
+
*/
|
|
40
|
+
declare const TAI_LEAP_SECONDS_MAX = 4294967295;
|
|
41
|
+
declare const LeapSecondsBrand: unique symbol;
|
|
42
|
+
/**
|
|
43
|
+
* `number` that has been confirmed to fit the
|
|
44
|
+
* `[0, TAI_LEAP_SECONDS_MAX]` u32be range required by
|
|
45
|
+
* the taistamp signed-payload framing. Construct only
|
|
46
|
+
* via {@link extractLeapSeconds} or {@link asLeapSeconds};
|
|
47
|
+
* the brand prevents an arbitrary number from reaching
|
|
48
|
+
* the signing path.
|
|
49
|
+
*/
|
|
50
|
+
type LeapSeconds = number & {
|
|
51
|
+
readonly [LeapSecondsBrand]: never;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Coerce a `number` to a {@link LeapSeconds}. Returns
|
|
55
|
+
* `undefined` when `value` is absent, non-integer,
|
|
56
|
+
* negative, or exceeds {@link TAI_LEAP_SECONDS_MAX}.
|
|
57
|
+
*/
|
|
58
|
+
declare const asLeapSeconds: (value: number | undefined) => LeapSeconds | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Current TAI − UTC offset in whole seconds, used by
|
|
61
|
+
* `fromUTC()` and emitted in the `TAI-Leap-Seconds`
|
|
62
|
+
* response header. The value 37 has been in force
|
|
63
|
+
* since 2017-01-01; update on the next IERS leap-second
|
|
64
|
+
* announcement.
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* Stays a single `LeapSeconds` until a leap-seconds
|
|
68
|
+
* table is added so the offset can be computed for any
|
|
69
|
+
* TAI second; this constant becomes redundant then.
|
|
70
|
+
*/
|
|
71
|
+
declare const TAI_LEAP_SECONDS: LeapSeconds;
|
|
72
|
+
/**
|
|
73
|
+
* Extract a usable leap-seconds count from response
|
|
74
|
+
* headers. Returns `undefined` when the
|
|
75
|
+
* `TAI-Leap-Seconds` field is missing, empty,
|
|
76
|
+
* non-numeric, non-integer, negative, or out-of-range
|
|
77
|
+
* — every "treat as unsigned" case in spec §5.3
|
|
78
|
+
* collapsed into one verdict.
|
|
79
|
+
*/
|
|
80
|
+
declare const extractLeapSeconds: (headers: Headers) => LeapSeconds | undefined;
|
|
81
|
+
type timestamp = {
|
|
82
|
+
nano: number;
|
|
83
|
+
sec: number;
|
|
84
|
+
offset?: number;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Convert a UTC timestamp in milliseconds (the
|
|
88
|
+
* `Date.now()` shape) to TAI seconds and nanoseconds,
|
|
89
|
+
* applying the current `TAI_LEAP_SECONDS` offset. The
|
|
90
|
+
* package deals in the current time: the offset tracks
|
|
91
|
+
* the present, and anything before the unix epoch is
|
|
92
|
+
* out of scope.
|
|
93
|
+
*/
|
|
94
|
+
declare const fromUTC: (utc: number) => timestamp;
|
|
95
|
+
/**
|
|
96
|
+
* The current TAI time as seconds and nanoseconds —
|
|
97
|
+
* {@link fromUTC} applied to `Date.now()`.
|
|
98
|
+
*/
|
|
99
|
+
declare const now: () => timestamp;
|
|
100
|
+
/**
|
|
101
|
+
* Format a TAI timestamp as the 25-byte TAI64N label
|
|
102
|
+
* served at `/.well-known/taistamp`: `@` followed by
|
|
103
|
+
* 16 hex digits of TAI64 seconds (the 2^62 epoch
|
|
104
|
+
* offset applied) and 8 hex digits of nanoseconds.
|
|
105
|
+
* Defaults to {@link now} when no value is given.
|
|
106
|
+
*/
|
|
107
|
+
declare const tai64nLabel: (value?: timestamp) => string;
|
|
108
|
+
/**
|
|
109
|
+
* The 25-byte TAI64N label for a UTC timestamp in
|
|
110
|
+
* milliseconds — shorthand for
|
|
111
|
+
* `tai64nLabel(fromUTC(utc))`.
|
|
112
|
+
*
|
|
113
|
+
* Labels are fixed-width hex, so they order
|
|
114
|
+
* lexicographically: a verifier can bounds-check a
|
|
115
|
+
* received label between the labels of
|
|
116
|
+
* `Date.now() - skew` and `Date.now() + skew` without
|
|
117
|
+
* decoding it.
|
|
118
|
+
*/
|
|
119
|
+
declare const tai64nLabelFromUTC: (utc: number) => string;
|
|
120
|
+
/**
|
|
121
|
+
* Recover a UTC timestamp in milliseconds (the
|
|
122
|
+
* `Date.now()` shape) from a TAI64N label — the inverse
|
|
123
|
+
* of {@link tai64nLabelFromUTC}. A label minted from a
|
|
124
|
+
* millisecond value round-trips back to it exactly; a
|
|
125
|
+
* `Date` is one `new Date(ms)` away.
|
|
126
|
+
*
|
|
127
|
+
* Returns `undefined` for any value that is not `@`
|
|
128
|
+
* followed by 24 hex digits (either case) — the
|
|
129
|
+
* verify-side "malformed is absent" collapse shared with
|
|
130
|
+
* `asSignature` and `asNonce`, so it drops straight into
|
|
131
|
+
* a gate pipeline.
|
|
132
|
+
*
|
|
133
|
+
* `leapSeconds` is the TAI − UTC offset removed when
|
|
134
|
+
* mapping TAI back to UTC; it defaults to the current
|
|
135
|
+
* {@link TAI_LEAP_SECONDS}, mirroring {@link fromUTC}.
|
|
136
|
+
* Pass a response's `extractLeapSeconds(headers)` to
|
|
137
|
+
* honour a server that declared a different count — an
|
|
138
|
+
* absent or malformed header yields `undefined` there
|
|
139
|
+
* and falls through to the default, while a genuine `0`
|
|
140
|
+
* is honoured.
|
|
141
|
+
*/
|
|
142
|
+
declare const tai64nLabelToUTC: (label: string, leapSeconds?: LeapSeconds) => number | undefined;
|
|
143
|
+
export { LeapSeconds, TAI64N_CONTENT_LENGTH, TAI64N_CONTENT_TYPE, TAI64N_EPOCH_HI, TAI64N_HEADER_KEY_SELECTOR, TAI64N_HEADER_LEAP_SECONDS, TAI64N_HEADER_NONCE, TAI64N_HEADER_SIGNATURE, TAI64N_LABEL_LENGTH, TAI64N_LABEL_PATTERN, TAI64N_PATH, TAI64_EPOCH_HI, TAISTAMP_CONTENT_LENGTH, TAISTAMP_CONTENT_TYPE, TAISTAMP_HEADER_KEY_SELECTOR, TAISTAMP_HEADER_LEAP_SECONDS, TAISTAMP_HEADER_NONCE, TAISTAMP_HEADER_SIGNATURE, TAISTAMP_PATH, TAI_LEAP_SECONDS, TAI_LEAP_SECONDS_MAX, asLeapSeconds, extractLeapSeconds, fromUTC, now, tai64nLabel, tai64nLabelFromUTC, tai64nLabelToUTC };
|
|
144
|
+
//# sourceMappingURL=time.d.mts.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { decodeBase64, encodeBase64, isInRange } from "@kagal/ed25519-secret";
|
|
2
|
+
const TAI64N_LABEL_PATTERN = /^@[\da-fA-F]{24}$/;
|
|
3
|
+
const TAI64N_LABEL_LENGTH = 25;
|
|
4
|
+
const TAI64N_CONTENT_TYPE = "application/tai64n";
|
|
5
|
+
const TAI64N_EPOCH_HI = 1073741824;
|
|
6
|
+
const TAI64_EPOCH_HI = TAI64N_EPOCH_HI;
|
|
7
|
+
const TAISTAMP_PATH = "/.well-known/taistamp";
|
|
8
|
+
const TAISTAMP_CONTENT_TYPE = TAI64N_CONTENT_TYPE;
|
|
9
|
+
const TAISTAMP_CONTENT_LENGTH = 25;
|
|
10
|
+
const TAISTAMP_HEADER_KEY_SELECTOR = "TAI-Key-Selector";
|
|
11
|
+
const TAISTAMP_HEADER_LEAP_SECONDS = "TAI-Leap-Seconds";
|
|
12
|
+
const TAISTAMP_HEADER_NONCE = "TAI-Nonce";
|
|
13
|
+
const TAISTAMP_HEADER_SIGNATURE = "TAI-Signature";
|
|
14
|
+
const TAI64N_PATH = TAISTAMP_PATH;
|
|
15
|
+
const TAI64N_CONTENT_LENGTH = 25;
|
|
16
|
+
const TAI64N_HEADER_KEY_SELECTOR = TAISTAMP_HEADER_KEY_SELECTOR;
|
|
17
|
+
const TAI64N_HEADER_LEAP_SECONDS = TAISTAMP_HEADER_LEAP_SECONDS;
|
|
18
|
+
const TAI64N_HEADER_NONCE = TAISTAMP_HEADER_NONCE;
|
|
19
|
+
const TAI64N_HEADER_SIGNATURE = TAISTAMP_HEADER_SIGNATURE;
|
|
20
|
+
const TAI_LEAP_SECONDS_MAX = 4294967295;
|
|
21
|
+
const asLeapSeconds = (value) => {
|
|
22
|
+
if (!isInRange(value, 0, 4294967295)) return void 0;
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
const TAI_LEAP_SECONDS = 37;
|
|
26
|
+
const DECIMAL_INTEGER = /^(?:0|[1-9]\d*)$/;
|
|
27
|
+
const extractLeapSeconds = (headers) => {
|
|
28
|
+
const raw = headers.get(TAISTAMP_HEADER_LEAP_SECONDS);
|
|
29
|
+
if (!raw || !DECIMAL_INTEGER.test(raw)) return void 0;
|
|
30
|
+
return asLeapSeconds(Number(raw));
|
|
31
|
+
};
|
|
32
|
+
const SF_BINARY_PATTERN = /^:(?:(?:[\d+/A-Za-z]{4})*(?:[\d+/A-Za-z]{4}|[\d+/A-Za-z]{3}=|[\d+/A-Za-z]{2}==))?:$/;
|
|
33
|
+
const encodeSFBinary = (bytes) => `:${encodeBase64(bytes)}:`;
|
|
34
|
+
const decodeSFBinary = (value, context) => {
|
|
35
|
+
if (!SF_BINARY_PATTERN.test(value)) {
|
|
36
|
+
const prefix = context ? `${context}: ` : "";
|
|
37
|
+
throw new TypeError(`${prefix}invalid sf-binary`);
|
|
38
|
+
}
|
|
39
|
+
return decodeBase64(value.slice(1, -1), context);
|
|
40
|
+
};
|
|
41
|
+
const fromUTC = (utc) => {
|
|
42
|
+
return {
|
|
43
|
+
sec: Math.floor(utc / 1e3) + 37,
|
|
44
|
+
nano: utc % 1e3 * 1e6,
|
|
45
|
+
offset: 37
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
const now = () => {
|
|
49
|
+
return fromUTC(Date.now());
|
|
50
|
+
};
|
|
51
|
+
const tai64nLabel = (value) => {
|
|
52
|
+
const { sec, nano } = value ?? now();
|
|
53
|
+
const secHi = Math.trunc(sec / u32Range) + TAI64N_EPOCH_HI;
|
|
54
|
+
const secLo = sec % u32Range;
|
|
55
|
+
return `@${secHi.toString(16).padStart(8, "0")}${secLo.toString(16).padStart(8, "0")}${nano.toString(16).padStart(8, "0")}`;
|
|
56
|
+
};
|
|
57
|
+
const tai64nLabelFromUTC = (utc) => tai64nLabel(fromUTC(utc));
|
|
58
|
+
const tai64nLabelToUTC = (label, leapSeconds = 37) => {
|
|
59
|
+
if (!TAI64N_LABEL_PATTERN.test(label)) return void 0;
|
|
60
|
+
const secHi = Number.parseInt(label.slice(1, 9), 16) - TAI64N_EPOCH_HI;
|
|
61
|
+
const secLo = Number.parseInt(label.slice(9, 17), 16);
|
|
62
|
+
const nano = Number.parseInt(label.slice(17, 25), 16);
|
|
63
|
+
return (secHi * u32Range + secLo - leapSeconds) * 1e3 + nano / 1e6;
|
|
64
|
+
};
|
|
65
|
+
const u32Range = 4294967296;
|
|
66
|
+
export { SF_BINARY_PATTERN, TAI64N_CONTENT_LENGTH, TAI64N_CONTENT_TYPE, TAI64N_EPOCH_HI, TAI64N_HEADER_KEY_SELECTOR, TAI64N_HEADER_LEAP_SECONDS, TAI64N_HEADER_NONCE, TAI64N_HEADER_SIGNATURE, TAI64N_LABEL_LENGTH, TAI64N_LABEL_PATTERN, TAI64N_PATH, TAI64_EPOCH_HI, TAISTAMP_CONTENT_LENGTH, TAISTAMP_CONTENT_TYPE, TAISTAMP_HEADER_KEY_SELECTOR, TAISTAMP_HEADER_LEAP_SECONDS, TAISTAMP_HEADER_NONCE, TAISTAMP_HEADER_SIGNATURE, TAISTAMP_PATH, TAI_LEAP_SECONDS, TAI_LEAP_SECONDS_MAX, asLeapSeconds, decodeSFBinary, encodeSFBinary, extractLeapSeconds, fromUTC, now, tai64nLabel, tai64nLabelFromUTC, tai64nLabelToUTC };
|
|
67
|
+
|
|
68
|
+
//# sourceMappingURL=time.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"time.mjs","names":[],"sources":["../../src/const.ts","../../src/leap-seconds.ts","../../src/sf-binary.ts","../../src/time.ts"],"sourcesContent":["//\n// tai64n format\n//\n// Constants describing the TAI64N label itself, surfaced on\n// the `@kagal/taistamp/utils` subpath.\n//\n\n/** `@` followed by 24 hex digits — the TAI64N label wire form. */\nexport const TAI64N_LABEL_PATTERN = /^@[\\da-fA-F]{24}$/;\n\n/** Byte length of a TAI64N label: `@` + 16 sec + 8 nano hex digits. */\nexport const TAI64N_LABEL_LENGTH = 1 + 16 + 8;\n\n/** Media type of a TAI64N label body. */\nexport const TAI64N_CONTENT_TYPE = 'application/tai64n';\n\n/** High 32 bits of the TAI64 second count at the unix epoch. */\nexport const TAI64N_EPOCH_HI = 0x40_00_00_00;\n\n/** @deprecated Renamed to {@link TAI64N_EPOCH_HI}. */\nexport const TAI64_EPOCH_HI = TAI64N_EPOCH_HI;\n\n//\n// taistamp protocol\n//\n// Constants describing the taistamp HTTP exchange, surfaced\n// on the main entry point.\n//\n\nexport const TAISTAMP_PATH = '/.well-known/taistamp';\n\n/** The taistamp response `Content-Type`. */\nexport const TAISTAMP_CONTENT_TYPE = TAI64N_CONTENT_TYPE;\n\n/** The taistamp response `Content-Length`. */\nexport const TAISTAMP_CONTENT_LENGTH = TAI64N_LABEL_LENGTH;\n\nexport const TAISTAMP_HEADER_KEY_SELECTOR = 'TAI-Key-Selector';\nexport const TAISTAMP_HEADER_LEAP_SECONDS = 'TAI-Leap-Seconds';\nexport const TAISTAMP_HEADER_NONCE = 'TAI-Nonce';\nexport const TAISTAMP_HEADER_SIGNATURE = 'TAI-Signature';\n\n//\n// Back-compat aliases for the released TAI64N_-prefixed\n// protocol names, surfaced on the /utils subpath.\n//\n\n/** @deprecated Renamed to {@link TAISTAMP_PATH}. */\nexport const TAI64N_PATH = TAISTAMP_PATH;\n\n/** @deprecated Renamed to {@link TAISTAMP_CONTENT_LENGTH}. */\nexport const TAI64N_CONTENT_LENGTH = TAISTAMP_CONTENT_LENGTH;\n\n/** @deprecated Renamed to {@link TAISTAMP_HEADER_KEY_SELECTOR}. */\nexport const TAI64N_HEADER_KEY_SELECTOR = TAISTAMP_HEADER_KEY_SELECTOR;\n\n/** @deprecated Renamed to {@link TAISTAMP_HEADER_LEAP_SECONDS}. */\nexport const TAI64N_HEADER_LEAP_SECONDS = TAISTAMP_HEADER_LEAP_SECONDS;\n\n/** @deprecated Renamed to {@link TAISTAMP_HEADER_NONCE}. */\nexport const TAI64N_HEADER_NONCE = TAISTAMP_HEADER_NONCE;\n\n/** @deprecated Renamed to {@link TAISTAMP_HEADER_SIGNATURE}. */\nexport const TAI64N_HEADER_SIGNATURE = TAISTAMP_HEADER_SIGNATURE;\n","// cspell:words IERS\n\nimport { isInRange } from '@kagal/ed25519-secret';\n\nimport { TAISTAMP_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 absent, non-integer,\n * negative, or exceeds {@link TAI_LEAP_SECONDS_MAX}.\n */\nexport const asLeapSeconds = (\n value: number | undefined,\n): LeapSeconds | undefined => {\n if (!isInRange(value, 0, TAI_LEAP_SECONDS_MAX)) 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(TAISTAMP_HEADER_LEAP_SECONDS);\n if (!raw || !DECIMAL_INTEGER.test(raw)) return undefined;\n return asLeapSeconds(Number(raw));\n};\n","import { type Bytes, decodeBase64, encodeBase64 } from '@kagal/ed25519-secret';\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 valid sf-binary;\n * consumers impose their own length bounds. The\n * alphabet contains no `,`, so a duplicated field\n * (joined by the Web `Headers` API with `,`) fails the\n * same check.\n */\nexport const 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\n/**\n * Encode bytes as an sf-binary item (RFC 9651 §3.3.5):\n * standard base64 wrapped in colons. The output\n * satisfies {@link SF_BINARY_PATTERN} and round-trips\n * through {@link decodeSFBinary}.\n */\nexport const encodeSFBinary = (bytes: Readonly<Uint8Array>): string =>\n `:${encodeBase64(bytes)}:`;\n\n/**\n * Decode an sf-binary item back into bytes. Enforces\n * the full RFC 9651 §3.3.5 syntax: throws `TypeError`\n * when `value` does not satisfy\n * {@link SF_BINARY_PATTERN}; the thrown message is\n * optionally prefixed `<context>: `.\n */\nexport const decodeSFBinary = (\n value: string,\n context?: string,\n): Bytes => {\n if (!SF_BINARY_PATTERN.test(value)) {\n const prefix = context ? `${context}: ` : '';\n throw new TypeError(`${prefix}invalid sf-binary`);\n }\n // the pattern guarantees decodable standard base64\n return decodeBase64(value.slice(1, -1), context);\n};\n","import { TAI64N_EPOCH_HI, TAI64N_LABEL_PATTERN } from './const';\nimport { type LeapSeconds, TAI_LEAP_SECONDS } from './leap-seconds';\n\ntype timestamp = {\n nano: number\n sec: number\n\n offset?: number\n};\n\n/**\n * Convert a UTC timestamp in milliseconds (the\n * `Date.now()` shape) to TAI seconds and nanoseconds,\n * applying the current `TAI_LEAP_SECONDS` offset. The\n * package deals in the current time: the offset tracks\n * the present, and anything before the unix epoch is\n * out of scope.\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\n/**\n * The current TAI time as seconds and nanoseconds —\n * {@link fromUTC} applied to `Date.now()`.\n */\nexport const now = (): timestamp => {\n const utc = Date.now();\n return fromUTC(utc);\n};\n\n/**\n * Format a TAI timestamp as the 25-byte TAI64N label\n * served at `/.well-known/taistamp`: `@` followed by\n * 16 hex digits of TAI64 seconds (the 2^62 epoch\n * offset applied) and 8 hex digits of nanoseconds.\n * Defaults to {@link now} when no value is given.\n */\nexport const tai64nLabel = (value?: timestamp): string => {\n const { sec, nano } = value ?? now();\n\n const secHi = Math.trunc(sec / u32Range) + TAI64N_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\n/**\n * The 25-byte TAI64N label for a UTC timestamp in\n * milliseconds — shorthand for\n * `tai64nLabel(fromUTC(utc))`.\n *\n * Labels are fixed-width hex, so they order\n * lexicographically: a verifier can bounds-check a\n * received label between the labels of\n * `Date.now() - skew` and `Date.now() + skew` without\n * decoding it.\n */\nexport const tai64nLabelFromUTC = (utc: number): string =>\n tai64nLabel(fromUTC(utc));\n\n/**\n * Recover a UTC timestamp in milliseconds (the\n * `Date.now()` shape) from a TAI64N label — the inverse\n * of {@link tai64nLabelFromUTC}. A label minted from a\n * millisecond value round-trips back to it exactly; a\n * `Date` is one `new Date(ms)` away.\n *\n * Returns `undefined` for any value that is not `@`\n * followed by 24 hex digits (either case) — the\n * verify-side \"malformed is absent\" collapse shared with\n * `asSignature` and `asNonce`, so it drops straight into\n * a gate pipeline.\n *\n * `leapSeconds` is the TAI − UTC offset removed when\n * mapping TAI back to UTC; it defaults to the current\n * {@link TAI_LEAP_SECONDS}, mirroring {@link fromUTC}.\n * Pass a response's `extractLeapSeconds(headers)` to\n * honour a server that declared a different count — an\n * absent or malformed header yields `undefined` there\n * and falls through to the default, while a genuine `0`\n * is honoured.\n */\nexport const tai64nLabelToUTC = (\n label: string,\n leapSeconds: LeapSeconds = TAI_LEAP_SECONDS,\n): number | undefined => {\n if (!TAI64N_LABEL_PATTERN.test(label)) return undefined;\n\n // The 64-bit seconds field must be read as two 32-bit\n // words: parsing all 16 hex digits at once yields\n // 2^62 + sec ≈ 4.6e18, whose float64 ULP (1024) has\n // already discarded sec's low bits before the epoch\n // base could be subtracted. Removing TAI64N_EPOCH_HI on\n // the high word alone keeps every term exact.\n const secHi = Number.parseInt(label.slice(1, 9), 16) - TAI64N_EPOCH_HI;\n const secLo = Number.parseInt(label.slice(9, 17), 16);\n const nano = Number.parseInt(label.slice(17, 25), 16);\n\n const sec = secHi * u32Range + secLo;\n return (sec - leapSeconds) * 1000 + nano / 1e6;\n};\n\nconst u32Range = 0x1_00_00_00_00;\n"],"mappings":";AAQA,MAAa,uBAAuB;AAGpC,MAAa,sBAAsB;AAGnC,MAAa,sBAAsB;AAGnC,MAAa,kBAAkB;AAG/B,MAAa,iBAAiB;AAS9B,MAAa,gBAAgB;AAG7B,MAAa,wBAAwB;AAGrC,MAAa,0BAAA;AAEb,MAAa,+BAA+B;AAC5C,MAAa,+BAA+B;AAC5C,MAAa,wBAAwB;AACrC,MAAa,4BAA4B;AAQzC,MAAa,cAAc;AAG3B,MAAa,wBAAA;AAGb,MAAa,6BAA6B;AAG1C,MAAa,6BAA6B;AAG1C,MAAa,sBAAsB;AAGnC,MAAa,0BAA0B;;;;;;ACjDvC,MAAa,mBAAA;;;;CAmBb,IAAA,CAAa,OAAA,CAAA,gBACX,KAC4B,GAAA,GAAA,OAAA,KAAA;CAC5B,OAAK,cAAU,OAAO,GAAA,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;CAoCxB,OAAa,IAAA,MAAA,SAAA,EACX,EAAA,SAC4B,GAAA,GAAA,IAAA,MAAA,SAAA,EAAA,EAAA,SAAA,GAAA,GAAA,IAAA,KAAA,SAAA,EAAA,EAAA,SAAA,GAAA,GAAA;;MAG5B,sBAAqB,QAAW,YAAA,QAAA,GAAA,CAAA"}
|