@traffic-twin/sdk 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +171 -2
- package/dist/cjs/agent.d.ts +143 -0
- package/dist/cjs/agent.js +927 -0
- package/dist/cjs/capture/body.d.ts +11 -0
- package/dist/cjs/capture/body.js +73 -0
- package/dist/cjs/capture/common.d.ts +75 -0
- package/dist/cjs/capture/common.js +180 -0
- package/dist/cjs/capture/context.d.ts +28 -0
- package/dist/cjs/capture/context.js +92 -0
- package/dist/cjs/capture/inbound.d.ts +12 -0
- package/dist/cjs/capture/inbound.js +195 -0
- package/dist/cjs/capture/outbound-http.d.ts +4 -0
- package/dist/cjs/capture/outbound-http.js +200 -0
- package/dist/cjs/capture/outbound-undici.d.ts +2 -0
- package/dist/cjs/capture/outbound-undici.js +242 -0
- package/dist/cjs/capture/wire.d.ts +27 -0
- package/dist/cjs/capture/wire.js +68 -0
- package/dist/cjs/export/event.d.ts +46 -0
- package/dist/cjs/export/event.js +91 -0
- package/dist/cjs/export/exporter.d.ts +50 -0
- package/dist/cjs/export/exporter.js +192 -0
- package/dist/cjs/export/pipeline.d.ts +20 -0
- package/dist/cjs/export/pipeline.js +40 -0
- package/dist/cjs/fleet/asn1.d.ts +44 -0
- package/dist/cjs/fleet/asn1.js +150 -0
- package/dist/cjs/fleet/client.d.ts +84 -0
- package/dist/cjs/fleet/client.js +216 -0
- package/dist/cjs/fleet/http.d.ts +17 -0
- package/dist/cjs/fleet/http.js +80 -0
- package/dist/cjs/fleet/store.d.ts +18 -0
- package/dist/cjs/fleet/store.js +115 -0
- package/dist/cjs/index.d.ts +14 -0
- package/dist/cjs/index.js +69 -0
- package/dist/cjs/log.d.ts +4 -0
- package/dist/cjs/log.js +30 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/register.d.ts +1 -0
- package/dist/cjs/register.js +18 -0
- package/dist/cjs/sanitizer/bodies.d.ts +55 -0
- package/dist/cjs/sanitizer/bodies.js +710 -0
- package/dist/cjs/sanitizer/formats.d.ts +26 -0
- package/dist/cjs/sanitizer/formats.js +110 -0
- package/dist/cjs/sanitizer/graphql.d.ts +23 -0
- package/dist/cjs/sanitizer/graphql.js +478 -0
- package/dist/cjs/sanitizer/hostpat.d.ts +15 -0
- package/dist/cjs/sanitizer/hostpat.js +129 -0
- package/dist/cjs/sanitizer/json.d.ts +60 -0
- package/dist/cjs/sanitizer/json.js +460 -0
- package/dist/cjs/sanitizer/policy.d.ts +224 -0
- package/dist/cjs/sanitizer/policy.js +820 -0
- package/dist/cjs/sanitizer/sanitizer.d.ts +149 -0
- package/dist/cjs/sanitizer/sanitizer.js +1473 -0
- package/dist/cjs/sanitizer/utf8.d.ts +22 -0
- package/dist/cjs/sanitizer/utf8.js +180 -0
- package/dist/cjs/sanitizer/vectors.d.ts +22 -0
- package/dist/cjs/sanitizer/vectors.js +130 -0
- package/dist/cjs/version.d.ts +2 -0
- package/dist/cjs/version.js +5 -0
- package/dist/cjs/worker/dir.d.ts +1 -0
- package/dist/cjs/worker/dir.js +8 -0
- package/dist/cjs/worker/pool.d.ts +47 -0
- package/dist/cjs/worker/pool.js +179 -0
- package/dist/cjs/worker/protocol.d.ts +37 -0
- package/dist/cjs/worker/protocol.js +3 -0
- package/dist/cjs/worker/sanitizer-worker.d.ts +1 -0
- package/dist/cjs/worker/sanitizer-worker.js +53 -0
- package/dist/esm/agent.d.ts +143 -0
- package/dist/esm/agent.js +921 -0
- package/dist/esm/capture/body.d.ts +11 -0
- package/dist/esm/capture/body.js +69 -0
- package/dist/esm/capture/common.d.ts +75 -0
- package/dist/esm/capture/common.js +166 -0
- package/dist/esm/capture/context.d.ts +28 -0
- package/dist/esm/capture/context.js +84 -0
- package/dist/esm/capture/inbound.d.ts +12 -0
- package/dist/esm/capture/inbound.js +187 -0
- package/dist/esm/capture/outbound-http.d.ts +4 -0
- package/dist/esm/capture/outbound-http.js +193 -0
- package/dist/esm/capture/outbound-undici.d.ts +2 -0
- package/dist/esm/capture/outbound-undici.js +236 -0
- package/dist/esm/capture/wire.d.ts +27 -0
- package/dist/esm/capture/wire.js +65 -0
- package/dist/esm/export/event.d.ts +46 -0
- package/dist/esm/export/event.js +85 -0
- package/dist/esm/export/exporter.d.ts +50 -0
- package/dist/esm/export/exporter.js +188 -0
- package/dist/esm/export/pipeline.d.ts +20 -0
- package/dist/esm/export/pipeline.js +37 -0
- package/dist/esm/fleet/asn1.d.ts +44 -0
- package/dist/esm/fleet/asn1.js +128 -0
- package/dist/esm/fleet/client.d.ts +84 -0
- package/dist/esm/fleet/client.js +204 -0
- package/dist/esm/fleet/http.d.ts +17 -0
- package/dist/esm/fleet/http.js +73 -0
- package/dist/esm/fleet/store.d.ts +18 -0
- package/dist/esm/fleet/store.js +110 -0
- package/dist/esm/index.d.ts +14 -0
- package/dist/esm/index.js +48 -0
- package/dist/esm/log.d.ts +4 -0
- package/dist/esm/log.js +25 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/register.d.ts +1 -0
- package/dist/esm/register.js +16 -0
- package/dist/esm/sanitizer/bodies.d.ts +55 -0
- package/dist/esm/sanitizer/bodies.js +694 -0
- package/dist/esm/sanitizer/formats.d.ts +26 -0
- package/dist/esm/sanitizer/formats.js +101 -0
- package/dist/esm/sanitizer/graphql.d.ts +23 -0
- package/dist/esm/sanitizer/graphql.js +472 -0
- package/dist/esm/sanitizer/hostpat.d.ts +15 -0
- package/dist/esm/sanitizer/hostpat.js +122 -0
- package/dist/esm/sanitizer/json.d.ts +60 -0
- package/dist/esm/sanitizer/json.js +447 -0
- package/dist/esm/sanitizer/policy.d.ts +224 -0
- package/dist/esm/sanitizer/policy.js +800 -0
- package/dist/esm/sanitizer/sanitizer.d.ts +149 -0
- package/dist/esm/sanitizer/sanitizer.js +1459 -0
- package/dist/esm/sanitizer/utf8.d.ts +22 -0
- package/dist/esm/sanitizer/utf8.js +166 -0
- package/dist/esm/sanitizer/vectors.d.ts +22 -0
- package/dist/esm/sanitizer/vectors.js +125 -0
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +2 -0
- package/dist/esm/worker/dir.d.ts +1 -0
- package/dist/esm/worker/dir.js +5 -0
- package/dist/esm/worker/pool.d.ts +47 -0
- package/dist/esm/worker/pool.js +175 -0
- package/dist/esm/worker/protocol.d.ts +37 -0
- package/dist/esm/worker/protocol.js +2 -0
- package/dist/esm/worker/sanitizer-worker.d.ts +1 -0
- package/dist/esm/worker/sanitizer-worker.js +51 -0
- package/package.json +75 -4
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
// Compiled policy model (policy.md 2), body validation (policy.md 4, schema policy-compiled.v1),
|
|
2
|
+
// signed envelope verification (policy.md 3) and HMAC key parsing (sanitizer.md 7.1).
|
|
3
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
4
|
+
import { JsonError, Lex, canonicalNumber, lexemeWithinLimits, nodeToPlain, parseJson } from './json.js';
|
|
5
|
+
import { cmpUtf8 } from './utf8.js';
|
|
6
|
+
export class Refused extends Error {
|
|
7
|
+
code;
|
|
8
|
+
detail;
|
|
9
|
+
constructor(code, detail = '') {
|
|
10
|
+
super(code + (detail ? ': ' + detail : ''));
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.detail = detail;
|
|
13
|
+
this.name = 'Refused';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export const RESERVED_DOMAINS = new Set(['subject', 'sample', 'sample-trace', 'discovery']);
|
|
17
|
+
export const BODY_FORMAT = 'tt.policy.body/v1';
|
|
18
|
+
export const ENVELOPE_FORMAT = 'tt.policy.compiled/v1';
|
|
19
|
+
export const SIGNATURE_PREFIX = Buffer.from('tt.policy.body/v1\0', 'latin1');
|
|
20
|
+
const DAY_MS = 86_400_000;
|
|
21
|
+
export const BODIES_OFF = Object.freeze({ xml: false, multipart: false, docs: false, maxLargeBodyBytes: 0, largeArray: 0, largeNestedArray: 0 });
|
|
22
|
+
export const DEFAULT_LIMITS = { maxBodyBytes: 1048576, maxDepth: 32, maxArray: 32, maxKeys: 256, keepMaxBytes: 64 };
|
|
23
|
+
/** Ready-to-use compiled policy. */
|
|
24
|
+
export class Policy {
|
|
25
|
+
body;
|
|
26
|
+
policyId;
|
|
27
|
+
version;
|
|
28
|
+
issuedSeq;
|
|
29
|
+
createdAt;
|
|
30
|
+
notAfter;
|
|
31
|
+
groups;
|
|
32
|
+
kid;
|
|
33
|
+
acceptKids;
|
|
34
|
+
epoch;
|
|
35
|
+
subject;
|
|
36
|
+
rateBp;
|
|
37
|
+
trustBaggage;
|
|
38
|
+
propagateTo;
|
|
39
|
+
autoWords;
|
|
40
|
+
autoWordsExpiresAt;
|
|
41
|
+
allow;
|
|
42
|
+
hostsAllow;
|
|
43
|
+
/** Spec v1.5 `hosts.discovery`: true = `auto` (default, also when `hosts` is absent), false = `off` (strict mode). */
|
|
44
|
+
hostsAuto;
|
|
45
|
+
limits;
|
|
46
|
+
bodies;
|
|
47
|
+
/**
|
|
48
|
+
* Bytes of a body a source keeps (sanitizer.md 9.0 and 22.5): max(max_body_bytes,
|
|
49
|
+
* bodies.max_large_body_bytes) + 1. The rest is only counted.
|
|
50
|
+
*/
|
|
51
|
+
captureLimit;
|
|
52
|
+
discoveryEnabled;
|
|
53
|
+
discoveryExpiresAt;
|
|
54
|
+
global;
|
|
55
|
+
services;
|
|
56
|
+
inbound;
|
|
57
|
+
baseHosts;
|
|
58
|
+
/** SHA-256 of the body bytes, when known (`sha256:<hex>`). */
|
|
59
|
+
hash = '';
|
|
60
|
+
/** Signed body bytes, when known: lets a worker thread rebuild this policy. */
|
|
61
|
+
bodyBytes;
|
|
62
|
+
constructor(body) {
|
|
63
|
+
this.body = body;
|
|
64
|
+
const b = body;
|
|
65
|
+
this.policyId = str(b.policy_id, '');
|
|
66
|
+
this.version = int(b.version, 0);
|
|
67
|
+
this.issuedSeq = int(b.issued_seq, 0);
|
|
68
|
+
this.createdAt = parseTime(b.created_at);
|
|
69
|
+
this.notAfter = parseTime(b.not_after);
|
|
70
|
+
const targets = obj(b.targets);
|
|
71
|
+
this.groups = arr(targets?.groups).map((g) => str(g, ''));
|
|
72
|
+
const hmac = obj(b.hmac) ?? {};
|
|
73
|
+
this.kid = str(hmac.kid, '');
|
|
74
|
+
this.acceptKids = arr(hmac.accept_kids).map((k) => str(k, ''));
|
|
75
|
+
this.epoch = str(hmac.epoch, '');
|
|
76
|
+
const subj = obj(obj(b.identity)?.subject);
|
|
77
|
+
if (subj) {
|
|
78
|
+
this.subject = { from: str(subj.from, 'header'), name: str(subj.name, '') };
|
|
79
|
+
if (typeof subj.jwt_claim === 'string')
|
|
80
|
+
this.subject.jwtClaim = subj.jwt_claim;
|
|
81
|
+
if (typeof subj.json_path === 'string')
|
|
82
|
+
this.subject.jsonPath = subj.json_path;
|
|
83
|
+
}
|
|
84
|
+
const sampling = obj(b.sampling) ?? {};
|
|
85
|
+
this.rateBp = int(sampling.rate_bp, 0);
|
|
86
|
+
this.trustBaggage = sampling.trust_baggage === true;
|
|
87
|
+
this.propagateTo = new Set(arr(sampling.propagate_to).map((h) => str(h, '')));
|
|
88
|
+
const seg = obj(b.segments) ?? {};
|
|
89
|
+
this.autoWords = seg.auto_words === true;
|
|
90
|
+
this.autoWordsExpiresAt = parseTime(seg.auto_words_expires_at);
|
|
91
|
+
this.allow = new Set(arr(seg.allow).map((x) => str(x, '')));
|
|
92
|
+
this.hostsAllow = new Set(arr(seg.hosts_allow).map((x) => str(x, '')));
|
|
93
|
+
this.hostsAuto = obj(b.hosts)?.discovery !== 'off';
|
|
94
|
+
const lim = obj(b.limits) ?? {};
|
|
95
|
+
this.limits = {
|
|
96
|
+
maxBodyBytes: int(lim.max_body_bytes, DEFAULT_LIMITS.maxBodyBytes),
|
|
97
|
+
maxDepth: int(lim.max_depth, DEFAULT_LIMITS.maxDepth),
|
|
98
|
+
maxArray: int(lim.max_array, DEFAULT_LIMITS.maxArray),
|
|
99
|
+
maxKeys: int(lim.max_keys, DEFAULT_LIMITS.maxKeys),
|
|
100
|
+
keepMaxBytes: int(lim.keep_max_bytes, DEFAULT_LIMITS.keepMaxBytes),
|
|
101
|
+
};
|
|
102
|
+
const bd = obj(b.bodies);
|
|
103
|
+
this.bodies = bd
|
|
104
|
+
? {
|
|
105
|
+
xml: bd.xml === true,
|
|
106
|
+
multipart: bd.multipart === true,
|
|
107
|
+
docs: bd.docs === true,
|
|
108
|
+
maxLargeBodyBytes: int(bd.max_large_body_bytes, 0),
|
|
109
|
+
largeArray: int(bd.large_array, 0),
|
|
110
|
+
largeNestedArray: int(bd.large_nested_array, 0),
|
|
111
|
+
}
|
|
112
|
+
: BODIES_OFF;
|
|
113
|
+
this.captureLimit = Math.max(this.limits.maxBodyBytes, this.bodies.maxLargeBodyBytes) + 1;
|
|
114
|
+
const disc = obj(b.discovery);
|
|
115
|
+
this.discoveryEnabled = disc?.enabled === true;
|
|
116
|
+
this.discoveryExpiresAt = parseTime(disc?.expires_at);
|
|
117
|
+
const g = obj(b.global) ?? {};
|
|
118
|
+
this.global = { request: part(g.request), response: part(g.response), errors: part(g.errors) };
|
|
119
|
+
this.services = [];
|
|
120
|
+
this.inbound = new Map();
|
|
121
|
+
this.baseHosts = new Set();
|
|
122
|
+
for (const s of arr(b.services)) {
|
|
123
|
+
const so = obj(s);
|
|
124
|
+
if (!so)
|
|
125
|
+
continue;
|
|
126
|
+
const m = obj(so.match) ?? {};
|
|
127
|
+
const svc = {
|
|
128
|
+
name: str(so.name, ''),
|
|
129
|
+
inbound: m.inbound === true,
|
|
130
|
+
internal: m.internal === true,
|
|
131
|
+
request: part(so.request),
|
|
132
|
+
response: part(so.response),
|
|
133
|
+
errors: part(so.errors),
|
|
134
|
+
ops: [],
|
|
135
|
+
};
|
|
136
|
+
if (typeof m.base === 'string') {
|
|
137
|
+
svc.base = splitBase(m.base);
|
|
138
|
+
const auth = svc.base.origin.replace(/^[a-z]+:\/\//, '');
|
|
139
|
+
this.baseHosts.add(auth);
|
|
140
|
+
this.baseHosts.add(auth.replace(/:[0-9]+$/, ''));
|
|
141
|
+
}
|
|
142
|
+
for (const o of arr(so.ops)) {
|
|
143
|
+
const oo = obj(o);
|
|
144
|
+
if (!oo)
|
|
145
|
+
continue;
|
|
146
|
+
const parsed = parseOpKey(str(oo.op, ''));
|
|
147
|
+
if (!parsed)
|
|
148
|
+
continue;
|
|
149
|
+
svc.ops.push({ ...parsed, request: part(oo.request), response: part(oo.response) });
|
|
150
|
+
}
|
|
151
|
+
this.services.push(svc);
|
|
152
|
+
if (svc.inbound && !this.inbound.has(svc.name))
|
|
153
|
+
this.inbound.set(svc.name, svc);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** 4.3: auto words active at `now` (ms). */
|
|
157
|
+
autoWordsActive(now) {
|
|
158
|
+
return this.autoWords && now !== undefined && now < this.autoWordsExpiresAt && now < this.createdAt + 7 * DAY_MS;
|
|
159
|
+
}
|
|
160
|
+
/** 15: discovery active at `now` (ms). */
|
|
161
|
+
discoveryActive(now) {
|
|
162
|
+
return this.discoveryEnabled && now !== undefined && now < this.discoveryExpiresAt && now < this.createdAt + 7 * DAY_MS;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function obj(v) {
|
|
166
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Lex) ? v : undefined;
|
|
167
|
+
}
|
|
168
|
+
function arr(v) {
|
|
169
|
+
return Array.isArray(v) ? v : [];
|
|
170
|
+
}
|
|
171
|
+
function str(v, d) {
|
|
172
|
+
return typeof v === 'string' ? v : d;
|
|
173
|
+
}
|
|
174
|
+
function int(v, d) {
|
|
175
|
+
if (v instanceof Lex) {
|
|
176
|
+
const n = Number(v.lex);
|
|
177
|
+
return Number.isFinite(n) ? n : d;
|
|
178
|
+
}
|
|
179
|
+
return d;
|
|
180
|
+
}
|
|
181
|
+
const RFC3339 = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|([+-])([0-9]{2}):([0-9]{2}))$/;
|
|
182
|
+
/** RFC 3339 timestamp to epoch milliseconds; NaN when absent or malformed. */
|
|
183
|
+
export function parseTime(v) {
|
|
184
|
+
if (typeof v !== 'string')
|
|
185
|
+
return NaN;
|
|
186
|
+
const m = RFC3339.exec(v);
|
|
187
|
+
if (!m)
|
|
188
|
+
return NaN;
|
|
189
|
+
const [y, mo, d, h, mi, s] = [m[1], m[2], m[3], m[4], m[5], m[6]].map(Number);
|
|
190
|
+
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 60)
|
|
191
|
+
return NaN;
|
|
192
|
+
let ms = Date.UTC(y, mo - 1, d, h, mi, s);
|
|
193
|
+
if (m[7])
|
|
194
|
+
ms += Math.floor(Number('0' + m[7]) * 1000);
|
|
195
|
+
if (m[9]) {
|
|
196
|
+
const off = (Number(m[10]) * 60 + Number(m[11])) * 60_000;
|
|
197
|
+
ms += m[9] === '+' ? -off : off;
|
|
198
|
+
}
|
|
199
|
+
return ms;
|
|
200
|
+
}
|
|
201
|
+
function parseAction(v) {
|
|
202
|
+
const o = obj(v);
|
|
203
|
+
if (!o)
|
|
204
|
+
return undefined;
|
|
205
|
+
const a = o.action;
|
|
206
|
+
if (a === 'code' && o.shape === 'alnum')
|
|
207
|
+
return { action: 'code', shape: 'alnum' };
|
|
208
|
+
if (a === 'drop' || a === 'keep' || a === 'code' || a === 'graphql')
|
|
209
|
+
return { action: a };
|
|
210
|
+
if (a === 'hmac')
|
|
211
|
+
return { action: 'hmac', domain: str(o.domain, 'id') };
|
|
212
|
+
if (a === 'bucket' && o.kind === 'num') {
|
|
213
|
+
return {
|
|
214
|
+
action: 'bucket',
|
|
215
|
+
kind: 'num',
|
|
216
|
+
bounds: arr(o.bounds).map((x) => {
|
|
217
|
+
const lex = x instanceof Lex ? x.lex : String(x);
|
|
218
|
+
return { canon: canonicalNumber(lex), d: Number(lex) };
|
|
219
|
+
}),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (a === 'bucket' && o.kind === 'time')
|
|
223
|
+
return { action: 'bucket', kind: 'time', unit: str(o.unit, 'day') };
|
|
224
|
+
return { action: 'drop' };
|
|
225
|
+
}
|
|
226
|
+
function nameMap(v) {
|
|
227
|
+
const m = new Map();
|
|
228
|
+
const o = obj(v);
|
|
229
|
+
if (!o)
|
|
230
|
+
return m;
|
|
231
|
+
for (const k of Object.keys(o)) {
|
|
232
|
+
const a = parseAction(o[k]);
|
|
233
|
+
if (a)
|
|
234
|
+
m.set(k, a);
|
|
235
|
+
}
|
|
236
|
+
return m;
|
|
237
|
+
}
|
|
238
|
+
/** Parses a tt-path; returns null if it does not follow the grammar. */
|
|
239
|
+
export function parseTtPath(p) {
|
|
240
|
+
if (p[0] !== '$')
|
|
241
|
+
return null;
|
|
242
|
+
const steps = [];
|
|
243
|
+
let i = 1;
|
|
244
|
+
while (i < p.length) {
|
|
245
|
+
if (p.startsWith('.*', i)) {
|
|
246
|
+
steps.push({ t: 'any' });
|
|
247
|
+
i += 2;
|
|
248
|
+
}
|
|
249
|
+
else if (p.startsWith('[*]', i)) {
|
|
250
|
+
steps.push({ t: 'idx' });
|
|
251
|
+
i += 3;
|
|
252
|
+
}
|
|
253
|
+
else if (p.startsWith('.#text', i)) {
|
|
254
|
+
// Spec v1.19 (sanitizer.md 5.0): mixed text of an XML element.
|
|
255
|
+
steps.push({ t: 'name', name: '#text' });
|
|
256
|
+
i += 6;
|
|
257
|
+
}
|
|
258
|
+
else if (p[i] === '.') {
|
|
259
|
+
// `.name`, or (spec v1.19) `.@name` for an XML attribute.
|
|
260
|
+
const m = /^@?[A-Za-z_][A-Za-z0-9_-]{0,63}/.exec(p.slice(i + 1));
|
|
261
|
+
if (!m)
|
|
262
|
+
return null;
|
|
263
|
+
steps.push({ t: 'name', name: m[0] });
|
|
264
|
+
i += 1 + m[0].length;
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return steps;
|
|
271
|
+
}
|
|
272
|
+
function part(v) {
|
|
273
|
+
const o = obj(v);
|
|
274
|
+
if (!o)
|
|
275
|
+
return undefined;
|
|
276
|
+
const body = [];
|
|
277
|
+
for (const r of arr(o.body)) {
|
|
278
|
+
const ro = obj(r);
|
|
279
|
+
if (!ro)
|
|
280
|
+
continue;
|
|
281
|
+
const path = str(ro.path, '');
|
|
282
|
+
const steps = parseTtPath(path);
|
|
283
|
+
const action = parseAction(ro.action);
|
|
284
|
+
if (!steps || !action)
|
|
285
|
+
continue;
|
|
286
|
+
body.push({ path, steps, names: steps.filter((s) => s.t === 'name').length, action });
|
|
287
|
+
}
|
|
288
|
+
return { query: nameMap(o.query), headers: nameMap(o.headers), cookies: nameMap(o.cookies), path: nameMap(o.path), body };
|
|
289
|
+
}
|
|
290
|
+
export function parseOpKey(key) {
|
|
291
|
+
const sp = key.indexOf(' ');
|
|
292
|
+
if (sp <= 0)
|
|
293
|
+
return null;
|
|
294
|
+
const method = key.slice(0, sp);
|
|
295
|
+
const tmpl = key.slice(sp + 1);
|
|
296
|
+
if (tmpl[0] !== '/')
|
|
297
|
+
return null;
|
|
298
|
+
const segs = [];
|
|
299
|
+
let literals = 0;
|
|
300
|
+
for (const s of tmpl.split('/')) {
|
|
301
|
+
if (s === '')
|
|
302
|
+
continue;
|
|
303
|
+
if (s[0] === '{' && s[s.length - 1] === '}')
|
|
304
|
+
segs.push({ param: s.slice(1, -1) });
|
|
305
|
+
else {
|
|
306
|
+
segs.push({ lit: s });
|
|
307
|
+
literals++;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return { key, method, segs, literals };
|
|
311
|
+
}
|
|
312
|
+
/** Splits a normalized `match.base` into origin and path segments. */
|
|
313
|
+
export function splitBase(base) {
|
|
314
|
+
const m = /^([a-z][a-z0-9+.-]*:\/\/[^/]*)(\/.*)?$/.exec(base);
|
|
315
|
+
if (!m)
|
|
316
|
+
return { origin: base, segs: [] };
|
|
317
|
+
return { origin: m[1], segs: (m[2] ?? '').split('/').filter((s) => s !== '') };
|
|
318
|
+
}
|
|
319
|
+
// ---------------------------------------------------------------------------------
|
|
320
|
+
// Body checks
|
|
321
|
+
/** Every hmac domain used by any rule of the body. */
|
|
322
|
+
function* actionsOf(body) {
|
|
323
|
+
const parts = (scope, keys) => {
|
|
324
|
+
const out = [];
|
|
325
|
+
const s = obj(scope);
|
|
326
|
+
if (!s)
|
|
327
|
+
return out;
|
|
328
|
+
for (const k of keys) {
|
|
329
|
+
const p = obj(s[k]);
|
|
330
|
+
if (p)
|
|
331
|
+
out.push(p);
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
};
|
|
335
|
+
const all = [...parts(body.global, ['request', 'response', 'errors'])];
|
|
336
|
+
for (const svc of arr(body.services)) {
|
|
337
|
+
all.push(...parts(svc, ['request', 'response', 'errors']));
|
|
338
|
+
for (const op of arr(obj(svc)?.ops))
|
|
339
|
+
all.push(...parts(op, ['request', 'response']));
|
|
340
|
+
}
|
|
341
|
+
for (const p of all) {
|
|
342
|
+
for (const sec of ['query', 'headers', 'cookies', 'path']) {
|
|
343
|
+
const m = obj(p[sec]);
|
|
344
|
+
if (m)
|
|
345
|
+
for (const k of Object.keys(m)) {
|
|
346
|
+
const a = obj(m[k]);
|
|
347
|
+
if (a)
|
|
348
|
+
yield a;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
for (const r of arr(p.body)) {
|
|
352
|
+
const a = obj(obj(r)?.action);
|
|
353
|
+
if (a)
|
|
354
|
+
yield a;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** Detail prefix of a Refused raised by the v1.3 safety subset; the agent reports it as `policy_unsafe`. */
|
|
359
|
+
export const UNSAFE_DETAIL = 'unsafe';
|
|
360
|
+
const CREDENTIAL_HEADERS = new Set(['authorization', 'cookie', 'set-cookie', 'proxy-authorization']);
|
|
361
|
+
/**
|
|
362
|
+
* Source-side safety subset (hardening.md 2.1, v1.3), applied to every signed body, whatever
|
|
363
|
+
* the validity of tt-core and keyguard:
|
|
364
|
+
* H1 keep, code (v1.11, any shape) or hmac on authorization, cookie, set-cookie, proxy-authorization (header rules of
|
|
365
|
+
* request, response and errors; global, services and operations);
|
|
366
|
+
* H2 keep, code (v1.11) or hmac on a tt-path with any wildcard step (`.*` or `[*]`) in the global body rules;
|
|
367
|
+
* H3 discovery or auto_words expiring later than created_at + 7 days.
|
|
368
|
+
* Properties of the body: neither the clock nor the group matter.
|
|
369
|
+
*/
|
|
370
|
+
export function checkUnsafe(b) {
|
|
371
|
+
const unsafe = (why) => {
|
|
372
|
+
throw new Refused('policy_rejected', UNSAFE_DETAIL + ': ' + why);
|
|
373
|
+
};
|
|
374
|
+
const g = obj(b.global);
|
|
375
|
+
const scopes = [];
|
|
376
|
+
const add = (scope, keys) => {
|
|
377
|
+
if (!scope)
|
|
378
|
+
return;
|
|
379
|
+
for (const k of keys) {
|
|
380
|
+
const part = obj(scope[k]);
|
|
381
|
+
if (part)
|
|
382
|
+
scopes.push(part);
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
add(g, ['request', 'response', 'errors']);
|
|
386
|
+
for (const svc of arr(b.services)) {
|
|
387
|
+
add(obj(svc), ['request', 'response', 'errors']);
|
|
388
|
+
for (const op of arr(obj(svc)?.ops))
|
|
389
|
+
add(obj(op), ['request', 'response']);
|
|
390
|
+
}
|
|
391
|
+
for (const part of scopes) {
|
|
392
|
+
const headers = obj(part.headers);
|
|
393
|
+
if (!headers)
|
|
394
|
+
continue;
|
|
395
|
+
for (const name of Object.keys(headers)) {
|
|
396
|
+
const act = obj(headers[name])?.action;
|
|
397
|
+
if (CREDENTIAL_HEADERS.has(name) && (act === 'keep' || act === 'code' || act === 'hmac'))
|
|
398
|
+
unsafe(act + ' on header ' + name);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (g) {
|
|
402
|
+
for (const k of ['request', 'response', 'errors']) {
|
|
403
|
+
for (const r of arr(obj(g[k])?.body)) {
|
|
404
|
+
const rule = obj(r);
|
|
405
|
+
const act = obj(rule?.action)?.action;
|
|
406
|
+
if ((act === 'keep' || act === 'code' || act === 'hmac') && typeof rule?.path === 'string' && (rule.path.includes('.*') || rule.path.includes('[*]'))) {
|
|
407
|
+
unsafe(act + ' on ' + rule.path + ' in a global rule');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const limit = parseTime(b.created_at) + 7 * DAY_MS;
|
|
413
|
+
const seg = obj(b.segments);
|
|
414
|
+
if (seg?.auto_words === true && parseTime(seg.auto_words_expires_at) > limit)
|
|
415
|
+
unsafe('auto_words expires beyond created_at + 7 days');
|
|
416
|
+
const d = obj(b.discovery);
|
|
417
|
+
if (d?.enabled === true && parseTime(d.expires_at) > limit)
|
|
418
|
+
unsafe('discovery expires beyond created_at + 7 days');
|
|
419
|
+
}
|
|
420
|
+
/** Minimal body checks of the conformance runner (format.md, step 1), plus the v1.3 safety subset. */
|
|
421
|
+
export function checkBodyMinimal(body) {
|
|
422
|
+
const b = obj(body);
|
|
423
|
+
if (!b || b.format !== BODY_FORMAT)
|
|
424
|
+
throw new Refused('policy_rejected', 'body format');
|
|
425
|
+
for (const a of actionsOf(b)) {
|
|
426
|
+
if (a.action === 'hmac' && typeof a.domain === 'string' && RESERVED_DOMAINS.has(a.domain)) {
|
|
427
|
+
throw new Refused('policy_rejected', 'reserved domain');
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
checkUnsafe(b);
|
|
431
|
+
return b;
|
|
432
|
+
}
|
|
433
|
+
class SchemaError extends Error {
|
|
434
|
+
}
|
|
435
|
+
const fail = (at, why) => {
|
|
436
|
+
throw new SchemaError(at + ': ' + why);
|
|
437
|
+
};
|
|
438
|
+
const isObj = (v) => obj(v) !== undefined;
|
|
439
|
+
const vStr = (re) => (v, at) => {
|
|
440
|
+
if (typeof v !== 'string' || !re.test(v))
|
|
441
|
+
fail(at, 'string');
|
|
442
|
+
};
|
|
443
|
+
const vInt = (min, max = Number.MAX_SAFE_INTEGER) => (v, at) => {
|
|
444
|
+
if (!(v instanceof Lex) || !/^-?(0|[1-9][0-9]*)$/.test(v.lex))
|
|
445
|
+
return fail(at, 'integer');
|
|
446
|
+
const n = Number(v.lex);
|
|
447
|
+
if (n < min || n > max)
|
|
448
|
+
fail(at, 'range');
|
|
449
|
+
};
|
|
450
|
+
const vBool = (v, at) => {
|
|
451
|
+
if (typeof v !== 'boolean')
|
|
452
|
+
fail(at, 'boolean');
|
|
453
|
+
};
|
|
454
|
+
const vTime = (v, at) => {
|
|
455
|
+
if (Number.isNaN(parseTime(v)))
|
|
456
|
+
fail(at, 'date-time');
|
|
457
|
+
};
|
|
458
|
+
const vConst = (c) => (v, at) => {
|
|
459
|
+
if (v !== c)
|
|
460
|
+
fail(at, 'const');
|
|
461
|
+
};
|
|
462
|
+
const vEnum = (vals) => (v, at) => {
|
|
463
|
+
if (typeof v !== 'string' || !vals.includes(v))
|
|
464
|
+
fail(at, 'enum');
|
|
465
|
+
};
|
|
466
|
+
const vArr = (item, max, unique, min = 0) => (v, at) => {
|
|
467
|
+
if (!Array.isArray(v) || v.length > max || v.length < min)
|
|
468
|
+
return fail(at, 'array');
|
|
469
|
+
const seen = new Set();
|
|
470
|
+
v.forEach((x, i) => {
|
|
471
|
+
item(x, at + '[' + i + ']');
|
|
472
|
+
if (unique) {
|
|
473
|
+
const k = JSON.stringify(x instanceof Lex ? x.lex : x);
|
|
474
|
+
if (seen.has(k))
|
|
475
|
+
fail(at, 'unique');
|
|
476
|
+
seen.add(k);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
};
|
|
480
|
+
const vObj = (props, required) => (v, at) => {
|
|
481
|
+
if (!isObj(v))
|
|
482
|
+
return fail(at, 'object');
|
|
483
|
+
for (const k of Object.keys(v)) {
|
|
484
|
+
const p = props[k];
|
|
485
|
+
if (!p)
|
|
486
|
+
fail(at + '.' + k, 'unexpected');
|
|
487
|
+
else
|
|
488
|
+
p(v[k], at + '.' + k);
|
|
489
|
+
}
|
|
490
|
+
for (const r of required)
|
|
491
|
+
if (!(r in v))
|
|
492
|
+
fail(at + '.' + r, 'required');
|
|
493
|
+
};
|
|
494
|
+
const vMap = (name, val, max) => (v, at) => {
|
|
495
|
+
if (!isObj(v))
|
|
496
|
+
return fail(at, 'object');
|
|
497
|
+
const keys = Object.keys(v);
|
|
498
|
+
if (keys.length > max)
|
|
499
|
+
fail(at, 'maxProperties');
|
|
500
|
+
for (const k of keys) {
|
|
501
|
+
if (!name.test(k))
|
|
502
|
+
fail(at + '.' + k, 'name');
|
|
503
|
+
val(v[k], at + '.' + k);
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
const RE_POLICY_ID = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
507
|
+
const RE_DOMAIN = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
508
|
+
const RE_KID = /^[a-z0-9]{1,8}$/;
|
|
509
|
+
const RE_HOSTPORT = /^[a-z0-9.-]{1,253}(:[0-9]{1,5})?$/;
|
|
510
|
+
const RE_KEYNAME = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
|
|
511
|
+
const RE_HEADERNAME = /^[a-z0-9!#$%&'*+.^_`|~-]{1,64}$/;
|
|
512
|
+
const RE_PARAMNAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
513
|
+
const RE_TTPATH = /^\$(\.@?[A-Za-z_][A-Za-z0-9_-]{0,63}|\.#text|\.\*|\[\*\]){0,64}$/;
|
|
514
|
+
const RE_OPKEY = /^[A-Z]{1,16} \/([A-Za-z0-9._~-]{1,64}|\{[A-Za-z_][A-Za-z0-9_]{0,63}\})?(\/([A-Za-z0-9._~-]{1,64}|\{[A-Za-z_][A-Za-z0-9_]{0,63}\})){0,31}$/;
|
|
515
|
+
const RE_BASE = /^https?:\/\/[a-z0-9.-]{1,253}(:[0-9]{1,5})?(\/[A-Za-z0-9._~-]{1,64})*$/;
|
|
516
|
+
const vAction = (v, at) => {
|
|
517
|
+
if (!isObj(v))
|
|
518
|
+
return fail(at, 'action');
|
|
519
|
+
const a = v.action;
|
|
520
|
+
// Spec v1.19 (sanitizer.md 5.6): code may carry the alnum shape; no other action takes a shape.
|
|
521
|
+
if (a === 'code' && 'shape' in v)
|
|
522
|
+
return vObj({ action: () => { }, shape: vConst('alnum') }, ['action', 'shape'])(v, at);
|
|
523
|
+
if (a === 'drop' || a === 'keep' || a === 'code' || a === 'graphql')
|
|
524
|
+
return vObj({ action: () => { } }, ['action'])(v, at);
|
|
525
|
+
if (a === 'hmac') {
|
|
526
|
+
vObj({ action: () => { }, domain: vStr(RE_DOMAIN) }, ['action', 'domain'])(v, at);
|
|
527
|
+
if (RESERVED_DOMAINS.has(v.domain))
|
|
528
|
+
fail(at, 'reserved domain');
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (a === 'bucket' && v.kind === 'num') {
|
|
532
|
+
vObj({
|
|
533
|
+
action: () => { },
|
|
534
|
+
kind: () => { },
|
|
535
|
+
bounds: vArr((x, a2) => {
|
|
536
|
+
if (!(x instanceof Lex))
|
|
537
|
+
fail(a2, 'number');
|
|
538
|
+
else if (x.lex.length > 64)
|
|
539
|
+
fail(a2, 'lexeme');
|
|
540
|
+
}, 32, false, 1),
|
|
541
|
+
}, ['action', 'kind', 'bounds'])(v, at);
|
|
542
|
+
const ds = v.bounds.map((x) => Number(x.lex));
|
|
543
|
+
for (let i = 1; i < ds.length; i++)
|
|
544
|
+
if (!(ds[i - 1] < ds[i]))
|
|
545
|
+
fail(at, 'bounds not increasing');
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (a === 'bucket' && v.kind === 'time') {
|
|
549
|
+
return vObj({ action: () => { }, kind: () => { }, unit: vStr(/^(year|month|day|hour)$/) }, ['action', 'kind', 'unit'])(v, at);
|
|
550
|
+
}
|
|
551
|
+
fail(at, 'action');
|
|
552
|
+
};
|
|
553
|
+
const vBodyRules = vArr(vObj({ path: vStr(RE_TTPATH), action: vAction }, ['path', 'action']), 1024, false);
|
|
554
|
+
const vPart = vObj({
|
|
555
|
+
query: vMap(RE_KEYNAME, vAction, 256),
|
|
556
|
+
headers: vMap(RE_HEADERNAME, vAction, 256),
|
|
557
|
+
cookies: vMap(RE_KEYNAME, vAction, 256),
|
|
558
|
+
body: vBodyRules,
|
|
559
|
+
}, []);
|
|
560
|
+
const vOpPart = vObj({
|
|
561
|
+
path: vMap(RE_PARAMNAME, vAction, 32),
|
|
562
|
+
query: vMap(RE_KEYNAME, vAction, 256),
|
|
563
|
+
headers: vMap(RE_HEADERNAME, vAction, 256),
|
|
564
|
+
cookies: vMap(RE_KEYNAME, vAction, 256),
|
|
565
|
+
body: vBodyRules,
|
|
566
|
+
}, []);
|
|
567
|
+
const vScope = vObj({ request: vPart, response: vPart, errors: vPart }, []);
|
|
568
|
+
const vMatch = (v, at) => {
|
|
569
|
+
if (!isObj(v))
|
|
570
|
+
return fail(at, 'match');
|
|
571
|
+
if ('base' in v)
|
|
572
|
+
return vObj({ base: vStr(RE_BASE) }, ['base'])(v, at);
|
|
573
|
+
vObj({ inbound: vConst(true), internal: vBool }, ['inbound', 'internal'])(v, at);
|
|
574
|
+
};
|
|
575
|
+
const vService = vObj({
|
|
576
|
+
name: vStr(/^[a-z0-9][a-z0-9._-]{0,62}$/),
|
|
577
|
+
match: vMatch,
|
|
578
|
+
request: vPart,
|
|
579
|
+
response: vPart,
|
|
580
|
+
errors: vPart,
|
|
581
|
+
ops: vArr(vObj({ op: vStr(RE_OPKEY), request: vOpPart, response: vOpPart }, ['op']), 4096, false),
|
|
582
|
+
}, ['name', 'match', 'ops']);
|
|
583
|
+
const RE_JSON_PATH = /^[A-Za-z0-9_-]{1,64}(\.[A-Za-z0-9_-]{1,64}){0,7}$/;
|
|
584
|
+
const vBody = vObj({
|
|
585
|
+
format: vConst(BODY_FORMAT),
|
|
586
|
+
policy_id: vStr(RE_POLICY_ID),
|
|
587
|
+
version: vInt(1),
|
|
588
|
+
issued_seq: vInt(1),
|
|
589
|
+
created_at: vTime,
|
|
590
|
+
not_after: vTime,
|
|
591
|
+
targets: vObj({ groups: vArr(vStr(RE_POLICY_ID), 256, true, 1) }, ['groups']),
|
|
592
|
+
hmac: vObj({ kid: vStr(RE_KID), accept_kids: vArr(vStr(RE_KID), 4, true), epoch: vStr(/^[A-Za-z0-9._-]{1,32}$/) }, ['kid', 'epoch', 'accept_kids']),
|
|
593
|
+
identity: vObj({
|
|
594
|
+
subject: (v, at) => {
|
|
595
|
+
vObj({
|
|
596
|
+
from: vStr(/^(header|cookie)$/),
|
|
597
|
+
name: vStr(/^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/),
|
|
598
|
+
jwt_claim: vStr(RE_KEYNAME),
|
|
599
|
+
json_path: vStr(RE_JSON_PATH),
|
|
600
|
+
}, ['from', 'name'])(v, at);
|
|
601
|
+
// Spec v1.18: json_path reads a header only and excludes jwt_claim.
|
|
602
|
+
if (isObj(v) && 'json_path' in v && ('jwt_claim' in v || v.from !== 'header'))
|
|
603
|
+
fail(at + '.json_path', 'header only, excludes jwt_claim');
|
|
604
|
+
},
|
|
605
|
+
}, []),
|
|
606
|
+
sampling: vObj({ rate_bp: vInt(0, 10000), trust_baggage: vBool, propagate_to: vArr(vStr(RE_HOSTPORT), 256, true) }, ['rate_bp', 'trust_baggage', 'propagate_to']),
|
|
607
|
+
segments: (v, at) => {
|
|
608
|
+
vObj({
|
|
609
|
+
auto_words: vBool,
|
|
610
|
+
auto_words_expires_at: vTime,
|
|
611
|
+
allow: vArr(vStr(/^[A-Za-z0-9._~-]{1,64}$/), 1024, true),
|
|
612
|
+
hosts_allow: vArr(vStr(RE_HOSTPORT), 1024, true),
|
|
613
|
+
}, ['auto_words', 'allow', 'hosts_allow'])(v, at);
|
|
614
|
+
if (isObj(v) && v.auto_words === true && !('auto_words_expires_at' in v))
|
|
615
|
+
fail(at, 'auto_words_expires_at required');
|
|
616
|
+
},
|
|
617
|
+
limits: vObj({
|
|
618
|
+
max_body_bytes: vInt(0, 16777216),
|
|
619
|
+
max_depth: vInt(1, 128),
|
|
620
|
+
max_array: vInt(0, 1024),
|
|
621
|
+
max_keys: vInt(1, 4096),
|
|
622
|
+
keep_max_bytes: vInt(0, 1024),
|
|
623
|
+
}, ['max_body_bytes', 'max_depth', 'max_array', 'max_keys', 'keep_max_bytes']),
|
|
624
|
+
// Spec v1.19 (sanitizer.md 22.1): XML/SOAP, multipart, base64 documents, large bodies. Absent = all off.
|
|
625
|
+
bodies: vObj({
|
|
626
|
+
xml: vBool,
|
|
627
|
+
multipart: vBool,
|
|
628
|
+
docs: vBool,
|
|
629
|
+
max_large_body_bytes: vInt(0, 67108864),
|
|
630
|
+
large_array: vInt(0, 1024),
|
|
631
|
+
large_nested_array: vInt(0, 1024),
|
|
632
|
+
}, ['xml', 'multipart', 'docs', 'max_large_body_bytes', 'large_array', 'large_nested_array']),
|
|
633
|
+
discovery: vObj({ enabled: vBool, expires_at: vTime }, ['enabled', 'expires_at']),
|
|
634
|
+
// Spec v1.5 (sanitizer.md 3.5): how hosts without a matching base leave. Absent = auto.
|
|
635
|
+
hosts: vObj({ discovery: vEnum(['auto', 'off']) }, ['discovery']),
|
|
636
|
+
global: vScope,
|
|
637
|
+
services: vArr(vService, 1024, false),
|
|
638
|
+
}, ['format', 'policy_id', 'version', 'issued_seq', 'created_at', 'not_after', 'targets', 'hmac', 'sampling', 'segments', 'limits', 'global', 'services']);
|
|
639
|
+
/** Full schema validation of a policy body, plus reserved domains, double bounds and the v1.3 safety subset. */
|
|
640
|
+
export function validateBodySchema(body) {
|
|
641
|
+
try {
|
|
642
|
+
vBody(body, '$');
|
|
643
|
+
}
|
|
644
|
+
catch (e) {
|
|
645
|
+
if (e instanceof SchemaError)
|
|
646
|
+
throw new Refused('policy_rejected', 'schema ' + e.message);
|
|
647
|
+
throw e;
|
|
648
|
+
}
|
|
649
|
+
checkUnsafe(body);
|
|
650
|
+
return body;
|
|
651
|
+
}
|
|
652
|
+
// ---------------------------------------------------------------------------------
|
|
653
|
+
// Envelope
|
|
654
|
+
const B64URL = /^[A-Za-z0-9_-]*$/;
|
|
655
|
+
/** Strict base64url without padding; null on any other character or impossible length. */
|
|
656
|
+
export function b64urlDecodeStrict(s) {
|
|
657
|
+
if (!B64URL.test(s) || s.length % 4 === 1)
|
|
658
|
+
return null;
|
|
659
|
+
return Buffer.from(s, 'base64url');
|
|
660
|
+
}
|
|
661
|
+
export function b64urlNoPad(b) {
|
|
662
|
+
return Buffer.from(b.buffer, b.byteOffset, b.length).toString('base64url');
|
|
663
|
+
}
|
|
664
|
+
/** Ed25519 public key object from the raw 32 bytes (base64url, no padding). */
|
|
665
|
+
export function ed25519PublicKey(rawB64url) {
|
|
666
|
+
const raw = b64urlDecodeStrict(rawB64url);
|
|
667
|
+
if (!raw || raw.length !== 32)
|
|
668
|
+
return null;
|
|
669
|
+
try {
|
|
670
|
+
return createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: rawB64url }, format: 'jwk' });
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Verifies a `tt.policy.compiled/v1` envelope (policy.md 3-4 conditions 1-2). `keys` maps a
|
|
678
|
+
* pinned, non-revoked signature kid to its public key. `strict` applies the full body
|
|
679
|
+
* schema (production); otherwise only the minimal body checks of the conformance runner.
|
|
680
|
+
*/
|
|
681
|
+
export function verifyEnvelope(env, keys, strict) {
|
|
682
|
+
const reject = (why) => {
|
|
683
|
+
throw new Refused('policy_rejected', why);
|
|
684
|
+
};
|
|
685
|
+
const e = obj(env);
|
|
686
|
+
if (!e)
|
|
687
|
+
return reject('envelope');
|
|
688
|
+
if (strict) {
|
|
689
|
+
const allowed = new Set(['format', 'policy_id', 'version', 'issued_seq', 'hash', 'body_b64', 'signature']);
|
|
690
|
+
for (const k of Object.keys(e))
|
|
691
|
+
if (!allowed.has(k))
|
|
692
|
+
reject('envelope field');
|
|
693
|
+
}
|
|
694
|
+
if (e.format !== ENVELOPE_FORMAT)
|
|
695
|
+
reject('envelope format');
|
|
696
|
+
const sig = obj(e.signature);
|
|
697
|
+
if (!sig || sig.alg !== 'Ed25519' || typeof sig.kid !== 'string' || typeof sig.sig !== 'string')
|
|
698
|
+
return reject('signature');
|
|
699
|
+
const pub = keys.get(sig.kid);
|
|
700
|
+
if (!pub)
|
|
701
|
+
return reject('unknown kid');
|
|
702
|
+
if (typeof e.body_b64 !== 'string')
|
|
703
|
+
return reject('body_b64');
|
|
704
|
+
const bodyBytes = b64urlDecodeStrict(e.body_b64);
|
|
705
|
+
if (!bodyBytes || bodyBytes.length === 0)
|
|
706
|
+
return reject('body_b64');
|
|
707
|
+
const hash = 'sha256:' + createHash('sha256').update(bodyBytes).digest('hex');
|
|
708
|
+
if (e.hash !== hash)
|
|
709
|
+
reject('hash');
|
|
710
|
+
const sigBytes = b64urlDecodeStrict(sig.sig);
|
|
711
|
+
if (!sigBytes || sigBytes.length !== 64)
|
|
712
|
+
return reject('signature encoding');
|
|
713
|
+
let ok = false;
|
|
714
|
+
try {
|
|
715
|
+
ok = cryptoVerify(null, Buffer.concat([SIGNATURE_PREFIX, bodyBytes]), pub, sigBytes);
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
ok = false;
|
|
719
|
+
}
|
|
720
|
+
if (!ok)
|
|
721
|
+
reject('signature');
|
|
722
|
+
let plain;
|
|
723
|
+
try {
|
|
724
|
+
plain = nodeToPlain(parseJson(bodyBytes), true);
|
|
725
|
+
}
|
|
726
|
+
catch (err) {
|
|
727
|
+
if (err instanceof JsonError)
|
|
728
|
+
return reject('body json');
|
|
729
|
+
throw err;
|
|
730
|
+
}
|
|
731
|
+
const body = strict ? validateBodySchema(plain) : checkBodyMinimal(plain);
|
|
732
|
+
const same = (a, b) => a instanceof Lex && b instanceof Lex ? a.lex === b.lex : a === b;
|
|
733
|
+
if (!same(e.policy_id, body.policy_id))
|
|
734
|
+
reject('policy_id mismatch');
|
|
735
|
+
if (!same(e.version, body.version))
|
|
736
|
+
reject('version mismatch');
|
|
737
|
+
if ((strict || e.issued_seq !== undefined || body.issued_seq !== undefined) && !same(e.issued_seq, body.issued_seq))
|
|
738
|
+
reject('issued_seq mismatch');
|
|
739
|
+
if (strict && body.format !== BODY_FORMAT)
|
|
740
|
+
reject('body format');
|
|
741
|
+
checkBodyMinimal(body);
|
|
742
|
+
return { bodyBytes, body, hash };
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* policy.md 4, conditions 3-5: group, expiry, issued_seq ordering. `allowEqualSeq` lets a
|
|
746
|
+
* source re-verify the very policy it already accepted (cache at start-up).
|
|
747
|
+
*/
|
|
748
|
+
export function checkAcceptance(p, o) {
|
|
749
|
+
if (!p.groups.includes(o.group))
|
|
750
|
+
throw new Refused('policy_rejected', 'group');
|
|
751
|
+
if (!(o.now < p.notAfter))
|
|
752
|
+
throw new Refused('expired_policy');
|
|
753
|
+
const fresh = o.allowEqualSeq ? p.issuedSeq >= o.lastIssuedSeq : p.issuedSeq > o.lastIssuedSeq;
|
|
754
|
+
if (!fresh)
|
|
755
|
+
throw new Refused('policy_rejected', 'issued_seq replay');
|
|
756
|
+
if (p.issuedSeq < o.minIssuedSeq)
|
|
757
|
+
throw new Refused('policy_rejected', 'issued_seq below minimum');
|
|
758
|
+
}
|
|
759
|
+
/** Parses the content of TT_HMAC_KEY / TT_HMAC_KEY_FILE. Throws Refused('key_missing'). */
|
|
760
|
+
export function parseKeyRing(text) {
|
|
761
|
+
if (text === undefined || text === null)
|
|
762
|
+
throw new Refused('key_missing', 'absent');
|
|
763
|
+
const keys = new Map();
|
|
764
|
+
const order = [];
|
|
765
|
+
for (let line of text.split('\n')) {
|
|
766
|
+
if (line.endsWith('\r'))
|
|
767
|
+
line = line.slice(0, -1);
|
|
768
|
+
if (line === '')
|
|
769
|
+
continue;
|
|
770
|
+
const c = line.indexOf(':');
|
|
771
|
+
if (c < 0)
|
|
772
|
+
throw new Refused('key_missing', 'malformed');
|
|
773
|
+
const kid = line.slice(0, c);
|
|
774
|
+
if (!RE_KID.test(kid))
|
|
775
|
+
throw new Refused('key_missing', 'kid');
|
|
776
|
+
const raw = b64urlDecodeStrict(line.slice(c + 1));
|
|
777
|
+
if (!raw || raw.length < 32)
|
|
778
|
+
throw new Refused('key_missing', 'key bytes');
|
|
779
|
+
if (!keys.has(kid)) {
|
|
780
|
+
keys.set(kid, raw);
|
|
781
|
+
order.push(kid);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (keys.size === 0)
|
|
785
|
+
throw new Refused('key_missing', 'empty');
|
|
786
|
+
return { keys, order };
|
|
787
|
+
}
|
|
788
|
+
/** 7.1: picks the key for a policy (hmac.kid, then accept_kids in order). */
|
|
789
|
+
export function selectKey(ring, policy) {
|
|
790
|
+
const k = ring.keys.get(policy.kid);
|
|
791
|
+
if (k)
|
|
792
|
+
return { kid: policy.kid, key: k };
|
|
793
|
+
for (const kid of policy.acceptKids) {
|
|
794
|
+
const a = ring.keys.get(kid);
|
|
795
|
+
if (a)
|
|
796
|
+
return { kid, key: a };
|
|
797
|
+
}
|
|
798
|
+
throw new Refused('key_mismatch');
|
|
799
|
+
}
|
|
800
|
+
export { cmpUtf8, lexemeWithinLimits };
|