@a2a-exposed/control-core 0.8.33 → 0.8.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -307,7 +307,33 @@ var MAX_WAKE_PLAIN = (() => {
307
307
  }
308
308
  return n;
309
309
  })();
310
- var MAX_WAKE_SEALED = 3 + 16 + 1 + Math.ceil((MAX_WAKE_PLAIN * 3 + 16) * 4 / 3);
310
+ var MAX_WAKE_SEALED = 3 + 64 + 1 + 16 + 1 + Math.ceil((MAX_WAKE_PLAIN * 3 + 16) * 4 / 3);
311
+ function tenantSecretsKeyring(input) {
312
+ if (!input) return null;
313
+ if (!input.trimStart().startsWith("{")) return { active: "", keys: { "": input }, legacy: true };
314
+ if (input.length > 65536) return null;
315
+ try {
316
+ const value = JSON.parse(input);
317
+ if (!value || value.v !== 2 || typeof value.active !== "string" || !value.keys || typeof value.keys !== "object" || Array.isArray(value.keys) || Object.keys(value).some((name) => !["v", "active", "keys"].includes(name))) return null;
318
+ const entries = Object.entries(value.keys);
319
+ if (entries.length < 1 || entries.length > 2 || !Object.hasOwn(value.keys, value.active)) return null;
320
+ for (const [kid, key] of entries) {
321
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(kid) || typeof key !== "string" || !key.trim() || key.length > 4096) return null;
322
+ }
323
+ return { active: value.active, keys: value.keys, legacy: false };
324
+ } catch {
325
+ return null;
326
+ }
327
+ }
328
+ function tenantSecretsMaterials(input) {
329
+ const ring = tenantSecretsKeyring(input);
330
+ if (!ring) return [];
331
+ return [ring.keys[ring.active], ...Object.entries(ring.keys).filter(([kid]) => kid !== ring.active).map(([, key]) => key)];
332
+ }
333
+ function unreadable() {
334
+ console.error(JSON.stringify({ event: "wake_secrets_unreadable", level: "error" }));
335
+ return null;
336
+ }
311
337
  function b64(bytes2) {
312
338
  let raw = "";
313
339
  for (let i = 0; i < bytes2.length; i += 32768) raw += String.fromCharCode(...bytes2.subarray(i, i + 32768));
@@ -336,47 +362,68 @@ function payloadOf(secrets) {
336
362
  return payload;
337
363
  }
338
364
  async function sealWakeSecrets(ikm, tenantId, secrets) {
339
- const key = await derive(ikm, tenantId);
365
+ const ring = tenantSecretsKeyring(ikm);
366
+ const key = ring ? await derive(ring.keys[ring.active], tenantId) : null;
340
367
  const payload = payloadOf(secrets);
341
368
  if (!key || !payload) return null;
342
369
  const json = JSON.stringify(payload);
343
370
  const encoded = new TextEncoder().encode(json);
344
371
  if (json.length > MAX_WAKE_PLAIN || encoded.length > MAX_WAKE_PLAIN * 3) return null;
372
+ const prefix = ring.legacy ? "v1" : `v2.${ring.active}`;
373
+ const aad = ring.legacy ? tenantId : `${prefix}\0${tenantId}`;
345
374
  const iv = crypto.getRandomValues(new Uint8Array(12));
346
375
  const ct = new Uint8Array(await crypto.subtle.encrypt(
347
- { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(tenantId) },
376
+ { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(aad) },
348
377
  key,
349
378
  encoded
350
379
  ));
351
- const sealed = `v1.${b64(iv)}.${b64(ct)}`;
380
+ const sealed = `${prefix}.${b64(iv)}.${b64(ct)}`;
352
381
  return sealed.length <= MAX_WAKE_SEALED ? sealed : null;
353
382
  }
354
383
  async function openWakeSecrets(ikm, tenantId, sealed) {
355
384
  if (sealed == null || sealed === "") return {};
356
- if (sealed.length > MAX_WAKE_SEALED) return null;
357
- const key = await derive(ikm, tenantId);
358
- if (!key) return null;
359
- const [version, iv, ct, extra] = sealed.split(".");
360
- if (version !== "v1" || !iv || !ct || extra !== void 0) return null;
361
- try {
362
- const plain = new TextDecoder().decode(await crypto.subtle.decrypt(
363
- { name: "AES-GCM", iv: unb64(iv), additionalData: new TextEncoder().encode(tenantId) },
364
- key,
365
- unb64(ct)
366
- ));
367
- if (plain.length > MAX_WAKE_PLAIN) return null;
368
- const parsed = JSON.parse(plain);
369
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
370
- const out = {};
371
- for (const name of WAKE_SECRET_KEYS) {
372
- const value = parsed[name];
373
- if (typeof value !== "string" || !value || value.length > MAX_WAKE_VALUE) continue;
374
- out[name] = value;
385
+ if (typeof sealed !== "string" || sealed.length > MAX_WAKE_SEALED) return unreadable();
386
+ const ring = tenantSecretsKeyring(ikm);
387
+ if (!ring || !tenantId) return unreadable();
388
+ const parts = sealed.split(".");
389
+ const version = parts[0];
390
+ const kid = version === "v2" ? parts[1] : "";
391
+ if (version !== "v1" && version !== "v2") return unreadable();
392
+ if (parts.length !== (version === "v2" ? 4 : 3)) return unreadable();
393
+ const [iv, ct] = parts.slice(version === "v2" ? 2 : 1);
394
+ if (!iv || !ct || version === "v2" && (ring.legacy || !Object.hasOwn(ring.keys, kid))) return unreadable();
395
+ const materials = version === "v2" ? [ring.keys[kid]] : tenantSecretsMaterials(ikm);
396
+ const aad = version === "v2" ? `v2.${kid}\0${tenantId}` : tenantId;
397
+ for (const material of materials) {
398
+ try {
399
+ const key = await derive(material, tenantId);
400
+ if (!key) continue;
401
+ const ivBytes = unb64(iv);
402
+ if (ivBytes.length !== 12) return unreadable();
403
+ const plain = new TextDecoder().decode(await crypto.subtle.decrypt(
404
+ { name: "AES-GCM", iv: ivBytes, additionalData: new TextEncoder().encode(aad) },
405
+ key,
406
+ unb64(ct)
407
+ ));
408
+ if (plain.length > MAX_WAKE_PLAIN) return unreadable();
409
+ const parsed = JSON.parse(plain);
410
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return unreadable();
411
+ const out = {};
412
+ for (const name of WAKE_SECRET_KEYS) {
413
+ const value = parsed[name];
414
+ if (typeof value !== "string" || !value || value.length > MAX_WAKE_VALUE) continue;
415
+ out[name] = value;
416
+ }
417
+ return out;
418
+ } catch {
375
419
  }
376
- return out;
377
- } catch {
378
- return null;
379
420
  }
421
+ return unreadable();
422
+ }
423
+ async function rewrapWakeSecrets(ikm, tenantId, sealed) {
424
+ if (!sealed) return null;
425
+ const secrets = await openWakeSecrets(ikm, tenantId, sealed);
426
+ return secrets ? sealWakeSecrets(ikm, tenantId, secrets) : null;
380
427
  }
381
428
 
382
429
  // src/tenants/create.ts
@@ -3192,8 +3239,12 @@ var BASIC_RE = /(basic\s+)(\S+)/gi;
3192
3239
  var PEM_RE = /-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z0-9]+ )?PRIVATE KEY-----|$)/g;
