@agent-custody/receipts 0.6.0 → 0.6.2

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/portal.js ADDED
@@ -0,0 +1,547 @@
1
+ // The tenant portal: where a team registers, gets its tenant on the hosted log and its first API key, watches its
2
+ // usage against its plan, mints and revokes keys, and pays. One process beside the log, on the same Postgres, with
3
+ // its own tables under `portal_`. Everything a tenant can do here they could also do by asking the operator; the
4
+ // portal removes the asking. It holds no receipts and never sees a receipt: the numbers it shows are the log's
5
+ // counts, the hashes it lists are the same hashes the export carries.
6
+ //
7
+ // Sessions are a signed cookie, passwords are scrypt, the page is one inline file with no framework and a strict
8
+ // content-security policy, and every API write requires a JSON body, which with a SameSite=Strict cookie is what
9
+ // keeps a cross-site page from acting as the user. Billing is Stripe Checkout for the team plan; the webhook moves
10
+ // the tenant's plan, and nothing about a card ever passes through here.
11
+ import { createHmac, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
12
+ import { createServer } from "node:http";
13
+ import { postgresCheckpoints } from "./checkpoints.js";
14
+ import { welcomeSheet } from "./log-admin.js";
15
+ import { clientAddress } from "./log-sink.js";
16
+ import { RateLimiter } from "./log-store.js";
17
+ const ident = (s) => {
18
+ if (!/^[a-z_][a-z0-9_]*$/.test(s))
19
+ throw new Error(`prefix must be a plain identifier; got "${s}"`);
20
+ return s;
21
+ };
22
+ /** Users, memberships, and billing records, beside the log's tables. */
23
+ export class PortalStore {
24
+ client;
25
+ p;
26
+ ready = null;
27
+ // no parameter properties: the CLI runs on plain Node type stripping
28
+ constructor(client, prefix = "portal_") {
29
+ this.client = client;
30
+ this.p = ident(prefix);
31
+ }
32
+ init() {
33
+ if (!this.ready) {
34
+ const p = this.p;
35
+ this.ready = (async () => {
36
+ await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`);
37
+ await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}members (user_id TEXT NOT NULL REFERENCES ${p}users(id), tenant_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'owner', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (user_id, tenant_id))`);
38
+ await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}billing (tenant_id TEXT PRIMARY KEY, customer_id TEXT, subscription_id TEXT, status TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`);
39
+ })();
40
+ }
41
+ return this.ready;
42
+ }
43
+ static hashPassword(password) {
44
+ const salt = randomBytes(16);
45
+ return `scrypt$${salt.toString("hex")}$${scryptSync(password, salt, 64).toString("hex")}`;
46
+ }
47
+ static checkPassword(password, stored) {
48
+ const [alg, saltHex, hashHex] = stored.split("$");
49
+ if (alg !== "scrypt" || !saltHex || !hashHex)
50
+ return false;
51
+ const expected = Buffer.from(hashHex, "hex");
52
+ const got = scryptSync(password, Buffer.from(saltHex, "hex"), expected.length);
53
+ return got.length === expected.length && timingSafeEqual(got, expected);
54
+ }
55
+ async createUser(email, password) {
56
+ await this.init();
57
+ const id = randomUUID();
58
+ const rows = (await this.client.query(`INSERT INTO ${this.p}users (id, email, password_hash) VALUES ($1, $2, $3) ON CONFLICT (email) DO NOTHING RETURNING id, email, created_at`, [id, email, PortalStore.hashPassword(password)])).rows;
59
+ if (!rows[0])
60
+ throw new Error("an account with this email already exists");
61
+ return { id, email, createdAt: new Date(rows[0].created_at).toISOString() };
62
+ }
63
+ async authenticate(email, password) {
64
+ await this.init();
65
+ const rows = (await this.client.query(`SELECT id, email, password_hash, created_at FROM ${this.p}users WHERE email = $1`, [email])).rows;
66
+ const r = rows[0];
67
+ if (!r || !PortalStore.checkPassword(password, String(r.password_hash)))
68
+ return null;
69
+ return { id: String(r.id), email: String(r.email), createdAt: new Date(r.created_at).toISOString() };
70
+ }
71
+ async user(id) {
72
+ await this.init();
73
+ const rows = (await this.client.query(`SELECT id, email, created_at FROM ${this.p}users WHERE id = $1`, [id])).rows;
74
+ const r = rows[0];
75
+ return r ? { id: String(r.id), email: String(r.email), createdAt: new Date(r.created_at).toISOString() } : null;
76
+ }
77
+ async addMember(userId, tenantId) {
78
+ await this.init();
79
+ await this.client.query(`INSERT INTO ${this.p}members (user_id, tenant_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [userId, tenantId]);
80
+ }
81
+ /** the user's tenant; one per account today */
82
+ async tenantOf(userId) {
83
+ await this.init();
84
+ const rows = (await this.client.query(`SELECT tenant_id FROM ${this.p}members WHERE user_id = $1 ORDER BY created_at LIMIT 1`, [userId])).rows;
85
+ return rows[0]?.tenant_id ?? null;
86
+ }
87
+ async setBilling(tenantId, b) {
88
+ await this.init();
89
+ await this.client.query(`INSERT INTO ${this.p}billing (tenant_id, customer_id, subscription_id, status, updated_at) VALUES ($1, $2, $3, $4, now())
90
+ ON CONFLICT (tenant_id) DO UPDATE SET customer_id = COALESCE(EXCLUDED.customer_id, ${this.p}billing.customer_id), subscription_id = COALESCE(EXCLUDED.subscription_id, ${this.p}billing.subscription_id), status = EXCLUDED.status, updated_at = now()`, [tenantId, b.customerId ?? null, b.subscriptionId ?? null, b.status]);
91
+ }
92
+ async billing(tenantId) {
93
+ await this.init();
94
+ const rows = (await this.client.query(`SELECT customer_id, subscription_id, status FROM ${this.p}billing WHERE tenant_id = $1`, [tenantId])).rows;
95
+ const r = rows[0];
96
+ return r ? { customerId: r.customer_id ? String(r.customer_id) : null, subscriptionId: r.subscription_id ? String(r.subscription_id) : null, status: String(r.status) } : null;
97
+ }
98
+ async tenantBySubscription(subscriptionId) {
99
+ await this.init();
100
+ const rows = (await this.client.query(`SELECT tenant_id FROM ${this.p}billing WHERE subscription_id = $1`, [subscriptionId])).rows;
101
+ return rows[0]?.tenant_id ?? null;
102
+ }
103
+ }
104
+ // ---- sessions ----
105
+ const b64u = (b) => b.toString("base64url");
106
+ export function signSession(secret, userId, ttlMs = 14 * 86_400_000) {
107
+ const body = b64u(Buffer.from(JSON.stringify({ u: userId, e: Date.now() + ttlMs })));
108
+ return `${body}.${createHmac("sha256", secret).update(body).digest("base64url")}`;
109
+ }
110
+ export function readSession(secret, cookie) {
111
+ if (!cookie)
112
+ return null;
113
+ const [body, mac] = cookie.split(".");
114
+ if (!body || !mac)
115
+ return null;
116
+ const expected = createHmac("sha256", secret).update(body).digest("base64url");
117
+ if (expected.length !== mac.length || !timingSafeEqual(Buffer.from(expected), Buffer.from(mac)))
118
+ return null;
119
+ try {
120
+ const { u, e } = JSON.parse(Buffer.from(body, "base64url").toString());
121
+ return typeof u === "string" && typeof e === "number" && e > Date.now() ? u : null;
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ // ---- Stripe, over its REST API; no SDK ----
128
+ const form = (o) => new URLSearchParams(o).toString();
129
+ export async function stripeRequest(s, path, body) {
130
+ const f = s.fetch ?? fetch;
131
+ const res = await f(`https://api.stripe.com/v1/${path}`, { method: "POST", headers: { authorization: `Bearer ${s.secretKey}`, "content-type": "application/x-www-form-urlencoded" }, body: form(body), signal: AbortSignal.timeout(15_000) });
132
+ const json = (await res.json());
133
+ if (!res.ok)
134
+ throw new Error(`stripe ${path}: ${json.error?.message ?? res.status}`);
135
+ return json;
136
+ }
137
+ /** Stripe-Signature: t=<unix>,v1=<hmac>; the mac is over `${t}.${rawBody}` with the endpoint secret. */
138
+ export function verifyStripeSignature(header, rawBody, secret, now = Date.now(), toleranceMs = 5 * 60_000) {
139
+ if (!header)
140
+ return false;
141
+ const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
142
+ const t = Number(parts.t);
143
+ if (!Number.isFinite(t) || Math.abs(now - t * 1000) > toleranceMs)
144
+ return false;
145
+ const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
146
+ return header
147
+ .split(",")
148
+ .filter((kv) => kv.startsWith("v1="))
149
+ .some((kv) => {
150
+ const sig = kv.slice(3);
151
+ return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
152
+ });
153
+ }
154
+ const TENANT_ID = /^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/;
155
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
156
+ const RESERVED = new Set(["default", "admin", "api", "www", "log", "checkpoints", "app", "portal", "stripe", "health", "t"]);
157
+ export function portalHandler(o) {
158
+ const store = new PortalStore(o.client, o.prefix);
159
+ const heads = postgresCheckpoints(o.client);
160
+ const log = o.log ?? ((m) => console.error(m));
161
+ const loginFailures = new RateLimiter({ perSecond: 1 / 30, burst: 8 });
162
+ const registrations = new RateLimiter({ perSecond: 1 / 600, burst: 5 });
163
+ const base = o.publicUrl.endsWith("/") ? o.publicUrl : `${o.publicUrl}/`;
164
+ const cookieName = "custody_session";
165
+ const sheet = (tenant, logId) => welcomeSheet({ tenant, logId, publicUrl: base, ...(o.checkpointsUrl ? { checkpointsUrl: o.checkpointsUrl } : {}), ...(o.keyid ? { keyid: o.keyid } : {}) });
166
+ const exportCommand = (tenant) => `npx @agent-custody/receipts log-export --log-url ${base} --tenant ${tenant} --token-env AGENT_CUSTODY_LOG_TOKEN --out custody-export/`;
167
+ return async (req, res) => {
168
+ const url = new URL(req.url ?? "/", "http://localhost");
169
+ const addr = clientAddress(req, o.trustProxy);
170
+ const json = (status, body, headers = {}) => {
171
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...headers });
172
+ res.end(JSON.stringify(body));
173
+ };
174
+ const setCookie = (value) => `${cookieName}=${value ?? ""}; Path=/; HttpOnly; SameSite=Strict${o.trustProxy ? "; Secure" : ""}; Max-Age=${value ? 14 * 86_400 : 0}`;
175
+ const cookies = Object.fromEntries((req.headers.cookie ?? "").split(";").map((c) => c.trim().split("=")).filter(([k]) => k));
176
+ const userId = readSession(o.secret, cookies[cookieName]);
177
+ const rawBody = async () => {
178
+ let text = "";
179
+ for await (const chunk of req) {
180
+ text += chunk;
181
+ if (text.length > 65_536)
182
+ throw new Error("body too large");
183
+ }
184
+ return text;
185
+ };
186
+ const jsonBody = async () => {
187
+ if (!(req.headers["content-type"] ?? "").startsWith("application/json"))
188
+ throw new Error("expected a JSON body");
189
+ const t = await rawBody();
190
+ return t ? JSON.parse(t) : {};
191
+ };
192
+ try {
193
+ if (req.method === "GET" && url.pathname === "/health")
194
+ return json(200, { ok: true, stripe: !!o.stripe });
195
+ if (req.method === "GET" && url.pathname === "/") {
196
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-frame-options": "DENY", "content-security-policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'none'" });
197
+ return void res.end(PORTAL_PAGE);
198
+ }
199
+ // ---- Stripe's webhook: the only caller that is not a browser with a session ----
200
+ if (req.method === "POST" && url.pathname === "/stripe/webhook") {
201
+ if (!o.stripe)
202
+ return json(503, { error: "billing is not configured" });
203
+ const raw = await rawBody();
204
+ if (!verifyStripeSignature(req.headers["stripe-signature"], raw, o.stripe.webhookSecret))
205
+ return json(400, { error: "bad signature" });
206
+ const event = JSON.parse(raw);
207
+ const obj = event.data.object;
208
+ if (event.type === "checkout.session.completed") {
209
+ const tenant = String(obj.client_reference_id ?? "");
210
+ if (tenant && (await o.tenancy.tenant(tenant))) {
211
+ await store.setBilling(tenant, { customerId: obj.customer ? String(obj.customer) : null, subscriptionId: obj.subscription ? String(obj.subscription) : null, status: "active" });
212
+ await o.tenancy.setPlan(tenant, "team", "stripe:checkout");
213
+ log(`agent-custody portal: tenant ${tenant} moved to team by checkout ${String(obj.id ?? "")}`);
214
+ }
215
+ }
216
+ else if (event.type === "customer.subscription.deleted" || event.type === "customer.subscription.updated") {
217
+ const tenant = await store.tenantBySubscription(String(obj.id ?? ""));
218
+ if (tenant) {
219
+ const status = event.type === "customer.subscription.deleted" ? "canceled" : String(obj.status ?? "unknown");
220
+ await store.setBilling(tenant, { status });
221
+ const plan = status === "active" || status === "trialing" || status === "past_due" ? "team" : "free";
222
+ const current = (await o.tenancy.tenant(tenant))?.plan;
223
+ if (current !== "enterprise" && current !== plan)
224
+ await o.tenancy.setPlan(tenant, plan, `stripe:${event.type}`);
225
+ }
226
+ }
227
+ return json(200, { received: true });
228
+ }
229
+ // ---- registration and login ----
230
+ if (req.method === "POST" && url.pathname === "/api/register") {
231
+ if (!registrations.take(`reg:${addr}`))
232
+ return json(429, { error: "too many registrations from this address; try again later" });
233
+ const b = await jsonBody();
234
+ const email = String(b.email ?? "").trim().toLowerCase();
235
+ const password = String(b.password ?? "");
236
+ const tenant = String(b.tenant ?? "").trim().toLowerCase();
237
+ if (!EMAIL.test(email))
238
+ return json(400, { error: "a valid email address is needed" });
239
+ if (password.length < 10)
240
+ return json(400, { error: "the password needs at least ten characters" });
241
+ if (!TENANT_ID.test(tenant) || RESERVED.has(tenant))
242
+ return json(400, { error: "the tenant id is the name in your log's URL: three to forty lowercase letters, digits, or hyphens, and not a reserved word" });
243
+ if (await o.tenancy.tenant(tenant))
244
+ return json(409, { error: "that tenant id is taken" });
245
+ let user;
246
+ try {
247
+ user = await store.createUser(email, password);
248
+ }
249
+ catch (e) {
250
+ return json(409, { error: e instanceof Error ? e.message : String(e) });
251
+ }
252
+ const t = await o.tenancy.addTenant(tenant, tenant, `portal:${email}`);
253
+ await store.addMember(user.id, tenant);
254
+ const minted = await o.tenancy.addToken(tenant, "first key", `portal:${email}`);
255
+ log(`agent-custody portal: ${email} registered tenant ${tenant}`);
256
+ return json(200, { tenant: t.id, logId: t.logId, plan: t.plan, token: minted.token, tokenHash: minted.tokenHash.slice(0, 12), welcome: sheet(t.id, t.logId), exportCommand: exportCommand(t.id) }, { "set-cookie": setCookie(signSession(o.secret, user.id)) });
257
+ }
258
+ if (req.method === "POST" && url.pathname === "/api/login") {
259
+ if (!loginFailures.take(`login:${addr}`))
260
+ return json(429, { error: "too many attempts; wait a minute" });
261
+ const b = await jsonBody();
262
+ const user = await store.authenticate(String(b.email ?? "").trim().toLowerCase(), String(b.password ?? ""));
263
+ if (!user)
264
+ return json(401, { error: "email or password not recognised" });
265
+ return json(200, { email: user.email }, { "set-cookie": setCookie(signSession(o.secret, user.id)) });
266
+ }
267
+ if (req.method === "POST" && url.pathname === "/api/logout")
268
+ return json(200, { ok: true }, { "set-cookie": setCookie(null) });
269
+ // ---- everything below needs a session ----
270
+ if (!url.pathname.startsWith("/api/"))
271
+ return json(404, { error: "not found" });
272
+ const user = userId ? await store.user(userId) : null;
273
+ if (!user)
274
+ return json(401, { error: "sign in first" });
275
+ const tenantId = await store.tenantOf(user.id);
276
+ if (!tenantId)
277
+ return json(409, { error: "this account has no tenant" });
278
+ const tenant = await o.tenancy.tenant(tenantId);
279
+ if (!tenant)
280
+ return json(409, { error: "the tenant no longer exists" });
281
+ if (req.method !== "GET" && !(req.headers["content-type"] ?? "").startsWith("application/json"))
282
+ return json(415, { error: "expected a JSON body" });
283
+ if (req.method === "GET" && url.pathname === "/api/me") {
284
+ const q = await o.tenancy.quota(tenantId);
285
+ return json(200, { email: user.email, tenant: tenantId, logId: tenant.logId, plan: q.plan, used: q.used, quota: q.quota, disabled: !!tenant.disabledAt, billing: !!o.stripe });
286
+ }
287
+ if (req.method === "GET" && url.pathname === "/api/overview") {
288
+ const q = await o.tenancy.quota(tenantId);
289
+ const backend = await o.tenancy.log(tenantId);
290
+ const size = await backend.size();
291
+ const latest = await heads.latest(tenantId);
292
+ const months = [];
293
+ for (let i = 5; i >= 0; i--) {
294
+ const d = new Date();
295
+ const month = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - i, 1)).toISOString().slice(0, 7);
296
+ const u = await o.tenancy.usage(month);
297
+ months.push({ month, appends: u.tenants.find((t) => t.id === tenantId)?.appends ?? 0 });
298
+ }
299
+ const keys = (await o.tenancy.listTokens(tenantId)).map((k) => ({ label: k.label, hash: k.tokenHash.slice(0, 12), createdAt: k.createdAt, revokedAt: k.revokedAt }));
300
+ const audit = await o.tenancy.audit({ tenant: tenantId, limit: 50 });
301
+ const billing = await store.billing(tenantId);
302
+ return json(200, { tenant: tenantId, logId: tenant.logId, plan: q.plan, used: q.used, quota: q.quota, treeSize: size, rootHash: size ? await backend.root(size) : null, latestCheckpoint: latest ? { treeSize: latest.treeSize, signedAt: latest.signedAt } : null, months, keys, audit, billing: billing ? { status: billing.status } : null, urls: { log: `${base}t/${tenantId}/`, keys: `${base}.well-known/agent-custody-log.json`, checkpoints: o.checkpointsUrl ? `${o.checkpointsUrl.replace(/\/?$/, "/")}${tenantId}/latest.json` : null }, exportCommand: exportCommand(tenantId), welcome: sheet(tenantId, tenant.logId), stripe: !!o.stripe });
303
+ }
304
+ if (req.method === "GET" && url.pathname === "/api/keys") {
305
+ return json(200, { keys: (await o.tenancy.listTokens(tenantId)).map((k) => ({ label: k.label, hash: k.tokenHash.slice(0, 12), createdAt: k.createdAt, revokedAt: k.revokedAt })) });
306
+ }
307
+ if (req.method === "POST" && url.pathname === "/api/keys") {
308
+ const b = await jsonBody();
309
+ const label = String(b.label ?? "").trim().slice(0, 64) || "key";
310
+ const minted = await o.tenancy.addToken(tenantId, label, `portal:${user.email}`);
311
+ return json(200, { token: minted.token, tokenHash: minted.tokenHash.slice(0, 12), label });
312
+ }
313
+ const revoke = /^\/api\/keys\/([0-9a-f]{8,64})\/revoke$/.exec(url.pathname);
314
+ if (req.method === "POST" && revoke) {
315
+ await jsonBody();
316
+ return json(200, { revoked: await o.tenancy.revokeToken(tenantId, revoke[1], `portal:${user.email}`) });
317
+ }
318
+ if (req.method === "POST" && url.pathname === "/api/checkout") {
319
+ if (!o.stripe)
320
+ return json(503, { error: "billing is not configured on this portal yet; email us and we move the plan by hand" });
321
+ await jsonBody();
322
+ if (tenant.plan !== "free")
323
+ return json(409, { error: `this tenant is already on the ${tenant.plan} plan` });
324
+ const portalUrl = (o.portalUrl ?? "http://localhost/").replace(/\/?$/, "/");
325
+ const session = await stripeRequest(o.stripe, "checkout/sessions", { mode: "subscription", "line_items[0][price]": o.stripe.priceTeam, "line_items[0][quantity]": "1", client_reference_id: tenantId, customer_email: user.email, success_url: `${portalUrl}?upgraded=1`, cancel_url: `${portalUrl}?cancelled=1`, "metadata[tenant]": tenantId });
326
+ return json(200, { url: String(session.url) });
327
+ }
328
+ if (req.method === "POST" && url.pathname === "/api/billing-portal") {
329
+ if (!o.stripe)
330
+ return json(503, { error: "billing is not configured" });
331
+ await jsonBody();
332
+ const b = await store.billing(tenantId);
333
+ if (!b?.customerId)
334
+ return json(409, { error: "no billing record for this tenant" });
335
+ const session = await stripeRequest(o.stripe, "billing_portal/sessions", { customer: b.customerId, return_url: (o.portalUrl ?? "http://localhost/").replace(/\/?$/, "/") });
336
+ return json(200, { url: String(session.url) });
337
+ }
338
+ return json(404, { error: "not found" });
339
+ }
340
+ catch (e) {
341
+ if (!res.headersSent)
342
+ json(500, { error: e instanceof Error ? e.message : String(e) });
343
+ }
344
+ };
345
+ }
346
+ export function servePortal(o, opts) {
347
+ const host = opts.host ?? "127.0.0.1";
348
+ const handler = portalHandler(o);
349
+ const server = createServer((req, res) => void handler(req, res));
350
+ return new Promise((resolve) => {
351
+ server.listen(opts.port, host, () => {
352
+ const { port } = server.address();
353
+ resolve({ url: `http://${host}:${port}/`, close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); }) });
354
+ });
355
+ });
356
+ }
357
+ // ---- the page: one file, no framework, no outside requests ----
358
+ const PORTAL_PAGE = `<!doctype html>
359
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
360
+ <title>agent-custody</title>
361
+ <style>
362
+ :root { color-scheme: light dark; --ink: #1b2430; --ink2: #5b6b7a; --line: #d7dfe5; --bg: #f5f7f9; --panel: #ffffff; --accent: #0f6e63; --accent-bg: #e8f3f1; --warn: #b3731a; --bad: #b3261e; --ok: #1f7a4d; --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
363
+ @media (prefers-color-scheme: dark) { :root { --ink: #e6ecf0; --ink2: #9fb0bd; --line: #27333c; --bg: #0e1418; --panel: #151d23; --accent: #4fc3b0; --accent-bg: #16302b; --warn: #e2b862; --bad: #ff8a80; --ok: #6fd39a; } }
364
+ * { box-sizing: border-box; }
365
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
366
+ .top { display: flex; align-items: center; gap: 1rem; padding: .7rem 1.25rem; border-bottom: 1px solid var(--line); background: var(--panel); }
367
+ .brand { font-weight: 700; letter-spacing: .04em; } .brand b { color: var(--accent); }
368
+ .top .who { margin-left: auto; color: var(--ink2); font-size: .9rem; }
369
+ .pill { display: inline-block; padding: .05rem .5rem; border-radius: 999px; font: 600 .72rem/1.6 var(--mono); letter-spacing: .06em; text-transform: uppercase; background: var(--accent-bg); color: var(--accent); }
370
+ .layout { display: grid; grid-template-columns: 15rem 1fr; min-height: calc(100vh - 3.3rem); }
371
+ nav { border-right: 1px solid var(--line); background: var(--panel); padding: 1rem 0; }
372
+ nav .group { font: 600 .68rem/1.4 var(--mono); letter-spacing: .12em; text-transform: uppercase; color: var(--accent); padding: 1rem 1.25rem .35rem; }
373
+ nav a { display: flex; justify-content: space-between; padding: .45rem 1.25rem; color: var(--ink); text-decoration: none; border-left: 3px solid transparent; }
374
+ nav a.on { border-left-color: var(--accent); background: var(--accent-bg); }
375
+ nav a span.n { color: var(--ink2); font-family: var(--mono); font-size: .8rem; }
376
+ main { padding: 1.5rem; max-width: 72rem; }
377
+ h1 { font-size: 1.25rem; margin: 0 0 1rem; } h2 { font: 600 .72rem/1.4 var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--ink2); margin: 1.5rem 0 .6rem; }
378
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); gap: .9rem; }
379
+ .card { background: var(--panel); border: 1px solid var(--line); border-top: 3px solid var(--accent); border-radius: 6px; padding: 1rem 1.1rem; }
380
+ .card.warn { border-top-color: var(--warn); } .card.bad { border-top-color: var(--bad); } .card.ok { border-top-color: var(--ok); }
381
+ .card .k { font: 600 .68rem/1.4 var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--ink2); }
382
+ .card .v { font-size: 1.9rem; font-weight: 700; margin: .2rem 0 0; font-variant-numeric: tabular-nums; }
383
+ .card .s { color: var(--ink2); font-size: .88rem; }
384
+ .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 1rem 1.1rem; margin-top: .9rem; }
385
+ table { border-collapse: collapse; width: 100%; font-size: .92rem; }
386
+ th, td { text-align: left; padding: .45rem .5rem .45rem 0; border-bottom: 1px solid var(--line); vertical-align: top; }
387
+ th { font: 600 .68rem/1.4 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--ink2); }
388
+ code, pre, .mono { font-family: var(--mono); font-size: .86em; }
389
+ pre { background: var(--bg); border: 1px solid var(--line); border-radius: 4px; padding: .8rem .9rem; overflow-x: auto; white-space: pre-wrap; }
390
+ .bars { display: grid; grid-template-columns: repeat(6, 1fr); gap: .6rem; align-items: end; height: 9rem; }
391
+ .bar { display: flex; flex-direction: column; justify-content: flex-end; align-items: center; height: 100%; font-size: .75rem; color: var(--ink2); }
392
+ .bar i { display: block; width: 70%; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; }
393
+ .bar b { font-family: var(--mono); font-weight: 400; margin-top: .3rem; }
394
+ label { display: grid; gap: .25rem; font-size: .85rem; color: var(--ink2); margin: 0 0 .8rem; }
395
+ input, select { font: inherit; padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); color: var(--ink); }
396
+ button { font: inherit; padding: .5rem .9rem; border-radius: 4px; border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
397
+ button.quiet { background: transparent; color: var(--accent); }
398
+ button.link { background: none; border: 0; padding: 0; color: var(--accent); text-decoration: underline; }
399
+ .auth { max-width: 26rem; margin: 4rem auto; }
400
+ .once { border: 1px solid var(--warn); background: color-mix(in srgb, var(--warn) 10%, var(--panel)); border-radius: 6px; padding: 1rem 1.1rem; margin: 1rem 0; }
401
+ .tok { font-family: var(--mono); word-break: break-all; padding: .6rem; background: var(--bg); border-radius: 4px; }
402
+ .msg { min-height: 1.4rem; margin: .6rem 0; color: var(--ink2); } .msg.err { color: var(--bad); } .msg.ok { color: var(--ok); }
403
+ .muted { color: var(--ink2); }
404
+ [hidden] { display: none !important; }
405
+ @media (max-width: 48rem) { .layout { grid-template-columns: 1fr; } nav { display: flex; flex-wrap: wrap; padding: .3rem; border-right: 0; border-bottom: 1px solid var(--line); } nav .group { display: none; } nav a { border-left: 0; border-bottom: 3px solid transparent; } nav a.on { border-bottom-color: var(--accent); } }
406
+ </style>
407
+ <div class="top"><span class="brand"><b>◆</b> agent-custody</span><span id="tenantTag" class="pill" hidden></span><span id="planTag" class="pill" hidden></span><span class="who" id="who"></span></div>
408
+ <section id="auth" class="auth" hidden>
409
+ <h1 id="authTitle">Sign in</h1>
410
+ <form id="authForm">
411
+ <label>Email<input id="email" type="email" autocomplete="email" required></label>
412
+ <label>Password<input id="password" type="password" autocomplete="current-password" minlength="10" required></label>
413
+ <label id="tenantField" hidden>Tenant id, the name in your log's URL<input id="tenant" placeholder="acme" pattern="[a-z0-9][a-z0-9-]{1,38}[a-z0-9]"></label>
414
+ <button id="authGo" type="submit">Sign in</button>
415
+ <p class="msg" id="authMsg"></p>
416
+ </form>
417
+ <p class="muted"><button class="link" id="authSwap" type="button">Create an account and a tenant instead</button></p>
418
+ <p class="muted">The free plan is ten thousand appends a month, no card. Your gateway sends only hashes; nothing you log here can be read by us.</p>
419
+ </section>
420
+ <section id="welcome" class="auth" hidden>
421
+ <h1>Your tenant is ready</h1>
422
+ <div class="once"><p><b>Your first API key, shown once.</b> Put it in the environment your gateway reads as <code>AGENT_CUSTODY_LOG_TOKEN</code>. We keep only its hash.</p><p class="tok" id="firstToken"></p><button class="quiet" id="copyFirst">Copy key</button></div>
423
+ <h2>Your welcome sheet</h2>
424
+ <pre id="firstSheet"></pre>
425
+ <button id="toDash">Go to the dashboard</button>
426
+ </section>
427
+ <div class="layout" id="app" hidden>
428
+ <nav>
429
+ <div class="group">Monitor</div>
430
+ <a href="#overview" data-view="overview">Overview</a>
431
+ <a href="#usage" data-view="usage">Usage</a>
432
+ <div class="group">Configure</div>
433
+ <a href="#keys" data-view="keys">API keys <span class="n" id="nKeys"></span></a>
434
+ <a href="#billing" data-view="billing">Billing</a>
435
+ <a href="#export" data-view="export">Export</a>
436
+ <div class="group">Account</div>
437
+ <a href="#" id="signout">Sign out</a>
438
+ </nav>
439
+ <main>
440
+ <div data-pane="overview">
441
+ <h1>Overview</h1>
442
+ <div class="cards">
443
+ <div class="card" id="cUsed"><div class="k">Appends this month</div><div class="v" id="vUsed">–</div><div class="s" id="sUsed"></div></div>
444
+ <div class="card" id="cSize"><div class="k">Receipts in your log</div><div class="v" id="vSize">–</div><div class="s">leaf hashes, all time</div></div>
445
+ <div class="card" id="cKeys"><div class="k">Live keys</div><div class="v" id="vKeys">–</div><div class="s" id="sKeys"></div></div>
446
+ <div class="card" id="cCp"><div class="k">Latest checkpoint</div><div class="v" id="vCp">–</div><div class="s" id="sCp"></div></div>
447
+ </div>
448
+ <h2>Your log</h2>
449
+ <div class="panel"><table><tbody id="urls"></tbody></table></div>
450
+ <h2>Recent activity</h2>
451
+ <div class="panel"><table><thead><tr><th>when</th><th>who</th><th>action</th><th>detail</th></tr></thead><tbody id="audit"></tbody></table></div>
452
+ </div>
453
+ <div data-pane="usage" hidden>
454
+ <h1>Usage</h1>
455
+ <div class="panel"><div class="bars" id="bars"></div></div>
456
+ <p class="muted" id="usageNote"></p>
457
+ </div>
458
+ <div data-pane="keys" hidden>
459
+ <h1>API keys</h1>
460
+ <p class="muted">A key is a bearer token your gateway presents on append. It is shown once when minted; we keep only its hash. Mint a second key before revoking the first to rotate without a gap.</p>
461
+ <div class="panel"><form id="mintForm" style="display:flex;gap:.6rem;align-items:end;flex-wrap:wrap"><label style="margin:0">Label<input id="label" placeholder="support fleet"></label><button type="submit">Mint key</button></form>
462
+ <div id="minted" class="once" hidden><p><b>Shown once.</b></p><p class="tok" id="mintedTok"></p><button class="quiet" id="copyMinted">Copy key</button></div>
463
+ <table style="margin-top:1rem"><thead><tr><th>label</th><th>hash</th><th>created</th><th>state</th><th></th></tr></thead><tbody id="keys"></tbody></table></div>
464
+ <p class="msg" id="keysMsg"></p>
465
+ </div>
466
+ <div data-pane="billing" hidden>
467
+ <h1>Billing</h1>
468
+ <div class="panel" id="billingPanel"></div>
469
+ <p class="msg" id="billingMsg"></p>
470
+ </div>
471
+ <div data-pane="export" hidden>
472
+ <h1>Export</h1>
473
+ <p class="muted">Everything the log holds about you, any time, with your key: every leaf hash as a log file the verifier reads offline, the signed head, the published keys, the checkpoints, your usage, and the actions taken on your tenant. It checks itself before writing.</p>
474
+ <pre id="exportCmd"></pre>
475
+ <h2>Welcome sheet</h2>
476
+ <pre id="sheet"></pre>
477
+ </div>
478
+ </main>
479
+ </div>
480
+ <script>
481
+ (() => {
482
+ const $ = (id) => document.getElementById(id);
483
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
484
+ const api = async (method, path, body) => {
485
+ const r = await fetch(path, { method, headers: body !== undefined ? { "content-type": "application/json" } : {}, body: body !== undefined ? JSON.stringify(body) : undefined, credentials: "same-origin" });
486
+ const j = await r.json().catch(() => ({}));
487
+ if (!r.ok) { const e = new Error(j.error || r.statusText); e.status = r.status; throw e; }
488
+ return j;
489
+ };
490
+ const fmt = (n) => Number(n).toLocaleString();
491
+ const ago = (iso) => { const m = Math.round((Date.now() - Date.parse(iso)) / 60000); return m < 60 ? m + " min ago" : m < 1440 ? Math.round(m / 60) + " h ago" : Math.round(m / 1440) + " d ago"; };
492
+ let registering = false;
493
+ const show = (id) => { for (const s of ["auth", "welcome", "app"]) $(s).hidden = s !== id; };
494
+ const view = (name) => {
495
+ for (const p of document.querySelectorAll("[data-pane]")) p.hidden = p.dataset.pane !== name;
496
+ for (const a of document.querySelectorAll("nav a[data-view]")) a.classList.toggle("on", a.dataset.view === name);
497
+ location.hash = name;
498
+ };
499
+ $("authSwap").onclick = () => { registering = !registering; $("authTitle").textContent = registering ? "Create your tenant" : "Sign in"; $("authGo").textContent = registering ? "Create tenant" : "Sign in"; $("tenantField").hidden = !registering; $("tenant").required = registering; $("password").autocomplete = registering ? "new-password" : "current-password"; $("authSwap").textContent = registering ? "I already have an account" : "Create an account and a tenant instead"; };
500
+ $("authForm").onsubmit = async (e) => {
501
+ e.preventDefault(); $("authMsg").className = "msg"; $("authMsg").textContent = "";
502
+ try {
503
+ if (registering) {
504
+ const r = await api("POST", "/api/register", { email: $("email").value, password: $("password").value, tenant: $("tenant").value });
505
+ $("firstToken").textContent = r.token; $("firstSheet").textContent = r.welcome; show("welcome");
506
+ } else { await api("POST", "/api/login", { email: $("email").value, password: $("password").value }); await enter(); }
507
+ } catch (err) { $("authMsg").className = "msg err"; $("authMsg").textContent = err.message; }
508
+ };
509
+ $("copyFirst").onclick = () => navigator.clipboard.writeText($("firstToken").textContent);
510
+ $("toDash").onclick = () => enter();
511
+ $("signout").onclick = async (e) => { e.preventDefault(); await api("POST", "/api/logout", {}); location.hash = ""; show("auth"); };
512
+ for (const a of document.querySelectorAll("nav a[data-view]")) a.onclick = (e) => { e.preventDefault(); view(a.dataset.view); };
513
+ const render = (o) => {
514
+ $("tenantTag").textContent = o.tenant; $("tenantTag").hidden = false; $("planTag").textContent = o.plan + " plan"; $("planTag").hidden = false;
515
+ const pct = o.quota ? o.used / o.quota : 0;
516
+ $("cUsed").className = "card " + (o.quota === null ? "ok" : pct >= 1 ? "bad" : pct >= .8 ? "warn" : "ok");
517
+ $("vUsed").textContent = fmt(o.used); $("sUsed").textContent = o.quota === null ? "no allowance on " + o.plan : "of " + fmt(o.quota) + " on " + o.plan + (pct >= 1 ? ": appends are refused until next month" : "");
518
+ $("vSize").textContent = fmt(o.treeSize);
519
+ const live = o.keys.filter((k) => !k.revokedAt).length; $("vKeys").textContent = live; $("sKeys").textContent = o.keys.length - live + " revoked"; $("nKeys").textContent = live;
520
+ $("cCp").className = "card " + (o.latestCheckpoint ? (Date.now() - Date.parse(o.latestCheckpoint.signedAt) < 7 * 3600e3 ? "ok" : "warn") : "warn");
521
+ $("vCp").textContent = o.latestCheckpoint ? "size " + fmt(o.latestCheckpoint.treeSize) : "none yet"; $("sCp").textContent = o.latestCheckpoint ? "signed " + ago(o.latestCheckpoint.signedAt) : "published after your first append";
522
+ $("urls").innerHTML = [["Append here", o.urls.log], ["Log id on tree heads", o.logId], ["The log's keys", o.urls.keys], ["Your checkpoints", o.urls.checkpoints || "(not published)"], ["Current root", o.rootHash || "(empty)"]].map(([k, v]) => "<tr><th>" + esc(k) + "</th><td class=mono>" + esc(v) + "</td></tr>").join("");
523
+ $("audit").innerHTML = o.audit.map((e) => "<tr><td>" + esc(e.at.replace("T", " ").slice(0, 16)) + "</td><td class=mono>" + esc(e.actor) + "</td><td>" + esc(e.action) + "</td><td class=muted>" + esc(Object.entries(e.detail).map(([k, v]) => k + "=" + v).join(" ")) + "</td></tr>").join("") || "<tr><td colspan=4 class=muted>nothing yet</td></tr>";
524
+ const max = Math.max(1, ...o.months.map((m) => m.appends));
525
+ $("bars").innerHTML = o.months.map((m) => "<div class=bar><span class=mono>" + fmt(m.appends) + "</span><i style=\\"height:" + Math.max(2, Math.round(100 * m.appends / max)) + "%\\"></i><b>" + esc(m.month.slice(2)) + "</b></div>").join("");
526
+ $("usageNote").textContent = "Appends per calendar month, UTC. Your plan allows " + (o.quota === null ? "any number" : fmt(o.quota)) + " a month; the count resets on the first.";
527
+ $("keys").innerHTML = o.keys.map((k) => "<tr><td>" + esc(k.label) + "</td><td class=mono>" + esc(k.hash) + "</td><td>" + esc(k.createdAt.slice(0, 10)) + "</td><td>" + (k.revokedAt ? "revoked " + esc(k.revokedAt.slice(0, 10)) : "live") + "</td><td>" + (k.revokedAt ? "" : "<button class=quiet data-revoke=\\"" + esc(k.hash) + "\\">Revoke</button>") + "</td></tr>").join("");
528
+ $("billingPanel").innerHTML = o.plan === "free"
529
+ ? "<p>You are on the <b>free</b> plan: ten thousand appends a month, no card.</p><p>The <b>team</b> plan is <b>$50 a month</b>: a million appends, email support within two working days, the same export and audit trail. No availability commitment yet, and the design fails closed: when the log is unreachable your gateway withholds pre-committed calls.</p>" + (o.stripe ? "<button id=upgrade>Upgrade to team, $50/month</button>" : "<p class=muted>Card payments are not switched on for this portal yet; email us and we move the plan by hand.</p>")
530
+ : "<p>You are on the <b>" + esc(o.plan) + "</b> plan" + (o.billing ? " (subscription " + esc(o.billing.status) + ")" : "") + ".</p>" + (o.stripe && o.billing ? "<button class=quiet id=manage>Manage billing</button>" : "");
531
+ $("exportCmd").textContent = o.exportCommand; $("sheet").textContent = o.welcome;
532
+ const up = $("upgrade"); if (up) up.onclick = async () => { try { const r = await api("POST", "/api/checkout", {}); location.href = r.url; } catch (err) { $("billingMsg").className = "msg err"; $("billingMsg").textContent = err.message; } };
533
+ const mg = $("manage"); if (mg) mg.onclick = async () => { try { const r = await api("POST", "/api/billing-portal", {}); location.href = r.url; } catch (err) { $("billingMsg").className = "msg err"; $("billingMsg").textContent = err.message; } };
534
+ };
535
+ const load = async () => render(await api("GET", "/api/overview"));
536
+ const enter = async () => {
537
+ try { const me = await api("GET", "/api/me"); $("who").textContent = me.email; show("app"); await load(); view((location.hash || "#overview").slice(1) || "overview"); }
538
+ catch (err) { if (err.status === 401) show("auth"); else { show("app"); $("who").textContent = err.message; } }
539
+ };
540
+ $("mintForm").onsubmit = async (e) => { e.preventDefault(); try { const r = await api("POST", "/api/keys", { label: $("label").value }); $("mintedTok").textContent = r.token; $("minted").hidden = false; $("keysMsg").className = "msg ok"; $("keysMsg").textContent = "minted " + r.label + ", stored as hash " + r.tokenHash; await load(); } catch (err) { $("keysMsg").className = "msg err"; $("keysMsg").textContent = err.message; } };
541
+ $("copyMinted").onclick = () => navigator.clipboard.writeText($("mintedTok").textContent);
542
+ document.addEventListener("click", async (e) => { const b = e.target.closest("button[data-revoke]"); if (!b) return; if (!confirm("Revoke key " + b.dataset.revoke + "? A gateway using it stops appending at once.")) return; try { await api("POST", "/api/keys/" + b.dataset.revoke + "/revoke", {}); $("keysMsg").className = "msg ok"; $("keysMsg").textContent = "revoked"; await load(); } catch (err) { $("keysMsg").className = "msg err"; $("keysMsg").textContent = err.message; } });
543
+ if (new URLSearchParams(location.search).get("upgraded")) history.replaceState(null, "", "/#billing");
544
+ enter();
545
+ })();
546
+ </script>
547
+ `;
package/dist/verify.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Independent verification of a receipt bundle. Needs only public keys, and optionally a copy of the log.
2
2
  import { canonicalize, digestOf, dsseVerifiers, dsseVerify } from "./crypto.js";
