@typedstandards/verify-core 0.10.0 → 0.11.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,79 @@ 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.11.0 — 2026-09-22
10
+
11
+ The verifier follow-ons (anchored at `typedstandards#98`): specification check #6, the ratified
12
+ `attestation/revises/v1` (specification v0.1.10), one fetch per referenced file, and a timestamp result
13
+ that tells a token that does not verify from one whose only fault is its chain. **A minor bump** — new
14
+ exports, `VerifyResult` gains a field, and `Rfc3161FailReason` gains a member, so a consumer that
15
+ builds a `VerifyResult` literal (a test double, say), or has an exhaustive `switch` or `Record` over
16
+ the reasons, needs the new cases.
17
+
18
+ - **Check #6, `metadata.signingKeyId` consistency (`checkSigningKeyIdConsistency`,
19
+ `SIGNING_KEY_ID_CONSISTENCY_STATUSES`; typedstandards#88).** Specification §9.2 check #6 was
20
+ implemented nowhere. It compares the signature envelope's `kid` with the package's
21
+ `metadata.signingKeyId` and reports one of:
22
+ - `ok` — both present and equal;
23
+ - `signingKeyId_mismatch` — both present and different; the one failing status (§8.3.1: the field
24
+ MUST equal the envelope `kid`). The result carries `kid` and `signingKeyId`;
25
+ - `kid_absent` — the envelope carries no `kid`; legitimate under a key-derived identifier, and on
26
+ legacy packages, which carry neither field;
27
+ - `signingKeyId_absent` — a `kid` that the signed bytes do not name, so the check cannot confirm
28
+ it.
29
+
30
+ `signer.identifier` is not compared: under a key-derived identifier the `kid` SHOULD be the
31
+ identifier, which is not a condition of this check. `VerifyResult` gains `signingKeyIdConsistency`,
32
+ null when there is no package, no signature envelope, or a malformed one. The captured and
33
+ published packages measured for this change (the three captured commitment bundles, the
34
+ self-certified interop fixture, the two ADR-0028 eval-run packages, and two published example
35
+ bundles under a key-derived identifier) read `ok`, except the legacy capture, which carries neither
36
+ field and reads `kid_absent`.
37
+ - **Check #12 registers `attestation/revises/v1` (typedstandards#96).** The ratified set is sixteen
38
+ attestation sub-types plus `content/analysis/v1`; a node of that type read `unknown_type` and now
39
+ reads `ok`.
40
+ - **One fetch per BlobRef URL per `verifyRecord` (typedstandards#90).** Under `raw-bytes/v1` with a
41
+ BlobRef `output`, checks #9 and #4 each fetched the file. They now share one request per URL within a
42
+ `verifyRecord` call, and report exactly what they reported before, including when the fetch fails and
43
+ when the bytes do not match. No public signature changes, and no request is added.
44
+ - **Check #7 evaluates the TSA signature when the chain does not reach a pinned root
45
+ (typedstandards#94).** `verifyRfc3161Timestamp` used to return at the chain step, so for a token
46
+ whose chain did not reach a pinned anchor `signatureValid` read `null` whether the signature was
47
+ genuine or not. It now reads `true` or `false` there as well. For a consumer:
48
+ - `signatureValid` is `null` when an earlier step returned first (`parse_error`,
49
+ `unexpected_algorithm`, `imprint_mismatch`, `no_message_digest`, `content_not_bound`,
50
+ `no_signing_cert`, `eku_not_timestamping`), or when the signing cert's key is not a P-384 key.
51
+ `true` means the signature verifies under the embedded signing cert's key; that key is trusted
52
+ only when `chainVerified` is `true`. `false` means a P-384 key under which the signature does
53
+ not verify, including a malformed signature encoding;
54
+ - `reason` reports the token's own faults ahead of the chain's: `genTime_outside_validity`, then
55
+ `unexpected_algorithm` for a signing-cert key that is not P-384, then `signature_invalid`, then
56
+ the chain reason. A token with an unpinned chain and a signature that does not verify used to
57
+ read `untrusted_root` (or another chain reason) and now reads `signature_invalid`. A signing-cert
58
+ key that is not P-384 used to read the chain reason, or `signature_invalid` under a pinned chain,
59
+ and now reads `unexpected_algorithm`. `untrusted_root`, `chain_incomplete`,
60
+ `chain_signature_invalid` and `chain_outside_validity` are now reported only for a token whose
61
+ TSA signature verifies and whose `genTime` is within the signing cert's validity;
62
+ - **new reason `chain_outside_validity`:** an intermediate or root certificate not valid at
63
+ `genTime`, a chain fault. `genTime_outside_validity` now means only the signing cert. A token
64
+ whose signing cert is valid at `genTime` and whose intermediate or root is not used to read
65
+ `genTime_outside_validity` and now reads `chain_outside_validity`;
66
+ - **the message imprint is compared before the SignerInfo algorithms:** a token that does not bind
67
+ this package's hash reads `imprint_mismatch` whatever algorithms it uses. One that did so with a
68
+ digest or signature algorithm outside the checked set used to read `unexpected_algorithm`, and
69
+ `imprintMatches` was `null` for any `unexpected_algorithm` result from that check; it is now
70
+ `true` there;
71
+ - each reason now names one kind of fault, so a consumer can classify a token by `reason` alone;
72
+ - `verified` is unchanged: `true` only for a fully verified token chained to a pinned anchor. No
73
+ request is added.
74
+ - **`KNOWN_TYPE_URIS` is exported.** The type URIs check #12 resolves `ok` (`content/analysis/v1` and
75
+ the sixteen attestation sub-types), frozen, so a check outside this package can compare them with the
76
+ specification's list.
77
+ - **`RFC3161_FAIL_REASONS` is exported.** The thirteen reasons `verifyRfc3161Timestamp` can return,
78
+ as a frozen array; `Rfc3161FailReason` is now derived from it. It gains one member,
79
+ `chain_outside_validity`, so a consumer with an exhaustive `switch` or `Record` over it needs the
80
+ new case.
81
+
9
82
  ## 0.10.0 — 2026-09-22
10
83
 
11
84
  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_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,46 @@ 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`, or `chain_outside_validity` (an
50
+ * intermediate or root not valid at genTime). So a chain reason is reported only for
51
+ * a token whose TSA signature verifies and whose genTime is within the signing cert's
52
+ * validity, and each reason names one kind of fault.
53
+ */
30
54
  reason?: Rfc3161FailReason;
31
55
  }
32
56
  /**
33
57
  * Verify an RFC 3161 token (base64) attests `expectedHashHex` (the package SHA-256),
34
58
  * chaining the token's embedded signing cert to a pinned TSA root. Async (the cert
35
59
  * 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.
60
+ * thrown. The message imprint is compared first, before the SignerInfo algorithms.
61
+ * The TSA signature is evaluated even when the chain does not reach a pinned
62
+ * root, and a fault of the token itself (`genTime_outside_validity`,
63
+ * `unexpected_algorithm` for a signing-cert key that is not P-384,
64
+ * `signature_invalid`) is reported ahead of a chain fault. `parse_error`, and
65
+ * `unexpected_algorithm` for the SignerInfo's digest or signature algorithm, still
66
+ * return before any signature is evaluated.
38
67
  */
39
68
  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,25 @@ 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_outside_validity',
70
+ 'untrusted_root',
71
+ 'signature_invalid',
72
+ ]);
44
73
  // EC P-384 SPKI DER: 23-byte prefix + 97-byte uncompressed point (0x04 ‖ X ‖ Y).
45
74
  const EC_P384_SPKI_PREFIX = Uint8Array.from([
46
75
  0x30, 0x76, 0x30, 0x10, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01,
@@ -124,11 +153,14 @@ function parseTstInfo(tst) {
124
153
  // Map the X.509 chain verifier's precise reason onto this module's vocabulary. The
125
154
  // strict-RFC structural/policy rejections (#119 P4) collapse to `chain_incomplete`
126
155
  // (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
+ // `chain_signature_invalid`; the chain layer keeps the fine-grained reason. The
157
+ // chain's validity failure is `chain_outside_validity`: `verifyRfc3161Timestamp`
158
+ // reports a signing cert not valid at genTime before the chain's reason, so here it
159
+ // can only be an intermediate or the root (typedstandards#94).
128
160
  function toRfc3161ChainReason(reason) {
129
161
  switch (reason) {
130
162
  case 'genTime_outside_validity':
131
- return 'genTime_outside_validity';
163
+ return 'chain_outside_validity';
132
164
  case 'untrusted_root':
133
165
  return 'untrusted_root';
134
166
  case 'link_signature_invalid':
@@ -161,8 +193,13 @@ function fail(base, reason) {
161
193
  * Verify an RFC 3161 token (base64) attests `expectedHashHex` (the package SHA-256),
162
194
  * chaining the token's embedded signing cert to a pinned TSA root. Async (the cert
163
195
  * 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.
196
+ * thrown. The message imprint is compared first, before the SignerInfo algorithms.
197
+ * The TSA signature is evaluated even when the chain does not reach a pinned
198
+ * root, and a fault of the token itself (`genTime_outside_validity`,
199
+ * `unexpected_algorithm` for a signing-cert key that is not P-384,
200
+ * `signature_invalid`) is reported ahead of a chain fault. `parse_error`, and
201
+ * `unexpected_algorithm` for the SignerInfo's digest or signature algorithm, still
202
+ * return before any signature is evaluated.
166
203
  */
167
204
  export async function verifyRfc3161Timestamp(tokenB64, expectedHashHex, anchors = FREETSA_ROOT_ANCHORS) {
168
205
  let buf;
@@ -197,13 +234,15 @@ export async function verifyRfc3161Timestamp(tokenB64, expectedHashHex, anchors
197
234
  signatureValid: null,
198
235
  genTime: tst.genTime,
199
236
  };
200
- if (token.digestAlgOid !== OID_SHA512 || token.signatureAlgOid !== OID_ECDSA_SHA512) {
201
- return fail(base, 'unexpected_algorithm');
202
- }
237
+ // The imprint reads only the TSTInfo, not the SignerInfo algorithms, so it runs
238
+ // first: a token that does not bind this package fails whatever it is signed with.
203
239
  base.imprintMatches =
204
240
  tst.hashAlgOid === OID_SHA256 && bytesToHex(tst.hashedMessage) === expectedHashHex.toLowerCase();
205
241
  if (!base.imprintMatches)
206
242
  return fail(base, 'imprint_mismatch');
243
+ if (token.digestAlgOid !== OID_SHA512 || token.signatureAlgOid !== OID_ECDSA_SHA512) {
244
+ return fail(base, 'unexpected_algorithm');
245
+ }
207
246
  if (!mdAttr)
208
247
  return fail(base, 'no_message_digest');
209
248
  base.contentBound = bytesEqual(mdAttr, sha512(token.tstInfo));
@@ -219,27 +258,47 @@ export async function verifyRfc3161Timestamp(tokenB64, expectedHashHex, anchors
219
258
  base.withinValidity = tst.genTime >= leaf.notBefore && tst.genTime <= leaf.notAfter;
220
259
  const chain = await validateChainToRoot(token.certs, leaf, anchors, tst.genTime);
221
260
  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');
261
+ if (chain.ok)
262
+ base.tsa = chain.tsa;
227
263
  // 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;
264
+ // under the leaf's key. lowS:false — a TSA emits high-S (#119 fix). Evaluated
265
+ // whatever the chain read (#94): the key is trusted only if `chain.ok`, but a
266
+ // signature that does not verify under it is a fault of the token itself.
267
+ //
268
+ // Two separate steps. A leaf key that is not a P-384 SPKI is one this verifier
269
+ // cannot evaluate: `signatureValid` stays null and the token reads
270
+ // `unexpected_algorithm`. Only a P-384 key under which the signature does not
271
+ // verify (a malformed signature encoding included) is `signature_invalid`.
272
+ let point = null;
231
273
  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
- });
274
+ point = extractP384Point(leaf.spkiDer);
238
275
  }
239
276
  catch {
240
- base.signatureValid = false;
277
+ point = null;
278
+ }
279
+ if (point) {
280
+ const signedBytes = Uint8Array.from(rawTlv(buf, token.signedAttrs));
281
+ signedBytes[0] = 0x31;
282
+ try {
283
+ base.signatureValid = p384.verify(token.signature, sha512(signedBytes), point, {
284
+ format: 'der',
285
+ prehash: false,
286
+ lowS: false,
287
+ });
288
+ }
289
+ catch {
290
+ base.signatureValid = false;
291
+ }
241
292
  }
293
+ // The token's own faults first, then the chain's: a chain reason is reported only
294
+ // for a token whose signature verifies and whose genTime is within validity.
295
+ if (!base.withinValidity)
296
+ return fail(base, 'genTime_outside_validity');
297
+ if (!point)
298
+ return fail(base, 'unexpected_algorithm');
242
299
  if (!base.signatureValid)
243
300
  return fail(base, 'signature_invalid');
301
+ if (!chain.ok)
302
+ return fail(base, chain.reason ?? 'untrusted_root');
244
303
  return { ...base, verified: true };
245
304
  }
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typedstandards/verify-core",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",