3193
3240
  var API_KEY_RE = /(?:(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|\bAKIA[0-9A-Z]{16}\b|\bsk_(?:live|test)_[A-Za-z0-9]{10,}|\brk_(?:live|test)_[A-Za-z0-9]{10,}|\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}|\bxox[baprs]-[A-Za-z0-9-]{10,}|\bAIza[0-9A-Za-z_-]{35}\b)/g;
3194
3241
  var JWT_RE = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g;
3195
- var AUTH_RE = /\b((?:proxy-)?authorization)(\s*[:=]\s*)([^\n]+)/gi;
3242
+ var AUTH_RE = /\b((?:proxy-)?authorization)(\s*[:=]\s*)([^\r\n]+)/gi;
3196
3243
  var QUERY_RE = /(^|[?&#\s])([^?&#\s="'<>]+)(\s*=\s*)("(?:\\.|[^"\\])*(?:"|$)|'(?:\\.|[^'\\])*(?:'|$)|(?:\\(?!["'])|[^&#\s"'<>\\])*)/g;
3244
+ var HEADER_RE = /\b([A-Za-z][A-Za-z0-9_-]*)([ \t]*:[ \t]*)/g;
3245
+ var CONFIG_LINE_RE = /^([ \t]*)([A-Z_][A-Z0-9_]*)([ \t]*=[ \t]*)([^\r\n]*)/gm;
3246
+ var URL_PATH_RE = /(\bhttps?:\/\/[^/?#\s"'<>]+)([^?#\s"'<>]*)/gi;
3247
+ var QUERY_SECRET = /* @__PURE__ */ new Set(["key", "sig", "code", "codeverifier"]);
3197
3248
  var ASSIGN_RE = /\b([A-Za-z_][A-Za-z0-9_]*)(\s*=\s*)/g;
3198
3249
  var SECRET_EXACT = /* @__PURE__ */ new Set([
3199
3250
  "authorization",
@@ -3203,6 +3254,8 @@ var SECRET_EXACT = /* @__PURE__ */ new Set([
3203
3254
  "password",
3204
3255
  "passwd",
3205
3256
  "secret",
3257
+ "secrets",
3258
+ "signature",
3206
3259
  "token",
3207
3260
  "apikey",
3208
3261
  "accesstoken",
@@ -3216,9 +3269,12 @@ var SECRET_EXACT = /* @__PURE__ */ new Set([
3216
3269
  "bearer",
3217
3270
  "webhookkey",
3218
3271
  "accessclientid",
3219
- "accessclientsecret"
3272
+ "accessclientsecret",
3273
+ "xhubsignature256"
3220
3274
  ]);
3221
3275
  var SECRET_SUFFIX = [
3276
+ "secrets",
3277
+ "signature",
3222
3278
  "authorization",
3223
3279
  "token",
3224
3280
  "secret",
@@ -3273,6 +3329,38 @@ function unquotedValueEnd(text, start) {
3273
3329
  while (i < text.length && !/\s/.test(text[i])) i += 1;
3274
3330
  return i;
3275
3331
  }
3332
+ function jsonStringSpans(text) {
3333
+ const strings = [];
3334
+ for (let i = 0; i < text.length; i += 1) {
3335
+ if (text[i] !== '"') continue;
3336
+ const end = quotedValueEnd(text, i);
3337
+ try {
3338
+ JSON.parse(text.slice(i, end));
3339
+ strings.push([i, end]);
3340
+ } catch {
3341
+ }
3342
+ i = end - 1;
3343
+ }
3344
+ return strings;
3345
+ }
3346
+ function redactHeaderLines(text) {
3347
+ const strings = jsonStringSpans(text);
3348
+ const pattern = new RegExp(HEADER_RE.source, "g");
3349
+ let out = "";
3350
+ let last = 0;
3351
+ let match;
3352
+ while ((match = pattern.exec(text)) !== null) {
3353
+ if (!secretName(match[1]) || strings.some(([start2, end2]) => match.index >= start2 && match.index < end2)) continue;
3354
+ const start = pattern.lastIndex;
3355
+ let end = start;
3356
+ while (end < text.length && !/[\r\n]/.test(text[end])) end += 1;
3357
+ if (end === start) continue;
3358
+ out += text.slice(last, start) + REDACTED;
3359
+ last = end;
3360
+ pattern.lastIndex = end;
3361
+ }
3362
+ return out + text.slice(last);
3363
+ }
3276
3364
  function redactAssignments(text) {
3277
3365
  const pattern = new RegExp(ASSIGN_RE.source, "g");
3278
3366
  const parameters = Array.from(text.matchAll(new RegExp(QUERY_RE.source, "g")));
@@ -3377,19 +3465,43 @@ function redactNested(value, depth, cache = /* @__PURE__ */ new Map()) {
3377
3465
  return REDACTED;
3378
3466
  }
3379
3467
  }
3468
+ function redactEnvironmentLines(text) {
3469
+ const pattern = new RegExp(CONFIG_LINE_RE.source, "gm");
3470
+ let out = "";
3471
+ let last = 0;
3472
+ let match;
3473
+ while ((match = pattern.exec(text)) !== null) {
3474
+ const [, indent, name, sep, item] = match;
3475
+ const structured = item.startsWith("{") || item.startsWith("[");
3476
+ const parameterList = /^[^\s<>]*[&#][^?&#\s="'<>]+[ \t]*=/.test(item) && !structured;
3477
+ if (!secretName(name) || !item || /^["']/.test(item) || parameterList || /\b[A-Za-z_][A-Za-z0-9_]*\s*=\s*$/.test(item)) continue;
3478
+ const start = match.index + indent.length + name.length + sep.length;
3479
+ const end = structured ? jsonValueEnd(text, start) : pattern.lastIndex;
3480
+ out += text.slice(last, start) + REDACTED;
3481
+ last = end;
3482
+ pattern.lastIndex = end;
3483
+ }
3484
+ return out + text.slice(last);
3485
+ }
3380
3486
  function redactText(value, depth, cache) {
3381
3487
  let text = redactJsonFields(value, depth, cache);
3382
3488
  text = replaceAll(text, PEM_RE, REDACTED);
3489
+ text = redactEnvironmentLines(text);
3383
3490
  text = replaceAll(text, JWT_RE, REDACTED);
3384
3491
  text = replaceAll(text, API_KEY_RE, REDACTED);
3385
3492
  text = replaceAll(text, TOKEN_RE, REDACTED);
3386
3493
  text = replaceAll(text, BEARER_RE, `$1${REDACTED}`);
3387
3494
  text = replaceAll(text, BASIC_RE, `$1${REDACTED}`);
3388
- text = replaceAll(text, AUTH_RE, (_all, name, sep, rest) => {
3495
+ text = redactHeaderLines(text);
3496
+ const authStrings = jsonStringSpans(text);
3497
+ text = text.replace(AUTH_RE, (all, name, sep, rest, offset) => {
3498
+ if (authStrings.some(([start, end]) => offset >= start && offset < end)) return all;
3389
3499
  if (rest.trim().length < 8 && !/bearer|basic/i.test(rest)) return `${name}${sep}${rest}`;
3390
3500
  return `${name}${sep}${REDACTED}`;
3391
3501
  });
3392
3502
  text = replaceAll(text, USERINFO_RE, `$1${REDACTED}@`);
3503
+ text = text.replace(/(\/webhooks\/)\d+\/[^/?#\s"'<>]+/gi, `$1${REDACTED}/${REDACTED}`).replace(/(\/(?:hooks|webhooks)\/)[^/?#\s"'<>]+/gi, `$1${REDACTED}`).replace(/\/bot[0-9]+:[^/?#\s"'<>]+/gi, `/bot${REDACTED}`);
3504
+ text = replaceAll(text, URL_PATH_RE, (_all, origin, path) => origin + path.replace(/\/[^/]{16,}/g, `/${REDACTED}`));
3393
3505
  const cleanParameter = (all, sep, name, assignment, item) => {
3394
3506
  let decoded = name;
3395
3507
  while (true) {
@@ -3408,7 +3520,7 @@ function redactText(value, depth, cache) {
3408
3520
  setup = /^[A-Za-z0-9_-]{16,}/.test(candidate);
3409
3521
  }
3410
3522
  if (!item) return all;
3411
- if (!secretName(decoded) && !setup) {
3523
+ if (!secretName(decoded) && !QUERY_SECRET.has(compactName(decoded)) && !setup) {
3412
3524
  const quote = item[0];
3413
3525
  const wrapped = (quote === '"' || quote === "'") && item.endsWith(quote) && item.length > 1;
3414
3526
  const cleaned = wrapped ? quote + redactNested(item.slice(1, -1), depth + 1, cache) + quote : redactNested(item, depth + 1, cache);
@@ -3416,17 +3528,7 @@ function redactText(value, depth, cache) {
3416
3528
  }
3417
3529
  return `${sep}${name}${assignment}${REDACTED}`;
3418
3530
  };
3419
- const strings = [];
3420
- for (let i = 0; i < text.length; i += 1) {
3421
- if (text[i] !== '"') continue;
3422
- const end = quotedValueEnd(text, i);
3423
- try {
3424
- JSON.parse(text.slice(i, end));
3425
- strings.push([i, end]);
3426
- } catch {
3427
- }
3428
- i = end - 1;
3429
- }
3531
+ const strings = jsonStringSpans(text);
3430
3532
  const parameters = new RegExp(QUERY_RE.source, "g");
3431
3533
  let out = "";
3432
3534
  let last = 0;
@@ -3478,6 +3580,17 @@ function walk(value, seen) {
3478
3580
  return value.map((item) => walk(item, seen));
3479
3581
  }
3480
3582
  const proto = Object.getPrototypeOf(value);
3583
+ if (value instanceof Error) {
3584
+ const out2 = new Error();
3585
+ Object.setPrototypeOf(out2, proto);
3586
+ delete out2.stack;
3587
+ for (const key of Reflect.ownKeys(value)) {
3588
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
3589
+ const item = "value" in descriptor && !(typeof key === "string" && secretName(key)) ? walk(descriptor.value, seen) : REDACTED;
3590
+ Object.defineProperty(out2, key, { value: item, enumerable: descriptor.enumerable, configurable: true, writable: true });
3591
+ }
3592
+ return out2;
3593
+ }
3481
3594
  if (proto !== Object.prototype && proto !== null) return value;
3482
3595
  const record = value;
3483
3596
  const hidden = /* @__PURE__ */ new Set();
@@ -3606,6 +3719,7 @@ export {
3606
3719
  resultList,
3607
3720
  resultObject,
3608
3721
  resumeFields,
3722
+ rewrapWakeSecrets,
3609
3723
  rotateOwnerToken,
3610
3724
  routes,
3611
3725
  runInboxStep,
@@ -6,7 +6,7 @@ export { rotateOwnerToken } from "../tenants/rotate.js";
6
6
  export type { RotateResult } from "../tenants/rotate.js";
7
7
  export { WAKE_PRESETS, configFields, saveWakeSettings, tenantForAccount, wakeHeaderProblem, wakeSettingsOf, wakeUrlProblem, } from "../tenants/wake.js";
8
8
  export type { WakeSave, WakeSaveInput, WakeSettings } from "../tenants/wake.js";
9
- export { MAX_WAKE_PLAIN, MAX_WAKE_SEALED, MAX_WAKE_VALUE, WAKE_SECRET_KEYS, openWakeSecrets, sealWakeSecrets, } from "../tenants/wake-secrets.js";
9
+ export { MAX_WAKE_PLAIN, MAX_WAKE_SEALED, MAX_WAKE_VALUE, WAKE_SECRET_KEYS, openWakeSecrets, rewrapWakeSecrets, sealWakeSecrets, } from "../tenants/wake-secrets.js";
10
10
  export type { WakeSecretKey, WakeSecrets } from "../tenants/wake-secrets.js";
11
11
  export { RESERVED_NAMES, TENANT_NAME, tenantNameProblem } from "../tenants/reserved.js";
12
12
  export { httpsOrigin, newApprovalClient } from "../tenants/approval.js";
@@ -32,7 +32,7 @@ export interface ControlEnv extends PlaneEnv {
32
32
  SIGNUP_ALLOWLIST?: string;
33
33
  /** Default agents per account. Unset or empty: no cap, unless that account sets agent_limit. An explicit non-negative integer sets the cap. */
34
34
  FREE_AGENT_LIMIT?: string;
35
- /** Same secret as the data-plane Worker. Seals wake secrets. Unset: a new key cannot be saved. */
35
+ /** Same raw key or JSON rotation keyring as the data-plane Worker. Seals tenant secrets. Unset: a new key cannot be saved. */
36
36
  TENANT_SECRETS_KEY?: string;
37
37
  /**
38
38
  * Fine-grained token with Issues write on FEEDBACK_REPO. A secret.
@@ -9,17 +9,29 @@ export declare const MAX_WAKE_VALUE = 4096;
9
9
  * and every known field may be present. `openWakeSecrets` accepts this and nothing larger.
10
10
  */
11
11
  export declare const MAX_WAKE_PLAIN: number;
12
- /** `v1.` + base64(12-byte iv) + `.` + base64(utf-8 plaintext + 16-byte tag). */
12
+ /** Longest envelope: `v2.<64-char kid>.<iv>.<ciphertext>`. */
13
13
  export declare const MAX_WAKE_SEALED: number;
14
+ /** A bounded keyring lives in the existing secret binding, never in public config. */
15
+ export interface TenantSecretsKeyring {
16
+ active: string;
17
+ keys: Record<string, string>;
18
+ legacy: boolean;
19
+ }
20
+ /** Raw keys preserve v1 writes for a reader-first rollout. JSON keyrings enable v2 writes. */
21
+ export declare function tenantSecretsKeyring(input: string): TenantSecretsKeyring | null;
22
+ /** Current material first, then the single read-only rotation key. No caller may log these values. */
23
+ export declare function tenantSecretsMaterials(input: string): string[];
14
24
  /**
15
25
  * Config written next to a sealed blob. The sealed fields stay in `secrets_enc`.
16
26
  * With no ciphertext, the config is returned unchanged.
17
27
  */
18
28
  export declare function omitSealedSecrets<T extends Record<string, unknown>>(config: T, secretsEnc: string | null | undefined): T;
19
- /** `v1.<iv>.<ciphertext>`, or null when the key or a value is unusable. */
29
+ /** `v2.<kid>.<iv>.<ciphertext>` for keyrings; raw keys keep v1 writes until rollout completes. */
20
30
  export declare function sealWakeSecrets(ikm: string, tenantId: string, secrets: WakeSecrets): Promise<string | null>;
21
31
  /**
22
32
  * The sealed fields, `{}` when there is nothing to open, or null when the blob cannot be read.
23
33
  * A null result must not be treated as "no secrets": the caller keeps the previous ciphertext.
24
34
  */
25
35
  export declare function openWakeSecrets(ikm: string, tenantId: string, sealed: string | null | undefined): Promise<WakeSecrets | null>;
36
+ /** Pure maintenance helper. The caller must CAS the original blob and queue the new tenant config. */
37
+ export declare function rewrapWakeSecrets(ikm: string, tenantId: string, sealed: string): Promise<string | null>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2a-exposed/control-core",
3
- "version": "0.8.33",
3
+ "version": "0.8.35",
4
4
  "description": "A2A endpoint provisioning, routing, and D1 migrations for a2a-exposed.",
5
5
  "type": "module",
6
6
  "scripts": {