3
- import { decodeDelegation, delegationValidAt, describeChain, verifyDelegation } from "./delegation.js";
3
+ import { delegationHasParent, delegationValidAt, describeChain, verifyDelegation } from "./delegation.js";
4
4
  import { leafHash, MerkleLog, verifyConsistency, verifyInclusion } from "./log.js";
5
5
  import { checkProvider, checkUpstream, contentDigest, isProviderAttestation } from "./upstream.js";
6
6
  import { AUTHORIZATION_PREDICATE_TYPE, RECEIPT_PREDICATE_TYPE, RECEIPT_TYPE, TREEHEAD_TYPE } from "./receipt.js";
@@ -33,7 +33,7 @@ export function verifyBundle(bundle, opts) {
33
33
  add("delegation signature (principal key)", del.ok, del.ok ? `signed by ${short(del.keyid)}` : del.error);
34
34
  // A chained grant: every link signed by the key its parent names, scopes and windows nested, one principal
35
35
  // throughout. The line appears only when the grant embeds a parent, and fails with the link that broke.
36
- const chained = decodeDelegation(p.delegation.envelope)?.parent !== undefined;
36
+ const chained = delegationHasParent(p.delegation.envelope);
37
37
  if (chained)
38
38
  add("delegation chain to the principal", del.ok, del.ok ? `${describeChain(del.chain)} (${del.chain.length - 1} delegation(s))` : del.error);
39
39
  if (del.ok) {
@@ -38,7 +38,9 @@ Each row names the attacker, the move, the defence, and whether the defence is a
38
38
  | attack | defence | kind |
39
39
  | --- | --- | --- |
40
40
  | Call a tool the grant does not allow | The gateway evaluates the policy against the grant the principal signed; the agent never sees the policy or the key. A denied call never reaches the upstream and still produces a receipt. | deployment: only calls through the gateway are covered |
41
- | Feed the policy lies in the arguments | `context.args` is `claimed`; policies that matter read `context.facts`, which the gateway fetched itself and marks `observed`. A policy that decides on args alone is the operator's choice and the receipt shows it. | evidence: provenance is in the receipt |
41
+ | Feed the policy lies in the arguments | `context.args` is `claimed`; policies that matter read `context.facts`, which the gateway fetched itself and marks `observed`. A policy that decides on args alone is the operator's choice and the receipt shows it. Arguments are passed to Cedar as data only: a value shaped as a Cedar entity or extension (`__entity`, `__extn`) is a deny before evaluation, so an argument cannot satisfy a comparison by its shape. | evidence: provenance is in the receipt |
42
+ | Make the gateway run a fact lookup on the way to a denial | A fact lookup runs before the policy decides, because the policy decides on it, and only after the tool is in the grant's scope. It is therefore a read the gateway makes on the agent's request: a fact tool named in `precommit` is refused at startup, so no consequential call can be reached through a lookup, and every lookup is on the receipt with its arguments. | deployment: fact tools must be reads |
43
+ | Read the gateway's secrets from a tool process | A stdio upstream is started with a minimal environment (the path, home, and temp directories, plus what its own config names), never the gateway's, so the tokens other upstreams and the log are given do not reach it. | deployment |
42
44
  | Bypass the gateway with a credential in its own environment | Nothing in the packages prevents this. The gateway covers the tools behind it and no others. | not covered; the deployment must keep credentials out of the agent |
43
45
  | Skip an in-process hook or SDK wrapper | Same: an SDK receipt is the agent's own report, and every field in it is `claimed`. | not covered; use the gateway for anything consequential |
44
46
  | Write memory the fleet will trust | Writes not made through the gateway are `claimed` and quarantined until a gateway confirms them; reads leave them out unless asked for. | deployment |
@@ -73,7 +75,7 @@ Each row names the attacker, the move, the defence, and whether the defence is a
73
75
 
74
76
  | attack | defence | kind |
75
77
  | --- | --- | --- |
76
- | Read receipt contents | With `hashOnly`, the log never receives them; only leaf hashes cross the wire. This is the default in the welcome sheet and the runbook, and it is the tenant's setting, not ours. | deployment on the tenant's side |
78
+ | Read receipt contents | The hosted log runs with `--hash-only` and refuses a full leaf at append, so no receipt reaches the log process even from a misconfigured gateway or a stolen token; the tenant's `hashOnly` setting keeps the receipt from ever leaving their machine. A log an operator runs for themselves may accept full leaves. | evidence: the server refuses; deployment on the tenant's side |
77
79
  | Rewrite a tenant's tree | Every head is signed and published as a checkpoint on a second host; a rewrite means two signed heads at one size with different roots, or a later head that does not extend an earlier one. Anyone holding an earlier head detects it with `audit`; the monitor does it every ten minutes; the witness countersigns only heads that extend the last it signed. | evidence, given a witness or an earlier head held elsewhere |
78
80
  | Show different histories to different verifiers | Same: checkpoints are public and the witness sees one history. Without a witness, two verifiers who compare heads detect it, and nobody else does. | evidence with a witness; otherwise detection needs comparison |
79
81
  | Mint a token for a tenant and append noise | Appends are hashes with no content; the tenant's export and usage show leaves and live tokens they did not make, and the audit trail, which the tenant's export carries, records who minted what and from where. | deployment |
@@ -96,6 +98,7 @@ Each row names the attacker, the move, the defence, and whether the defence is a
96
98
  | Alter an append in flight | TLS between gateway and log; the leaf hash is over a signed envelope, so an altered hash simply fails inclusion for the real receipt. | evidence |
97
99
  | Drop the log's answer | The gateway retries, then errors or withholds; no receipt is handed out without an inclusion proof. Timeouts bound the wait. | evidence |
98
100
  | Steal a tenant token | Tokens are bearer secrets; a stolen one appends noise to that tenant's log until revoked. Rate limits bound the damage per second; the tenant's usage shows it. | deployment |
101
+ | Escape a per-address limit by forging `X-Forwarded-For` | Behind the proxy the deployment runs, the limiter keys on the address the proxy appended, the rightmost entry, never on one the client supplied. | deployment |
99
102
 
100
103
  ## What is not defended
101
104