@parmana/verifier 2.0.1 → 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.
@@ -1,53 +1,55 @@
1
- #!/usr/bin/env node
2
- // Usage:
3
- // parmana-verify <proof.json> <trust.json>
4
- // parmana-verify key-fingerprint <public-key.pem>
5
- //
6
- // verify exits 0 = valid, 1 = invalid, 2 = usage or read error.
7
- // key-fingerprint prints the "sha256:..." value to pin in a trust file.
8
-
9
- import { readFileSync } from "node:fs";
10
- import { verifyExecutionTrustRecord } from "../src/verify.mjs";
11
- import { keyFingerprint, parseTrustPolicy } from "../src/trust.mjs";
12
-
13
- const args = process.argv.slice(2);
14
- const USAGE = "usage: parmana-verify <proof.json> <trust.json>\n parmana-verify key-fingerprint <public-key.pem>";
15
-
16
- if (args[0] === "key-fingerprint") {
17
- if (args.length !== 2) {
18
- console.error(USAGE);
19
- process.exit(2);
20
- }
21
-
22
- try {
23
- console.log(keyFingerprint(readFileSync(args[1], "utf8")));
24
- process.exit(0);
25
- } catch (error) {
26
- console.error(`key error: ${error.message}`);
27
- process.exit(2);
28
- }
29
- }
30
-
31
- const [proofPath, trustPath] = args;
32
-
33
- if (!proofPath || !trustPath || args.length !== 2) {
34
- console.error(USAGE);
35
- process.exit(2);
36
- }
37
-
38
- let proof;
39
- let policy;
40
-
41
- try {
42
- proof = JSON.parse(readFileSync(proofPath, "utf8"));
43
- policy = parseTrustPolicy(JSON.parse(readFileSync(trustPath, "utf8")));
44
- } catch (error) {
45
- console.error(`read error: ${error.message}`);
46
- process.exit(2);
47
- }
48
-
49
- const result = verifyExecutionTrustRecord(proof, policy.keys, { hybridRequiredFrom: policy.hybridRequiredFrom ?? undefined, allowLegacyOnly: policy.allowLegacyOnly, allowLegacyV2: policy.allowLegacyV2 });
50
-
51
- console.log(JSON.stringify(result));
52
-
53
- process.exit(result.valid ? 0 : 1);
1
+ #!/usr/bin/env node
2
+ // Usage:
3
+ // parmana-verify <proof.json> <trust.json>
4
+ // parmana-verify key-fingerprint <public-key.pem>
5
+ //
6
+ // verify exits 0 = valid, 1 = invalid, 2 = usage or read error.
7
+ // key-fingerprint prints the "sha256:..." value to pin in a trust file.
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { verifyExecutionTrustRecord } from "../src/verify.mjs";
11
+ import { keyFingerprint, parseTrustPolicy } from "../src/trust.mjs";
12
+ import { checkTrustRecordCorrespondence } from "../src/correspondence.mjs";
13
+
14
+ const args = process.argv.slice(2);
15
+ const USAGE = "usage: parmana-verify <proof.json> <trust.json>\n parmana-verify key-fingerprint <public-key.pem>";
16
+
17
+ if (args[0] === "key-fingerprint") {
18
+ if (args.length !== 2) {
19
+ console.error(USAGE);
20
+ process.exit(2);
21
+ }
22
+
23
+ try {
24
+ console.log(keyFingerprint(readFileSync(args[1], "utf8")));
25
+ process.exit(0);
26
+ } catch (error) {
27
+ console.error(`key error: ${error.message}`);
28
+ process.exit(2);
29
+ }
30
+ }
31
+
32
+ const [proofPath, trustPath] = args;
33
+
34
+ if (!proofPath || !trustPath || args.length !== 2) {
35
+ console.error(USAGE);
36
+ process.exit(2);
37
+ }
38
+
39
+ let proof;
40
+ let policy;
41
+
42
+ try {
43
+ proof = JSON.parse(readFileSync(proofPath, "utf8"));
44
+ policy = parseTrustPolicy(JSON.parse(readFileSync(trustPath, "utf8")));
45
+ } catch (error) {
46
+ console.error(`read error: ${error.message}`);
47
+ process.exit(2);
48
+ }
49
+
50
+ const result = verifyExecutionTrustRecord(proof, policy.keys, { hybridRequiredFrom: policy.hybridRequiredFrom ?? undefined, allowLegacyOnly: policy.allowLegacyOnly, allowLegacyV2: policy.allowLegacyV2 });
51
+
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) }));
54
+
55
+ process.exit(result.valid ? 0 : 1);
package/package.json CHANGED
@@ -1,40 +1,40 @@
1
- {
2
- "name": "@parmana/verifier",
3
- "version": "2.0.1",
4
- "description": "Offline verifier for Parmana Execution Trust Records.",
5
- "license": "Apache-2.0",
6
- "type": "module",
7
- "bin": {
8
- "parmana-verify": "bin/parmana-verify.mjs"
9
- },
10
- "files": [
11
- "bin",
12
- "src",
13
- "LICENSE",
14
- "NOTICE"
15
- ],
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",
18
- "examples": "node examples/run-all.mjs"
19
- },
20
- "engines": {
21
- "node": ">=24"
22
- },
23
- "repository": {
24
- "type": "git",
25
- "url": "git+https://github.com/pavancharak/parmana-verifier.git"
26
- },
27
- "homepage": "https://github.com/pavancharak/parmana-verifier#readme",
28
- "bugs": {
29
- "url": "https://github.com/pavancharak/parmana-verifier/issues"
30
- },
31
- "keywords": [
32
- "parmana",
33
- "verifier",
34
- "execution-trust-record",
35
- "ed25519",
36
- "ml-dsa",
37
- "post-quantum",
38
- "offline"
39
- ]
40
- }
1
+ {
2
+ "name": "@parmana/verifier",
3
+ "version": "2.0.3",
4
+ "description": "Offline verifier for Parmana Execution Trust Records.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "bin": {
8
+ "parmana-verify": "bin/parmana-verify.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "LICENSE",
14
+ "NOTICE"
15
+ ],
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 test/correspondence.test.mjs test/parmana-core-records.test.mjs test/quickstart.test.mjs",
18
+ "examples": "node examples/run-all.mjs"
19
+ },
20
+ "engines": {
21
+ "node": ">=24"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/pavancharak/parmana-verifier.git"
26
+ },
27
+ "homepage": "https://github.com/pavancharak/parmana-verifier#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/pavancharak/parmana-verifier/issues"
30
+ },
31
+ "keywords": [
32
+ "parmana",
33
+ "verifier",
34
+ "execution-trust-record",
35
+ "ed25519",
36
+ "ml-dsa",
37
+ "post-quantum",
38
+ "offline"
39
+ ]
40
+ }
package/src/canonical.mjs CHANGED
@@ -1,35 +1,35 @@
1
- // Canonical serialization for Parmana artifacts.
2
- //
3
- // Object keys are sorted in code-unit order, arrays keep their order, and the
4
- // result is UTF-8 encoded JSON. Every hash and signature is computed over
5
- // these bytes, so the verifier must produce exactly the same bytes as the
6
- // signer.
7
-
8
- export function canonicalBytes(value) {
9
- return new TextEncoder().encode(JSON.stringify(normalize(value)));
10
- }
11
-
12
- function normalize(value) {
13
- if (value === null || typeof value !== "object") {
14
- return value;
15
- }
16
-
17
- if (Array.isArray(value)) {
18
- return value.map(normalize);
19
- }
20
-
21
- const normalized = {};
22
-
23
- for (const key of Object.keys(value).sort()) {
24
- // defineProperty, not assignment: a key named "__proto__" must stay an
25
- // own property so its content is covered by the hash and signature.
26
- Object.defineProperty(normalized, key, {
27
- value: normalize(value[key]),
28
- enumerable: true,
29
- writable: true,
30
- configurable: true,
31
- });
32
- }
33
-
34
- return normalized;
35
- }
1
+ // Canonical serialization for Parmana artifacts.
2
+ //
3
+ // Object keys are sorted in code-unit order, arrays keep their order, and the
4
+ // result is UTF-8 encoded JSON. Every hash and signature is computed over
5
+ // these bytes, so the verifier must produce exactly the same bytes as the
6
+ // signer.
7
+
8
+ export function canonicalBytes(value) {
9
+ return new TextEncoder().encode(JSON.stringify(normalize(value)));
10
+ }
11
+
12
+ function normalize(value) {
13
+ if (value === null || typeof value !== "object") {
14
+ return value;
15
+ }
16
+
17
+ if (Array.isArray(value)) {
18
+ return value.map(normalize);
19
+ }
20
+
21
+ const normalized = {};
22
+
23
+ for (const key of Object.keys(value).sort()) {
24
+ // defineProperty, not assignment: a key named "__proto__" must stay an
25
+ // own property so its content is covered by the hash and signature.
26
+ Object.defineProperty(normalized, key, {
27
+ value: normalize(value[key]),
28
+ enumerable: true,
29
+ writable: true,
30
+ configurable: true,
31
+ });
32
+ }
33
+
34
+ return normalized;
35
+ }
@@ -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
+ }
package/src/timestamp.mjs CHANGED
@@ -1,65 +1,65 @@
1
- // Strict ISO-8601 timestamp parsing, matching python/parmana_verifier/timestamps.py.
2
- //
3
- // Accepts a date, or a date and time with an explicit offset ("Z" or "+hh:mm").
4
- // A timestamp without an offset is rejected rather than read in the local time
5
- // zone, so a result never depends on the machine's clock setting. Returns NaN
6
- // for anything else, so callers can keep their existing Number.isNaN checks.
7
-
8
- const DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:\d{2})$/;
9
- const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
10
-
11
- export function parseTimestamp(value) {
12
- if (typeof value !== "string") return NaN;
13
-
14
- const dateOnly = DATE_ONLY.exec(value);
15
- if (dateOnly) {
16
- return utcOrNaN(Number(dateOnly[1]), Number(dateOnly[2]), Number(dateOnly[3]), 0, 0, 0, 0);
17
- }
18
-
19
- const match = DATE_TIME.exec(value);
20
- if (!match) return NaN;
21
-
22
- const [, year, month, day, hour, minute, second, fraction, offset] = match;
23
- const millis = Number(((fraction ?? "") + "000").slice(0, 3));
24
- const utc = utcOrNaN(
25
- Number(year),
26
- Number(month),
27
- Number(day),
28
- Number(hour),
29
- Number(minute),
30
- Number(second ?? 0),
31
- millis,
32
- );
33
- if (Number.isNaN(utc)) return NaN;
34
-
35
- const offsetMs = offset === "Z" ? 0 : offsetToMillis(offset);
36
- if (offsetMs === undefined) return NaN;
37
-
38
- return utc - offsetMs;
39
- }
40
-
41
- function utcOrNaN(year, month, day, hour, minute, second, millis) {
42
- if (month < 1 || month > 12 || day < 1 || hour > 23 || minute > 59 || second > 59) return NaN;
43
-
44
- const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second, millis));
45
-
46
- // Date.UTC rolls an impossible day (for example 31 February) into the next
47
- // month. Reject it by checking the fields round-trip unchanged.
48
- if (
49
- date.getUTCFullYear() !== year ||
50
- date.getUTCMonth() !== month - 1 ||
51
- date.getUTCDate() !== day
52
- ) {
53
- return NaN;
54
- }
55
-
56
- return date.getTime();
57
- }
58
-
59
- function offsetToMillis(text) {
60
- const sign = text[0] === "+" ? 1 : -1;
61
- const hours = Number(text.slice(1, 3));
62
- const minutes = Number(text.slice(4, 6));
63
- if (hours > 23 || minutes > 59) return undefined;
64
- return sign * (hours * 60 + minutes) * 60_000;
65
- }
1
+ // Strict ISO-8601 timestamp parsing, matching python/parmana_verifier/timestamps.py.
2
+ //
3
+ // Accepts a date, or a date and time with an explicit offset ("Z" or "+hh:mm").
4
+ // A timestamp without an offset is rejected rather than read in the local time
5
+ // zone, so a result never depends on the machine's clock setting. Returns NaN
6
+ // for anything else, so callers can keep their existing Number.isNaN checks.
7
+
8
+ const DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:\d{2})$/;
9
+ const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
10
+
11
+ export function parseTimestamp(value) {
12
+ if (typeof value !== "string") return NaN;
13
+
14
+ const dateOnly = DATE_ONLY.exec(value);
15
+ if (dateOnly) {
16
+ return utcOrNaN(Number(dateOnly[1]), Number(dateOnly[2]), Number(dateOnly[3]), 0, 0, 0, 0);
17
+ }
18
+
19
+ const match = DATE_TIME.exec(value);
20
+ if (!match) return NaN;
21
+
22
+ const [, year, month, day, hour, minute, second, fraction, offset] = match;
23
+ const millis = Number(((fraction ?? "") + "000").slice(0, 3));
24
+ const utc = utcOrNaN(
25
+ Number(year),
26
+ Number(month),
27
+ Number(day),
28
+ Number(hour),
29
+ Number(minute),
30
+ Number(second ?? 0),
31
+ millis,
32
+ );
33
+ if (Number.isNaN(utc)) return NaN;
34
+
35
+ const offsetMs = offset === "Z" ? 0 : offsetToMillis(offset);
36
+ if (offsetMs === undefined) return NaN;
37
+
38
+ return utc - offsetMs;
39
+ }
40
+
41
+ function utcOrNaN(year, month, day, hour, minute, second, millis) {
42
+ if (month < 1 || month > 12 || day < 1 || hour > 23 || minute > 59 || second > 59) return NaN;
43
+
44
+ const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second, millis));
45
+
46
+ // Date.UTC rolls an impossible day (for example 31 February) into the next
47
+ // month. Reject it by checking the fields round-trip unchanged.
48
+ if (
49
+ date.getUTCFullYear() !== year ||
50
+ date.getUTCMonth() !== month - 1 ||
51
+ date.getUTCDate() !== day
52
+ ) {
53
+ return NaN;
54
+ }
55
+
56
+ return date.getTime();
57
+ }
58
+
59
+ function offsetToMillis(text) {
60
+ const sign = text[0] === "+" ? 1 : -1;
61
+ const hours = Number(text.slice(1, 3));
62
+ const minutes = Number(text.slice(4, 6));
63
+ if (hours > 23 || minutes > 59) return undefined;
64
+ return sign * (hours * 60 + minutes) * 60_000;
65
+ }