@parmana/verifier 1.6.2 → 2.0.0
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/LICENSE +202 -202
- package/NOTICE +1 -1
- package/README.md +241 -207
- package/bin/parmana-verify.mjs +53 -53
- package/package.json +40 -40
- package/src/canonical.mjs +35 -35
- package/src/timestamp.mjs +65 -0
- package/src/trust.mjs +121 -104
- package/src/verify.mjs +343 -312
package/package.json
CHANGED
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@parmana/verifier",
|
|
3
|
-
"version": "
|
|
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",
|
|
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.0",
|
|
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
|
+
}
|
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,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
|
@@ -1,104 +1,121 @@
|
|
|
1
|
-
// Trust set: the keys a verifier accepts, each pinned by fingerprint.
|
|
2
|
-
//
|
|
3
|
-
// A key is used only when all of these hold:
|
|
4
|
-
// - its PEM hashes to the pinned fingerprint (SHA-256 of the SPKI DER);
|
|
5
|
-
// - its algorithm matches the algorithm the signature names;
|
|
6
|
-
// - it is not marked compromised;
|
|
7
|
-
// - the record's createdAt falls within [validFrom, validUntil).
|
|
8
|
-
//
|
|
9
|
-
// The trust set is supplied by the caller. The verifier never fetches keys.
|
|
10
|
-
|
|
11
|
-
import { createHash, createPublicKey } from "node:crypto";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// a
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
throw new Error(`${at}.
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
throw new Error(`${at}.
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
1
|
+
// Trust set: the keys a verifier accepts, each pinned by fingerprint.
|
|
2
|
+
//
|
|
3
|
+
// A key is used only when all of these hold:
|
|
4
|
+
// - its PEM hashes to the pinned fingerprint (SHA-256 of the SPKI DER);
|
|
5
|
+
// - its algorithm matches the algorithm the signature names;
|
|
6
|
+
// - it is not marked compromised;
|
|
7
|
+
// - the record's createdAt falls within [validFrom, validUntil).
|
|
8
|
+
//
|
|
9
|
+
// The trust set is supplied by the caller. The verifier never fetches keys.
|
|
10
|
+
|
|
11
|
+
import { createHash, createPublicKey } from "node:crypto";
|
|
12
|
+
import { parseTimestamp } from "./timestamp.mjs";
|
|
13
|
+
|
|
14
|
+
export const ALGORITHM_NAMES = ["ed25519", "dilithium3"];
|
|
15
|
+
|
|
16
|
+
export function keyFingerprint(publicKeyPem) {
|
|
17
|
+
return fingerprintOfKey(createPublicKey(publicKeyPem));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function fingerprintOfKey(publicKey) {
|
|
21
|
+
const der = publicKey.export({ type: "spki", format: "der" });
|
|
22
|
+
return `sha256:${createHash("sha256").update(der).digest("hex")}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Parses a trust-set document into a Map keyId -> entry. Throws an Error with
|
|
26
|
+
// a readable message when the document is malformed.
|
|
27
|
+
export function parseTrustSet(document) {
|
|
28
|
+
return parseTrustPolicy(document).keys;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Parses a trust-set document into { keys, hybridRequiredFrom }. keys is the
|
|
32
|
+
// Map that parseTrustSet returns. hybridRequiredFrom is the optional cutover:
|
|
33
|
+
// a record created at or after it must carry a valid hybrid envelope. It is
|
|
34
|
+
// null when the document does not set one. It must be an ISO-8601 UTC
|
|
35
|
+
// timestamp ending in "Z", so that it means the same time everywhere.
|
|
36
|
+
export function parseTrustPolicy(document) {
|
|
37
|
+
if (document === null || typeof document !== "object" || !Array.isArray(document.keys)) {
|
|
38
|
+
throw new Error('trust set must be an object with a "keys" array');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let hybridRequiredFrom = null;
|
|
42
|
+
|
|
43
|
+
if (document.hybridRequiredFrom !== undefined && document.hybridRequiredFrom !== null) {
|
|
44
|
+
if (typeof document.hybridRequiredFrom !== "string" || !/Z$/.test(document.hybridRequiredFrom) || Number.isNaN(parseTimestamp(document.hybridRequiredFrom))) {
|
|
45
|
+
throw new Error('hybridRequiredFrom must be an ISO-8601 UTC timestamp ending in "Z"');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
hybridRequiredFrom = document.hybridRequiredFrom;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Off by default: a record with no hybrid envelope is refused unless the
|
|
52
|
+
// trust file explicitly allows legacy-only records.
|
|
53
|
+
if (document.allowLegacyOnly !== undefined && typeof document.allowLegacyOnly !== "boolean") {
|
|
54
|
+
throw new Error('allowLegacyOnly must be true or false');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const allowLegacyOnly = document.allowLegacyOnly === true;
|
|
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
|
+
|
|
67
|
+
const trustKeys = new Map();
|
|
68
|
+
|
|
69
|
+
for (const [index, entry] of document.keys.entries()) {
|
|
70
|
+
const at = `keys[${index}]`;
|
|
71
|
+
|
|
72
|
+
if (entry === null || typeof entry !== "object") {
|
|
73
|
+
throw new Error(`${at} must be an object`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (typeof entry.keyId !== "string" || entry.keyId === "") {
|
|
77
|
+
throw new Error(`${at}.keyId must be a non-empty string`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (trustKeys.has(entry.keyId)) {
|
|
81
|
+
throw new Error(`duplicate keyId "${entry.keyId}"`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!ALGORITHM_NAMES.includes(entry.algorithm)) {
|
|
85
|
+
throw new Error(`${at}.algorithm must be one of ${ALGORITHM_NAMES.join(", ")}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (typeof entry.publicKeyPem !== "string") {
|
|
89
|
+
throw new Error(`${at}.publicKeyPem must be a PEM string`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (typeof entry.fingerprint !== "string" || !/^sha256:[0-9a-f]{64}$/.test(entry.fingerprint)) {
|
|
93
|
+
throw new Error(`${at}.fingerprint must be "sha256:" followed by 64 lowercase hex digits`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (typeof entry.validFrom !== "string" || Number.isNaN(parseTimestamp(entry.validFrom))) {
|
|
97
|
+
throw new Error(`${at}.validFrom must be an ISO timestamp`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (entry.validUntil !== undefined && entry.validUntil !== null &&
|
|
101
|
+
(typeof entry.validUntil !== "string" || Number.isNaN(parseTimestamp(entry.validUntil)))) {
|
|
102
|
+
throw new Error(`${at}.validUntil must be an ISO timestamp or null`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (entry.compromised !== undefined && typeof entry.compromised !== "boolean") {
|
|
106
|
+
throw new Error(`${at}.compromised must be true or false`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
trustKeys.set(entry.keyId, {
|
|
110
|
+
keyId: entry.keyId,
|
|
111
|
+
algorithm: entry.algorithm,
|
|
112
|
+
publicKeyPem: entry.publicKeyPem,
|
|
113
|
+
fingerprint: entry.fingerprint,
|
|
114
|
+
validFrom: entry.validFrom,
|
|
115
|
+
validUntil: entry.validUntil ?? null,
|
|
116
|
+
compromised: entry.compromised ?? false,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { keys: trustKeys, hybridRequiredFrom, allowLegacyOnly, allowLegacyV2 };
|
|
121
|
+
}
|