@parmana/verifier 1.6.3 → 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/src/verify.mjs CHANGED
@@ -1,326 +1,343 @@
1
- // Offline verification of an Execution Trust Record.
2
- //
3
- // Checks run on the record alone:
4
- // 1. trustRecordHash equals the SHA-256 (hex) of the canonical record fields.
5
- // 2. signature is a valid signature over those canonical bytes, made by a
6
- // trusted key named by signature.keyId (see trust.mjs for the trust rules).
7
- // 3. If signatures is present, it is a hybrid envelope: at least two entries,
8
- // no repeated algorithm, and every entry verifies over the message for
9
- // its schemaVersion (default 2). Any failure makes the record invalid.
10
- // A record with schemaVersion 3 must carry a hybrid envelope.
11
- // 4. If a cutover is given (options.hybridRequiredFrom, from the trust
12
- // file), a record created at or after it must pass check 3.
13
- //
14
- // Schema versions:
15
- // 2 the message is the canonical fields plus schemaVersion.
16
- // 3 the message is every top-level field except signature, signatures,
17
- // schemaVersion, verifications and receipts, plus schemaVersion.
18
- // Fields outside the version 2 message, such as updatedAt, are covered.
19
- //
20
- // The result's coverage is "full" when a version 3 envelope verifies, and
21
- // "legacy" otherwise. Records with other schemaVersion values are invalid.
22
- //
23
- // Supported algorithms:
24
- // ed25519 Ed25519 keys. Messages over 4096 bytes may be signed as a
25
- // commitment: the prefix below followed by the SHA-512 digest of
26
- // the canonical bytes. A raw signature over a large message is
27
- // also accepted. A commitment over a small message is rejected.
28
- // dilithium3 ML-DSA-65 keys (Node 24+ with OpenSSL 3.5+). Raw signatures
29
- // only.
30
- //
31
- // The verifier reads no environment variables, no private keys, and makes no
32
- // network calls.
33
-
34
- import {
35
- createHash,
36
- createPublicKey,
37
- generateKeyPairSync,
38
- verify as verifySignature,
39
- } from "node:crypto";
40
- import { canonicalBytes } from "./canonical.mjs";
41
- import { fingerprintOfKey } from "./trust.mjs";
42
-
43
- const ALGORITHMS = {
44
- ed25519: { keyType: "ed25519", commitment: true },
45
- dilithium3: { keyType: "ml-dsa-65", commitment: false },
46
- };
47
-
48
- const RAW_MESSAGE_LIMIT_BYTES = 4096;
49
- const COMMITMENT_PREFIX = Buffer.from("PARMANA-ED25519-LARGE-MESSAGE-V1\0", "utf8");
50
- const DEFAULT_SCHEMA_VERSION = 2;
51
- const WHOLE_RECORD_SCHEMA_VERSION = 3;
52
- const SUPPORTED_SCHEMA_VERSIONS = [2, 3];
53
-
54
- // Top-level fields that a version 3 message does not cover. verifications and
55
- // receipts are attached after signing; receipts carry their own signature.
56
- const WHOLE_RECORD_EXCLUDED_FIELDS = ["signature", "signatures", "schemaVersion", "verifications", "receipts"];
57
-
58
- // The fields covered by trustRecordHash and the legacy signature.
59
- export function canonicalTrustRecord(record) {
60
- return {
61
- trustRecordId: record.trustRecordId,
62
- businessTransactionId: record.businessTransactionId,
63
- transaction: record.transaction,
64
- authorization: record.authorization,
65
- overrides: record.overrides,
66
- executions: record.executions,
67
- createdAt: record.createdAt,
68
- };
69
- }
70
-
71
- // The message a hybrid envelope of the given schema version signs.
72
- export function hybridMessage(record, schemaVersion) {
73
- if (schemaVersion === WHOLE_RECORD_SCHEMA_VERSION) {
74
- const covered = Object.entries(record).filter(([key]) => !WHOLE_RECORD_EXCLUDED_FIELDS.includes(key));
75
-
76
- return canonicalBytes({ ...Object.fromEntries(covered), schemaVersion });
77
- }
78
-
79
- return canonicalBytes({ ...canonicalTrustRecord(record), schemaVersion });
80
- }
81
-
82
- // trustKeys is a Map from keyId to a trust entry, as returned by parseTrustSet.
83
- // options.hybridRequiredFrom is an ISO-8601 UTC timestamp from the trust file
84
- // (see parseTrustPolicy); records created at or after it need a hybrid envelope.
85
- export function verifyExecutionTrustRecord(record, trustKeys, options = {}) {
86
- const errors = [];
87
-
88
- if (!isObject(record)) {
89
- return { valid: false, coverage: "legacy", errors: ["record must be a JSON object"], checks: {} };
90
- }
91
-
92
- const legacyMessage = canonicalBytes(canonicalTrustRecord(record));
93
- const expectedHash = createHash("sha256").update(legacyMessage).digest("hex");
94
- const hashValid = expectedHash === record.trustRecordHash;
95
-
96
- if (!hashValid) {
97
- errors.push(`trustRecordHash mismatch: expected ${expectedHash}.`);
98
- }
99
-
100
- const context = { trustKeys, createdAt: record.createdAt };
101
-
102
- const signatureValid = verifyLegacySignature(record.signature, legacyMessage, context, errors);
103
-
104
- const checks = { trustRecordHash: hashValid, signature: signatureValid };
105
-
106
- const schemaVersion = record.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
107
- const hasEnvelope = record.signatures !== undefined && record.signatures !== null;
108
-
109
- if (!SUPPORTED_SCHEMA_VERSIONS.includes(schemaVersion)) {
110
- errors.push(`unsupported schemaVersion ${String(schemaVersion)}: expected 2 or 3.`);
111
- checks.hybridSignatures = false;
112
- } else if (hasEnvelope) {
113
- checks.hybridSignatures = verifyHybridSignatures(record.signatures, hybridMessage(record, schemaVersion), context, errors);
114
- } else if (schemaVersion === WHOLE_RECORD_SCHEMA_VERSION) {
115
- errors.push("schemaVersion 3 record has no signatures array: a whole-record signature is required.");
116
- checks.hybridSignatures = false;
117
- }
118
-
119
- // A record with no hybrid envelope is accepted only when the caller allows
120
- // it: explicitly (allowLegacyOnly), or because it predates a cutover the
121
- // caller supplied. Otherwise it is refused, so a stripped envelope never
122
- // passes by default.
123
- const cutoverGiven = options.hybridRequiredFrom !== undefined && options.hybridRequiredFrom !== null;
124
- const predatesCutover = cutoverGiven && !isAtOrAfterCutover(record.createdAt, options.hybridRequiredFrom);
125
- // With a cutover, the cutover alone decides; allowLegacyOnly applies only when none is given.
126
- const legacyOnlyAllowed = cutoverGiven ? predatesCutover : options.allowLegacyOnly === true;
127
-
128
- // A record created at or after the cutover needs a valid envelope, whether it
129
- // is missing or present but invalid.
130
- const atOrAfterCutover = cutoverGiven && !predatesCutover;
131
- if (atOrAfterCutover && checks.hybridSignatures !== true) {
132
- errors.push(`record created at or after hybridRequiredFrom (${options.hybridRequiredFrom}) has no valid hybrid envelope.`);
133
- } else if (checks.hybridSignatures === undefined && !legacyOnlyAllowed) {
134
- errors.push("record has no hybrid envelope: set hybridRequiredFrom or allowLegacyOnly to accept a legacy-only record.");
135
- }
136
-
137
- const envelopeOk = checks.hybridSignatures === undefined ? legacyOnlyAllowed : checks.hybridSignatures;
138
-
139
- const coverage = schemaVersion === WHOLE_RECORD_SCHEMA_VERSION && checks.hybridSignatures === true ? "full" : "legacy";
140
-
141
- return {
142
- valid: hashValid && signatureValid && envelopeOk,
143
- coverage,
144
- errors,
145
- checks,
146
- };
147
- }
148
-
149
- function isAtOrAfterCutover(createdAt, hybridRequiredFrom) {
150
- if (hybridRequiredFrom === undefined) {
151
- return false;
152
- }
153
-
154
- const created = Date.parse(createdAt);
155
-
156
- return !Number.isNaN(created) && created >= Date.parse(hybridRequiredFrom);
157
- }
158
-
159
- function verifyLegacySignature(signature, message, context, errors) {
160
- if (!isSignatureEntry(signature)) {
161
- errors.push("signature is missing or malformed.");
162
- return false;
163
- }
164
-
165
- return verifyEntry(signature, message, context, errors);
166
- }
167
-
168
- function verifyHybridSignatures(entries, message, context, errors) {
169
- if (!Array.isArray(entries) || !entries.every(isSignatureEntry)) {
170
- errors.push("malformed signatures: expected an array of entries with string algorithm, keyId and signature.");
171
- return false;
172
- }
173
-
174
- if (entries.length < 2) {
175
- errors.push(`signatures array has ${entries.length} entr${entries.length === 1 ? "y" : "ies"}, need at least 2 for a hybrid record.`);
176
- return false;
177
- }
178
-
179
- const seen = new Set();
180
- let allValid = true;
181
-
182
- for (const entry of entries) {
183
- if (seen.has(entry.algorithm)) {
184
- errors.push(`duplicate algorithm in signatures array: ${entry.algorithm}.`);
185
- allValid = false;
186
- continue;
187
- }
188
-
189
- seen.add(entry.algorithm);
190
- allValid = verifyEntry(entry, message, context, errors) && allValid;
191
- }
192
-
193
- return allValid;
194
- }
195
-
196
- // Accepts { algorithm, keyId, signature } entries. The legacy `signature` field
197
- // uses `value` for the same three parts, so that is accepted too.
198
- function verifyEntry(entry, message, context, errors) {
199
- const signatureText = entry.signature ?? entry.value;
200
- const algorithm = ALGORITHMS[entry.algorithm];
201
-
202
- if (!algorithm) {
203
- errors.push(`unsupported algorithm: ${entry.algorithm}.`);
204
- return false;
205
- }
206
-
207
- const trusted = context.trustKeys.get(entry.keyId);
208
-
209
- if (!trusted) {
210
- errors.push(`no trusted key for keyId "${entry.keyId}".`);
211
- return false;
212
- }
213
-
214
- if (trusted.algorithm !== entry.algorithm) {
215
- errors.push(`keyId "${entry.keyId}" is trusted for ${trusted.algorithm}, not ${entry.algorithm}.`);
216
- return false;
217
- }
218
-
219
- if (trusted.compromised) {
220
- errors.push(`keyId "${entry.keyId}" is marked compromised; its signatures are not accepted.`);
221
- return false;
222
- }
223
-
224
- if (!withinValidityWindow(trusted, context.createdAt)) {
225
- errors.push(`createdAt ${context.createdAt} is outside the validity window of keyId "${entry.keyId}".`);
226
- return false;
227
- }
228
-
229
- if (entry.algorithm === "dilithium3" && !mlDsaSupported()) {
230
- errors.push("algorithm dilithium3 needs Node 24 or later with OpenSSL 3.5 or later.");
231
- return false;
232
- }
233
-
234
- let publicKey;
235
-
236
- try {
237
- publicKey = createPublicKey(trusted.publicKeyPem);
238
- } catch {
239
- errors.push(`public key for keyId "${entry.keyId}" is not a valid PEM key.`);
240
- return false;
241
- }
242
-
243
- if (fingerprintOfKey(publicKey) !== trusted.fingerprint) {
244
- errors.push(`public key for keyId "${entry.keyId}" does not match its pinned fingerprint.`);
245
- return false;
246
- }
247
-
248
- if (publicKey.asymmetricKeyType !== algorithm.keyType) {
249
- errors.push(`public key for keyId "${entry.keyId}" has type ${publicKey.asymmetricKeyType}, expected ${algorithm.keyType}.`);
250
- return false;
251
- }
252
-
253
- const sigBytes = Buffer.from(signatureText, "base64");
254
-
255
- const rawValid = safeVerify(message, sigBytes, publicKey);
256
-
257
- const commitmentValid =
258
- !rawValid &&
259
- algorithm.commitment &&
260
- message.length > RAW_MESSAGE_LIMIT_BYTES &&
261
- safeVerify(commitment(message), sigBytes, publicKey);
262
-
263
- if (!rawValid && !commitmentValid) {
264
- errors.push(`signature verification failed for keyId "${entry.keyId}" (${entry.algorithm}).`);
265
- return false;
266
- }
267
-
268
- return true;
269
- }
270
-
271
- function withinValidityWindow(trusted, createdAt) {
272
- const at = Date.parse(createdAt);
273
-
274
- if (Number.isNaN(at)) {
275
- return false;
276
- }
277
-
278
- if (at < Date.parse(trusted.validFrom)) {
279
- return false;
280
- }
281
-
282
- return trusted.validUntil === null || at < Date.parse(trusted.validUntil);
283
- }
284
-
285
- function commitment(message) {
286
- return Buffer.concat([
287
- COMMITMENT_PREFIX,
288
- createHash("sha512").update(message).digest(),
289
- ]);
290
- }
291
-
292
- function safeVerify(message, sigBytes, publicKey) {
293
- try {
294
- return verifySignature(null, Buffer.from(message), publicKey, sigBytes);
295
- } catch {
296
- return false;
297
- }
298
- }
299
-
300
- let mlDsa;
301
-
302
- function mlDsaSupported() {
303
- if (mlDsa === undefined) {
304
- try {
305
- generateKeyPairSync("ml-dsa-65");
306
- mlDsa = true;
307
- } catch {
308
- mlDsa = false;
309
- }
310
- }
311
-
312
- return mlDsa;
313
- }
314
-
315
- function isSignatureEntry(value) {
316
- return (
317
- isObject(value) &&
318
- typeof value.algorithm === "string" &&
319
- typeof value.keyId === "string" &&
320
- (typeof value.signature === "string" || typeof value.value === "string")
321
- );
322
- }
323
-
324
- function isObject(value) {
325
- return value !== null && typeof value === "object" && !Array.isArray(value);
326
- }
1
+ // Offline verification of an Execution Trust Record.
2
+ //
3
+ // Checks run on the record alone:
4
+ // 1. trustRecordHash equals the SHA-256 (hex) of the canonical record fields.
5
+ // 2. signature is a valid signature over those canonical bytes, made by a
6
+ // trusted key named by signature.keyId (see trust.mjs for the trust rules).
7
+ // 3. If signatures is present, it is a hybrid envelope: at least two entries,
8
+ // no repeated algorithm, and every entry verifies over the message for
9
+ // its schemaVersion (default 2). Any failure makes the record invalid.
10
+ // A record with schemaVersion 3 must carry a hybrid envelope.
11
+ // 4. If a cutover is given (options.hybridRequiredFrom, from the trust
12
+ // file), a record created at or after it must pass check 3.
13
+ //
14
+ // Schema versions:
15
+ // 2 the message is the canonical fields plus schemaVersion.
16
+ // 3 the message is every top-level field except signature, signatures,
17
+ // schemaVersion, verifications and receipts, plus schemaVersion.
18
+ // Fields outside the version 2 message, such as updatedAt, are covered.
19
+ //
20
+ // The result's coverage is "full" when a version 3 envelope verifies, and
21
+ // "legacy" otherwise. Records with other schemaVersion values are invalid.
22
+ //
23
+ // Supported algorithms:
24
+ // ed25519 Ed25519 keys. Messages over 4096 bytes may be signed as a
25
+ // commitment: the prefix below followed by the SHA-512 digest of
26
+ // the canonical bytes. A raw signature over a large message is
27
+ // also accepted. A commitment over a small message is rejected.
28
+ // dilithium3 ML-DSA-65 keys (Node 24+ with OpenSSL 3.5+). Raw signatures
29
+ // only.
30
+ //
31
+ // The verifier reads no environment variables, no private keys, and makes no
32
+ // network calls.
33
+
34
+ import {
35
+ createHash,
36
+ createPublicKey,
37
+ generateKeyPairSync,
38
+ verify as verifySignature,
39
+ } from "node:crypto";
40
+ import { canonicalBytes } from "./canonical.mjs";
41
+ import { fingerprintOfKey } from "./trust.mjs";
42
+ import { parseTimestamp } from "./timestamp.mjs";
43
+
44
+ const ALGORITHMS = {
45
+ ed25519: { keyType: "ed25519", commitment: true },
46
+ dilithium3: { keyType: "ml-dsa-65", commitment: false },
47
+ };
48
+
49
+ const RAW_MESSAGE_LIMIT_BYTES = 4096;
50
+ const COMMITMENT_PREFIX = Buffer.from("PARMANA-ED25519-LARGE-MESSAGE-V1\0", "utf8");
51
+ const DEFAULT_SCHEMA_VERSION = 2;
52
+ const WHOLE_RECORD_SCHEMA_VERSION = 3;
53
+ const SUPPORTED_SCHEMA_VERSIONS = [2, 3];
54
+
55
+ // Top-level fields that a version 3 message does not cover. verifications and
56
+ // receipts are attached after signing; receipts carry their own signature.
57
+ const WHOLE_RECORD_EXCLUDED_FIELDS = ["signature", "signatures", "schemaVersion", "verifications", "receipts"];
58
+
59
+ // The fields covered by trustRecordHash and the legacy signature.
60
+ export function canonicalTrustRecord(record) {
61
+ return {
62
+ trustRecordId: record.trustRecordId,
63
+ businessTransactionId: record.businessTransactionId,
64
+ transaction: record.transaction,
65
+ authorization: record.authorization,
66
+ overrides: record.overrides,
67
+ executions: record.executions,
68
+ createdAt: record.createdAt,
69
+ };
70
+ }
71
+
72
+ // The message a hybrid envelope of the given schema version signs.
73
+ export function hybridMessage(record, schemaVersion) {
74
+ if (schemaVersion === WHOLE_RECORD_SCHEMA_VERSION) {
75
+ const covered = Object.entries(record).filter(([key]) => !WHOLE_RECORD_EXCLUDED_FIELDS.includes(key));
76
+
77
+ return canonicalBytes({ ...Object.fromEntries(covered), schemaVersion });
78
+ }
79
+
80
+ return canonicalBytes({ ...canonicalTrustRecord(record), schemaVersion });
81
+ }
82
+
83
+ // trustKeys is a Map from keyId to a trust entry, as returned by parseTrustSet.
84
+ // options.hybridRequiredFrom is an ISO-8601 UTC timestamp from the trust file
85
+ // (see parseTrustPolicy); records created at or after it need a hybrid envelope.
86
+ export function verifyExecutionTrustRecord(record, trustKeys, options = {}) {
87
+ const errors = [];
88
+
89
+ if (!isObject(record)) {
90
+ return { valid: false, coverage: "legacy", errors: ["record must be a JSON object"], checks: {} };
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
+
109
+ const legacyMessage = canonicalBytes(canonicalTrustRecord(record));
110
+ const expectedHash = createHash("sha256").update(legacyMessage).digest("hex");
111
+ const hashValid = expectedHash === record.trustRecordHash;
112
+
113
+ if (!hashValid) {
114
+ errors.push(`trustRecordHash mismatch: expected ${expectedHash}.`);
115
+ }
116
+
117
+ const context = { trustKeys, createdAt: record.createdAt };
118
+
119
+ const signatureValid = verifyLegacySignature(record.signature, legacyMessage, context, errors);
120
+
121
+ const checks = { trustRecordHash: hashValid, signature: signatureValid };
122
+
123
+ const schemaVersion = record.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
124
+ const hasEnvelope = record.signatures !== undefined && record.signatures !== null;
125
+
126
+ if (!SUPPORTED_SCHEMA_VERSIONS.includes(schemaVersion)) {
127
+ errors.push(`unsupported schemaVersion ${String(schemaVersion)}: expected 2 or 3.`);
128
+ checks.hybridSignatures = false;
129
+ } else if (hasEnvelope) {
130
+ checks.hybridSignatures = verifyHybridSignatures(record.signatures, hybridMessage(record, schemaVersion), context, errors);
131
+ } else if (schemaVersion === WHOLE_RECORD_SCHEMA_VERSION) {
132
+ errors.push("schemaVersion 3 record has no signatures array: a whole-record signature is required.");
133
+ checks.hybridSignatures = false;
134
+ }
135
+
136
+ // A record with no hybrid envelope is accepted only when the caller allows
137
+ // it: explicitly (allowLegacyOnly), or because it predates a cutover the
138
+ // caller supplied. Otherwise it is refused, so a stripped envelope never
139
+ // passes by default.
140
+ const cutoverGiven = options.hybridRequiredFrom !== undefined && options.hybridRequiredFrom !== null;
141
+ const predatesCutover = cutoverGiven && !isAtOrAfterCutover(record.createdAt, options.hybridRequiredFrom);
142
+ // With a cutover, the cutover alone decides; allowLegacyOnly applies only when none is given.
143
+ const legacyOnlyAllowed = cutoverGiven ? predatesCutover : options.allowLegacyOnly === true;
144
+
145
+ // A record created at or after the cutover needs a valid envelope, whether it
146
+ // is missing or present but invalid.
147
+ const atOrAfterCutover = cutoverGiven && !predatesCutover;
148
+ if (atOrAfterCutover && checks.hybridSignatures !== true) {
149
+ errors.push(`record created at or after hybridRequiredFrom (${options.hybridRequiredFrom}) has no valid hybrid envelope.`);
150
+ } else if (checks.hybridSignatures === undefined && !legacyOnlyAllowed) {
151
+ errors.push("record has no hybrid envelope: set hybridRequiredFrom or allowLegacyOnly to accept a legacy-only record.");
152
+ }
153
+
154
+ const envelopeOk = checks.hybridSignatures === undefined ? legacyOnlyAllowed : checks.hybridSignatures;
155
+
156
+ const coverage = schemaVersion === WHOLE_RECORD_SCHEMA_VERSION && checks.hybridSignatures === true ? "full" : "legacy";
157
+
158
+ return {
159
+ valid: hashValid && signatureValid && envelopeOk,
160
+ coverage,
161
+ errors,
162
+ checks,
163
+ };
164
+ }
165
+
166
+ function isAtOrAfterCutover(createdAt, hybridRequiredFrom) {
167
+ if (hybridRequiredFrom === undefined) {
168
+ return false;
169
+ }
170
+
171
+ const created = parseTimestamp(createdAt);
172
+
173
+ return !Number.isNaN(created) && created >= parseTimestamp(hybridRequiredFrom);
174
+ }
175
+
176
+ function verifyLegacySignature(signature, message, context, errors) {
177
+ if (!isSignatureEntry(signature)) {
178
+ errors.push("signature is missing or malformed.");
179
+ return false;
180
+ }
181
+
182
+ return verifyEntry(signature, message, context, errors);
183
+ }
184
+
185
+ function verifyHybridSignatures(entries, message, context, errors) {
186
+ if (!Array.isArray(entries) || !entries.every(isSignatureEntry)) {
187
+ errors.push("malformed signatures: expected an array of entries with string algorithm, keyId and signature.");
188
+ return false;
189
+ }
190
+
191
+ if (entries.length < 2) {
192
+ errors.push(`signatures array has ${entries.length} entr${entries.length === 1 ? "y" : "ies"}, need at least 2 for a hybrid record.`);
193
+ return false;
194
+ }
195
+
196
+ const seen = new Set();
197
+ let allValid = true;
198
+
199
+ for (const entry of entries) {
200
+ if (seen.has(entry.algorithm)) {
201
+ errors.push(`duplicate algorithm in signatures array: ${entry.algorithm}.`);
202
+ allValid = false;
203
+ continue;
204
+ }
205
+
206
+ seen.add(entry.algorithm);
207
+ allValid = verifyEntry(entry, message, context, errors) && allValid;
208
+ }
209
+
210
+ return allValid;
211
+ }
212
+
213
+ // Accepts { algorithm, keyId, signature } entries. The legacy `signature` field
214
+ // uses `value` for the same three parts, so that is accepted too.
215
+ function verifyEntry(entry, message, context, errors) {
216
+ const signatureText = entry.signature ?? entry.value;
217
+ const algorithm = ALGORITHMS[entry.algorithm];
218
+
219
+ if (!algorithm) {
220
+ errors.push(`unsupported algorithm: ${entry.algorithm}.`);
221
+ return false;
222
+ }
223
+
224
+ const trusted = context.trustKeys.get(entry.keyId);
225
+
226
+ if (!trusted) {
227
+ errors.push(`no trusted key for keyId "${entry.keyId}".`);
228
+ return false;
229
+ }
230
+
231
+ if (trusted.algorithm !== entry.algorithm) {
232
+ errors.push(`keyId "${entry.keyId}" is trusted for ${trusted.algorithm}, not ${entry.algorithm}.`);
233
+ return false;
234
+ }
235
+
236
+ if (trusted.compromised) {
237
+ errors.push(`keyId "${entry.keyId}" is marked compromised; its signatures are not accepted.`);
238
+ return false;
239
+ }
240
+
241
+ if (!withinValidityWindow(trusted, context.createdAt)) {
242
+ errors.push(`createdAt ${context.createdAt} is outside the validity window of keyId "${entry.keyId}".`);
243
+ return false;
244
+ }
245
+
246
+ if (entry.algorithm === "dilithium3" && !mlDsaSupported()) {
247
+ errors.push("algorithm dilithium3 needs Node 24 or later with OpenSSL 3.5 or later.");
248
+ return false;
249
+ }
250
+
251
+ let publicKey;
252
+
253
+ try {
254
+ publicKey = createPublicKey(trusted.publicKeyPem);
255
+ } catch {
256
+ errors.push(`public key for keyId "${entry.keyId}" is not a valid PEM key.`);
257
+ return false;
258
+ }
259
+
260
+ if (fingerprintOfKey(publicKey) !== trusted.fingerprint) {
261
+ errors.push(`public key for keyId "${entry.keyId}" does not match its pinned fingerprint.`);
262
+ return false;
263
+ }
264
+
265
+ if (publicKey.asymmetricKeyType !== algorithm.keyType) {
266
+ errors.push(`public key for keyId "${entry.keyId}" has type ${publicKey.asymmetricKeyType}, expected ${algorithm.keyType}.`);
267
+ return false;
268
+ }
269
+
270
+ const sigBytes = Buffer.from(signatureText, "base64");
271
+
272
+ const rawValid = safeVerify(message, sigBytes, publicKey);
273
+
274
+ const commitmentValid =
275
+ !rawValid &&
276
+ algorithm.commitment &&
277
+ message.length > RAW_MESSAGE_LIMIT_BYTES &&
278
+ safeVerify(commitment(message), sigBytes, publicKey);
279
+
280
+ if (!rawValid && !commitmentValid) {
281
+ errors.push(`signature verification failed for keyId "${entry.keyId}" (${entry.algorithm}).`);
282
+ return false;
283
+ }
284
+
285
+ return true;
286
+ }
287
+
288
+ function withinValidityWindow(trusted, createdAt) {
289
+ const at = parseTimestamp(createdAt);
290
+
291
+ if (Number.isNaN(at)) {
292
+ return false;
293
+ }
294
+
295
+ if (at < parseTimestamp(trusted.validFrom)) {
296
+ return false;
297
+ }
298
+
299
+ return trusted.validUntil === null || at < parseTimestamp(trusted.validUntil);
300
+ }
301
+
302
+ function commitment(message) {
303
+ return Buffer.concat([
304
+ COMMITMENT_PREFIX,
305
+ createHash("sha512").update(message).digest(),
306
+ ]);
307
+ }
308
+
309
+ function safeVerify(message, sigBytes, publicKey) {
310
+ try {
311
+ return verifySignature(null, Buffer.from(message), publicKey, sigBytes);
312
+ } catch {
313
+ return false;
314
+ }
315
+ }
316
+
317
+ let mlDsa;
318
+
319
+ function mlDsaSupported() {
320
+ if (mlDsa === undefined) {
321
+ try {
322
+ generateKeyPairSync("ml-dsa-65");
323
+ mlDsa = true;
324
+ } catch {
325
+ mlDsa = false;
326
+ }
327
+ }
328
+
329
+ return mlDsa;
330
+ }
331
+
332
+ function isSignatureEntry(value) {
333
+ return (
334
+ isObject(value) &&
335
+ typeof value.algorithm === "string" &&
336
+ typeof value.keyId === "string" &&
337
+ (typeof value.signature === "string" || typeof value.value === "string")
338
+ );
339
+ }
340
+
341
+ function isObject(value) {
342
+ return value !== null && typeof value === "object" && !Array.isArray(value);
343
+ }