@dsh-enhanced/assistant-actions 0.1.30
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 +21 -0
- package/README.md +247 -0
- package/cordis.patch.yml +3 -0
- package/lib/broker-cli.d.ts +70 -0
- package/lib/broker-cli.d.ts.map +1 -0
- package/lib/broker-cli.js +255 -0
- package/lib/broker-cli.js.map +1 -0
- package/lib/broker-client.d.ts +54 -0
- package/lib/broker-client.d.ts.map +1 -0
- package/lib/broker-client.js +266 -0
- package/lib/broker-client.js.map +1 -0
- package/lib/broker-core.d.ts +51 -0
- package/lib/broker-core.d.ts.map +1 -0
- package/lib/broker-core.js +441 -0
- package/lib/broker-core.js.map +1 -0
- package/lib/broker-ledger.d.ts +131 -0
- package/lib/broker-ledger.d.ts.map +1 -0
- package/lib/broker-ledger.js +925 -0
- package/lib/broker-ledger.js.map +1 -0
- package/lib/broker-peer-linux.d.ts +23 -0
- package/lib/broker-peer-linux.d.ts.map +1 -0
- package/lib/broker-peer-linux.js +68 -0
- package/lib/broker-peer-linux.js.map +1 -0
- package/lib/broker-protocol.d.ts +419 -0
- package/lib/broker-protocol.d.ts.map +1 -0
- package/lib/broker-protocol.js +786 -0
- package/lib/broker-protocol.js.map +1 -0
- package/lib/broker-server.d.ts +77 -0
- package/lib/broker-server.d.ts.map +1 -0
- package/lib/broker-server.js +467 -0
- package/lib/broker-server.js.map +1 -0
- package/lib/cli.d.ts +3 -0
- package/lib/cli.d.ts.map +1 -0
- package/lib/cli.js +36 -0
- package/lib/cli.js.map +1 -0
- package/lib/config.d.ts +54 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +165 -0
- package/lib/config.js.map +1 -0
- package/lib/external-delivery-receipt.d.ts +11 -0
- package/lib/external-delivery-receipt.d.ts.map +1 -0
- package/lib/external-delivery-receipt.js +25 -0
- package/lib/external-delivery-receipt.js.map +1 -0
- package/lib/github.d.ts +78 -0
- package/lib/github.d.ts.map +1 -0
- package/lib/github.js +611 -0
- package/lib/github.js.map +1 -0
- package/lib/index.d.ts +19 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +23 -0
- package/lib/index.js.map +1 -0
- package/lib/ledger.d.ts +78 -0
- package/lib/ledger.d.ts.map +1 -0
- package/lib/ledger.js +1064 -0
- package/lib/ledger.js.map +1 -0
- package/lib/repository-readback.d.ts +34 -0
- package/lib/repository-readback.d.ts.map +1 -0
- package/lib/repository-readback.js +193 -0
- package/lib/repository-readback.js.map +1 -0
- package/lib/service.d.ts +86 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +1259 -0
- package/lib/service.js.map +1 -0
- package/lib/types.d.ts +174 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/verified-delivery.d.ts +104 -0
- package/lib/verified-delivery.d.ts.map +1 -0
- package/lib/verified-delivery.js +262 -0
- package/lib/verified-delivery.js.map +1 -0
- package/lib/version.d.ts +2 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +2 -0
- package/lib/version.js.map +1 -0
- package/package.json +123 -0
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, KeyObject, randomBytes, randomUUID, sign, verify } from 'node:crypto';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
export const GITHUB_BROKER_PROTOCOL = 'assistant-actions/github-broker/v1';
|
|
4
|
+
export const GITHUB_BROKER_REQUEST_MAX_BYTES = 8 * 1024 * 1024;
|
|
5
|
+
export const GITHUB_BROKER_RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
|
|
6
|
+
export const GITHUB_BROKER_DEFAULT_HELLO_TTL_MS = 30_000;
|
|
7
|
+
const HELLO_DOMAIN = 'assistant-actions/github-broker/v1/server-hello\0';
|
|
8
|
+
const REQUEST_DOMAIN = 'assistant-actions/github-broker/v1/client-request\0';
|
|
9
|
+
const RESPONSE_DOMAIN = 'assistant-actions/github-broker/v1/server-response\0';
|
|
10
|
+
const ADMIN_REQUEST_DOMAIN = 'assistant-actions/github-broker/v1/admin-request\0';
|
|
11
|
+
const ADMIN_RESPONSE_DOMAIN = 'assistant-actions/github-broker/v1/admin-response\0';
|
|
12
|
+
const MAX_CANONICAL_DEPTH = 64;
|
|
13
|
+
const MAX_CANONICAL_NODES = 200_000;
|
|
14
|
+
const DIGEST = /^[0-9a-f]{64}$/;
|
|
15
|
+
const OID = /^[0-9a-f]{40,128}$/;
|
|
16
|
+
const BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
17
|
+
const INSTANCE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
18
|
+
const REPOSITORY = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
19
|
+
const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
20
|
+
const SENSITIVE_KEY = /^(?:access[_-]?token|api[_-]?key|authorization|cookie|credential|password|private[_-]?key|secret|token)$/i;
|
|
21
|
+
const SENSITIVE_VALUE = /(?:authorization\s*:|bearer\s+|gh[opusr]_|github_pat_|-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----)/i;
|
|
22
|
+
const ERROR_CODE = /^[a-z][a-z0-9-]{0,127}$/;
|
|
23
|
+
export class BrokerProtocolError extends Error {
|
|
24
|
+
code;
|
|
25
|
+
constructor(code, message) {
|
|
26
|
+
super(`assistant-actions broker protocol: ${message}`);
|
|
27
|
+
this.name = 'BrokerProtocolError';
|
|
28
|
+
this.code = code;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function reject(code, message) { throw new BrokerProtocolError(code, message); }
|
|
32
|
+
function record(value, label) {
|
|
33
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
34
|
+
reject('invalid-message', `${label} must be an object`);
|
|
35
|
+
if (Object.getPrototypeOf(value) !== Object.prototype || Object.getOwnPropertySymbols(value).length !== 0)
|
|
36
|
+
reject('invalid-message', `${label} must be a plain data object`);
|
|
37
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
38
|
+
if (Object.values(descriptors).some(descriptor => !descriptor.enumerable || !('value' in descriptor)))
|
|
39
|
+
reject('invalid-message', `${label} must contain enumerable data fields only`);
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
function exact(value, keys, label) {
|
|
43
|
+
const output = record(value, label);
|
|
44
|
+
const actual = Object.keys(output).sort();
|
|
45
|
+
const expected = [...keys].sort();
|
|
46
|
+
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index]))
|
|
47
|
+
reject('unknown-field', `${label} has unknown or missing fields`);
|
|
48
|
+
return output;
|
|
49
|
+
}
|
|
50
|
+
function text(value, label, max = 256) {
|
|
51
|
+
if (typeof value !== 'string' || value.length < 1 || value.length > max || /[\p{Cc}]/u.test(value))
|
|
52
|
+
reject('invalid-message', `${label} is invalid`);
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
function integer(value, label, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) {
|
|
56
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum || value > maximum)
|
|
57
|
+
reject('invalid-message', `${label} is invalid`);
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
function digest(value, label) {
|
|
61
|
+
if (typeof value !== 'string' || !DIGEST.test(value))
|
|
62
|
+
reject('invalid-message', `${label} is invalid`);
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
function base64url(value, label, bytes) {
|
|
66
|
+
if (typeof value !== 'string' || !BASE64URL.test(value))
|
|
67
|
+
reject('invalid-message', `${label} is invalid`);
|
|
68
|
+
const decoded = Buffer.from(value, 'base64url');
|
|
69
|
+
if (decoded.length !== bytes || decoded.toString('base64url') !== value)
|
|
70
|
+
reject('invalid-message', `${label} is invalid`);
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
function instance(value, label) {
|
|
74
|
+
if (typeof value !== 'string' || !INSTANCE.test(value))
|
|
75
|
+
reject('invalid-message', `${label} is invalid`);
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
function oid(value, label) {
|
|
79
|
+
if (typeof value !== 'string' || !OID.test(value))
|
|
80
|
+
reject('invalid-message', `${label} is invalid`);
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
function canonical(value, state, depth) {
|
|
84
|
+
if (++state.nodes > MAX_CANONICAL_NODES || depth > MAX_CANONICAL_DEPTH)
|
|
85
|
+
reject('invalid-json', 'canonical JSON complexity exceeded');
|
|
86
|
+
if (value === null)
|
|
87
|
+
return 'null';
|
|
88
|
+
if (typeof value === 'boolean' || typeof value === 'string')
|
|
89
|
+
return JSON.stringify(value);
|
|
90
|
+
if (typeof value === 'number') {
|
|
91
|
+
if (!Number.isSafeInteger(value) || Object.is(value, -0))
|
|
92
|
+
reject('invalid-json', 'only safe canonical integers are supported');
|
|
93
|
+
return JSON.stringify(value);
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(value)) {
|
|
96
|
+
if (value.length > MAX_CANONICAL_NODES - state.nodes)
|
|
97
|
+
reject('invalid-json', 'canonical JSON complexity exceeded');
|
|
98
|
+
if (Object.getPrototypeOf(value) !== Array.prototype || Object.getOwnPropertySymbols(value).length !== 0)
|
|
99
|
+
reject('invalid-json', 'arrays must use the ordinary JSON shape');
|
|
100
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
101
|
+
if (Object.keys(descriptors).some(key => key !== 'length' && (!/^(?:0|[1-9][0-9]*)$/.test(key) || Number(key) >= value.length))
|
|
102
|
+
|| Object.entries(descriptors).some(([key, descriptor]) => key !== 'length' && (!descriptor.enumerable || !('value' in descriptor))))
|
|
103
|
+
reject('invalid-json', 'arrays must be dense enumerable data values');
|
|
104
|
+
const entries = [];
|
|
105
|
+
for (let index = 0; index < value.length; index++) {
|
|
106
|
+
if (!Object.hasOwn(descriptors, String(index)))
|
|
107
|
+
reject('invalid-json', 'arrays must be dense enumerable data values');
|
|
108
|
+
entries.push(canonical(value[index], state, depth + 1));
|
|
109
|
+
}
|
|
110
|
+
return `[${entries.join(',')}]`;
|
|
111
|
+
}
|
|
112
|
+
if (typeof value !== 'object')
|
|
113
|
+
reject('invalid-json', 'unsupported JSON value');
|
|
114
|
+
const input = record(value, 'canonical JSON object');
|
|
115
|
+
const keys = Object.keys(input);
|
|
116
|
+
if (keys.length > MAX_CANONICAL_NODES - state.nodes)
|
|
117
|
+
reject('invalid-json', 'canonical JSON complexity exceeded');
|
|
118
|
+
keys.sort();
|
|
119
|
+
return `{${keys.map(key => {
|
|
120
|
+
if (SENSITIVE_KEY.test(key))
|
|
121
|
+
reject('secret-field', 'credentials are forbidden on the broker wire');
|
|
122
|
+
return `${JSON.stringify(key)}:${canonical(input[key], state, depth + 1)}`;
|
|
123
|
+
}).join(',')}}`;
|
|
124
|
+
}
|
|
125
|
+
/** RFC-8785-style deterministic JSON for this integer-only protocol. */
|
|
126
|
+
export function canonicalBrokerJson(value) { return canonical(value, { nodes: 0 }, 0); }
|
|
127
|
+
export function brokerDigest(value) { return createHash('sha256').update(canonicalBrokerJson(value)).digest('hex'); }
|
|
128
|
+
function parseCanonicalJson(bytes) {
|
|
129
|
+
const source = bytes.toString('utf8');
|
|
130
|
+
if (!Buffer.from(source, 'utf8').equals(bytes))
|
|
131
|
+
reject('invalid-json', 'frame is not valid UTF-8');
|
|
132
|
+
preflightJsonComplexity(source);
|
|
133
|
+
let value;
|
|
134
|
+
try {
|
|
135
|
+
value = JSON.parse(source);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
reject('invalid-json', 'frame is not valid JSON');
|
|
139
|
+
}
|
|
140
|
+
if (canonicalBrokerJson(value) !== source)
|
|
141
|
+
reject('non-canonical-json', 'frame JSON is not canonical');
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
/** Bound structural work before JSON.parse allocates an attacker-sized tree. */
|
|
145
|
+
function preflightJsonComplexity(source) {
|
|
146
|
+
let depth = 0, tokens = 1, inString = false, escaped = false;
|
|
147
|
+
for (let index = 0; index < source.length; index++) {
|
|
148
|
+
const character = source[index];
|
|
149
|
+
if (inString) {
|
|
150
|
+
if (escaped)
|
|
151
|
+
escaped = false;
|
|
152
|
+
else if (character === '\\')
|
|
153
|
+
escaped = true;
|
|
154
|
+
else if (character === '"')
|
|
155
|
+
inString = false;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (character === '"') {
|
|
159
|
+
inString = true;
|
|
160
|
+
tokens++;
|
|
161
|
+
}
|
|
162
|
+
else if (character === '{' || character === '[') {
|
|
163
|
+
depth++;
|
|
164
|
+
tokens++;
|
|
165
|
+
if (depth > MAX_CANONICAL_DEPTH)
|
|
166
|
+
reject('invalid-json', 'canonical JSON complexity exceeded');
|
|
167
|
+
}
|
|
168
|
+
else if (character === '}' || character === ']')
|
|
169
|
+
depth--;
|
|
170
|
+
else if (character === ',')
|
|
171
|
+
tokens++;
|
|
172
|
+
if (tokens > MAX_CANONICAL_NODES || depth < 0)
|
|
173
|
+
reject('invalid-json', 'canonical JSON complexity exceeded');
|
|
174
|
+
}
|
|
175
|
+
if (inString || escaped || depth !== 0)
|
|
176
|
+
reject('invalid-json', 'frame is not valid JSON');
|
|
177
|
+
}
|
|
178
|
+
export function encodeBrokerFrame(value, maxBytes) {
|
|
179
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 0xffff_ffff)
|
|
180
|
+
reject('invalid-limit', 'frame limit is invalid');
|
|
181
|
+
const payload = Buffer.from(canonicalBrokerJson(value), 'utf8');
|
|
182
|
+
if (payload.length > maxBytes)
|
|
183
|
+
reject('frame-too-large', 'frame exceeds its byte limit');
|
|
184
|
+
const output = Buffer.allocUnsafe(payload.length + 4);
|
|
185
|
+
output.writeUInt32BE(payload.length, 0);
|
|
186
|
+
payload.copy(output, 4);
|
|
187
|
+
return output;
|
|
188
|
+
}
|
|
189
|
+
/** Incremental four-byte big-endian length-prefixed canonical JSON decoder. */
|
|
190
|
+
export class BrokerFrameDecoder {
|
|
191
|
+
#maxBytes;
|
|
192
|
+
#maxFrames;
|
|
193
|
+
#header = Buffer.alloc(4);
|
|
194
|
+
#headerOffset = 0;
|
|
195
|
+
#payload;
|
|
196
|
+
#payloadOffset = 0;
|
|
197
|
+
#frames = 0;
|
|
198
|
+
constructor(maxBytes, maxFrames = 1) {
|
|
199
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 0xffff_ffff || !Number.isSafeInteger(maxFrames) || maxFrames < 1)
|
|
200
|
+
reject('invalid-limit', 'decoder limits are invalid');
|
|
201
|
+
this.#maxBytes = maxBytes;
|
|
202
|
+
this.#maxFrames = maxFrames;
|
|
203
|
+
}
|
|
204
|
+
push(chunk) {
|
|
205
|
+
if (!(chunk instanceof Uint8Array))
|
|
206
|
+
reject('invalid-frame', 'frame chunk is invalid');
|
|
207
|
+
if (chunk.byteLength === 0)
|
|
208
|
+
return [];
|
|
209
|
+
if (this.#frames >= this.#maxFrames)
|
|
210
|
+
reject('trailing-data', 'bytes follow the final frame');
|
|
211
|
+
const output = [];
|
|
212
|
+
const input = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
213
|
+
let offset = 0;
|
|
214
|
+
while (offset < input.length) {
|
|
215
|
+
if (this.#frames >= this.#maxFrames)
|
|
216
|
+
reject('trailing-data', 'bytes follow the final frame');
|
|
217
|
+
if (!this.#payload) {
|
|
218
|
+
const copied = input.copy(this.#header, this.#headerOffset, offset, Math.min(input.length, offset + 4 - this.#headerOffset));
|
|
219
|
+
this.#headerOffset += copied;
|
|
220
|
+
offset += copied;
|
|
221
|
+
if (this.#headerOffset < 4)
|
|
222
|
+
continue;
|
|
223
|
+
const length = this.#header.readUInt32BE(0);
|
|
224
|
+
if (length === 0)
|
|
225
|
+
reject('invalid-frame', 'empty frames are invalid');
|
|
226
|
+
if (length > this.#maxBytes)
|
|
227
|
+
reject('frame-too-large', 'declared frame exceeds its byte limit');
|
|
228
|
+
this.#payload = Buffer.allocUnsafe(length);
|
|
229
|
+
this.#payloadOffset = 0;
|
|
230
|
+
}
|
|
231
|
+
const copied = input.copy(this.#payload, this.#payloadOffset, offset, Math.min(input.length, offset + this.#payload.length - this.#payloadOffset));
|
|
232
|
+
this.#payloadOffset += copied;
|
|
233
|
+
offset += copied;
|
|
234
|
+
if (this.#payloadOffset === this.#payload.length) {
|
|
235
|
+
output.push(parseCanonicalJson(this.#payload));
|
|
236
|
+
this.#frames++;
|
|
237
|
+
this.#headerOffset = 0;
|
|
238
|
+
this.#payload = undefined;
|
|
239
|
+
this.#payloadOffset = 0;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return output;
|
|
243
|
+
}
|
|
244
|
+
finish() {
|
|
245
|
+
if (this.#headerOffset !== 0 || this.#payload !== undefined)
|
|
246
|
+
reject('truncated-frame', 'connection ended during a frame');
|
|
247
|
+
}
|
|
248
|
+
get frameCount() { return this.#frames; }
|
|
249
|
+
}
|
|
250
|
+
function privateKey(value) {
|
|
251
|
+
let key;
|
|
252
|
+
try {
|
|
253
|
+
key = value instanceof KeyObject ? value : createPrivateKey(value);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
reject('invalid-key', 'client private key is invalid');
|
|
257
|
+
}
|
|
258
|
+
if (key.type !== 'private' || key.asymmetricKeyType !== 'ed25519')
|
|
259
|
+
reject('invalid-key', 'client private key must be Ed25519');
|
|
260
|
+
return key;
|
|
261
|
+
}
|
|
262
|
+
function publicKey(value) {
|
|
263
|
+
let key;
|
|
264
|
+
try {
|
|
265
|
+
if (value instanceof KeyObject && value.type === 'public')
|
|
266
|
+
key = value;
|
|
267
|
+
else
|
|
268
|
+
key = createPublicKey(value);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
reject('invalid-key', 'server public key is invalid');
|
|
272
|
+
}
|
|
273
|
+
if (key.type !== 'public' || key.asymmetricKeyType !== 'ed25519')
|
|
274
|
+
reject('invalid-key', 'server public key must be Ed25519');
|
|
275
|
+
return key;
|
|
276
|
+
}
|
|
277
|
+
function signingBytes(domain, value) { return Buffer.from(domain + canonicalBrokerJson(value), 'utf8'); }
|
|
278
|
+
function signatureFor(domain, value, key) { return sign(null, signingBytes(domain, value), privateKey(key)).toString('base64url'); }
|
|
279
|
+
function verifySignature(domain, value, signature, key) {
|
|
280
|
+
if (!verify(null, signingBytes(domain, value), publicKey(key), Buffer.from(signature, 'base64url')))
|
|
281
|
+
reject('invalid-signature', 'signature verification failed');
|
|
282
|
+
}
|
|
283
|
+
export function createBrokerServerHello(input, key) {
|
|
284
|
+
const unsigned = normalizeHelloUnsigned({ protocol: GITHUB_BROKER_PROTOCOL, type: 'server-hello', instanceId: input.instanceId, generation: input.generation, policyEpoch: input.policyEpoch, emergencyEpoch: input.emergencyEpoch, challenge: input.challenge ?? randomBytes(32).toString('base64url'), expiresAt: input.expiresAt });
|
|
285
|
+
return Object.freeze({ ...unsigned, signature: signatureFor(HELLO_DOMAIN, unsigned, key) });
|
|
286
|
+
}
|
|
287
|
+
function normalizeHelloUnsigned(value) {
|
|
288
|
+
const input = exact(value, ['protocol', 'type', 'instanceId', 'generation', 'policyEpoch', 'emergencyEpoch', 'challenge', 'expiresAt'], 'server hello');
|
|
289
|
+
if (input.protocol !== GITHUB_BROKER_PROTOCOL || input.type !== 'server-hello')
|
|
290
|
+
reject('wrong-protocol', 'server hello protocol is invalid');
|
|
291
|
+
return Object.freeze({ protocol: GITHUB_BROKER_PROTOCOL, type: 'server-hello', instanceId: instance(input.instanceId, 'hello.instanceId'), generation: integer(input.generation, 'hello.generation', 1), policyEpoch: integer(input.policyEpoch, 'hello.policyEpoch'), emergencyEpoch: integer(input.emergencyEpoch, 'hello.emergencyEpoch'), challenge: base64url(input.challenge, 'hello.challenge', 32), expiresAt: integer(input.expiresAt, 'hello.expiresAt', 1) });
|
|
292
|
+
}
|
|
293
|
+
export function verifyBrokerServerHello(value, key, options = {}) {
|
|
294
|
+
const input = exact(value, ['protocol', 'type', 'instanceId', 'generation', 'policyEpoch', 'emergencyEpoch', 'challenge', 'expiresAt', 'signature'], 'server hello');
|
|
295
|
+
const { signature: rawSignature, ...rawUnsigned } = input;
|
|
296
|
+
const unsigned = normalizeHelloUnsigned(rawUnsigned);
|
|
297
|
+
const signature = base64url(rawSignature, 'hello.signature', 64);
|
|
298
|
+
const now = options.now ?? Date.now(), maxTtlMs = options.maxTtlMs ?? GITHUB_BROKER_DEFAULT_HELLO_TTL_MS;
|
|
299
|
+
if (!Number.isSafeInteger(now) || !Number.isSafeInteger(maxTtlMs) || maxTtlMs < 1 || unsigned.expiresAt <= now || unsigned.expiresAt - now > maxTtlMs)
|
|
300
|
+
reject('expired-hello', 'server hello is expired or has an excessive lifetime');
|
|
301
|
+
if (options.expectedInstanceId !== undefined && unsigned.instanceId !== options.expectedInstanceId)
|
|
302
|
+
reject('server-identity-mismatch', 'server instance does not match');
|
|
303
|
+
if (options.minimumGeneration !== undefined && unsigned.generation < options.minimumGeneration)
|
|
304
|
+
reject('server-identity-mismatch', 'server generation regressed');
|
|
305
|
+
verifySignature(HELLO_DOMAIN, unsigned, signature, key);
|
|
306
|
+
return Object.freeze({ ...unsigned, signature });
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Canonical digest of the broker-authoritative grant, including fields that
|
|
310
|
+
* remain private to the broker. Host projections consume this issued digest;
|
|
311
|
+
* they must never attempt to reconstruct it from a reduced mirror.
|
|
312
|
+
*/
|
|
313
|
+
export function normalizeBrokerGrantAuthority(value) {
|
|
314
|
+
const input = exactOptional(value, ['protocol', 'id', 'revision', 'clientKeyId', 'owner', 'sessionId', 'destination', 'credentialId', 'expiresAt', 'maxActions', 'maxTotalBytes', 'maxCostUnits', 'allowedOperations', 'allowedInspectKinds', 'client', 'source', 'policyEpoch', 'emergencyEpoch'], ['verifiedDelivery'], 'broker grant');
|
|
315
|
+
if (input.protocol !== 'assistant-actions/external-github-grant/v1')
|
|
316
|
+
reject('wrong-protocol', 'broker grant protocol is invalid');
|
|
317
|
+
const normalizedOwner = owner(input.owner);
|
|
318
|
+
if (!isAbsolute(normalizedOwner.workspace) || resolve(normalizedOwner.workspace) !== normalizedOwner.workspace || normalizedOwner.workspace === '/')
|
|
319
|
+
reject('invalid-message', 'broker grant workspace is invalid');
|
|
320
|
+
const rawDestination = grantDestination(input.destination, 'broker grant destination');
|
|
321
|
+
const normalizedDestination = destination({ classification: rawDestination.classification, repository: rawDestination.repository, branch: rawDestination.branch, ...(rawDestination.baseBranch === undefined ? {} : { baseBranch: rawDestination.baseBranch }) });
|
|
322
|
+
if (!Array.isArray(rawDestination.paths) || rawDestination.paths.length < 1 || rawDestination.paths.length > 128 || !rawDestination.paths.every(validPath) || new Set(rawDestination.paths).size !== rawDestination.paths.length)
|
|
323
|
+
reject('invalid-message', 'broker grant paths are invalid');
|
|
324
|
+
if (!Array.isArray(input.allowedOperations) || input.allowedOperations.length < 1 || input.allowedOperations.length > 3 || input.allowedOperations.some(value => !['commit', 'inspect', 'pull-request'].includes(String(value))) || new Set(input.allowedOperations).size !== input.allowedOperations.length)
|
|
325
|
+
reject('invalid-message', 'broker grant operations are invalid');
|
|
326
|
+
if (!Array.isArray(input.allowedInspectKinds) || input.allowedInspectKinds.length > 6 || input.allowedInspectKinds.some(value => !['repository', 'branch', 'file', 'pull-request', 'checks', 'reviews'].includes(String(value))) || new Set(input.allowedInspectKinds).size !== input.allowedInspectKinds.length
|
|
327
|
+
|| input.allowedOperations.includes('inspect') !== (input.allowedInspectKinds.length > 0))
|
|
328
|
+
reject('invalid-message', 'broker grant inspect kinds are invalid');
|
|
329
|
+
if (input.allowedInspectKinds.some(kind => ['pull-request', 'checks', 'reviews'].includes(String(kind)))
|
|
330
|
+
&& (normalizedDestination.baseBranch === undefined || normalizedDestination.baseBranch === normalizedDestination.branch))
|
|
331
|
+
reject('invalid-message', 'broker grant base branch is required for pull request inspection');
|
|
332
|
+
if (input.allowedOperations.includes('pull-request') && (normalizedDestination.baseBranch === undefined || normalizedDestination.baseBranch === normalizedDestination.branch))
|
|
333
|
+
reject('invalid-message', 'broker grant base branch is required for pull request delivery');
|
|
334
|
+
const verifiedDelivery = input.verifiedDelivery === undefined ? undefined : deliveryMetadata(input.verifiedDelivery, 'broker grant verified delivery');
|
|
335
|
+
if (verifiedDelivery !== undefined && !input.allowedOperations.includes('commit'))
|
|
336
|
+
reject('invalid-message', 'broker grant verified delivery requires commit authority');
|
|
337
|
+
return Object.freeze({
|
|
338
|
+
protocol: 'assistant-actions/external-github-grant/v1', id: text(input.id, 'broker grant id'), revision: integer(input.revision, 'broker grant revision', 1), clientKeyId: instance(input.clientKeyId, 'broker grant clientKeyId'), owner: normalizedOwner, sessionId: text(input.sessionId, 'broker grant sessionId'),
|
|
339
|
+
destination: Object.freeze({ ...normalizedDestination, paths: Object.freeze([...rawDestination.paths]) }), credentialId: instance(input.credentialId, 'broker grant credentialId'), expiresAt: integer(input.expiresAt, 'broker grant expiresAt', 1),
|
|
340
|
+
maxActions: integer(input.maxActions, 'broker grant maxActions', 1, 10_000), maxTotalBytes: integer(input.maxTotalBytes, 'broker grant maxTotalBytes', 1, 64 * 1024 * 1024), maxCostUnits: integer(input.maxCostUnits, 'broker grant maxCostUnits'),
|
|
341
|
+
allowedOperations: Object.freeze([...input.allowedOperations]), allowedInspectKinds: Object.freeze([...input.allowedInspectKinds]), ...(verifiedDelivery === undefined ? {} : { verifiedDelivery }),
|
|
342
|
+
client: endpoint(input.client, 'assistant-actions-host', 'broker grant client'), source: dataSource(input.source), policyEpoch: integer(input.policyEpoch, 'broker grant policyEpoch'), emergencyEpoch: integer(input.emergencyEpoch, 'broker grant emergencyEpoch'),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
export function brokerGrantAuthorityDigest(grant) {
|
|
346
|
+
return brokerDigest(normalizeBrokerGrantAuthority(grant));
|
|
347
|
+
}
|
|
348
|
+
export function createBrokerGrantProjection(grant) {
|
|
349
|
+
const value = normalizeBrokerGrantAuthority(grant);
|
|
350
|
+
return Object.freeze({ id: value.id, revision: value.revision, grantDigest: brokerGrantAuthorityDigest(value), owner: value.owner, sessionId: value.sessionId, destination: value.destination, expiresAt: value.expiresAt, maxActions: value.maxActions, maxTotalBytes: value.maxTotalBytes, source: value.source, maxCostUnits: value.maxCostUnits, allowedOperations: value.allowedOperations, allowedInspectKinds: value.allowedInspectKinds, ...(value.verifiedDelivery === undefined ? {} : { verifiedDelivery: value.verifiedDelivery }) });
|
|
351
|
+
}
|
|
352
|
+
export function normalizeBrokerGrantProjection(value) {
|
|
353
|
+
const input = exactOptional(value, ['id', 'revision', 'grantDigest', 'owner', 'sessionId', 'destination', 'expiresAt', 'maxActions', 'maxTotalBytes', 'source', 'maxCostUnits', 'allowedOperations', 'allowedInspectKinds'], ['verifiedDelivery'], 'broker grant projection');
|
|
354
|
+
const rawDestination = grantDestination(input.destination, 'broker grant projection destination');
|
|
355
|
+
const normalizedDestination = destination({ classification: rawDestination.classification, repository: rawDestination.repository, branch: rawDestination.branch, ...(rawDestination.baseBranch === undefined ? {} : { baseBranch: rawDestination.baseBranch }) });
|
|
356
|
+
if (!Array.isArray(rawDestination.paths) || rawDestination.paths.length < 1 || rawDestination.paths.length > 128 || !rawDestination.paths.every(validPath) || new Set(rawDestination.paths).size !== rawDestination.paths.length)
|
|
357
|
+
reject('invalid-message', 'broker grant projection paths are invalid');
|
|
358
|
+
if (!Array.isArray(input.allowedOperations) || input.allowedOperations.length < 1 || input.allowedOperations.length > 3 || input.allowedOperations.some(value => !['commit', 'inspect', 'pull-request'].includes(String(value))) || new Set(input.allowedOperations).size !== input.allowedOperations.length)
|
|
359
|
+
reject('invalid-message', 'broker grant projection operations are invalid');
|
|
360
|
+
if (!Array.isArray(input.allowedInspectKinds) || input.allowedInspectKinds.length > 6 || input.allowedInspectKinds.some(value => !['repository', 'branch', 'file', 'pull-request', 'checks', 'reviews'].includes(String(value))) || new Set(input.allowedInspectKinds).size !== input.allowedInspectKinds.length
|
|
361
|
+
|| input.allowedOperations.includes('inspect') !== (input.allowedInspectKinds.length > 0))
|
|
362
|
+
reject('invalid-message', 'broker grant projection inspect kinds are invalid');
|
|
363
|
+
if (input.allowedInspectKinds.some(kind => ['pull-request', 'checks', 'reviews'].includes(String(kind)))
|
|
364
|
+
&& (normalizedDestination.baseBranch === undefined || normalizedDestination.baseBranch === normalizedDestination.branch))
|
|
365
|
+
reject('invalid-message', 'broker grant projection base branch is required for pull request inspection');
|
|
366
|
+
if (input.allowedOperations.includes('pull-request') && (normalizedDestination.baseBranch === undefined || normalizedDestination.baseBranch === normalizedDestination.branch))
|
|
367
|
+
reject('invalid-message', 'broker grant projection base branch is required for pull request delivery');
|
|
368
|
+
const verifiedDelivery = input.verifiedDelivery === undefined ? undefined : deliveryMetadata(input.verifiedDelivery, 'broker grant projection verified delivery');
|
|
369
|
+
if (verifiedDelivery !== undefined && !input.allowedOperations.includes('commit'))
|
|
370
|
+
reject('invalid-message', 'broker grant projection verified delivery requires commit authority');
|
|
371
|
+
const normalizedOwner = owner(input.owner);
|
|
372
|
+
if (!isAbsolute(normalizedOwner.workspace) || resolve(normalizedOwner.workspace) !== normalizedOwner.workspace || normalizedOwner.workspace === '/')
|
|
373
|
+
reject('invalid-message', 'broker grant projection workspace is invalid');
|
|
374
|
+
return Object.freeze({ id: text(input.id, 'broker grant projection id'), revision: integer(input.revision, 'broker grant projection revision', 1), grantDigest: digest(input.grantDigest, 'broker grant projection digest'), owner: normalizedOwner, sessionId: text(input.sessionId, 'broker grant projection sessionId'), destination: Object.freeze({ ...normalizedDestination, paths: Object.freeze([...rawDestination.paths]) }), expiresAt: integer(input.expiresAt, 'broker grant projection expiresAt', 1), maxActions: integer(input.maxActions, 'broker grant projection maxActions', 1, 10_000), maxTotalBytes: integer(input.maxTotalBytes, 'broker grant projection maxTotalBytes', 1, 64 * 1024 * 1024), source: dataSource(input.source), maxCostUnits: integer(input.maxCostUnits, 'broker grant projection maxCostUnits'), allowedOperations: Object.freeze([...input.allowedOperations]), allowedInspectKinds: Object.freeze([...input.allowedInspectKinds]), ...(verifiedDelivery === undefined ? {} : { verifiedDelivery }) });
|
|
375
|
+
}
|
|
376
|
+
function validPath(value) {
|
|
377
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 1024 && !/[\\\p{Cc}]/u.test(value)
|
|
378
|
+
&& value.split('/').every(part => part !== '' && part !== '.' && part !== '..' && part.toLowerCase() !== '.git');
|
|
379
|
+
}
|
|
380
|
+
function deliveryMetadata(value, label) {
|
|
381
|
+
const input = exactOptional(value, ['ownerRouteId', 'budgetId'], ['acceptance'], label);
|
|
382
|
+
if (input.acceptance !== undefined && input.acceptance !== 'goal-outcome' && input.acceptance !== 'goal-step')
|
|
383
|
+
reject('invalid-message', `${label} acceptance is invalid`);
|
|
384
|
+
return Object.freeze(input.acceptance === undefined ? { ownerRouteId: text(input.ownerRouteId, `${label} owner route`, 200), budgetId: text(input.budgetId, `${label} budget`, 200) } : { ownerRouteId: text(input.ownerRouteId, `${label} owner route`, 200), budgetId: text(input.budgetId, `${label} budget`, 200), acceptance: input.acceptance });
|
|
385
|
+
}
|
|
386
|
+
function dataSource(value) {
|
|
387
|
+
const input = exact(value, ['classification', 'provenanceDigest'], 'request.source');
|
|
388
|
+
if (!['public', 'internal', 'confidential', 'restricted'].includes(String(input.classification)))
|
|
389
|
+
reject('invalid-message', 'request source classification is invalid');
|
|
390
|
+
return Object.freeze({ classification: input.classification, provenanceDigest: digest(input.provenanceDigest, 'request.source.provenanceDigest') });
|
|
391
|
+
}
|
|
392
|
+
function validBranch(value, label) {
|
|
393
|
+
const branch = text(value, label, 255);
|
|
394
|
+
if (!BRANCH.test(branch) || branch.includes('..') || branch.startsWith('refs/') || branch.split('/').some(part => !part || part.startsWith('.') || part.endsWith('.') || part.endsWith('.lock')))
|
|
395
|
+
reject('invalid-message', `${label} is invalid`);
|
|
396
|
+
return branch;
|
|
397
|
+
}
|
|
398
|
+
function grantDestination(value, label) {
|
|
399
|
+
const input = record(value, label);
|
|
400
|
+
if (Object.keys(input).some(key => !['classification', 'repository', 'branch', 'baseBranch', 'paths'].includes(key))
|
|
401
|
+
|| !Object.hasOwn(input, 'classification') || !Object.hasOwn(input, 'repository') || !Object.hasOwn(input, 'branch') || !Object.hasOwn(input, 'paths'))
|
|
402
|
+
reject('unknown-field', `${label} has unknown or missing fields`);
|
|
403
|
+
return input;
|
|
404
|
+
}
|
|
405
|
+
function destination(value) {
|
|
406
|
+
const input = record(value, 'request.destination');
|
|
407
|
+
if (Object.keys(input).some(key => !['classification', 'repository', 'branch', 'baseBranch'].includes(key))
|
|
408
|
+
|| !Object.hasOwn(input, 'classification') || !Object.hasOwn(input, 'repository') || !Object.hasOwn(input, 'branch'))
|
|
409
|
+
reject('unknown-field', 'request.destination has unknown or missing fields');
|
|
410
|
+
if (input.classification !== 'github-repository' || typeof input.repository !== 'string' || input.repository.length > 256 || !REPOSITORY.test(input.repository))
|
|
411
|
+
reject('invalid-message', 'request destination repository is invalid');
|
|
412
|
+
const branch = validBranch(input.branch, 'request.destination.branch');
|
|
413
|
+
const baseBranch = input.baseBranch === undefined ? undefined : validBranch(input.baseBranch, 'request.destination.baseBranch');
|
|
414
|
+
return Object.freeze(baseBranch === undefined ? { classification: 'github-repository', repository: input.repository, branch } : { classification: 'github-repository', repository: input.repository, branch, baseBranch });
|
|
415
|
+
}
|
|
416
|
+
function endpoint(value, kind, label) {
|
|
417
|
+
const input = exact(value, ['kind', 'instanceId', 'generation'], label);
|
|
418
|
+
if (input.kind !== kind)
|
|
419
|
+
reject('invalid-message', `${label}.kind is invalid`);
|
|
420
|
+
return Object.freeze({ kind, instanceId: instance(input.instanceId, `${label}.instanceId`), generation: integer(input.generation, `${label}.generation`, 1) });
|
|
421
|
+
}
|
|
422
|
+
function budget(value) {
|
|
423
|
+
const input = exact(value, ['reservationId', 'actions', 'bytes', 'costMetric', 'maxCostUnits'], 'request.budget');
|
|
424
|
+
if (input.costMetric !== 'github-api-units')
|
|
425
|
+
reject('invalid-message', 'request budget cost metric is invalid');
|
|
426
|
+
return Object.freeze({ reservationId: text(input.reservationId, 'request.budget.reservationId'), actions: integer(input.actions, 'request.budget.actions', 1), bytes: integer(input.bytes, 'request.budget.bytes', 0), costMetric: 'github-api-units', maxCostUnits: integer(input.maxCostUnits, 'request.budget.maxCostUnits') });
|
|
427
|
+
}
|
|
428
|
+
function owner(value) {
|
|
429
|
+
const input = exact(value, ['principalDigest', 'principalRecordId', 'principalVersion', 'workspace', 'preset', 'bindingId', 'bindingVersion', 'bindingGeneration'], 'request.owner');
|
|
430
|
+
const workspace = text(input.workspace, 'request.owner.workspace', 4096);
|
|
431
|
+
if (!isAbsolute(workspace) || resolve(workspace) !== workspace || workspace === '/')
|
|
432
|
+
reject('invalid-message', 'request owner workspace is invalid');
|
|
433
|
+
return Object.freeze({ principalDigest: digest(input.principalDigest, 'request.owner.principalDigest'), principalRecordId: text(input.principalRecordId, 'request.owner.principalRecordId'), principalVersion: integer(input.principalVersion, 'request.owner.principalVersion', 1), workspace, preset: text(input.preset, 'request.owner.preset'), bindingId: text(input.bindingId, 'request.owner.bindingId'), bindingVersion: integer(input.bindingVersion, 'request.owner.bindingVersion', 1), bindingGeneration: integer(input.bindingGeneration, 'request.owner.bindingGeneration', 1) });
|
|
434
|
+
}
|
|
435
|
+
function commitPayload(value) {
|
|
436
|
+
const input = exact(value, ['expectedHeadOid', 'headline', 'files'], 'commit payload');
|
|
437
|
+
if (!Array.isArray(input.files) || input.files.length < 1 || input.files.length > 32)
|
|
438
|
+
reject('invalid-message', 'commit payload files are invalid');
|
|
439
|
+
const files = Object.freeze(input.files.map((value, index) => {
|
|
440
|
+
const file = exact(value, ['path', 'content'], `commit payload file ${index}`);
|
|
441
|
+
if (!validPath(file.path) || typeof file.content !== 'string' || Buffer.byteLength(file.content) > 1_048_576 || Buffer.from(file.content, 'utf8').toString('utf8') !== file.content)
|
|
442
|
+
reject('invalid-message', 'commit payload file is invalid');
|
|
443
|
+
return Object.freeze({ path: file.path, content: file.content });
|
|
444
|
+
}));
|
|
445
|
+
if (new Set(files.map(file => file.path)).size !== files.length)
|
|
446
|
+
reject('invalid-message', 'commit payload paths are not unique');
|
|
447
|
+
return Object.freeze({ expectedHeadOid: oid(input.expectedHeadOid, 'commit payload expectedHeadOid'), headline: text(input.headline, 'commit payload headline', 200), files });
|
|
448
|
+
}
|
|
449
|
+
function pullRequestPayload(value) {
|
|
450
|
+
const input = exact(value, ['expectedHeadOid', 'title', 'body'], 'pull request payload');
|
|
451
|
+
if (typeof input.body !== 'string' || Buffer.byteLength(input.body) > 65_536 || Buffer.from(input.body, 'utf8').toString('utf8') !== input.body)
|
|
452
|
+
reject('invalid-message', 'pull request payload body is invalid');
|
|
453
|
+
return Object.freeze({ expectedHeadOid: oid(input.expectedHeadOid, 'pull request payload expectedHeadOid'), title: text(input.title, 'pull request payload title', 200), body: input.body });
|
|
454
|
+
}
|
|
455
|
+
function inspectPayload(value) {
|
|
456
|
+
const input = record(value, 'inspect payload');
|
|
457
|
+
const kind = input.kind;
|
|
458
|
+
if (!['repository', 'branch', 'file', 'pull-request', 'checks', 'reviews'].includes(String(kind)))
|
|
459
|
+
reject('invalid-message', 'inspect kind is invalid');
|
|
460
|
+
const expected = kind === 'file' ? ['kind', 'path'] : ['pull-request', 'checks', 'reviews'].includes(String(kind)) ? ['kind', 'pullRequestNumber'] : ['kind'];
|
|
461
|
+
exact(input, expected, 'inspect payload');
|
|
462
|
+
if (kind === 'file') {
|
|
463
|
+
if (!validPath(input.path))
|
|
464
|
+
reject('invalid-message', 'inspect path is invalid');
|
|
465
|
+
return Object.freeze({ kind, path: input.path });
|
|
466
|
+
}
|
|
467
|
+
if (expected.length === 2)
|
|
468
|
+
return Object.freeze({ kind, pullRequestNumber: integer(input.pullRequestNumber, 'inspect pullRequestNumber', 1) });
|
|
469
|
+
return Object.freeze({ kind });
|
|
470
|
+
}
|
|
471
|
+
function normalizePayload(operation, value) {
|
|
472
|
+
if (operation === 'commit')
|
|
473
|
+
return commitPayload(value);
|
|
474
|
+
if (operation === 'inspect')
|
|
475
|
+
return inspectPayload(value);
|
|
476
|
+
if (operation === 'pull-request')
|
|
477
|
+
return pullRequestPayload(value);
|
|
478
|
+
reject('invalid-message', 'request operation is invalid');
|
|
479
|
+
}
|
|
480
|
+
const REQUEST_UNSIGNED_KEYS = ['protocol', 'type', 'requestId', 'challenge', 'client', 'broker', 'actionId', 'grantId', 'grantRevision', 'grantDigest', 'owner', 'sessionId', 'agentId', 'rootCallId', 'callId', 'operation', 'source', 'destination', 'payload', 'payloadDigest', 'deadline', 'policyEpoch', 'emergencyEpoch', 'budget', 'clientKeyId'];
|
|
481
|
+
function normalizeRequestUnsigned(value) {
|
|
482
|
+
const input = exact(value, REQUEST_UNSIGNED_KEYS, 'client request');
|
|
483
|
+
if (input.protocol !== GITHUB_BROKER_PROTOCOL || input.type !== 'client-request')
|
|
484
|
+
reject('wrong-protocol', 'client request protocol is invalid');
|
|
485
|
+
const operation = input.operation;
|
|
486
|
+
const payload = normalizePayload(operation, input.payload);
|
|
487
|
+
const normalizedDestination = destination(input.destination);
|
|
488
|
+
if ((operation === 'inspect' && ['pull-request', 'checks', 'reviews'].includes(payload.kind) || operation === 'pull-request')
|
|
489
|
+
&& (normalizedDestination.baseBranch === undefined || normalizedDestination.baseBranch === normalizedDestination.branch))
|
|
490
|
+
reject('invalid-message', 'request base branch is required for pull request inspection');
|
|
491
|
+
const payloadDigest = digest(input.payloadDigest, 'request.payloadDigest');
|
|
492
|
+
if (brokerDigest(payload) !== payloadDigest)
|
|
493
|
+
reject('digest-mismatch', 'request payload digest does not match');
|
|
494
|
+
return Object.freeze({
|
|
495
|
+
protocol: GITHUB_BROKER_PROTOCOL, type: 'client-request', requestId: text(input.requestId, 'request.requestId'), challenge: base64url(input.challenge, 'request.challenge', 32),
|
|
496
|
+
client: endpoint(input.client, 'assistant-actions-host', 'request.client'), broker: endpoint(input.broker, 'github-broker', 'request.broker'),
|
|
497
|
+
actionId: text(input.actionId, 'request.actionId'), grantId: text(input.grantId, 'request.grantId'), grantRevision: integer(input.grantRevision, 'request.grantRevision', 1), grantDigest: digest(input.grantDigest, 'request.grantDigest'),
|
|
498
|
+
owner: owner(input.owner), sessionId: text(input.sessionId, 'request.sessionId'), agentId: text(input.agentId, 'request.agentId'), rootCallId: text(input.rootCallId, 'request.rootCallId'), callId: text(input.callId, 'request.callId'), operation: operation, source: dataSource(input.source), destination: normalizedDestination, payload, payloadDigest,
|
|
499
|
+
deadline: integer(input.deadline, 'request.deadline', 1), policyEpoch: integer(input.policyEpoch, 'request.policyEpoch'), emergencyEpoch: integer(input.emergencyEpoch, 'request.emergencyEpoch'), budget: budget(input.budget), clientKeyId: instance(input.clientKeyId, 'request.clientKeyId'),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
export function createBrokerClientRequest(input, hello, source, clientKeyId, key, requestId = randomUUID()) {
|
|
503
|
+
const unsigned = normalizeRequestUnsigned({ protocol: GITHUB_BROKER_PROTOCOL, type: 'client-request', requestId, challenge: hello.challenge, client: source, broker: { kind: 'github-broker', instanceId: hello.instanceId, generation: hello.generation }, ...input, payloadDigest: brokerDigest(input.payload), policyEpoch: hello.policyEpoch, emergencyEpoch: hello.emergencyEpoch, clientKeyId });
|
|
504
|
+
return Object.freeze({ ...unsigned, signature: signatureFor(REQUEST_DOMAIN, unsigned, key) });
|
|
505
|
+
}
|
|
506
|
+
export function verifyBrokerClientRequest(value, hello, key, options = {}) {
|
|
507
|
+
const input = exact(value, [...REQUEST_UNSIGNED_KEYS, 'signature'], 'client request');
|
|
508
|
+
const { signature: rawSignature, ...rawUnsigned } = input;
|
|
509
|
+
const unsigned = normalizeRequestUnsigned(rawUnsigned);
|
|
510
|
+
const signature = base64url(rawSignature, 'request.signature', 64);
|
|
511
|
+
const now = options.now ?? Date.now();
|
|
512
|
+
if (hello.expiresAt <= now)
|
|
513
|
+
reject('expired-hello', 'server hello expired before client request verification');
|
|
514
|
+
if (unsigned.deadline <= now)
|
|
515
|
+
reject('expired-request', 'client request deadline expired');
|
|
516
|
+
if (unsigned.challenge !== hello.challenge || unsigned.broker.instanceId !== hello.instanceId || unsigned.broker.generation !== hello.generation
|
|
517
|
+
|| unsigned.policyEpoch !== hello.policyEpoch || unsigned.emergencyEpoch !== hello.emergencyEpoch)
|
|
518
|
+
reject('challenge-mismatch', 'client request is not bound to this server hello');
|
|
519
|
+
if (options.expectedClientKeyId !== undefined && unsigned.clientKeyId !== options.expectedClientKeyId)
|
|
520
|
+
reject('client-identity-mismatch', 'client key identity does not match');
|
|
521
|
+
verifySignature(REQUEST_DOMAIN, unsigned, signature, key);
|
|
522
|
+
return Object.freeze({ ...unsigned, signature });
|
|
523
|
+
}
|
|
524
|
+
/** Stable action semantics used by the durable actionId idempotency ledger. */
|
|
525
|
+
export function brokerRequestDigest(request) {
|
|
526
|
+
return brokerDigest({
|
|
527
|
+
protocol: 'assistant-actions/github-broker-intent/v1', actionId: request.actionId, grantId: request.grantId, grantRevision: request.grantRevision, grantDigest: request.grantDigest,
|
|
528
|
+
owner: request.owner, sessionId: request.sessionId, operation: request.operation, source: request.source, destination: request.destination,
|
|
529
|
+
payload: request.payload, payloadDigest: request.payloadDigest, budget: request.budget, clientKeyId: request.clientKeyId,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
/** Per-connection digest used only to bind a response to its exact signed request. */
|
|
533
|
+
export function brokerWireRequestDigest(request) {
|
|
534
|
+
const { signature: _signature, ...unsigned } = request;
|
|
535
|
+
return brokerDigest(unsigned);
|
|
536
|
+
}
|
|
537
|
+
function successResult(value, request) {
|
|
538
|
+
const input = record(value, 'response result');
|
|
539
|
+
if (request.operation === 'commit') {
|
|
540
|
+
const result = exact(input, ['operation', 'repository', 'branch', 'parentOid', 'commitOid'], 'commit response result');
|
|
541
|
+
if (result.operation !== 'commit' || result.repository !== request.destination.repository || result.branch !== request.destination.branch)
|
|
542
|
+
reject('response-mismatch', 'commit response target does not match');
|
|
543
|
+
const payload = request.payload;
|
|
544
|
+
if (result.parentOid !== payload.expectedHeadOid)
|
|
545
|
+
reject('response-mismatch', 'commit response parent does not match');
|
|
546
|
+
return Object.freeze({ operation: 'commit', repository: result.repository, branch: result.branch, parentOid: oid(result.parentOid, 'response.parentOid'), commitOid: oid(result.commitOid, 'response.commitOid') });
|
|
547
|
+
}
|
|
548
|
+
if (request.operation === 'pull-request') {
|
|
549
|
+
const result = exact(input, ['operation', 'repository', 'branch', 'baseBranch', 'expectedHeadOid', 'pullRequestNumber'], 'pull request response result');
|
|
550
|
+
if (result.operation !== 'pull-request' || result.repository !== request.destination.repository || result.branch !== request.destination.branch || result.baseBranch !== request.destination.baseBranch)
|
|
551
|
+
reject('response-mismatch', 'pull request response target does not match');
|
|
552
|
+
const payload = request.payload;
|
|
553
|
+
if (result.expectedHeadOid !== payload.expectedHeadOid)
|
|
554
|
+
reject('response-mismatch', 'pull request response expected head does not match');
|
|
555
|
+
return Object.freeze({ operation: 'pull-request', repository: result.repository, branch: result.branch, baseBranch: validBranch(result.baseBranch, 'response.baseBranch'), expectedHeadOid: oid(result.expectedHeadOid, 'response.expectedHeadOid'), pullRequestNumber: integer(result.pullRequestNumber, 'response.pullRequestNumber', 1) });
|
|
556
|
+
}
|
|
557
|
+
const result = exact(input, ['operation', 'repository', 'branch', 'kind', 'observed', 'observedDigest'], 'inspect response result');
|
|
558
|
+
const payload = request.payload;
|
|
559
|
+
if (result.operation !== 'inspect' || result.repository !== request.destination.repository || result.branch !== request.destination.branch || result.kind !== payload.kind)
|
|
560
|
+
reject('response-mismatch', 'inspect response target does not match');
|
|
561
|
+
const observed = normalizeBrokerInspectObservation(payload, result.observed, request.destination);
|
|
562
|
+
const observedCanonical = canonicalBrokerJson(observed);
|
|
563
|
+
if (containsCredentialShapedValue(result.observed))
|
|
564
|
+
reject('secret-field', 'credential-shaped response value is forbidden');
|
|
565
|
+
const observedDigest = digest(result.observedDigest, 'response.observedDigest');
|
|
566
|
+
if (createHash('sha256').update(observedCanonical).digest('hex') !== observedDigest)
|
|
567
|
+
reject('digest-mismatch', 'response observation digest does not match');
|
|
568
|
+
return Object.freeze({ operation: 'inspect', repository: result.repository, branch: result.branch, kind: result.kind, observed, observedDigest });
|
|
569
|
+
}
|
|
570
|
+
function containsCredentialShapedValue(value) {
|
|
571
|
+
if (typeof value === 'string')
|
|
572
|
+
return SENSITIVE_VALUE.test(value);
|
|
573
|
+
if (Array.isArray(value))
|
|
574
|
+
return value.some(containsCredentialShapedValue);
|
|
575
|
+
if (value !== null && typeof value === 'object')
|
|
576
|
+
return Object.values(value).some(containsCredentialShapedValue);
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
function exactOptional(value, required, optional, label) {
|
|
580
|
+
const input = record(value, label), keys = Object.keys(input);
|
|
581
|
+
if (keys.length < required.length || keys.length > required.length + optional.length || required.some(key => !Object.hasOwn(input, key)) || keys.some(key => !required.includes(key) && !optional.includes(key)))
|
|
582
|
+
reject('unknown-field', `${label} has unknown or missing fields`);
|
|
583
|
+
return input;
|
|
584
|
+
}
|
|
585
|
+
function truth(value, label) { if (value !== true)
|
|
586
|
+
reject('invalid-message', `${label} must be true`); return true; }
|
|
587
|
+
function boolean(value, label) { if (typeof value !== 'boolean')
|
|
588
|
+
reject('invalid-message', `${label} must be boolean`); return value; }
|
|
589
|
+
function pullRequestScope(value, destination, expectedNumber, topLevel = false) {
|
|
590
|
+
const input = exact(value, topLevel ? ['number', 'state', 'merged', 'head', 'base', 'untrusted'] : ['number', 'state', 'merged', 'head', 'base'], 'pull request observation');
|
|
591
|
+
const head = exact(input.head, ['ref', 'sha', 'repo'], 'pull request head'), headRepo = exact(head.repo, ['full_name'], 'pull request head repository');
|
|
592
|
+
const base = exact(input.base, ['ref', 'repo'], 'pull request base'), baseRepo = exact(base.repo, ['full_name'], 'pull request base repository');
|
|
593
|
+
const number = integer(input.number, 'pull request number', 1), state = input.state;
|
|
594
|
+
if (expectedNumber !== undefined && number !== expectedNumber || !['open', 'closed'].includes(String(state)) || head.ref !== destination.branch || headRepo.full_name !== destination.repository || baseRepo.full_name !== destination.repository || destination.baseBranch === undefined || base.ref !== destination.baseBranch || head.ref === base.ref)
|
|
595
|
+
reject('response-mismatch', 'pull request observation scope does not match');
|
|
596
|
+
const output = Object.freeze({ number, state: state, merged: boolean(input.merged, 'pull request merged'), head: Object.freeze({ ref: validBranch(head.ref, 'pull request head ref'), sha: oid(head.sha, 'pull request head sha'), repo: Object.freeze({ full_name: text(headRepo.full_name, 'pull request head repository', 256) }) }), base: Object.freeze({ ref: validBranch(base.ref, 'pull request base ref'), repo: Object.freeze({ full_name: text(baseRepo.full_name, 'pull request base repository', 256) }) }) });
|
|
597
|
+
return topLevel ? Object.freeze({ ...output, untrusted: truth(input.untrusted, 'pull request untrusted') }) : output;
|
|
598
|
+
}
|
|
599
|
+
export function normalizeBrokerInspectObservation(payload, value, destination) {
|
|
600
|
+
if (payload.kind === 'repository') {
|
|
601
|
+
const input = exact(value, ['full_name', 'untrusted'], 'repository observation');
|
|
602
|
+
if (input.full_name !== destination.repository)
|
|
603
|
+
reject('response-mismatch', 'repository observation does not match');
|
|
604
|
+
return Object.freeze({ full_name: text(input.full_name, 'repository full_name', 256), untrusted: truth(input.untrusted, 'repository untrusted') });
|
|
605
|
+
}
|
|
606
|
+
if (payload.kind === 'branch') {
|
|
607
|
+
const input = exact(value, ['name', 'commit', 'untrusted'], 'branch observation'), commit = exact(input.commit, ['sha'], 'branch commit');
|
|
608
|
+
if (input.name !== destination.branch)
|
|
609
|
+
reject('response-mismatch', 'branch observation does not match');
|
|
610
|
+
return Object.freeze({ name: text(input.name, 'branch name', 255), commit: Object.freeze({ sha: oid(commit.sha, 'branch commit sha') }), untrusted: truth(input.untrusted, 'branch untrusted') });
|
|
611
|
+
}
|
|
612
|
+
if (payload.kind === 'file') {
|
|
613
|
+
const input = exact(value, ['path', 'sha', 'content', 'untrusted'], 'file observation');
|
|
614
|
+
if (!validPath(input.path) || input.path !== payload.path || typeof input.content !== 'string' || Buffer.byteLength(input.content) > 65_536 || Buffer.from(input.content, 'utf8').toString('utf8') !== input.content)
|
|
615
|
+
reject('response-mismatch', 'file observation does not match');
|
|
616
|
+
return Object.freeze({ path: input.path, sha: oid(input.sha, 'file observation sha'), content: input.content, untrusted: truth(input.untrusted, 'file untrusted') });
|
|
617
|
+
}
|
|
618
|
+
if (payload.kind === 'pull-request')
|
|
619
|
+
return pullRequestScope(value, destination, payload.pullRequestNumber, true);
|
|
620
|
+
const input = exact(value, ['pullRequest', 'headOid', 'items', 'truncated', 'untrusted'], `${payload.kind} observation`);
|
|
621
|
+
const pullRequest = pullRequestScope(input.pullRequest, destination, payload.pullRequestNumber);
|
|
622
|
+
const headOid = oid(input.headOid, `${payload.kind} headOid`);
|
|
623
|
+
if (headOid !== pullRequest.head.sha || !Array.isArray(input.items))
|
|
624
|
+
reject('response-mismatch', `${payload.kind} observation head does not match`);
|
|
625
|
+
const truncated = boolean(input.truncated, `${payload.kind} truncated`), untrusted = truth(input.untrusted, `${payload.kind} untrusted`);
|
|
626
|
+
if (payload.kind === 'checks') {
|
|
627
|
+
if (input.items.length > 20)
|
|
628
|
+
reject('invalid-message', 'checks observation is too large');
|
|
629
|
+
const items = Object.freeze(input.items.map((value, index) => {
|
|
630
|
+
const item = exact(value, ['id', 'name', 'status', 'conclusion', 'head_sha', 'app'], `check ${index}`), app = exact(item.app, ['id'], `check ${index} app`);
|
|
631
|
+
if (!['queued', 'in_progress', 'completed', 'waiting', 'requested', 'pending'].includes(String(item.status)) || item.conclusion !== null && typeof item.conclusion !== 'string' || item.head_sha !== headOid)
|
|
632
|
+
reject('invalid-message', 'check observation is invalid');
|
|
633
|
+
return Object.freeze({ id: integer(item.id, 'check id', 1), name: text(item.name, 'check name'), status: item.status, conclusion: item.conclusion, head_sha: oid(item.head_sha, 'check head sha'), app: Object.freeze({ id: integer(app.id, 'check app id', 1) }) });
|
|
634
|
+
}));
|
|
635
|
+
return Object.freeze({ pullRequest, headOid, items, truncated, untrusted });
|
|
636
|
+
}
|
|
637
|
+
if (input.items.length > 30)
|
|
638
|
+
reject('invalid-message', 'reviews observation is too large');
|
|
639
|
+
const items = Object.freeze(input.items.map((value, index) => {
|
|
640
|
+
const item = exactOptional(value, ['id', 'state', 'commit_id', 'user'], ['submitted_at'], `review ${index}`), user = exact(item.user, ['id'], `review ${index} user`);
|
|
641
|
+
if (!['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING'].includes(String(item.state)) || item.submitted_at !== undefined && (typeof item.submitted_at !== 'string' || Number.isNaN(Date.parse(item.submitted_at))))
|
|
642
|
+
reject('invalid-message', 'review observation is invalid');
|
|
643
|
+
return Object.freeze({ id: integer(item.id, 'review id', 1), state: item.state, commit_id: oid(item.commit_id, 'review commit id'), user: Object.freeze({ id: integer(user.id, 'review user id', 1) }), ...(item.submitted_at === undefined ? {} : { submitted_at: item.submitted_at }) });
|
|
644
|
+
}));
|
|
645
|
+
return Object.freeze({ pullRequest, headOid, items, truncated, untrusted });
|
|
646
|
+
}
|
|
647
|
+
const RESPONSE_UNSIGNED_KEYS = ['protocol', 'type', 'requestId', 'actionId', 'instanceId', 'generation', 'challenge', 'requestDigest', 'status', 'dispatched', 'result', 'error', 'completedAt'];
|
|
648
|
+
function normalizeResponseUnsigned(value, request) {
|
|
649
|
+
const input = exact(value, RESPONSE_UNSIGNED_KEYS, 'server response');
|
|
650
|
+
if (input.protocol !== GITHUB_BROKER_PROTOCOL || input.type !== 'server-response' || !['succeeded', 'failed', 'unknown'].includes(String(input.status)) || typeof input.dispatched !== 'boolean')
|
|
651
|
+
reject('wrong-protocol', 'server response protocol is invalid');
|
|
652
|
+
const status = input.status;
|
|
653
|
+
let result = null, error = null;
|
|
654
|
+
if (status === 'succeeded') {
|
|
655
|
+
if (input.dispatched !== true || input.error !== null)
|
|
656
|
+
reject('invalid-message', 'successful response state is invalid');
|
|
657
|
+
result = successResult(input.result, request);
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
if (input.result !== null)
|
|
661
|
+
reject('invalid-message', 'non-success response must not carry a result');
|
|
662
|
+
const rawError = exact(input.error, ['code'], 'response error');
|
|
663
|
+
const code = text(rawError.code, 'response.error.code');
|
|
664
|
+
if (!ERROR_CODE.test(code))
|
|
665
|
+
reject('invalid-message', 'response error code is invalid');
|
|
666
|
+
error = Object.freeze({ code });
|
|
667
|
+
if (status === 'unknown' && input.dispatched !== true)
|
|
668
|
+
reject('invalid-message', 'unknown response must be post-dispatch');
|
|
669
|
+
}
|
|
670
|
+
return Object.freeze({ protocol: GITHUB_BROKER_PROTOCOL, type: 'server-response', requestId: text(input.requestId, 'response.requestId'), actionId: text(input.actionId, 'response.actionId'), instanceId: instance(input.instanceId, 'response.instanceId'), generation: integer(input.generation, 'response.generation', 1), challenge: base64url(input.challenge, 'response.challenge', 32), requestDigest: digest(input.requestDigest, 'response.requestDigest'), status, dispatched: input.dispatched, result, error, completedAt: integer(input.completedAt, 'response.completedAt', 1) });
|
|
671
|
+
}
|
|
672
|
+
export function createBrokerServerResponse(input, request, hello, key) {
|
|
673
|
+
const unsigned = normalizeResponseUnsigned({ protocol: GITHUB_BROKER_PROTOCOL, type: 'server-response', requestId: request.requestId, actionId: request.actionId, instanceId: hello.instanceId, generation: hello.generation, challenge: hello.challenge, requestDigest: brokerWireRequestDigest(request), ...input }, request);
|
|
674
|
+
return Object.freeze({ ...unsigned, signature: signatureFor(RESPONSE_DOMAIN, unsigned, key) });
|
|
675
|
+
}
|
|
676
|
+
export function verifyBrokerServerResponse(value, request, hello, key) {
|
|
677
|
+
const input = exact(value, [...RESPONSE_UNSIGNED_KEYS, 'signature'], 'server response');
|
|
678
|
+
const { signature: rawSignature, ...rawUnsigned } = input;
|
|
679
|
+
const unsigned = normalizeResponseUnsigned(rawUnsigned, request);
|
|
680
|
+
const signature = base64url(rawSignature, 'response.signature', 64);
|
|
681
|
+
if (unsigned.requestId !== request.requestId || unsigned.actionId !== request.actionId || unsigned.instanceId !== hello.instanceId || unsigned.generation !== hello.generation || unsigned.challenge !== hello.challenge || unsigned.requestDigest !== brokerWireRequestDigest(request))
|
|
682
|
+
reject('response-mismatch', 'server response is not bound to this request');
|
|
683
|
+
verifySignature(RESPONSE_DOMAIN, unsigned, signature, key);
|
|
684
|
+
return Object.freeze({ ...unsigned, signature });
|
|
685
|
+
}
|
|
686
|
+
function adminEndpoint(value) {
|
|
687
|
+
const input = exact(value, ['kind', 'instanceId', 'generation'], 'admin request source');
|
|
688
|
+
if (input.kind !== 'assistant-actions-admin')
|
|
689
|
+
reject('invalid-message', 'admin request source kind is invalid');
|
|
690
|
+
return Object.freeze({ kind: 'assistant-actions-admin', instanceId: instance(input.instanceId, 'admin source instanceId'), generation: integer(input.generation, 'admin source generation', 1) });
|
|
691
|
+
}
|
|
692
|
+
function adminBody(operation, value) {
|
|
693
|
+
if (operation === 'status') {
|
|
694
|
+
exact(value, [], 'status body');
|
|
695
|
+
return Object.freeze({});
|
|
696
|
+
}
|
|
697
|
+
if (operation === 'stop') {
|
|
698
|
+
const input = exact(value, ['expectedControlVersion', 'drainDeadline', 'reason'], 'stop body');
|
|
699
|
+
if (!['operator-request', 'security-response', 'maintenance'].includes(String(input.reason)))
|
|
700
|
+
reject('invalid-message', 'stop reason is invalid');
|
|
701
|
+
return Object.freeze({ expectedControlVersion: integer(input.expectedControlVersion, 'stop expectedControlVersion'), drainDeadline: integer(input.drainDeadline, 'stop drainDeadline', 1), reason: input.reason });
|
|
702
|
+
}
|
|
703
|
+
if (operation === 'resume') {
|
|
704
|
+
const input = exact(value, ['expectedControlVersion', 'expectedGeneration'], 'resume body');
|
|
705
|
+
return Object.freeze({ expectedControlVersion: integer(input.expectedControlVersion, 'resume expectedControlVersion'), expectedGeneration: integer(input.expectedGeneration, 'resume expectedGeneration', 1) });
|
|
706
|
+
}
|
|
707
|
+
if (operation === 'revoke') {
|
|
708
|
+
const input = exact(value, ['expectedControlVersion', 'grantId', 'grantRevision', 'grantDigest', 'policyEpoch', 'emergencyEpoch', 'reason'], 'revoke body');
|
|
709
|
+
if (!['operator-request', 'security-response', 'grant-replaced', 'grant-expired'].includes(String(input.reason)))
|
|
710
|
+
reject('invalid-message', 'revoke reason is invalid');
|
|
711
|
+
return Object.freeze({ expectedControlVersion: integer(input.expectedControlVersion, 'revoke expectedControlVersion'), grantId: text(input.grantId, 'revoke grantId'), grantRevision: integer(input.grantRevision, 'revoke grantRevision', 1), grantDigest: digest(input.grantDigest, 'revoke grantDigest'), policyEpoch: integer(input.policyEpoch, 'revoke policyEpoch'), emergencyEpoch: integer(input.emergencyEpoch, 'revoke emergencyEpoch'), reason: input.reason });
|
|
712
|
+
}
|
|
713
|
+
reject('invalid-message', 'admin operation is invalid');
|
|
714
|
+
}
|
|
715
|
+
const ADMIN_REQUEST_UNSIGNED_KEYS = ['protocol', 'type', 'requestId', 'nonce', 'challenge', 'source', 'destination', 'operation', 'body', 'deadline', 'adminKeyId'];
|
|
716
|
+
function normalizeAdminRequestUnsigned(value) {
|
|
717
|
+
const input = exact(value, ADMIN_REQUEST_UNSIGNED_KEYS, 'admin request');
|
|
718
|
+
if (input.protocol !== GITHUB_BROKER_PROTOCOL || input.type !== 'admin-request')
|
|
719
|
+
reject('wrong-protocol', 'admin request protocol is invalid');
|
|
720
|
+
const operation = input.operation;
|
|
721
|
+
if (!['status', 'stop', 'resume', 'revoke'].includes(String(operation)))
|
|
722
|
+
reject('invalid-message', 'admin operation is invalid');
|
|
723
|
+
return Object.freeze({ protocol: GITHUB_BROKER_PROTOCOL, type: 'admin-request', requestId: text(input.requestId, 'admin requestId'), nonce: base64url(input.nonce, 'admin nonce', 32), challenge: base64url(input.challenge, 'admin challenge', 32), source: adminEndpoint(input.source), destination: endpoint(input.destination, 'github-broker', 'admin destination'), operation: operation, body: adminBody(operation, input.body), deadline: integer(input.deadline, 'admin deadline', 1), adminKeyId: instance(input.adminKeyId, 'admin keyId') });
|
|
724
|
+
}
|
|
725
|
+
export function createBrokerAdminRequest(input, hello, source, adminKeyId, key, requestId = randomUUID(), nonce = randomBytes(32).toString('base64url')) {
|
|
726
|
+
const unsigned = normalizeAdminRequestUnsigned({ protocol: GITHUB_BROKER_PROTOCOL, type: 'admin-request', requestId, nonce, challenge: hello.challenge, source, destination: { kind: 'github-broker', instanceId: hello.instanceId, generation: hello.generation }, ...input, adminKeyId });
|
|
727
|
+
return Object.freeze({ ...unsigned, signature: signatureFor(ADMIN_REQUEST_DOMAIN, unsigned, key) });
|
|
728
|
+
}
|
|
729
|
+
export function verifyBrokerAdminRequest(value, hello, key, options = {}) {
|
|
730
|
+
const input = exact(value, [...ADMIN_REQUEST_UNSIGNED_KEYS, 'signature'], 'admin request');
|
|
731
|
+
const { signature: rawSignature, ...rawUnsigned } = input;
|
|
732
|
+
const unsigned = normalizeAdminRequestUnsigned(rawUnsigned), signature = base64url(rawSignature, 'admin signature', 64);
|
|
733
|
+
const now = options.now ?? Date.now();
|
|
734
|
+
if (hello.expiresAt <= now)
|
|
735
|
+
reject('expired-hello', 'server hello expired before admin request verification');
|
|
736
|
+
if (unsigned.deadline <= now)
|
|
737
|
+
reject('expired-request', 'admin request deadline expired');
|
|
738
|
+
if (unsigned.challenge !== hello.challenge || unsigned.destination.instanceId !== hello.instanceId || unsigned.destination.generation !== hello.generation)
|
|
739
|
+
reject('challenge-mismatch', 'admin request is not bound to this server hello');
|
|
740
|
+
if (options.expectedAdminKeyId !== undefined && unsigned.adminKeyId !== options.expectedAdminKeyId)
|
|
741
|
+
reject('client-identity-mismatch', 'admin key identity does not match');
|
|
742
|
+
verifySignature(ADMIN_REQUEST_DOMAIN, unsigned, signature, key);
|
|
743
|
+
return Object.freeze({ ...unsigned, signature });
|
|
744
|
+
}
|
|
745
|
+
export function brokerAdminRequestDigest(request) { const { signature: _signature, ...unsigned } = request; return brokerDigest(unsigned); }
|
|
746
|
+
function adminState(value) {
|
|
747
|
+
const input = exact(value, ['admission', 'generation', 'controlVersion', 'activeRequests', 'revocationEpoch'], 'admin state');
|
|
748
|
+
if (!['accepting', 'draining', 'stopped'].includes(String(input.admission)))
|
|
749
|
+
reject('invalid-message', 'admin admission state is invalid');
|
|
750
|
+
return Object.freeze({ admission: input.admission, generation: integer(input.generation, 'admin state generation', 1), controlVersion: integer(input.controlVersion, 'admin state controlVersion'), activeRequests: integer(input.activeRequests, 'admin state activeRequests'), revocationEpoch: integer(input.revocationEpoch, 'admin state revocationEpoch') });
|
|
751
|
+
}
|
|
752
|
+
const ADMIN_RESPONSE_UNSIGNED_KEYS = ['protocol', 'type', 'requestId', 'instanceId', 'generation', 'challenge', 'requestDigest', 'status', 'state', 'error', 'completedAt'];
|
|
753
|
+
function normalizeAdminResponseUnsigned(value) {
|
|
754
|
+
const input = exact(value, ADMIN_RESPONSE_UNSIGNED_KEYS, 'admin response');
|
|
755
|
+
if (input.protocol !== GITHUB_BROKER_PROTOCOL || input.type !== 'admin-response' || !['succeeded', 'failed'].includes(String(input.status)))
|
|
756
|
+
reject('wrong-protocol', 'admin response protocol is invalid');
|
|
757
|
+
let error = null;
|
|
758
|
+
if (input.status === 'succeeded') {
|
|
759
|
+
if (input.error !== null)
|
|
760
|
+
reject('invalid-message', 'successful admin response has an error');
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
const value = exact(input.error, ['code'], 'admin response error');
|
|
764
|
+
const code = text(value.code, 'admin response error code');
|
|
765
|
+
if (!ERROR_CODE.test(code))
|
|
766
|
+
reject('invalid-message', 'admin response error code is invalid');
|
|
767
|
+
error = Object.freeze({ code });
|
|
768
|
+
}
|
|
769
|
+
return Object.freeze({ protocol: GITHUB_BROKER_PROTOCOL, type: 'admin-response', requestId: text(input.requestId, 'admin response requestId'), instanceId: instance(input.instanceId, 'admin response instanceId'), generation: integer(input.generation, 'admin response generation', 1), challenge: base64url(input.challenge, 'admin response challenge', 32), requestDigest: digest(input.requestDigest, 'admin response requestDigest'), status: input.status, state: adminState(input.state), error, completedAt: integer(input.completedAt, 'admin response completedAt', 1) });
|
|
770
|
+
}
|
|
771
|
+
export function createBrokerAdminResponse(input, request, hello, key) {
|
|
772
|
+
const unsigned = normalizeAdminResponseUnsigned({ protocol: GITHUB_BROKER_PROTOCOL, type: 'admin-response', requestId: request.requestId, instanceId: hello.instanceId, generation: hello.generation, challenge: hello.challenge, requestDigest: brokerAdminRequestDigest(request), ...input });
|
|
773
|
+
return Object.freeze({ ...unsigned, signature: signatureFor(ADMIN_RESPONSE_DOMAIN, unsigned, key) });
|
|
774
|
+
}
|
|
775
|
+
export function verifyBrokerAdminResponse(value, request, hello, key) {
|
|
776
|
+
const input = exact(value, [...ADMIN_RESPONSE_UNSIGNED_KEYS, 'signature'], 'admin response');
|
|
777
|
+
const { signature: rawSignature, ...rawUnsigned } = input;
|
|
778
|
+
const unsigned = normalizeAdminResponseUnsigned(rawUnsigned), signature = base64url(rawSignature, 'admin response signature', 64);
|
|
779
|
+
if (unsigned.requestId !== request.requestId || unsigned.instanceId !== hello.instanceId || unsigned.generation !== hello.generation || unsigned.challenge !== hello.challenge || unsigned.requestDigest !== brokerAdminRequestDigest(request))
|
|
780
|
+
reject('response-mismatch', 'admin response is not bound to this request');
|
|
781
|
+
if (unsigned.state.generation !== unsigned.generation)
|
|
782
|
+
reject('response-mismatch', 'admin state generation does not match the response');
|
|
783
|
+
verifySignature(ADMIN_RESPONSE_DOMAIN, unsigned, signature, key);
|
|
784
|
+
return Object.freeze({ ...unsigned, signature });
|
|
785
|
+
}
|
|
786
|
+
//# sourceMappingURL=broker-protocol.js.map
|