@kagal/taistamp 0.0.1
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/LICENCE.txt +21 -0
- package/README.md +231 -0
- package/dist/index.d.mts +185 -0
- package/dist/index.d.ts +185 -0
- package/dist/index.mjs +142 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +73 -0
package/LICENCE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Apptly Software Ltd <oss@apptly.co>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# @kagal/taistamp
|
|
2
|
+
|
|
3
|
+
Platform-neutral handler for `/.well-known/taistamp` —
|
|
4
|
+
serves signed [TAI64N][tai64n] timestamps over HTTP for
|
|
5
|
+
clients that need authenticated wall-clock time without
|
|
6
|
+
running an NTP stack or trusting an unauthenticated TLS
|
|
7
|
+
handshake clock.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pnpm add @kagal/taistamp
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Handler
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { newTaistampHandler, TAI64N_PATH } from '@kagal/taistamp';
|
|
19
|
+
|
|
20
|
+
const taistamp = newTaistampHandler();
|
|
21
|
+
|
|
22
|
+
// Worker fetch handler
|
|
23
|
+
export default {
|
|
24
|
+
async fetch(request: Request): Promise<Response> {
|
|
25
|
+
if (new URL(request.url).pathname === TAI64N_PATH) {
|
|
26
|
+
return taistamp(request);
|
|
27
|
+
}
|
|
28
|
+
// ...
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Hono route
|
|
33
|
+
app.get(TAI64N_PATH, (c) => taistamp(c.req.raw));
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`newTaistampHandler()` returns an
|
|
37
|
+
`async (request) => Response`. `GET` and `HEAD` succeed
|
|
38
|
+
with a fresh 25-byte TAI64N label
|
|
39
|
+
(`@<sec-hi><sec-lo><nano>`); other methods return `405`
|
|
40
|
+
with `Allow: GET, HEAD`. A request that carries more
|
|
41
|
+
than one `TAI-Nonce` header is rejected with `400` —
|
|
42
|
+
stricter than the spec's "treat as absent" rule, since
|
|
43
|
+
a duplicated singleton field is malformed input.
|
|
44
|
+
|
|
45
|
+
Response headers on success:
|
|
46
|
+
|
|
47
|
+
| Header | Value |
|
|
48
|
+
|--------|-------|
|
|
49
|
+
| `Content-Type` | `application/tai64n` |
|
|
50
|
+
| `Content-Length` | `25` |
|
|
51
|
+
| `Cache-Control` | `no-store` |
|
|
52
|
+
| `TAI-Leap-Seconds` | decimal count (e.g. `37`), always present |
|
|
53
|
+
|
|
54
|
+
A request `TAI-Nonce` is echoed verbatim in the
|
|
55
|
+
response. `HEAD` responses carry the same headers as
|
|
56
|
+
the corresponding `GET` but never include
|
|
57
|
+
`TAI-Key-Selector` or `TAI-Signature` — the signed
|
|
58
|
+
payload covers the response body, so a `HEAD` cannot
|
|
59
|
+
be verified.
|
|
60
|
+
|
|
61
|
+
## Signing
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
import {
|
|
65
|
+
newEd25519Signer,
|
|
66
|
+
newTaistampHandler,
|
|
67
|
+
} from '@kagal/taistamp';
|
|
68
|
+
|
|
69
|
+
const taistamp = newTaistampHandler({
|
|
70
|
+
selector: 'sel2026q2',
|
|
71
|
+
signer: newEd25519Signer(privateKey),
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`signer` and `selector` are co-required: pass both to
|
|
76
|
+
sign, neither for an unsigned handler. Construction
|
|
77
|
+
throws if only one is supplied, or if `selector` does
|
|
78
|
+
not match `[A-Za-z][A-Za-z0-9_-]{0,62}` (a single
|
|
79
|
+
DNS-safe label that starts with a letter and is also a
|
|
80
|
+
valid Structured Field token).
|
|
81
|
+
|
|
82
|
+
When the request is a `GET` carrying a `TAI-Nonce` of
|
|
83
|
+
14–174 octets *and* a signer is configured, the
|
|
84
|
+
response gains:
|
|
85
|
+
|
|
86
|
+
- `TAI-Key-Selector: <selector>`
|
|
87
|
+
- `TAI-Signature: :<base64>:` (sf-binary, RFC 8941)
|
|
88
|
+
over the framed payload.
|
|
89
|
+
|
|
90
|
+
`HEAD`, `405`, nonce-less, and out-of-range-nonce
|
|
91
|
+
responses are never signed.
|
|
92
|
+
|
|
93
|
+
The framed payload is:
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
'taistamp-v1\0' || labelBytes || leapU32BE
|
|
97
|
+
|| selectorLen(u8) || selectorBytes
|
|
98
|
+
|| nonceBytes
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
- `taistamp-v1\0` — domain-separation tag with
|
|
102
|
+
trailing NUL, so the same key cannot be tricked
|
|
103
|
+
into signing for any other protocol.
|
|
104
|
+
- `labelBytes` — the 25 ASCII bytes of the TAI64N
|
|
105
|
+
label.
|
|
106
|
+
- `leapU32BE` — leap-seconds count as a 4-byte
|
|
107
|
+
big-endian unsigned integer.
|
|
108
|
+
- `selectorLen` / `selectorBytes` — the selector
|
|
109
|
+
length-prefixed by a single byte, so a downgrade
|
|
110
|
+
attacker cannot rewrite `TAI-Key-Selector` without
|
|
111
|
+
invalidating the signature.
|
|
112
|
+
- `nonceBytes` — the request nonce, verbatim
|
|
113
|
+
(including any sf-binary `:` framing).
|
|
114
|
+
|
|
115
|
+
`newEd25519Signer(key: CryptoKey)` is the built-in
|
|
116
|
+
signer — pass an Ed25519 private `CryptoKey` with
|
|
117
|
+
`'sign'` usage and the response carries a 64-byte
|
|
118
|
+
RFC 8032 signature. The `Signer` interface is
|
|
119
|
+
HSM/KMS-friendly:
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
interface Signer {
|
|
123
|
+
sign: (message: BufferSource) => Promise<ArrayBuffer>;
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## DNS publication
|
|
128
|
+
|
|
129
|
+
Publish the public key as a DNS `TXT` record at
|
|
130
|
+
`<selector>._taistamp.<host>` (DKIM-style). The same
|
|
131
|
+
host that serves `/.well-known/taistamp`. Verifiers
|
|
132
|
+
read the selector from the `TAI-Key-Selector` response
|
|
133
|
+
header and look up the matching record.
|
|
134
|
+
|
|
135
|
+
TXT record format (single string, ≤ 255 bytes,
|
|
136
|
+
DKIM/DMARC-style tag-value list):
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
v=tai1; k=ed25519; p=<base64-of-32-raw-pubkey-bytes>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
| Tag | Value |
|
|
143
|
+
|-----|-------|
|
|
144
|
+
| `v` | Protocol version. `tai1` for the framing in this README. |
|
|
145
|
+
| `k` | Key algorithm. `ed25519` for the only algorithm currently defined. |
|
|
146
|
+
| `p` | Public key, standard base64. For Ed25519: 32 raw bytes → 43-44 chars. |
|
|
147
|
+
|
|
148
|
+
Rotate by publishing a new selector alongside the old
|
|
149
|
+
one, switching the handler over to the new selector,
|
|
150
|
+
then removing the old TXT once cached responses have
|
|
151
|
+
expired. Verifiers cache by selector, so old
|
|
152
|
+
signatures stay verifiable until their TXT is removed.
|
|
153
|
+
|
|
154
|
+
## Verifying
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
import { taistampSignedPayload } from '@kagal/taistamp';
|
|
158
|
+
|
|
159
|
+
const response = await fetch(taistampURL, {
|
|
160
|
+
headers: { 'TAI-Nonce': clientNonce },
|
|
161
|
+
});
|
|
162
|
+
const label = await response.text();
|
|
163
|
+
const leap = Number(response.headers.get('TAI-Leap-Seconds'));
|
|
164
|
+
const selector = response.headers.get('TAI-Key-Selector')!;
|
|
165
|
+
const sigSf = response.headers.get('TAI-Signature')!;
|
|
166
|
+
|
|
167
|
+
// Look up the public key in DNS at
|
|
168
|
+
// `${selector}._taistamp.${host}` and parse the
|
|
169
|
+
// `p=` tag from the TXT record.
|
|
170
|
+
const publicKey = await loadPublicKey(host, selector);
|
|
171
|
+
|
|
172
|
+
const payload = taistampSignedPayload(
|
|
173
|
+
label,
|
|
174
|
+
leap,
|
|
175
|
+
selector,
|
|
176
|
+
clientNonce,
|
|
177
|
+
);
|
|
178
|
+
const valid = await crypto.subtle.verify(
|
|
179
|
+
'Ed25519',
|
|
180
|
+
publicKey,
|
|
181
|
+
sfBinaryDecode(sigSf), // strip leading/trailing ':' then base64-decode
|
|
182
|
+
payload,
|
|
183
|
+
);
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`taistampSignedPayload(label, leapSeconds, selector,
|
|
187
|
+
nonce)` reconstructs the exact byte sequence the
|
|
188
|
+
server signed; the verifier supplies only the public
|
|
189
|
+
key and an sf-binary decoder. Comparing the verifier's
|
|
190
|
+
recorded nonce against the response's `TAI-Nonce`
|
|
191
|
+
defends against replay.
|
|
192
|
+
|
|
193
|
+
## TAI64N helpers
|
|
194
|
+
|
|
195
|
+
The handler uses these primitives internally; they
|
|
196
|
+
are re-exported for callers that need raw TAI64N
|
|
197
|
+
construction:
|
|
198
|
+
|
|
199
|
+
| Export | Description |
|
|
200
|
+
|--------|-------------|
|
|
201
|
+
| `now()` | Current TAI as `{ sec, nano, offset }` |
|
|
202
|
+
| `fromUTC(utc)` | `Date.now()`-shaped milliseconds → TAI timestamp |
|
|
203
|
+
| `tai64nLabel(t?)` | 25-byte label string for a timestamp (or `now()`) |
|
|
204
|
+
| `tai64nLabelFromUTC(utc)` | Shortcut for `tai64nLabel(fromUTC(utc))` |
|
|
205
|
+
|
|
206
|
+
`fromUTC` applies the constant `TAI_OFFSET` (currently
|
|
207
|
+
37 seconds). Historic UTC timestamps spanning a
|
|
208
|
+
leap-second boundary need caller-side adjustment —
|
|
209
|
+
the constant tracks the present, not history.
|
|
210
|
+
|
|
211
|
+
## Constants
|
|
212
|
+
|
|
213
|
+
| Name | Value |
|
|
214
|
+
|------|-------|
|
|
215
|
+
| `TAI64N_PATH` | `/.well-known/taistamp` |
|
|
216
|
+
| `TAI64N_CONTENT_TYPE` | `application/tai64n` |
|
|
217
|
+
| `TAI64N_CONTENT_LENGTH` | `25` |
|
|
218
|
+
| `TAI64N_HEADER_KEY_SELECTOR` | `TAI-Key-Selector` |
|
|
219
|
+
| `TAI64N_HEADER_LEAP_SECONDS` | `TAI-Leap-Seconds` |
|
|
220
|
+
| `TAI64N_HEADER_NONCE` | `TAI-Nonce` |
|
|
221
|
+
| `TAI64N_HEADER_SIGNATURE` | `TAI-Signature` |
|
|
222
|
+
| `TAI_OFFSET` | `37` |
|
|
223
|
+
| `TAI64_EPOCH_HI` | `0x40000000` |
|
|
224
|
+
|
|
225
|
+
## Licence
|
|
226
|
+
|
|
227
|
+
[MIT][mit]
|
|
228
|
+
|
|
229
|
+
<!-- references -->
|
|
230
|
+
[mit]: ../../LICENCE.txt
|
|
231
|
+
[tai64n]: https://cr.yp.to/libtai/tai64.html
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
declare const TAI_OFFSET: number;
|
|
2
|
+
declare const TAI64N_PATH = "/.well-known/taistamp";
|
|
3
|
+
declare const TAI64N_CONTENT_TYPE = "application/tai64n";
|
|
4
|
+
declare const TAI64N_CONTENT_LENGTH: number;
|
|
5
|
+
declare const TAI64N_HEADER_KEY_SELECTOR = "TAI-Key-Selector";
|
|
6
|
+
declare const TAI64N_HEADER_LEAP_SECONDS = "TAI-Leap-Seconds";
|
|
7
|
+
declare const TAI64N_HEADER_NONCE = "TAI-Nonce";
|
|
8
|
+
declare const TAI64N_HEADER_SIGNATURE = "TAI-Signature";
|
|
9
|
+
declare const TAI64_EPOCH_HI = 1073741824;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generic signer abstraction over a private key.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* The handler doesn't care which algorithm or key
|
|
16
|
+
* store produced the signature, only that signing
|
|
17
|
+
* succeeded. Pluggable so consumers can wire in
|
|
18
|
+
* HSM-backed, KMS-backed, or in-process WebCrypto
|
|
19
|
+
* signers without touching the handler. Verifiers
|
|
20
|
+
* must agree on the algorithm and the public key
|
|
21
|
+
* out-of-band — typically by pinning the public key
|
|
22
|
+
* to a DNS TXT record.
|
|
23
|
+
*/
|
|
24
|
+
interface Signer {
|
|
25
|
+
/**
|
|
26
|
+
* Produce a signature over `message`.
|
|
27
|
+
*
|
|
28
|
+
* @param message - bytes to sign; the caller is
|
|
29
|
+
* responsible for any framing or domain separation.
|
|
30
|
+
* Typed as {@link BufferSource} to match WebCrypto's
|
|
31
|
+
* own input shape — any `ArrayBuffer` or typed-array
|
|
32
|
+
* view is accepted.
|
|
33
|
+
* @returns the raw signature bytes (algorithm-defined
|
|
34
|
+
* length and encoding) as an `ArrayBuffer`, matching
|
|
35
|
+
* WebCrypto's native output shape
|
|
36
|
+
*/
|
|
37
|
+
sign: (message: BufferSource) => Promise<ArrayBuffer>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Build a {@link Signer} backed by a WebCrypto Ed25519
|
|
41
|
+
* private `CryptoKey`.
|
|
42
|
+
*
|
|
43
|
+
* @param key - Ed25519 private key with `'sign'` in
|
|
44
|
+
* `key.usages`. The algorithm `name` must be
|
|
45
|
+
* `'Ed25519'`.
|
|
46
|
+
* @returns a {@link Signer} producing 64-byte raw
|
|
47
|
+
* Ed25519 signatures (R ‖ s, RFC 8032)
|
|
48
|
+
*
|
|
49
|
+
* @see {@link https://datatracker.ietf.org/doc/html/rfc8032}
|
|
50
|
+
*/
|
|
51
|
+
declare const newEd25519Signer: (key: CryptoKey) => Signer;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compose the byte sequence covered by a TAI-Signature.
|
|
55
|
+
*
|
|
56
|
+
* @param label - the 25-byte TAI64N label string the
|
|
57
|
+
* server is returning
|
|
58
|
+
* @param leapSeconds - the leap-seconds count the server
|
|
59
|
+
* advertises in `TAI-Leap-Seconds`
|
|
60
|
+
* @param selector - the key selector the server
|
|
61
|
+
* advertises in `TAI-Key-Selector`; verifiers use
|
|
62
|
+
* this to look up the public key in DNS at
|
|
63
|
+
* `<selector>._taistamp.<host>`
|
|
64
|
+
* @param nonce - the client-supplied nonce, echoed
|
|
65
|
+
* verbatim in `TAI-Nonce`
|
|
66
|
+
* @returns the byte sequence verifiers reconstruct
|
|
67
|
+
* from the response and pass to their public-key
|
|
68
|
+
* verify routine. The framing is the
|
|
69
|
+
* domain-separation tag (`taistamp-v1` plus a
|
|
70
|
+
* trailing NUL byte), then the label bytes, then
|
|
71
|
+
* the leap-seconds count as a 4-byte big-endian
|
|
72
|
+
* unsigned integer, then a 1-byte selector length,
|
|
73
|
+
* then the selector bytes, then the nonce bytes.
|
|
74
|
+
*
|
|
75
|
+
* @remarks
|
|
76
|
+
* Binding the selector into the signed payload stops a
|
|
77
|
+
* downgrade attacker from rewriting `TAI-Key-Selector`
|
|
78
|
+
* to point at a compromised or weaker key — the
|
|
79
|
+
* signature would no longer verify under that key.
|
|
80
|
+
* `leapSeconds` is encoded as a 4-byte big-endian
|
|
81
|
+
* unsigned integer; the selector is length-prefixed by
|
|
82
|
+
* a single byte (selectors are ≤ 63 chars per
|
|
83
|
+
* {@link newTaistampHandler}'s validation).
|
|
84
|
+
*/
|
|
85
|
+
declare const taistampSignedPayload: (label: string, leapSeconds: number, selector: string, nonce: string) => ArrayBuffer;
|
|
86
|
+
/**
|
|
87
|
+
* Configuration for {@link newTaistampHandler}.
|
|
88
|
+
*
|
|
89
|
+
* @remarks
|
|
90
|
+
* `signer` and `selector` are co-required: pass both
|
|
91
|
+
* to enable authenticated responses, or neither for
|
|
92
|
+
* an unsigned handler. Passing only one is rejected
|
|
93
|
+
* at construction time — without the selector
|
|
94
|
+
* verifiers cannot find the key in DNS, and a
|
|
95
|
+
* selector without a signer is a misconfiguration.
|
|
96
|
+
*/
|
|
97
|
+
interface TaistampHandlerConfig {
|
|
98
|
+
/**
|
|
99
|
+
* Key selector advertised in the `TAI-Key-Selector`
|
|
100
|
+
* response header and bound into the signed payload.
|
|
101
|
+
* Verifiers look up the public key at
|
|
102
|
+
* `<selector>._taistamp.<host>` in DNS.
|
|
103
|
+
*
|
|
104
|
+
* Must match `[A-Za-z][A-Za-z0-9_-]{0,62}` (a single
|
|
105
|
+
* DNS label starting with a letter, using
|
|
106
|
+
* DKIM-compatible characters and a valid sf-token);
|
|
107
|
+
* rotate by changing the selector and publishing a
|
|
108
|
+
* new TXT record.
|
|
109
|
+
*/
|
|
110
|
+
selector?: string;
|
|
111
|
+
/**
|
|
112
|
+
* {@link Signer} that produces `TAI-Signature` over
|
|
113
|
+
* the framed payload from {@link taistampSignedPayload}.
|
|
114
|
+
* Without a signer the nonce is still echoed but the
|
|
115
|
+
* response is unsigned.
|
|
116
|
+
*/
|
|
117
|
+
signer?: Signer;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Build a handler for `/.well-known/taistamp`.
|
|
121
|
+
*
|
|
122
|
+
* @param config - optional {@link TaistampHandlerConfig}
|
|
123
|
+
* @returns an `async (request) => Response` callable
|
|
124
|
+
* directly as a Web `fetch` handler or as a Hono
|
|
125
|
+
* route handler.
|
|
126
|
+
*
|
|
127
|
+
* @throws TypeError if `signer` and `selector` are not
|
|
128
|
+
* both set or both unset, or if `selector` does not
|
|
129
|
+
* match `[A-Za-z][A-Za-z0-9_-]{0,62}`.
|
|
130
|
+
*
|
|
131
|
+
* @remarks
|
|
132
|
+
* Behaviour:
|
|
133
|
+
*
|
|
134
|
+
* - `GET` / `HEAD` — body is a fresh 25-byte TAI64N
|
|
135
|
+
* label (`HEAD` omits the body). Response headers:
|
|
136
|
+
* Content-Type `application/tai64n`, Content-Length
|
|
137
|
+
* `25`, Cache-Control `no-store`, plus
|
|
138
|
+
* `TAI-Leap-Seconds` carrying the current count.
|
|
139
|
+
* - Any other method — `405 Method Not Allowed` with
|
|
140
|
+
* `Allow: GET, HEAD`.
|
|
141
|
+
* - Request with more than one `TAI-Nonce` header —
|
|
142
|
+
* `400 Bad Request`. Stricter than the spec's
|
|
143
|
+
* "treat as absent" rule: a duplicated singleton
|
|
144
|
+
* field is malformed input, so we refuse rather
|
|
145
|
+
* than silently down-ranking it to unsigned.
|
|
146
|
+
* - Request `TAI-Nonce` — the value is echoed verbatim
|
|
147
|
+
* in the response.
|
|
148
|
+
* - Request `TAI-Nonce` *and* `signer` configured *and*
|
|
149
|
+
* the request method is `GET` *and* the nonce field
|
|
150
|
+
* value is between 14 and 174 octets — adds
|
|
151
|
+
* `TAI-Key-Selector` and `TAI-Signature` (sf-binary)
|
|
152
|
+
* over the bytes produced by
|
|
153
|
+
* {@link taistampSignedPayload}. The
|
|
154
|
+
* domain-separation tag means the same key cannot
|
|
155
|
+
* be tricked into producing valid signatures for
|
|
156
|
+
* other protocols. `HEAD` and `405` responses are
|
|
157
|
+
* never signed.
|
|
158
|
+
*
|
|
159
|
+
* The corresponding public key is expected to be
|
|
160
|
+
* published out-of-band as a DNS TXT record at
|
|
161
|
+
* `<selector>._taistamp.<host>` — verifiers fetch the
|
|
162
|
+
* key by selector so the operator can rotate keys by
|
|
163
|
+
* publishing a new selector while the old one is
|
|
164
|
+
* still cached.
|
|
165
|
+
*
|
|
166
|
+
* @see {@link https://cr.yp.to/libtai/tai64.html} for
|
|
167
|
+
* TAI64N format
|
|
168
|
+
*/
|
|
169
|
+
declare const newTaistampHandler: (config?: TaistampHandlerConfig) => ((request: Request) => Promise<Response>);
|
|
170
|
+
|
|
171
|
+
type timestamp = {
|
|
172
|
+
nano: number;
|
|
173
|
+
sec: number;
|
|
174
|
+
offset?: number;
|
|
175
|
+
};
|
|
176
|
+
declare const fromUTC: (utc: number) => timestamp;
|
|
177
|
+
declare const now: () => timestamp;
|
|
178
|
+
declare const tai64nLabel: (value?: timestamp) => string;
|
|
179
|
+
declare const tai64nLabelFromUTC: (utc: number) => string;
|
|
180
|
+
|
|
181
|
+
/** Package version from package.json. */
|
|
182
|
+
declare const VERSION: string;
|
|
183
|
+
|
|
184
|
+
export { 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, TAI_OFFSET, VERSION, fromUTC, newEd25519Signer, newTaistampHandler, now, tai64nLabel, tai64nLabelFromUTC, taistampSignedPayload };
|
|
185
|
+
export type { Signer, TaistampHandlerConfig };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
declare const TAI_OFFSET: number;
|
|
2
|
+
declare const TAI64N_PATH = "/.well-known/taistamp";
|
|
3
|
+
declare const TAI64N_CONTENT_TYPE = "application/tai64n";
|
|
4
|
+
declare const TAI64N_CONTENT_LENGTH: number;
|
|
5
|
+
declare const TAI64N_HEADER_KEY_SELECTOR = "TAI-Key-Selector";
|
|
6
|
+
declare const TAI64N_HEADER_LEAP_SECONDS = "TAI-Leap-Seconds";
|
|
7
|
+
declare const TAI64N_HEADER_NONCE = "TAI-Nonce";
|
|
8
|
+
declare const TAI64N_HEADER_SIGNATURE = "TAI-Signature";
|
|
9
|
+
declare const TAI64_EPOCH_HI = 1073741824;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generic signer abstraction over a private key.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* The handler doesn't care which algorithm or key
|
|
16
|
+
* store produced the signature, only that signing
|
|
17
|
+
* succeeded. Pluggable so consumers can wire in
|
|
18
|
+
* HSM-backed, KMS-backed, or in-process WebCrypto
|
|
19
|
+
* signers without touching the handler. Verifiers
|
|
20
|
+
* must agree on the algorithm and the public key
|
|
21
|
+
* out-of-band — typically by pinning the public key
|
|
22
|
+
* to a DNS TXT record.
|
|
23
|
+
*/
|
|
24
|
+
interface Signer {
|
|
25
|
+
/**
|
|
26
|
+
* Produce a signature over `message`.
|
|
27
|
+
*
|
|
28
|
+
* @param message - bytes to sign; the caller is
|
|
29
|
+
* responsible for any framing or domain separation.
|
|
30
|
+
* Typed as {@link BufferSource} to match WebCrypto's
|
|
31
|
+
* own input shape — any `ArrayBuffer` or typed-array
|
|
32
|
+
* view is accepted.
|
|
33
|
+
* @returns the raw signature bytes (algorithm-defined
|
|
34
|
+
* length and encoding) as an `ArrayBuffer`, matching
|
|
35
|
+
* WebCrypto's native output shape
|
|
36
|
+
*/
|
|
37
|
+
sign: (message: BufferSource) => Promise<ArrayBuffer>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Build a {@link Signer} backed by a WebCrypto Ed25519
|
|
41
|
+
* private `CryptoKey`.
|
|
42
|
+
*
|
|
43
|
+
* @param key - Ed25519 private key with `'sign'` in
|
|
44
|
+
* `key.usages`. The algorithm `name` must be
|
|
45
|
+
* `'Ed25519'`.
|
|
46
|
+
* @returns a {@link Signer} producing 64-byte raw
|
|
47
|
+
* Ed25519 signatures (R ‖ s, RFC 8032)
|
|
48
|
+
*
|
|
49
|
+
* @see {@link https://datatracker.ietf.org/doc/html/rfc8032}
|
|
50
|
+
*/
|
|
51
|
+
declare const newEd25519Signer: (key: CryptoKey) => Signer;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compose the byte sequence covered by a TAI-Signature.
|
|
55
|
+
*
|
|
56
|
+
* @param label - the 25-byte TAI64N label string the
|
|
57
|
+
* server is returning
|
|
58
|
+
* @param leapSeconds - the leap-seconds count the server
|
|
59
|
+
* advertises in `TAI-Leap-Seconds`
|
|
60
|
+
* @param selector - the key selector the server
|
|
61
|
+
* advertises in `TAI-Key-Selector`; verifiers use
|
|
62
|
+
* this to look up the public key in DNS at
|
|
63
|
+
* `<selector>._taistamp.<host>`
|
|
64
|
+
* @param nonce - the client-supplied nonce, echoed
|
|
65
|
+
* verbatim in `TAI-Nonce`
|
|
66
|
+
* @returns the byte sequence verifiers reconstruct
|
|
67
|
+
* from the response and pass to their public-key
|
|
68
|
+
* verify routine. The framing is the
|
|
69
|
+
* domain-separation tag (`taistamp-v1` plus a
|
|
70
|
+
* trailing NUL byte), then the label bytes, then
|
|
71
|
+
* the leap-seconds count as a 4-byte big-endian
|
|
72
|
+
* unsigned integer, then a 1-byte selector length,
|
|
73
|
+
* then the selector bytes, then the nonce bytes.
|
|
74
|
+
*
|
|
75
|
+
* @remarks
|
|
76
|
+
* Binding the selector into the signed payload stops a
|
|
77
|
+
* downgrade attacker from rewriting `TAI-Key-Selector`
|
|
78
|
+
* to point at a compromised or weaker key — the
|
|
79
|
+
* signature would no longer verify under that key.
|
|
80
|
+
* `leapSeconds` is encoded as a 4-byte big-endian
|
|
81
|
+
* unsigned integer; the selector is length-prefixed by
|
|
82
|
+
* a single byte (selectors are ≤ 63 chars per
|
|
83
|
+
* {@link newTaistampHandler}'s validation).
|
|
84
|
+
*/
|
|
85
|
+
declare const taistampSignedPayload: (label: string, leapSeconds: number, selector: string, nonce: string) => ArrayBuffer;
|
|
86
|
+
/**
|
|
87
|
+
* Configuration for {@link newTaistampHandler}.
|
|
88
|
+
*
|
|
89
|
+
* @remarks
|
|
90
|
+
* `signer` and `selector` are co-required: pass both
|
|
91
|
+
* to enable authenticated responses, or neither for
|
|
92
|
+
* an unsigned handler. Passing only one is rejected
|
|
93
|
+
* at construction time — without the selector
|
|
94
|
+
* verifiers cannot find the key in DNS, and a
|
|
95
|
+
* selector without a signer is a misconfiguration.
|
|
96
|
+
*/
|
|
97
|
+
interface TaistampHandlerConfig {
|
|
98
|
+
/**
|
|
99
|
+
* Key selector advertised in the `TAI-Key-Selector`
|
|
100
|
+
* response header and bound into the signed payload.
|
|
101
|
+
* Verifiers look up the public key at
|
|
102
|
+
* `<selector>._taistamp.<host>` in DNS.
|
|
103
|
+
*
|
|
104
|
+
* Must match `[A-Za-z][A-Za-z0-9_-]{0,62}` (a single
|
|
105
|
+
* DNS label starting with a letter, using
|
|
106
|
+
* DKIM-compatible characters and a valid sf-token);
|
|
107
|
+
* rotate by changing the selector and publishing a
|
|
108
|
+
* new TXT record.
|
|
109
|
+
*/
|
|
110
|
+
selector?: string;
|
|
111
|
+
/**
|
|
112
|
+
* {@link Signer} that produces `TAI-Signature` over
|
|
113
|
+
* the framed payload from {@link taistampSignedPayload}.
|
|
114
|
+
* Without a signer the nonce is still echoed but the
|
|
115
|
+
* response is unsigned.
|
|
116
|
+
*/
|
|
117
|
+
signer?: Signer;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Build a handler for `/.well-known/taistamp`.
|
|
121
|
+
*
|
|
122
|
+
* @param config - optional {@link TaistampHandlerConfig}
|
|
123
|
+
* @returns an `async (request) => Response` callable
|
|
124
|
+
* directly as a Web `fetch` handler or as a Hono
|
|
125
|
+
* route handler.
|
|
126
|
+
*
|
|
127
|
+
* @throws TypeError if `signer` and `selector` are not
|
|
128
|
+
* both set or both unset, or if `selector` does not
|
|
129
|
+
* match `[A-Za-z][A-Za-z0-9_-]{0,62}`.
|
|
130
|
+
*
|
|
131
|
+
* @remarks
|
|
132
|
+
* Behaviour:
|
|
133
|
+
*
|
|
134
|
+
* - `GET` / `HEAD` — body is a fresh 25-byte TAI64N
|
|
135
|
+
* label (`HEAD` omits the body). Response headers:
|
|
136
|
+
* Content-Type `application/tai64n`, Content-Length
|
|
137
|
+
* `25`, Cache-Control `no-store`, plus
|
|
138
|
+
* `TAI-Leap-Seconds` carrying the current count.
|
|
139
|
+
* - Any other method — `405 Method Not Allowed` with
|
|
140
|
+
* `Allow: GET, HEAD`.
|
|
141
|
+
* - Request with more than one `TAI-Nonce` header —
|
|
142
|
+
* `400 Bad Request`. Stricter than the spec's
|
|
143
|
+
* "treat as absent" rule: a duplicated singleton
|
|
144
|
+
* field is malformed input, so we refuse rather
|
|
145
|
+
* than silently down-ranking it to unsigned.
|
|
146
|
+
* - Request `TAI-Nonce` — the value is echoed verbatim
|
|
147
|
+
* in the response.
|
|
148
|
+
* - Request `TAI-Nonce` *and* `signer` configured *and*
|
|
149
|
+
* the request method is `GET` *and* the nonce field
|
|
150
|
+
* value is between 14 and 174 octets — adds
|
|
151
|
+
* `TAI-Key-Selector` and `TAI-Signature` (sf-binary)
|
|
152
|
+
* over the bytes produced by
|
|
153
|
+
* {@link taistampSignedPayload}. The
|
|
154
|
+
* domain-separation tag means the same key cannot
|
|
155
|
+
* be tricked into producing valid signatures for
|
|
156
|
+
* other protocols. `HEAD` and `405` responses are
|
|
157
|
+
* never signed.
|
|
158
|
+
*
|
|
159
|
+
* The corresponding public key is expected to be
|
|
160
|
+
* published out-of-band as a DNS TXT record at
|
|
161
|
+
* `<selector>._taistamp.<host>` — verifiers fetch the
|
|
162
|
+
* key by selector so the operator can rotate keys by
|
|
163
|
+
* publishing a new selector while the old one is
|
|
164
|
+
* still cached.
|
|
165
|
+
*
|
|
166
|
+
* @see {@link https://cr.yp.to/libtai/tai64.html} for
|
|
167
|
+
* TAI64N format
|
|
168
|
+
*/
|
|
169
|
+
declare const newTaistampHandler: (config?: TaistampHandlerConfig) => ((request: Request) => Promise<Response>);
|
|
170
|
+
|
|
171
|
+
type timestamp = {
|
|
172
|
+
nano: number;
|
|
173
|
+
sec: number;
|
|
174
|
+
offset?: number;
|
|
175
|
+
};
|
|
176
|
+
declare const fromUTC: (utc: number) => timestamp;
|
|
177
|
+
declare const now: () => timestamp;
|
|
178
|
+
declare const tai64nLabel: (value?: timestamp) => string;
|
|
179
|
+
declare const tai64nLabelFromUTC: (utc: number) => string;
|
|
180
|
+
|
|
181
|
+
/** Package version from package.json. */
|
|
182
|
+
declare const VERSION: string;
|
|
183
|
+
|
|
184
|
+
export { 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, TAI_OFFSET, VERSION, fromUTC, newEd25519Signer, newTaistampHandler, now, tai64nLabel, tai64nLabelFromUTC, taistampSignedPayload };
|
|
185
|
+
export type { Signer, TaistampHandlerConfig };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const version = "0.0.1";
|
|
2
|
+
const pkg = {
|
|
3
|
+
version: version};
|
|
4
|
+
|
|
5
|
+
const TAI_OFFSET = 37;
|
|
6
|
+
const TAI64N_PATH = "/.well-known/taistamp";
|
|
7
|
+
const TAI64N_CONTENT_TYPE = "application/tai64n";
|
|
8
|
+
const TAI64N_CONTENT_LENGTH = 1 + 16 + 8;
|
|
9
|
+
const TAI64N_HEADER_KEY_SELECTOR = "TAI-Key-Selector";
|
|
10
|
+
const TAI64N_HEADER_LEAP_SECONDS = "TAI-Leap-Seconds";
|
|
11
|
+
const TAI64N_HEADER_NONCE = "TAI-Nonce";
|
|
12
|
+
const TAI64N_HEADER_SIGNATURE = "TAI-Signature";
|
|
13
|
+
const TAI64_EPOCH_HI = 1073741824;
|
|
14
|
+
|
|
15
|
+
const fromUTC = (utc) => {
|
|
16
|
+
const sec = Math.floor(utc / 1e3) + TAI_OFFSET;
|
|
17
|
+
const nano = utc % 1e3 * 1e6;
|
|
18
|
+
return { sec, nano, offset: TAI_OFFSET };
|
|
19
|
+
};
|
|
20
|
+
const now = () => {
|
|
21
|
+
const utc = Date.now();
|
|
22
|
+
return fromUTC(utc);
|
|
23
|
+
};
|
|
24
|
+
const tai64nLabel = (value) => {
|
|
25
|
+
const { sec, nano } = value ?? now();
|
|
26
|
+
const secHi = Math.trunc(sec / u32Range) + TAI64_EPOCH_HI;
|
|
27
|
+
const secLo = sec % u32Range;
|
|
28
|
+
const secHiHex = secHi.toString(16).padStart(8, "0");
|
|
29
|
+
const secLoHex = secLo.toString(16).padStart(8, "0");
|
|
30
|
+
const nanoHex = nano.toString(16).padStart(8, "0");
|
|
31
|
+
return `@${secHiHex}${secLoHex}${nanoHex}`;
|
|
32
|
+
};
|
|
33
|
+
const tai64nLabelFromUTC = (utc) => tai64nLabel(fromUTC(utc));
|
|
34
|
+
const u32Range = 4294967296;
|
|
35
|
+
|
|
36
|
+
const SELECTOR_PATTERN = /^[A-Za-z][\dA-Za-z_-]{0,62}$/;
|
|
37
|
+
const NONCE_MIN_OCTETS = 14;
|
|
38
|
+
const NONCE_MAX_OCTETS = 174;
|
|
39
|
+
const textEncoder = new TextEncoder();
|
|
40
|
+
const DOMAIN_SEPARATOR = textEncoder.encode("taistamp-v1\0");
|
|
41
|
+
const asBytes = (source) => {
|
|
42
|
+
if (source instanceof Uint8Array) {
|
|
43
|
+
return source;
|
|
44
|
+
}
|
|
45
|
+
if (ArrayBuffer.isView(source)) {
|
|
46
|
+
return new Uint8Array(
|
|
47
|
+
source.buffer,
|
|
48
|
+
source.byteOffset,
|
|
49
|
+
source.byteLength
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return new Uint8Array(source);
|
|
53
|
+
};
|
|
54
|
+
const encodeStructuredBinary = (source) => {
|
|
55
|
+
const bytes = asBytes(source);
|
|
56
|
+
const standard = btoa(String.fromCodePoint(...bytes));
|
|
57
|
+
return `:${standard}:`;
|
|
58
|
+
};
|
|
59
|
+
const taistampSignedPayload = (label, leapSeconds, selector, nonce) => {
|
|
60
|
+
const labelBytes = textEncoder.encode(label);
|
|
61
|
+
const selectorBytes = textEncoder.encode(selector);
|
|
62
|
+
const nonceBytes = textEncoder.encode(nonce);
|
|
63
|
+
const buffer = new ArrayBuffer(
|
|
64
|
+
DOMAIN_SEPARATOR.length + labelBytes.length + 4 + 1 + selectorBytes.length + nonceBytes.length
|
|
65
|
+
);
|
|
66
|
+
const view = new Uint8Array(buffer);
|
|
67
|
+
let offset = 0;
|
|
68
|
+
view.set(DOMAIN_SEPARATOR, offset);
|
|
69
|
+
offset += DOMAIN_SEPARATOR.length;
|
|
70
|
+
view.set(labelBytes, offset);
|
|
71
|
+
offset += labelBytes.length;
|
|
72
|
+
new DataView(buffer).setUint32(offset, leapSeconds, false);
|
|
73
|
+
offset += 4;
|
|
74
|
+
view[offset] = selectorBytes.length;
|
|
75
|
+
offset += 1;
|
|
76
|
+
view.set(selectorBytes, offset);
|
|
77
|
+
offset += selectorBytes.length;
|
|
78
|
+
view.set(nonceBytes, offset);
|
|
79
|
+
return buffer;
|
|
80
|
+
};
|
|
81
|
+
const newTaistampHandler = (config = {}) => {
|
|
82
|
+
const { selector, signer } = config;
|
|
83
|
+
if (signer === void 0 !== (selector === void 0)) {
|
|
84
|
+
throw new TypeError(
|
|
85
|
+
"newTaistampHandler: signer and selector must be set together"
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (selector !== void 0 && !SELECTOR_PATTERN.test(selector)) {
|
|
89
|
+
throw new TypeError(
|
|
90
|
+
`newTaistampHandler: selector must match ${SELECTOR_PATTERN.source}`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return async (request) => {
|
|
94
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
95
|
+
return new Response(void 0, {
|
|
96
|
+
status: 405,
|
|
97
|
+
headers: { allow: "GET, HEAD" }
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const nonce = request.headers.get(TAI64N_HEADER_NONCE);
|
|
101
|
+
if (nonce !== null && nonce.includes(",")) {
|
|
102
|
+
return new Response(void 0, { status: 400 });
|
|
103
|
+
}
|
|
104
|
+
const label = tai64nLabel();
|
|
105
|
+
const headers = new Headers({
|
|
106
|
+
"cache-control": "no-store",
|
|
107
|
+
"content-length": String(TAI64N_CONTENT_LENGTH),
|
|
108
|
+
"content-type": TAI64N_CONTENT_TYPE,
|
|
109
|
+
[TAI64N_HEADER_LEAP_SECONDS]: String(TAI_OFFSET)
|
|
110
|
+
});
|
|
111
|
+
if (nonce !== null) {
|
|
112
|
+
headers.set(TAI64N_HEADER_NONCE, nonce);
|
|
113
|
+
const nonceLength = textEncoder.encode(nonce).length;
|
|
114
|
+
const inRange = nonceLength >= NONCE_MIN_OCTETS && nonceLength <= NONCE_MAX_OCTETS;
|
|
115
|
+
if (request.method === "GET" && signer !== void 0 && selector !== void 0 && inRange) {
|
|
116
|
+
const message = taistampSignedPayload(
|
|
117
|
+
label,
|
|
118
|
+
TAI_OFFSET,
|
|
119
|
+
selector,
|
|
120
|
+
nonce
|
|
121
|
+
);
|
|
122
|
+
const signature = await signer.sign(message);
|
|
123
|
+
headers.set(TAI64N_HEADER_KEY_SELECTOR, selector);
|
|
124
|
+
headers.set(
|
|
125
|
+
TAI64N_HEADER_SIGNATURE,
|
|
126
|
+
encodeStructuredBinary(signature)
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const body = request.method === "HEAD" ? void 0 : label;
|
|
131
|
+
return new Response(body, { status: 200, headers });
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const newEd25519Signer = (key) => ({
|
|
136
|
+
sign: async (message) => crypto.subtle.sign("Ed25519", key, message)
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const VERSION = pkg.version;
|
|
140
|
+
|
|
141
|
+
export { 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, TAI_OFFSET, VERSION, fromUTC, newEd25519Signer, newTaistampHandler, now, tai64nLabel, tai64nLabelFromUTC, taistampSignedPayload };
|
|
142
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","sources":["../src/const.ts","../src/utils.ts","../src/handler.ts","../src/signer.ts","../src/index.ts"],"sourcesContent":["export const TAI_OFFSET: number = 37;\n\nexport const TAI64N_PATH = '/.well-known/taistamp';\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 { TAI64_EPOCH_HI, TAI_OFFSET } from './const';\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_OFFSET;\n const nano = (utc % 1000) * 1e6;\n return { sec, nano, offset: TAI_OFFSET };\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 type { Signer } from './signer';\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 TAI_OFFSET,\n} from './const';\nimport { tai64nLabel } from './utils';\n\nconst SELECTOR_PATTERN = /^[A-Za-z][\\dA-Za-z_-]{0,62}$/;\n\nconst NONCE_MIN_OCTETS = 14;\nconst NONCE_MAX_OCTETS = 174;\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 8941 §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/rfc8941#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`\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 taistampSignedPayload = (\n label: string,\n leapSeconds: number,\n selector: string,\n nonce: string,\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 taistampSignedPayload}.\n * Without a signer the nonce is still echoed but the\n * response is unsigned.\n */\n signer?: Signer\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 * - Any other method — `405 Method Not Allowed` with\n * `Allow: GET, HEAD`.\n * - Request with more than one `TAI-Nonce` header —\n * `400 Bad Request`. Stricter than the spec's\n * \"treat as absent\" rule: a duplicated singleton\n * field is malformed input, so we refuse rather\n * than silently down-ranking it to unsigned.\n * - Request `TAI-Nonce` — the value is echoed verbatim\n * in the response.\n * - Request `TAI-Nonce` *and* `signer` configured *and*\n * the request method is `GET` *and* the nonce field\n * value is between 14 and 174 octets — adds\n * `TAI-Key-Selector` and `TAI-Signature` (sf-binary)\n * over the bytes produced by\n * {@link taistampSignedPayload}. The\n * domain-separation tag means the same key cannot\n * be tricked into producing valid signatures for\n * other protocols. `HEAD` and `405` responses are\n * 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 { 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 (selector !== undefined && !SELECTOR_PATTERN.test(selector)) {\n throw new TypeError(\n `newTaistampHandler: selector must match ${SELECTOR_PATTERN.source}`,\n );\n }\n\n return async (request) => {\n if (request.method !== 'GET' && request.method !== 'HEAD') {\n return new Response(undefined, {\n status: 405,\n headers: { allow: 'GET, HEAD' },\n });\n }\n\n const nonce = request.headers.get(TAI64N_HEADER_NONCE);\n\n // `TAI-Nonce` is a singleton sf-binary; a valid value\n // contains no `,` of its own, so a comma in the joined\n // header value means the client sent the field more\n // than once.\n if (nonce !== null && nonce.includes(',')) {\n return new Response(undefined, { status: 400 });\n }\n\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_OFFSET),\n });\n\n if (nonce !== null) {\n headers.set(TAI64N_HEADER_NONCE, nonce);\n\n const nonceLength = textEncoder.encode(nonce).length;\n const inRange =\n nonceLength >= NONCE_MIN_OCTETS && nonceLength <= NONCE_MAX_OCTETS;\n\n if (\n request.method === 'GET' &&\n signer !== undefined &&\n selector !== undefined &&\n inRange\n ) {\n const message = taistampSignedPayload(\n label,\n TAI_OFFSET,\n selector,\n nonce,\n );\n const signature = await signer.sign(message);\n headers.set(TAI64N_HEADER_KEY_SELECTOR, selector);\n headers.set(\n TAI64N_HEADER_SIGNATURE,\n encodeStructuredBinary(signature),\n );\n }\n }\n\n const body = request.method === 'HEAD' ? undefined : label;\n return new Response(body, { status: 200, headers });\n };\n};\n","/**\n * Generic signer abstraction over a private key.\n *\n * @remarks\n * The handler doesn't care which algorithm or key\n * store produced the signature, only that signing\n * succeeded. Pluggable so consumers can wire in\n * HSM-backed, KMS-backed, or in-process WebCrypto\n * signers without touching the handler. Verifiers\n * must agree on the algorithm and the public key\n * out-of-band — typically by pinning the public key\n * to a DNS TXT record.\n */\nexport interface Signer {\n /**\n * Produce a signature over `message`.\n *\n * @param message - bytes to sign; the caller is\n * responsible for any framing or domain separation.\n * Typed as {@link BufferSource} to match WebCrypto's\n * own input shape — any `ArrayBuffer` or typed-array\n * view is accepted.\n * @returns the raw signature bytes (algorithm-defined\n * length and encoding) as an `ArrayBuffer`, matching\n * WebCrypto's native output shape\n */\n sign: (message: BufferSource) => Promise<ArrayBuffer>\n}\n\n/**\n * Build a {@link Signer} backed by a WebCrypto Ed25519\n * private `CryptoKey`.\n *\n * @param key - Ed25519 private key with `'sign'` in\n * `key.usages`. The algorithm `name` must be\n * `'Ed25519'`.\n * @returns a {@link Signer} producing 64-byte raw\n * Ed25519 signatures (R ‖ s, RFC 8032)\n *\n * @see {@link https://datatracker.ietf.org/doc/html/rfc8032}\n */\nexport const newEd25519Signer = (key: CryptoKey): Signer => ({\n sign: async (message) => crypto.subtle.sign('Ed25519', key, message),\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 * from './const';\nexport {\n newTaistampHandler,\n type TaistampHandlerConfig,\n taistampSignedPayload,\n} from './handler';\nexport {\n newEd25519Signer,\n type Signer,\n} from './signer';\nexport {\n fromUTC,\n now,\n tai64nLabel,\n tai64nLabelFromUTC,\n} from './utils';\n"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kagal/taistamp",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Signed TAI64N timestamps over HTTP",
|
|
6
|
+
"author": "Apptly Software Ltd <oss@apptly.co>",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"homepage": "https://github.com/kagal-dev/taistamp/tree/main/packages/@kagal-taistamp#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/kagal-dev/taistamp.git",
|
|
12
|
+
"directory": "packages/@kagal-taistamp"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/kagal-dev/taistamp/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"kagal",
|
|
19
|
+
"tai64n",
|
|
20
|
+
"taistamp",
|
|
21
|
+
"typescript"
|
|
22
|
+
],
|
|
23
|
+
"types": "./dist/index.d.mts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.mts",
|
|
27
|
+
"default": "./dist/index.mjs"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist"
|
|
32
|
+
],
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@kagal/build-tsdoc": "~0.1.0",
|
|
35
|
+
"@kagal/cross-test": "~0.1.3",
|
|
36
|
+
"@poupe/eslint-config": "~0.9.1",
|
|
37
|
+
"@types/node": "^20.19.39",
|
|
38
|
+
"@vitest/coverage-istanbul": "^4.1.5",
|
|
39
|
+
"eslint": "^9.39.4",
|
|
40
|
+
"npm-run-all2": "^8.0.4",
|
|
41
|
+
"publint": "~0.3.18",
|
|
42
|
+
"rimraf": "^6.1.3",
|
|
43
|
+
"typescript": "~5.9.3",
|
|
44
|
+
"unbuild": "3.6.1",
|
|
45
|
+
"vitest": "^4.1.5"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">= 20.20.1",
|
|
49
|
+
"pnpm": ">= 10.33.2"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "unbuild",
|
|
56
|
+
"clean": "rimraf dist node_modules",
|
|
57
|
+
"dev": "vitest",
|
|
58
|
+
"dev:prepare": "unbuild --stub",
|
|
59
|
+
"lint": "eslint --fix .",
|
|
60
|
+
"lint:_pkg-pr-new": "eslint --fix package.json",
|
|
61
|
+
"lint:check": "eslint .",
|
|
62
|
+
"precommit": "run-s dev:prepare lint type-check build test",
|
|
63
|
+
"publint": "publint",
|
|
64
|
+
"publish:maybe": "npm view ${npm_package_name}@${npm_package_version} version 2>/dev/null && echo \"${npm_package_name}@${npm_package_version} already published\" || pnpm publish",
|
|
65
|
+
"test": "vitest run",
|
|
66
|
+
"test:coverage": "vitest run --coverage",
|
|
67
|
+
"test:watch": "vitest",
|
|
68
|
+
"type-check": "run-s type-check:main type-check:tools type-check:tests",
|
|
69
|
+
"type-check:main": "tsc --noEmit -p tsconfig.json",
|
|
70
|
+
"type-check:tests": "tsc --noEmit -p tsconfig.tests.json",
|
|
71
|
+
"type-check:tools": "tsc --noEmit -p tsconfig.tools.json"
|
|
72
|
+
}
|
|
73
|
+
}
|