@agent-custody/receipts 0.4.0 → 0.5.1
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/README.md +6 -1
- package/dist/checkpoints.d.ts +26 -0
- package/dist/checkpoints.js +95 -0
- package/dist/cli.js +164 -15
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/dist/log-admin.d.ts +33 -0
- package/dist/log-admin.js +235 -0
- package/dist/log-sink.d.ts +59 -3
- package/dist/log-sink.js +248 -71
- package/dist/log-store.d.ts +122 -0
- package/dist/log-store.js +312 -0
- package/dist/log.d.ts +13 -0
- package/dist/log.js +1 -1
- package/dist/signer.d.ts +64 -0
- package/dist/signer.js +136 -0
- package/docs/usage.md +1 -1
- package/docs/verification.md +3 -1
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -225,6 +225,9 @@ If a vendor tells you their receipts prove more than the first five rows, ask th
|
|
|
225
225
|
src/config.ts gateway and SDK config schemas, path resolution
|
|
226
226
|
src/crypto.ts canonical JSON, sha256, Ed25519 keys, DSSE sign/verify
|
|
227
227
|
src/log.ts Merkle log: append, root, inclusion and consistency proofs, verify, JSONL persistence
|
|
228
|
+
src/signer.ts the signer: the log's key in its own process, the key document verifiers fetch
|
|
229
|
+
src/checkpoints.ts signed heads published on a schedule, to files and to Postgres
|
|
230
|
+
src/log-store.ts the log server's backends: the file, and Postgres with tenants, hashed tokens, one writer per tenant, rate limits
|
|
228
231
|
src/log-sink.ts where leaves go: the local file, or a remote log over HTTP; plus the reference log server
|
|
229
232
|
src/policy.ts Cedar evaluation wrapper, fail-closed
|
|
230
233
|
src/delegation.ts signed delegation grants
|
|
@@ -277,6 +280,8 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
|
|
|
277
280
|
- Remote log: the issuer can append to a log run by someone else over HTTP, whose key then signs the tree heads, so a verifier learns the receipt was in a log the operator could not rewrite. Includes the reference log server, bearer-token auth, and a root endpoint for auditors.
|
|
278
281
|
- Framework adapters, each tested against the real package with a scripted model and no network: OpenAI Agents SDK (`wrapTools` enforces, `observeRunner` records from lifecycle events), Vercel AI SDK (`wrapTools` over a real `generateText` loop), LangChain (`ReceiptCallbackHandler` records, `issuer.wrap` enforces).
|
|
279
282
|
|
|
283
|
+
- The signer, keys, and checkpoints: the key in its own process (`signer`, `--signer-url`), the key document at `/.well-known/agent-custody-log.json` fetched and pinned by `verify --log-url` and `audit --log-url`, and signed checkpoints per log published to a directory and a table for a verifier who was not watching. Phase 3 of [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
|
|
284
|
+
- The log over Postgres: `log --db-env`, leaves as hashes in one table keyed by tenant, one writer per tenant by advisory lock, tenants and hashed tokens in tables managed by `log-admin`, rate limits and a body cap, retries in the sink, and `import` for an existing file log. Phase 2 of [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
|
|
280
285
|
- A log for someone else: `hashOnly` sends leaf hashes so the log never holds a receipt; the reference server runs several tenant logs at `/t/<tenant>/` with their own tokens and ids; tree heads name their log and the verifier checks it with `--log-id`. Phase 1 of the hosted log, [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
|
|
281
286
|
- OpenTelemetry export: with `otel` in either config, every receipt is also one span at the collector the team already runs, trace id equal to the receipt id, attributes for tool, agent, principal, status, decision, and log position; after the receipt, best effort, never on the evidence path.
|
|
282
287
|
- The REST connector: a plain HTTP API described as tools in the gateway config, credentials from the environment, so an agent's direct API calls become receipted, policy-checked tool calls through the gateway.
|
|
@@ -284,7 +289,7 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
|
|
|
284
289
|
|
|
285
290
|
**Next, in the order it pays off**
|
|
286
291
|
|
|
287
|
-
1. The hosted log, [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6)
|
|
292
|
+
1. The hosted log, [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6): running at log.agent-custody.dev with keys published and checkpoints on a second host, taking its first tenants. What remains is the witness that countersigns checkpoints.
|
|
288
293
|
2. Post-quantum signatures: ML-DSA beside Ed25519 in the same DSSE envelope, hybrid by default when a PQ key is present, in every signed artefact and in the browser verifier. [Issue #11](https://github.com/ch4r10t33r/agent-custody/issues/11).
|
|
289
294
|
3. An HTTP transport for the gateway, with the grant presented per connection, for a shared deployment rather than one process per agent session.
|
|
290
295
|
4. Delegation chains for sub-agents.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Envelope } from "./crypto.ts";
|
|
2
|
+
import type { PostgresLike } from "./log-store.ts";
|
|
3
|
+
export interface Checkpoint {
|
|
4
|
+
tenant: string;
|
|
5
|
+
logId: string | undefined;
|
|
6
|
+
treeSize: number;
|
|
7
|
+
rootHash: string;
|
|
8
|
+
signedAt: string;
|
|
9
|
+
envelope: Envelope;
|
|
10
|
+
}
|
|
11
|
+
export interface CheckpointStore {
|
|
12
|
+
save(c: Checkpoint): Promise<void>;
|
|
13
|
+
/** checkpoints of a tenant with treeSize > since, oldest first */
|
|
14
|
+
list(tenant: string, since?: number): Promise<Checkpoint[]>;
|
|
15
|
+
latest(tenant: string): Promise<Checkpoint | null>;
|
|
16
|
+
}
|
|
17
|
+
/** Files: <dir>/<tenant>/<treeSize>.json and <dir>/<tenant>/latest.json. Serve the directory read-only from the checkpoints host. */
|
|
18
|
+
export declare function dirCheckpoints(dir: string): CheckpointStore;
|
|
19
|
+
/** Rows in <prefix>heads, one per tenant and tree size. */
|
|
20
|
+
export declare function postgresCheckpoints(client: PostgresLike, prefix?: string): CheckpointStore;
|
|
21
|
+
/**
|
|
22
|
+
* Writes every checkpoint to each store: the directory the checkpoints host serves and the database the API lists
|
|
23
|
+
* from. `latest` is the store that is furthest behind, so a store that missed a write (a directory that was not yet
|
|
24
|
+
* writable, say) is caught up on the next publication; saves are idempotent in every store.
|
|
25
|
+
*/
|
|
26
|
+
export declare function bothCheckpoints(...stores: CheckpointStore[]): CheckpointStore;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Checkpoints: signed tree heads published on a schedule to a place the log's own API does not have to be up to
|
|
2
|
+
// serve. A verifier who kept a head can fetch a later checkpoint and the consistency proof between them, and
|
|
3
|
+
// learn that nothing was rewritten while nobody was watching. On disk they are plain files, one per tree size,
|
|
4
|
+
// meant to be served statically from a second host; in Postgres they are rows, so the API can list them too.
|
|
5
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
const safe = (s) => s.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
8
|
+
/** Files: <dir>/<tenant>/<treeSize>.json and <dir>/<tenant>/latest.json. Serve the directory read-only from the checkpoints host. */
|
|
9
|
+
export function dirCheckpoints(dir) {
|
|
10
|
+
const folder = (tenant) => join(dir, safe(tenant));
|
|
11
|
+
const read = (tenant, name) => {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(join(folder(tenant), name), "utf8"));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
async save(c) {
|
|
21
|
+
mkdirSync(folder(c.tenant), { recursive: true });
|
|
22
|
+
const text = JSON.stringify(c, null, 2);
|
|
23
|
+
writeFileSync(join(folder(c.tenant), `${c.treeSize}.json`), text);
|
|
24
|
+
writeFileSync(join(folder(c.tenant), "latest.json"), text);
|
|
25
|
+
},
|
|
26
|
+
async list(tenant, since = -1) {
|
|
27
|
+
let names;
|
|
28
|
+
try {
|
|
29
|
+
names = readdirSync(folder(tenant));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
return names
|
|
35
|
+
.filter((n) => /^\d+\.json$/.test(n))
|
|
36
|
+
.map((n) => Number(n.slice(0, -5)))
|
|
37
|
+
.filter((n) => n > since)
|
|
38
|
+
.sort((a, b) => a - b)
|
|
39
|
+
.map((n) => read(tenant, `${n}.json`))
|
|
40
|
+
.filter((c) => c !== null);
|
|
41
|
+
},
|
|
42
|
+
async latest(tenant) {
|
|
43
|
+
return read(tenant, "latest.json");
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Rows in <prefix>heads, one per tenant and tree size. */
|
|
48
|
+
export function postgresCheckpoints(client, prefix = "log_") {
|
|
49
|
+
if (!/^[a-z_][a-z0-9_]*$/.test(prefix))
|
|
50
|
+
throw new Error(`prefix must be a plain lowercase identifier; got "${prefix}"`);
|
|
51
|
+
const table = `${prefix}heads`;
|
|
52
|
+
let ready = null;
|
|
53
|
+
const init = () => (ready ??= client.query(`CREATE TABLE IF NOT EXISTS ${table} (tenant_id TEXT NOT NULL, tree_size BIGINT NOT NULL, log_id TEXT, root_hash TEXT NOT NULL, signed_at TIMESTAMPTZ NOT NULL, envelope TEXT NOT NULL, PRIMARY KEY (tenant_id, tree_size))`).then(() => { }));
|
|
54
|
+
const row = (r) => ({ tenant: String(r.tenant_id), logId: r.log_id ? String(r.log_id) : undefined, treeSize: Number(r.tree_size), rootHash: String(r.root_hash), signedAt: new Date(r.signed_at).toISOString(), envelope: JSON.parse(String(r.envelope)) });
|
|
55
|
+
return {
|
|
56
|
+
async save(c) {
|
|
57
|
+
await init();
|
|
58
|
+
await client.query(`INSERT INTO ${table} (tenant_id, tree_size, log_id, root_hash, signed_at, envelope) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (tenant_id, tree_size) DO NOTHING`, [c.tenant, c.treeSize, c.logId ?? null, c.rootHash, c.signedAt, JSON.stringify(c.envelope)]);
|
|
59
|
+
},
|
|
60
|
+
async list(tenant, since = -1) {
|
|
61
|
+
await init();
|
|
62
|
+
return (await client.query(`SELECT tenant_id, tree_size, log_id, root_hash, signed_at, envelope FROM ${table} WHERE tenant_id = $1 AND tree_size > $2 ORDER BY tree_size`, [tenant, since])).rows.map(row);
|
|
63
|
+
},
|
|
64
|
+
async latest(tenant) {
|
|
65
|
+
await init();
|
|
66
|
+
const rows = (await client.query(`SELECT tenant_id, tree_size, log_id, root_hash, signed_at, envelope FROM ${table} WHERE tenant_id = $1 ORDER BY tree_size DESC LIMIT 1`, [tenant])).rows;
|
|
67
|
+
return rows[0] ? row(rows[0]) : null;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Writes every checkpoint to each store: the directory the checkpoints host serves and the database the API lists
|
|
73
|
+
* from. `latest` is the store that is furthest behind, so a store that missed a write (a directory that was not yet
|
|
74
|
+
* writable, say) is caught up on the next publication; saves are idempotent in every store.
|
|
75
|
+
*/
|
|
76
|
+
export function bothCheckpoints(...stores) {
|
|
77
|
+
return {
|
|
78
|
+
async save(c) {
|
|
79
|
+
for (const s of stores)
|
|
80
|
+
await s.save(c);
|
|
81
|
+
},
|
|
82
|
+
list: (t, since) => stores[0].list(t, since),
|
|
83
|
+
async latest(t) {
|
|
84
|
+
let behind;
|
|
85
|
+
for (const s of stores) {
|
|
86
|
+
const l = await s.latest(t);
|
|
87
|
+
if (l === null)
|
|
88
|
+
return null;
|
|
89
|
+
if (behind === undefined || l.treeSize < behind.treeSize)
|
|
90
|
+
behind = l;
|
|
91
|
+
}
|
|
92
|
+
return behind ?? null;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,12 @@ import { loadConfig, loadSdkConfig } from "./config.js";
|
|
|
6
6
|
import { generateKeyPair, loadPrivateKey, loadPublicKey, writeKeyPair } from "./crypto.js";
|
|
7
7
|
import { createDelegation } from "./delegation.js";
|
|
8
8
|
import { createGateway, serveStdio } from "./gateway.js";
|
|
9
|
-
import { serveLog } from "./log-sink.js";
|
|
9
|
+
import { postgresResolver, serveLog } from "./log-sink.js";
|
|
10
|
+
import { importLogFile, PostgresTenancy } from "./log-store.js";
|
|
11
|
+
import { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.js";
|
|
12
|
+
import { connectSigner, fetchLogKeys, localSigner, serveSigner } from "./signer.js";
|
|
13
|
+
import { CheckpointPublisher, fileResolver } from "./log-sink.js";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
10
15
|
import { pruneLog } from "./retention.js";
|
|
11
16
|
import { serveSidecar } from "./sidecar.js";
|
|
12
17
|
import { MerkleLog } from "./log.js";
|
|
@@ -30,10 +35,43 @@ const USAGE = `agent-custody <command>
|
|
|
30
35
|
prune --log <log.jsonl> --before <ISO instant> [--receipts <dir>]
|
|
31
36
|
retention on the receipt log: replaces older leaves with their hashes, so proofs still verify and the content is gone
|
|
32
37
|
log --file <log.jsonl> --key <log.key> [--port 8787] [--host 127.0.0.1] [--token-env <NAME>] reference log server
|
|
33
|
-
verify <bundle.json> --issuer-key <pub> [--principal-key <pub>] [--log-key <pub>] [--log-id <id>] [--upstream-key <pub>] [--stripe-secret-env NAME] [--github-secret-env NAME] [--log <log.jsonl>] [--json]
|
|
34
|
-
|
|
38
|
+
verify <bundle.json> --issuer-key <pub> [--principal-key <pub>] [--log-key <pub> | --log-url <url>] [--log-id <id>] [--upstream-key <pub>] [--stripe-secret-env NAME] [--github-secret-env NAME] [--log <log.jsonl>] [--json]
|
|
39
|
+
log ... --db-env NAME the same server over Postgres: tenants and tokens from the database, one writer per tenant,
|
|
40
|
+
root paths serve the tenant "default" (created with --log-id). Needs the pg package.
|
|
41
|
+
log ... (--key <log.key> [--retired-key <pub>]... | --signer-url <url> [--signer-token-env NAME]) [--checkpoint-dir <dir>] [--checkpoint-every <seconds>]
|
|
42
|
+
sign with a key in this process, or through a signer process that holds it; publish a signed
|
|
43
|
+
checkpoint per log that has grown, every 300 s by default, to the directory (and, with a
|
|
44
|
+
database, to its heads table); serve the key document at /.well-known/agent-custody-log.json
|
|
45
|
+
log ... --db-env NAME --admin-token-env NAME [--public-url <https://log.example.com/>] [--checkpoints-url <https://checkpoints.example.com/>]
|
|
46
|
+
the operator's admin page at /admin and its API, behind the admin token: tenants, tokens shown once,
|
|
47
|
+
the welcome sheet; the public URLs fill the sheet in
|
|
48
|
+
signer --key <log.key> --port 8790 [--host 127.0.0.1] [--token-env NAME] [--retired-key <pub>]...
|
|
49
|
+
the one process that holds the log's key: POST /sign, GET /keys
|
|
50
|
+
log-admin --db-env NAME tenant add <id> [--log-id <id>] | tenant list | tenant disable <id>
|
|
51
|
+
log-admin --db-env NAME token add <tenant> --label <text> | token list <tenant> | token revoke <tenant> <hash-prefix>
|
|
52
|
+
log-admin --db-env NAME import --file <log.jsonl> [--tenant default] copies a file log into the database as hashes
|
|
53
|
+
audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) [--issuer-key <pub>] [--log-key <pub>] [--log-id <id>] [--json]
|
|
54
|
+
with --log-url the log's published keys are fetched and pinned by keyid
|
|
35
55
|
checks that the newer receipt's log extends the older one's: nothing between them was rewritten
|
|
36
56
|
`;
|
|
57
|
+
/** A retired public key for the key document, from a .pub file; still listed so heads it signed keep verifying. */
|
|
58
|
+
function retiredKey(pubFile) {
|
|
59
|
+
return { key: loadPublicKey(pubFile), pem: readFileSync(pubFile, "utf8") };
|
|
60
|
+
}
|
|
61
|
+
/** A pg Pool from the URL in an environment variable. pg is an optional peer: it is loaded only here, and its absence says what to install. */
|
|
62
|
+
function openPostgres(envName) {
|
|
63
|
+
const url = process.env[envName];
|
|
64
|
+
if (!url)
|
|
65
|
+
throw new Error(`environment variable ${envName} is not set`);
|
|
66
|
+
let Pool;
|
|
67
|
+
try {
|
|
68
|
+
({ Pool } = createRequire(import.meta.url)("pg"));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new Error("a Postgres log needs the pg package: npm install pg");
|
|
72
|
+
}
|
|
73
|
+
return new Pool({ connectionString: url });
|
|
74
|
+
}
|
|
37
75
|
/** The tenants file for `log --tenants`: paths relative to the file, tokens from the environment, ids default to the tenant name. */
|
|
38
76
|
function loadTenants(path) {
|
|
39
77
|
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -118,6 +156,66 @@ async function main(argv) {
|
|
|
118
156
|
await running.close();
|
|
119
157
|
return 0;
|
|
120
158
|
}
|
|
159
|
+
case "signer": {
|
|
160
|
+
const { values } = parseArgs({ args: rest, options: { key: { type: "string" }, port: { type: "string", default: "8790" }, host: { type: "string", default: "127.0.0.1" }, "token-env": { type: "string" }, "retired-key": { type: "string", multiple: true } } });
|
|
161
|
+
if (!values.key)
|
|
162
|
+
throw new Error("signer needs --key");
|
|
163
|
+
const token = values["token-env"] ? process.env[values["token-env"]] : undefined;
|
|
164
|
+
if (values["token-env"] && !token)
|
|
165
|
+
throw new Error(`signer: environment variable ${values["token-env"]} is not set`);
|
|
166
|
+
const kp = loadPrivateKey(values.key);
|
|
167
|
+
const running = await serveSigner(kp, { port: Number(values.port), host: values.host, ...(token ? { token } : {}), retired: (values["retired-key"] ?? []).map(retiredKey) });
|
|
168
|
+
console.error(`agent-custody signer: ${running.url} keyid=${kp.keyid} ${token ? "token required" : "open: bind this to a private network"}${values["retired-key"]?.length ? ` retired=${values["retired-key"].length}` : ""}`);
|
|
169
|
+
await new Promise((resolve) => process.once("SIGINT", resolve));
|
|
170
|
+
await running.close();
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
case "log-admin": {
|
|
174
|
+
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { "db-env": { type: "string" }, "log-id": { type: "string" }, label: { type: "string" }, file: { type: "string" }, tenant: { type: "string", default: "default" } } });
|
|
175
|
+
if (!values["db-env"])
|
|
176
|
+
throw new Error("log-admin needs --db-env NAME");
|
|
177
|
+
const tenancy = new PostgresTenancy(openPostgres(values["db-env"]));
|
|
178
|
+
const [what, verb, ...args] = positionals;
|
|
179
|
+
if (what === "tenant" && verb === "add" && args[0]) {
|
|
180
|
+
const t = await tenancy.addTenant(args[0], values["log-id"] ?? args[0]);
|
|
181
|
+
console.log(`tenant ${t.id} log=${t.logId} reached at /t/${t.id}/`);
|
|
182
|
+
}
|
|
183
|
+
else if (what === "tenant" && verb === "list") {
|
|
184
|
+
for (const t of await tenancy.listTenants())
|
|
185
|
+
console.log(`${t.id.padEnd(24)} log=${t.logId.padEnd(28)} created ${t.createdAt}${t.disabledAt ? ` DISABLED ${t.disabledAt}` : ""}`);
|
|
186
|
+
}
|
|
187
|
+
else if (what === "tenant" && verb === "disable" && args[0]) {
|
|
188
|
+
await tenancy.disableTenant(args[0]);
|
|
189
|
+
console.log(`tenant ${args[0]} disabled`);
|
|
190
|
+
}
|
|
191
|
+
else if (what === "token" && verb === "add" && args[0]) {
|
|
192
|
+
if (!values.label)
|
|
193
|
+
throw new Error("token add needs --label");
|
|
194
|
+
const { token, tokenHash } = await tenancy.addToken(args[0], values.label);
|
|
195
|
+
console.error(`token for ${args[0]} (${values.label}); shown once, stored as hash ${tokenHash.slice(0, 12)}…:`);
|
|
196
|
+
console.log(token);
|
|
197
|
+
}
|
|
198
|
+
else if (what === "token" && verb === "list" && args[0]) {
|
|
199
|
+
for (const t of await tenancy.listTokens(args[0]))
|
|
200
|
+
console.log(`${t.tokenHash.slice(0, 12)} ${t.label.padEnd(24)} created ${t.createdAt}${t.revokedAt ? ` REVOKED ${t.revokedAt}` : ""}`);
|
|
201
|
+
}
|
|
202
|
+
else if (what === "token" && verb === "revoke" && args[0] && args[1]) {
|
|
203
|
+
console.log(`revoked ${await tenancy.revokeToken(args[0], args[1])} token(s)`);
|
|
204
|
+
}
|
|
205
|
+
else if (what === "import") {
|
|
206
|
+
if (!values.file)
|
|
207
|
+
throw new Error("import needs --file <log.jsonl>");
|
|
208
|
+
if (!(await tenancy.tenant(values.tenant)))
|
|
209
|
+
throw new Error(`unknown tenant ${values.tenant}; add it first`);
|
|
210
|
+
const r = await importLogFile(values.file, await tenancy.log(values.tenant));
|
|
211
|
+
console.log(`imported ${r.added} leaf hash(es) into ${values.tenant}; the log now has ${r.total}`);
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
console.error(USAGE);
|
|
215
|
+
return 2;
|
|
216
|
+
}
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
121
219
|
case "prune": {
|
|
122
220
|
const { values } = parseArgs({ args: rest, options: { log: { type: "string" }, before: { type: "string" }, receipts: { type: "string" } } });
|
|
123
221
|
if (!values.log || !values.before)
|
|
@@ -131,19 +229,64 @@ async function main(argv) {
|
|
|
131
229
|
case "log": {
|
|
132
230
|
const { values } = parseArgs({
|
|
133
231
|
args: rest,
|
|
134
|
-
options: { file: { type: "string" }, key: { type: "string" }, port: { type: "string", default: "8787" }, host: { type: "string", default: "127.0.0.1" }, "token-env": { type: "string" }, "log-id": { type: "string" }, tenants: { type: "string" } },
|
|
232
|
+
options: { file: { type: "string" }, key: { type: "string" }, port: { type: "string", default: "8787" }, host: { type: "string", default: "127.0.0.1" }, "token-env": { type: "string" }, "log-id": { type: "string" }, tenants: { type: "string" }, "db-env": { type: "string" }, "signer-url": { type: "string" }, "signer-token-env": { type: "string" }, "retired-key": { type: "string", multiple: true }, "checkpoint-dir": { type: "string" }, "checkpoint-every": { type: "string", default: "300" }, "admin-token-env": { type: "string" }, "public-url": { type: "string" }, "checkpoints-url": { type: "string" } },
|
|
135
233
|
});
|
|
136
|
-
if (!values.
|
|
137
|
-
throw new Error("log needs --
|
|
234
|
+
if (!values.key === !values["signer-url"])
|
|
235
|
+
throw new Error("log needs exactly one of --key or --signer-url");
|
|
138
236
|
const token = values["token-env"] ? process.env[values["token-env"]] : undefined;
|
|
139
237
|
if (values["token-env"] && !token)
|
|
140
238
|
throw new Error(`log: environment variable ${values["token-env"]} is not set`);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
239
|
+
// The signer: a key in this process, or the signer service that holds it.
|
|
240
|
+
let signer;
|
|
241
|
+
if (values.key)
|
|
242
|
+
signer = localSigner(loadPrivateKey(values.key), { retired: (values["retired-key"] ?? []).map(retiredKey) });
|
|
243
|
+
else {
|
|
244
|
+
const st = values["signer-token-env"] ? process.env[values["signer-token-env"]] : undefined;
|
|
245
|
+
if (values["signer-token-env"] && !st)
|
|
246
|
+
throw new Error(`log: environment variable ${values["signer-token-env"]} is not set`);
|
|
247
|
+
signer = await connectSigner(values["signer-url"], st ? { token: st } : {});
|
|
248
|
+
}
|
|
249
|
+
const everyMs = Number(values["checkpoint-every"]) * 1000;
|
|
250
|
+
if (!(everyMs > 0))
|
|
251
|
+
throw new Error("--checkpoint-every must be a positive number of seconds");
|
|
252
|
+
let resolver;
|
|
253
|
+
let checkpoints = values["checkpoint-dir"] ? dirCheckpoints(values["checkpoint-dir"]) : undefined;
|
|
254
|
+
let where;
|
|
255
|
+
let admin;
|
|
256
|
+
if (values["db-env"]) {
|
|
257
|
+
// Postgres: the file is not used; tenants, tokens, leaves, and checkpoints live in the database.
|
|
258
|
+
const client = openPostgres(values["db-env"]);
|
|
259
|
+
const tenancy = new PostgresTenancy(client);
|
|
260
|
+
const defaultId = values["log-id"] ?? "default";
|
|
261
|
+
if (!(await tenancy.tenant("default")))
|
|
262
|
+
await tenancy.addTenant("default", defaultId);
|
|
263
|
+
resolver = postgresResolver(tenancy, { defaultTenant: "default", ...(token ? { staticTokens: [token] } : {}) });
|
|
264
|
+
const table = postgresCheckpoints(client);
|
|
265
|
+
checkpoints = checkpoints ? bothCheckpoints(table, checkpoints) : table;
|
|
266
|
+
if (values["admin-token-env"]) {
|
|
267
|
+
const adminToken = process.env[values["admin-token-env"]];
|
|
268
|
+
if (!adminToken)
|
|
269
|
+
throw new Error(`log: environment variable ${values["admin-token-env"]} is not set`);
|
|
270
|
+
admin = { tenancy, token: adminToken, ...(values["public-url"] ? { publicUrl: values["public-url"] } : {}), ...(values["checkpoints-url"] ? { checkpointsUrl: values["checkpoints-url"] } : {}) };
|
|
271
|
+
}
|
|
272
|
+
where = `store=postgres default-log=${(await tenancy.tenant("default"))?.logId} ${token ? "environment token accepted for the default log; " : ""}tokens from the database`;
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
if (values["admin-token-env"])
|
|
276
|
+
throw new Error("the admin page needs --db-env; tenants live in the database");
|
|
277
|
+
if (!values.file)
|
|
278
|
+
throw new Error("log needs --file, or --db-env");
|
|
279
|
+
// --tenants names a JSON file { "<tenant>": { "file": "...", "tokenEnv": "NAME", "logId": "..." } }; each is reached at /t/<tenant>/.
|
|
280
|
+
const tenants = values.tenants ? loadTenants(values.tenants) : undefined;
|
|
281
|
+
resolver = fileResolver(values.file, { ...(token ? { tokens: [token] } : {}), ...(values["log-id"] ? { logId: values["log-id"] } : {}), ...(tenants ? { tenants } : {}) });
|
|
282
|
+
where = `file=${values.file}${values["log-id"] ? ` log=${values["log-id"]}` : ""} ${token ? "bearer token required" : "open, anyone may append"}${tenants ? ` tenants=${Object.keys(tenants).join(",")}` : ""}`;
|
|
283
|
+
}
|
|
284
|
+
const running = await serveLog(resolver, signer, { port: Number(values.port), host: values.host, ...(checkpoints ? { checkpoints } : {}), ...(admin ? { admin } : {}) });
|
|
285
|
+
const publisher = checkpoints ? new CheckpointPublisher(resolver, signer, checkpoints, everyMs) : null;
|
|
286
|
+
publisher?.start();
|
|
287
|
+
console.error(`agent-custody log: ${running.url} keyid=${signer.keyid} ${values["signer-url"] ? `signer=${values["signer-url"]} ` : ""}${where}${checkpoints ? ` checkpoints every ${values["checkpoint-every"]}s${values["checkpoint-dir"] ? ` to ${values["checkpoint-dir"]}` : ""}` : ""}${admin ? " admin page at /admin" : ""}`);
|
|
146
288
|
await new Promise((resolve) => process.once("SIGINT", resolve));
|
|
289
|
+
publisher?.stop();
|
|
147
290
|
await running.close();
|
|
148
291
|
return 0;
|
|
149
292
|
}
|
|
@@ -156,6 +299,7 @@ async function main(argv) {
|
|
|
156
299
|
"gateway-key": { type: "string", multiple: true },
|
|
157
300
|
"principal-key": { type: "string", multiple: true },
|
|
158
301
|
"log-key": { type: "string", multiple: true },
|
|
302
|
+
"log-url": { type: "string" },
|
|
159
303
|
"log-id": { type: "string" },
|
|
160
304
|
"upstream-key": { type: "string", multiple: true },
|
|
161
305
|
"stripe-secret-env": { type: "string" },
|
|
@@ -169,10 +313,12 @@ async function main(argv) {
|
|
|
169
313
|
if (!file || issuerKeyFiles.length === 0)
|
|
170
314
|
throw new Error("verify needs <bundle> --issuer-key (alias --gateway-key)");
|
|
171
315
|
const bundle = JSON.parse(readFileSync(file, "utf8"));
|
|
316
|
+
const fetchedLogKeys = values["log-url"] ? (await fetchLogKeys(values["log-url"])).keys : [];
|
|
317
|
+
const logKeys = [...(values["log-key"] ?? []).map(loadPublicKey), ...fetchedLogKeys];
|
|
172
318
|
const result = verifyBundle(bundle, {
|
|
173
319
|
issuerKeys: issuerKeyFiles.map(loadPublicKey),
|
|
174
320
|
principalKeys: (values["principal-key"] ?? []).map(loadPublicKey),
|
|
175
|
-
...(
|
|
321
|
+
...(logKeys.length ? { logKeys } : {}),
|
|
176
322
|
...(values["log-id"] ? { logId: values["log-id"] } : {}),
|
|
177
323
|
...(values["upstream-key"] ? { upstreamKeys: values["upstream-key"].map(loadPublicKey) } : {}),
|
|
178
324
|
...(values["stripe-secret-env"] || values["github-secret-env"] ? { providerSecrets: { ...(values["stripe-secret-env"] ? { stripe: secretFrom(values["stripe-secret-env"]) } : {}), ...(values["github-secret-env"] ? { github: secretFrom(values["github-secret-env"]) } : {}) } } : {}),
|
|
@@ -196,10 +342,13 @@ async function main(argv) {
|
|
|
196
342
|
},
|
|
197
343
|
});
|
|
198
344
|
const keyFiles = [...(values["issuer-key"] ?? []), ...(values["log-key"] ?? [])];
|
|
199
|
-
if (!values.older || !values.newer
|
|
200
|
-
throw new Error("audit needs --older
|
|
345
|
+
if (!values.older || !values.newer)
|
|
346
|
+
throw new Error("audit needs --older and --newer");
|
|
201
347
|
if (!values.log === !values["log-url"])
|
|
202
348
|
throw new Error("audit needs exactly one of --log or --log-url");
|
|
349
|
+
const auditKeys = [...keyFiles.map(loadPublicKey), ...(values["log-url"] ? (await fetchLogKeys(values["log-url"])).keys : [])];
|
|
350
|
+
if (auditKeys.length === 0)
|
|
351
|
+
throw new Error("audit needs a key: --issuer-key, --log-key, or a --log-url that publishes its keys");
|
|
203
352
|
const older = JSON.parse(readFileSync(values.older, "utf8")).treeHead;
|
|
204
353
|
const newer = JSON.parse(readFileSync(values.newer, "utf8")).treeHead;
|
|
205
354
|
const sizeOf = (env) => JSON.parse(Buffer.from(env.payload, "base64").toString()).treeSize;
|
|
@@ -213,7 +362,7 @@ async function main(argv) {
|
|
|
213
362
|
throw new Error(`log refused the consistency query: ${res.status}`);
|
|
214
363
|
proof = (await res.json()).hashes;
|
|
215
364
|
}
|
|
216
|
-
const result = auditExtends(older, newer, proof,
|
|
365
|
+
const result = auditExtends(older, newer, proof, auditKeys, values["log-id"]);
|
|
217
366
|
if (values.json)
|
|
218
367
|
console.log(JSON.stringify(result, null, 2));
|
|
219
368
|
else {
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,14 @@ export type { AuthorizationBundle, AuthorizationPredicate, AuthorizationStatemen
|
|
|
3
3
|
export type { GatewayOptions } from "./gateway.ts";
|
|
4
4
|
export { buildRequest, restUpstream } from "./rest.ts";
|
|
5
5
|
export { openExporter, otlpExporter, spanFor } from "./otel.ts";
|
|
6
|
+
export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter } from "./log-store.ts";
|
|
7
|
+
export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.ts";
|
|
8
|
+
export type { KeyDocument, RemoteSignerOptions, RetiredKey, RunningSigner, Signer, SignerServerOptions } from "./signer.ts";
|
|
9
|
+
export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.ts";
|
|
10
|
+
export { adminRoutes, welcomeSheet } from "./log-admin.ts";
|
|
11
|
+
export type { AdminOptions } from "./log-admin.ts";
|
|
12
|
+
export type { Checkpoint, CheckpointStore } from "./checkpoints.ts";
|
|
13
|
+
export type { AppendResult, LogBackend, PostgresLike, PostgresLogOptions, RateLimitOptions, Tenant, TokenRecord } from "./log-store.ts";
|
|
6
14
|
export type { OtelConfig, OtlpOptions, ReceiptExporter } from "./otel.ts";
|
|
7
15
|
export type { IssuerOptions } from "./issue.ts";
|
|
8
16
|
export type { RestOptions, UpstreamClient } from "./rest.ts";
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
export { AUTHORIZATION_PREDICATE_TYPE, buildAuthorizationStatement } from "./receipt.js";
|
|
3
3
|
export { buildRequest, restUpstream } from "./rest.js";
|
|
4
4
|
export { openExporter, otlpExporter, spanFor } from "./otel.js";
|
|
5
|
+
export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter } from "./log-store.js";
|
|
6
|
+
export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.js";
|
|
7
|
+
export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.js";
|
|
8
|
+
export { adminRoutes, welcomeSheet } from "./log-admin.js";
|
|
5
9
|
export * from "./config.js";
|
|
6
10
|
export * from "./crypto.js";
|
|
7
11
|
export * from "./delegation.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { PostgresTenancy } from "./log-store.ts";
|
|
3
|
+
export interface AdminOptions {
|
|
4
|
+
tenancy: PostgresTenancy;
|
|
5
|
+
/** the admin token; every /admin route needs it as a bearer */
|
|
6
|
+
token: string;
|
|
7
|
+
/** the log's public base URL, for the welcome sheet, e.g. https://log.example.com/ */
|
|
8
|
+
publicUrl?: string;
|
|
9
|
+
/** the checkpoints host, e.g. https://checkpoints.example.com/ */
|
|
10
|
+
checkpointsUrl?: string;
|
|
11
|
+
/** the current signing keyid, for the sheet */
|
|
12
|
+
keyid?: string;
|
|
13
|
+
}
|
|
14
|
+
/** The welcome sheet as text, the same one deploy/onboard-tenant.sh prints. */
|
|
15
|
+
export declare function welcomeSheet(o: {
|
|
16
|
+
tenant: string;
|
|
17
|
+
logId: string;
|
|
18
|
+
publicUrl: string;
|
|
19
|
+
checkpointsUrl?: string;
|
|
20
|
+
keyid?: string;
|
|
21
|
+
}): string;
|
|
22
|
+
/**
|
|
23
|
+
* Routes under /admin. Returns true when it handled the request.
|
|
24
|
+
* GET /admin the page
|
|
25
|
+
* GET /admin/info { publicUrl, checkpointsUrl, keyid }
|
|
26
|
+
* GET /admin/tenants [{ id, logId, createdAt, disabledAt, tokens }]
|
|
27
|
+
* POST /admin/tenants { id, logId? } the tenant
|
|
28
|
+
* POST /admin/tenants/:id/disable
|
|
29
|
+
* GET /admin/tenants/:id/tokens [{ label, tokenHash, createdAt, revokedAt }]
|
|
30
|
+
* POST /admin/tenants/:id/tokens { label } { token, tokenHash, welcome } token shown once
|
|
31
|
+
* POST /admin/tenants/:id/tokens/:prefix/revoke { revoked }
|
|
32
|
+
*/
|
|
33
|
+
export declare function adminRoutes(opts: AdminOptions): (req: IncomingMessage, res: ServerResponse, url: URL) => Promise<boolean>;
|