@substrat-run/contracts 0.158.0 → 0.160.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.
Files changed (41) hide show
  1. package/dist/attachments.d.ts +102 -0
  2. package/dist/attachments.d.ts.map +1 -1
  3. package/dist/attachments.js +100 -1
  4. package/dist/attachments.js.map +1 -1
  5. package/dist/auth-diagnostics.d.ts +32 -0
  6. package/dist/auth-diagnostics.d.ts.map +1 -0
  7. package/dist/auth-diagnostics.js +62 -0
  8. package/dist/auth-diagnostics.js.map +1 -0
  9. package/dist/control-plane.d.ts +348 -8
  10. package/dist/control-plane.d.ts.map +1 -1
  11. package/dist/control-plane.js +298 -9
  12. package/dist/control-plane.js.map +1 -1
  13. package/dist/file-source.d.ts +167 -0
  14. package/dist/file-source.d.ts.map +1 -0
  15. package/dist/file-source.js +144 -0
  16. package/dist/file-source.js.map +1 -0
  17. package/dist/ids.d.ts +7 -0
  18. package/dist/ids.d.ts.map +1 -1
  19. package/dist/ids.js +7 -0
  20. package/dist/ids.js.map +1 -1
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/platform-request.d.ts +2 -2
  26. package/dist/platform-request.d.ts.map +1 -1
  27. package/dist/platform-request.js +8 -6
  28. package/dist/platform-request.js.map +1 -1
  29. package/dist/registry.d.ts +27 -0
  30. package/dist/registry.d.ts.map +1 -1
  31. package/dist/registry.js +28 -0
  32. package/dist/registry.js.map +1 -1
  33. package/dist/wire-headers.d.ts +15 -0
  34. package/dist/wire-headers.d.ts.map +1 -1
  35. package/dist/wire-headers.js +15 -0
  36. package/dist/wire-headers.js.map +1 -1
  37. package/dist/wire-signing.d.ts +443 -0
  38. package/dist/wire-signing.d.ts.map +1 -0
  39. package/dist/wire-signing.js +734 -0
  40. package/dist/wire-signing.js.map +1 -0
  41. package/package.json +9 -1
