a2a-exposed 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/package.json +1 -1
- package/worker/src/hosted.ts +0 -3
- package/worker/src/index.ts +13 -9
- package/worker/src/tenancy.ts +12 -1
- package/worker/src/version.ts +1 -1
- package/worker/src/wake-secrets.ts +90 -29
- package/worker/test/address.test.ts +59 -1
- package/worker/test/hosted.test.ts +17 -2
- package/worker/test/owner-mcp.test.ts +11 -0
- package/worker/test/tenancy.test.ts +17 -0
package/package.json
CHANGED
package/worker/src/hosted.ts
CHANGED
|
@@ -163,9 +163,6 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
163
163
|
// config_json is ignored, and a blob that cannot be opened leaves those fields unset.
|
|
164
164
|
if (row.secretsEnc) {
|
|
165
165
|
const opened = await openWakeSecrets(parsed.secretsKey || "", row.tenantId, row.secretsEnc);
|
|
166
|
-
if (!opened) {
|
|
167
|
-
console.log(JSON.stringify({ ts: new Date().toISOString(), event: "wake_secrets_unreadable", tenant: row.tenantId }));
|
|
168
|
-
}
|
|
169
166
|
for (const key of WAKE_SECRET_KEYS) {
|
|
170
167
|
const value = opened?.[key];
|
|
171
168
|
if (value) config[key] = value;
|
package/worker/src/index.ts
CHANGED
|
@@ -1233,14 +1233,21 @@ async function withMovedHeader(ctx: TenantContext, res: Response): Promise<Respo
|
|
|
1233
1233
|
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
1234
1234
|
}
|
|
1235
1235
|
|
|
1236
|
+
/** Every hosted credential sealed by peerKey must retain the previous key during rotation. */
|
|
1237
|
+
async function openPeerSecret(ctx: TenantContext, aad: string, sealed: string): Promise<string | null> {
|
|
1238
|
+
for (const key of await ctx.peerReadKeys()) {
|
|
1239
|
+
const opened = await M.openPeerTokenWithKey(key, aad, sealed);
|
|
1240
|
+
if (opened !== null) return opened;
|
|
1241
|
+
}
|
|
1242
|
+
return null;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1236
1245
|
type PairingIdentity = { publicKey: string; privateKey: string };
|
|
1237
1246
|
|
|
1238
1247
|
async function readIdentity(ctx: TenantContext): Promise<PairingIdentity | null | "unreadable"> {
|
|
1239
1248
|
const row: Json = await ctx.DB.prepare("SELECT value FROM settings WHERE key = ?").bind(Addr.IDENTITY_SETTING).first();
|
|
1240
1249
|
if (!row?.value) return null;
|
|
1241
|
-
const
|
|
1242
|
-
if (!key) return "unreadable";
|
|
1243
|
-
const opened = await M.openPeerTokenWithKey(key, Addr.IDENTITY_AAD, row.value);
|
|
1250
|
+
const opened = await openPeerSecret(ctx, Addr.IDENTITY_AAD, row.value);
|
|
1244
1251
|
if (!opened) return "unreadable";
|
|
1245
1252
|
try {
|
|
1246
1253
|
const parsed = JSON.parse(opened);
|
|
@@ -1411,8 +1418,7 @@ async function planReverse(ctx: TenantContext, alias: string): Promise<{ kind: "
|
|
|
1411
1418
|
async function openReverse(ctx: TenantContext, label: string): Promise<string | null> {
|
|
1412
1419
|
const row: Json = await ctx.DB.prepare("SELECT token_enc, source, revoked_at FROM peers WHERE label = ?").bind(label).first();
|
|
1413
1420
|
if (!row || row.revoked_at || row.source !== "reverse" || !row.token_enc) return null;
|
|
1414
|
-
|
|
1415
|
-
return key ? await M.openPeerTokenWithKey(key, label, row.token_enc) : null;
|
|
1421
|
+
return openPeerSecret(ctx, label, row.token_enc);
|
|
1416
1422
|
}
|
|
1417
1423
|
|
|
1418
1424
|
async function storeReverse(ctx: TenantContext, label: string, token: string, clientName: string, reissue = false): Promise<void> {
|
|
@@ -1544,8 +1550,7 @@ async function replyPeer(ctx: TenantContext, label: string): Promise<{ alias: st
|
|
|
1544
1550
|
WHERE o.alias = ? AND o.token_enc IS NOT NULL AND p.revoked_at IS NULL`,
|
|
1545
1551
|
).bind(label).first();
|
|
1546
1552
|
if (!row) return null;
|
|
1547
|
-
const
|
|
1548
|
-
const token = key ? await M.openPeerTokenWithKey(key, row.alias, row.token_enc) : null;
|
|
1553
|
+
const token = await openPeerSecret(ctx, row.alias, row.token_enc);
|
|
1549
1554
|
return token ? { alias: row.alias, url: row.url, token, identityKey: Addr.cleanPublicKey(row.identity_key), announced: row.source === "announce" } : null;
|
|
1550
1555
|
}
|
|
1551
1556
|
|
|
@@ -3533,8 +3538,7 @@ async function outboundPeer(ctx: TenantContext, alias: string): Promise<{ alias:
|
|
|
3533
3538
|
if (!r) throw new ToolError(`no peer "${alias}" synced to this A2A endpoint. On the machine with the CLI: ${cliCommand(ctx)} peers sync ${alias}`);
|
|
3534
3539
|
let token: string | null = null;
|
|
3535
3540
|
if (r.token_enc) {
|
|
3536
|
-
|
|
3537
|
-
token = peerKey ? await M.openPeerTokenWithKey(peerKey, alias, r.token_enc) : null;
|
|
3541
|
+
token = await openPeerSecret(ctx, alias, r.token_enc);
|
|
3538
3542
|
if (!token) throw new ToolError(`the stored token for "${alias}" can't be read (the owner token was rotated since it was synced): run ${cliCommand(ctx)} peers sync ${alias} again`);
|
|
3539
3543
|
}
|
|
3540
3544
|
return { alias, url: r.url, token };
|
package/worker/src/tenancy.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// and an allowlist are all set (approval-oidc.ts).
|
|
12
12
|
|
|
13
13
|
import * as A from "./a2a.ts";
|
|
14
|
+
import { tenantSecretsMaterials } from "./wake-secrets.ts";
|
|
14
15
|
|
|
15
16
|
/** Subset of D1 used by the handlers. A Durable Object adapter satisfies the same shape. */
|
|
16
17
|
export interface SqlDb {
|
|
@@ -489,10 +490,20 @@ export class TenantContext implements ConfigFields {
|
|
|
489
490
|
return this.#peerKey;
|
|
490
491
|
}
|
|
491
492
|
|
|
493
|
+
/** Read-only keys for hosted outbound tokens during rotation; self-host keeps OWNER_TOKEN semantics. */
|
|
494
|
+
async peerReadKeys(): Promise<CryptoKey[]> {
|
|
495
|
+
const active = await this.peerKey();
|
|
496
|
+
if (!active) return [];
|
|
497
|
+
if (this.mode === "single") return [active];
|
|
498
|
+
const previous = tenantSecretsMaterials(this.#secretsKey || "").slice(1);
|
|
499
|
+
return [active, ...await Promise.all(previous.map((material) => derivePeerKey(material, peerKeyInfoForTenant(this.id))))];
|
|
500
|
+
}
|
|
501
|
+
|
|
492
502
|
async #derivePeerKey(): Promise<CryptoKey | null> {
|
|
493
503
|
if (this.mode === "single") return this.#ownerToken ? derivePeerKey(this.#ownerToken) : null;
|
|
494
504
|
if (!this.#secretsKey || !this.id) return null;
|
|
495
|
-
|
|
505
|
+
const material = tenantSecretsMaterials(this.#secretsKey)[0];
|
|
506
|
+
return material ? derivePeerKey(material, peerKeyInfoForTenant(this.id)) : null;
|
|
496
507
|
}
|
|
497
508
|
}
|
|
498
509
|
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
2
|
+
export const VERSION = "0.8.35";
|
|
@@ -36,8 +36,47 @@ export const MAX_WAKE_PLAIN = (() => {
|
|
|
36
36
|
return n;
|
|
37
37
|
})();
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
export const MAX_WAKE_SEALED = 3 + 16 + 1 + Math.ceil((MAX_WAKE_PLAIN * 3 + 16) * 4 / 3);
|
|
39
|
+
/** Longest envelope: `v2.<64-char kid>.<iv>.<ciphertext>`. */
|
|
40
|
+
export const MAX_WAKE_SEALED = 3 + 64 + 1 + 16 + 1 + Math.ceil((MAX_WAKE_PLAIN * 3 + 16) * 4 / 3);
|
|
41
|
+
|
|
42
|
+
/** A bounded keyring lives in the existing secret binding, never in public config. */
|
|
43
|
+
export interface TenantSecretsKeyring {
|
|
44
|
+
active: string;
|
|
45
|
+
keys: Record<string, string>;
|
|
46
|
+
legacy: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Raw keys preserve v1 writes for a reader-first rollout. JSON keyrings enable v2 writes. */
|
|
50
|
+
export function tenantSecretsKeyring(input: string): TenantSecretsKeyring | null {
|
|
51
|
+
if (!input) return null;
|
|
52
|
+
if (!input.trimStart().startsWith("{")) return { active: "", keys: { "": input }, legacy: true };
|
|
53
|
+
if (input.length > 65536) return null;
|
|
54
|
+
try {
|
|
55
|
+
const value = JSON.parse(input);
|
|
56
|
+
if (!value || value.v !== 2 || typeof value.active !== "string" ||
|
|
57
|
+
!value.keys || typeof value.keys !== "object" || Array.isArray(value.keys) ||
|
|
58
|
+
Object.keys(value).some((name) => !["v", "active", "keys"].includes(name))) return null;
|
|
59
|
+
const entries = Object.entries(value.keys);
|
|
60
|
+
if (entries.length < 1 || entries.length > 2 || !Object.hasOwn(value.keys, value.active)) return null;
|
|
61
|
+
for (const [kid, key] of entries) {
|
|
62
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(kid) || typeof key !== "string" || !key.trim() || key.length > 4096) return null;
|
|
63
|
+
}
|
|
64
|
+
return { active: value.active, keys: value.keys, legacy: false };
|
|
65
|
+
} catch { return null; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Current material first, then the single read-only rotation key. No caller may log these values. */
|
|
69
|
+
export function tenantSecretsMaterials(input: string): string[] {
|
|
70
|
+
const ring = tenantSecretsKeyring(input);
|
|
71
|
+
if (!ring) return [];
|
|
72
|
+
return [ring.keys[ring.active], ...Object.entries(ring.keys).filter(([kid]) => kid !== ring.active).map(([, key]) => key)];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function unreadable(): null {
|
|
76
|
+
// Only a fixed event: ciphertext, key ids, credentials and tenant identifiers never enter logs.
|
|
77
|
+
console.error(JSON.stringify({ event: "wake_secrets_unreadable", level: "error" }));
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
41
80
|
|
|
42
81
|
function b64(bytes: Uint8Array): string {
|
|
43
82
|
let raw = "";
|
|
@@ -77,21 +116,24 @@ function payloadOf(secrets: WakeSecrets): Record<string, string> | null {
|
|
|
77
116
|
return payload;
|
|
78
117
|
}
|
|
79
118
|
|
|
80
|
-
/** `
|
|
119
|
+
/** `v2.<kid>.<iv>.<ciphertext>` for keyrings; raw keys keep v1 writes until rollout completes. */
|
|
81
120
|
export async function sealWakeSecrets(ikm: string, tenantId: string, secrets: WakeSecrets): Promise<string | null> {
|
|
82
|
-
const
|
|
121
|
+
const ring = tenantSecretsKeyring(ikm);
|
|
122
|
+
const key = ring ? await derive(ring.keys[ring.active], tenantId) : null;
|
|
83
123
|
const payload = payloadOf(secrets);
|
|
84
124
|
if (!key || !payload) return null;
|
|
85
125
|
const json = JSON.stringify(payload);
|
|
86
126
|
const encoded = new TextEncoder().encode(json);
|
|
87
127
|
if (json.length > MAX_WAKE_PLAIN || encoded.length > MAX_WAKE_PLAIN * 3) return null;
|
|
128
|
+
const prefix = ring!.legacy ? "v1" : `v2.${ring!.active}`;
|
|
129
|
+
const aad = ring!.legacy ? tenantId : `${prefix}\0${tenantId}`;
|
|
88
130
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
89
131
|
const ct = new Uint8Array(await crypto.subtle.encrypt(
|
|
90
|
-
{ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(
|
|
132
|
+
{ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(aad) },
|
|
91
133
|
key,
|
|
92
134
|
encoded,
|
|
93
135
|
));
|
|
94
|
-
const sealed =
|
|
136
|
+
const sealed = `${prefix}.${b64(iv)}.${b64(ct)}`;
|
|
95
137
|
return sealed.length <= MAX_WAKE_SEALED ? sealed : null;
|
|
96
138
|
}
|
|
97
139
|
|
|
@@ -101,28 +143,47 @@ export async function sealWakeSecrets(ikm: string, tenantId: string, secrets: Wa
|
|
|
101
143
|
*/
|
|
102
144
|
export async function openWakeSecrets(ikm: string, tenantId: string, sealed: string | null | undefined): Promise<WakeSecrets | null> {
|
|
103
145
|
if (sealed == null || sealed === "") return {};
|
|
104
|
-
if (sealed.length > MAX_WAKE_SEALED) return
|
|
105
|
-
const
|
|
106
|
-
if (!
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
146
|
+
if (typeof sealed !== "string" || sealed.length > MAX_WAKE_SEALED) return unreadable();
|
|
147
|
+
const ring = tenantSecretsKeyring(ikm);
|
|
148
|
+
if (!ring || !tenantId) return unreadable();
|
|
149
|
+
const parts = sealed.split(".");
|
|
150
|
+
const version = parts[0];
|
|
151
|
+
const kid = version === "v2" ? parts[1] : "";
|
|
152
|
+
if (version !== "v1" && version !== "v2") return unreadable();
|
|
153
|
+
if (parts.length !== (version === "v2" ? 4 : 3)) return unreadable();
|
|
154
|
+
const [iv, ct] = parts.slice(version === "v2" ? 2 : 1);
|
|
155
|
+
if (!iv || !ct || (version === "v2" && (ring.legacy || !Object.hasOwn(ring.keys, kid)))) return unreadable();
|
|
156
|
+
const materials = version === "v2" ? [ring.keys[kid]] : tenantSecretsMaterials(ikm);
|
|
157
|
+
const aad = version === "v2" ? `v2.${kid}\0${tenantId}` : tenantId;
|
|
158
|
+
for (const material of materials) {
|
|
159
|
+
try {
|
|
160
|
+
const key = await derive(material, tenantId);
|
|
161
|
+
if (!key) continue;
|
|
162
|
+
const ivBytes = unb64(iv);
|
|
163
|
+
if (ivBytes.length !== 12) return unreadable();
|
|
164
|
+
const plain = new TextDecoder().decode(await crypto.subtle.decrypt(
|
|
165
|
+
{ name: "AES-GCM", iv: ivBytes, additionalData: new TextEncoder().encode(aad) },
|
|
166
|
+
key,
|
|
167
|
+
unb64(ct),
|
|
168
|
+
));
|
|
169
|
+
if (plain.length > MAX_WAKE_PLAIN) return unreadable();
|
|
170
|
+
const parsed = JSON.parse(plain) as unknown;
|
|
171
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return unreadable();
|
|
172
|
+
const out: WakeSecrets = {};
|
|
173
|
+
for (const name of WAKE_SECRET_KEYS) {
|
|
174
|
+
const value = (parsed as Record<string, unknown>)[name];
|
|
175
|
+
if (typeof value !== "string" || !value || value.length > MAX_WAKE_VALUE) continue;
|
|
176
|
+
out[name] = value;
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
} catch { /* Try the previous key only for an unlabelled v1 blob. */ }
|
|
127
180
|
}
|
|
181
|
+
return unreadable();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Pure maintenance helper. The caller must CAS the original blob and queue the new tenant config. */
|
|
185
|
+
export async function rewrapWakeSecrets(ikm: string, tenantId: string, sealed: string): Promise<string | null> {
|
|
186
|
+
if (!sealed) return null;
|
|
187
|
+
const secrets = await openWakeSecrets(ikm, tenantId, sealed);
|
|
188
|
+
return secrets ? sealWakeSecrets(ikm, tenantId, secrets) : null;
|
|
128
189
|
}
|
|
@@ -7,6 +7,7 @@ import { dispatch, runOwnerRpc } from "../src/index.ts";
|
|
|
7
7
|
import { redactExport, exportRows } from "../src/export.ts";
|
|
8
8
|
import { hostedTenantContext, type DoNamespace, type KvNamespace, type TenantContext, type WorkerBindings } from "../src/tenancy.ts";
|
|
9
9
|
import { d1 } from "./d1.ts";
|
|
10
|
+
import { openPeerTokenWithKey } from "../src/mcp.ts";
|
|
10
11
|
|
|
11
12
|
const MIGRATIONS = new URL("../migrations/", import.meta.url);
|
|
12
13
|
const ectx = { waitUntil(p: Promise<unknown>) { p.catch(() => {}); }, passThroughOnException() {} };
|
|
@@ -70,7 +71,14 @@ async function world(t: { after: (fn: () => void) => void }) {
|
|
|
70
71
|
};
|
|
71
72
|
const owner = (ctx: TenantContext, accountId: string, method: string, path: string, body?: unknown) =>
|
|
72
73
|
runOwnerRpc(ctx, ectx as never, { accountId, method, path, ...(body === undefined ? {} : { body }) });
|
|
73
|
-
|
|
74
|
+
const rotate = async (agent: typeof alice, secretsKey: string) => {
|
|
75
|
+
agent.ctx = hostedTenantContext({ ...env, TENANT_SECRETS_KEY: secretsKey }, {
|
|
76
|
+
id: agent.ctx.id, db: agent.db, publicUrl: agent.ctx.PUBLIC_URL,
|
|
77
|
+
ownerTokenHash: await sha256(agent.ownerToken), status: "active", config: { AGENT_NAME: "fixture" },
|
|
78
|
+
});
|
|
79
|
+
byId.set(agent.ctx.id, agent.ctx);
|
|
80
|
+
};
|
|
81
|
+
return { alice, bob, carol, call, owner, rotate };
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
async function pair(bob: { ctx: TenantContext; ownerToken: string }, call: (ctx: TenantContext, path: string, init?: RequestInit) => Promise<{ status: number; data: any }>, identity?: string) {
|
|
@@ -474,3 +482,53 @@ test("export omits the sealed pairing identity and a stored return token", async
|
|
|
474
482
|
assert.equal(JSON.stringify(redacted).includes(ident.privateKey), false);
|
|
475
483
|
assert.equal(JSON.stringify(redacted).includes("sealed-return"), false);
|
|
476
484
|
});
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
test("hosted key rotation preserves identity proofs, reverse tokens and direct replies", async (t) => {
|
|
488
|
+
const { alice, carol, call, owner, rotate } = await world(t as never);
|
|
489
|
+
const ident = await Addr.generateIdentity();
|
|
490
|
+
const putIdentity = () => call(alice.ctx, "/owner/identity", {
|
|
491
|
+
method: "PUT", headers: { authorization: `Bearer ${alice.ownerToken}`, "content-type": "application/json" },
|
|
492
|
+
body: JSON.stringify(ident),
|
|
493
|
+
});
|
|
494
|
+
assert.equal((await putIdentity()).status, 200);
|
|
495
|
+
const paired = await pair(carol, call, ident.publicKey);
|
|
496
|
+
assert.equal((await owner(alice.ctx, "acct-alice", "PUT", `/owner/outbound-peers/${paired.label}`, { url: carol.ctx.PUBLIC_URL, token: paired.access })).status, 200);
|
|
497
|
+
const announce = () => owner(alice.ctx, "acct-alice", "POST", "/owner/peers/announce", { to: paired.label });
|
|
498
|
+
const firstAnnouncement = await announce();
|
|
499
|
+
assert.equal(firstAnnouncement.body.results[0].status, "updated", JSON.stringify(firstAnnouncement.body));
|
|
500
|
+
const reverseBefore = await alice.db.prepare("SELECT token_hash, token_enc FROM peers WHERE label = ? AND source = 'reverse'").bind(paired.label).first() as { token_hash: string; token_enc: string };
|
|
501
|
+
assert.ok(reverseBefore.token_enc);
|
|
502
|
+
const identityBefore = await alice.db.prepare("SELECT value FROM settings WHERE key = ?").bind(Addr.IDENTITY_SETTING).first() as { value: string };
|
|
503
|
+
const ring = JSON.stringify({ v: 2, active: "current", keys: { previous: "platform-key", current: "new-platform-key" } });
|
|
504
|
+
await rotate(alice, ring);
|
|
505
|
+
await rotate(carol, ring);
|
|
506
|
+
const cardUrl = Addr.cardUrlFor(alice.ctx.PUBLIC_URL);
|
|
507
|
+
const card = await call(alice.ctx, "/.well-known/agent-card.json");
|
|
508
|
+
assert.equal(await Addr.cardProves(card.data, ident.publicKey, cardUrl), true, "the stored old-key identity still signs the card");
|
|
509
|
+
assert.deepEqual(await alice.db.prepare("SELECT value FROM settings WHERE key = ?").bind(Addr.IDENTITY_SETTING).first(), identityBefore, "a public read never rewrites identity storage");
|
|
510
|
+
const taskId = await send(carol.ctx, paired.access, "rotation question", "ctx-rotation");
|
|
511
|
+
const reply = await owner(carol.ctx, "acct-carol", "POST", "/owner/mcp/reply", { taskId, text: "rotation answer", state: "completed" });
|
|
512
|
+
assert.equal(reply.body.structuredContent.status, "delivered", JSON.stringify(reply.body));
|
|
513
|
+
assert.equal((await announce()).body.results[0].status, "updated", "the old-key identity and outbound token remain usable");
|
|
514
|
+
const reverseAfter = await alice.db.prepare("SELECT token_hash, token_enc FROM peers WHERE label = ? AND source = 'reverse'").bind(paired.label).first() as typeof reverseBefore;
|
|
515
|
+
assert.equal(reverseAfter.token_hash, reverseBefore.token_hash, "rotation must preserve the existing return token");
|
|
516
|
+
assert.notEqual(reverseAfter.token_enc, reverseBefore.token_enc);
|
|
517
|
+
assert.ok(await openPeerTokenWithKey((await alice.ctx.peerKey())!, paired.label, reverseAfter.token_enc), "authorized announcement reseals the same reverse token with the active key");
|
|
518
|
+
// Existing owner-only identity installation reseals the SAME validated identity; it never regenerates it.
|
|
519
|
+
const unauthorized = await call(alice.ctx, "/owner/identity", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(ident) });
|
|
520
|
+
assert.equal(unauthorized.status, 401);
|
|
521
|
+
const resealed = await putIdentity();
|
|
522
|
+
assert.equal(resealed.status, 200);
|
|
523
|
+
assert.equal(resealed.data.publicKey, ident.publicKey);
|
|
524
|
+
assert.equal(JSON.stringify(resealed.data).includes(ident.privateKey), false);
|
|
525
|
+
const onlyCurrent = JSON.stringify({ v: 2, active: "current", keys: { current: "new-platform-key" } });
|
|
526
|
+
await rotate(alice, onlyCurrent);
|
|
527
|
+
const retiredCard = await call(alice.ctx, "/.well-known/agent-card.json");
|
|
528
|
+
assert.equal(await Addr.cardProves(retiredCard.data, ident.publicKey, cardUrl), true, "owner reseal keeps the identity readable after retirement");
|
|
529
|
+
const identityAfter = await alice.db.prepare("SELECT value FROM settings WHERE key = ?").bind(Addr.IDENTITY_SETTING).first() as { value: string };
|
|
530
|
+
assert.notEqual(identityAfter.value, identityBefore.value);
|
|
531
|
+
const other = await Addr.generateIdentity();
|
|
532
|
+
const replacement = await call(alice.ctx, "/owner/identity", { method: "PUT", headers: { authorization: `Bearer ${alice.ownerToken}`, "content-type": "application/json" }, body: JSON.stringify(other) });
|
|
533
|
+
assert.equal(replacement.status, 409, "rotation never authorizes replacing the identity");
|
|
534
|
+
});
|
|
@@ -933,8 +933,10 @@ test("self-host workerd: human mode owner token approves, the device poll return
|
|
|
933
933
|
|
|
934
934
|
test("sealed upstream and wake Access credentials reach only their tenant context in workerd", async (t) => {
|
|
935
935
|
const received: { origin: string; bearer: string | null; id: string | null; secret: string | null }[] = [];
|
|
936
|
+
const keys = { previous: "platform-secret", current: "next-platform-secret" };
|
|
937
|
+
const keyring = (active: string) => JSON.stringify({ v: 2, active, keys });
|
|
936
938
|
const mf = await start({
|
|
937
|
-
bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "
|
|
939
|
+
bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: keyring("current") },
|
|
938
940
|
durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
|
|
939
941
|
outboundService: async (request: Request) => {
|
|
940
942
|
received.push({ origin: new URL(request.url).origin, bearer: request.headers.get("authorization"), id: request.headers.get("cf-access-client-id"), secret: request.headers.get("cf-access-client-secret") });
|
|
@@ -966,9 +968,22 @@ test("sealed upstream and wake Access credentials reach only their tenant contex
|
|
|
966
968
|
assert.equal(preview.status, 200);
|
|
967
969
|
assert.equal((preview.body as { hasAccessServiceToken: boolean }).hasAccessServiceToken, true);
|
|
968
970
|
for (const value of Object.values(secrets)) assert.equal(JSON.stringify([facade.body, preview.body]).includes(value), false);
|
|
971
|
+
let version = 1;
|
|
972
|
+
for (const active of ["previous", "current"]) {
|
|
973
|
+
const v2 = await sealWakeSecrets(keyring(active), "sealed-tenant", secrets);
|
|
974
|
+
assert.ok(v2?.startsWith(`v2.${active}.`));
|
|
975
|
+
assert.equal((await push(++version, v2)).applied, true);
|
|
976
|
+
const facadeV2 = await stub.owner({ accountId: "dummy-owner", method: "GET", path: "/owner/facade" });
|
|
977
|
+
assert.equal((facadeV2.body as { hasUpstreamToken: boolean }).hasUpstreamToken, true);
|
|
978
|
+
assert.equal(received.at(-1)?.bearer, "Bearer dummy-bearer");
|
|
979
|
+
const wakeV2 = await stub.owner({ accountId: "dummy-owner", method: "GET", path: "/owner/wake/preview" });
|
|
980
|
+
assert.equal((wakeV2.body as { hasAccessServiceToken: boolean }).hasAccessServiceToken, true);
|
|
981
|
+
for (const value of Object.values(secrets)) assert.equal(JSON.stringify([facadeV2.body, wakeV2.body]).includes(value), false);
|
|
982
|
+
}
|
|
983
|
+
|
|
969
984
|
const otherTenantBlob = await sealWakeSecrets("platform-secret", "another-tenant", secrets);
|
|
970
985
|
assert.ok(otherTenantBlob);
|
|
971
|
-
assert.equal((await push(
|
|
986
|
+
assert.equal((await push(++version, otherTenantBlob)).applied, true);
|
|
972
987
|
const rejected = await stub.owner({ accountId: "dummy-owner", method: "GET", path: "/owner/facade" });
|
|
973
988
|
assert.equal((rejected.body as { hasUpstreamToken: boolean }).hasUpstreamToken, false);
|
|
974
989
|
assert.equal((rejected.body as { hasUpstreamAccessServiceToken: boolean }).hasUpstreamAccessServiceToken, false);
|
|
@@ -95,6 +95,17 @@ test("hosted control plane send, reply, and mark_working", async (t) => {
|
|
|
95
95
|
assert.equal(viaHttp.text.includes("tok-alpha"), false);
|
|
96
96
|
assert.equal(peerCalls.at(-1)!.body.params.message.parts[0].text, "from the owner token");
|
|
97
97
|
|
|
98
|
+
// A hosted rotation keeps stored outbound tokens readable through the actual owner send path.
|
|
99
|
+
const rotating = hostedTenantContext({ ...env, TENANT_SECRETS_KEY: JSON.stringify({
|
|
100
|
+
v: 2, active: "next", keys: { old: "platform-key", next: "next-platform-key" },
|
|
101
|
+
}) }, { id: "ten_carol", db: DB, publicUrl: BASE, ownerTokenHash: hash, status: "active" });
|
|
102
|
+
const afterRotation = await runOwnerRpc(rotating, ectx as any, {
|
|
103
|
+
accountId: "acct_carol", method: "POST", path: "/owner/mcp/send", body: { to: "alpha", text: "after rotation" },
|
|
104
|
+
});
|
|
105
|
+
assert.equal(afterRotation.status, 200);
|
|
106
|
+
assert.equal((afterRotation.body as any).isError, false, JSON.stringify(afterRotation.body));
|
|
107
|
+
assert.equal(peerCalls.at(-1)!.auth, "Bearer tok-alpha");
|
|
108
|
+
|
|
98
109
|
const withBody = await runOwnerRpc(ctx, ectx as any, { accountId: "acct_carol", method: "GET", path: "/owner/peers", body: { label: "bob" } });
|
|
99
110
|
assert.equal(withBody.status, 400);
|
|
100
111
|
assert.equal((withBody.body as { error?: string }).error, "this call takes no body");
|
|
@@ -268,3 +268,20 @@ test("fetch path: gates set or unset, the self-host Worker still answers from OW
|
|
|
268
268
|
const no = await call({ ...base, ...gates }, "/owner/inbox", { authorization: "Bearer platform-key" });
|
|
269
269
|
assert.equal(no.status, 401);
|
|
270
270
|
});
|
|
271
|
+
|
|
272
|
+
test("hosted peer token keys follow the active ring key and keep the previous key for reads", async () => {
|
|
273
|
+
const make = (key: string, id = "ten_a") => hostedTenantContext(selfEnv({ TENANT_SECRETS_KEY: key }), { id, db: dummy, publicUrl: BASE });
|
|
274
|
+
const old = make("old-platform-key");
|
|
275
|
+
const ring = JSON.stringify({ v: 2, active: "current", keys: { previous: "old-platform-key", current: "new-platform-key" } });
|
|
276
|
+
const rotating = make(ring);
|
|
277
|
+
const oldToken = await sealPeerTokenWithKey((await old.peerKey())!, "alpha", "fixture-old-token");
|
|
278
|
+
const keys = await rotating.peerReadKeys();
|
|
279
|
+
assert.equal(keys.length, 2);
|
|
280
|
+
assert.equal(await openPeerTokenWithKey(keys[0], "alpha", oldToken), null);
|
|
281
|
+
assert.equal(await openPeerTokenWithKey(keys[1], "alpha", oldToken), "fixture-old-token");
|
|
282
|
+
const newToken = await sealPeerTokenWithKey((await rotating.peerKey())!, "alpha", "fixture-new-token");
|
|
283
|
+
assert.equal(await openPeerTokenWithKey((await make("new-platform-key").peerKey())!, "alpha", newToken), "fixture-new-token");
|
|
284
|
+
for (const key of await make(ring, "ten_b").peerReadKeys()) assert.equal(await openPeerTokenWithKey(key, "alpha", oldToken), null);
|
|
285
|
+
assert.deepEqual(await make('{"v":2}').peerReadKeys(), []);
|
|
286
|
+
assert.equal((await resolveTenant(selfEnv()).peerReadKeys()).length, 1);
|
|
287
|
+
});
|