@typedstandards/verify-core 0.10.0 → 0.12.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/CHANGELOG.md CHANGED
@@ -6,6 +6,107 @@ references are to `npstorey/civic-ai-tools-website` (#119 is the offline-crypto
6
6
  hardening arc; #116 is the standalone-verifier arc this package was extracted
7
7
  in).
8
8
 
9
+ ## 0.12.0 — 2026-09-24
10
+
11
+ The certificate-chain split (typedstandards#100, shipped in #106). **A minor bump** — `ChainFailReason`
12
+ and `Rfc3161FailReason` each gain a member, so a consumer with an exhaustive `switch` or `Record` over
13
+ either needs the new case.
14
+
15
+ - **A chain link signed with an algorithm the validator does not implement is no longer reported as an
16
+ invalid signature.** `verifyCertChainToAnchor` checks each link's signature algorithm at the step
17
+ where it verifies the link's signature:
18
+ - an algorithm it does not implement (anything but RSASSA-PKCS1-v1_5 with SHA-256, SHA-384 or
19
+ SHA-512; an ECDSA-signed intermediate, say) reads the new reason `link_algorithm_unsupported`. It
20
+ used to read `link_signature_invalid`;
21
+ - an RSA link whose signature does not verify keeps `link_signature_invalid`;
22
+ - no other reason changes order, and `verifyCertSignatureRsa` is unchanged.
23
+ - **`verifyRfc3161Timestamp` reports that link as `chain_algorithm_unsupported`**, a new reason, where
24
+ it used to report `chain_signature_invalid`. `RFC3161_FAIL_REASONS` holds fourteen codes.
25
+ `chain_signature_invalid` now means only a link whose signature does not verify: a fault in the
26
+ token, not a limit of the verifier. `verified` is unchanged, and no request is added.
27
+ - **On typedstandards.org** (the site's verifier, live since #106; not part of this package's API):
28
+ - **a timestamp whose chain has a link with an invalid signature (`chain_signature_invalid`) now
29
+ fails the package, where it was a caveat.** `chain_algorithm_unsupported` is a caveat, as the
30
+ verifier's other policy limits are, and the #7 row reads amber for every caveat-class reason
31
+ (typedstandards#104);
32
+ - **bundle mode requests nothing.** A fully offline run gives `verifyRecord` a fetcher that sends no
33
+ request, so a file the package references is not fetched and reads "not checked offline", and no
34
+ transparency-log lookup is sent (typedstandards#105). A consumer that wants the same passes such a
35
+ fetcher as `VerifyDeps.fetch`.
36
+
37
+ ## 0.11.0 — 2026-09-22
38
+
39
+ The verifier follow-ons (anchored at `typedstandards#98`): specification check #6, the ratified
40
+ `attestation/revises/v1` (specification v0.1.10), one fetch per referenced file, and a timestamp result
41
+ that tells a token that does not verify from one whose only fault is its chain. **A minor bump** — new
42
+ exports, `VerifyResult` gains a field, and `Rfc3161FailReason` gains a member, so a consumer that
43
+ builds a `VerifyResult` literal (a test double, say), or has an exhaustive `switch` or `Record` over
44
+ the reasons, needs the new cases.
45
+
46
+ - **Check #6, `metadata.signingKeyId` consistency (`checkSigningKeyIdConsistency`,
47
+ `SIGNING_KEY_ID_CONSISTENCY_STATUSES`; typedstandards#88).** Specification §9.2 check #6 was
48
+ implemented nowhere. It compares the signature envelope's `kid` with the package's
49
+ `metadata.signingKeyId` and reports one of:
50
+ - `ok` — both present and equal;
51
+ - `signingKeyId_mismatch` — both present and different; the one failing status (§8.3.1: the field
52
+ MUST equal the envelope `kid`). The result carries `kid` and `signingKeyId`;
53
+ - `kid_absent` — the envelope carries no `kid`; legitimate under a key-derived identifier, and on
54
+ legacy packages, which carry neither field;
55
+ - `signingKeyId_absent` — a `kid` that the signed bytes do not name, so the check cannot confirm
56
+ it.
57
+
58
+ `signer.identifier` is not compared: under a key-derived identifier the `kid` SHOULD be the
59
+ identifier, which is not a condition of this check. `VerifyResult` gains `signingKeyIdConsistency`,
60
+ null when there is no package, no signature envelope, or a malformed one. The captured and
61
+ published packages measured for this change (the three captured commitment bundles, the
62
+ self-certified interop fixture, the two ADR-0028 eval-run packages, and two published example
63
+ bundles under a key-derived identifier) read `ok`, except the legacy capture, which carries neither
64
+ field and reads `kid_absent`.
65
+ - **Check #12 registers `attestation/revises/v1` (typedstandards#96).** The ratified set is sixteen
66
+ attestation sub-types plus `content/analysis/v1`; a node of that type read `unknown_type` and now
67
+ reads `ok`.
68
+ - **One fetch per BlobRef URL per `verifyRecord` (typedstandards#90).** Under `raw-bytes/v1` with a
69
+ BlobRef `output`, checks #9 and #4 each fetched the file. They now share one request per URL within a
70
+ `verifyRecord` call, and report exactly what they reported before, including when the fetch fails and
71
+ when the bytes do not match. No public signature changes, and no request is added.
72
+ - **Check #7 evaluates the TSA signature when the chain does not reach a pinned root
73
+ (typedstandards#94).** `verifyRfc3161Timestamp` used to return at the chain step, so for a token
74
+ whose chain did not reach a pinned anchor `signatureValid` read `null` whether the signature was
75
+ genuine or not. It now reads `true` or `false` there as well. For a consumer:
76
+ - `signatureValid` is `null` when an earlier step returned first (`parse_error`,
77
+ `unexpected_algorithm`, `imprint_mismatch`, `no_message_digest`, `content_not_bound`,
78
+ `no_signing_cert`, `eku_not_timestamping`), or when the signing cert's key is not a P-384 key.
79
+ `true` means the signature verifies under the embedded signing cert's key; that key is trusted
80
+ only when `chainVerified` is `true`. `false` means a P-384 key under which the signature does
81
+ not verify, including a malformed signature encoding;
82
+ - `reason` reports the token's own faults ahead of the chain's: `genTime_outside_validity`, then
83
+ `unexpected_algorithm` for a signing-cert key that is not P-384, then `signature_invalid`, then
84
+ the chain reason. A token with an unpinned chain and a signature that does not verify used to
85
+ read `untrusted_root` (or another chain reason) and now reads `signature_invalid`. A signing-cert
86
+ key that is not P-384 used to read the chain reason, or `signature_invalid` under a pinned chain,
87
+ and now reads `unexpected_algorithm`. `untrusted_root`, `chain_incomplete`,
88
+ `chain_signature_invalid` and `chain_outside_validity` are now reported only for a token whose
89
+ TSA signature verifies and whose `genTime` is within the signing cert's validity;
90
+ - **new reason `chain_outside_validity`:** an intermediate or root certificate not valid at
91
+ `genTime`, a chain fault. `genTime_outside_validity` now means only the signing cert. A token
92
+ whose signing cert is valid at `genTime` and whose intermediate or root is not used to read
93
+ `genTime_outside_validity` and now reads `chain_outside_validity`;
94
+ - **the message imprint is compared before the SignerInfo algorithms:** a token that does not bind
95
+ this package's hash reads `imprint_mismatch` whatever algorithms it uses. One that did so with a
96
+ digest or signature algorithm outside the checked set used to read `unexpected_algorithm`, and
97
+ `imprintMatches` was `null` for any `unexpected_algorithm` result from that check; it is now
98
+ `true` there;
99
+ - each reason now names one kind of fault, so a consumer can classify a token by `reason` alone;
100
+ - `verified` is unchanged: `true` only for a fully verified token chained to a pinned anchor. No
101
+ request is added.
102
+ - **`KNOWN_TYPE_URIS` is exported.** The type URIs check #12 resolves `ok` (`content/analysis/v1` and
103
+ the sixteen attestation sub-types), frozen, so a check outside this package can compare them with the
104
+ specification's list.
105
+ - **`RFC3161_FAIL_REASONS` is exported.** The thirteen reasons `verifyRfc3161Timestamp` can return,
106
+ as a frozen array; `Rfc3161FailReason` is now derived from it. It gains one member,
107
+ `chain_outside_validity`, so a consumer with an exhaustive `switch` or `Record` over it needs the
108
+ new case.
109
+
9
110
  ## 0.10.0 — 2026-09-22
10
111
 
11
112
  Specification v0.1.9 (hub ADR-0029 and ADR-0030, anchored at `typedstandards#77`): a Producer Profile
package/dist/checks.d.ts CHANGED
@@ -102,6 +102,7 @@ export interface ContentProfileCheck {
102
102
  * integrity failure: both labels are signature-covered.
103
103
  */
104
104
  export declare function checkContentProfile(pkg: Record<string, unknown>): ContentProfileCheck;
105
+ export declare const KNOWN_TYPE_URIS: readonly string[];
105
106
  export declare const TYPE_RESOLUTION_STATUSES: readonly ["ok", "implicit", "unknown_type"];
106
107
  export type TypeResolutionStatus = (typeof TYPE_RESOLUTION_STATUSES)[number];
107
108
  export interface TypeResolution {
@@ -115,6 +116,46 @@ export interface TypeResolution {
115
116
  * and renders as such — it does NOT fail verification.
116
117
  */
117
118
  export declare function resolvePackageType(pkg: Record<string, unknown>): TypeResolution;
119
+ export declare const SIGNING_KEY_ID_CONSISTENCY_STATUSES: readonly ["ok", "signingKeyId_mismatch", "kid_absent", "signingKeyId_absent"];
120
+ export type SigningKeyIdConsistencyStatus = (typeof SIGNING_KEY_ID_CONSISTENCY_STATUSES)[number];
121
+ export interface SigningKeyIdConsistencyCheck {
122
+ status: SigningKeyIdConsistencyStatus;
123
+ /** On `signingKeyId_mismatch`: the signature envelope's `kid`. */
124
+ kid?: string;
125
+ /** On `signingKeyId_mismatch`: `metadata.signingKeyId` as carried, when it is
126
+ * a string. */
127
+ signingKeyId?: string;
128
+ }
129
+ /**
130
+ * Check #6 — the signature envelope's `kid` against `metadata.signingKeyId`
131
+ * (spec §9.2 check #6, §8.3.1). The package's `signingKeyId` is inside the
132
+ * signed bytes and the envelope's `kid` is not, so a `kid` swapped on the
133
+ * envelope after signing leaves the signature valid; this comparison is what
134
+ * detects it.
135
+ * - both present and equal → `ok`.
136
+ * - both present and different → `signingKeyId_mismatch`, the one failing
137
+ * status: §8.3.1 says `metadata.signingKeyId` MUST equal the envelope's
138
+ * `kid`, and the spec reads a mismatch as envelope-vs-canonical drift. The
139
+ * comparison is exact (no case or whitespace folding), and a present value
140
+ * that is not a string is a mismatch.
141
+ * - the envelope carries no `kid` → `kid_absent`: there is nothing to compare.
142
+ * Under a key-derived `signer.identifier` (§8.5.1) `kid` is optional, and
143
+ * legacy packages carry neither field; for a registry signer, check #5
144
+ * carries the consequence of a missing `kid`.
145
+ * - the envelope carries a `kid` and the package no `metadata.signingKeyId`
146
+ * → `signingKeyId_absent`: the `kid` is bound by nothing signed, so the
147
+ * check cannot confirm it. The package does not meet §8.3.1, which requires
148
+ * the field; that is not evidence the envelope was changed.
149
+ * `undefined`, `null` and `''` count as absent on either side. Only a mismatch
150
+ * carries the two values, which are then the finding; otherwise they are
151
+ * `VerifyResult.kid` and the package's own field.
152
+ *
153
+ * `signer.identifier` is not read. Under a key-derived identifier the `kid`
154
+ * SHOULD be the identifier string (§8.3.1); that is not a condition of this
155
+ * check, and for a registry signer `kid` and `signer.identifier` differ by
156
+ * design. The identity cross-check is check #14's.
157
+ */
158
+ export declare function checkSigningKeyIdConsistency(pkg: Record<string, unknown>, kid: string | undefined): SigningKeyIdConsistencyCheck;
118
159
  export declare const SIGNER_IDENTITY_CHECK_STATUSES: readonly ["ok", "signer_identity_mismatch", "no_signer", "no_registry_identity", "key_derived_match", "key_derived_mismatch"];
119
160
  export type SignerIdentityCheckStatus = (typeof SIGNER_IDENTITY_CHECK_STATUSES)[number];
120
161
  export interface SignerIdentityCheck {
package/dist/checks.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Content-canonicalization, content-hash, and typed-standards envelope checks
2
- // (spec §9.2 checks #3, #4, #12, #13, #14, #15, #16) + the package-level
2
+ // (spec §9.2 checks #3, #4, #6, #12, #13, #14, #15, #16) + the package-level
3
3
  // blob-ref walker (#9) — browser-safe.
4
4
  //
5
5
  // Factored verbatim from the server `verify.ts` (WS2); these functions were
@@ -247,12 +247,16 @@ export function checkContentProfile(pkg) {
247
247
  // v0.1 ratified node type URIs (spec §8.12.1). Recognized so check #12 resolves
248
248
  // them; only `content/analysis/v1` (+ the lifecycle sub-types) are operationalized
249
249
  // today, but the full ratified set is registered so conformant packages don't
250
- // render as `unknown_type`.
251
- const KNOWN_TYPE_URIS = [
250
+ // render as `unknown_type`. The sixteen attestation sub-types are in the order
251
+ // of the §8.12.1 table; `type-resolution.test.ts` holds that table's copy.
252
+ // Exported and frozen so a check outside this package can compare the registered
253
+ // set with the specification's list without being able to change it.
254
+ export const KNOWN_TYPE_URIS = Object.freeze([
252
255
  'content/analysis/v1',
253
256
  'attestation/withdraws/v1',
254
257
  'attestation/reinstates/v1',
255
258
  'attestation/supersedes/v1',
259
+ 'attestation/revises/v1',
256
260
  'attestation/publishes/v1',
257
261
  'attestation/locatedAt/v1',
258
262
  'attestation/corroborates/v1',
@@ -265,7 +269,7 @@ const KNOWN_TYPE_URIS = [
265
269
  'attestation/certifies/v1',
266
270
  'attestation/evaluates/v1',
267
271
  'attestation/conforms/v1',
268
- ];
272
+ ]);
269
273
  export const TYPE_RESOLUTION_STATUSES = ['ok', 'implicit', 'unknown_type'];
270
274
  /**
271
275
  * Check #12 — `type` resolution. An absent field resolves to the implicit
@@ -282,6 +286,63 @@ export function resolvePackageType(pkg) {
282
286
  }
283
287
  return { status: 'unknown_type', type: raw };
284
288
  }
289
+ // --- Envelope kid ↔ metadata.signingKeyId (spec §9.2 check #6) ---
290
+ export const SIGNING_KEY_ID_CONSISTENCY_STATUSES = [
291
+ 'ok',
292
+ 'signingKeyId_mismatch',
293
+ 'kid_absent',
294
+ 'signingKeyId_absent',
295
+ ];
296
+ const isAbsent = (v) => v === undefined || v === null || v === '';
297
+ /**
298
+ * Check #6 — the signature envelope's `kid` against `metadata.signingKeyId`
299
+ * (spec §9.2 check #6, §8.3.1). The package's `signingKeyId` is inside the
300
+ * signed bytes and the envelope's `kid` is not, so a `kid` swapped on the
301
+ * envelope after signing leaves the signature valid; this comparison is what
302
+ * detects it.
303
+ * - both present and equal → `ok`.
304
+ * - both present and different → `signingKeyId_mismatch`, the one failing
305
+ * status: §8.3.1 says `metadata.signingKeyId` MUST equal the envelope's
306
+ * `kid`, and the spec reads a mismatch as envelope-vs-canonical drift. The
307
+ * comparison is exact (no case or whitespace folding), and a present value
308
+ * that is not a string is a mismatch.
309
+ * - the envelope carries no `kid` → `kid_absent`: there is nothing to compare.
310
+ * Under a key-derived `signer.identifier` (§8.5.1) `kid` is optional, and
311
+ * legacy packages carry neither field; for a registry signer, check #5
312
+ * carries the consequence of a missing `kid`.
313
+ * - the envelope carries a `kid` and the package no `metadata.signingKeyId`
314
+ * → `signingKeyId_absent`: the `kid` is bound by nothing signed, so the
315
+ * check cannot confirm it. The package does not meet §8.3.1, which requires
316
+ * the field; that is not evidence the envelope was changed.
317
+ * `undefined`, `null` and `''` count as absent on either side. Only a mismatch
318
+ * carries the two values, which are then the finding; otherwise they are
319
+ * `VerifyResult.kid` and the package's own field.
320
+ *
321
+ * `signer.identifier` is not read. Under a key-derived identifier the `kid`
322
+ * SHOULD be the identifier string (§8.3.1); that is not a condition of this
323
+ * check, and for a registry signer `kid` and `signer.identifier` differ by
324
+ * design. The identity cross-check is check #14's.
325
+ */
326
+ export function checkSigningKeyIdConsistency(pkg, kid) {
327
+ const metadata = pkg['metadata'];
328
+ const signingKeyId = typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata)
329
+ ? metadata['signingKeyId']
330
+ : undefined;
331
+ if (kid === undefined || isAbsent(kid)) {
332
+ return { status: 'kid_absent' };
333
+ }
334
+ if (isAbsent(signingKeyId)) {
335
+ return { status: 'signingKeyId_absent' };
336
+ }
337
+ if (signingKeyId === kid) {
338
+ return { status: 'ok' };
339
+ }
340
+ return {
341
+ status: 'signingKeyId_mismatch',
342
+ kid,
343
+ ...(typeof signingKeyId === 'string' ? { signingKeyId } : {}),
344
+ };
345
+ }
285
346
  export const SIGNER_IDENTITY_CHECK_STATUSES = [
286
347
  'ok',
287
348
  'signer_identity_mismatch',
package/dist/rfc3161.d.ts CHANGED
@@ -10,7 +10,8 @@ export interface TsaRootAnchor {
10
10
  rootKeyDer: string;
11
11
  }
12
12
  export declare const FREETSA_ROOT_ANCHORS: readonly TsaRootAnchor[];
13
- export type Rfc3161FailReason = 'parse_error' | 'unexpected_algorithm' | 'no_message_digest' | 'content_not_bound' | 'imprint_mismatch' | 'no_signing_cert' | 'eku_not_timestamping' | 'genTime_outside_validity' | 'chain_incomplete' | 'chain_signature_invalid' | 'untrusted_root' | 'signature_invalid';
13
+ export declare const RFC3161_FAIL_REASONS: readonly ["parse_error", "unexpected_algorithm", "no_message_digest", "content_not_bound", "imprint_mismatch", "no_signing_cert", "eku_not_timestamping", "genTime_outside_validity", "chain_incomplete", "chain_signature_invalid", "chain_algorithm_unsupported", "chain_outside_validity", "untrusted_root", "signature_invalid"];
14
+ export type Rfc3161FailReason = (typeof RFC3161_FAIL_REASONS)[number];
14
15
  export interface Rfc3161VerifyResult {
15
16
  /** All checks passed: a chain-trusted TSA signed this package hash at genTime. */
16
17
  verified: boolean;
@@ -22,18 +23,48 @@ export interface Rfc3161VerifyResult {
22
23
  ekuTimestamping: boolean | null;
23
24
  /** genTime within the signing cert's validity window. */
24
25
  withinValidity: boolean | null;
25
- /** The TSA ECDSA-P384 signature verifies under the chain-validated leaf key. */
26
+ /**
27
+ * The TSA ECDSA-P384 signature verifies under the embedded signing cert's key.
28
+ * Evaluated whenever the token parses, uses the checked algorithms, binds this
29
+ * package and carries a timestamping signing cert with a P-384 key — whether or not
30
+ * that cert chains to a pinned root (`chainVerified`), which is what makes the key
31
+ * trusted. `null` when an earlier step returned first, or when the signing cert's
32
+ * key is not a P-384 key (`unexpected_algorithm`): a key this verifier cannot
33
+ * evaluate is not an invalid signature. `false` covers a P-384 key under which the
34
+ * signature does not verify, including a malformed signature encoding.
35
+ */
26
36
  signatureValid: boolean | null;
27
37
  genTime?: number;
28
38
  /** The matched root anchor name. */
29
39
  tsa?: string;
40
+ /**
41
+ * The first failing step, in this order: `parse_error`; `imprint_mismatch` (the
42
+ * TSTInfo imprint is not SHA-256 of this package's hash; compared before the
43
+ * SignerInfo algorithms); `unexpected_algorithm` (SignerInfo digest or signature
44
+ * algorithm outside the checked set); `no_message_digest`, `content_not_bound`;
45
+ * `no_signing_cert`, `eku_not_timestamping`. After the signing-cert step the token's
46
+ * own faults come first: `genTime_outside_validity` (the signing cert only), then
47
+ * `unexpected_algorithm` (a signing-cert key that is not P-384), then
48
+ * `signature_invalid`, then the chain's reason — `untrusted_root`,
49
+ * `chain_incomplete`, `chain_signature_invalid` (a link whose RSA signature does not
50
+ * verify), `chain_algorithm_unsupported` (a link signed with an algorithm the chain
51
+ * validator does not implement), or `chain_outside_validity` (an intermediate or
52
+ * root not valid at genTime). So a chain reason is reported only for
53
+ * a token whose TSA signature verifies and whose genTime is within the signing cert's
54
+ * validity, and each reason names one kind of fault.
55
+ */
30
56
  reason?: Rfc3161FailReason;
31
57
  }
32
58
  /**
33
59
  * Verify an RFC 3161 token (base64) attests `expectedHashHex` (the package SHA-256),
34
60
  * chaining the token's embedded signing cert to a pinned TSA root. Async (the cert
35
61
  * chain uses WebCrypto RSA). A parse failure / untrusted chain is reported, not
36
- * thrown; `imprint_mismatch` / `content_not_bound` / `chain_signature_invalid` /
37
- * `signature_invalid` are the alarm signals.
62
+ * thrown. The message imprint is compared first, before the SignerInfo algorithms.
63
+ * The TSA signature is evaluated even when the chain does not reach a pinned
64
+ * root, and a fault of the token itself (`genTime_outside_validity`,
65
+ * `unexpected_algorithm` for a signing-cert key that is not P-384,
66
+ * `signature_invalid`) is reported ahead of a chain fault. `parse_error`, and
67
+ * `unexpected_algorithm` for the SignerInfo's digest or signature algorithm, still
68
+ * return before any signature is evaluated.
38
69
  */
39
70
  export declare function verifyRfc3161Timestamp(tokenB64: string, expectedHashHex: string, anchors?: readonly TsaRootAnchor[]): Promise<Rfc3161VerifyResult>;
package/dist/rfc3161.js CHANGED
@@ -7,11 +7,21 @@
7
7
  // 2. signedAttrs.messageDigest == SHA-512(TSTInfo) — the signature is BOUND to it;
8
8
  // 3. the token's EMBEDDED signing cert chains to the PINNED FreeTSA RSA-4096 root
9
9
  // (each link's signature verified; CA + validity-vs-genTime checked) — so the
10
- // signer is trusted WITHOUT pinning the (rotatable) signing key;
10
+ // signer is trusted WITHOUT pinning the (rotatable) signing key. An intermediate
11
+ // or root not valid at genTime is a chain fault, `chain_outside_validity`;
11
12
  // 4. the signing cert carries EKU id-kp-timeStamping and its validity covers
12
13
  // genTime;
13
14
  // 5. the TSA's ECDSA-P384 signature over SHA-512(signedAttrs) verifies under THAT
14
- // chain-validated leaf key.
15
+ // leaf's key. The signature is evaluated whether or not step 3 reaches a pinned
16
+ // root (typedstandards#94), so a token that does not verify reads differently
17
+ // from one whose only fault is its chain; `verified` still requires all five. A
18
+ // leaf key that is not a P-384 key is outside the set this verifier checks: it
19
+ // reads `unexpected_algorithm`, not `signature_invalid`.
20
+ //
21
+ // Step 1 runs before the SignerInfo digest/signature algorithms are checked: it reads
22
+ // only the TSTInfo, so a token that does not bind this package reads `imprint_mismatch`
23
+ // whatever algorithms it uses. Each reason names one kind of fault, so a consumer can
24
+ // classify a token by `reason` alone.
15
25
  //
16
26
  // Crypto: the leaf TSA signature is `@noble` ECDSA-P384 (the cert key); the cert
17
27
  // chain is RSASSA-PKCS1-v1.5/SHA-512 via WebCrypto (`globalThis.crypto.subtle`, a
@@ -41,6 +51,26 @@ export const FREETSA_ROOT_ANCHORS = [
41
51
  rootKeyDer: 'MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtgKODjAy8REQ2WTNqUudAnjhlCrpE6qlmQfNppeTmVvZrH4zutn+NwTaHAGpjSGv4/WRpZ1wZ3BRZ5mPUBZyLgq0YrIfQ5Fx0s/MRZPzc1r3lKWrMR9sAQx4mN4z11xFEO529L0dFJjPF9MD8Gpd2feWzGyptlelb+PqT+++fOa2oY0+NaMM7l/xcNHPOaMz0/2olk0i22hbKeVhvokPCqhFhzsuhKsmq4Of/o+t6dI7sx5h0nPMm4gGSRhfq+z6BTRgCrqQG2FOLoVFgt6iIm/BnNffUr7VDYd3zZmIwFOj/H3DKHoGik/xK3E82YA2ZulVOFRW/zj4ApjPa5OFbpIkd0pmzxzdEcL479hSA9dFiyVmSxPtY5ze1P+BE9bMU1PScpRzw8MHFXxyKqW13Qv7LWw4sbk3SciB7GACbQiVGzgkvXG6y85HOuvWNvC5GLSiyP9GlPB0V68tbxz4JVTRdw/Xn/XTFNzRBM3cq8lBOAVt/PAX5+uFcv1S9wFE8YjaBfWCP1jdBil+c4e+0tdywT2oJmYBBF/kEt1wmGwMmHunNEuQNzh1FtJY54hbUfiWi38mASE7xMtMhfj/C4SvapiDN837gYaPfs8x3KZxbX7C3YAsFnJinlwAUss1fdKar8Q/YVs7H/nU4c4Ixxxz4f67fcVqM2ITKentbCMCAwEAAQ==',
42
52
  },
43
53
  ];
54
+ // Source-of-truth array (not just a type), as BLOB_REF_VERIFY_REASONS in blob-ref.ts,
55
+ // so a consumer can enumerate every reason `verifyRfc3161Timestamp` can return at
56
+ // runtime and fail when a new one arrives unclassified. Frozen: reading it cannot
57
+ // change it.
58
+ export const RFC3161_FAIL_REASONS = Object.freeze([
59
+ 'parse_error',
60
+ 'unexpected_algorithm',
61
+ 'no_message_digest',
62
+ 'content_not_bound',
63
+ 'imprint_mismatch',
64
+ 'no_signing_cert',
65
+ 'eku_not_timestamping',
66
+ 'genTime_outside_validity',
67
+ 'chain_incomplete',
68
+ 'chain_signature_invalid',
69
+ 'chain_algorithm_unsupported',
70
+ 'chain_outside_validity',
71
+ 'untrusted_root',
72
+ 'signature_invalid',
73
+ ]);
44
74
  // EC P-384 SPKI DER: 23-byte prefix + 97-byte uncompressed point (0x04 ‖ X ‖ Y).
45
75
  const EC_P384_SPKI_PREFIX = Uint8Array.from([
46
76
  0x30, 0x76, 0x30, 0x10, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01,
@@ -123,16 +153,23 @@ function parseTstInfo(tst) {
123
153
  }
124
154
  // Map the X.509 chain verifier's precise reason onto this module's vocabulary. The
125
155
  // strict-RFC structural/policy rejections (#119 P4) collapse to `chain_incomplete`
126
- // (the chain could not be trusted) and only a real RSA link failure surfaces as
127
- // `chain_signature_invalid`; the chain layer keeps the fine-grained reason.
156
+ // (the chain could not be trusted), only a real RSA link failure surfaces as
157
+ // `chain_signature_invalid`, and a link signed with an algorithm the validator does
158
+ // not implement is `chain_algorithm_unsupported` (#100); the chain layer keeps the
159
+ // fine-grained reason. The
160
+ // chain's validity failure is `chain_outside_validity`: `verifyRfc3161Timestamp`
161
+ // reports a signing cert not valid at genTime before the chain's reason, so here it
162
+ // can only be an intermediate or the root (typedstandards#94).
128
163
  function toRfc3161ChainReason(reason) {
129
164
  switch (reason) {
130
165
  case 'genTime_outside_validity':
131
- return 'genTime_outside_validity';
166
+ return 'chain_outside_validity';
132
167
  case 'untrusted_root':
133
168
  return 'untrusted_root';
134
169
  case 'link_signature_invalid':
135
170
  return 'chain_signature_invalid';
171
+ case 'link_algorithm_unsupported':
172
+ return 'chain_algorithm_unsupported';
136
173
  case 'unsupported_critical_extension':
137
174
  case 'algorithm_mismatch':
138
175
  case 'issuer_not_found':
@@ -161,8 +198,13 @@ function fail(base, reason) {
161
198
  * Verify an RFC 3161 token (base64) attests `expectedHashHex` (the package SHA-256),
162
199
  * chaining the token's embedded signing cert to a pinned TSA root. Async (the cert
163
200
  * chain uses WebCrypto RSA). A parse failure / untrusted chain is reported, not
164
- * thrown; `imprint_mismatch` / `content_not_bound` / `chain_signature_invalid` /
165
- * `signature_invalid` are the alarm signals.
201
+ * thrown. The message imprint is compared first, before the SignerInfo algorithms.
202
+ * The TSA signature is evaluated even when the chain does not reach a pinned
203
+ * root, and a fault of the token itself (`genTime_outside_validity`,
204
+ * `unexpected_algorithm` for a signing-cert key that is not P-384,
205
+ * `signature_invalid`) is reported ahead of a chain fault. `parse_error`, and
206
+ * `unexpected_algorithm` for the SignerInfo's digest or signature algorithm, still
207
+ * return before any signature is evaluated.
166
208
  */
167
209
  export async function verifyRfc3161Timestamp(tokenB64, expectedHashHex, anchors = FREETSA_ROOT_ANCHORS) {
168
210
  let buf;
@@ -197,13 +239,15 @@ export async function verifyRfc3161Timestamp(tokenB64, expectedHashHex, anchors
197
239
  signatureValid: null,
198
240
  genTime: tst.genTime,
199
241
  };
200
- if (token.digestAlgOid !== OID_SHA512 || token.signatureAlgOid !== OID_ECDSA_SHA512) {
201
- return fail(base, 'unexpected_algorithm');
202
- }
242
+ // The imprint reads only the TSTInfo, not the SignerInfo algorithms, so it runs
243
+ // first: a token that does not bind this package fails whatever it is signed with.
203
244
  base.imprintMatches =
204
245
  tst.hashAlgOid === OID_SHA256 && bytesToHex(tst.hashedMessage) === expectedHashHex.toLowerCase();
205
246
  if (!base.imprintMatches)
206
247
  return fail(base, 'imprint_mismatch');
248
+ if (token.digestAlgOid !== OID_SHA512 || token.signatureAlgOid !== OID_ECDSA_SHA512) {
249
+ return fail(base, 'unexpected_algorithm');
250
+ }
207
251
  if (!mdAttr)
208
252
  return fail(base, 'no_message_digest');
209
253
  base.contentBound = bytesEqual(mdAttr, sha512(token.tstInfo));
@@ -219,27 +263,47 @@ export async function verifyRfc3161Timestamp(tokenB64, expectedHashHex, anchors
219
263
  base.withinValidity = tst.genTime >= leaf.notBefore && tst.genTime <= leaf.notAfter;
220
264
  const chain = await validateChainToRoot(token.certs, leaf, anchors, tst.genTime);
221
265
  base.chainVerified = chain.ok;
222
- if (!chain.ok)
223
- return fail(base, chain.reason ?? 'untrusted_root');
224
- base.tsa = chain.tsa;
225
- if (!base.withinValidity)
226
- return fail(base, 'genTime_outside_validity');
266
+ if (chain.ok)
267
+ base.tsa = chain.tsa;
227
268
  // The TSA signature: ECDSA-P384 over SHA-512(signedAttrs re-tagged [0]→SET),
228
- // under the chain-validated leaf key. lowS:false — a TSA emits high-S (#119 fix).
229
- const signedBytes = Uint8Array.from(rawTlv(buf, token.signedAttrs));
230
- signedBytes[0] = 0x31;
269
+ // under the leaf's key. lowS:false — a TSA emits high-S (#119 fix). Evaluated
270
+ // whatever the chain read (#94): the key is trusted only if `chain.ok`, but a
271
+ // signature that does not verify under it is a fault of the token itself.
272
+ //
273
+ // Two separate steps. A leaf key that is not a P-384 SPKI is one this verifier
274
+ // cannot evaluate: `signatureValid` stays null and the token reads
275
+ // `unexpected_algorithm`. Only a P-384 key under which the signature does not
276
+ // verify (a malformed signature encoding included) is `signature_invalid`.
277
+ let point = null;
231
278
  try {
232
- const point = extractP384Point(leaf.spkiDer);
233
- base.signatureValid = p384.verify(token.signature, sha512(signedBytes), point, {
234
- format: 'der',
235
- prehash: false,
236
- lowS: false,
237
- });
279
+ point = extractP384Point(leaf.spkiDer);
238
280
  }
239
281
  catch {
240
- base.signatureValid = false;
282
+ point = null;
283
+ }
284
+ if (point) {
285
+ const signedBytes = Uint8Array.from(rawTlv(buf, token.signedAttrs));
286
+ signedBytes[0] = 0x31;
287
+ try {
288
+ base.signatureValid = p384.verify(token.signature, sha512(signedBytes), point, {
289
+ format: 'der',
290
+ prehash: false,
291
+ lowS: false,
292
+ });
293
+ }
294
+ catch {
295
+ base.signatureValid = false;
296
+ }
241
297
  }
298
+ // The token's own faults first, then the chain's: a chain reason is reported only
299
+ // for a token whose signature verifies and whose genTime is within validity.
300
+ if (!base.withinValidity)
301
+ return fail(base, 'genTime_outside_validity');
302
+ if (!point)
303
+ return fail(base, 'unexpected_algorithm');
242
304
  if (!base.signatureValid)
243
305
  return fail(base, 'signature_invalid');
306
+ if (!chain.ok)
307
+ return fail(base, chain.reason ?? 'untrusted_root');
244
308
  return { ...base, verified: true };
245
309
  }
package/dist/verify.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type RekorInclusionProof, type RekorInclusionResult } from './rekor-inclusion.ts';
2
2
  import { type Rfc3161VerifyResult } from './rfc3161.ts';
3
3
  import { type KeyTrustResult, type TrustRegistry, type TrustRegistryProvenance } from './trust-registry.ts';
4
- import { type ContentCanonicalizationResolution, type ContentHashCheck, type TypeResolution, type SignerIdentityCheck, type CaptureMethodVocabCheck, type ContentProfileCheck, type BlobRefVerification } from './checks.ts';
4
+ import { type ContentCanonicalizationResolution, type ContentHashCheck, type TypeResolution, type SignerIdentityCheck, type SigningKeyIdConsistencyCheck, type CaptureMethodVocabCheck, type ContentProfileCheck, type BlobRefVerification } from './checks.ts';
5
5
  import { type LifecycleResolution } from './lifecycle.ts';
6
6
  import type { FetchLike } from './types.ts';
7
7
  /** The parsed signature envelope (`buildCommitmentView`'s `signature`). */
@@ -149,6 +149,10 @@ export interface VerifyResult {
149
149
  contentHash: ContentHashCheck | null;
150
150
  typeResolution: TypeResolution | null;
151
151
  signerIdentity: SignerIdentityCheck | null;
152
+ /** Check #6 — the envelope `kid` against `metadata.signingKeyId`. Null when
153
+ * the package is absent, no signature envelope was supplied, or the
154
+ * signature was malformed: there is no envelope `kid` to read. */
155
+ signingKeyIdConsistency: SigningKeyIdConsistencyCheck | null;
152
156
  captureMethodVocab: CaptureMethodVocabCheck | null;
153
157
  /** Check #16 — `metadata.contentProfile` (hub ADR-0029 §5). */
154
158
  contentProfile: ContentProfileCheck | null;
package/dist/verify.js CHANGED
@@ -35,8 +35,50 @@ import { verifyRekorInclusion, } from "./rekor-inclusion.js";
35
35
  import { verifyRfc3161Timestamp } from "./rfc3161.js";
36
36
  import { verifyKeyTrust, legacyEmbeddedKeyTrust, } from "./trust-registry.js";
37
37
  import { applyKeyDerivedRegistryRule, applySelfCertifiedKeyTrust, compareKeyDerivedIdentifier, } from "./self-certifying.js";
38
- import { resolveContentCanonicalization, verifyContentHashWithFetch, resolvePackageType, checkSignerIdentity, checkCaptureMethodVocab, checkContentProfile, verifyPackageBlobRefs, } from "./checks.js";
38
+ import { resolveContentCanonicalization, verifyContentHashWithFetch, resolvePackageType, checkSignerIdentity, checkSigningKeyIdConsistency, checkCaptureMethodVocab, checkContentProfile, verifyPackageBlobRefs, } from "./checks.js";
39
39
  import { resolveLifecycleFromLegacyColumns, } from "./lifecycle.js";
40
+ /**
41
+ * A fetcher that sends at most one request per URL and replays that request's
42
+ * outcome to every later call for the same URL. `verifyRecord` makes one per
43
+ * call and hands it to checks #9 and #4, which would otherwise each fetch a
44
+ * raw-bytes/v1 BlobRef `output` (#90); the scope is one `verifyRecord` call.
45
+ *
46
+ * The outcome replays as it happened, so neither check reads anything
47
+ * differently: a fetcher that threw throws again, a non-ok response is
48
+ * non-ok again, and a body that could not be read fails again. The body is
49
+ * read once, on first demand, and every caller receives the same
50
+ * `ArrayBuffer`; the two checks only read it. The first call's `init` (its
51
+ * abort signal) governs the one request. The underlying fetcher is resolved
52
+ * when the request is sent (`globalThis.fetch` when none is injected), as the
53
+ * checks resolve it.
54
+ */
55
+ function oneFetchPerUrl(fetcher) {
56
+ const requests = new Map();
57
+ return async (url, init) => {
58
+ let request = requests.get(url);
59
+ if (request === undefined) {
60
+ const send = fetcher ?? globalThis.fetch;
61
+ request = Promise.resolve()
62
+ .then(() => send(url, init))
63
+ .then((response) => ({ response }));
64
+ requests.set(url, request);
65
+ }
66
+ const held = await request;
67
+ const body = () => {
68
+ if (held.body === undefined)
69
+ held.body = held.response.arrayBuffer();
70
+ return held.body;
71
+ };
72
+ return {
73
+ ok: held.response.ok,
74
+ status: held.response.status,
75
+ headers: held.response.headers,
76
+ arrayBuffer: () => body(),
77
+ text: async () => new TextDecoder().decode(await body()),
78
+ json: async () => JSON.parse(new TextDecoder().decode(await body())),
79
+ };
80
+ };
81
+ }
40
82
  /**
41
83
  * Run the §9.2 check suite. The step order mirrors the server route exactly
42
84
  * (notably: Rekor before key-trust, because a deprecated-key trust decision is
@@ -113,11 +155,14 @@ export async function verifyRecord(input, deps) {
113
155
  if (input.rfc3161Timestamp && packageHash) {
114
156
  rfc3161 = await verifyRfc3161Timestamp(input.rfc3161Timestamp, packageHash);
115
157
  }
158
+ // Checks #9 and #4 share one fetch per BlobRef URL: under raw-bytes/v1 with a
159
+ // BlobRef `output`, both read the same file (#90).
160
+ const blobFetch = oneFetchPerUrl(deps.fetch);
116
161
  // Step 3b — blob references embedded in the package (check #9).
117
162
  let blobRefs = [];
118
163
  let blobRefsVerified = null;
119
164
  if (pkg) {
120
- blobRefs = await verifyPackageBlobRefs(pkg, { fetch: deps.fetch });
165
+ blobRefs = await verifyPackageBlobRefs(pkg, { fetch: blobFetch });
121
166
  if (blobRefs.length > 0) {
122
167
  blobRefsVerified = blobRefs.every((r) => r.ok);
123
168
  }
@@ -159,18 +204,22 @@ export async function verifyRecord(input, deps) {
159
204
  : applyKeyDerivedRegistryRule(keyTrust, deps.registryProvenance);
160
205
  }
161
206
  // Step 5 — canonicalization, content-hash, and envelope checks
162
- // (#3/#4/#12/#14/#15/#16). #4 fetches only for a raw-bytes/v1 BlobRef output.
207
+ // (#3/#4/#6/#12/#14/#15/#16). #4 fetches only for a raw-bytes/v1 BlobRef output.
163
208
  let contentCanonicalization = null;
164
209
  let contentHashCheck = null;
165
210
  let typeResolution = null;
166
211
  let signerIdentity = null;
212
+ let signingKeyIdConsistency = null;
167
213
  let captureMethodVocab = null;
168
214
  let contentProfile = null;
169
215
  if (pkg) {
170
216
  contentCanonicalization = resolveContentCanonicalization(pkg);
171
- contentHashCheck = await verifyContentHashWithFetch(pkg, contentCanonicalization, input.legacyExternalHash ?? packageHash, { fetch: deps.fetch });
217
+ contentHashCheck = await verifyContentHashWithFetch(pkg, contentCanonicalization, input.legacyExternalHash ?? packageHash, { fetch: blobFetch });
172
218
  typeResolution = resolvePackageType(pkg);
173
219
  signerIdentity = checkSignerIdentity(pkg, sigKid, deps.registry, sigPublicKey);
220
+ if (input.signature && !input.signatureMalformed) {
221
+ signingKeyIdConsistency = checkSigningKeyIdConsistency(pkg, input.signature.kid);
222
+ }
174
223
  captureMethodVocab = checkCaptureMethodVocab(pkg);
175
224
  contentProfile = checkContentProfile(pkg);
176
225
  }
@@ -205,6 +254,7 @@ export async function verifyRecord(input, deps) {
205
254
  contentHash: contentHashCheck,
206
255
  typeResolution,
207
256
  signerIdentity,
257
+ signingKeyIdConsistency,
208
258
  captureMethodVocab,
209
259
  contentProfile,
210
260
  lifecycle,
package/dist/x509.d.ts CHANGED
@@ -40,7 +40,8 @@ export declare function bytesEqual(a: Uint8Array, b: Uint8Array): boolean;
40
40
  * Verify `cert.signature` over `cert.tbsBytes` using `issuerSpkiDer` (RSA), via
41
41
  * WebCrypto (`globalThis.crypto.subtle` — a browser/Node global, not a `node:`
42
42
  * import, so verify-core stays browser-safe). Returns false for an unsupported
43
- * algorithm or any verification error.
43
+ * algorithm or any verification error; `verifyCertChainToAnchor` tells the two
44
+ * apart before it calls this.
44
45
  */
45
46
  export declare function verifyCertSignatureRsa(cert: X509Cert, issuerSpkiDer: Uint8Array): Promise<boolean>;
46
47
  /** A pinned trust anchor: a root whose public key is trusted by value. */
@@ -49,7 +50,7 @@ export interface ChainAnchor {
49
50
  /** Root public key, base64 SPKI DER. */
50
51
  rootKeyDer: string;
51
52
  }
52
- export type ChainFailReason = 'unsupported_critical_extension' | 'algorithm_mismatch' | 'genTime_outside_validity' | 'issuer_not_found' | 'issuer_not_ca' | 'path_len_exceeded' | 'link_signature_invalid' | 'untrusted_root';
53
+ export type ChainFailReason = 'unsupported_critical_extension' | 'algorithm_mismatch' | 'genTime_outside_validity' | 'issuer_not_found' | 'issuer_not_ca' | 'path_len_exceeded' | 'link_signature_invalid' | 'link_algorithm_unsupported' | 'untrusted_root';
53
54
  export interface ChainResult {
54
55
  /** The path links to a pinned anchor and every strict check passed. */
55
56
  ok: boolean;
package/dist/x509.js CHANGED
@@ -148,11 +148,18 @@ const RSA_SIG_HASH = {
148
148
  '1.2.840.113549.1.1.12': 'SHA-384',
149
149
  '1.2.840.113549.1.1.13': 'SHA-512',
150
150
  };
151
+ /** Whether `oid` is a certificate signature algorithm `verifyCertSignatureRsa`
152
+ * implements. The chain validator reports a link signed with any other algorithm as
153
+ * `link_algorithm_unsupported`, not as a signature that does not verify (#100). */
154
+ function isImplementedCertSignatureAlgorithm(oid) {
155
+ return Object.prototype.hasOwnProperty.call(RSA_SIG_HASH, oid);
156
+ }
151
157
  /**
152
158
  * Verify `cert.signature` over `cert.tbsBytes` using `issuerSpkiDer` (RSA), via
153
159
  * WebCrypto (`globalThis.crypto.subtle` — a browser/Node global, not a `node:`
154
160
  * import, so verify-core stays browser-safe). Returns false for an unsupported
155
- * algorithm or any verification error.
161
+ * algorithm or any verification error; `verifyCertChainToAnchor` tells the two
162
+ * apart before it calls this.
156
163
  */
157
164
  export async function verifyCertSignatureRsa(cert, issuerSpkiDer) {
158
165
  const hash = RSA_SIG_HASH[cert.sigAlgOid];
@@ -207,6 +214,11 @@ export async function verifyCertChainToAnchor(certs, leaf, anchors, atTime) {
207
214
  if (issuer.pathLen !== null && depth > issuer.pathLen) {
208
215
  return { ok: false, reason: 'path_len_exceeded' }; // (c)
209
216
  }
217
+ // The algorithm is checked where the signature is, so the reasons before it read
218
+ // as they did; a link this validator cannot check is not one that fails (#100).
219
+ if (!isImplementedCertSignatureAlgorithm(current.sigAlgOid)) {
220
+ return { ok: false, reason: 'link_algorithm_unsupported' };
221
+ }
210
222
  if (!(await verifyCertSignatureRsa(current, issuer.spkiDer))) {
211
223
  return { ok: false, reason: 'link_signature_invalid' };
212
224
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typedstandards/verify-core",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Portable, browser-safe verification core for Typed Standards record packages — the §9.2 check suite, single-sourced so it verifies identically on a server and in the browser.",
5
5
  "license": "MIT",
6
6
  "author": "Nathan Storey",