@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 +4 -4
- package/bin/parmana-verify.mjs +1 -1
- package/package.json +2 -2
- package/src/timestamp.mjs +65 -0
- package/src/trust.mjs +13 -4
- package/src/verify.mjs +22 -5
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/pavancharak/parmana-verifier/actions/workflows/ci.yml) [](LICENSE) [](package.json)
|
|
4
4
|
|
|
5
|
-
|
|
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
|
|
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,
|
|
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
|
|
package/bin/parmana-verify.mjs
CHANGED
|
@@ -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": "
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
171
|
+
const created = parseTimestamp(createdAt);
|
|
155
172
|
|
|
156
|
-
return !Number.isNaN(created) && created >=
|
|
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 =
|
|
289
|
+
const at = parseTimestamp(createdAt);
|
|
273
290
|
|
|
274
291
|
if (Number.isNaN(at)) {
|
|
275
292
|
return false;
|
|
276
293
|
}
|
|
277
294
|
|
|
278
|
-
if (at <
|
|
295
|
+
if (at < parseTimestamp(trusted.validFrom)) {
|
|
279
296
|
return false;
|
|
280
297
|
}
|
|
281
298
|
|
|
282
|
-
return trusted.validUntil === null || at <
|
|
299
|
+
return trusted.validUntil === null || at < parseTimestamp(trusted.validUntil);
|
|
283
300
|
}
|
|
284
301
|
|
|
285
302
|
function commitment(message) {
|