@verax-ai/body 0.2.2 → 0.4.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.
@@ -15,16 +15,33 @@
15
15
  */
16
16
  import { readFileSync } from "node:fs";
17
17
  import { verifyLedger } from "@verax-ai/proxy";
18
+ import { directoryAccess, unreadableSentence } from "./install.js";
18
19
  export const EX_VERIFY_FAILED = 1;
19
20
  function usage() {
20
21
  return [
21
- "usage: verax verify <stateDir> [--key <public.pem>] [--json]",
22
+ "usage: verax verify <stateDir> [--key <public.pem>] [--effect-key <public.pem>] [--witness-key <public.pem>] [--checkpoint-key <public.pem>] [--json]",
22
23
  "",
23
24
  " <stateDir> the directory holding decisions.jsonl and effects.jsonl",
24
- " --key <file> verify against a public key you hold, instead of the one",
25
- " the records carry. This is the difference between",
26
- " 'these files agree with each other' and 'these files",
27
- " were signed by the key I was given'.",
25
+ " --key <file> verify decision records against a public key you hold,",
26
+ " instead of the one the records carry. This is the",
27
+ " difference between 'these files agree with each other'",
28
+ " and 'these files were signed by the key I was given'.",
29
+ " --effect-key <file>",
30
+ " verify self effect rows against a public key you hold,",
31
+ " instead of one key taken from the first self row. Same",
32
+ " distinction as --key, for the effect signer. When this",
33
+ " is set and the ledger has a same-org row, --witness-key",
34
+ " is required too.",
35
+ " --witness-key <file>",
36
+ " verify same-org effect rows against a public key you",
37
+ " hold, instead of one key taken from the first same-org",
38
+ " row. Agreement is not trust, the same as --key. When",
39
+ " this is set and the ledger has a self row, --effect-key",
40
+ " is required too.",
41
+ " --checkpoint-key <file>",
42
+ " verify every checkpoint row against a public key you",
43
+ " hold, instead of one key taken from the checkpoint",
44
+ " file. Same distinction as --key, for the witness.",
28
45
  " --json machine-readable result on stdout",
29
46
  "",
30
47
  "Exit code is 0 when the ledger verifies and 1 when it does not.",
@@ -44,6 +61,44 @@ export function renderVerify(r) {
44
61
  ? "the key carried in these files"
45
62
  : "no key"}`);
46
63
  lines.push(` ${r.trust.note}`);
64
+ lines.push(`effects with ${r.effectTrust.source === "pinned"
65
+ ? "a key you supplied"
66
+ : r.effectTrust.source === "in-ledger"
67
+ ? "the key carried in these files"
68
+ : "no key"}`);
69
+ lines.push(` ${r.effectTrust.note}`);
70
+ const witnessTrust = r.witnessTrust ?? {
71
+ source: "none",
72
+ publicKeyPem: null,
73
+ note: "no same-org effects, so no witness key was used",
74
+ };
75
+ lines.push(`same-org with ${witnessTrust.source === "pinned"
76
+ ? "a key you supplied"
77
+ : witnessTrust.source === "in-ledger"
78
+ ? "the key carried in these files"
79
+ : "no key"}`);
80
+ lines.push(` ${witnessTrust.note}`);
81
+ lines.push(`checkpoints with ${r.checkpointTrust.source === "pinned"
82
+ ? "a key you supplied"
83
+ : r.checkpointTrust.source === "in-ledger"
84
+ ? "the key carried in these files"
85
+ : "no key"}`);
86
+ lines.push(` ${r.checkpointTrust.note}`);
87
+ lines.push(r.index.line);
88
+ lines.push(r.effectCompleteness);
89
+ if (r.tail.checkpoint) {
90
+ const head = r.tail.checkpoint.chainHeadHash ?? "(no head hash)";
91
+ const holds = r.tail.checkpoint.ledgerHoldsRecord === null
92
+ ? "no head hash to look up"
93
+ : r.tail.checkpoint.ledgerHoldsRecord
94
+ ? "ledger holds that record"
95
+ : "ledger does not hold that record";
96
+ const covered = r.tail.checkpoint.receiptCount === null
97
+ ? "no record count"
98
+ : `${r.tail.checkpoint.receiptCount} record(s)`;
99
+ lines.push(`checkpoint newest covers ${covered}, head ${head}, ${holds}`);
100
+ }
101
+ lines.push(r.tail.line);
47
102
  if (r.problems.length > 0) {
48
103
  lines.push("");
49
104
  lines.push("problems:");
@@ -64,28 +119,93 @@ export async function runVerify(argv, out = (s) => process.stdout.write(`${s}\n`
64
119
  }
65
120
  const json = args.includes("--json");
66
121
  let publicKeyPem;
67
- const keyAt = args.indexOf("--key");
68
- if (keyAt !== -1) {
69
- const path = args[keyAt + 1];
122
+ let effectPublicKeyPem;
123
+ let witnessPublicKeyPem;
124
+ let checkpointPublicKeyPem;
125
+ const readKeyFlag = (flag) => {
126
+ const at = args.indexOf(flag);
127
+ if (at === -1)
128
+ return { ok: true };
129
+ const path = args[at + 1];
70
130
  if (!path || path.startsWith("-")) {
71
- out("verify: --key needs a file path");
72
- return EX_VERIFY_FAILED;
131
+ out(`verify: ${flag} needs a file path`);
132
+ return { ok: false };
73
133
  }
74
134
  try {
75
- publicKeyPem = readFileSync(path, "utf8");
135
+ const pem = readFileSync(path, "utf8");
136
+ if (pem.trim() === "") {
137
+ out(`verify-key-empty: ${flag}`);
138
+ return { ok: false };
139
+ }
140
+ args.splice(at, 2);
141
+ return { ok: true, pem };
76
142
  }
77
143
  catch {
78
144
  out(`verify: cannot read key file ${path}`);
79
- return EX_VERIFY_FAILED;
145
+ return { ok: false };
80
146
  }
81
- args.splice(keyAt, 2);
82
- }
147
+ };
148
+ const recordKey = readKeyFlag("--key");
149
+ if (!recordKey.ok)
150
+ return EX_VERIFY_FAILED;
151
+ publicKeyPem = recordKey.pem;
152
+ const effectKey = readKeyFlag("--effect-key");
153
+ if (!effectKey.ok)
154
+ return EX_VERIFY_FAILED;
155
+ effectPublicKeyPem = effectKey.pem;
156
+ const witnessKey = readKeyFlag("--witness-key");
157
+ if (!witnessKey.ok)
158
+ return EX_VERIFY_FAILED;
159
+ witnessPublicKeyPem = witnessKey.pem;
160
+ const checkpointKey = readKeyFlag("--checkpoint-key");
161
+ if (!checkpointKey.ok)
162
+ return EX_VERIFY_FAILED;
163
+ checkpointPublicKeyPem = checkpointKey.pem;
83
164
  const dir = args.find((a) => !a.startsWith("-"));
84
165
  if (!dir) {
85
166
  out(usage());
86
167
  return EX_VERIFY_FAILED;
87
168
  }
88
- const result = await verifyLedger(dir, publicKeyPem ? { publicKeyPem } : {});
169
+ if (directoryAccess(dir) === "unreadable") {
170
+ out(unreadableSentence(dir, "verify"));
171
+ return 77;
172
+ }
173
+ let result;
174
+ try {
175
+ result = await verifyLedger(dir, {
176
+ ...(publicKeyPem ? { publicKeyPem } : {}),
177
+ ...(effectPublicKeyPem ? { effectPublicKeyPem } : {}),
178
+ ...(witnessPublicKeyPem ? { witnessPublicKeyPem } : {}),
179
+ ...(checkpointPublicKeyPem ? { checkpointPublicKeyPem } : {}),
180
+ });
181
+ }
182
+ catch (err) {
183
+ const problem = err instanceof Error ? err.message : "ledger could not be read";
184
+ const failed = {
185
+ ok: false,
186
+ directory: dir,
187
+ decisions: 0,
188
+ effects: 0,
189
+ signaturesValid: 0,
190
+ signaturesInvalid: 0,
191
+ chainBreakAt: null,
192
+ effectsBound: 0,
193
+ effectsOrphaned: 0,
194
+ trust: { source: "none", publicKeyPem: null, note: problem },
195
+ effectTrust: { source: "none", publicKeyPem: null, note: problem },
196
+ witnessTrust: { source: "none", publicKeyPem: null, note: problem },
197
+ checkpointTrust: { source: "none", publicKeyPem: null, note: problem },
198
+ index: { present: false, missing: 0, line: "index: none (cannot check for removed records)" },
199
+ effectCompleteness: "effect completeness was not checked",
200
+ tail: {
201
+ line: "tail: no checkpoint; removing the newest records with their effects is not detectable from these files",
202
+ checkpoint: null,
203
+ },
204
+ problems: [problem],
205
+ };
206
+ out(json ? JSON.stringify(failed, null, 2) : renderVerify(failed));
207
+ return EX_VERIFY_FAILED;
208
+ }
89
209
  out(json ? JSON.stringify(result, null, 2) : renderVerify(result));
90
210
  return result.ok ? 0 : EX_VERIFY_FAILED;
91
211
  }
package/dist/wiring.js CHANGED
@@ -66,7 +66,7 @@ export function createBodyServices(opts) {
66
66
  : {}),
67
67
  };
68
68
  };
69
- registry.set("audit.explain", async (call) => auditExplain(call, ledger, await explainOpts()));
69
+ registry.set("audit.explain", async (call, principal) => auditExplain(call, ledger, await explainOpts(), { principal, stateDir: opts.stateDir }));
70
70
  registry.set("message.read", (call, principal) => messageRead(call, opts.stateDir, principal));
71
71
  registry.set("message.send", (call, principal, ref) => messageSend(call, opts.stateDir, ref ?? "", principal));
72
72
  registry.set("spend", (call, _principal, ref) => spendAuthorize(call, ref ?? ""));
package/dist/witness.js CHANGED
@@ -3,9 +3,12 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from
3
3
  import { createServer } from "node:http";
4
4
  import { join } from "node:path";
5
5
  import { writeFileAtomic } from "./atomic-write.js";
6
- import { buildCheckpointClaims, checkpointHash, signCheckpoint, totalsFromDecisionRecords, } from "@cedulon/checkpoint";
7
- import { decisionRecordHash } from "@cedulon/core";
6
+ import { buildCheckpointClaims, checkpointHash, signCheckpoint, totalsFromDecisionRecords, verifyCheckpoint, verifyCheckpointUnderPin, } from "@cedulon/checkpoint";
7
+ import { canonical, decisionRecordHash } from "@cedulon/core";
8
+ import { coseFromHex, decodeCoseSign1, verifyCoseSign1 } from "@cedulon/cose";
9
+ import { verifyEffectExtract } from "@cedulon/effect-extract";
8
10
  import { checkpointsPath, listPieceFiles, signEffectAttestation } from "@verax-ai/proxy";
11
+ import { assertEd25519PublicKey } from "./keys.js";
9
12
  import { pidAlive } from "./unlock.js";
10
13
  const WITNESS_CLASS = "same-org";
11
14
  function listenPath(stateDir) {
@@ -24,19 +27,23 @@ export function loadOrCreateWitnessKeys(stateDir) {
24
27
  mkdirSync(dir, { recursive: true, mode: 0o700 });
25
28
  const priv = join(dir, "witness.private.pem");
26
29
  const pub = join(dir, "witness.public.pem");
27
- if (existsSync(priv) && existsSync(pub)) {
28
- return { privateKeyPem: readFileSync(priv, "utf8"), publicKeyPem: readFileSync(pub, "utf8") };
29
- }
30
- if (existsSync(priv) || existsSync(pub)) {
30
+ const pair = existsSync(priv) && existsSync(pub)
31
+ ? { privateKeyPem: readFileSync(priv, "utf8"), publicKeyPem: readFileSync(pub, "utf8") }
32
+ : existsSync(priv) || existsSync(pub)
33
+ ? null
34
+ : (() => {
35
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
36
+ const created = {
37
+ publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(),
38
+ privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
39
+ };
40
+ writePemAtomic(priv, created.privateKeyPem);
41
+ writePemAtomic(pub, created.publicKeyPem);
42
+ return created;
43
+ })();
44
+ if (!pair)
31
45
  throw new Error("witness-keys-partial");
32
- }
33
- const { publicKey, privateKey } = generateKeyPairSync("ed25519");
34
- const pair = {
35
- publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(),
36
- privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
37
- };
38
- writePemAtomic(priv, pair.privateKeyPem);
39
- writePemAtomic(pub, pair.publicKeyPem);
46
+ assertEd25519PublicKey(pair.publicKeyPem, "witness");
40
47
  return pair;
41
48
  }
42
49
  export function readWitnessListen(stateDir) {
@@ -122,21 +129,61 @@ function loadDecisions(stateDir) {
122
129
  }
123
130
  return out;
124
131
  }
132
+ /** Last checkpoint hash this process recorded. A truncated file is compared with it. */
133
+ function recordedCheckpointHash(stateDir) {
134
+ let text;
135
+ try {
136
+ text = readFileSync(statusPath(stateDir), "utf8");
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ let found = null;
142
+ for (const line of text.split("\n")) {
143
+ if (line === "")
144
+ continue;
145
+ try {
146
+ const row = JSON.parse(line);
147
+ if (typeof row.checkpointHash === "string" && row.checkpointHash !== "")
148
+ found = row.checkpointHash;
149
+ }
150
+ catch {
151
+ // A torn status line is not a hash.
152
+ }
153
+ }
154
+ return found;
155
+ }
125
156
  function lastCheckpointHash(stateDir) {
157
+ const path = checkpointsPath(stateDir);
158
+ let text;
126
159
  try {
127
- const text = readFileSync(checkpointsPath(stateDir), "utf8").trim();
128
- if (text === "")
129
- return null;
130
- const lines = text.split("\n").filter((l) => l !== "");
131
- const last = JSON.parse(lines[lines.length - 1] ?? "{}");
132
- if (typeof last.coseHex !== "string" || !last.claims)
133
- return null;
134
- return checkpointHash(last);
160
+ text = readFileSync(path, "utf8");
135
161
  }
136
162
  catch (err) {
137
163
  if (err.code === "ENOENT")
138
164
  return null;
139
- return null;
165
+ throw new Error("checkpoint-unreadable");
166
+ }
167
+ const trimmed = text.trim();
168
+ if (trimmed === "") {
169
+ // A missing file starts a chain. Zero bytes or whitespace are not that file.
170
+ // They also are not the hash recorded when the last checkpoint was written.
171
+ const recorded = recordedCheckpointHash(stateDir);
172
+ if (recorded === null || !trimmed.includes(recorded))
173
+ throw new Error("checkpoint-unreadable");
174
+ }
175
+ const lines = trimmed.split("\n").filter((line) => line !== "");
176
+ try {
177
+ const last = JSON.parse(lines[lines.length - 1] ?? "");
178
+ if (!last || typeof last !== "object" || typeof last.coseHex !== "string" || !last.claims) {
179
+ throw new Error("checkpoint-unreadable");
180
+ }
181
+ return checkpointHash(last);
182
+ }
183
+ catch (err) {
184
+ if (err instanceof Error && err.message === "checkpoint-unreadable")
185
+ throw err;
186
+ throw new Error("checkpoint-unreadable");
140
187
  }
141
188
  }
142
189
  function signWindowCheckpoint(stateDir, window, keys) {
@@ -146,6 +193,11 @@ function signWindowCheckpoint(stateDir, window, keys) {
146
193
  const claims = buildCheckpointClaims(window.epoch, inWindow, startMs, endMs, lastCheckpointHash(stateDir), totalsFromDecisionRecords, (row) => decisionRecordHash(row));
147
194
  const signed = signCheckpoint(claims, keys.privateKeyPem, keys.publicKeyPem);
148
195
  appendFileSync(checkpointsPath(stateDir), `${JSON.stringify(signed)}\n`, { encoding: "utf8" });
196
+ appendStatus(stateDir, {
197
+ atMs: Date.now(),
198
+ result: "checkpoint",
199
+ checkpointHash: checkpointHash(signed),
200
+ });
149
201
  return signed;
150
202
  }
151
203
  export async function requestWitnessCheckpoint(stateDir, window) {
@@ -167,12 +219,75 @@ export async function requestWitnessCheckpoint(stateDir, window) {
167
219
  const body = (await res.json());
168
220
  if (typeof body.coseHex !== "string" || !body.claims)
169
221
  return null;
222
+ if (!checkpointMatchesListenKey(body, listen.publicKeyPem)) {
223
+ appendStatus(stateDir, {
224
+ atMs: Date.now(),
225
+ result: "checkpoint-refused",
226
+ reason: "witness-key-mismatch",
227
+ });
228
+ return null;
229
+ }
170
230
  return body;
171
231
  }
172
232
  catch {
173
233
  return null;
174
234
  }
175
235
  }
236
+ /** The listen file's key, not whichever key answered on the port. */
237
+ function checkpointMatchesListenKey(body, publicKeyPem) {
238
+ if (typeof body.coseHex !== "string" || body.coseHex === "" || !body.claims)
239
+ return false;
240
+ if (typeof body.publicKeyPem !== "string" || !pemSame(body.publicKeyPem, publicKeyPem))
241
+ return false;
242
+ try {
243
+ return verifyCheckpointUnderPin(body, publicKeyPem) && verifyCheckpoint(body, publicKeyPem);
244
+ }
245
+ catch {
246
+ return false;
247
+ }
248
+ }
249
+ function pemSame(left, right) {
250
+ return left.replace(/\r\n/g, "\n").trim() === right.replace(/\r\n/g, "\n").trim();
251
+ }
252
+ /** The listen file's key, not whichever key answered on the port. */
253
+ function answerMatchesListenKey(body, row, resultHash, publicKeyPem) {
254
+ const receipt = body.receipt;
255
+ const coseHex = body.attestation?.coseHex;
256
+ if (!receipt || typeof coseHex !== "string" || coseHex === "")
257
+ return false;
258
+ if (typeof receipt.publicKeyPem !== "string" || !pemSame(receipt.publicKeyPem, publicKeyPem))
259
+ return false;
260
+ const clean = {
261
+ ref: row.ref,
262
+ effectHash: row.effectHash,
263
+ effectClass: row.effectClass,
264
+ timestampMs: row.timestampMs,
265
+ ...(row.actor !== undefined ? { actor: row.actor } : {}),
266
+ };
267
+ const expected = canonical({
268
+ ref: clean.ref,
269
+ effectHash: clean.effectHash,
270
+ witnessClass: WITNESS_CLASS,
271
+ resultHash: resultHash ?? null,
272
+ });
273
+ try {
274
+ const decoded = decodeCoseSign1(coseFromHex(coseHex));
275
+ const payload = JSON.parse(Buffer.from(decoded.payload).toString("utf8"));
276
+ if (canonical(payload) !== expected)
277
+ return false;
278
+ if (!verifyCoseSign1(coseFromHex(coseHex), publicKeyPem, "application/json"))
279
+ return false;
280
+ if (!verifyEffectExtract(receipt, publicKeyPem))
281
+ return false;
282
+ const signedRow = receipt.body.effects[0];
283
+ if (signedRow == null || canonical(signedRow) !== canonical(clean))
284
+ return false;
285
+ }
286
+ catch {
287
+ return false;
288
+ }
289
+ return true;
290
+ }
176
291
  export async function requestWitnessSign(stateDir, row, resultHash) {
177
292
  const listen = readWitnessListen(stateDir);
178
293
  if (!listen || !pidAlive(listen.pid)) {
@@ -213,6 +328,15 @@ export async function requestWitnessSign(stateDir, row, resultHash) {
213
328
  });
214
329
  return null;
215
330
  }
331
+ if (!answerMatchesListenKey(body, row, resultHash, listen.publicKeyPem)) {
332
+ appendStatus(stateDir, {
333
+ atMs: Date.now(),
334
+ ref: row.ref,
335
+ result: "self-fallback",
336
+ reason: "witness-key-mismatch",
337
+ });
338
+ return null;
339
+ }
216
340
  appendStatus(stateDir, { atMs: Date.now(), ref: row.ref, result: "signed", pid: listen.pid });
217
341
  return {
218
342
  witnessClass: WITNESS_CLASS,
@@ -283,7 +407,13 @@ export async function runWitness(stateDir) {
283
407
  }
284
408
  send(res, 404, { error: "not-found" });
285
409
  }
286
- catch {
410
+ catch (err) {
411
+ const reason = err instanceof Error ? err.message : "";
412
+ if (reason === "checkpoint-unreadable") {
413
+ appendStatus(stateDir, { atMs: Date.now(), result: "checkpoint-refused", reason });
414
+ send(res, 409, { error: reason });
415
+ return;
416
+ }
287
417
  send(res, 400, { error: "bad-request" });
288
418
  }
289
419
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verax-ai/body",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "engines": {
@@ -21,9 +21,13 @@
21
21
  "dist"
22
22
  ],
23
23
  "dependencies": {
24
+ "@cedulon/checkpoint": "0.13.1",
25
+ "@cedulon/core": "0.13.1",
26
+ "@cedulon/cose": "0.13.1",
27
+ "@cedulon/effect-extract": "0.13.1",
24
28
  "@modelcontextprotocol/sdk": "^1.30.0",
25
- "@verax-ai/inventory": "^0.2.2",
26
- "@verax-ai/proxy": "^0.2.2",
29
+ "@verax-ai/inventory": "^0.4.0",
30
+ "@verax-ai/proxy": "^0.4.0",
27
31
  "jose": "^6.2.11"
28
32
  },
29
33
  "description": "VERAX body: an MCP server that decides, records and proves what an agent did.",