@substrat-run/contracts 0.159.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.
- package/dist/attachments.d.ts +102 -0
- package/dist/attachments.d.ts.map +1 -1
- package/dist/attachments.js +100 -1
- package/dist/attachments.js.map +1 -1
- package/dist/control-plane.d.ts +348 -8
- package/dist/control-plane.d.ts.map +1 -1
- package/dist/control-plane.js +298 -9
- package/dist/control-plane.js.map +1 -1
- package/dist/file-source.d.ts +167 -0
- package/dist/file-source.d.ts.map +1 -0
- package/dist/file-source.js +144 -0
- package/dist/file-source.js.map +1 -0
- package/dist/ids.d.ts +7 -0
- package/dist/ids.d.ts.map +1 -1
- package/dist/ids.js +7 -0
- package/dist/ids.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/platform-request.d.ts +2 -2
- package/dist/platform-request.d.ts.map +1 -1
- package/dist/platform-request.js +8 -6
- package/dist/platform-request.js.map +1 -1
- package/dist/registry.d.ts +27 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +28 -0
- package/dist/registry.js.map +1 -1
- package/dist/wire-headers.d.ts +15 -0
- package/dist/wire-headers.d.ts.map +1 -1
- package/dist/wire-headers.js +15 -0
- package/dist/wire-headers.js.map +1 -1
- package/dist/wire-signing.d.ts +443 -0
- package/dist/wire-signing.d.ts.map +1 -0
- package/dist/wire-signing.js +734 -0
- package/dist/wire-signing.js.map +1 -0
- package/package.json +5 -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
|