verifyhash 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/ANCHORING.md +43 -22
- package/package.json +3 -2
- package/verifier/README.md +29 -0
- package/verifier/dist/BUILD-PROVENANCE.json +6 -6
- package/verifier/dist/verify-vh-standalone.js +1005 -3
- package/verifier/dist/verify-vh-standalone.js.sha256 +1 -1
- package/verifier/package.json +1 -1
- package/verifier/verify-vh.js +1002 -0
package/verifier/verify-vh.js
CHANGED
|
@@ -39,6 +39,10 @@
|
|
|
39
39
|
const fs = require("fs");
|
|
40
40
|
const os = require("os");
|
|
41
41
|
const path = require("path");
|
|
42
|
+
// Node CORE sha256 (no npm dependency — the same zero-install class as fs/path; the bundle already
|
|
43
|
+
// allows `crypto` for its embedded --self-attest). Used ONLY by the T-70.4 anchored-receipt section
|
|
44
|
+
// below (the dataset/parcel attestation digest legs), which lives OUTSIDE the pure engine block.
|
|
45
|
+
const nodeCrypto = require("crypto");
|
|
42
46
|
|
|
43
47
|
const merkle = require("./lib/merkle");
|
|
44
48
|
const canonical = require("./lib/canonical");
|
|
@@ -1335,6 +1339,934 @@ function verifyArtifactFromBytes(params) {
|
|
|
1335
1339
|
|
|
1336
1340
|
// ============================= END VERIFY-VH PURE ENGINE (T-66.1) =============================
|
|
1337
1341
|
|
|
1342
|
+
// ===================================================================================================
|
|
1343
|
+
// ANCHORED-RECEIPT OFFLINE BINDING VERIFY (T-70.4) — `verify-vh <receipt> --anchored-artifact <seal>`.
|
|
1344
|
+
//
|
|
1345
|
+
// WHY THIS EXISTS
|
|
1346
|
+
// `vh anchor-artifact` (EPIC-70) emits a canonical `vh-anchored-receipt@1` container binding ONE
|
|
1347
|
+
// sealed artifact's digest to an on-chain registry record. Its OFFLINE binding leg is pure hashing —
|
|
1348
|
+
// but until T-70.4 it ran ONLY through the producer `cli/` stack (which loads `ethers` at module
|
|
1349
|
+
// load), so the family's zero-install "verify without the producer's stack" promise did not reach
|
|
1350
|
+
// the receipt. This section closes that gap: it is an INDEPENDENT, dependency-free port of the
|
|
1351
|
+
// producer core `cli/core/anchor-binding.js` — the receipt container validation, the CLOSED
|
|
1352
|
+
// six-kind digest table, and the binding verdict — written entirely against the verifier's OWN
|
|
1353
|
+
// primitives (lib/merkle keccak, lib/canonical, Node-core sha256). NO `ethers`, NO `cli/` import.
|
|
1354
|
+
//
|
|
1355
|
+
// WHAT IT CHECKS (and what it does NOT)
|
|
1356
|
+
// OFFLINE binding leg ONLY: the receipt is validated STRICTLY (unknown/missing fields, a drifted
|
|
1357
|
+
// trust note, malformed chain facts — each a named `bad-receipt`), the artifact's ONE canonical
|
|
1358
|
+
// digest is RECOMPUTED through the SAME closed kind table the producer uses (each leg re-validating
|
|
1359
|
+
// the artifact through a strict port of its shipped validator first), and the full
|
|
1360
|
+
// { kind, digest, how } triple must match — `kind-mismatch` / `digest-mismatch` / `how-mismatch`
|
|
1361
|
+
// are the specific named rejects, exactly the producer's verdict vocabulary. The receipt's `chain`
|
|
1362
|
+
// facts remain the ANCHORER'S CLAIM: re-checking them against the chain needs a chain endpoint by
|
|
1363
|
+
// definition and stays with the producer cli (`vh verify-anchored --rpc --contract`).
|
|
1364
|
+
//
|
|
1365
|
+
// PARITY DISCIPLINE (pinned by test/verifier.standalone.test.js)
|
|
1366
|
+
// Every wire-format constant here (the receipt kind, the verbatim ANCHOR_TRUST_NOTE, the reason
|
|
1367
|
+
// codes, the closed kind list, the per-kind derivation-rule `how` strings) MUST equal the producer
|
|
1368
|
+
// core's byte-for-byte, and the verdicts on identical inputs MUST match the producer's — the test
|
|
1369
|
+
// asserts both mechanically, so neither side can drift alone. TOTAL: hostile input yields a named
|
|
1370
|
+
// { ok:false, reason, field?, detail? }, never a throw.
|
|
1371
|
+
// ===================================================================================================
|
|
1372
|
+
|
|
1373
|
+
// The container kind + the standing trust note, VERBATIM the producer's (cli/core/anchor-binding.js).
|
|
1374
|
+
const ANCHORED_RECEIPT_KIND = "vh-anchored-receipt@1";
|
|
1375
|
+
|
|
1376
|
+
const ANCHOR_TRUST_NOTE =
|
|
1377
|
+
"This anchored receipt binds the artifact digest above to an on-chain registry record. A receipt " +
|
|
1378
|
+
"from a LOCAL dev chain proves MECHANISM only and is worth NOTHING publicly until a human deploys " +
|
|
1379
|
+
"the registry (STRATEGY.md P-2). On a public chain it proves ONLY that an on-chain record binds " +
|
|
1380
|
+
"this exact digest at a block whose timestamp BOUNDS existence — as trustworthy as the chain + " +
|
|
1381
|
+
"YOUR pinned contract address — NOT the artifact's truth, NOT faithful recording, NOT attribution " +
|
|
1382
|
+
"beyond the anchoring key. The `chain` facts in this receipt are the anchorer's claim until " +
|
|
1383
|
+
"re-checked against the chain (`vh verify-anchored --rpc`).";
|
|
1384
|
+
|
|
1385
|
+
// The stable, named reason codes — the producer's verdict contract, byte-for-byte.
|
|
1386
|
+
const ANCHOR_REASONS = Object.freeze({
|
|
1387
|
+
NOT_AN_OBJECT: "not-an-object",
|
|
1388
|
+
UNKNOWN_KIND: "unknown-kind",
|
|
1389
|
+
EVIDENCE_SEAL_INVALID: "evidence-seal-invalid",
|
|
1390
|
+
AGENT_PACKET_INVALID: "agent-packet-invalid",
|
|
1391
|
+
JOURNAL_TREE_HEAD_INVALID: "journal-tree-head-invalid",
|
|
1392
|
+
TRUSTLEDGER_SEAL_INVALID: "trustledger-seal-invalid",
|
|
1393
|
+
DATASET_ATTESTATION_INVALID: "dataset-attestation-invalid",
|
|
1394
|
+
PARCEL_ATTESTATION_INVALID: "parcel-attestation-invalid",
|
|
1395
|
+
BAD_ARGS: "bad-args",
|
|
1396
|
+
BAD_DIGEST: "bad-digest",
|
|
1397
|
+
BAD_HOW: "bad-how",
|
|
1398
|
+
BAD_LABEL: "bad-label",
|
|
1399
|
+
BAD_CHAIN: "bad-chain",
|
|
1400
|
+
BAD_RECEIPT: "bad-receipt",
|
|
1401
|
+
DIGEST_MISMATCH: "digest-mismatch",
|
|
1402
|
+
KIND_MISMATCH: "kind-mismatch",
|
|
1403
|
+
HOW_MISMATCH: "how-mismatch",
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
// The two closed-table kinds this verifier did not already name (the other four reuse KINDS above).
|
|
1407
|
+
const ANCHOR_JOURNAL_TREE_HEAD_KIND = "vh.journal-tree-head";
|
|
1408
|
+
const ANCHOR_PARCEL_ATTESTATION_KIND = "verifyhash.parcel-attestation";
|
|
1409
|
+
|
|
1410
|
+
// The CLOSED, frozen kind table — same six kinds, same order as the producer core.
|
|
1411
|
+
const ANCHOR_ARTIFACT_KINDS = Object.freeze([
|
|
1412
|
+
KINDS.EVIDENCE_SEAL, // "vh.evidence-seal"
|
|
1413
|
+
KINDS.AGENT_PACKET, // "vh.agent-session-packet"
|
|
1414
|
+
ANCHOR_JOURNAL_TREE_HEAD_KIND, // "vh.journal-tree-head"
|
|
1415
|
+
KINDS.TRUST_SEAL, // "trustledger.reconcile-seal"
|
|
1416
|
+
KINDS.DATASET_ATTESTATION, // "verifyhash.dataset-attestation"
|
|
1417
|
+
ANCHOR_PARCEL_ATTESTATION_KIND, // "verifyhash.parcel-attestation"
|
|
1418
|
+
]);
|
|
1419
|
+
|
|
1420
|
+
// Canonical-case wire shapes (the receipt is canonical LOWERCASE; artifacts may carry mixed-case hex
|
|
1421
|
+
// exactly where the producer validators accept it).
|
|
1422
|
+
const ANCHOR_HEX32_LC_RE = /^0x[0-9a-f]{64}$/;
|
|
1423
|
+
const ANCHOR_ADDRESS_LC_RE = /^0x[0-9a-f]{40}$/;
|
|
1424
|
+
const ANCHOR_CONTROL_CHAR_RE = /[\u0000-\u001f\u007f]/;
|
|
1425
|
+
const ANCHOR_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
1426
|
+
|
|
1427
|
+
function anchorIsPlainObject(v) {
|
|
1428
|
+
return v != null && typeof v === "object" && !Array.isArray(v);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// The per-kind derivation rules (`how`) — VERBATIM the producer's HOW_FIXED table. These are WIRE
|
|
1432
|
+
// FORMAT (bound into every receipt), so they name the producer's files even though THIS verifier
|
|
1433
|
+
// re-derives the digest with its own independent code: the rule describes the derivation, and the
|
|
1434
|
+
// parity test pins these strings against the producer core byte-for-byte.
|
|
1435
|
+
const ANCHOR_HOW_FIXED = Object.freeze({
|
|
1436
|
+
[KINDS.EVIDENCE_SEAL]:
|
|
1437
|
+
"digest = the evidence packet's `root` (sorted-pair Merkle root over its path-bound file leaves), " +
|
|
1438
|
+
"re-derived by cli/evidence.js readSeal before extraction",
|
|
1439
|
+
[KINDS.AGENT_PACKET]:
|
|
1440
|
+
"digest = the agent-session packet's verified head `root` (RFC-6962 ordered Merkle root over the " +
|
|
1441
|
+
"event leaves), re-derived by cli/agent.js verifyPacket before extraction",
|
|
1442
|
+
[KINDS.TRUST_SEAL]:
|
|
1443
|
+
"digest = the TrustLedger sealfile's `root` (Merkle root over its committed input/output leaves + " +
|
|
1444
|
+
"verdict header), re-derived by trustledger/seal.js readSeal before extraction",
|
|
1445
|
+
[KINDS.DATASET_ATTESTATION]:
|
|
1446
|
+
"digest = 0x + sha256 over the canonical UNSIGNED dataset-attestation bytes, exactly as " +
|
|
1447
|
+
"`vh dataset timestamp-request` computes it (cli/core/timestamp.js sha256Hex)",
|
|
1448
|
+
[ANCHOR_PARCEL_ATTESTATION_KIND]:
|
|
1449
|
+
"digest = 0x + sha256 over the canonical UNSIGNED parcel-attestation bytes, exactly as " +
|
|
1450
|
+
"`vh parcel timestamp-request` computes it (cli/core/timestamp.js sha256Hex)",
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
function anchorJournalHow(size) {
|
|
1454
|
+
return (
|
|
1455
|
+
`digest = the journal tree head \`root\` (RFC-6962 ordered Merkle root, cli/journal-log.js ` +
|
|
1456
|
+
`treeHead) over ${size} entries; the head size is bound into this derivation rule`
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const ANCHOR_JOURNAL_HOW_RE =
|
|
1461
|
+
/^digest = the journal tree head `root` \(RFC-6962 ordered Merkle root, cli\/journal-log\.js treeHead\) over (0|[1-9][0-9]*) entries; the head size is bound into this derivation rule$/;
|
|
1462
|
+
|
|
1463
|
+
function anchorHowValidFor(kind, how) {
|
|
1464
|
+
if (typeof how !== "string") return false;
|
|
1465
|
+
if (kind === ANCHOR_JOURNAL_TREE_HEAD_KIND) {
|
|
1466
|
+
const m = ANCHOR_JOURNAL_HOW_RE.exec(how);
|
|
1467
|
+
return m !== null && Number.isSafeInteger(Number(m[1]));
|
|
1468
|
+
}
|
|
1469
|
+
return how === ANCHOR_HOW_FIXED[kind];
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function anchorOk(digest, kind, how) {
|
|
1473
|
+
return { ok: true, digest, kind, how };
|
|
1474
|
+
}
|
|
1475
|
+
function anchorNo(reason, detail) {
|
|
1476
|
+
return detail === undefined ? { ok: false, reason } : { ok: false, reason, detail };
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// ---------------------------------------------------------------------------------------------------
|
|
1480
|
+
// The per-kind STRICT validators + digest extraction — independent ports of the artifacts' shipped
|
|
1481
|
+
// validators (the messages mirror the producers' so the named verdict a counterparty reads is the
|
|
1482
|
+
// same either way). Each leg is TOTAL: a defect is a named reject, never a throw out of this section.
|
|
1483
|
+
// ---------------------------------------------------------------------------------------------------
|
|
1484
|
+
|
|
1485
|
+
// vh.evidence-seal — a strict port of cli/core/packetseal.js validateSeal under the evidence config
|
|
1486
|
+
// (kind/schemaVersion/note pinned, per-entry leaf self-consistency, NO header, and the LOAD-BEARING
|
|
1487
|
+
// root re-derivation from the seal's OWN (relPath, contentHash) leaves via the verifier's merkle lib).
|
|
1488
|
+
const ANCHOR_EVIDENCE_TRUST_NOTE =
|
|
1489
|
+
"This evidence seal is TAMPER-EVIDENT + OFFLINE-RECOMPUTABLE, NOT a trusted timestamp. Its Merkle " +
|
|
1490
|
+
"`root` commits to the full set of (relPath, content) pairs in the directory: any edit, rename, add, " +
|
|
1491
|
+
"or remove changes the root, and verify RE-DERIVES the root from the bytes you hold and LOCALIZES the " +
|
|
1492
|
+
"change to the exact file (MATCH / CHANGED / MISSING / UNEXPECTED). It does NOT prove WHEN the sealing " +
|
|
1493
|
+
'happened ("sealed at T" rides the human-owned signing/timestamp trust-root, STRATEGY.md P-3) and it ' +
|
|
1494
|
+
"is NOT a legal opinion. The packet is an UNTRUSTED transport container: verify never trusts the " +
|
|
1495
|
+
"packet's own stored hashes.";
|
|
1496
|
+
const ANCHOR_EVIDENCE_SCHEMA_VERSIONS = Object.freeze([1]);
|
|
1497
|
+
|
|
1498
|
+
// Shared strict per-entry + root checks for the two packetseal-family legs. `label` carries the
|
|
1499
|
+
// product wording; `headerLeaf` (when non-null) is folded into the root as the reserved header entry.
|
|
1500
|
+
function anchorCheckSealEntries(entries, label, where, seenRelPath, flat, headerRelPath) {
|
|
1501
|
+
entries.forEach((entry, i) => {
|
|
1502
|
+
if (!anchorIsPlainObject(entry)) {
|
|
1503
|
+
throw new Error(`${label} ${where}[${i}] must be an object`);
|
|
1504
|
+
}
|
|
1505
|
+
if (typeof entry.relPath !== "string" || entry.relPath.length === 0) {
|
|
1506
|
+
throw new Error(`${label} ${where}[${i}].relPath must be a non-empty string`);
|
|
1507
|
+
}
|
|
1508
|
+
if (headerRelPath !== null && entry.relPath === headerRelPath) {
|
|
1509
|
+
throw new Error(
|
|
1510
|
+
`${label} ${where}[${i}].relPath ${JSON.stringify(entry.relPath)} is reserved for the seal header`
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
if (seenRelPath.has(entry.relPath)) {
|
|
1514
|
+
throw new Error(`${label} has a duplicate relPath across the file set: ${JSON.stringify(entry.relPath)}`);
|
|
1515
|
+
}
|
|
1516
|
+
seenRelPath.add(entry.relPath);
|
|
1517
|
+
for (const f of ["contentHash", "leaf"]) {
|
|
1518
|
+
if (typeof entry[f] !== "string" || !merkle.HEX32_RE.test(entry[f])) {
|
|
1519
|
+
throw new Error(
|
|
1520
|
+
`${label} ${where}[${i}].${f} must be a 0x-prefixed 32-byte hex string, got: ${String(entry[f])}`
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
const expectedLeaf = merkle.pathLeaf(entry.relPath, entry.contentHash);
|
|
1525
|
+
if (entry.leaf.toLowerCase() !== expectedLeaf.toLowerCase()) {
|
|
1526
|
+
throw new Error(
|
|
1527
|
+
`${label} ${where}[${i}].leaf is inconsistent with its relPath+contentHash ` +
|
|
1528
|
+
`(expected ${expectedLeaf}, got ${entry.leaf})`
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
flat.push({ relPath: entry.relPath, contentHash: entry.contentHash });
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function anchorValidateEvidenceSeal(obj) {
|
|
1536
|
+
const label = "evidence seal";
|
|
1537
|
+
if (!anchorIsPlainObject(obj)) throw new Error(`${label} must be a JSON object`);
|
|
1538
|
+
if (obj.kind !== KINDS.EVIDENCE_SEAL) {
|
|
1539
|
+
throw new Error(`not a ${label} (kind: ${JSON.stringify(obj.kind)}; expected ${JSON.stringify(KINDS.EVIDENCE_SEAL)})`);
|
|
1540
|
+
}
|
|
1541
|
+
if (!ANCHOR_EVIDENCE_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
|
|
1542
|
+
throw new Error(
|
|
1543
|
+
`unsupported ${label} schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
|
|
1544
|
+
`(this build understands ${JSON.stringify(ANCHOR_EVIDENCE_SCHEMA_VERSIONS)})`
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
if (obj.note !== ANCHOR_EVIDENCE_TRUST_NOTE) {
|
|
1548
|
+
throw new Error(`${label} \`note\` must be the standing trust note (caveat must not drift)`);
|
|
1549
|
+
}
|
|
1550
|
+
if (typeof obj.root !== "string" || !merkle.HEX32_RE.test(obj.root)) {
|
|
1551
|
+
throw new Error(`${label} root must be a 0x-prefixed 32-byte hex string, got: ${String(obj.root)}`);
|
|
1552
|
+
}
|
|
1553
|
+
if (!Array.isArray(obj.files) || obj.files.length === 0) {
|
|
1554
|
+
throw new Error(`${label} \`files\` must be a non-empty array`);
|
|
1555
|
+
}
|
|
1556
|
+
const flat = [];
|
|
1557
|
+
anchorCheckSealEntries(obj.files, label, "files", new Set(), flat, null);
|
|
1558
|
+
if (obj.fileCount !== undefined && obj.fileCount !== obj.files.length) {
|
|
1559
|
+
throw new Error(`${label} fileCount (${String(obj.fileCount)}) does not match the files length (${obj.files.length})`);
|
|
1560
|
+
}
|
|
1561
|
+
if (obj.header !== undefined) {
|
|
1562
|
+
throw new Error(`${label} carries a header but its config declares none`);
|
|
1563
|
+
}
|
|
1564
|
+
const rederived = merkle.rootFromFlat(flat);
|
|
1565
|
+
if (rederived.toLowerCase() !== obj.root.toLowerCase()) {
|
|
1566
|
+
throw new Error(
|
|
1567
|
+
`${label} root does not re-derive from its listed entries ` +
|
|
1568
|
+
`(expected ${rederived}, got ${obj.root}) — the seal is internally inconsistent ` +
|
|
1569
|
+
"(a file was edited without updating the root)"
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
return obj;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
function anchorEvidenceDigest(artifact) {
|
|
1576
|
+
try {
|
|
1577
|
+
anchorValidateEvidenceSeal(artifact);
|
|
1578
|
+
} catch (e) {
|
|
1579
|
+
return anchorNo(ANCHOR_REASONS.EVIDENCE_SEAL_INVALID, e && e.message ? e.message : String(e));
|
|
1580
|
+
}
|
|
1581
|
+
return anchorOk(artifact.root.toLowerCase(), KINDS.EVIDENCE_SEAL, ANCHOR_HOW_FIXED[KINDS.EVIDENCE_SEAL]);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// vh.agent-session-packet — REUSES this verifier's OWN independent agent engine verbatim: the strict
|
|
1585
|
+
// packet-structure validation + the authoritative per-event/leaf/root/counts recompute, PLUS (when a
|
|
1586
|
+
// headAttestation is present) the head-binding and signature-genuineness checks — the exact facts the
|
|
1587
|
+
// producer's `agent.verifyPacket` gates the digest on (a vendor pin is not part of digest extraction).
|
|
1588
|
+
function anchorAgentDigest(artifact) {
|
|
1589
|
+
let structure;
|
|
1590
|
+
try {
|
|
1591
|
+
structure = validateAgentPacketStructure(artifact);
|
|
1592
|
+
} catch (e) {
|
|
1593
|
+
return anchorNo(ANCHOR_REASONS.AGENT_PACKET_INVALID, e && e.message ? e.message : String(e));
|
|
1594
|
+
}
|
|
1595
|
+
const fileResult = verifyAgentSeal(artifact);
|
|
1596
|
+
const agent = fileResult.agent;
|
|
1597
|
+
const seqOf = () => (agent.seq !== null && agent.seq !== undefined ? ` at seq ${agent.seq}` : "");
|
|
1598
|
+
if (!fileResult.filesOk) {
|
|
1599
|
+
const reason = agent.reason || fileResult.reasonKind || "REJECTED";
|
|
1600
|
+
return anchorNo(ANCHOR_REASONS.AGENT_PACKET_INVALID, `packet verify REJECTED: ${reason}${seqOf()}`);
|
|
1601
|
+
}
|
|
1602
|
+
if (artifact.headAttestation !== undefined) {
|
|
1603
|
+
const embedded = structure.signedHead.embeddedHead;
|
|
1604
|
+
const bound =
|
|
1605
|
+
embedded.size === agent.recomputedHead.size && embedded.root === agent.recomputedHead.root;
|
|
1606
|
+
if (!bound) {
|
|
1607
|
+
return anchorNo(ANCHOR_REASONS.AGENT_PACKET_INVALID, "packet verify REJECTED: HEAD_NOT_BOUND");
|
|
1608
|
+
}
|
|
1609
|
+
const claimed = artifact.headAttestation.signature.signer; // lowercase, structurally enforced
|
|
1610
|
+
const recovered = tryRecover(artifact.headAttestation.attestation, artifact.headAttestation.signature.signature);
|
|
1611
|
+
if (recovered == null || recovered !== claimed) {
|
|
1612
|
+
return anchorNo(ANCHOR_REASONS.AGENT_PACKET_INVALID, "packet verify REJECTED: SIGNATURE_FORGED");
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
return anchorOk(fileResult.recomputedRoot, KINDS.AGENT_PACKET, ANCHOR_HOW_FIXED[KINDS.AGENT_PACKET]);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// vh.journal-tree-head — the bare { size, root } commitment or its kind-tagged twin. The empty-root
|
|
1619
|
+
// constant is re-derived HERE from the family's domain string with the verifier's own keccak (equal
|
|
1620
|
+
// to cli/journal-log.js EMPTY_ROOT — pinned by the parity test).
|
|
1621
|
+
const ANCHOR_JOURNAL_EMPTY_ROOT = merkle.hashBytes(Buffer.from(AGENT_EMPTY_ROOT_DOMAIN, "utf8"));
|
|
1622
|
+
|
|
1623
|
+
function anchorJournalHeadDigest(artifact, tagged) {
|
|
1624
|
+
const allowed = tagged ? ["kind", "size", "root"] : ["size", "root"];
|
|
1625
|
+
for (const k of Object.keys(artifact)) {
|
|
1626
|
+
if (!allowed.includes(k)) {
|
|
1627
|
+
return anchorNo(
|
|
1628
|
+
ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
|
|
1629
|
+
`journal tree head has unknown field: ${JSON.stringify(k)}`
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
if (!Number.isSafeInteger(artifact.size) || artifact.size < 0) {
|
|
1634
|
+
return anchorNo(
|
|
1635
|
+
ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
|
|
1636
|
+
`journal tree head size must be a non-negative integer, got: ${String(artifact.size)}`
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
if (typeof artifact.root !== "string" || !ANCHOR_HEX32_LC_RE.test(artifact.root)) {
|
|
1640
|
+
return anchorNo(
|
|
1641
|
+
ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
|
|
1642
|
+
`journal tree head root must be a LOWERCASE 0x-bytes32 hex string, got: ${String(artifact.root)}`
|
|
1643
|
+
);
|
|
1644
|
+
}
|
|
1645
|
+
if (artifact.size === 0 && artifact.root !== ANCHOR_JOURNAL_EMPTY_ROOT) {
|
|
1646
|
+
return anchorNo(
|
|
1647
|
+
ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
|
|
1648
|
+
`an EMPTY journal tree head (size 0) must carry the documented empty root ${ANCHOR_JOURNAL_EMPTY_ROOT}`
|
|
1649
|
+
);
|
|
1650
|
+
}
|
|
1651
|
+
if (artifact.size > 0 && artifact.root === ANCHOR_JOURNAL_EMPTY_ROOT) {
|
|
1652
|
+
return anchorNo(
|
|
1653
|
+
ANCHOR_REASONS.JOURNAL_TREE_HEAD_INVALID,
|
|
1654
|
+
"a non-empty journal tree head cannot carry the domain-separated EMPTY root"
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
return anchorOk(artifact.root, ANCHOR_JOURNAL_TREE_HEAD_KIND, anchorJournalHow(artifact.size));
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// trustledger.reconcile-seal — a strict port of trustledger/seal.js validateSeal: the verdict/role/
|
|
1661
|
+
// inputs/outputs checks, per-entry leaf self-consistency, and the LOAD-BEARING root re-derivation from
|
|
1662
|
+
// the seal's OWN leaves PLUS the synthetic verdict/role HEADER leaf (content re-derived from the
|
|
1663
|
+
// seal's recorded verdict + input role bindings via the verifier's own lib/canonical port).
|
|
1664
|
+
const ANCHOR_TRUST_SEAL_NOTE =
|
|
1665
|
+
"This reconciliation seal is TAMPER-EVIDENT, not a trusted timestamp and not a legal opinion. Its " +
|
|
1666
|
+
"Merkle `root` commits to the full set of (relPath, content) pairs across the source inputs AND " +
|
|
1667
|
+
"every emitted packet file, PLUS a reserved HEADER leaf binding the recorded verdict " +
|
|
1668
|
+
"(pass/reportDate/period) and each input's logical role: any edit, rename, add, or remove of a " +
|
|
1669
|
+
"file — or any edit of the verdict/date/period or swap of an input role — changes the root, and " +
|
|
1670
|
+
"verifySeal localizes a file change to the exact file and a verdict/role change to the header. It " +
|
|
1671
|
+
"does NOT prove WHEN the sealing actually happened (the bound reportDate cannot be edited " +
|
|
1672
|
+
"undetected, but a self-asserted date still rides the human trust-root P-3 — standing up a real " +
|
|
1673
|
+
"signing key or timestamp anchor is needs-human) and it does NOT validate the legal MEANING of " +
|
|
1674
|
+
"the reconciliation (the CPA review still governs). The seal is an UNTRUSTED transport container: " +
|
|
1675
|
+
"verifySeal RE-DERIVES the root from the bytes you supply — it never trusts the seal's own hashes.";
|
|
1676
|
+
const ANCHOR_TRUST_SEAL_SCHEMA_VERSIONS = Object.freeze([1]);
|
|
1677
|
+
const ANCHOR_TRUST_SEAL_INPUT_ROLES = Object.freeze(["bank", "book", "rentroll"]);
|
|
1678
|
+
const ANCHOR_TRUST_SEAL_CORE_LABEL = "trustledger reconciliation seal";
|
|
1679
|
+
|
|
1680
|
+
function anchorValidateTrustSeal(obj) {
|
|
1681
|
+
if (!anchorIsPlainObject(obj)) throw new Error("seal must be a JSON object");
|
|
1682
|
+
if (obj.kind !== KINDS.TRUST_SEAL) {
|
|
1683
|
+
throw new Error(
|
|
1684
|
+
`not a trustledger reconciliation seal (kind: ${JSON.stringify(obj.kind)}; expected ` +
|
|
1685
|
+
`${JSON.stringify(KINDS.TRUST_SEAL)})`
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
if (!ANCHOR_TRUST_SEAL_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
|
|
1689
|
+
throw new Error(
|
|
1690
|
+
`unsupported seal schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
|
|
1691
|
+
`(this build understands ${JSON.stringify(ANCHOR_TRUST_SEAL_SCHEMA_VERSIONS)})`
|
|
1692
|
+
);
|
|
1693
|
+
}
|
|
1694
|
+
if (obj.note !== ANCHOR_TRUST_SEAL_NOTE) {
|
|
1695
|
+
throw new Error("seal `note` must be the standing SEAL_TRUST_NOTE (caveat must not drift)");
|
|
1696
|
+
}
|
|
1697
|
+
if (typeof obj.root !== "string" || !merkle.HEX32_RE.test(obj.root)) {
|
|
1698
|
+
throw new Error(`seal root must be a 0x-prefixed 32-byte hex string, got: ${String(obj.root)}`);
|
|
1699
|
+
}
|
|
1700
|
+
if (!anchorIsPlainObject(obj.verdict)) {
|
|
1701
|
+
throw new Error("seal is missing `verdict` { pass, reportDate }");
|
|
1702
|
+
}
|
|
1703
|
+
if (typeof obj.verdict.pass !== "boolean") {
|
|
1704
|
+
throw new Error("seal verdict.pass must be a boolean");
|
|
1705
|
+
}
|
|
1706
|
+
if (!ANCHOR_DATE_RE.test(String(obj.verdict.reportDate || ""))) {
|
|
1707
|
+
throw new Error('seal verdict.reportDate must be a "YYYY-MM-DD" string');
|
|
1708
|
+
}
|
|
1709
|
+
if (!("period" in obj.verdict)) {
|
|
1710
|
+
throw new Error("seal verdict is missing `period` (may be null)");
|
|
1711
|
+
}
|
|
1712
|
+
if (obj.verdict.period !== null && typeof obj.verdict.period !== "string") {
|
|
1713
|
+
throw new Error("seal verdict.period must be a string or null");
|
|
1714
|
+
}
|
|
1715
|
+
if (!Array.isArray(obj.inputs) || obj.inputs.length === 0) {
|
|
1716
|
+
throw new Error("seal `inputs` must be a non-empty array");
|
|
1717
|
+
}
|
|
1718
|
+
if (!Array.isArray(obj.outputs) || obj.outputs.length === 0) {
|
|
1719
|
+
throw new Error("seal `outputs` must be a non-empty array");
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
const seenRelPath = new Set();
|
|
1723
|
+
const seenRole = new Set();
|
|
1724
|
+
const flat = [];
|
|
1725
|
+
// Per-entry checks use the trustledger wording (`seal inputs[0]...`); the reserved-header check uses
|
|
1726
|
+
// the core-config label, exactly as the producer's core-delegated view reports it.
|
|
1727
|
+
const checkEntries = (entries, where) => {
|
|
1728
|
+
entries.forEach((entry, i) => {
|
|
1729
|
+
if (!anchorIsPlainObject(entry)) throw new Error(`seal ${where}[${i}] must be an object`);
|
|
1730
|
+
if (typeof entry.relPath !== "string" || entry.relPath.length === 0) {
|
|
1731
|
+
throw new Error(`seal ${where}[${i}].relPath must be a non-empty string`);
|
|
1732
|
+
}
|
|
1733
|
+
if (entry.relPath === canonical.TRUST_SEAL_HEADER_RELPATH) {
|
|
1734
|
+
throw new Error(
|
|
1735
|
+
`${ANCHOR_TRUST_SEAL_CORE_LABEL} files[${flat.length}].relPath ` +
|
|
1736
|
+
`${JSON.stringify(entry.relPath)} is reserved for the seal header`
|
|
1737
|
+
);
|
|
1738
|
+
}
|
|
1739
|
+
if (seenRelPath.has(entry.relPath)) {
|
|
1740
|
+
throw new Error(`seal has a duplicate relPath across the file set: ${JSON.stringify(entry.relPath)}`);
|
|
1741
|
+
}
|
|
1742
|
+
seenRelPath.add(entry.relPath);
|
|
1743
|
+
for (const f of ["contentHash", "leaf"]) {
|
|
1744
|
+
if (typeof entry[f] !== "string" || !merkle.HEX32_RE.test(entry[f])) {
|
|
1745
|
+
throw new Error(
|
|
1746
|
+
`seal ${where}[${i}].${f} must be a 0x-prefixed 32-byte hex string, got: ${String(entry[f])}`
|
|
1747
|
+
);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
const expectedLeaf = merkle.pathLeaf(entry.relPath, entry.contentHash);
|
|
1751
|
+
if (entry.leaf.toLowerCase() !== expectedLeaf.toLowerCase()) {
|
|
1752
|
+
throw new Error(
|
|
1753
|
+
`seal ${where}[${i}].leaf is inconsistent with its relPath+contentHash ` +
|
|
1754
|
+
`(expected ${expectedLeaf}, got ${entry.leaf})`
|
|
1755
|
+
);
|
|
1756
|
+
}
|
|
1757
|
+
flat.push({ relPath: entry.relPath, contentHash: entry.contentHash });
|
|
1758
|
+
});
|
|
1759
|
+
};
|
|
1760
|
+
checkEntries(obj.inputs, "inputs");
|
|
1761
|
+
obj.inputs.forEach((entry, i) => {
|
|
1762
|
+
if (!ANCHOR_TRUST_SEAL_INPUT_ROLES.includes(entry.role)) {
|
|
1763
|
+
throw new Error(
|
|
1764
|
+
`seal inputs[${i}].role must be one of ${JSON.stringify(ANCHOR_TRUST_SEAL_INPUT_ROLES)}, got: ` +
|
|
1765
|
+
`${JSON.stringify(entry.role)}`
|
|
1766
|
+
);
|
|
1767
|
+
}
|
|
1768
|
+
if (seenRole.has(entry.role)) {
|
|
1769
|
+
throw new Error(`seal has a duplicate input role: ${JSON.stringify(entry.role)}`);
|
|
1770
|
+
}
|
|
1771
|
+
seenRole.add(entry.role);
|
|
1772
|
+
});
|
|
1773
|
+
checkEntries(obj.outputs, "outputs");
|
|
1774
|
+
obj.outputs.forEach((entry, i) => {
|
|
1775
|
+
if (entry.role !== undefined && entry.role !== null) {
|
|
1776
|
+
throw new Error(
|
|
1777
|
+
`seal outputs[${i}] must not carry a role (roles partition INPUTS only), got: ` +
|
|
1778
|
+
`${JSON.stringify(entry.role)}`
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
});
|
|
1782
|
+
const total = obj.inputs.length + obj.outputs.length;
|
|
1783
|
+
if (obj.fileCount !== undefined && obj.fileCount !== total) {
|
|
1784
|
+
throw new Error(`seal fileCount (${String(obj.fileCount)}) does not match the entry total (${total})`);
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// THE LOAD-BEARING CHECK: re-derive the root from the listed leaves PLUS the verdict/role HEADER leaf.
|
|
1788
|
+
const headerBytes = canonical.trustSealHeaderBytes(
|
|
1789
|
+
obj.verdict,
|
|
1790
|
+
obj.inputs.map((e) => ({ role: e.role, relPath: e.relPath }))
|
|
1791
|
+
);
|
|
1792
|
+
const committed = [
|
|
1793
|
+
...flat,
|
|
1794
|
+
{ relPath: canonical.TRUST_SEAL_HEADER_RELPATH, contentHash: merkle.hashBytes(headerBytes) },
|
|
1795
|
+
];
|
|
1796
|
+
const rederived = merkle.rootFromFlat(committed);
|
|
1797
|
+
if (rederived.toLowerCase() !== obj.root.toLowerCase()) {
|
|
1798
|
+
throw new Error(
|
|
1799
|
+
"seal root does not re-derive from its listed entries + verdict/role header " +
|
|
1800
|
+
"(the seal is internally inconsistent: a file, the verdict, or an input role was edited " +
|
|
1801
|
+
"without updating the root)"
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
return obj;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
function anchorTrustledgerDigest(artifact) {
|
|
1808
|
+
try {
|
|
1809
|
+
anchorValidateTrustSeal(artifact);
|
|
1810
|
+
} catch (e) {
|
|
1811
|
+
return anchorNo(ANCHOR_REASONS.TRUSTLEDGER_SEAL_INVALID, e && e.message ? e.message : String(e));
|
|
1812
|
+
}
|
|
1813
|
+
return anchorOk(artifact.root.toLowerCase(), KINDS.TRUST_SEAL, ANCHOR_HOW_FIXED[KINDS.TRUST_SEAL]);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// verifyhash.dataset-attestation / verifyhash.parcel-attestation — strict ports of the shipped
|
|
1817
|
+
// validators (cli/dataset.js validateAttestation / cli/parcel.js validateParcelAttestation), then the
|
|
1818
|
+
// SAME canonical bytes the producers serialize (via the verifier's own lib/canonical port — the two
|
|
1819
|
+
// attestation shapes share the identical canonical key order), hashed with Node-core sha256. The
|
|
1820
|
+
// closed field set is enforced FIRST, exactly as the producer core does: an unknown key would ride
|
|
1821
|
+
// along unbound by the digest, so it is rejected rather than silently dropped.
|
|
1822
|
+
const ANCHOR_ATTESTATION_FIELDS = Object.freeze([
|
|
1823
|
+
"kind",
|
|
1824
|
+
"schemaVersion",
|
|
1825
|
+
"note",
|
|
1826
|
+
"root",
|
|
1827
|
+
"fileCount",
|
|
1828
|
+
"manifestDigest",
|
|
1829
|
+
"signed",
|
|
1830
|
+
"signature",
|
|
1831
|
+
]);
|
|
1832
|
+
const ANCHOR_ATTESTATION_SCHEMA_VERSIONS = Object.freeze([1]);
|
|
1833
|
+
|
|
1834
|
+
function anchorValidateAttestation(obj, kind, noun) {
|
|
1835
|
+
if (!anchorIsPlainObject(obj)) throw new Error(`${noun} attestation must be a JSON object`);
|
|
1836
|
+
if (obj.kind !== kind) {
|
|
1837
|
+
throw new Error(
|
|
1838
|
+
`not a verifyhash ${noun} attestation (kind: ${JSON.stringify(obj.kind)}; expected ${JSON.stringify(kind)})`
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1841
|
+
if (!ANCHOR_ATTESTATION_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
|
|
1842
|
+
throw new Error(
|
|
1843
|
+
`unsupported ${noun} attestation schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
|
|
1844
|
+
`(this build understands ${JSON.stringify(ANCHOR_ATTESTATION_SCHEMA_VERSIONS)})`
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
for (const f of ["root", "manifestDigest"]) {
|
|
1848
|
+
if (typeof obj[f] !== "string" || !merkle.HEX32_RE.test(obj[f])) {
|
|
1849
|
+
throw new Error(`${noun} attestation ${f} must be a 0x-prefixed 32-byte hex string, got: ${String(obj[f])}`);
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
if (!Number.isInteger(obj.fileCount) || obj.fileCount < 1) {
|
|
1853
|
+
throw new Error(`${noun} attestation fileCount must be a positive integer, got: ${String(obj.fileCount)}`);
|
|
1854
|
+
}
|
|
1855
|
+
if (obj.signed !== false) {
|
|
1856
|
+
throw new Error(
|
|
1857
|
+
`${noun} attestation signed must be false (this build emits/reads only the UNSIGNED payload; ` +
|
|
1858
|
+
`attaching a real signature is the human-owned trust-root, P-3), got: ${String(obj.signed)}`
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
if (obj.signature !== null) {
|
|
1862
|
+
throw new Error(`${noun} attestation signature must be null in the UNSIGNED payload, got: ${String(obj.signature)}`);
|
|
1863
|
+
}
|
|
1864
|
+
return obj;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function anchorAttestationDigest(artifact, kind, noun, reason) {
|
|
1868
|
+
for (const k of Object.keys(artifact)) {
|
|
1869
|
+
if (!ANCHOR_ATTESTATION_FIELDS.includes(k)) {
|
|
1870
|
+
return anchorNo(reason, `attestation has unknown field ${JSON.stringify(k)} (the canonical bytes would not bind it)`);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
let canonicalBytes;
|
|
1874
|
+
try {
|
|
1875
|
+
anchorValidateAttestation(artifact, kind, noun);
|
|
1876
|
+
// The verifier's own canonical serializer: the SAME fixed key order + trailing newline the
|
|
1877
|
+
// producer emits (dataset and parcel attestations share the identical canonical shape).
|
|
1878
|
+
canonicalBytes = canonical.serializeUnsignedDatasetAttestation(artifact);
|
|
1879
|
+
} catch (e) {
|
|
1880
|
+
return anchorNo(reason, e && e.message ? e.message : String(e));
|
|
1881
|
+
}
|
|
1882
|
+
const digest = "0x" + nodeCrypto.createHash("sha256").update(canonicalBytes, "utf8").digest("hex");
|
|
1883
|
+
return anchorOk(digest, kind, ANCHOR_HOW_FIXED[kind]);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
/**
|
|
1887
|
+
* Extract the ONE canonical 32-byte digest a chain record binds for `artifact` — the standalone port
|
|
1888
|
+
* of the producer core's artifactDigest, dispatching over the SAME closed kind table. TOTAL.
|
|
1889
|
+
*/
|
|
1890
|
+
function anchorArtifactDigest(artifact) {
|
|
1891
|
+
try {
|
|
1892
|
+
if (!anchorIsPlainObject(artifact)) {
|
|
1893
|
+
return anchorNo(ANCHOR_REASONS.NOT_AN_OBJECT, "artifact must be a parsed JSON object");
|
|
1894
|
+
}
|
|
1895
|
+
const kind = artifact.kind;
|
|
1896
|
+
if (kind === undefined) {
|
|
1897
|
+
if ("size" in artifact || "root" in artifact) {
|
|
1898
|
+
return anchorJournalHeadDigest(artifact, false);
|
|
1899
|
+
}
|
|
1900
|
+
return anchorNo(
|
|
1901
|
+
ANCHOR_REASONS.UNKNOWN_KIND,
|
|
1902
|
+
"artifact carries no `kind` and is not a { size, root } journal tree head"
|
|
1903
|
+
);
|
|
1904
|
+
}
|
|
1905
|
+
if (typeof kind !== "string") {
|
|
1906
|
+
return anchorNo(ANCHOR_REASONS.UNKNOWN_KIND, "artifact `kind` must be a string");
|
|
1907
|
+
}
|
|
1908
|
+
switch (kind) {
|
|
1909
|
+
case KINDS.EVIDENCE_SEAL:
|
|
1910
|
+
return anchorEvidenceDigest(artifact);
|
|
1911
|
+
case KINDS.AGENT_PACKET:
|
|
1912
|
+
return anchorAgentDigest(artifact);
|
|
1913
|
+
case ANCHOR_JOURNAL_TREE_HEAD_KIND:
|
|
1914
|
+
return anchorJournalHeadDigest(artifact, true);
|
|
1915
|
+
case KINDS.TRUST_SEAL:
|
|
1916
|
+
return anchorTrustledgerDigest(artifact);
|
|
1917
|
+
case KINDS.DATASET_ATTESTATION:
|
|
1918
|
+
return anchorAttestationDigest(
|
|
1919
|
+
artifact,
|
|
1920
|
+
KINDS.DATASET_ATTESTATION,
|
|
1921
|
+
"dataset",
|
|
1922
|
+
ANCHOR_REASONS.DATASET_ATTESTATION_INVALID
|
|
1923
|
+
);
|
|
1924
|
+
case ANCHOR_PARCEL_ATTESTATION_KIND:
|
|
1925
|
+
return anchorAttestationDigest(
|
|
1926
|
+
artifact,
|
|
1927
|
+
ANCHOR_PARCEL_ATTESTATION_KIND,
|
|
1928
|
+
"parcel",
|
|
1929
|
+
ANCHOR_REASONS.PARCEL_ATTESTATION_INVALID
|
|
1930
|
+
);
|
|
1931
|
+
default:
|
|
1932
|
+
return anchorNo(
|
|
1933
|
+
ANCHOR_REASONS.UNKNOWN_KIND,
|
|
1934
|
+
`unknown artifact kind ${JSON.stringify(kind)} (the closed table: ${ANCHOR_ARTIFACT_KINDS.join(", ")})`
|
|
1935
|
+
);
|
|
1936
|
+
}
|
|
1937
|
+
} catch (e) {
|
|
1938
|
+
return anchorNo(ANCHOR_REASONS.NOT_AN_OBJECT, e && e.message ? e.message : String(e));
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// ---------------------------------------------------------------------------------------------------
|
|
1943
|
+
// Receipt validation + the binding verdict — verbatim ports of the producer core's _validateReceipt /
|
|
1944
|
+
// verifyAnchoredReceipt (strict form checks; every deviation a named `bad-receipt` naming the field).
|
|
1945
|
+
// ---------------------------------------------------------------------------------------------------
|
|
1946
|
+
|
|
1947
|
+
const ANCHOR_CHAIN_FIELDS = Object.freeze([
|
|
1948
|
+
"authorBound",
|
|
1949
|
+
"blockNumber",
|
|
1950
|
+
"blockTime",
|
|
1951
|
+
"chainId",
|
|
1952
|
+
"contract",
|
|
1953
|
+
"contributor",
|
|
1954
|
+
"txHash",
|
|
1955
|
+
]);
|
|
1956
|
+
const ANCHOR_RECEIPT_FIELDS = Object.freeze(["artifactKind", "artifactLabel", "chain", "digest", "how", "kind", "note"]);
|
|
1957
|
+
const ANCHOR_RECEIPT_REQUIRED = Object.freeze(["artifactKind", "chain", "digest", "how", "kind", "note"]);
|
|
1958
|
+
|
|
1959
|
+
function anchorBadReceipt(field, detail) {
|
|
1960
|
+
return { ok: false, reason: ANCHOR_REASONS.BAD_RECEIPT, field, detail };
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
function anchorCheckChain(chain) {
|
|
1964
|
+
if (!anchorIsPlainObject(chain)) {
|
|
1965
|
+
return { ok: false, field: "chain", detail: "chain must be an object of the seven recorded chain facts" };
|
|
1966
|
+
}
|
|
1967
|
+
for (const k of Object.keys(chain)) {
|
|
1968
|
+
if (!ANCHOR_CHAIN_FIELDS.includes(k)) {
|
|
1969
|
+
return { ok: false, field: `chain.${k}`, detail: `chain has unknown field: ${JSON.stringify(k)}` };
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
for (const k of ANCHOR_CHAIN_FIELDS) {
|
|
1973
|
+
if (!(k in chain)) {
|
|
1974
|
+
return { ok: false, field: `chain.${k}`, detail: `chain is missing required field: ${JSON.stringify(k)}` };
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
if (typeof chain.authorBound !== "boolean") {
|
|
1978
|
+
return { ok: false, field: "chain.authorBound", detail: "authorBound must be a boolean" };
|
|
1979
|
+
}
|
|
1980
|
+
for (const k of ["blockNumber", "blockTime"]) {
|
|
1981
|
+
if (!Number.isSafeInteger(chain[k]) || chain[k] < 0) {
|
|
1982
|
+
return { ok: false, field: `chain.${k}`, detail: `${k} must be a non-negative integer, got: ${String(chain[k])}` };
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
if (!Number.isSafeInteger(chain.chainId) || chain.chainId < 1) {
|
|
1986
|
+
return { ok: false, field: "chain.chainId", detail: `chainId must be a positive integer, got: ${String(chain.chainId)}` };
|
|
1987
|
+
}
|
|
1988
|
+
for (const k of ["contract", "contributor"]) {
|
|
1989
|
+
if (typeof chain[k] !== "string" || !ANCHOR_ADDRESS_LC_RE.test(chain[k])) {
|
|
1990
|
+
return {
|
|
1991
|
+
ok: false,
|
|
1992
|
+
field: `chain.${k}`,
|
|
1993
|
+
detail: `${k} must be a LOWERCASE 0x-address (canonical case), got: ${String(chain[k])}`,
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
if (typeof chain.txHash !== "string" || !ANCHOR_HEX32_LC_RE.test(chain.txHash)) {
|
|
1998
|
+
return {
|
|
1999
|
+
ok: false,
|
|
2000
|
+
field: "chain.txHash",
|
|
2001
|
+
detail: `txHash must be a LOWERCASE 0x-bytes32 hex string, got: ${String(chain.txHash)}`,
|
|
2002
|
+
};
|
|
2003
|
+
}
|
|
2004
|
+
return { ok: true };
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function anchorCanonicalChain(chain) {
|
|
2008
|
+
return {
|
|
2009
|
+
authorBound: chain.authorBound,
|
|
2010
|
+
blockNumber: chain.blockNumber,
|
|
2011
|
+
blockTime: chain.blockTime,
|
|
2012
|
+
chainId: chain.chainId,
|
|
2013
|
+
contract: chain.contract,
|
|
2014
|
+
contributor: chain.contributor,
|
|
2015
|
+
txHash: chain.txHash,
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
function anchorValidateReceipt(receipt) {
|
|
2020
|
+
if (!anchorIsPlainObject(receipt)) {
|
|
2021
|
+
return anchorBadReceipt("receipt", "receipt must be a parsed JSON object");
|
|
2022
|
+
}
|
|
2023
|
+
for (const k of Object.keys(receipt)) {
|
|
2024
|
+
if (!ANCHOR_RECEIPT_FIELDS.includes(k)) {
|
|
2025
|
+
return anchorBadReceipt(k, `receipt has unknown field: ${JSON.stringify(k)}`);
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
for (const k of ANCHOR_RECEIPT_REQUIRED) {
|
|
2029
|
+
if (!(k in receipt)) {
|
|
2030
|
+
return anchorBadReceipt(k, `receipt is missing required field: ${JSON.stringify(k)}`);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
if (receipt.kind !== ANCHORED_RECEIPT_KIND) {
|
|
2034
|
+
return anchorBadReceipt(
|
|
2035
|
+
"kind",
|
|
2036
|
+
`not an anchored receipt this build understands (kind: ${JSON.stringify(receipt.kind)}; expected ${JSON.stringify(ANCHORED_RECEIPT_KIND)})`
|
|
2037
|
+
);
|
|
2038
|
+
}
|
|
2039
|
+
if (receipt.note !== ANCHOR_TRUST_NOTE) {
|
|
2040
|
+
return anchorBadReceipt("note", "receipt `note` must be the standing trust note VERBATIM (the caveat must not drift)");
|
|
2041
|
+
}
|
|
2042
|
+
if (typeof receipt.digest !== "string" || !ANCHOR_HEX32_LC_RE.test(receipt.digest)) {
|
|
2043
|
+
return anchorBadReceipt("digest", `receipt digest must be a LOWERCASE 0x-bytes32 hex string, got: ${String(receipt.digest)}`);
|
|
2044
|
+
}
|
|
2045
|
+
if (typeof receipt.artifactKind !== "string" || !ANCHOR_ARTIFACT_KINDS.includes(receipt.artifactKind)) {
|
|
2046
|
+
return anchorBadReceipt(
|
|
2047
|
+
"artifactKind",
|
|
2048
|
+
`receipt artifactKind ${JSON.stringify(receipt.artifactKind)} is not in the closed table (${ANCHOR_ARTIFACT_KINDS.join(", ")})`
|
|
2049
|
+
);
|
|
2050
|
+
}
|
|
2051
|
+
if (!anchorHowValidFor(receipt.artifactKind, receipt.how)) {
|
|
2052
|
+
return anchorBadReceipt("how", `receipt \`how\` is not the documented derivation rule for ${receipt.artifactKind}`);
|
|
2053
|
+
}
|
|
2054
|
+
if (receipt.artifactLabel !== undefined) {
|
|
2055
|
+
const l = receipt.artifactLabel;
|
|
2056
|
+
if (typeof l !== "string" || l.length === 0 || l.length > 200 || ANCHOR_CONTROL_CHAR_RE.test(l)) {
|
|
2057
|
+
return anchorBadReceipt(
|
|
2058
|
+
"artifactLabel",
|
|
2059
|
+
"artifactLabel, when present, must be a 1..200-char string with no control characters"
|
|
2060
|
+
);
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
const c = anchorCheckChain(receipt.chain);
|
|
2064
|
+
if (!c.ok) return anchorBadReceipt(c.field, c.detail);
|
|
2065
|
+
return { ok: true };
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/**
|
|
2069
|
+
* Verify that `receipt` is a well-formed `vh-anchored-receipt@1` AND that it binds EXACTLY the
|
|
2070
|
+
* supplied `artifact` — the OFFLINE binding leg, standalone: the digest is RECOMPUTED from the
|
|
2071
|
+
* artifact via the closed table (never trusted from either side) and the full { kind, digest, how }
|
|
2072
|
+
* triple must match. NEVER consults a network; the receipt's chain facts are returned as the
|
|
2073
|
+
* anchorer's CLAIM. TOTAL: named rejects, no throws. Same verdicts as the producer core.
|
|
2074
|
+
*
|
|
2075
|
+
* @param {object} args { receipt, artifact } — both caller-supplied PARSED objects
|
|
2076
|
+
* @returns {{ ok:true, digest:string, chain:object } |
|
|
2077
|
+
* { ok:false, reason:string, field?:string, detail?:string }}
|
|
2078
|
+
*/
|
|
2079
|
+
function verifyAnchoredReceipt(args) {
|
|
2080
|
+
try {
|
|
2081
|
+
if (!anchorIsPlainObject(args)) {
|
|
2082
|
+
return anchorNo(ANCHOR_REASONS.BAD_ARGS, "verifyAnchoredReceipt requires { receipt, artifact }");
|
|
2083
|
+
}
|
|
2084
|
+
const r = anchorValidateReceipt(args.receipt);
|
|
2085
|
+
if (!r.ok) return r;
|
|
2086
|
+
const d = anchorArtifactDigest(args.artifact);
|
|
2087
|
+
if (!d.ok) return d; // the artifact's OWN named validation reject, propagated verbatim
|
|
2088
|
+
const receipt = args.receipt;
|
|
2089
|
+
if (d.kind !== receipt.artifactKind) {
|
|
2090
|
+
return anchorNo(
|
|
2091
|
+
ANCHOR_REASONS.KIND_MISMATCH,
|
|
2092
|
+
`receipt anchors a ${receipt.artifactKind} but the supplied artifact is a ${d.kind}`
|
|
2093
|
+
);
|
|
2094
|
+
}
|
|
2095
|
+
if (d.digest !== receipt.digest) {
|
|
2096
|
+
return anchorNo(
|
|
2097
|
+
ANCHOR_REASONS.DIGEST_MISMATCH,
|
|
2098
|
+
`recomputed digest ${d.digest} != receipt digest ${receipt.digest} — this receipt does not bind this artifact`
|
|
2099
|
+
);
|
|
2100
|
+
}
|
|
2101
|
+
if (d.how !== receipt.how) {
|
|
2102
|
+
return anchorNo(ANCHOR_REASONS.HOW_MISMATCH, `recomputed derivation rule != receipt \`how\` (recomputed: ${d.how})`);
|
|
2103
|
+
}
|
|
2104
|
+
return { ok: true, digest: d.digest, chain: anchorCanonicalChain(receipt.chain) };
|
|
2105
|
+
} catch (e) {
|
|
2106
|
+
return anchorNo(ANCHOR_REASONS.BAD_ARGS, e && e.message ? e.message : String(e));
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// ---------------------------------------------------------------------------------------------------
|
|
2111
|
+
// The anchored-receipt CLI leg: read + parse the two files, run the pure binding verify, render the
|
|
2112
|
+
// stable human/JSON verdict. READ-ONLY (no receipt/temp/side-effect file is ever written); exit
|
|
2113
|
+
// contract 0 ACCEPTED / 3 REJECTED (named) / 2 usage / 1 IO — the family's shared verify contract.
|
|
2114
|
+
// ---------------------------------------------------------------------------------------------------
|
|
2115
|
+
|
|
2116
|
+
// The in-band honesty of the offline leg, stated once for both output shapes.
|
|
2117
|
+
const ANCHOR_OFFLINE_NOTE =
|
|
2118
|
+
"OFFLINE binding check: the receipt binds this exact artifact, but its chain facts were NOT " +
|
|
2119
|
+
"re-checked (this standalone verifier opens no network). Confirm them against the chain with the " +
|
|
2120
|
+
"producer cli: vh verify-anchored <receipt> <sealed-file> --rpc <url> --contract <addr>.";
|
|
2121
|
+
|
|
2122
|
+
// ---------------------------------------------------------------------------------------------------
|
|
2123
|
+
// CHAIN-CLASS trust guidance for the OFFLINE leg. The offline binding leg proves the receipt binds
|
|
2124
|
+
// THIS artifact; it can NEVER (offline, by definition) confirm the digest is actually anchored on any
|
|
2125
|
+
// chain. But it CAN classify the chain the receipt CLAIMS — and that classification is the single most
|
|
2126
|
+
// load-bearing thing a counterparty needs to avoid this vertical's worst overclaim: mistaking a
|
|
2127
|
+
// receipt from a worthless LOCAL DEV chain (STRATEGY.md P-2 — a local-chain anchor proves MECHANISM
|
|
2128
|
+
// only and is worth NOTHING publicly) for a public-chain proof. Surfacing it HERE puts the check in
|
|
2129
|
+
// the INDEPENDENT verifier a counterparty actually runs, not only in the producer's prose, and makes
|
|
2130
|
+
// it MACHINE-GATEABLE (`chainClass` / `publiclyMeaningful` in --json — a stable, additive contract a
|
|
2131
|
+
// future indexer/UI keys on). The id sets MIRROR the producer's cli/anchor.js KNOWN_TESTNET_CHAIN_IDS
|
|
2132
|
+
// (test/verifier.standalone.test.js pins them against it byte-for-byte so the two sides cannot drift):
|
|
2133
|
+
// the two generic dev chains are LOCAL-DEV, the remaining known ids are PUBLIC TESTNETS, and every
|
|
2134
|
+
// other id is UNKNOWN (a chain — possibly a mainnet — whose weight this offline leg cannot judge).
|
|
2135
|
+
//
|
|
2136
|
+
// This guidance is STRICTLY ADDITIVE: it never changes the accept/reject decision (a bound receipt is
|
|
2137
|
+
// still ACCEPTED at exit 0) and it never touches the pure `verifyAnchoredReceipt` verdict object,
|
|
2138
|
+
// which stays a byte-faithful port of the producer core. It is presentation-layer trust context only.
|
|
2139
|
+
const ANCHOR_LOCAL_DEV_CHAIN_IDS = Object.freeze([31337, 1337]);
|
|
2140
|
+
const ANCHOR_PUBLIC_TESTNET_CHAIN_IDS = Object.freeze([
|
|
2141
|
+
80002, 80001, 11155111, 17000, 5, 11155420, 84532, 421614,
|
|
2142
|
+
]);
|
|
2143
|
+
|
|
2144
|
+
// Classify the chainId a receipt CLAIMS into { chainClass, publiclyMeaningful, advisory }. TOTAL — a
|
|
2145
|
+
// non-integer/out-of-set id falls through to the honest "unknown" bucket (never throws). `chainId`
|
|
2146
|
+
// arrives already strict-validated (a positive safe integer) from anchorCheckChain.
|
|
2147
|
+
function anchorClassifyChainId(chainId) {
|
|
2148
|
+
if (ANCHOR_LOCAL_DEV_CHAIN_IDS.includes(chainId)) {
|
|
2149
|
+
return {
|
|
2150
|
+
chainClass: "local-dev",
|
|
2151
|
+
publiclyMeaningful: false,
|
|
2152
|
+
advisory:
|
|
2153
|
+
`this receipt's chain (chainId ${chainId}) is a LOCAL DEV chain: the anchor proves MECHANISM ` +
|
|
2154
|
+
`ONLY and is worth NOTHING publicly until a human deploys the registry to a public chain ` +
|
|
2155
|
+
`(STRATEGY.md P-2). Do NOT treat a local-dev receipt as a public proof.`,
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
if (ANCHOR_PUBLIC_TESTNET_CHAIN_IDS.includes(chainId)) {
|
|
2159
|
+
return {
|
|
2160
|
+
chainClass: "public-testnet",
|
|
2161
|
+
publiclyMeaningful: false,
|
|
2162
|
+
advisory:
|
|
2163
|
+
`this receipt's chain (chainId ${chainId}) is a PUBLIC TESTNET: an anchor there demonstrates ` +
|
|
2164
|
+
`the mechanism on a public chain but carries NO economic finality — treat it as a testnet ` +
|
|
2165
|
+
`proof, never a mainnet one.`,
|
|
2166
|
+
};
|
|
2167
|
+
}
|
|
2168
|
+
return {
|
|
2169
|
+
chainClass: "unknown",
|
|
2170
|
+
publiclyMeaningful: null,
|
|
2171
|
+
advisory:
|
|
2172
|
+
`this receipt's chainId ${chainId} is outside this verifier's known local/testnet set (it may ` +
|
|
2173
|
+
`be a mainnet): the OFFLINE leg cannot weigh the chain — re-check the anchor against that chain ` +
|
|
2174
|
+
`before relying on it.`,
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
function anchorReadJson(label, filePath) {
|
|
2179
|
+
let text;
|
|
2180
|
+
try {
|
|
2181
|
+
text = fs.readFileSync(path.resolve(filePath), "utf8");
|
|
2182
|
+
} catch (e) {
|
|
2183
|
+
throw new IOError(`cannot read ${label} ${filePath}: ${e.message}`);
|
|
2184
|
+
}
|
|
2185
|
+
let obj;
|
|
2186
|
+
try {
|
|
2187
|
+
obj = JSON.parse(text);
|
|
2188
|
+
} catch (e) {
|
|
2189
|
+
throw new IOError(`${label} ${filePath} is not valid JSON: ${e.message}`);
|
|
2190
|
+
}
|
|
2191
|
+
if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
2192
|
+
throw new IOError(`${label} ${filePath} must be a JSON object`);
|
|
2193
|
+
}
|
|
2194
|
+
return obj;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
function runVerifyAnchoredOffline(opts, write, writeErr) {
|
|
2198
|
+
let receipt;
|
|
2199
|
+
let artifact;
|
|
2200
|
+
try {
|
|
2201
|
+
receipt = anchorReadJson("receipt", opts.artifact);
|
|
2202
|
+
artifact = anchorReadJson("artifact", opts.anchoredArtifact);
|
|
2203
|
+
} catch (e) {
|
|
2204
|
+
writeErr(`error: ${e.message}\n`);
|
|
2205
|
+
return EXIT.IO;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
const v = verifyAnchoredReceipt({ receipt, artifact });
|
|
2209
|
+
if (!v.ok) {
|
|
2210
|
+
if (opts.json) {
|
|
2211
|
+
write(
|
|
2212
|
+
JSON.stringify(
|
|
2213
|
+
{ ok: false, verdict: "REJECTED", mode: "offline", reason: v.reason, field: v.field, detail: v.detail },
|
|
2214
|
+
null,
|
|
2215
|
+
2
|
|
2216
|
+
) + "\n"
|
|
2217
|
+
);
|
|
2218
|
+
} else {
|
|
2219
|
+
writeErr(`verify-vh anchored-receipt: REJECTED (${v.reason})${v.detail ? `: ${v.detail}` : ""}\n`);
|
|
2220
|
+
}
|
|
2221
|
+
return EXIT.REJECTED;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
// Classify the chain the receipt CLAIMS (additive trust context — never changes the ACCEPT verdict).
|
|
2225
|
+
const cls = anchorClassifyChainId(v.chain.chainId);
|
|
2226
|
+
|
|
2227
|
+
if (opts.json) {
|
|
2228
|
+
write(
|
|
2229
|
+
JSON.stringify(
|
|
2230
|
+
{
|
|
2231
|
+
ok: true,
|
|
2232
|
+
verdict: "ACCEPTED",
|
|
2233
|
+
mode: "offline",
|
|
2234
|
+
digest: v.digest,
|
|
2235
|
+
artifactKind: receipt.artifactKind,
|
|
2236
|
+
chain: v.chain,
|
|
2237
|
+
chainClass: cls.chainClass,
|
|
2238
|
+
publiclyMeaningful: cls.publiclyMeaningful,
|
|
2239
|
+
chainAdvisory: cls.advisory,
|
|
2240
|
+
registry: null,
|
|
2241
|
+
note: ANCHOR_OFFLINE_NOTE,
|
|
2242
|
+
},
|
|
2243
|
+
null,
|
|
2244
|
+
2
|
|
2245
|
+
) + "\n"
|
|
2246
|
+
);
|
|
2247
|
+
} else {
|
|
2248
|
+
const c = v.chain;
|
|
2249
|
+
write("verify-vh anchored-receipt: ACCEPTED (offline binding check)\n");
|
|
2250
|
+
write(` digest: ${v.digest}\n`);
|
|
2251
|
+
write(` kind: ${receipt.artifactKind}\n`);
|
|
2252
|
+
write(
|
|
2253
|
+
` chain CLAIM: chainId ${c.chainId}, contract ${c.contract}, tx ${c.txHash}, ` +
|
|
2254
|
+
`block ${c.blockNumber}, blockTime ${c.blockTime}, contributor ${c.contributor}, ` +
|
|
2255
|
+
`authorBound ${c.authorBound}\n`
|
|
2256
|
+
);
|
|
2257
|
+
write(` chain class: ${cls.chainClass} (publiclyMeaningful: ${cls.publiclyMeaningful})\n`);
|
|
2258
|
+
// For anything not proven publicly meaningful, lead with a WARNING so a counterparty cannot skim
|
|
2259
|
+
// past the caveat; a local-dev receipt (the committed-fixture case) is worth NOTHING publicly.
|
|
2260
|
+
write(` ${cls.publiclyMeaningful === true ? "ADVISORY" : "WARNING"}: ${cls.advisory}\n`);
|
|
2261
|
+
write(
|
|
2262
|
+
" NOTE: the OFFLINE binding leg only — the chain facts above are the anchorer's CLAIM, not " +
|
|
2263
|
+
"re-checked against any chain. Confirm them with the producer cli: " +
|
|
2264
|
+
"vh verify-anchored <receipt> <sealed-file> --rpc <url> --contract <addr>.\n"
|
|
2265
|
+
);
|
|
2266
|
+
}
|
|
2267
|
+
return EXIT.OK;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
1338
2270
|
// ---------------------------------------------------------------------------
|
|
1339
2271
|
// Argument parsing.
|
|
1340
2272
|
// SINGLE-ARTIFACT (the original, byte-for-byte unchanged contract):
|
|
@@ -1358,6 +2290,7 @@ function parseArgs(argv) {
|
|
|
1358
2290
|
manifest: undefined,
|
|
1359
2291
|
revocations: undefined,
|
|
1360
2292
|
asOf: undefined,
|
|
2293
|
+
anchoredArtifact: undefined,
|
|
1361
2294
|
_pos: [],
|
|
1362
2295
|
};
|
|
1363
2296
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -1380,6 +2313,9 @@ function parseArgs(argv) {
|
|
|
1380
2313
|
case "--revocations":
|
|
1381
2314
|
opts.revocations = need("--revocations");
|
|
1382
2315
|
break;
|
|
2316
|
+
case "--anchored-artifact":
|
|
2317
|
+
opts.anchoredArtifact = need("--anchored-artifact");
|
|
2318
|
+
break;
|
|
1383
2319
|
case "--as-of":
|
|
1384
2320
|
opts.asOf = need("--as-of");
|
|
1385
2321
|
break;
|
|
@@ -1426,6 +2362,33 @@ function parseArgs(argv) {
|
|
|
1426
2362
|
);
|
|
1427
2363
|
}
|
|
1428
2364
|
}
|
|
2365
|
+
// ANCHORED-RECEIPT leg (T-70.4): `--anchored-artifact <sealed-file>` pairs ONE receipt positional
|
|
2366
|
+
// with ONE sealed artifact. It is a dedicated two-file binding check, so the sibling-verify flags
|
|
2367
|
+
// (--vendor/--dir/--revocations/--as-of) and the batch/manifest modes do not compose with it — each
|
|
2368
|
+
// incompatible combination is a NAMED usage error up front, never a silently-ignored flag.
|
|
2369
|
+
if (opts.anchoredArtifact !== undefined) {
|
|
2370
|
+
if (opts.manifest !== undefined) {
|
|
2371
|
+
throw new UsageError("--anchored-artifact verifies ONE receipt; it cannot be combined with --manifest");
|
|
2372
|
+
}
|
|
2373
|
+
for (const [flag, val] of [
|
|
2374
|
+
["--vendor", opts.vendor],
|
|
2375
|
+
["--dir", opts.dir],
|
|
2376
|
+
["--revocations", opts.revocations],
|
|
2377
|
+
["--as-of", opts.asOf],
|
|
2378
|
+
]) {
|
|
2379
|
+
if (val !== undefined) {
|
|
2380
|
+
throw new UsageError(
|
|
2381
|
+
`${flag} does not apply to the anchored-receipt binding check (--anchored-artifact reads exactly two files: the receipt and the sealed artifact)`
|
|
2382
|
+
);
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
if (opts._pos.length !== 1) {
|
|
2386
|
+
throw new UsageError(
|
|
2387
|
+
"--anchored-artifact requires exactly ONE <receipt> positional: verify-vh <receipt> --anchored-artifact <sealed-file>"
|
|
2388
|
+
);
|
|
2389
|
+
}
|
|
2390
|
+
opts.batch = false;
|
|
2391
|
+
}
|
|
1429
2392
|
// Preserve the SINGLE-artifact contract verbatim: exactly one positional and no --manifest.
|
|
1430
2393
|
opts.artifact = opts._pos[0];
|
|
1431
2394
|
return opts;
|
|
@@ -1628,6 +2591,15 @@ function verifyArtifact(opts) {
|
|
|
1628
2591
|
throw new IOError(`artifact ${opts.artifact} must be a JSON object`);
|
|
1629
2592
|
}
|
|
1630
2593
|
|
|
2594
|
+
// A bare anchored receipt reached the sibling-verify path: point the caller at the two-file binding
|
|
2595
|
+
// check instead of the generic "unrecognized kind" (a receipt alone carries nothing to re-derive).
|
|
2596
|
+
if (obj.kind === ANCHORED_RECEIPT_KIND) {
|
|
2597
|
+
throw new UsageError(
|
|
2598
|
+
`${opts.artifact} is a ${ANCHORED_RECEIPT_KIND} anchored receipt — verify its OFFLINE binding ` +
|
|
2599
|
+
"leg against the sealed artifact it anchors: verify-vh <receipt> --anchored-artifact <sealed-file>"
|
|
2600
|
+
);
|
|
2601
|
+
}
|
|
2602
|
+
|
|
1631
2603
|
// The base directory siblings resolve against: --dir override else the artifact's own directory.
|
|
1632
2604
|
const baseDir = opts.dir != null ? path.resolve(opts.dir) : path.dirname(artifactPath);
|
|
1633
2605
|
|
|
@@ -2187,6 +3159,7 @@ function usage() {
|
|
|
2187
3159
|
" verify-vh <artifact> [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir> [--as-of <ISO>]] [--json]",
|
|
2188
3160
|
" verify-vh <artifact> <artifact> ... [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir>] [--json] (batch)",
|
|
2189
3161
|
" verify-vh --manifest <file> [--vendor <0xaddr>] [--dir <d>] [--revocations <file-or-dir>] [--json] (batch)",
|
|
3162
|
+
" verify-vh <receipt> --anchored-artifact <sealed-file> [--json] (anchored-receipt binding check)",
|
|
2190
3163
|
"",
|
|
2191
3164
|
"DEMO: `verify-vh demo` runs a self-contained, genuinely-signed packet through the real verify path —",
|
|
2192
3165
|
"NO flags, NO key, NO install state: it ACCEPTs the packet (naming the signer), then REJECTs a one-byte-",
|
|
@@ -2209,6 +3182,15 @@ function usage() {
|
|
|
2209
3182
|
"third-party revocation is IGNORED with a warning. This reaches the SAME downgrade the producer's",
|
|
2210
3183
|
"`vh ... verify-signed --revocations` does, OFFLINE — no producer stack, no network, no key.",
|
|
2211
3184
|
"",
|
|
3185
|
+
"ANCHORED RECEIPTS (T-70.4): a `vh-anchored-receipt@1` produced by `vh anchor-artifact` verifies",
|
|
3186
|
+
"here WITHOUT the producer stack: --anchored-artifact <sealed-file> re-derives the sealed artifact's",
|
|
3187
|
+
"digest through the SAME closed kind table (evidence seal, agent-session packet, journal tree head,",
|
|
3188
|
+
"TrustLedger seal, dataset/parcel attestation), validates the receipt strictly (a drifted trust note",
|
|
3189
|
+
"is a named bad-receipt), and confirms the receipt binds EXACTLY those bytes — ACCEPTED exit 0, or",
|
|
3190
|
+
"the specific named reject (digest-mismatch / kind-mismatch / how-mismatch / bad-receipt / the",
|
|
3191
|
+
"artifact's own named reject) exit 3. OFFLINE binding leg ONLY: the receipt's `chain` facts remain",
|
|
3192
|
+
"the anchorer's CLAIM — re-check them on chain with the producer cli (`vh verify-anchored --rpc`).",
|
|
3193
|
+
"",
|
|
2212
3194
|
"BATCH/MANIFEST: pass several <artifact> args, or --manifest <file> (a newline list or JSON array of",
|
|
2213
3195
|
"artifact paths, each line/object may carry its own --vendor/--dir). ALL must pass for exit 0; if ANY",
|
|
2214
3196
|
"is rejected, exit is 3 and the report names which artifact failed and why. --json emits a stable",
|
|
@@ -2275,6 +3257,12 @@ function run(argv, io = {}) {
|
|
|
2275
3257
|
return EXIT.USAGE;
|
|
2276
3258
|
}
|
|
2277
3259
|
|
|
3260
|
+
// ANCHORED-RECEIPT binding check (T-70.4): a dedicated two-file leg — parseArgs already guaranteed
|
|
3261
|
+
// exactly one <receipt> positional and no incompatible flag. READ-ONLY; exit 0/3/2/1 as everywhere.
|
|
3262
|
+
if (opts.anchoredArtifact !== undefined) {
|
|
3263
|
+
return runVerifyAnchoredOffline(opts, write, writeErr);
|
|
3264
|
+
}
|
|
3265
|
+
|
|
2278
3266
|
// The recipient's current decision instant (the default --as-of). Injectable via io.nowISO so a test can
|
|
2279
3267
|
// pin the clock; otherwise the wall clock. Threaded onto opts for the (optional) revocation evaluation.
|
|
2280
3268
|
opts.nowISO = io.nowISO || new Date().toISOString();
|
|
@@ -2355,6 +3343,20 @@ module.exports = {
|
|
|
2355
3343
|
verifyProofBundle,
|
|
2356
3344
|
verifyAgentSeal,
|
|
2357
3345
|
AGENT_TRUST_NOTE,
|
|
3346
|
+
// ANCHORED-RECEIPT surface (T-70.4) — wire-format constants + the pure binding verify, exported so
|
|
3347
|
+
// the parity test can pin them against the producer core (cli/core/anchor-binding.js) byte-for-byte.
|
|
3348
|
+
ANCHORED_RECEIPT_KIND,
|
|
3349
|
+
ANCHOR_TRUST_NOTE,
|
|
3350
|
+
ANCHOR_REASONS,
|
|
3351
|
+
ANCHOR_ARTIFACT_KINDS,
|
|
3352
|
+
ANCHOR_JOURNAL_TREE_HEAD_KIND,
|
|
3353
|
+
ANCHOR_JOURNAL_EMPTY_ROOT,
|
|
3354
|
+
ANCHOR_LOCAL_DEV_CHAIN_IDS,
|
|
3355
|
+
ANCHOR_PUBLIC_TESTNET_CHAIN_IDS,
|
|
3356
|
+
anchorClassifyChainId,
|
|
3357
|
+
anchorArtifactDigest,
|
|
3358
|
+
verifyAnchoredReceipt,
|
|
3359
|
+
runVerifyAnchoredOffline,
|
|
2358
3360
|
renderHuman,
|
|
2359
3361
|
revocation,
|
|
2360
3362
|
usage,
|