@agent-custody/receipts 0.4.0 → 0.5.0
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 +22 -0
- package/dist/checkpoints.js +81 -0
- package/dist/cli.js +152 -15
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/log-sink.d.ts +56 -3
- package/dist/log-sink.js +244 -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): phases 1 to 3 are done; what remains is running it for the first tenant, and then a 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,22 @@
|
|
|
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
|
+
/** Writes every checkpoint to each store: the directory the checkpoints host serves and the database the API lists from. */
|
|
22
|
+
export declare function bothCheckpoints(...stores: CheckpointStore[]): CheckpointStore;
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
/** Writes every checkpoint to each store: the directory the checkpoints host serves and the database the API lists from. */
|
|
72
|
+
export function bothCheckpoints(...stores) {
|
|
73
|
+
return {
|
|
74
|
+
async save(c) {
|
|
75
|
+
for (const s of stores)
|
|
76
|
+
await s.save(c);
|
|
77
|
+
},
|
|
78
|
+
list: (t, since) => stores[0].list(t, since),
|
|
79
|
+
latest: (t) => stores[0].latest(t),
|
|
80
|
+
};
|
|
81
|
+
}
|
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,40 @@ 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
|
+
signer --key <log.key> --port 8790 [--host 127.0.0.1] [--token-env NAME] [--retired-key <pub>]...
|
|
46
|
+
the one process that holds the log's key: POST /sign, GET /keys
|
|
47
|
+
log-admin --db-env NAME tenant add <id> [--log-id <id>] | tenant list | tenant disable <id>
|
|
48
|
+
log-admin --db-env NAME token add <tenant> --label <text> | token list <tenant> | token revoke <tenant> <hash-prefix>
|
|
49
|
+
log-admin --db-env NAME import --file <log.jsonl> [--tenant default] copies a file log into the database as hashes
|
|
50
|
+
audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) [--issuer-key <pub>] [--log-key <pub>] [--log-id <id>] [--json]
|
|
51
|
+
with --log-url the log's published keys are fetched and pinned by keyid
|
|
35
52
|
checks that the newer receipt's log extends the older one's: nothing between them was rewritten
|
|
36
53
|
`;
|
|
54
|
+
/** A retired public key for the key document, from a .pub file; still listed so heads it signed keep verifying. */
|
|
55
|
+
function retiredKey(pubFile) {
|
|
56
|
+
return { key: loadPublicKey(pubFile), pem: readFileSync(pubFile, "utf8") };
|
|
57
|
+
}
|
|
58
|
+
/** 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. */
|
|
59
|
+
function openPostgres(envName) {
|
|
60
|
+
const url = process.env[envName];
|
|
61
|
+
if (!url)
|
|
62
|
+
throw new Error(`environment variable ${envName} is not set`);
|
|
63
|
+
let Pool;
|
|
64
|
+
try {
|
|
65
|
+
({ Pool } = createRequire(import.meta.url)("pg"));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
throw new Error("a Postgres log needs the pg package: npm install pg");
|
|
69
|
+
}
|
|
70
|
+
return new Pool({ connectionString: url });
|
|
71
|
+
}
|
|
37
72
|
/** The tenants file for `log --tenants`: paths relative to the file, tokens from the environment, ids default to the tenant name. */
|
|
38
73
|
function loadTenants(path) {
|
|
39
74
|
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -118,6 +153,66 @@ async function main(argv) {
|
|
|
118
153
|
await running.close();
|
|
119
154
|
return 0;
|
|
120
155
|
}
|
|
156
|
+
case "signer": {
|
|
157
|
+
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 } } });
|
|
158
|
+
if (!values.key)
|
|
159
|
+
throw new Error("signer needs --key");
|
|
160
|
+
const token = values["token-env"] ? process.env[values["token-env"]] : undefined;
|
|
161
|
+
if (values["token-env"] && !token)
|
|
162
|
+
throw new Error(`signer: environment variable ${values["token-env"]} is not set`);
|
|
163
|
+
const kp = loadPrivateKey(values.key);
|
|
164
|
+
const running = await serveSigner(kp, { port: Number(values.port), host: values.host, ...(token ? { token } : {}), retired: (values["retired-key"] ?? []).map(retiredKey) });
|
|
165
|
+
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}` : ""}`);
|
|
166
|
+
await new Promise((resolve) => process.once("SIGINT", resolve));
|
|
167
|
+
await running.close();
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
case "log-admin": {
|
|
171
|
+
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" } } });
|
|
172
|
+
if (!values["db-env"])
|
|
173
|
+
throw new Error("log-admin needs --db-env NAME");
|
|
174
|
+
const tenancy = new PostgresTenancy(openPostgres(values["db-env"]));
|
|
175
|
+
const [what, verb, ...args] = positionals;
|
|
176
|
+
if (what === "tenant" && verb === "add" && args[0]) {
|
|
177
|
+
const t = await tenancy.addTenant(args[0], values["log-id"] ?? args[0]);
|
|
178
|
+
console.log(`tenant ${t.id} log=${t.logId} reached at /t/${t.id}/`);
|
|
179
|
+
}
|
|
180
|
+
else if (what === "tenant" && verb === "list") {
|
|
181
|
+
for (const t of await tenancy.listTenants())
|
|
182
|
+
console.log(`${t.id.padEnd(24)} log=${t.logId.padEnd(28)} created ${t.createdAt}${t.disabledAt ? ` DISABLED ${t.disabledAt}` : ""}`);
|
|
183
|
+
}
|
|
184
|
+
else if (what === "tenant" && verb === "disable" && args[0]) {
|
|
185
|
+
await tenancy.disableTenant(args[0]);
|
|
186
|
+
console.log(`tenant ${args[0]} disabled`);
|
|
187
|
+
}
|
|
188
|
+
else if (what === "token" && verb === "add" && args[0]) {
|
|
189
|
+
if (!values.label)
|
|
190
|
+
throw new Error("token add needs --label");
|
|
191
|
+
const { token, tokenHash } = await tenancy.addToken(args[0], values.label);
|
|
192
|
+
console.error(`token for ${args[0]} (${values.label}); shown once, stored as hash ${tokenHash.slice(0, 12)}…:`);
|
|
193
|
+
console.log(token);
|
|
194
|
+
}
|
|
195
|
+
else if (what === "token" && verb === "list" && args[0]) {
|
|
196
|
+
for (const t of await tenancy.listTokens(args[0]))
|
|
197
|
+
console.log(`${t.tokenHash.slice(0, 12)} ${t.label.padEnd(24)} created ${t.createdAt}${t.revokedAt ? ` REVOKED ${t.revokedAt}` : ""}`);
|
|
198
|
+
}
|
|
199
|
+
else if (what === "token" && verb === "revoke" && args[0] && args[1]) {
|
|
200
|
+
console.log(`revoked ${await tenancy.revokeToken(args[0], args[1])} token(s)`);
|
|
201
|
+
}
|
|
202
|
+
else if (what === "import") {
|
|
203
|
+
if (!values.file)
|
|
204
|
+
throw new Error("import needs --file <log.jsonl>");
|
|
205
|
+
if (!(await tenancy.tenant(values.tenant)))
|
|
206
|
+
throw new Error(`unknown tenant ${values.tenant}; add it first`);
|
|
207
|
+
const r = await importLogFile(values.file, await tenancy.log(values.tenant));
|
|
208
|
+
console.log(`imported ${r.added} leaf hash(es) into ${values.tenant}; the log now has ${r.total}`);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
console.error(USAGE);
|
|
212
|
+
return 2;
|
|
213
|
+
}
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
121
216
|
case "prune": {
|
|
122
217
|
const { values } = parseArgs({ args: rest, options: { log: { type: "string" }, before: { type: "string" }, receipts: { type: "string" } } });
|
|
123
218
|
if (!values.log || !values.before)
|
|
@@ -131,19 +226,55 @@ async function main(argv) {
|
|
|
131
226
|
case "log": {
|
|
132
227
|
const { values } = parseArgs({
|
|
133
228
|
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" } },
|
|
229
|
+
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" } },
|
|
135
230
|
});
|
|
136
|
-
if (!values.
|
|
137
|
-
throw new Error("log needs --
|
|
231
|
+
if (!values.key === !values["signer-url"])
|
|
232
|
+
throw new Error("log needs exactly one of --key or --signer-url");
|
|
138
233
|
const token = values["token-env"] ? process.env[values["token-env"]] : undefined;
|
|
139
234
|
if (values["token-env"] && !token)
|
|
140
235
|
throw new Error(`log: environment variable ${values["token-env"]} is not set`);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
236
|
+
// The signer: a key in this process, or the signer service that holds it.
|
|
237
|
+
let signer;
|
|
238
|
+
if (values.key)
|
|
239
|
+
signer = localSigner(loadPrivateKey(values.key), { retired: (values["retired-key"] ?? []).map(retiredKey) });
|
|
240
|
+
else {
|
|
241
|
+
const st = values["signer-token-env"] ? process.env[values["signer-token-env"]] : undefined;
|
|
242
|
+
if (values["signer-token-env"] && !st)
|
|
243
|
+
throw new Error(`log: environment variable ${values["signer-token-env"]} is not set`);
|
|
244
|
+
signer = await connectSigner(values["signer-url"], st ? { token: st } : {});
|
|
245
|
+
}
|
|
246
|
+
const everyMs = Number(values["checkpoint-every"]) * 1000;
|
|
247
|
+
if (!(everyMs > 0))
|
|
248
|
+
throw new Error("--checkpoint-every must be a positive number of seconds");
|
|
249
|
+
let resolver;
|
|
250
|
+
let checkpoints = values["checkpoint-dir"] ? dirCheckpoints(values["checkpoint-dir"]) : undefined;
|
|
251
|
+
let where;
|
|
252
|
+
if (values["db-env"]) {
|
|
253
|
+
// Postgres: the file is not used; tenants, tokens, leaves, and checkpoints live in the database.
|
|
254
|
+
const client = openPostgres(values["db-env"]);
|
|
255
|
+
const tenancy = new PostgresTenancy(client);
|
|
256
|
+
const defaultId = values["log-id"] ?? "default";
|
|
257
|
+
if (!(await tenancy.tenant("default")))
|
|
258
|
+
await tenancy.addTenant("default", defaultId);
|
|
259
|
+
resolver = postgresResolver(tenancy, { defaultTenant: "default", ...(token ? { staticTokens: [token] } : {}) });
|
|
260
|
+
const table = postgresCheckpoints(client);
|
|
261
|
+
checkpoints = checkpoints ? bothCheckpoints(table, checkpoints) : table;
|
|
262
|
+
where = `store=postgres default-log=${(await tenancy.tenant("default"))?.logId} ${token ? "environment token accepted for the default log; " : ""}tokens from the database`;
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
if (!values.file)
|
|
266
|
+
throw new Error("log needs --file, or --db-env");
|
|
267
|
+
// --tenants names a JSON file { "<tenant>": { "file": "...", "tokenEnv": "NAME", "logId": "..." } }; each is reached at /t/<tenant>/.
|
|
268
|
+
const tenants = values.tenants ? loadTenants(values.tenants) : undefined;
|
|
269
|
+
resolver = fileResolver(values.file, { ...(token ? { tokens: [token] } : {}), ...(values["log-id"] ? { logId: values["log-id"] } : {}), ...(tenants ? { tenants } : {}) });
|
|
270
|
+
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(",")}` : ""}`;
|
|
271
|
+
}
|
|
272
|
+
const running = await serveLog(resolver, signer, { port: Number(values.port), host: values.host, ...(checkpoints ? { checkpoints } : {}) });
|
|
273
|
+
const publisher = checkpoints ? new CheckpointPublisher(resolver, signer, checkpoints, everyMs) : null;
|
|
274
|
+
publisher?.start();
|
|
275
|
+
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"]}` : ""}` : ""}`);
|
|
146
276
|
await new Promise((resolve) => process.once("SIGINT", resolve));
|
|
277
|
+
publisher?.stop();
|
|
147
278
|
await running.close();
|
|
148
279
|
return 0;
|
|
149
280
|
}
|
|
@@ -156,6 +287,7 @@ async function main(argv) {
|
|
|
156
287
|
"gateway-key": { type: "string", multiple: true },
|
|
157
288
|
"principal-key": { type: "string", multiple: true },
|
|
158
289
|
"log-key": { type: "string", multiple: true },
|
|
290
|
+
"log-url": { type: "string" },
|
|
159
291
|
"log-id": { type: "string" },
|
|
160
292
|
"upstream-key": { type: "string", multiple: true },
|
|
161
293
|
"stripe-secret-env": { type: "string" },
|
|
@@ -169,10 +301,12 @@ async function main(argv) {
|
|
|
169
301
|
if (!file || issuerKeyFiles.length === 0)
|
|
170
302
|
throw new Error("verify needs <bundle> --issuer-key (alias --gateway-key)");
|
|
171
303
|
const bundle = JSON.parse(readFileSync(file, "utf8"));
|
|
304
|
+
const fetchedLogKeys = values["log-url"] ? (await fetchLogKeys(values["log-url"])).keys : [];
|
|
305
|
+
const logKeys = [...(values["log-key"] ?? []).map(loadPublicKey), ...fetchedLogKeys];
|
|
172
306
|
const result = verifyBundle(bundle, {
|
|
173
307
|
issuerKeys: issuerKeyFiles.map(loadPublicKey),
|
|
174
308
|
principalKeys: (values["principal-key"] ?? []).map(loadPublicKey),
|
|
175
|
-
...(
|
|
309
|
+
...(logKeys.length ? { logKeys } : {}),
|
|
176
310
|
...(values["log-id"] ? { logId: values["log-id"] } : {}),
|
|
177
311
|
...(values["upstream-key"] ? { upstreamKeys: values["upstream-key"].map(loadPublicKey) } : {}),
|
|
178
312
|
...(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 +330,13 @@ async function main(argv) {
|
|
|
196
330
|
},
|
|
197
331
|
});
|
|
198
332
|
const keyFiles = [...(values["issuer-key"] ?? []), ...(values["log-key"] ?? [])];
|
|
199
|
-
if (!values.older || !values.newer
|
|
200
|
-
throw new Error("audit needs --older
|
|
333
|
+
if (!values.older || !values.newer)
|
|
334
|
+
throw new Error("audit needs --older and --newer");
|
|
201
335
|
if (!values.log === !values["log-url"])
|
|
202
336
|
throw new Error("audit needs exactly one of --log or --log-url");
|
|
337
|
+
const auditKeys = [...keyFiles.map(loadPublicKey), ...(values["log-url"] ? (await fetchLogKeys(values["log-url"])).keys : [])];
|
|
338
|
+
if (auditKeys.length === 0)
|
|
339
|
+
throw new Error("audit needs a key: --issuer-key, --log-key, or a --log-url that publishes its keys");
|
|
203
340
|
const older = JSON.parse(readFileSync(values.older, "utf8")).treeHead;
|
|
204
341
|
const newer = JSON.parse(readFileSync(values.newer, "utf8")).treeHead;
|
|
205
342
|
const sizeOf = (env) => JSON.parse(Buffer.from(env.payload, "base64").toString()).treeSize;
|
|
@@ -213,7 +350,7 @@ async function main(argv) {
|
|
|
213
350
|
throw new Error(`log refused the consistency query: ${res.status}`);
|
|
214
351
|
proof = (await res.json()).hashes;
|
|
215
352
|
}
|
|
216
|
-
const result = auditExtends(older, newer, proof,
|
|
353
|
+
const result = auditExtends(older, newer, proof, auditKeys, values["log-id"]);
|
|
217
354
|
if (values.json)
|
|
218
355
|
console.log(JSON.stringify(result, null, 2));
|
|
219
356
|
else {
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ 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 type { Checkpoint, CheckpointStore } from "./checkpoints.ts";
|
|
11
|
+
export type { AppendResult, LogBackend, PostgresLike, PostgresLogOptions, RateLimitOptions, Tenant, TokenRecord } from "./log-store.ts";
|
|
6
12
|
export type { OtelConfig, OtlpOptions, ReceiptExporter } from "./otel.ts";
|
|
7
13
|
export type { IssuerOptions } from "./issue.ts";
|
|
8
14
|
export type { RestOptions, UpstreamClient } from "./rest.ts";
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
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";
|
|
5
8
|
export * from "./config.js";
|
|
6
9
|
export * from "./crypto.js";
|
|
7
10
|
export * from "./delegation.js";
|
package/dist/log-sink.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
2
|
import { type Envelope, type KeyPair } from "./crypto.ts";
|
|
3
3
|
import { type InclusionProof } from "./log.ts";
|
|
4
|
+
import { type LogBackend, type PostgresTenancy, type RateLimitOptions } from "./log-store.ts";
|
|
5
|
+
import { type Signer } from "./signer.ts";
|
|
6
|
+
import type { Checkpoint, CheckpointStore } from "./checkpoints.ts";
|
|
4
7
|
export interface LogAppend {
|
|
5
8
|
inclusion: InclusionProof;
|
|
6
9
|
/** signed TreeHead; the signature's keyid says who runs the log */
|
|
@@ -19,9 +22,14 @@ export interface HttpLogOptions {
|
|
|
19
22
|
token?: string;
|
|
20
23
|
/** send only the leaf hash; the log then commits to the receipt without ever holding it. Use it for any log run by someone else. */
|
|
21
24
|
hashOnly?: boolean;
|
|
25
|
+
/** attempts on 429 and 5xx; default 3 */
|
|
26
|
+
retries?: number;
|
|
22
27
|
fetch?: typeof fetch;
|
|
23
28
|
}
|
|
24
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* A log reached over HTTP: POST <url>/append with {leaf} or {leafHash}, expecting a LogAppend back. A 429 or a 5xx
|
|
31
|
+
* is retried a few times with backoff, honouring Retry-After; anything else, or the last failure, is the caller's.
|
|
32
|
+
*/
|
|
25
33
|
export declare function httpLog(url: string, opts?: HttpLogOptions): LogSink;
|
|
26
34
|
export interface LogConfig {
|
|
27
35
|
logFile?: string | undefined;
|
|
@@ -48,6 +56,51 @@ export interface LogServerOptions {
|
|
|
48
56
|
tokens?: string[];
|
|
49
57
|
logId?: string;
|
|
50
58
|
}>;
|
|
59
|
+
/** appends per token (or per address without one); default 50 a second, burst 100 */
|
|
60
|
+
rateLimit?: RateLimitOptions;
|
|
61
|
+
/** largest append body accepted, in bytes; default 65536 */
|
|
62
|
+
maxBodyBytes?: number;
|
|
63
|
+
/** where published checkpoints go and are listed from; without one, /checkpoints answers with none */
|
|
64
|
+
checkpoints?: CheckpointStore;
|
|
65
|
+
}
|
|
66
|
+
/** One log as the handler sees it, whatever stands behind it. */
|
|
67
|
+
export interface ResolvedLog {
|
|
68
|
+
backend: LogBackend;
|
|
69
|
+
logId: string | undefined;
|
|
70
|
+
authorize(token: string | null): Promise<boolean>;
|
|
71
|
+
}
|
|
72
|
+
/** Turns the tenant in a path, or null for the root paths, into a log. */
|
|
73
|
+
export interface LogResolver {
|
|
74
|
+
resolve(tenant: string | null): Promise<ResolvedLog | null>;
|
|
75
|
+
/** every log this server has, null for the root one; what the checkpoint publisher walks */
|
|
76
|
+
tenants(): Promise<(string | null)[]>;
|
|
77
|
+
}
|
|
78
|
+
/** The reference server's logs: one file for the root paths and, optionally, a file per tenant from the options. */
|
|
79
|
+
export declare function fileResolver(file: string, opts?: LogServerOptions): LogResolver;
|
|
80
|
+
/**
|
|
81
|
+
* Logs in Postgres: every tenant from the tenants table, each with its own log and tokens; the root paths serve the
|
|
82
|
+
* tenant named `defaultTenant`, which also accepts `staticTokens` so a server can keep its environment token.
|
|
83
|
+
*/
|
|
84
|
+
export declare function postgresResolver(tenancy: PostgresTenancy, opts?: {
|
|
85
|
+
defaultTenant?: string;
|
|
86
|
+
staticTokens?: string[];
|
|
87
|
+
}): LogResolver;
|
|
88
|
+
/**
|
|
89
|
+
* Publishes one checkpoint per log whose tree has grown since the last one: the current head, signed, into the
|
|
90
|
+
* checkpoint store. Call publishOnce on a timer, or start() to run it every `everyMs`.
|
|
91
|
+
*/
|
|
92
|
+
export declare class CheckpointPublisher {
|
|
93
|
+
private timer;
|
|
94
|
+
private readonly resolver;
|
|
95
|
+
private readonly signer;
|
|
96
|
+
private readonly store;
|
|
97
|
+
private readonly everyMs;
|
|
98
|
+
private readonly warn;
|
|
99
|
+
constructor(resolver: LogResolver, signer: Signer, store: CheckpointStore, everyMs?: number, warn?: (m: string) => void);
|
|
100
|
+
/** Publishes for every log that has grown; returns the checkpoints written. */
|
|
101
|
+
publishOnce(): Promise<Checkpoint[]>;
|
|
102
|
+
start(): void;
|
|
103
|
+
stop(): void;
|
|
51
104
|
}
|
|
52
105
|
/**
|
|
53
106
|
* The reference log server as a node:http request handler.
|
|
@@ -56,13 +109,13 @@ export interface LogServerOptions {
|
|
|
56
109
|
* GET /consistency?old=M&new=N -> {oldSize, newSize, hashes}, proof that the log at N extends the log at M
|
|
57
110
|
* GET /head -> {treeHead}, the current tree head signed with the log's key
|
|
58
111
|
*/
|
|
59
|
-
export declare function logHandler(
|
|
112
|
+
export declare function logHandler(source: string | LogResolver, keyOrSigner: KeyPair | Signer, opts?: LogServerOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
60
113
|
export interface RunningLog {
|
|
61
114
|
url: string;
|
|
62
115
|
close(): Promise<void>;
|
|
63
116
|
}
|
|
64
117
|
/** Starts the reference log server. Port 0 picks a free port. */
|
|
65
|
-
export declare function serveLog(
|
|
118
|
+
export declare function serveLog(source: string | LogResolver, keyOrSigner: KeyPair | Signer, opts: LogServerOptions & {
|
|
66
119
|
port: number;
|
|
67
120
|
host?: string;
|
|
68
121
|
}): Promise<RunningLog>;
|