@ktlsr/aer-verify 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +44 -3
- package/dist/aer-verify/src/cli.js +136 -0
- package/dist/evidence-chain/src/canonicalize.js +23 -0
- package/dist/evidence-chain/src/chain.js +16 -0
- package/dist/evidence-chain/src/hash.js +37 -0
- package/dist/evidence-chain/src/index.js +41 -0
- package/dist/evidence-chain/src/manifest.js +58 -0
- package/dist/evidence-chain/src/packet.js +419 -0
- package/dist/evidence-chain/src/receipt.js +45 -0
- package/dist/evidence-chain/src/representable.js +31 -0
- package/dist/evidence-chain/src/types.js +3 -0
- package/dist/evidence-chain/src/verify.js +54 -0
- package/dist/evidence-chain/src/versions.js +21 -0
- package/package.json +50 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ktlesr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,44 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# @ktlsr/aer-verify
|
|
2
|
+
|
|
3
|
+
Offline verifier for [Agent Evidence Recorder](https://aer.ktlsr.com) audit packets. Reads local
|
|
4
|
+
files only — no network request, no AER account, no database. Zero runtime dependencies (Node ≥ 18).
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npx @ktlsr/aer-verify packet.json --receipt receipt.json --trusted-key aer-key.json
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
- `packet.json` — a JSON packet exported from AER (schema 1.1–2.0).
|
|
11
|
+
- `--receipt` — the receipt **you kept** when the run was sealed (the `complete` response, or its
|
|
12
|
+
`receipt` field). This is what makes a later rewrite detectable.
|
|
13
|
+
- `--trusted-key` (repeatable) — an Ed25519 public key you obtained **out of band**:
|
|
14
|
+
`{ "key_id", "public_key" }`, a list of them, or a saved `GET /api/v1/receipt-keys` response.
|
|
15
|
+
A key carried inside the packet is never used.
|
|
16
|
+
- `--require-anchor` — exit 1 unless the result is tied to your receipt or trusted key.
|
|
17
|
+
- `--json` — machine-readable report.
|
|
18
|
+
|
|
19
|
+
## What it reports, separately
|
|
20
|
+
|
|
21
|
+
| Check | Meaning |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| Schema | Known packet version? Unknown versions are never "verified". |
|
|
24
|
+
| Event chain | Each event's hash and link (`aer-chain/1`); `consistent_unsealed` while the run is open. |
|
|
25
|
+
| Seal | Whether the run was sealed (completed/failed). |
|
|
26
|
+
| Run manifest | Agent, status, tenant, times, policies, capture claims, lineage, sourced blocks — frozen at seal (`aer-manifest/1`). |
|
|
27
|
+
| Content hash | Whole-file consistency. Not tamper evidence by itself: an editor can recompute it. |
|
|
28
|
+
| Receipt in packet / signature | The server's receipt, and its signature checked **only** against your trusted keys. |
|
|
29
|
+
| Your kept receipt | Whether this packet is the run you hold a receipt for. |
|
|
30
|
+
|
|
31
|
+
It also lists the fields no integrity check covers (counters, export-time views).
|
|
32
|
+
|
|
33
|
+
**Anchored: NO** means the file is internally consistent — whoever produced it could have rewritten
|
|
34
|
+
and recomputed everything. Anchoring needs a receipt you kept or a signature by a key you trust.
|
|
35
|
+
|
|
36
|
+
Exit codes: `0` verified · `2` verified with limits (legacy schema, unsealed, pre-manifest run) ·
|
|
37
|
+
`1` failed · `4` unsupported or unreadable input.
|
|
38
|
+
|
|
39
|
+
## Limits
|
|
40
|
+
|
|
41
|
+
AER verifies what was **recorded**: not that the events happened, that sources are correct, or that
|
|
42
|
+
nothing went unreported. `issued_at` in a receipt is the AER server's clock, not an independent
|
|
43
|
+
timestamp. Schema 1.x packets carry no manifest; their content hash covers the writer's key order and
|
|
44
|
+
may not be reproducible after storage.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.main = main;
|
|
5
|
+
// aer-verify — check an AER audit packet offline. Reads local files only; makes no network request.
|
|
6
|
+
//
|
|
7
|
+
// aer-verify packet.json [--receipt receipt.json] [--trusted-key key.json ...] [--require-anchor] [--json]
|
|
8
|
+
//
|
|
9
|
+
// Exit codes: 0 verified · 2 verified with limits (partial / unsealed / legacy) · 1 failed · 4 unsupported/unreadable.
|
|
10
|
+
const node_fs_1 = require("node:fs");
|
|
11
|
+
const index_1 = require("../../evidence-chain/src/index");
|
|
12
|
+
const USAGE = "usage: aer-verify <packet.json> [--receipt <receipt.json>] [--trusted-key <key.json>]... [--require-anchor] [--json]";
|
|
13
|
+
/** Fixed messages only: a parser/OS error message can quote file contents (keys, payloads). */
|
|
14
|
+
class InputError extends Error {
|
|
15
|
+
}
|
|
16
|
+
function readJson(path, what) {
|
|
17
|
+
let text;
|
|
18
|
+
try {
|
|
19
|
+
text = (0, node_fs_1.readFileSync)(path, "utf8");
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new InputError(`cannot read the ${what} file`);
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(text);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new InputError(`the ${what} file is not valid JSON`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Accepts the receipt itself or the whole `complete` response ({ run, receipt }). */
|
|
32
|
+
function toReceipt(v) {
|
|
33
|
+
const o = v;
|
|
34
|
+
if (o && typeof o === "object" && o.statement)
|
|
35
|
+
return o;
|
|
36
|
+
if (o && typeof o === "object" && o.receipt)
|
|
37
|
+
return o.receipt;
|
|
38
|
+
throw new InputError("the receipt file has no `statement`");
|
|
39
|
+
}
|
|
40
|
+
/** Accepts one key {key_id, public_key}, an array of them, or the /api/v1/receipt-keys response. */
|
|
41
|
+
function toKeys(v) {
|
|
42
|
+
const list = Array.isArray(v) ? v : v && typeof v === "object" && "keys" in v ? v.keys : [v];
|
|
43
|
+
return list.map((k) => {
|
|
44
|
+
const o = k;
|
|
45
|
+
if (!o || typeof o.key_id !== "string" || typeof o.public_key !== "string")
|
|
46
|
+
throw new InputError("a trusted key needs key_id and public_key");
|
|
47
|
+
return { key_id: o.key_id, public_key: o.public_key };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const LINES = [
|
|
51
|
+
["schema", "Schema"],
|
|
52
|
+
["chain", "Event chain"],
|
|
53
|
+
["seal", "Seal"],
|
|
54
|
+
["manifest", "Run manifest"],
|
|
55
|
+
["content_hash", "Content hash"],
|
|
56
|
+
["packet_receipt", "Receipt in packet"],
|
|
57
|
+
["signature", "Receipt signature"],
|
|
58
|
+
["trusted_receipt", "Your kept receipt"],
|
|
59
|
+
["trusted_receipt_signature", "Kept receipt signature"],
|
|
60
|
+
];
|
|
61
|
+
function render(r) {
|
|
62
|
+
const out = [`AER packet verification — ${r.overall.toUpperCase()}`, ""];
|
|
63
|
+
for (const [key, label] of LINES) {
|
|
64
|
+
const c = r[key];
|
|
65
|
+
const extra = [c.version && `v${c.version}`, c.key_id && `key ${c.key_id}`, c.detail, c.reason].filter(Boolean).join(" · ");
|
|
66
|
+
out.push(` ${label.padEnd(24)} ${c.status}${extra ? ` (${extra})` : ""}`);
|
|
67
|
+
}
|
|
68
|
+
out.push("", ` Versions: canonical ${r.versions.canonicalization}, chain ${r.versions.chain}, manifest ${r.versions.manifest ?? "—"}, content ${r.versions.content_hash}`);
|
|
69
|
+
out.push(r.anchored
|
|
70
|
+
? " Anchored: yes — tied to a receipt you kept or a key you trust."
|
|
71
|
+
: " Anchored: NO — this only shows the file is internally consistent. Whoever produced it could have");
|
|
72
|
+
if (!r.anchored)
|
|
73
|
+
out.push(" rewritten and recomputed everything. Pass --receipt and/or --trusted-key to anchor it.");
|
|
74
|
+
if (r.not_integrity_bound.length) {
|
|
75
|
+
out.push("", " Not covered by chain or manifest (editable without detection):");
|
|
76
|
+
for (const f of r.not_integrity_bound)
|
|
77
|
+
out.push(` - ${f}`);
|
|
78
|
+
}
|
|
79
|
+
out.push("", " AER verifies what was recorded, not that the recorded events happened or that sources are correct.");
|
|
80
|
+
return out.join("\n");
|
|
81
|
+
}
|
|
82
|
+
function main(argv) {
|
|
83
|
+
const args = [...argv];
|
|
84
|
+
let packetPath;
|
|
85
|
+
let receiptPath;
|
|
86
|
+
const keyPaths = [];
|
|
87
|
+
let json = false;
|
|
88
|
+
let requireAnchor = false;
|
|
89
|
+
while (args.length) {
|
|
90
|
+
const a = args.shift();
|
|
91
|
+
if (a === "--receipt")
|
|
92
|
+
receiptPath = args.shift();
|
|
93
|
+
else if (a === "--trusted-key")
|
|
94
|
+
keyPaths.push(args.shift() ?? "");
|
|
95
|
+
else if (a === "--json")
|
|
96
|
+
json = true;
|
|
97
|
+
else if (a === "--require-anchor")
|
|
98
|
+
requireAnchor = true;
|
|
99
|
+
else if (a === "-h" || a === "--help") {
|
|
100
|
+
console.log(USAGE);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
else if (!packetPath && !a.startsWith("--"))
|
|
104
|
+
packetPath = a;
|
|
105
|
+
else {
|
|
106
|
+
console.error(USAGE);
|
|
107
|
+
return 4;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!packetPath) {
|
|
111
|
+
console.error(USAGE);
|
|
112
|
+
return 4;
|
|
113
|
+
}
|
|
114
|
+
let report;
|
|
115
|
+
try {
|
|
116
|
+
report = (0, index_1.verifyPacket)(readJson(packetPath, "packet"), {
|
|
117
|
+
trustedReceipt: receiptPath ? toReceipt(readJson(receiptPath, "receipt")) : undefined,
|
|
118
|
+
trustedKeys: keyPaths.flatMap((p) => toKeys(readJson(p, "trusted key"))),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
// Unreadable input: our own fixed message, never the underlying error text.
|
|
123
|
+
console.error(`aer-verify: ${err instanceof InputError ? err.message : "input could not be processed"}`);
|
|
124
|
+
return 4;
|
|
125
|
+
}
|
|
126
|
+
console.log(json ? JSON.stringify(report, null, 2) : render(report));
|
|
127
|
+
if (report.overall === "unsupported")
|
|
128
|
+
return 4;
|
|
129
|
+
if (report.overall === "failed")
|
|
130
|
+
return 1;
|
|
131
|
+
if (requireAnchor && !report.anchored)
|
|
132
|
+
return 1;
|
|
133
|
+
return report.overall === "verified" ? 0 : 2;
|
|
134
|
+
}
|
|
135
|
+
if (require.main === module)
|
|
136
|
+
process.exit(main(process.argv.slice(2)));
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Deterministic serialization. The writer and every verifier MUST produce identical bytes for the
|
|
3
|
+
// same logical value, regardless of object key order. Leaves are int | string | boolean | null
|
|
4
|
+
// (this domain has no floats; costMicroUsd is integer micro-USD). undefined is normalized to null.
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.canonicalize = canonicalize;
|
|
7
|
+
function sortValue(v) {
|
|
8
|
+
if (v === undefined || v === null)
|
|
9
|
+
return null;
|
|
10
|
+
if (Array.isArray(v))
|
|
11
|
+
return v.map(sortValue);
|
|
12
|
+
if (typeof v === "object") {
|
|
13
|
+
const out = {};
|
|
14
|
+
for (const k of Object.keys(v).sort()) {
|
|
15
|
+
out[k] = sortValue(v[k]);
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
return v;
|
|
20
|
+
}
|
|
21
|
+
function canonicalize(value) {
|
|
22
|
+
return JSON.stringify(sortValue(value));
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.computeChain = computeChain;
|
|
4
|
+
const hash_1 = require("./hash");
|
|
5
|
+
/** Build the append-only chain for a run: genesis → … → head, in seq order. */
|
|
6
|
+
function computeChain(runId, cores) {
|
|
7
|
+
const ordered = [...cores].sort((a, b) => a.seq - b.seq);
|
|
8
|
+
const links = [];
|
|
9
|
+
let prev = (0, hash_1.genesisHash)(runId);
|
|
10
|
+
for (const core of ordered) {
|
|
11
|
+
const hash = (0, hash_1.hashEvent)(prev, core);
|
|
12
|
+
links.push({ ...core, prevHash: prev, hash });
|
|
13
|
+
prev = hash;
|
|
14
|
+
}
|
|
15
|
+
return links;
|
|
16
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.genesisHash = genesisHash;
|
|
4
|
+
exports.hashEvent = hashEvent;
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
const canonicalize_1 = require("./canonicalize");
|
|
7
|
+
const sha256hex = (s) => (0, node_crypto_1.createHash)("sha256").update(s, "utf8").digest("hex");
|
|
8
|
+
/** Chain root, bound to the run so a chain copied wholesale from another run fails verification. */
|
|
9
|
+
function genesisHash(runId) {
|
|
10
|
+
return sha256hex("AER-CHAIN-v1:" + runId);
|
|
11
|
+
}
|
|
12
|
+
/** The canonical, hashed projection of an event. Findings are sorted so storage order can't change the hash. */
|
|
13
|
+
function coreView(c) {
|
|
14
|
+
return {
|
|
15
|
+
seq: c.seq,
|
|
16
|
+
type: c.type,
|
|
17
|
+
title: c.title,
|
|
18
|
+
occurredAt: c.occurredAt,
|
|
19
|
+
inputRedacted: c.inputRedacted ?? null,
|
|
20
|
+
outputRedacted: c.outputRedacted ?? null,
|
|
21
|
+
riskLevel: c.riskLevel ?? null,
|
|
22
|
+
costMicroUsd: c.costMicroUsd ?? null,
|
|
23
|
+
metadata: c.metadata ?? null,
|
|
24
|
+
findings: [...c.findings]
|
|
25
|
+
.map((f) => ({
|
|
26
|
+
findingType: f.findingType,
|
|
27
|
+
severity: f.severity,
|
|
28
|
+
fieldPath: f.fieldPath,
|
|
29
|
+
originalHash: f.originalHash,
|
|
30
|
+
}))
|
|
31
|
+
.sort((a, b) => `${a.fieldPath}|${a.originalHash}|${a.findingType}`.localeCompare(`${b.fieldPath}|${b.originalHash}|${b.findingType}`)),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** hash = sha256(prevHash + "\n" + canonical(core)). The delimiter keeps prev/core unambiguous. */
|
|
35
|
+
function hashEvent(prevHash, core) {
|
|
36
|
+
return sha256hex(prevHash + "\n" + (0, canonicalize_1.canonicalize)(coreView(core)));
|
|
37
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.unrepresentableKeyPath = exports.packetContentHash = exports.manifestInputFromPacket = exports.packetChainLinks = exports.verifyPacket = exports.checkReceiptSignature = exports.publicKeyOf = exports.signReceipt = exports.manifestHash = exports.buildManifest = exports.verifyChain = exports.computeChain = exports.hashEvent = exports.genesisHash = exports.canonicalize = void 0;
|
|
18
|
+
var canonicalize_1 = require("./canonicalize");
|
|
19
|
+
Object.defineProperty(exports, "canonicalize", { enumerable: true, get: function () { return canonicalize_1.canonicalize; } });
|
|
20
|
+
var hash_1 = require("./hash");
|
|
21
|
+
Object.defineProperty(exports, "genesisHash", { enumerable: true, get: function () { return hash_1.genesisHash; } });
|
|
22
|
+
Object.defineProperty(exports, "hashEvent", { enumerable: true, get: function () { return hash_1.hashEvent; } });
|
|
23
|
+
var chain_1 = require("./chain");
|
|
24
|
+
Object.defineProperty(exports, "computeChain", { enumerable: true, get: function () { return chain_1.computeChain; } });
|
|
25
|
+
var verify_1 = require("./verify");
|
|
26
|
+
Object.defineProperty(exports, "verifyChain", { enumerable: true, get: function () { return verify_1.verifyChain; } });
|
|
27
|
+
var manifest_1 = require("./manifest");
|
|
28
|
+
Object.defineProperty(exports, "buildManifest", { enumerable: true, get: function () { return manifest_1.buildManifest; } });
|
|
29
|
+
Object.defineProperty(exports, "manifestHash", { enumerable: true, get: function () { return manifest_1.manifestHash; } });
|
|
30
|
+
var receipt_1 = require("./receipt");
|
|
31
|
+
Object.defineProperty(exports, "signReceipt", { enumerable: true, get: function () { return receipt_1.signReceipt; } });
|
|
32
|
+
Object.defineProperty(exports, "publicKeyOf", { enumerable: true, get: function () { return receipt_1.publicKeyOf; } });
|
|
33
|
+
Object.defineProperty(exports, "checkReceiptSignature", { enumerable: true, get: function () { return receipt_1.checkReceiptSignature; } });
|
|
34
|
+
var packet_1 = require("./packet");
|
|
35
|
+
Object.defineProperty(exports, "verifyPacket", { enumerable: true, get: function () { return packet_1.verifyPacket; } });
|
|
36
|
+
Object.defineProperty(exports, "packetChainLinks", { enumerable: true, get: function () { return packet_1.packetChainLinks; } });
|
|
37
|
+
Object.defineProperty(exports, "manifestInputFromPacket", { enumerable: true, get: function () { return packet_1.manifestInputFromPacket; } });
|
|
38
|
+
Object.defineProperty(exports, "packetContentHash", { enumerable: true, get: function () { return packet_1.packetContentHash; } });
|
|
39
|
+
__exportStar(require("./versions"), exports);
|
|
40
|
+
var representable_1 = require("./representable");
|
|
41
|
+
Object.defineProperty(exports, "unrepresentableKeyPath", { enumerable: true, get: function () { return representable_1.unrepresentableKeyPath; } });
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildManifest = buildManifest;
|
|
4
|
+
exports.manifestHash = manifestHash;
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
const canonicalize_1 = require("./canonicalize");
|
|
7
|
+
const hash_1 = require("./hash");
|
|
8
|
+
const versions_1 = require("./versions");
|
|
9
|
+
const sha256hex = (s) => (0, node_crypto_1.createHash)("sha256").update(s, "utf8").digest("hex");
|
|
10
|
+
/**
|
|
11
|
+
* The run manifest: binds run context (agent, status, tenant, policies, lineage) and each event's
|
|
12
|
+
* out-of-chain fields (capture claim, policy, sourced block — the latter by digest) to the chain
|
|
13
|
+
* head. Frozen at seal; its hash is what a receipt/signature commits to.
|
|
14
|
+
*/
|
|
15
|
+
function buildManifest(input) {
|
|
16
|
+
const nullable = (v) => (v === undefined ? null : v);
|
|
17
|
+
return {
|
|
18
|
+
manifest_version: versions_1.MANIFEST,
|
|
19
|
+
canonicalization: versions_1.CANONICALIZATION,
|
|
20
|
+
hash_algorithm: "sha256",
|
|
21
|
+
chain: {
|
|
22
|
+
version: versions_1.CHAIN,
|
|
23
|
+
genesis: (0, hash_1.genesisHash)(input.run.id),
|
|
24
|
+
head: input.seal,
|
|
25
|
+
event_count: input.events.length,
|
|
26
|
+
},
|
|
27
|
+
run: {
|
|
28
|
+
id: input.run.id,
|
|
29
|
+
organization_id: input.run.organizationId,
|
|
30
|
+
project_id: input.run.projectId,
|
|
31
|
+
agent_name: input.run.agentName,
|
|
32
|
+
status: input.run.status,
|
|
33
|
+
risk_level: input.run.riskLevel,
|
|
34
|
+
started_at: input.run.startedAt,
|
|
35
|
+
ended_at: input.run.endedAt,
|
|
36
|
+
cost_micro_usd: input.run.costMicroUsd,
|
|
37
|
+
redaction_policy: input.run.redactionPolicy,
|
|
38
|
+
capture_declared: nullable(input.run.captureDeclared),
|
|
39
|
+
environment: input.run.environment,
|
|
40
|
+
conversation_ref: input.run.conversationRef,
|
|
41
|
+
turn_ref: input.run.turnRef,
|
|
42
|
+
parent_run_id: input.run.parentRunId,
|
|
43
|
+
},
|
|
44
|
+
events: [...input.events]
|
|
45
|
+
.sort((a, b) => a.seq - b.seq)
|
|
46
|
+
.map((e) => ({
|
|
47
|
+
seq: e.seq,
|
|
48
|
+
hash: e.hash,
|
|
49
|
+
redaction_policy: e.redactionPolicy,
|
|
50
|
+
capture_declared: nullable(e.captureDeclared),
|
|
51
|
+
sourced_digest: e.sourced === null || e.sourced === undefined ? null : sha256hex((0, canonicalize_1.canonicalize)(e.sourced)),
|
|
52
|
+
})),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** sha256 over the manifest's canonical bytes (raw hex). */
|
|
56
|
+
function manifestHash(manifest) {
|
|
57
|
+
return sha256hex((0, canonicalize_1.canonicalize)(manifest));
|
|
58
|
+
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.packetChainLinks = packetChainLinks;
|
|
4
|
+
exports.manifestInputFromPacket = manifestInputFromPacket;
|
|
5
|
+
exports.packetContentHash = packetContentHash;
|
|
6
|
+
exports.packetShapeError = packetShapeError;
|
|
7
|
+
exports.verifyPacket = verifyPacket;
|
|
8
|
+
// Offline packet verification. Input: a parsed packet (JSON) plus what the VERIFIER trusts (a receipt
|
|
9
|
+
// it kept, public keys it obtained out of band). No network, no DB, no AER account.
|
|
10
|
+
const node_crypto_1 = require("node:crypto");
|
|
11
|
+
const canonicalize_1 = require("./canonicalize");
|
|
12
|
+
const manifest_1 = require("./manifest");
|
|
13
|
+
const receipt_1 = require("./receipt");
|
|
14
|
+
const hash_1 = require("./hash");
|
|
15
|
+
const representable_1 = require("./representable");
|
|
16
|
+
const verify_1 = require("./verify");
|
|
17
|
+
const versions_1 = require("./versions");
|
|
18
|
+
const sha256hex = (s) => (0, node_crypto_1.createHash)("sha256").update(s, "utf8").digest("hex");
|
|
19
|
+
const strip = (v) => v.replace(/^sha256:/, "");
|
|
20
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
21
|
+
const str = (v) => (typeof v === "string" ? v : null);
|
|
22
|
+
/** Packet events → chain links, `sha256:` presentation prefix removed (the bytes the writer hashed). */
|
|
23
|
+
function packetChainLinks(packet) {
|
|
24
|
+
const events = Array.isArray(packet.events) ? packet.events : [];
|
|
25
|
+
return events.map((e) => ({
|
|
26
|
+
seq: e.seq,
|
|
27
|
+
type: e.type,
|
|
28
|
+
title: e.title,
|
|
29
|
+
occurredAt: e.occurredAt,
|
|
30
|
+
inputRedacted: e.inputRedacted ?? null,
|
|
31
|
+
outputRedacted: e.outputRedacted ?? null,
|
|
32
|
+
riskLevel: e.riskLevel ?? null,
|
|
33
|
+
costMicroUsd: e.costMicroUsd ?? null,
|
|
34
|
+
metadata: e.metadata ?? null,
|
|
35
|
+
findings: (Array.isArray(e.findings) ? e.findings : []).map((f) => ({
|
|
36
|
+
findingType: f.findingType,
|
|
37
|
+
severity: f.severity,
|
|
38
|
+
fieldPath: f.fieldPath,
|
|
39
|
+
originalHash: typeof f.originalHash === "string" ? strip(f.originalHash) : "",
|
|
40
|
+
})),
|
|
41
|
+
hash: typeof e.hash === "string" ? strip(e.hash) : "",
|
|
42
|
+
prevHash: typeof e.prevHash === "string" ? strip(e.prevHash) : "",
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
/** A capture view {layer, ...declared} back to the stored declaration. */
|
|
46
|
+
function declared(capture) {
|
|
47
|
+
const d = isObj(capture) ? capture.client_declared : null;
|
|
48
|
+
if (!isObj(d))
|
|
49
|
+
return null;
|
|
50
|
+
const { layer: _layer, ...rest } = d;
|
|
51
|
+
return rest;
|
|
52
|
+
}
|
|
53
|
+
const serverPolicy = (capture) => {
|
|
54
|
+
const s = isObj(capture) ? capture.server_applied : null;
|
|
55
|
+
return isObj(s) ? str(s.policy) : null;
|
|
56
|
+
};
|
|
57
|
+
/** Rebuild the manifest input from a 2.x packet — must match what the server froze at seal. */
|
|
58
|
+
function manifestInputFromPacket(packet) {
|
|
59
|
+
const run = packet.run;
|
|
60
|
+
const links = isObj(run.links) ? run.links : {};
|
|
61
|
+
const parent = isObj(links.parentRun) ? links.parentRun : null;
|
|
62
|
+
return {
|
|
63
|
+
run: {
|
|
64
|
+
id: run.id,
|
|
65
|
+
organizationId: run.organizationId,
|
|
66
|
+
projectId: run.projectId,
|
|
67
|
+
agentName: run.agentName,
|
|
68
|
+
status: run.status,
|
|
69
|
+
riskLevel: run.riskLevel,
|
|
70
|
+
startedAt: run.startedAt,
|
|
71
|
+
endedAt: str(run.endedAt),
|
|
72
|
+
costMicroUsd: run.costMicroUsd,
|
|
73
|
+
redactionPolicy: serverPolicy(run.capture),
|
|
74
|
+
captureDeclared: declared(run.capture),
|
|
75
|
+
environment: str(run.environment),
|
|
76
|
+
conversationRef: str(links.conversationRef),
|
|
77
|
+
turnRef: str(links.turnRef),
|
|
78
|
+
parentRunId: parent ? str(parent.id) : null,
|
|
79
|
+
},
|
|
80
|
+
events: packet.events.map((e) => {
|
|
81
|
+
let sourced = null;
|
|
82
|
+
if (isObj(e.sourced)) {
|
|
83
|
+
// `not_recorded` is a reading aid added at export time, not part of the stored block.
|
|
84
|
+
const { not_recorded: _gaps, ...rest } = e.sourced;
|
|
85
|
+
sourced = rest;
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
seq: e.seq,
|
|
89
|
+
hash: typeof e.hash === "string" ? strip(e.hash) : "",
|
|
90
|
+
redactionPolicy: serverPolicy(e.capture),
|
|
91
|
+
captureDeclared: declared(e.capture),
|
|
92
|
+
sourced,
|
|
93
|
+
};
|
|
94
|
+
}),
|
|
95
|
+
seal: typeof packet.seal === "string" ? strip(packet.seal) : "",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** content_hash as the writer computed it, per schema generation. */
|
|
99
|
+
function packetContentHash(packet) {
|
|
100
|
+
const exp = isObj(packet.export) ? packet.export : {};
|
|
101
|
+
const { content_hash: _h, ...restExport } = exp;
|
|
102
|
+
const base = { ...packet, export: restExport };
|
|
103
|
+
return String(packet.schema_version).startsWith("1.")
|
|
104
|
+
? { algorithm: versions_1.CONTENT_HASH_LEGACY, hex: sha256hex(JSON.stringify(base)) }
|
|
105
|
+
: { algorithm: versions_1.CONTENT_HASH, hex: sha256hex((0, canonicalize_1.canonicalize)(base)) };
|
|
106
|
+
}
|
|
107
|
+
/** Fields only the content_hash covers — anyone who edits the file can recompute that hash too. */
|
|
108
|
+
const NOT_INTEGRITY_BOUND = {
|
|
109
|
+
"1": [
|
|
110
|
+
"run.* (agentName, status, riskLevel, times, counters)",
|
|
111
|
+
"run.capture / events[].capture",
|
|
112
|
+
"run.links / events[].sourced",
|
|
113
|
+
"redaction_findings (copy of events[].findings)",
|
|
114
|
+
"export.*",
|
|
115
|
+
],
|
|
116
|
+
"2": [
|
|
117
|
+
"run.durationMs, run.eventCount, run.redactionCount (counters)",
|
|
118
|
+
"run.links.parentRun.status / agentName (resolved at export time)",
|
|
119
|
+
"events[].sourced.not_recorded (derived at export time)",
|
|
120
|
+
"redaction_findings (copy of events[].findings)",
|
|
121
|
+
"views, coverage (descriptive)",
|
|
122
|
+
"export.* (generatedAt, type)",
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
const HASH = /^sha256:[0-9a-f]{64}$/;
|
|
126
|
+
const isInt = (v) => Number.isInteger(v);
|
|
127
|
+
const isStrOrNull = (v) => v === null || typeof v === "string";
|
|
128
|
+
const isHashOrNull = (v) => v === null || (typeof v === "string" && HASH.test(v));
|
|
129
|
+
/**
|
|
130
|
+
* The packet's shape, checked BEFORE anything is hashed or compared: a wrong type must never be
|
|
131
|
+
* normalised into a valid one (e.g. `sourced: []` read as null). Free-form JSON (input/output/
|
|
132
|
+
* metadata, sourced/declaration contents) is only required to be present with the right outer type.
|
|
133
|
+
*/
|
|
134
|
+
function packetShapeError(p, version) {
|
|
135
|
+
const minor = version.startsWith("1.") ? Number(version.slice(2)) : 99;
|
|
136
|
+
const run = p.run;
|
|
137
|
+
const top = [
|
|
138
|
+
["run.id", typeof run.id === "string"],
|
|
139
|
+
["run.agentName", typeof run.agentName === "string"],
|
|
140
|
+
["run.status", typeof run.status === "string"],
|
|
141
|
+
["run.riskLevel", typeof run.riskLevel === "string"],
|
|
142
|
+
["run.startedAt", typeof run.startedAt === "string"],
|
|
143
|
+
["run.endedAt", isStrOrNull(run.endedAt)],
|
|
144
|
+
["run.costMicroUsd", isInt(run.costMicroUsd)],
|
|
145
|
+
["seal", isHashOrNull(p.seal)],
|
|
146
|
+
["chain", isObj(p.chain) && typeof p.chain.algorithm === "string" && typeof p.chain.genesis === "string"],
|
|
147
|
+
["export", isObj(p.export) && typeof p.export.generatedAt === "string"],
|
|
148
|
+
["redaction_findings", Array.isArray(p.redaction_findings)],
|
|
149
|
+
];
|
|
150
|
+
if (minor >= 2)
|
|
151
|
+
top.push(["run.capture", captureOk(run.capture)]);
|
|
152
|
+
if (minor >= 3) {
|
|
153
|
+
const l = run.links;
|
|
154
|
+
top.push([
|
|
155
|
+
"run.links",
|
|
156
|
+
isObj(l) && isStrOrNull(l.conversationRef) && isStrOrNull(l.turnRef) &&
|
|
157
|
+
(l.parentRun === null || (isObj(l.parentRun) && typeof l.parentRun.id === "string")),
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
if (minor === 99) {
|
|
161
|
+
top.push(["run.organizationId", typeof run.organizationId === "string"], ["run.projectId", typeof run.projectId === "string"], ["run.environment", isStrOrNull(run.environment)], ["manifest_hash", isHashOrNull(p.manifest_hash)], ["receipt", p.receipt === null || receiptShapeOk(p.receipt)], ["versions", isObj(p.versions)]);
|
|
162
|
+
}
|
|
163
|
+
for (const [field, ok] of top)
|
|
164
|
+
if (!ok)
|
|
165
|
+
return `${field} has the wrong type or is missing`;
|
|
166
|
+
const events = p.events;
|
|
167
|
+
for (let i = 0; i < events.length; i++) {
|
|
168
|
+
const e = events[i];
|
|
169
|
+
if (!isObj(e))
|
|
170
|
+
return `events[${i}] is not an object`;
|
|
171
|
+
const checks = [
|
|
172
|
+
["seq", isInt(e.seq) && e.seq > 0],
|
|
173
|
+
["type", typeof e.type === "string"],
|
|
174
|
+
["title", typeof e.title === "string"],
|
|
175
|
+
["occurredAt", typeof e.occurredAt === "string"],
|
|
176
|
+
["inputRedacted", "inputRedacted" in e],
|
|
177
|
+
["outputRedacted", "outputRedacted" in e],
|
|
178
|
+
["metadata", "metadata" in e],
|
|
179
|
+
["riskLevel", isStrOrNull(e.riskLevel)],
|
|
180
|
+
["costMicroUsd", e.costMicroUsd === null || isInt(e.costMicroUsd)],
|
|
181
|
+
["hash", isHashOrNull(e.hash)],
|
|
182
|
+
["prevHash", isHashOrNull(e.prevHash)],
|
|
183
|
+
[
|
|
184
|
+
"findings",
|
|
185
|
+
Array.isArray(e.findings) &&
|
|
186
|
+
e.findings.every((f) => isObj(f) && typeof f.findingType === "string" && typeof f.severity === "string" &&
|
|
187
|
+
typeof f.fieldPath === "string" && isHashOrNull(f.originalHash)),
|
|
188
|
+
],
|
|
189
|
+
];
|
|
190
|
+
if (minor >= 2)
|
|
191
|
+
checks.push(["capture", captureOk(e.capture)]);
|
|
192
|
+
if (minor >= 3)
|
|
193
|
+
checks.push(["sourced", e.sourced === null || isObj(e.sourced)]);
|
|
194
|
+
for (const [field, ok] of checks)
|
|
195
|
+
if (!ok)
|
|
196
|
+
return `events[${i}].${field} has the wrong type or is missing`;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
function captureOk(c) {
|
|
201
|
+
if (!isObj(c))
|
|
202
|
+
return false;
|
|
203
|
+
const s = c.server_applied;
|
|
204
|
+
const d = c.client_declared;
|
|
205
|
+
return ((s === null || (isObj(s) && s.layer === "server" && typeof s.policy === "string")) &&
|
|
206
|
+
(d === null || (isObj(d) && d.layer === "client")));
|
|
207
|
+
}
|
|
208
|
+
function receiptShapeOk(r) {
|
|
209
|
+
if (!isObj(r) || !isObj(r.statement))
|
|
210
|
+
return false;
|
|
211
|
+
const s = r.signature;
|
|
212
|
+
return s === null || (isObj(s) && typeof s.alg === "string" && typeof s.key_id === "string" && typeof s.value === "string");
|
|
213
|
+
}
|
|
214
|
+
/** Declared algorithm versions this verifier implements; anything else is unsupported, never assumed. */
|
|
215
|
+
function versionError(p, version, runId) {
|
|
216
|
+
const chain = p.chain;
|
|
217
|
+
if (chain.algorithm !== "sha256")
|
|
218
|
+
return `chain.algorithm ${String(chain.algorithm)} not supported`;
|
|
219
|
+
if (chain.genesis !== `sha256:${(0, hash_1.genesisHash)(runId)}`)
|
|
220
|
+
return "chain.genesis is not the aer-chain/1 genesis for this run";
|
|
221
|
+
if (version.startsWith("1."))
|
|
222
|
+
return null; // 1.x predates declared versions: implicitly canonical/1 + chain/1
|
|
223
|
+
const v = p.versions;
|
|
224
|
+
if (v.canonicalization !== versions_1.CANONICALIZATION)
|
|
225
|
+
return `canonicalization ${String(v.canonicalization)} not supported`;
|
|
226
|
+
if (v.chain !== versions_1.CHAIN)
|
|
227
|
+
return `chain ${String(v.chain)} not supported`;
|
|
228
|
+
if (v.content_hash !== versions_1.CONTENT_HASH)
|
|
229
|
+
return `content_hash ${String(v.content_hash)} not supported`;
|
|
230
|
+
if (!(v.manifest === null || v.manifest === versions_1.MANIFEST))
|
|
231
|
+
return `manifest ${String(v.manifest)} not supported`;
|
|
232
|
+
if (p.manifest_hash !== null && v.manifest !== versions_1.MANIFEST)
|
|
233
|
+
return "manifest_hash present without a supported manifest version";
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
/** Every commitment in a receipt statement, checked against the packet; issued_at stays a claim. */
|
|
237
|
+
function receiptMismatch(s, ctx) {
|
|
238
|
+
if (!isObj(s))
|
|
239
|
+
return "receipt has no statement";
|
|
240
|
+
if (s.receipt_version !== versions_1.RECEIPT)
|
|
241
|
+
return `receipt_version ${String(s.receipt_version)} not supported`;
|
|
242
|
+
if (s.manifest_version !== versions_1.MANIFEST)
|
|
243
|
+
return `manifest_version ${String(s.manifest_version)} not supported`;
|
|
244
|
+
const checks = [
|
|
245
|
+
["run_id", s.run_id === ctx.runId],
|
|
246
|
+
["organization_id", s.organization_id === ctx.run.organizationId],
|
|
247
|
+
["project_id", s.project_id === ctx.run.projectId],
|
|
248
|
+
["manifest_hash", typeof s.manifest_hash === "string" && ctx.manifest !== null && strip(s.manifest_hash) === ctx.manifest],
|
|
249
|
+
["chain_head", typeof s.chain_head === "string" && ctx.seal !== null && strip(s.chain_head) === ctx.seal],
|
|
250
|
+
["event_count", s.event_count === ctx.events],
|
|
251
|
+
["clock", s.clock === "aer-server"],
|
|
252
|
+
["issued_at", typeof s.issued_at === "string"],
|
|
253
|
+
];
|
|
254
|
+
for (const [field, ok] of checks)
|
|
255
|
+
if (!ok)
|
|
256
|
+
return `receipt ${field} does not match this packet`;
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Verify a packet offline. Each check is reported on its own; `overall` is "verified" only when the
|
|
261
|
+
* schema is current, the chain is sealed and intact, the content hash and the manifest reproduce,
|
|
262
|
+
* and nothing that was checked failed. Never throws on bad input — reports it.
|
|
263
|
+
*/
|
|
264
|
+
function verifyPacket(input, opts = {}) {
|
|
265
|
+
const trusted = opts.trustedKeys ?? [];
|
|
266
|
+
const report = {
|
|
267
|
+
overall: "failed",
|
|
268
|
+
anchored: false,
|
|
269
|
+
schema: { status: "malformed", version: null },
|
|
270
|
+
versions: { canonicalization: versions_1.CANONICALIZATION, chain: versions_1.CHAIN, manifest: null, content_hash: versions_1.CONTENT_HASH },
|
|
271
|
+
chain: { status: "not_run" },
|
|
272
|
+
content_hash: { status: "not_run" },
|
|
273
|
+
manifest: { status: "not_run" },
|
|
274
|
+
seal: { status: "not_run" },
|
|
275
|
+
signature: { status: "not_run" },
|
|
276
|
+
packet_receipt: { status: "not_run" },
|
|
277
|
+
trusted_receipt: { status: "not_run" },
|
|
278
|
+
trusted_receipt_signature: { status: "not_run" },
|
|
279
|
+
not_integrity_bound: [],
|
|
280
|
+
};
|
|
281
|
+
if (!isObj(input) || !isObj(input.run) || typeof input.run.id !== "string" || !Array.isArray(input.events)) {
|
|
282
|
+
report.schema.detail = "not an AER packet (run.id / events missing)";
|
|
283
|
+
return report;
|
|
284
|
+
}
|
|
285
|
+
const packet = input;
|
|
286
|
+
const version = str(packet.schema_version);
|
|
287
|
+
report.schema.version = version;
|
|
288
|
+
if (!version || !versions_1.SUPPORTED_PACKET_SCHEMAS.includes(version)) {
|
|
289
|
+
// Never "verify" a format this verifier does not know: its hash scopes may differ.
|
|
290
|
+
report.schema = { status: "unsupported", version, detail: `supported: ${versions_1.SUPPORTED_PACKET_SCHEMAS.join(", ")}` };
|
|
291
|
+
report.overall = "unsupported";
|
|
292
|
+
return report;
|
|
293
|
+
}
|
|
294
|
+
const legacySchema = version.startsWith("1.");
|
|
295
|
+
const runId = packet.run.id;
|
|
296
|
+
// Shape first: a wrong type is never coerced into a valid one before hashing/comparison.
|
|
297
|
+
const shape = packetShapeError(packet, version);
|
|
298
|
+
const proto = shape ? null : (0, representable_1.unrepresentableKeyPath)(packet);
|
|
299
|
+
if (shape || proto) {
|
|
300
|
+
report.schema = {
|
|
301
|
+
status: "malformed",
|
|
302
|
+
version,
|
|
303
|
+
detail: shape ?? `${proto}: own "__proto__" keys are not representable in ${versions_1.CANONICALIZATION} (not covered by any hash)`,
|
|
304
|
+
};
|
|
305
|
+
return report;
|
|
306
|
+
}
|
|
307
|
+
// Declared algorithms are reported as declared and checked — never assumed to be this build's.
|
|
308
|
+
if (!legacySchema) {
|
|
309
|
+
const v = packet.versions;
|
|
310
|
+
report.versions = {
|
|
311
|
+
canonicalization: String(v.canonicalization),
|
|
312
|
+
chain: String(v.chain),
|
|
313
|
+
manifest: v.manifest === null ? null : String(v.manifest),
|
|
314
|
+
content_hash: String(v.content_hash),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
report.versions.content_hash = versions_1.CONTENT_HASH_LEGACY;
|
|
319
|
+
}
|
|
320
|
+
const unsupported = versionError(packet, version, runId);
|
|
321
|
+
if (unsupported) {
|
|
322
|
+
report.schema = { status: "unsupported", version, detail: unsupported };
|
|
323
|
+
report.overall = "unsupported";
|
|
324
|
+
return report;
|
|
325
|
+
}
|
|
326
|
+
report.schema.status = "supported";
|
|
327
|
+
report.not_integrity_bound = NOT_INTEGRITY_BOUND[legacySchema ? "1" : "2"];
|
|
328
|
+
const seal = typeof packet.seal === "string" ? strip(packet.seal) : null;
|
|
329
|
+
// 1. Chain.
|
|
330
|
+
try {
|
|
331
|
+
const v = (0, verify_1.verifyChain)(packetChainLinks(packet), seal, runId);
|
|
332
|
+
report.chain = v.ok
|
|
333
|
+
? { status: v.status, detail: `${v.checkedCount} events` }
|
|
334
|
+
: v.status === "legacy"
|
|
335
|
+
? { status: "legacy", detail: v.reason }
|
|
336
|
+
: { status: "broken", detail: v.reason };
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
report.chain = { status: "broken", detail: "events are not well-formed chain links" };
|
|
340
|
+
}
|
|
341
|
+
report.seal = { status: seal ? "sealed" : "unsealed" };
|
|
342
|
+
// 2. Content hash (whole file consistency — NOT tamper evidence on its own).
|
|
343
|
+
const claimed = isObj(packet.export) ? str(packet.export.content_hash) : null;
|
|
344
|
+
const computed = packetContentHash(packet);
|
|
345
|
+
if (!claimed)
|
|
346
|
+
report.content_hash = { status: "missing" };
|
|
347
|
+
else if (strip(claimed) === computed.hex)
|
|
348
|
+
report.content_hash = { status: "verified" };
|
|
349
|
+
else
|
|
350
|
+
report.content_hash = legacySchema
|
|
351
|
+
? { status: "not_reproducible", detail: "1.x hashes the writer's key order; storage may have reordered keys" }
|
|
352
|
+
: { status: "mismatch" };
|
|
353
|
+
// 3. Manifest (2.x): rebuilt from the packet, compared with the hash frozen at seal.
|
|
354
|
+
let rebuilt = null;
|
|
355
|
+
if (legacySchema)
|
|
356
|
+
report.manifest = { status: "not_in_schema" };
|
|
357
|
+
else {
|
|
358
|
+
const frozen = str(packet.manifest_hash);
|
|
359
|
+
try {
|
|
360
|
+
rebuilt = seal ? (0, manifest_1.manifestHash)((0, manifest_1.buildManifest)(manifestInputFromPacket(packet))) : null;
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
rebuilt = null;
|
|
364
|
+
}
|
|
365
|
+
if (!frozen)
|
|
366
|
+
report.manifest = { status: "not_frozen", detail: seal ? "sealed before run manifests existed" : "run not sealed" };
|
|
367
|
+
else if (rebuilt && strip(frozen) === rebuilt)
|
|
368
|
+
report.manifest = { status: "verified" };
|
|
369
|
+
else
|
|
370
|
+
report.manifest = { status: "mismatch", detail: "run context or out-of-chain event fields differ from what was frozen at seal" };
|
|
371
|
+
}
|
|
372
|
+
const receiptCtx = { runId, run: packet.run, seal, manifest: rebuilt, events: packet.events.length };
|
|
373
|
+
// 4. The server's receipt inside the packet: signature only counts against keys the verifier trusts.
|
|
374
|
+
const packetReceipt = isObj(packet.receipt) ? packet.receipt : null;
|
|
375
|
+
if (!packetReceipt) {
|
|
376
|
+
report.packet_receipt = { status: "not_present" };
|
|
377
|
+
report.signature = { status: "not_present" };
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
report.signature = (0, receipt_1.checkReceiptSignature)(packetReceipt, trusted);
|
|
381
|
+
const why = receiptMismatch(packetReceipt.statement, receiptCtx);
|
|
382
|
+
report.packet_receipt = why ? { status: "mismatch", detail: why } : { status: "matches_packet" };
|
|
383
|
+
}
|
|
384
|
+
// 5. A receipt the verifier kept: the anchor that a fully recomputed packet cannot match.
|
|
385
|
+
if (!opts.trustedReceipt) {
|
|
386
|
+
report.trusted_receipt = { status: "not_provided" };
|
|
387
|
+
report.trusted_receipt_signature = { status: "not_provided" };
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
report.trusted_receipt_signature = (0, receipt_1.checkReceiptSignature)(opts.trustedReceipt, trusted);
|
|
391
|
+
const why = rebuilt ? receiptMismatch(opts.trustedReceipt.statement, receiptCtx) : null;
|
|
392
|
+
if (!rebuilt)
|
|
393
|
+
report.trusted_receipt = { status: "cannot_compare", detail: "packet has no reproducible manifest (unsealed or schema 1.x)" };
|
|
394
|
+
else if (why)
|
|
395
|
+
report.trusted_receipt = { status: "mismatch", detail: `packet differs from the run you hold a receipt for: ${why}` };
|
|
396
|
+
else
|
|
397
|
+
report.trusted_receipt = { status: "match" };
|
|
398
|
+
}
|
|
399
|
+
const failed = report.chain.status === "broken" ||
|
|
400
|
+
report.content_hash.status === "mismatch" ||
|
|
401
|
+
report.manifest.status === "mismatch" ||
|
|
402
|
+
report.signature.status === "invalid" ||
|
|
403
|
+
report.packet_receipt.status === "mismatch" ||
|
|
404
|
+
report.trusted_receipt.status === "mismatch" ||
|
|
405
|
+
report.trusted_receipt.status === "cannot_compare" ||
|
|
406
|
+
report.trusted_receipt_signature.status === "invalid";
|
|
407
|
+
report.anchored =
|
|
408
|
+
!failed && (report.signature.status === "valid_trusted" || report.trusted_receipt.status === "match");
|
|
409
|
+
report.overall = failed
|
|
410
|
+
? "failed"
|
|
411
|
+
: report.chain.status === "legacy"
|
|
412
|
+
? "legacy_unchained"
|
|
413
|
+
: report.chain.status === "consistent_unsealed"
|
|
414
|
+
? "consistent_unsealed"
|
|
415
|
+
: legacySchema || report.manifest.status !== "verified" || report.content_hash.status !== "verified"
|
|
416
|
+
? "verified_partial"
|
|
417
|
+
: "verified";
|
|
418
|
+
return report;
|
|
419
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.signReceipt = signReceipt;
|
|
4
|
+
exports.publicKeyOf = publicKeyOf;
|
|
5
|
+
exports.checkReceiptSignature = checkReceiptSignature;
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
const canonicalize_1 = require("./canonicalize");
|
|
8
|
+
function toPublicKey(material) {
|
|
9
|
+
return material.includes("BEGIN")
|
|
10
|
+
? (0, node_crypto_1.createPublicKey)(material)
|
|
11
|
+
: (0, node_crypto_1.createPublicKey)({ key: Buffer.from(material, "base64"), format: "der", type: "spki" });
|
|
12
|
+
}
|
|
13
|
+
function toPrivateKey(material) {
|
|
14
|
+
return material.includes("BEGIN")
|
|
15
|
+
? (0, node_crypto_1.createPrivateKey)(material)
|
|
16
|
+
: (0, node_crypto_1.createPrivateKey)({ key: Buffer.from(material, "base64"), format: "der", type: "pkcs8" });
|
|
17
|
+
}
|
|
18
|
+
const statementBytes = (s) => Buffer.from((0, canonicalize_1.canonicalize)(s), "utf8");
|
|
19
|
+
/** Sign with an Ed25519 private key (base64 PKCS8 DER or PEM). The key never leaves this call. */
|
|
20
|
+
function signReceipt(statement, privateKey, keyId) {
|
|
21
|
+
const value = (0, node_crypto_1.sign)(null, statementBytes(statement), toPrivateKey(privateKey)).toString("base64");
|
|
22
|
+
return { statement, signature: { alg: "Ed25519", key_id: keyId, value } };
|
|
23
|
+
}
|
|
24
|
+
/** base64 SPKI DER public key for a private key — what the server publishes for verifiers. */
|
|
25
|
+
function publicKeyOf(privateKey) {
|
|
26
|
+
return (0, node_crypto_1.createPublicKey)(toPrivateKey(privateKey)).export({ format: "der", type: "spki" }).toString("base64");
|
|
27
|
+
}
|
|
28
|
+
/** Check a receipt's signature against keys the caller trusts. A key carried in the packet is never used. */
|
|
29
|
+
function checkReceiptSignature(receipt, trusted) {
|
|
30
|
+
const sig = receipt.signature;
|
|
31
|
+
if (!sig)
|
|
32
|
+
return { status: "not_present" };
|
|
33
|
+
if (sig.alg !== "Ed25519")
|
|
34
|
+
return { status: "invalid", key_id: sig.key_id, reason: `unsupported alg ${sig.alg}` };
|
|
35
|
+
const key = trusted.find((k) => k.key_id === sig.key_id);
|
|
36
|
+
if (!key)
|
|
37
|
+
return { status: "untrusted_key", key_id: sig.key_id };
|
|
38
|
+
try {
|
|
39
|
+
const ok = (0, node_crypto_1.verify)(null, statementBytes(receipt.statement), toPublicKey(key.public_key), Buffer.from(sig.value, "base64"));
|
|
40
|
+
return ok ? { status: "valid_trusted", key_id: sig.key_id } : { status: "invalid", key_id: sig.key_id, reason: "signature does not match" };
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return { status: "invalid", key_id: sig.key_id, reason: "unreadable key or signature" };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.unrepresentableKeyPath = unrepresentableKeyPath;
|
|
4
|
+
/**
|
|
5
|
+
* aer-canonical/1 rebuilds objects by assignment (`out[k] = …`). An OWN "__proto__" key — which
|
|
6
|
+
* JSON.parse creates as plain data — is therefore re-routed to the prototype and silently left out
|
|
7
|
+
* of the hashed bytes: anything under it is not covered by any hash. The algorithm is frozen (old
|
|
8
|
+
* records were hashed that way), so such content is refused instead: at the API boundary (never
|
|
9
|
+
* written) and in the verifier (a packet carrying one is malformed, never "verified").
|
|
10
|
+
* Returns the JSON path of the first such key, or null.
|
|
11
|
+
*/
|
|
12
|
+
function unrepresentableKeyPath(value, path = "$") {
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
for (let i = 0; i < value.length; i++) {
|
|
15
|
+
const p = unrepresentableKeyPath(value[i], `${path}[${i}]`);
|
|
16
|
+
if (p)
|
|
17
|
+
return p;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
if (value !== null && typeof value === "object") {
|
|
22
|
+
if (Object.prototype.hasOwnProperty.call(value, "__proto__"))
|
|
23
|
+
return `${path}.__proto__`;
|
|
24
|
+
for (const k of Object.keys(value)) {
|
|
25
|
+
const p = unrepresentableKeyPath(value[k], `${path}.${k}`);
|
|
26
|
+
if (p)
|
|
27
|
+
return p;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.verifyChain = verifyChain;
|
|
4
|
+
const hash_1 = require("./hash");
|
|
5
|
+
/**
|
|
6
|
+
* The single shared verifier. Pure: input is ONLY the links + seal (+ runId for genesis).
|
|
7
|
+
* No DB, no app context, no I/O. Consumers map their source (packet JSON / DB rows) into
|
|
8
|
+
* ChainLink[] and delegate here — never reimplement chain logic.
|
|
9
|
+
*/
|
|
10
|
+
function verifyChain(links, seal, runId) {
|
|
11
|
+
const ordered = [...links].sort((a, b) => a.seq - b.seq);
|
|
12
|
+
// A run created before the chain feature carries no hashes — distinct from tampering.
|
|
13
|
+
if (ordered.length > 0 && ordered.every((l) => !l.hash && !l.prevHash)) {
|
|
14
|
+
return { ok: false, status: "legacy", reason: "no chain present (pre-chain run)" };
|
|
15
|
+
}
|
|
16
|
+
let prev = (0, hash_1.genesisHash)(runId);
|
|
17
|
+
let checked = 0;
|
|
18
|
+
for (const link of ordered) {
|
|
19
|
+
if (link.prevHash !== prev) {
|
|
20
|
+
return {
|
|
21
|
+
ok: false,
|
|
22
|
+
status: "broken",
|
|
23
|
+
brokenSeq: link.seq,
|
|
24
|
+
reason: `prevHash mismatch at seq ${link.seq}`,
|
|
25
|
+
checkedCount: checked,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (link.hash !== (0, hash_1.hashEvent)(prev, link)) {
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
status: "broken",
|
|
32
|
+
brokenSeq: link.seq,
|
|
33
|
+
reason: `content hash mismatch at seq ${link.seq}`,
|
|
34
|
+
checkedCount: checked,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
prev = link.hash;
|
|
38
|
+
checked++;
|
|
39
|
+
}
|
|
40
|
+
// seal is null while a run is still running: nothing to compare the head against yet.
|
|
41
|
+
if (seal === null)
|
|
42
|
+
return { ok: true, status: "consistent_unsealed", checkedCount: checked };
|
|
43
|
+
if (seal !== prev) {
|
|
44
|
+
const lastSeq = ordered.length ? ordered[ordered.length - 1].seq : 0;
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
status: "broken",
|
|
48
|
+
brokenSeq: lastSeq,
|
|
49
|
+
reason: "seal does not match chain head",
|
|
50
|
+
checkedCount: checked,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, status: "verified", checkedCount: checked };
|
|
54
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Named, frozen algorithm versions. A version string never changes meaning: any change to what is
|
|
3
|
+
// hashed, or how, gets a NEW identifier and the old one keeps verifying historical records.
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.SUPPORTED_PACKET_SCHEMAS = exports.CONTENT_HASH_LEGACY = exports.CONTENT_HASH = exports.RECEIPT = exports.MANIFEST = exports.CHAIN = exports.CANONICALIZATION = void 0;
|
|
6
|
+
/** canonicalize(): sorted object keys, undefined → null, JSON.stringify for leaves (see canonicalize.ts). */
|
|
7
|
+
exports.CANONICALIZATION = "aer-canonical/1";
|
|
8
|
+
/** hashEvent()/genesisHash(): sha256(prevHash + "\n" + canonical(coreView)), genesis "AER-CHAIN-v1:" + runId. */
|
|
9
|
+
exports.CHAIN = "aer-chain/1";
|
|
10
|
+
/** buildManifest(): run context + per-event out-of-chain fields, frozen at seal. */
|
|
11
|
+
exports.MANIFEST = "aer-manifest/1";
|
|
12
|
+
/** Receipt statement + optional Ed25519 signature over its canonical bytes. */
|
|
13
|
+
exports.RECEIPT = "aer-receipt/1";
|
|
14
|
+
/** Packet content_hash for schema >= 2.0: sha256(canonical(packet without export.content_hash)). */
|
|
15
|
+
exports.CONTENT_HASH = "aer-canonical/1+sha256";
|
|
16
|
+
/**
|
|
17
|
+
* Packet content_hash for schema 1.x: sha256(JSON.stringify(packet)) in the writer's key order.
|
|
18
|
+
* Only reproducible while that order survives (it does not through JSONB storage).
|
|
19
|
+
*/
|
|
20
|
+
exports.CONTENT_HASH_LEGACY = "emission-order-json+sha256";
|
|
21
|
+
exports.SUPPORTED_PACKET_SCHEMAS = ["1.1", "1.2", "1.3", "2.0"];
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ktlsr/aer-verify",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Offline verifier for Agent Evidence Recorder audit packets: chain, manifest, content hash, seal, receipt and signature — no network, no AER account.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agent-evidence-recorder",
|
|
7
|
+
"aer",
|
|
8
|
+
"audit",
|
|
9
|
+
"evidence",
|
|
10
|
+
"verify",
|
|
11
|
+
"verification",
|
|
12
|
+
"ai-agents",
|
|
13
|
+
"ed25519",
|
|
14
|
+
"receipt",
|
|
15
|
+
"hash-chain",
|
|
16
|
+
"tamper-evident",
|
|
17
|
+
"cli"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "ktlesr",
|
|
21
|
+
"type": "commonjs",
|
|
22
|
+
"bin": {
|
|
23
|
+
"aer-verify": "dist/aer-verify/src/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
36
|
+
"test": "tsc -p tsconfig.json && node --test test/cli.test.js test/review-stage4.test.js",
|
|
37
|
+
"prepublishOnly": "tsc -p tsconfig.json && node --test test/cli.test.js test/review-stage4.test.js"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/ktlesr/aer.git",
|
|
45
|
+
"directory": "packages/aer-verify"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://aer.ktlsr.com",
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^20.19.43",
|
|
50
|
+
"typescript": "^5"
|
|
51
|
+
}
|
|
52
|
+
}
|