@@ -0,0 +1,734 @@
1
+ /**
2
+ * The signed envelope the router and the control plane put on a call to a vertical (RFC
3
+ * wire-signing, K-58 proposed). Each signs only while its `sign` flag is on and its key is set
4
+ * (RFC §9, step 2), beside the legacy headers, which stay. The platform's entry module verifies
5
+ * one, inert until a deployment is given key lists (step 1), and the shared-secret checks in
6
+ * `wire-auth.ts` and vertical-host's `readRoutedNode` are unchanged.
7
+ *
8
+ * Ed25519 over a canonical encoding, the scheme the scope-store envelope already uses (K-52),
9
+ * with its conventions reused rather than restated: the key id, the raw public key lists, the
10
+ * strict PKCS8 form, the small-order and canonical-spelling refusals and base64url all come
11
+ * from `scope-store-envelope.ts`, so a key one module accepts the other accepts too. A vertical
12
+ * would hold only public keys, a list per issuer, so a key can rotate without a moment where no
13
+ * key verifies.
14
+ *
15
+ * The encoding is fifteen lines joined by `\n`, with no trailing newline, so a verifier in
16
+ * another language can rebuild it (`test/vectors/wire-envelope.json` is the shared vector):
17
+ *
18
+ * ```
19
+ * substrat-wire/1
20
+ * router|platform iss: which signer, so which key list
21
+ * route|call kind: a routed request, or a call to an internal endpoint
22
+ * <kid> as the store's: base64url of SHA-256's first 8 bytes
23
+ * <aud> the target deployment (the dispatch script name), opaque
24
+ * <tenant id> a ULID, or empty on a call that names no scope
25
+ * <scope id> a ULID, empty exactly when the tenant is
26
+ * <surface> a route's surface; empty on a call
27
+ * <vertical> the vertical slug, or empty
28
+ * <METHOD> upper-case
29
+ * <sha256 hex of the target> the request's pathname + search, as UTF-8
30
+ * <sha256 hex of the body> a call's exact body bytes; empty on a route
31
+ * <iat, unix seconds>
32
+ * <exp, unix seconds>
33
+ * <nonce, base64url of 16 bytes>
34
+ * ```
35
+ *
36
+ * Which fields a (iss, kind) pair carries is a closed table, {@link wireEnvelopeShape}. The
37
+ * first line names the protocol, so no message signed under another protocol (a store
38
+ * envelope's first line is `1`) parses as one of these, whatever key signed it.
39
+ *
40
+ * Every refusal of a caller is the same value, {@link WIRE_REFUSED}: a caller cannot tell a wrong
41
+ * audience from a bad signature, and neither can whoever it answers. The reason goes to
42
+ * `onRefusal`, for metrics only. A fault of the deployment's own throws instead: a key set it
43
+ * built some other way, a call expectation with no replay policy (or a cache with no pinned
44
+ * scope), and a nonce cache that cannot answer.
45
+ *
46
+ * Imports nothing but its siblings `scope-store-envelope.ts` and `invocation-record.ts`, which
47
+ * import nothing, so an entry bundle that imports this subpath pulls in no schema and no zod.
48
+ */
49
+ import { FIELD_COVERAGE_HEADER, PARENT_REQUEST_ID_HEADER, parentRequestIdOf } from './invocation-record.js';
50
+ import { base64UrlToBytes, bytesToBase64Url, parseScopeStorePublicKeys, ScopeStoreKeyError, scopeStoreKeyId, scopeStoreSignerPublicKey, verifyScopeStoreEnvelope, } from './scope-store-envelope.js';
51
+ export const WIRE_ENVELOPE_VERSION = 1;
52
+ /** The first line of every wire envelope: the protocol and its version. */
53
+ export const WIRE_ENVELOPE_TAG = `substrat-wire/${WIRE_ENVELOPE_VERSION}`;
54
+ /** The request header carrying base64url of the canonical bytes. */
55
+ export const WIRE_ENVELOPE_HEADER = 'x-substrat-wire-envelope';
56
+ /** The request header carrying the base64url signature. */
57
+ export const WIRE_SIGNATURE_HEADER = 'x-substrat-wire-signature';
58
+ /** What a signer gives an envelope when it is not told: `exp = iat + 60`. */
59
+ export const WIRE_ENVELOPE_LIFETIME_SECONDS = 60;
60
+ /** The longest `exp - iat` a verifier accepts, and a signer mints. */
61
+ export const WIRE_ENVELOPE_MAX_LIFETIME_SECONDS = 120;
62
+ /** How far a verifier's clock may be from the signer's, on either side of `[iat, exp]`. */
63
+ export const WIRE_CLOCK_SKEW_SECONDS = 30;
64
+ /**
65
+ * The (iss, kind) pairs version 1 admits and what each carries. Closed: a pair not here is
66
+ * refused, so the control plane can never assert a routed request, and a route never claims
67
+ * to bind a body it does not.
68
+ */
69
+ const SHAPES = {
70
+ router: {
71
+ // An end user's request the router resolved: always a tenant, a scope and a surface. The
72
+ // body is a stream the router does not buffer, so a route binds none.
73
+ route: { scope: 'required', surface: 'required', vertical: 'optional', body: 'absent' },
74
+ // An internal call the router makes itself, such as a peer invocation.
75
+ call: { scope: 'optional', surface: 'absent', vertical: 'optional', body: 'required' },
76
+ },
77
+ platform: {
78
+ call: { scope: 'optional', surface: 'absent', vertical: 'absent', body: 'required' },
79
+ },
80
+ };
81
+ /** What an (iss, kind) pair carries, or undefined when version 1 does not admit the pair. */
82
+ export function wireEnvelopeShape(iss, kind) {
83
+ const byKind = Object.hasOwn(SHAPES, iss) ? SHAPES[iss] : undefined;
84
+ return byKind && Object.hasOwn(byKind, kind) ? byKind[kind] : undefined;
85
+ }
86
+ const LINES = 15;
87
+ /** The ULID grammar the router asserts and vertical-host's `ROUTED_ID` reads. */
88
+ const ULID = /^[0-9A-HJKMNP-TV-Z]{26}$/;
89
+ /**
90
+ * An HTTP method token (RFC 9110 `tchar`) with its letters upper-cased, as `wireRequestBinding` gives
91
+ * it. At most 32 characters: the longest registered method, UPDATEREDIRECTREF, is 17, and the bound
92
+ * only keeps an unregistered token from making the line unbounded.
93
+ */
94
+ const METHOD = /^[!#$%&'*+.^_`|~0-9A-Z-]{1,32}$/;
95
+ const DIGEST = /^[0-9a-f]{64}$/;
96
+ // At most 15 digits, no sign and no leading zero: the text `String(n)` gives for an integer below 10^15.
97
+ const SECONDS = /^(0|[1-9]\d{0,14})$/;
98
+ // eslint-disable-next-line no-control-regex
99
+ const CONTROL = /[\u0000-\u001f\u007f]/;
100
+ /** Half a surrogate pair: UTF-8 cannot carry it, so the bytes signed would name another string. */
101
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
102
+ /** The longest opaque field (aud, surface, vertical), in UTF-16 code units. */
103
+ const MAX_TEXT = 255;
104
+ /** An opaque field: every other field's grammar is ASCII, so this is the one place well-formedness is judged. */
105
+ const isText = (value) => value.length <= MAX_TEXT && !CONTROL.test(value) && !LONE_SURROGATE.test(value);
106
+ /** An envelope's audience, the deployment's script name: present, and text the encoder carries. */
107
+ const isWireAudience = (value) => value !== '' && isText(value);
108
+ /**
109
+ * Whether `text` is the canonical base64url of exactly `length` bytes, as the store module judges
110
+ * it: so a kid is 11 characters and a nonce 22, with their unused trailing bits zero.
111
+ */
112
+ function isCanonicalBase64Url(text, length) {
113
+ const bytes = base64UrlToBytes(text);
114
+ return bytes?.length === length && bytesToBase64Url(bytes) === text;
115
+ }
116
+ /** Whether one line's text is what `presence` allows: empty, or a non-empty value `valid` accepts. */
117
+ function admits(presence, value, valid) {
118
+ if (value === '')
119
+ return presence !== 'required';
120
+ return presence !== 'absent' && valid(value);
121
+ }
122
+ /** The claims those bytes name, or undefined when they are not exactly what the encoder emits. */
123
+ export function parseWireEnvelope(canonical) {
124
+ const lines = canonical.split('\n');
125
+ if (lines.length !== LINES)
126
+ return undefined;
127
+ const [tag, iss, kind, kid, aud, tenantId, scopeId, surface, vertical, method, targetDigest, bodyDigest, iat, exp, nonce] = lines;
128
+ if (tag !== WIRE_ENVELOPE_TAG)
129
+ return undefined;
130
+ const shape = wireEnvelopeShape(iss, kind);
131
+ if (!shape)
132
+ return undefined;
133
+ if (!isCanonicalBase64Url(kid, 8) || !isCanonicalBase64Url(nonce, 16))
134
+ return undefined;
135
+ if (!isWireAudience(aud))
136
+ return undefined;
137
+ if ((tenantId === '') !== (scopeId === ''))
138
+ return undefined;
139
+ if (!admits(shape.scope, tenantId, (id) => ULID.test(id)) || !admits(shape.scope, scopeId, (id) => ULID.test(id)))
140
+ return undefined;
141
+ if (!admits(shape.surface, surface, isText) || !admits(shape.vertical, vertical, isText))
142
+ return undefined;
143
+ if (!METHOD.test(method) || !DIGEST.test(targetDigest) || !admits(shape.body, bodyDigest, (d) => DIGEST.test(d)))
144
+ return undefined;
145
+ if (!SECONDS.test(iat) || !SECONDS.test(exp))
146
+ return undefined;
147
+ const orNull = (value) => (value === '' ? null : value);
148
+ return {
149
+ v: 1,
150
+ iss: iss,
151
+ kind: kind,
152
+ kid,
153
+ aud,
154
+ tenantId: orNull(tenantId),
155
+ scopeId: orNull(scopeId),
156
+ surface: orNull(surface),
157
+ vertical: orNull(vertical),
158
+ method,
159
+ targetDigest,
160
+ bodyDigest: orNull(bodyDigest),
161
+ iat: Number(iat),
162
+ exp: Number(exp),
163
+ nonce,
164
+ };
165
+ }
166
+ /**
167
+ * The canonical text a header carries and the claims it names, or undefined when the header is not
168
+ * base64url of exactly what the encoder emits. Nothing is verified here: the verifier decodes with
169
+ * this, and an entry may read the claims beforehand only to choose what to verify them against.
170
+ */
171
+ export function decodeWireEnvelope(header) {
172
+ const bytes = base64UrlToBytes(header);
173
+ if (!bytes)
174
+ return undefined;
175
+ let canonical;
176
+ try {
177
+ // Fatal and BOM-keeping, so decoding and re-encoding gives back exactly these bytes.
178
+ canonical = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
179
+ }
180
+ catch {
181
+ return undefined;
182
+ }
183
+ const claims = parseWireEnvelope(canonical);
184
+ return claims ? { canonical, claims } : undefined;
185
+ }
186
+ /**
187
+ * The encoder's refusal of claims no verifier would accept: a field the parser would refuse, a body
188
+ * where the pair binds none or none where it binds one, a lifetime past the maximum. Its own class, so
189
+ * a signer counts it as `claims` and every other throw as `error` (RFC §8).
190
+ */
191
+ export class WireClaimsError extends Error {
192
+ name = 'WireClaimsError';
193
+ }
194
+ /** Fifteen lines, no trailing newline. Throws {@link WireClaimsError} on a field the parser would refuse. */
195
+ export function canonicalWireEnvelope(claims) {
196
+ if (claims.v !== WIRE_ENVELOPE_VERSION)
197
+ throw new WireClaimsError(`unknown wire envelope version ${claims.v}`);
198
+ for (const time of [claims.iat, claims.exp]) {
199
+ if (!Number.isSafeInteger(time) || time < 0)
200
+ throw new WireClaimsError('a wire envelope time is not a non-negative integer');
201
+ }
202
+ const optional = (value) => {
203
+ if (value === '')
204
+ throw new WireClaimsError('a wire envelope field is empty: an absent field is null');
205
+ return value ?? '';
206
+ };
207
+ const lines = [
208
+ WIRE_ENVELOPE_TAG,
209
+ claims.iss,
210
+ claims.kind,
211
+ claims.kid,
212
+ claims.aud,
213
+ optional(claims.tenantId),
214
+ optional(claims.scopeId),
215
+ optional(claims.surface),
216
+ optional(claims.vertical),
217
+ claims.method,
218
+ claims.targetDigest,
219
+ optional(claims.bodyDigest),
220
+ String(claims.iat),
221
+ String(claims.exp),
222
+ claims.nonce,
223
+ ];
224
+ if (lines.some((line) => line.includes('\n')))
225
+ throw new WireClaimsError('a wire envelope field contains a newline');
226
+ const canonical = lines.join('\n');
227
+ if (!parseWireEnvelope(canonical))
228
+ throw new WireClaimsError('a wire envelope field is malformed');
229
+ return canonical;
230
+ }
231
+ function bytesOf(data) {
232
+ if (typeof data === 'string')
233
+ return new TextEncoder().encode(data);
234
+ const view = ArrayBuffer.isView(data) ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) : new Uint8Array(data);
235
+ // Web Crypto copies what it is given, so only a shared buffer needs copying here, to type as one.
236
+ return view.buffer instanceof ArrayBuffer ? view : view.slice();
237
+ }
238
+ /** SHA-256 of the exact bytes, lowercase hex: for a string, the store's body digest. */
239
+ export async function wireDigest(data) {
240
+ const hash = await crypto.subtle.digest('SHA-256', bytesOf(data));
241
+ return [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
242
+ }
243
+ /**
244
+ * What an envelope binds of a request, read the same way by the signer (from the request it is
245
+ * about to send) and the verifier (from the request it received): the method, upper-cased, and
246
+ * the target, the URL's pathname plus its search as the URL API serializes them. The fragment
247
+ * never travels, and an empty `?` reads as no query on both ends.
248
+ */
249
+ export function wireRequestBinding(request) {
250
+ const url = new URL(request.url);
251
+ return { method: request.method.toUpperCase(), target: `${url.pathname}${url.search}` };
252
+ }
253
+ /** A signing key that is present but cannot be used. Its message never carries the key. */
254
+ export class WireKeyError extends Error {
255
+ reason;
256
+ name = 'WireKeyError';
257
+ constructor(message, reason) {
258
+ super(message);
259
+ this.reason = reason;
260
+ }
261
+ }
262
+ /**
263
+ * Import a PKCS8 Ed25519 private key (base64url) for signing. The key is judged by the store
264
+ * module's own check (only the 48-byte PKCS8 v1 form, never a small-order point), so the store
265
+ * key tooling's `generate` and `doctor` accept and refuse the same values. Throws
266
+ * {@link WireKeyError} when it is unusable.
267
+ */
268
+ export async function importWireSigningKey(pkcs8) {
269
+ let signer;
270
+ try {
271
+ signer = await scopeStoreSignerPublicKey(pkcs8);
272
+ }
273
+ catch (error) {
274
+ if (error instanceof ScopeStoreKeyError) {
275
+ throw new WireKeyError(`the wire signing key is unusable (${error.reason})`, error.reason);
276
+ }
277
+ throw error;
278
+ }
279
+ // Accepted above, so it decodes. Not extractable: nothing reads it back out of the isolate.
280
+ const key = await crypto.subtle.importKey('pkcs8', bytesOf(base64UrlToBytes(pkcs8)), { name: 'Ed25519' }, false, ['sign']);
281
+ return Object.freeze({ kid: signer.kid, publicKey: signer.publicKey, key });
282
+ }
283
+ /** Build, digest and sign one envelope. Throws {@link WireClaimsError} on an input no verifier would accept. */
284
+ export async function signWireEnvelope(key, input) {
285
+ // A pair the table does not admit fails in the encoder below, as malformed.
286
+ const body = wireEnvelopeShape(input.iss, input.kind)?.body;
287
+ if (body === 'absent' && input.body !== undefined)
288
+ throw new WireClaimsError(`a ${input.kind} envelope binds no body`);
289
+ // `== null`, as the verifier reads it: a null body is a read that failed, never the empty body.
290
+ if (body === 'required' && input.body == null)
291
+ throw new WireClaimsError(`a ${input.kind} envelope binds its body: pass it, empty or not`);
292
+ const iat = input.iat ?? Math.floor(Date.now() / 1000);
293
+ const exp = input.exp ?? iat + WIRE_ENVELOPE_LIFETIME_SECONDS;
294
+ if (!lifetimeAdmitted(iat, exp))
295
+ throw new WireClaimsError('a wire envelope lives for at most the maximum lifetime, and ends after it starts');
296
+ const claims = {
297
+ v: 1,
298
+ iss: input.iss,
299
+ kind: input.kind,
300
+ kid: key.kid,
301
+ aud: input.aud,
302
+ tenantId: input.tenantId ?? null,
303
+ scopeId: input.scopeId ?? null,
304
+ surface: input.surface ?? null,
305
+ vertical: input.vertical ?? null,
306
+ method: input.method,
307
+ targetDigest: await wireDigest(input.target),
308
+ bodyDigest: input.body === undefined ? null : await wireDigest(input.body),
309
+ iat,
310
+ exp,
311
+ nonce: input.nonce ?? bytesToBase64Url(crypto.getRandomValues(new Uint8Array(16))),
312
+ };
313
+ const canonical = canonicalWireEnvelope(claims);
314
+ const bytes = new TextEncoder().encode(canonical);
315
+ const signature = await crypto.subtle.sign('Ed25519', key.key, bytes);
316
+ return { claims, canonical, header: bytesToBase64Url(bytes), signature: bytesToBase64Url(new Uint8Array(signature)) };
317
+ }
318
+ function lifetimeAdmitted(iat, exp) {
319
+ return exp > iat && exp - iat <= WIRE_ENVELOPE_MAX_LIFETIME_SECONDS;
320
+ }
321
+ /**
322
+ * The verifier's own copy of each key set it was handed, by the object returned. The returned maps
323
+ * are for reading; a caller that mutates one (`keys.platform.set(…)`) changes nothing the verifier
324
+ * uses, so the both-issuers refusal cannot be undone after the fact.
325
+ */
326
+ const PREPARED = new WeakMap();
327
+ /**
328
+ * Each issuer's public keys, from the configured lists: raw Ed25519 keys, base64url, separated
329
+ * by commas, as the store's lists are and refused for the same faults. An unset or blank list is
330
+ * no keys, which verifies nothing. Throws on a key listed for both issuers: whoever holds the
331
+ * router's private key could then sign as the platform.
332
+ */
333
+ export async function wireVerificationKeys(config) {
334
+ const listed = (text) => (text?.trim() ? parseScopeStorePublicKeys(text) : []);
335
+ const router = listed(config.router);
336
+ const platform = listed(config.platform);
337
+ if (router.some((key) => platform.includes(key)))
338
+ throw new Error('a wire public key is listed for both issuers');
339
+ // The list parser refused a key listed twice, and canonical spelling makes equal text equal bytes.
340
+ const byKid = async (keys) => new Map(await Promise.all(keys.map(async (key) => [await scopeStoreKeyId(key), key])));
341
+ const own = Object.freeze({ router: await byKid(router), platform: await byKid(platform) });
342
+ const keys = Object.freeze({ router: new Map(own.router), platform: new Map(own.platform) });
343
+ PREPARED.set(keys, own);
344
+ return keys;
345
+ }
346
+ /** Why an envelope was refused, in the order the verifier checks. For metrics only. */
347
+ export const WIRE_REFUSAL_REASONS = [
348
+ 'missing',
349
+ 'malformed',
350
+ 'issuer',
351
+ 'kind',
352
+ 'kid',
353
+ 'signature',
354
+ 'audience',
355
+ 'lifetime',
356
+ 'not-yet-valid',
357
+ 'expired',
358
+ 'method',
359
+ 'target',
360
+ 'body',
361
+ 'scope',
362
+ 'claim-after-until',
363
+ 'replay',
364
+ ];
365
+ export const WIRE_REFUSED = Object.freeze({ ok: false });
366
+ /**
367
+ * Every check a deployment makes on an envelope, in one fixed order: present, well-formed, the
368
+ * expected issuer and kind, a listed key id, the signature, then (all over signed bytes) the
369
+ * audience, the lifetime, the clock, the method, the target, a call's body, any pinned tenant
370
+ * and scope, and last a call's nonce when its policy keeps them, so an envelope that fails anything
371
+ * else never spends a cache entry. A route keeps no nonces. `nowSeconds` is the time the request
372
+ * arrived, not when its body finished reading.
373
+ *
374
+ * Returns {@link WIRE_REFUSED} for every failure of the caller's. `onRefusal` hears the reason; a
375
+ * throw or a rejection from it is ignored. The deployment's own faults throw instead, so no counter
376
+ * files them under a caller, and they are checked before anything else: a key set
377
+ * {@link wireVerificationKeys} did not build, a call expectation whose replay policy is neither
378
+ * `'none'` nor a cache, a cache with no pinned `scopeId` (a cache lives in one scope's object, so
379
+ * the endpoint pins the scope it claims through), and a cache with no `clockSeconds` function. Two
380
+ * more are found only when a nonce is about to be claimed, and throw then: a claim clock that reads
381
+ * something other than a finite, non-negative number of seconds (a promise from an async clock
382
+ * included), before the cache is asked; and a throw from the cache.
383
+ */
384
+ export async function verifyWireEnvelope(input) {
385
+ const refuse = (reason) => {
386
+ try {
387
+ // Metrics never decide a verdict, and an async counter that rejects must not crash the isolate.
388
+ void Promise.resolve(input.onRefusal?.(reason)).catch(() => { });
389
+ }
390
+ catch {
391
+ // A synchronous throw, likewise ignored.
392
+ }
393
+ return WIRE_REFUSED;
394
+ };
395
+ const { expected, keys, nowSeconds } = input;
396
+ const own = PREPARED.get(keys);
397
+ if (!own)
398
+ throw new TypeError('verifyWireEnvelope takes the key set wireVerificationKeys built');
399
+ const replay = expected.kind === 'call' ? expected.replay : 'none';
400
+ if (replay !== 'none' && typeof replay?.claim !== 'function') {
401
+ throw new TypeError("a call's expectation states its replay policy: 'none' or a nonce cache");
402
+ }
403
+ // `== null`: an untyped `scopeId: null` pins nothing, so it is the same fault as leaving it out.
404
+ if (replay !== 'none' && expected.scopeId == null) {
405
+ throw new TypeError('a call that keeps nonces pins the scope whose object holds them');
406
+ }
407
+ // The claim clock is the deployment's, like `nowSeconds`; a default would mix an injected clock
408
+ // with the wall clock, so a call that keeps nonces must hand it over, and it is checked here.
409
+ if (replay !== 'none' && typeof input.clockSeconds !== 'function') {
410
+ throw new TypeError('a call that keeps nonces takes a clockSeconds function to time its claim');
411
+ }
412
+ const header = input.headers.get(WIRE_ENVELOPE_HEADER);
413
+ const signature = input.headers.get(WIRE_SIGNATURE_HEADER);
414
+ if (!header || !signature)
415
+ return refuse('missing');
416
+ const decoded = decodeWireEnvelope(header);
417
+ if (!decoded)
418
+ return refuse('malformed');
419
+ const { canonical, claims } = decoded;
420
+ if (claims.iss !== expected.iss)
421
+ return refuse('issuer');
422
+ if (claims.kind !== expected.kind)
423
+ return refuse('kind');
424
+ const publicKey = own[claims.iss].get(claims.kid);
425
+ if (!publicKey)
426
+ return refuse('kid');
427
+ if (!(await verifyScopeStoreEnvelope(publicKey, canonical, signature)))
428
+ return refuse('signature');
429
+ if (claims.aud !== expected.aud)
430
+ return refuse('audience');
431
+ if (!lifetimeAdmitted(claims.iat, claims.exp))
432
+ return refuse('lifetime');
433
+ // Negated, so a clock that is not a number fails both instead of passing both.
434
+ if (!(nowSeconds >= claims.iat - WIRE_CLOCK_SKEW_SECONDS))
435
+ return refuse('not-yet-valid');
436
+ if (!(nowSeconds <= claims.exp + WIRE_CLOCK_SKEW_SECONDS))
437
+ return refuse('expired');
438
+ if (claims.method !== expected.method)
439
+ return refuse('method');
440
+ if (claims.targetDigest !== (await wireDigest(expected.target)))
441
+ return refuse('target');
442
+ if (expected.kind === 'call') {
443
+ // A reader is read here and nowhere earlier. The type requires a call's body; an untyped caller
444
+ // that leaves it out, or hands over the null of a body read that failed, is refused, never read
445
+ // as an empty body.
446
+ const body = typeof expected.body === 'function' ? await expected.body() : expected.body;
447
+ if (body == null || claims.bodyDigest !== (await wireDigest(body)))
448
+ return refuse('body');
449
+ }
450
+ if (expected.tenantId !== undefined && claims.tenantId !== expected.tenantId)
451
+ return refuse('scope');
452
+ if (expected.scopeId !== undefined && claims.scopeId !== expected.scopeId)
453
+ return refuse('scope');
454
+ if (replay !== 'none') {
455
+ const until = claims.exp + WIRE_CLOCK_SKEW_SECONDS;
456
+ const claimedAt = input.clockSeconds();
457
+ // The claim clock is the deployment's: a reading that is not a time is its fault, never a
458
+ // caller's reason, and a negative one never reaches the cache. `Number.isFinite` does not coerce,
459
+ // so it refuses a string, null or a promise as well as NaN and the infinities.
460
+ if (!Number.isFinite(claimedAt) || claimedAt < 0) {
461
+ throw new TypeError('clockSeconds read something other than a time in seconds');
462
+ }
463
+ // A cache may forget the nonce after `until`, so a claim made later could not see a replay. Its
464
+ // own reason, so a slow body is not counted as one.
465
+ if (!(claimedAt <= until))
466
+ return refuse('claim-after-until');
467
+ if ((await replay.claim(claims.nonce, until)) !== true)
468
+ return refuse('replay');
469
+ }
470
+ return { ok: true, claims };
471
+ }
472
+ /*
473
+ * What a deployment is given at upload (RFC §5, §6) and what its entry writes per request (§8).
474
+ * Named here, beside the envelope, so the entry that reads them and the uploader and readiness
475
+ * report that will write and read them spell them one way.
476
+ */
477
+ /** The router's public keys, a comma-separated list. Plain configuration: public keys are not secret. */
478
+ export const WIRE_ROUTER_KEYS_VAR = 'SUBSTRAT_WIRE_ROUTER_KEYS';
479
+ /** The control plane's public keys, a comma-separated list. */
480
+ export const WIRE_PLATFORM_KEYS_VAR = 'SUBSTRAT_WIRE_PLATFORM_KEYS';
481
+ /** The deployment's own dispatch name, which an envelope's `aud` must equal. */
482
+ export const WIRE_AUDIENCE_VAR = 'SUBSTRAT_WIRE_AUDIENCE';
483
+ /** The environment's down-channel state, one of {@link WIRE_STATES}. */
484
+ export const WIRE_STATE_VAR = 'SUBSTRAT_WIRE_STATE';
485
+ /** What a deployment accepts on a call down (RFC §5). */
486
+ export const WIRE_STATES = ['legacy-only', 'dual', 'signed-only'];
487
+ /**
488
+ * A deployment's state from what its upload carries (RFC §5, Q3). No key list at all is
489
+ * `legacy-only` whatever the setting says, so a deployment no upload gave keys behaves as it
490
+ * did before the verifier existed. With keys, an explicit `legacy-only` or `signed-only` is
491
+ * taken as said, and anything else (missing, unknown) reads as `dual`: a wrong value fails
492
+ * toward accepting what is accepted today.
493
+ */
494
+ export function wireStateOf(config) {
495
+ const listed = (value) => typeof value === 'string' && value.trim() !== '';
496
+ if (!listed(config.routerKeys) && !listed(config.platformKeys))
497
+ return 'legacy-only';
498
+ return config.state === 'legacy-only' || config.state === 'signed-only' ? config.state : 'dual';
499
+ }
500
+ /**
501
+ * The only state an upload gives a deployment in step 2 (RFC §9): `dual`. Moving a deployment to
502
+ * `signed-only` is a later, separate step (the flip, follow-on 7), a person's act behind its own
503
+ * gate, so no code that injects settings here can name it.
504
+ */
505
+ export const WIRE_UPLOAD_STATE = 'dual';
506
+ /**
507
+ * What one upload gives the deployment it uploads as `audience` (RFC §5, §6, step 2), as the settings
508
+ * the entry reads: both key lists, its own name for the audience check, and the state
509
+ * {@link WIRE_UPLOAD_STATE}. The uploader writes them as plain configuration. The lists are judged first
510
+ * by {@link wireVerificationKeys}, the very function the entry loads them with, so an upload never gives
511
+ * a deployment lists its verifier would refuse: it throws instead, and the caller gives nothing. Each
512
+ * list must hold at least one key, and the audience must be one the encoder admits. Answers the kids
513
+ * each list names, for the capability record (RFC §7, Q8).
514
+ */
515
+ export async function wireUploadSettings(input) {
516
+ if (!isWireAudience(input.audience))
517
+ throw new Error('a wire audience is a script name of 1 to 255 characters');
518
+ const keys = await wireVerificationKeys({ router: input.routerKeys, platform: input.platformKeys });
519
+ const routerKids = [...keys.router.keys()];
520
+ const platformKids = [...keys.platform.keys()];
521
+ if (routerKids.length === 0 || platformKids.length === 0)
522
+ throw new Error('each wire issuer lists at least one key');
523
+ return {
524
+ settings: [
525
+ { name: WIRE_ROUTER_KEYS_VAR, text: input.routerKeys.trim() },
526
+ { name: WIRE_PLATFORM_KEYS_VAR, text: input.platformKeys.trim() },
527
+ { name: WIRE_AUDIENCE_VAR, text: input.audience },
528
+ { name: WIRE_STATE_VAR, text: WIRE_UPLOAD_STATE },
529
+ ],
530
+ routerKids,
531
+ platformKids,
532
+ };
533
+ }
534
+ const ROUTE_ENDPOINT = Object.freeze({ kind: 'route', issuers: Object.freeze(['router']) });
535
+ const CALL_ENDPOINT = Object.freeze({ kind: 'call', issuers: Object.freeze(['platform']) });
536
+ const PEER_INVOKE_ENDPOINT = Object.freeze({ kind: 'call', issuers: Object.freeze(['platform', 'router']) });
537
+ /** Decode `path` with `decoder`, skipping any run of escapes it cannot decode: Hono's `tryDecode`. */
538
+ function tryDecode(path, decoder) {
539
+ try {
540
+ return decoder(path);
541
+ }
542
+ catch {
543
+ return path.replace(/(?:%[0-9A-Fa-f]{2})+/g, (run) => {
544
+ try {
545
+ return decoder(run);
546
+ }
547
+ catch {
548
+ return run;
549
+ }
550
+ });
551
+ }
552
+ }
553
+ /** Empty and `.` segments dropped and `..` resolved, as a lenient router could read the path. */
554
+ function collapsed(path) {
555
+ const segments = [];
556
+ for (const segment of path.split('/')) {
557
+ if (segment === '..')
558
+ segments.pop();
559
+ else if (segment !== '' && segment !== '.')
560
+ segments.push(segment);
561
+ }
562
+ return `/${segments.join('/')}`;
563
+ }
564
+ const INTERNAL = /^\/internal(?:\/|$)/i;
565
+ /**
566
+ * Which envelope a deployment's endpoint accepts (RFC §3.4, check 3), by the request's path. The
567
+ * internal surface vertical-host's `mountPlatformSurface` gates (`/internal/*`) takes calls from
568
+ * the control plane, and its peer invocation (`/internal/vertical-invoke`) from the router too,
569
+ * which makes those calls. Every other path is a routed request. A verifier judges by this, and a
570
+ * signer signs what it says.
571
+ *
572
+ * The surface is matched on the path its router decodes (Hono's `getPath`: `decodeURI`, with `%25`
573
+ * kept escaped), so the path is judged in every form a router could read it: as sent, decoded that
574
+ * way, decoded fully, each with empty and dot segments collapsed, in any letter case. Any form under
575
+ * `/internal` makes it a call: the stricter reading wins. Only the exact path the router sends a peer
576
+ * invocation to admits the router.
577
+ */
578
+ export function wireEndpoint(pathname) {
579
+ const routed = tryDecode(pathname.includes('%25') ? pathname.replace(/%25/g, '%2525') : pathname, decodeURI);
580
+ const forms = [pathname, routed, tryDecode(pathname, decodeURIComponent)];
581
+ if (!forms.some((form) => INTERNAL.test(form) || INTERNAL.test(collapsed(form))))
582
+ return ROUTE_ENDPOINT;
583
+ return pathname === '/internal/vertical-invoke' ? PEER_INVOKE_ENDPOINT : CALL_ENDPOINT;
584
+ }
585
+ /**
586
+ * The internal calls that take a body but change nothing, so a call to one keeps no nonce memory
587
+ * (RFC §4): each only reads the scope it names. Named by the exact path the platform calls, so any
588
+ * other spelling of one keeps nonces.
589
+ */
590
+ export const WIRE_REPLAY_FREE_CALLS = Object.freeze([
591
+ '/internal/query',
592
+ '/internal/redrain-count',
593
+ '/internal/lifecycle-flow',
594
+ '/internal/operation-series',
595
+ '/internal/connector-grants',
596
+ ]);
597
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
598
+ /**
599
+ * A call's replay policy as its endpoint states it (RFC §4), never inferred from what an envelope
600
+ * names: `'none'` for a safe method or a call {@link WIRE_REPLAY_FREE_CALLS} lists, and `'nonce'`
601
+ * for every other call, a new endpoint included, whether or not its envelope names a scope. A call
602
+ * that keeps nonces and names no scope has no store to spend one in, which is its deployment's fault.
603
+ */
604
+ export function wireReplayOf(method, pathname) {
605
+ return SAFE_METHODS.has(method.toUpperCase()) || WIRE_REPLAY_FREE_CALLS.includes(pathname) ? 'none' : 'nonce';
606
+ }
607
+ /**
608
+ * How one request fared (RFC §8). `legacy-accepted` is an envelope refused beside a legacy
609
+ * credential that was valid, and carries the refusal's reason; `refused` is an envelope refused
610
+ * with no valid legacy credential beside it.
611
+ */
612
+ export const WIRE_OUTCOMES = ['signed-accepted', 'legacy-accepted', 'dual-mismatch', 'refused', 'fault'];
613
+ /**
614
+ * Which of the deployment's own faults a `fault` was: its key lists could not be loaded, it has
615
+ * no nonce store for a call that needs one, its nonce store threw or did not answer in time, or
616
+ * the verifier threw for any other reason.
617
+ */
618
+ export const WIRE_FAULTS = ['keys', 'no-nonce-store', 'nonce-store', 'verifier'];
619
+ /*
620
+ * The signers (RFC §5, §9 step 2): the router signs a route and its own calls, the control plane
621
+ * signs its calls. Both decide through {@link wireSignerOf}, so they read their flag and their key,
622
+ * put an envelope on a request and fall back when they cannot in exactly one way. The legacy
623
+ * headers stay.
624
+ */
625
+ /**
626
+ * Whether a signer's `sign` flag (RFC §5) is on: exactly `on`, around whitespace. Absent, empty or
627
+ * anything else is off, so a signer nobody configured adds nothing to any request.
628
+ */
629
+ export function wireSigningOn(value) {
630
+ return typeof value === 'string' && value.trim() === 'on';
631
+ }
632
+ /** One isolate's signing key, imported once per configured value. */
633
+ let importedKey;
634
+ /**
635
+ * {@link importWireSigningKey}, once per isolate for a given value: the signer's key is imported on
636
+ * the first request that needs it and reused after. A value that will not import keeps rejecting,
637
+ * with the same {@link WireKeyError}, until the value changes.
638
+ */
639
+ export function wireSigningKeyOf(pkcs8) {
640
+ if (importedKey?.pkcs8 !== pkcs8)
641
+ importedKey = { pkcs8, key: importWireSigningKey(pkcs8) };
642
+ return importedKey.key;
643
+ }
644
+ /**
645
+ * The request with one fresh envelope on it ({@link WIRE_ENVELOPE_HEADER} and
646
+ * {@link WIRE_SIGNATURE_HEADER}), every other header kept as it was. The method and target are read
647
+ * with {@link wireRequestBinding}, as the verifier reads them. A call binds the request's exact body,
648
+ * read whole here and handed on as the same bytes (an absent body is the empty one, as the verifier
649
+ * reads it); a route binds none, and its body is not read. Sign each attempt: a resend carries a
650
+ * spent nonce (RFC §4).
651
+ */
652
+ export async function signWireRequest(key, request, claims) {
653
+ return signedWith(key, request, claims, await bodyToBind(request, claims.kind));
654
+ }
655
+ /** A call's body, read once to be bound; a route binds none and its body is not read. */
656
+ const bodyToBind = async (request, kind) => (kind === 'call' && request.body !== null ? request.arrayBuffer() : undefined);
657
+ /** {@link signWireRequest} over a body already read (`undefined` for a route, or an absent body). */
658
+ async function signedWith(key, request, claims, body) {
659
+ const { method, target } = wireRequestBinding(request);
660
+ const issued = await signWireEnvelope(key, { ...claims, method, target, ...(claims.kind === 'call' ? { body: body ?? '' } : {}) });
661
+ const headers = new Headers(request.headers);
662
+ headers.set(WIRE_ENVELOPE_HEADER, issued.header);
663
+ headers.set(WIRE_SIGNATURE_HEADER, issued.signature);
664
+ return new Request(request, body === undefined ? { headers } : { headers, body });
665
+ }
666
+ /**
667
+ * A signer's whole decision (RFC §5, §8): `null` while its `sign` flag is off, which is the default,
668
+ * so nothing is read and nothing is signed. On, each request is signed afresh with the key, imported
669
+ * once per isolate. A signer that cannot sign never fails the request: it is handed back with its
670
+ * legacy headers and its whole body (a call's body is read once, and the request rebuilt from those
671
+ * bytes when signing fails after reading it), and the result says why, never with the key.
672
+ */
673
+ export function wireSignerOf(settings) {
674
+ if (!wireSigningOn(settings.sign))
675
+ return null;
676
+ const pkcs8 = settings.key;
677
+ if (!pkcs8)
678
+ return async (request) => ({ request, outcome: 'failed', reason: 'unset' });
679
+ return async (request, claims, named) => {
680
+ let key;
681
+ try {
682
+ key = await wireSigningKeyOf(pkcs8);
683
+ }
684
+ catch (error) {
685
+ return { request, outcome: 'failed', reason: error instanceof WireKeyError ? error.reason : 'error' };
686
+ }
687
+ let body;
688
+ try {
689
+ body = await bodyToBind(request, claims.kind);
690
+ return { request: await signedWith(key, request, { ...claims, ...named?.(request, body) }, body), outcome: 'signed' };
691
+ }
692
+ catch (error) {
693
+ // `claims` only for the encoder's own refusal; a body that cannot be read, a throw from
694
+ // `named`, the rebuild or the signing call is `error`. A body read before the failure goes
695
+ // back on the request, so it is sent whole.
696
+ const reason = error instanceof WireClaimsError ? 'claims' : 'error';
697
+ return { request: body === undefined ? request : new Request(request, { body }), outcome: 'failed', reason };
698
+ }
699
+ };
700
+ }
701
+ /**
702
+ * The headers the router adds that the envelope does not sign, listed by name as carrying no
703
+ * authority (RFC §5, decided in step 2), with the kind of request each rides on and the check its
704
+ * own reader applies: the field-coverage sample on a route (the dispatch id that arms the
705
+ * per-response field walk, one ULID as vertical-host reads it) and a peer call's parent request id
706
+ * on a call (which links the target's line to the request that made it, read by
707
+ * {@link parentRequestIdOf}). Each is a claim its reader already treats as one: the walk's report
708
+ * counts only when the router's own line names its dispatch id, and the parent id only relates two
709
+ * lines. So when the envelope alone admits a request, the entry hands each on, on its own kind of
710
+ * request, when its value passes that check, and drops it otherwise, as it drops every other
711
+ * `x-substrat-*` header the envelope did not sign. Signing them instead would take a new envelope
712
+ * version (`substrat-wire/2`).
713
+ */
714
+ const UNSIGNED = Object.freeze([
715
+ [FIELD_COVERAGE_HEADER, 'route', (value) => ULID.test(value)],
716
+ [PARENT_REQUEST_ID_HEADER, 'call', (value) => parentRequestIdOf(value) !== undefined],
717
+ ]);
718
+ /** The listed headers by name, each with the kind of request it rides on. */
719
+ export const WIRE_UNSIGNED_HEADERS = Object.freeze(Object.fromEntries(UNSIGNED.map(([name, kind]) => [name, kind])));
720
+ /**
721
+ * The listed headers ({@link WIRE_UNSIGNED_HEADERS}) a request of `kind` hands on, as name and value:
722
+ * only those listed for that kind, and only a value its reader's check accepts. A header sent twice
723
+ * reads as both values joined, which is not one id, so it is dropped whole.
724
+ */
725
+ export function wireUnsignedHeadersOf(kind, headers) {
726
+ const kept = [];
727
+ for (const [name, on, valid] of UNSIGNED) {
728
+ const value = on === kind ? headers.get(name) : null;
729
+ if (value !== null && valid(value))
730
+ kept.push([name, value]);
731
+ }
732
+ return kept;
733
+ }
734
+ //# sourceMappingURL=wire-signing.js.map