@parmana/verifier 2.0.0 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,11 +33,17 @@ node bin/parmana-verify.mjs test/vectors/record-whole-record-v3.json test/vector
33
33
  "trustRecordHash": true,
34
34
  "signature": true,
35
35
  "hybridSignatures": true
36
+ },
37
+ "correspondence": {
38
+ "status": "not_checked",
39
+ "executions": [
40
+ { "executionId": "ex_1", "result": { "status": "not_checked", "reason": "no authorization is embedded in the trust record" } }
41
+ ]
36
42
  }
37
43
  }
38
44
  ```
39
45
 
40
- The exit code is `0`. A record that has been changed is rejected:
46
+ The exit code is `0`. The `correspondence` field is a separate report, described in [Execution correspondence](#execution-correspondence). It does not change `valid` or the exit code. This sample record is a trimmed test vector that does not embed its authorization, so there is nothing to compare and its correspondence is reported as `not_checked`, not as a mismatch. A complete record from Parmana embeds the authorization and is checked. A record that has been changed is rejected:
41
47
 
42
48
  ```sh
43
49
  node bin/parmana-verify.mjs test/vectors/record-tampered-amount.json test/vectors/trust.json
@@ -102,6 +108,18 @@ The verify command prints one line of JSON and exits with `0` (valid), `1` (inva
102
108
 
103
109
  On Windows PowerShell, use the same commands with Windows paths, for example `node bin\parmana-verify.mjs proof.json trust.json`.
104
110
 
111
+ ## Execution correspondence
112
+
113
+ The output also carries a `correspondence` report: whether what was dispatched is what was authorized. It is checked from the record alone, the same way as the signatures, and it uses the same rules as Parmana's own offline check.
114
+
115
+ For each execution, the record's authorization (its nonce, business transaction hash, policy content hash and authority grant) is compared with the execution's `evidence.correspondence`. Every field must be present and must match, and the dispatched content hash must equal the authorized one. The statuses are:
116
+
117
+ - `passed`: every field is present and matches the authorization.
118
+ - `failed`: a field is missing or different. This is a mismatch that was observed.
119
+ - `not_checked`: the execution has no correspondence fields, no evidence, or the record embeds no authorization to compare with. Nothing was compared, so this is not a mismatch. It is never reported as passed.
120
+
121
+ The report is `passed` only when every execution is checked and corresponds. The verifier reports it and does not decide what it means for your system: a `failed` or `not_checked` report is for you to act on. It is meaningful only for a record whose signatures also verify, so read it together with `valid`.
122
+
105
123
  ## Pinning a key
106
124
 
107
125
  A signature proves only that a key made it. Before you trust a record, decide which keys you trust, and check that each key is the one its owner published.
@@ -163,11 +181,15 @@ Run the tests on Node 24. On Node 22 or any runtime without ML-DSA-65, the hybri
163
181
 
164
182
  The suite covers the legacy signature and hash, hybrid signatures, schema version 3 coverage, the trust rules, the test vectors, and the command-line exit codes. Each area has its own file under `test/`.
165
183
 
184
+ Records from parmana-core are tested in `test/parmana-core-records.test.mjs` and `python/tests/test_parmana_core_records.py`. They use `test/vectors/parmana-core-generated/cases.json`: 22 cases over records exported at parmana-core `7e9a54fb`, with tampered copies and trust-file edits. Both verifiers must agree on every case. The quick-start record in `python/README.md` is tested in `test/quickstart.test.mjs` and `python/tests/test_quickstart.py`.
185
+
166
186
  ## Limitations
167
187
 
168
188
  - The verifier can only check that a record was signed by the key you pinned. It cannot prove that the key belongs to Parmana. That depends on how you obtained and compared the fingerprint.
169
189
  - For records with `schemaVersion` 2, or with no `schemaVersion`, only the fields listed in [docs/FORMAT.md](docs/FORMAT.md) are protected. Any other field can be changed without the verifier noticing. Read decisions only from covered fields. See "Fields not covered by any signature" in that document.
170
- - For records with `schemaVersion` 3, every field except the signature envelope, `verifications` and `receipts` is protected. Check that `coverage` is `full` when that matters.
190
+ - For records with `schemaVersion` 3, every field except the signature envelope, `verifications` and `receipts` is protected.
191
+ - `coverage` is `full` only when the hybrid signatures also verify. It does not report the schema version. A schema version 3 record that fails the hybrid check reports `legacy`, and an Ed25519-only failure can report `full` alongside `valid: false`. Read `valid` for the verdict.
192
+ - `checks.signature` is the legacy top-level signature, which covers only the seven legacy fields. A change to `updatedAt` in a schema version 3 record is caught by the hybrid signatures alone, so `checks.signature` stays `true` (pinned by `test/parmana-core-records.test.mjs`).
171
193
  - A record with `schemaVersion` 2 can be downgraded by someone who can write to storage, since `schemaVersion` is unsigned. Set `hybridRequiredFrom` in the trust file to reject downgraded records created after a cutover. Records before the cutover stay open to this. With no cutover set, a record with no hybrid envelope is refused unless the trust file sets `allowLegacyOnly` to `true` (see "Records with no hybrid envelope" above).
172
194
  - The validity window is checked against the record's `createdAt`, not against the signing time. A record that carries a false `createdAt` is checked against the window for that false time.
173
195
  - Keys and fingerprints must be kept current. The verifier does not know about rotations or revocations that you have not written into the trust file.
@@ -9,6 +9,7 @@
9
9
  import { readFileSync } from "node:fs";
10
10
  import { verifyExecutionTrustRecord } from "../src/verify.mjs";
11
11
  import { keyFingerprint, parseTrustPolicy } from "../src/trust.mjs";
12
+ import { checkTrustRecordCorrespondence } from "../src/correspondence.mjs";
12
13
 
13
14
  const args = process.argv.slice(2);
14
15
  const USAGE = "usage: parmana-verify <proof.json> <trust.json>\n parmana-verify key-fingerprint <public-key.pem>";
@@ -48,6 +49,7 @@ try {
48
49
 
49
50
  const result = verifyExecutionTrustRecord(proof, policy.keys, { hybridRequiredFrom: policy.hybridRequiredFrom ?? undefined, allowLegacyOnly: policy.allowLegacyOnly, allowLegacyV2: policy.allowLegacyV2 });
50
51
 
51
- console.log(JSON.stringify(result));
52
+ // The correspondence report does not change `valid` or the exit code (see src/correspondence.mjs).
53
+ console.log(JSON.stringify({ ...result, correspondence: checkTrustRecordCorrespondence(proof) }));
52
54
 
53
55
  process.exit(result.valid ? 0 : 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parmana/verifier",
3
- "version": "2.0.0",
3
+ "version": "2.0.3",
4
4
  "description": "Offline verifier for Parmana Execution Trust Records.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -14,7 +14,7 @@
14
14
  "NOTICE"
15
15
  ],
16
16
  "scripts": {
17
- "test": "node --test test/verify.test.mjs test/hybrid.test.mjs test/trust.test.mjs test/vectors.test.mjs test/whole-record.test.mjs test/agentrust-fixture.test.mjs test/timestamp.test.mjs",
17
+ "test": "node --test test/verify.test.mjs test/hybrid.test.mjs test/trust.test.mjs test/vectors.test.mjs test/whole-record.test.mjs test/agentrust-fixture.test.mjs test/timestamp.test.mjs test/correspondence.test.mjs test/parmana-core-records.test.mjs test/quickstart.test.mjs",
18
18
  "examples": "node examples/run-all.mjs"
19
19
  },
20
20
  "engines": {
@@ -0,0 +1,138 @@
1
+ // Execution correspondence: an offline check that what was dispatched is what
2
+ // was authorized. It is a separate report from verifyExecutionTrustRecord: it
3
+ // does not change `valid`, and it means something only for a record whose
4
+ // signatures also verify. Run both.
5
+ //
6
+ // Mirrors parmana-core's checkExecutionCorrespondence and
7
+ // checkTrustRecordCorrespondenceOffline (packages/shared and packages/crypto).
8
+ // Pure string comparison; the caller's record is not modified.
9
+ //
10
+ // Statuses:
11
+ // "passed" every required field is present and matches the authorization.
12
+ // "failed" a field is missing, malformed or different, or the record
13
+ // embeds no authorization.
14
+ // "not_checked" the record carries no correspondence fields, or an execution
15
+ // has no evidence. Never reported as passed.
16
+ //
17
+ // The report's status: "failed" if any execution failed; "not_checked" if no
18
+ // execution was checked or any could not be checked; "passed" only when every
19
+ // execution was checked and corresponds.
20
+
21
+ const SUPPORTED_SCHEMA_VERSION = 1;
22
+
23
+ function isNonEmptyString(value) {
24
+ return typeof value === "string" && value.length > 0;
25
+ }
26
+
27
+ export function checkExecutionCorrespondence(correspondence, authorization) {
28
+ if (correspondence === undefined || correspondence === null) {
29
+ return { status: "not_checked", reason: "record predates the correspondence fields" };
30
+ }
31
+
32
+ const failures = [];
33
+
34
+ if (correspondence.schemaVersion !== SUPPORTED_SCHEMA_VERSION) {
35
+ failures.push(`unsupported correspondence schemaVersion ${String(correspondence.schemaVersion)}`);
36
+ }
37
+
38
+ if (!isNonEmptyString(correspondence.authorizationNonce)) {
39
+ failures.push("authorizationNonce is missing");
40
+ } else if (correspondence.authorizationNonce !== authorization.nonce) {
41
+ failures.push("authorizationNonce differs from the authorization");
42
+ }
43
+
44
+ if (!isNonEmptyString(correspondence.authorizedContentHash)) {
45
+ failures.push("authorizedContentHash is missing");
46
+ } else if (correspondence.authorizedContentHash !== authorization.businessTransactionHash) {
47
+ failures.push("authorizedContentHash differs from the authorization");
48
+ }
49
+
50
+ if (!isNonEmptyString(correspondence.dispatchedContentHash)) {
51
+ failures.push("dispatchedContentHash is missing");
52
+ } else if (correspondence.dispatchedContentHash !== correspondence.authorizedContentHash) {
53
+ failures.push("dispatched content differs from the authorized content");
54
+ }
55
+
56
+ // The source of the dispatched hash is recorded for review. Only the two known values are accepted when the field
57
+ // is present. Whether the two sources must agree is an owner decision; this check does not compare them.
58
+ if (
59
+ correspondence.dispatchedHashSource !== undefined &&
60
+ correspondence.dispatchedHashSource !== "gateway" &&
61
+ correspondence.dispatchedHashSource !== "connector-wire"
62
+ ) {
63
+ failures.push(`dispatchedHashSource ${String(correspondence.dispatchedHashSource)} is not a known source`);
64
+ }
65
+
66
+ if (authorization.policyContentHash !== undefined && correspondence.policyContentHash !== authorization.policyContentHash) {
67
+ failures.push("policyContentHash differs from the authorization");
68
+ }
69
+
70
+ if (authorization.authorityGrantId !== undefined && correspondence.authorityGrantId !== authorization.authorityGrantId) {
71
+ failures.push("authorityGrantId differs from the authorization");
72
+ }
73
+
74
+ return failures.length === 0 ? { status: "passed" } : { status: "failed", failures };
75
+ }
76
+
77
+ // The authorization fields correspondence is checked against, from the record.
78
+ function authorizationOf(record) {
79
+ const payload = record?.authorization?.payload;
80
+
81
+ if (payload === undefined || payload === null) {
82
+ return undefined;
83
+ }
84
+
85
+ const authorization = {
86
+ nonce: payload.nonce,
87
+ businessTransactionHash: payload.businessTransactionHash,
88
+ };
89
+
90
+ if (payload.policyContentHash !== undefined) {
91
+ authorization.policyContentHash = payload.policyContentHash;
92
+ }
93
+
94
+ if (payload.authorityGrant !== undefined && payload.authorityGrant !== null) {
95
+ authorization.authorityGrantId = payload.authorityGrant.grantId;
96
+ }
97
+
98
+ return authorization;
99
+ }
100
+
101
+ export function checkTrustRecordCorrespondence(record) {
102
+ const authorization = authorizationOf(record);
103
+ const executions = (Array.isArray(record?.executions) ? record.executions : []).map((execution) => {
104
+ if (authorization === undefined) {
105
+ // Nothing to compare with: not checked. It is not a mismatch, so it is not "failed".
106
+ return {
107
+ executionId: execution.executionId,
108
+ result: { status: "not_checked", reason: "no authorization is embedded in the trust record" },
109
+ };
110
+ }
111
+
112
+ if (execution.evidence === undefined || execution.evidence === null) {
113
+ return {
114
+ executionId: execution.executionId,
115
+ result: { status: "not_checked", reason: "execution has no evidence recorded" },
116
+ };
117
+ }
118
+
119
+ return {
120
+ executionId: execution.executionId,
121
+ result: checkExecutionCorrespondence(execution.evidence.correspondence, authorization),
122
+ };
123
+ });
124
+
125
+ if (executions.length === 0) {
126
+ return { status: "not_checked", executions };
127
+ }
128
+
129
+ if (executions.some((entry) => entry.result.status === "failed")) {
130
+ return { status: "failed", executions };
131
+ }
132
+
133
+ if (executions.some((entry) => entry.result.status === "not_checked")) {
134
+ return { status: "not_checked", executions };
135
+ }
136
+
137
+ return { status: "passed", executions };
138
+ }