@parmana/verifier 1.6.3 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  [![CI](https://github.com/pavancharak/parmana-verifier/actions/workflows/ci.yml/badge.svg)](https://github.com/pavancharak/parmana-verifier/actions/workflows/ci.yml) [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) [![Node](https://img.shields.io/badge/node-%3E%3D24-brightgreen)](package.json)
4
4
 
5
- Every action stays within what your business allowed, and Parmana keeps proof of what happened. This verifier checks that proof using only a file and the keys you trust. No network access, no account, no dependencies.
5
+ **Parmana makes sure AI agents take only the actions your business has approved, and keeps signed proof of what they did.** This verifier checks that proof using only a record file and the keys you trust. It needs no network access, no account and no dependencies.
6
6
 
7
- Parmana signs an **Execution Trust Record** for every action an agent takes through it. The record says what was authorized, what ran, and what the outcome was, and it is signed with keys. This verifier checks that a record has not been changed and that it was signed by keys you chose to trust. It does not ask Parmana's servers anything, so you do not have to trust them to check a record.
7
+ Parmana signs an **Execution Trust Record** for every action an agent takes through it. The record says what was authorized, what ran, and what the outcome was. This verifier checks that a record has not been changed and that it was signed by keys you chose to trust. It does not contact Parmana's servers, so you do not have to trust them to check a record.
8
8
 
9
9
  The records come from [Parmana](https://github.com/pavancharak/parmana-core), which signs them. The verifier works without the rest of Parmana: it needs only a record and a trust file.
10
10
 
@@ -70,7 +70,7 @@ npx @parmana/verifier <proof.json> <trust.json>
70
70
 
71
71
  From a clone of this repository, run the same commands with `node bin/parmana-verify.mjs` in place of `parmana-verify`.
72
72
 
73
- There is also a Python version in [`python/`](python/README.md). It checks the same records and gives the same results, except that it cannot check ML-DSA-65 (hybrid) signatures. Use the Node version for those.
73
+ There is also a Python version in [`python/`](python/README.md). It checks the same records and gives the same results, including ML-DSA-65 (hybrid) signatures when `cryptography` 48.0.0 or later is installed.
74
74
 
75
75
  ```sh
76
76
  pip install parmana-verifier
@@ -129,7 +129,7 @@ A record with no `signatures` array is a legacy-only record. The verifier refuse
129
129
  - the trust file sets `hybridRequiredFrom` and the record was created before that instant; or
130
130
  - the trust file sets `"allowLegacyOnly": true` and does not set `hybridRequiredFrom`.
131
131
 
132
- Otherwise the result is invalid, and the error says which setting would accept it. A record created at or after `hybridRequiredFrom` always needs a valid hybrid envelope, whatever `allowLegacyOnly` says. See [docs/FORMAT.md](docs/FORMAT.md#trust-set-trustjson).
132
+ Otherwise the result is invalid, and the error says which setting would accept it. A record with no `schemaVersion`, or with `schemaVersion` 2, is refused unless the trust file sets `"allowLegacyV2": true`, because it signs only seven top-level fields. A record created at or after `hybridRequiredFrom` always needs a valid hybrid envelope, whatever `allowLegacyOnly` says. See [docs/FORMAT.md](docs/FORMAT.md#trust-set-trustjson).
133
133
 
134
134
  ## Examples
135
135
 
@@ -46,7 +46,7 @@ try {
46
46
  process.exit(2);
47
47
  }
48
48
 
49
- const result = verifyExecutionTrustRecord(proof, policy.keys, { hybridRequiredFrom: policy.hybridRequiredFrom ?? undefined, allowLegacyOnly: policy.allowLegacyOnly });
49
+ const result = verifyExecutionTrustRecord(proof, policy.keys, { hybridRequiredFrom: policy.hybridRequiredFrom ?? undefined, allowLegacyOnly: policy.allowLegacyOnly, allowLegacyV2: policy.allowLegacyV2 });
50
50
 
51
51
  console.log(JSON.stringify(result));
52
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parmana/verifier",
3
- "version": "1.6.3",
3
+ "version": "2.0.1",
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",
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
18
  "examples": "node examples/run-all.mjs"
19
19
  },
20
20
  "engines": {
@@ -0,0 +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
+ }
package/src/trust.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  // The trust set is supplied by the caller. The verifier never fetches keys.
10
10
 
11
11
  import { createHash, createPublicKey } from "node:crypto";
12
+ import { parseTimestamp } from "./timestamp.mjs";
12
13
 
13
14
  export const ALGORITHM_NAMES = ["ed25519", "dilithium3"];
14
15
 
@@ -40,7 +41,7 @@ export function parseTrustPolicy(document) {
40
41
  let hybridRequiredFrom = null;
41
42
 
42
43
  if (document.hybridRequiredFrom !== undefined && document.hybridRequiredFrom !== null) {
43
- if (typeof document.hybridRequiredFrom !== "string" || !/Z$/.test(document.hybridRequiredFrom) || Number.isNaN(Date.parse(document.hybridRequiredFrom))) {
44
+ if (typeof document.hybridRequiredFrom !== "string" || !/Z$/.test(document.hybridRequiredFrom) || Number.isNaN(parseTimestamp(document.hybridRequiredFrom))) {
44
45
  throw new Error('hybridRequiredFrom must be an ISO-8601 UTC timestamp ending in "Z"');
45
46
  }
46
47
 
@@ -55,6 +56,14 @@ export function parseTrustPolicy(document) {
55
56
 
56
57
  const allowLegacyOnly = document.allowLegacyOnly === true;
57
58
 
59
+ // Off by default: a schema version 2 record (or one with no schemaVersion)
60
+ // is refused unless the trust file explicitly allows it.
61
+ if (document.allowLegacyV2 !== undefined && typeof document.allowLegacyV2 !== "boolean") {
62
+ throw new Error('allowLegacyV2 must be true or false');
63
+ }
64
+
65
+ const allowLegacyV2 = document.allowLegacyV2 === true;
66
+
58
67
  const trustKeys = new Map();
59
68
 
60
69
  for (const [index, entry] of document.keys.entries()) {
@@ -84,12 +93,12 @@ export function parseTrustPolicy(document) {
84
93
  throw new Error(`${at}.fingerprint must be "sha256:" followed by 64 lowercase hex digits`);
85
94
  }
86
95
 
87
- if (typeof entry.validFrom !== "string" || Number.isNaN(Date.parse(entry.validFrom))) {
96
+ if (typeof entry.validFrom !== "string" || Number.isNaN(parseTimestamp(entry.validFrom))) {
88
97
  throw new Error(`${at}.validFrom must be an ISO timestamp`);
89
98
  }
90
99
 
91
100
  if (entry.validUntil !== undefined && entry.validUntil !== null &&
92
- (typeof entry.validUntil !== "string" || Number.isNaN(Date.parse(entry.validUntil)))) {
101
+ (typeof entry.validUntil !== "string" || Number.isNaN(parseTimestamp(entry.validUntil)))) {
93
102
  throw new Error(`${at}.validUntil must be an ISO timestamp or null`);
94
103
  }
95
104
 
@@ -108,5 +117,5 @@ export function parseTrustPolicy(document) {
108
117
  });
109
118
  }
110
119
 
111
- return { keys: trustKeys, hybridRequiredFrom, allowLegacyOnly };
120
+ return { keys: trustKeys, hybridRequiredFrom, allowLegacyOnly, allowLegacyV2 };
112
121
  }
package/src/verify.mjs CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  } from "node:crypto";
40
40
  import { canonicalBytes } from "./canonical.mjs";
41
41
  import { fingerprintOfKey } from "./trust.mjs";
42
+ import { parseTimestamp } from "./timestamp.mjs";
42
43
 
43
44
  const ALGORITHMS = {
44
45
  ed25519: { keyType: "ed25519", commitment: true },
@@ -89,6 +90,22 @@ export function verifyExecutionTrustRecord(record, trustKeys, options = {}) {
89
90
  return { valid: false, coverage: "legacy", errors: ["record must be a JSON object"], checks: {} };
90
91
  }
91
92
 
93
+ // Schema version 2 signs only seven top-level fields. A record with no
94
+ // schemaVersion is treated as version 2. Both are refused unless the caller
95
+ // opts in with allowLegacyV2, so a partially covered record is never
96
+ // accepted by default.
97
+ const declaredVersion = record.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
98
+ if (declaredVersion === 2 && options.allowLegacyV2 !== true) {
99
+ return {
100
+ valid: false,
101
+ coverage: "legacy",
102
+ errors: [
103
+ "schemaVersion 2 (or no schemaVersion) covers only seven top-level fields and is refused by default: set allowLegacyV2 to accept it, or re-issue the record as schemaVersion 3.",
104
+ ],
105
+ checks: {},
106
+ };
107
+ }
108
+
92
109
  const legacyMessage = canonicalBytes(canonicalTrustRecord(record));
93
110
  const expectedHash = createHash("sha256").update(legacyMessage).digest("hex");
94
111
  const hashValid = expectedHash === record.trustRecordHash;
@@ -151,9 +168,9 @@ function isAtOrAfterCutover(createdAt, hybridRequiredFrom) {
151
168
  return false;
152
169
  }
153
170
 
154
- const created = Date.parse(createdAt);
171
+ const created = parseTimestamp(createdAt);
155
172
 
156
- return !Number.isNaN(created) && created >= Date.parse(hybridRequiredFrom);
173
+ return !Number.isNaN(created) && created >= parseTimestamp(hybridRequiredFrom);
157
174
  }
158
175
 
159
176
  function verifyLegacySignature(signature, message, context, errors) {
@@ -269,17 +286,17 @@ function verifyEntry(entry, message, context, errors) {
269
286
  }
270
287
 
271
288
  function withinValidityWindow(trusted, createdAt) {
272
- const at = Date.parse(createdAt);
289
+ const at = parseTimestamp(createdAt);
273
290
 
274
291
  if (Number.isNaN(at)) {
275
292
  return false;
276
293
  }
277
294
 
278
- if (at < Date.parse(trusted.validFrom)) {
295
+ if (at < parseTimestamp(trusted.validFrom)) {
279
296
  return false;
280
297
  }
281
298
 
282
- return trusted.validUntil === null || at < Date.parse(trusted.validUntil);
299
+ return trusted.validUntil === null || at < parseTimestamp(trusted.validUntil);
283
300
  }
284
301
 
285
302
  function commitment(message) {