@a2a-exposed/control-core 0.8.34 → 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
@@ -3672,6 +3719,7 @@ export {
3672
3719
  resultList,
3673
3720
  resultObject,
3674
3721
  resumeFields,
3722
+ rewrapWakeSecrets,
3675
3723
  rotateOwnerToken,
3676
3724
  routes,
3677
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.34",
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": {