@typedstandards/verify-core 0.9.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
@@ -1,11 +1,133 @@
1
1
  # Changelog — @typedstandards/verify-core
2
2
 
3
- Factual record of what changed per published version. Check numbers (#1–#15)
3
+ Factual record of what changed per published version. Check numbers (#1–#16)
4
4
  refer to the Typed Standards specification §9.2 verification sequence. Issue
5
5
  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
+
82
+ ## 0.10.0 — 2026-09-22
83
+
84
+ Specification v0.1.9 (hub ADR-0029 and ADR-0030, anchored at `typedstandards#77`): a Producer Profile
85
+ for deterministic tool output, a raw-bytes content rule, a content-profile check, and a self-certifying
86
+ signer. **A minor bump** — new exports, and every type-level union below gains members, so a consumer
87
+ with an exhaustive `switch` or `Record` over one of them needs the new cases.
88
+
89
+ - **The `scripted-recomputation` Producer Profile (ADR-0029).** `PROFILE_CAPTURE_VOCAB` gains
90
+ `scripted-recomputation: ['script-run', 'tool-emitted']`, a second stand-in entry under hub Q32, and
91
+ `CaptureMethod` gains both values. Check #15 now resolves a `scripted-recomputation/<subtype>`
92
+ package's capture method instead of reporting `producerProfile_bundle_unresolved`. The two signed
93
+ ADR-0028 eval-run packages read `ok`.
94
+ - **`raw-bytes/v1` (`RAW_BYTES_CANONICALIZATION`).** A third content-canonicalization rule, in
95
+ `KNOWN_CANONICALIZATION_RULES`: `contentHash` fingerprints the exact bytes of `output`.
96
+ - With inline `output`, check #4 hashes its UTF-8 bytes.
97
+ - With a BlobRef `output`, check #4 hashes the file's bytes obtained through the injected fetcher
98
+ (`verifyContentHashWithFetch`; `verifyRecord` runs it):
99
+ - `content_hash_mismatch` when they differ, or when `contentHash.sha256` differs from the hex of
100
+ `output.ref`;
101
+ - the new status `content_bytes_unavailable` when the bytes cannot be obtained. It is never `ok`
102
+ without hashing.
103
+ - `verifyContentHash` stays synchronous and I/O-free, with an optional `outputBytes` argument.
104
+ - **Check #16, the content-profile label (`checkContentProfile`, `CONTENT_PROFILE_STATUSES`,
105
+ `KNOWN_CONTENT_PROFILES`).** Reads `metadata.contentProfile` and reports one of:
106
+ - `ok`;
107
+ - `contentProfile_absent` (read as `"default"`);
108
+ - `contentProfile_unknown`;
109
+ - `contentProfile_inconsistent` (compared with `producerProfile` only when both are present).
110
+
111
+ `VerifyResult` gains `contentProfile`.
112
+ - **The self-certifying signer (ADR-0030).**
113
+ - **The identifier.** `deriveKeyDerivedIdentifier` derives `did:key:z…` from an Ed25519 SPKI
114
+ `publicKey`. `isKeyDerivedIdentifier` and `KEY_DERIVED_IDENTIFIER_PREFIX` recognize one.
115
+ - **Key trust.** `KEY_TRUST_STATUSES` gains `self_certified` (`verified: false`) for a
116
+ `pseudonymous` signer whose identifier matches its signing key.
117
+ - **Check #14.** It gains `key_derived_match` and `key_derived_mismatch` (fatal; the result carries
118
+ `claimed` and `derived`), and never reports `ok` under a key-derived identifier.
119
+ `checkSignerIdentity` takes an optional `publicKey`.
120
+ - **Registry provenance.** `VerifyDeps.registryProvenance` (`TRUST_REGISTRY_PROVENANCES`:
121
+ `'declared-url' | 'bundle'`; absent is treated as `'bundle'`) states where the caller's registry
122
+ came from.
123
+ - For a key-derived identifier, only a `'declared-url'` registry can raise the status.
124
+ - A bundle-carried registry contributes only `revoked` and `deprecated_invalid`.
125
+ - This holds at every tier, and under a mismatch too.
126
+ - **Base-58.** The encoder is written in place; there is no new runtime dependency.
127
+ - **Signers whose identifier is not key-derived are unchanged.** Already-signed packages verify
128
+ exactly as before, apart from checks #15 and #16, which now resolve a `scripted-recomputation`
129
+ profile and report a content profile.
130
+
9
131
  ## 0.9.0 — 2026-08-19
10
132
 
11
133
  Vocabulary settlement (spec v0.1.5 Appendix J; registry Q50/Q66, ADR-0025,
@@ -1,5 +1,8 @@
1
1
  export declare const LEGACY_JSON_CANONICALIZATION = "https://typedstandards.org/canonicalization/legacy-json/v1";
2
2
  export declare const DATHERE_AG_JUPYTER_CANONICALIZATION = "https://typedstandards.org/canonicalization/dathere-ag-jupyter/v1";
3
+ /** hub ADR-0029 §4: the SHA-256 of the bytes of the package's `output`, with no
4
+ * normalization — the digest `sha256sum` prints for the file. */
5
+ export declare const RAW_BYTES_CANONICALIZATION = "https://typedstandards.org/canonicalization/raw-bytes/v1";
3
6
  /** Canonicalization-rule URIs this implementation knows how to apply. An
4
7
  * unknown URI fails verify check #3 (`unknown_canonicalization_rule`). */
5
8
  export declare const KNOWN_CANONICALIZATION_RULES: readonly string[];
@@ -43,5 +46,9 @@ export declare function computeEnvelopeHash(pkg: Record<string, unknown>): strin
43
46
  * (and the sig envelope, which lives off-package here) omitted.
44
47
  * - dathere-ag-jupyter/v1 → the executed notebook object
45
48
  * (`extensions["org.civicaitools.notebook"]` and its rendered outputs).
49
+ * - raw-bytes/v1 → the UTF-8 bytes of an inline string `output`.
50
+ * An `output` that is not a string (a BlobRef, whose bytes live outside the
51
+ * package) throws here: those bytes have to be obtained first, which is what
52
+ * check #4's fetching path (`verifyContentHashWithFetch`) does.
46
53
  */
47
54
  export declare function computeContentHashSha256(pkg: Record<string, unknown>, rule: string): string;
@@ -17,11 +17,15 @@ import { sha256Hex } from "./primitives.js";
17
17
  // targets — verifiers resolve them via the local registry below.
18
18
  export const LEGACY_JSON_CANONICALIZATION = 'https://typedstandards.org/canonicalization/legacy-json/v1';
19
19
  export const DATHERE_AG_JUPYTER_CANONICALIZATION = 'https://typedstandards.org/canonicalization/dathere-ag-jupyter/v1';
20
+ /** hub ADR-0029 §4: the SHA-256 of the bytes of the package's `output`, with no
21
+ * normalization — the digest `sha256sum` prints for the file. */
22
+ export const RAW_BYTES_CANONICALIZATION = 'https://typedstandards.org/canonicalization/raw-bytes/v1';
20
23
  /** Canonicalization-rule URIs this implementation knows how to apply. An
21
24
  * unknown URI fails verify check #3 (`unknown_canonicalization_rule`). */
22
25
  export const KNOWN_CANONICALIZATION_RULES = [
23
26
  LEGACY_JSON_CANONICALIZATION,
24
27
  DATHERE_AG_JUPYTER_CANONICALIZATION,
28
+ RAW_BYTES_CANONICALIZATION,
25
29
  ];
26
30
  /** Notebook extension whose object the dathere-ag-jupyter/v1 rule fingerprints
27
31
  * (spec §8.2, §8.7.2). */
@@ -83,8 +87,19 @@ export function computeEnvelopeHash(pkg) {
83
87
  * (and the sig envelope, which lives off-package here) omitted.
84
88
  * - dathere-ag-jupyter/v1 → the executed notebook object
85
89
  * (`extensions["org.civicaitools.notebook"]` and its rendered outputs).
90
+ * - raw-bytes/v1 → the UTF-8 bytes of an inline string `output`.
91
+ * An `output` that is not a string (a BlobRef, whose bytes live outside the
92
+ * package) throws here: those bytes have to be obtained first, which is what
93
+ * check #4's fetching path (`verifyContentHashWithFetch`) does.
86
94
  */
87
95
  export function computeContentHashSha256(pkg, rule) {
96
+ if (rule === RAW_BYTES_CANONICALIZATION) {
97
+ const output = pkg['output'];
98
+ if (typeof output !== 'string') {
99
+ throw new Error('raw-bytes/v1 content hash over a package alone requires an inline string output');
100
+ }
101
+ return sha256Hex(output);
102
+ }
88
103
  if (rule === DATHERE_AG_JUPYTER_CANONICALIZATION) {
89
104
  const extensions = pkg['extensions'];
90
105
  const notebook = extensions?.[NOTEBOOK_EXTENSION_KEY];
package/dist/checks.d.ts CHANGED
@@ -25,7 +25,7 @@ export interface ContentCanonicalizationResolution {
25
25
  * legacy-json/v1).
26
26
  */
27
27
  export declare function resolveContentCanonicalization(pkg: Record<string, unknown>): ContentCanonicalizationResolution;
28
- export declare const CONTENT_HASH_STATUSES: readonly ["ok", "content_hash_mismatch", "contentHash_no_supported_algorithm", "unresolved_rule", "legacy_relabeled"];
28
+ export declare const CONTENT_HASH_STATUSES: readonly ["ok", "content_hash_mismatch", "contentHash_no_supported_algorithm", "unresolved_rule", "legacy_relabeled", "content_bytes_unavailable"];
29
29
  export type ContentHashStatus = (typeof CONTENT_HASH_STATUSES)[number];
30
30
  export interface ContentHashCheck {
31
31
  status: ContentHashStatus;
@@ -55,8 +55,54 @@ export interface ContentHashCheck {
55
55
  * single-SHA-256 is RELABELED as `{ sha256: <hex> }` per §8.2 rather than
56
56
  * recomputed; its integrity is established by check #1, so check #4 reports
57
57
  * `legacy_relabeled`.
58
+ *
59
+ * raw-bytes/v1 with a BlobRef `output` (hub ADR-0029 §4): the fingerprinted
60
+ * bytes live outside the package. `outputBytes` are those bytes when the caller
61
+ * holds them.
62
+ * - `contentHash.sha256` differs from the hex of `output.ref` →
63
+ * `content_hash_mismatch`, decided from the package alone.
64
+ * - no `outputBytes` → `content_bytes_unavailable`: the bytes were not
65
+ * checked. Never `ok` without hashing.
66
+ * - otherwise the SHA-256 of `outputBytes` is compared with
67
+ * `contentHash.sha256` → `ok` / `content_hash_mismatch`.
68
+ * This function does no I/O; `verifyContentHashWithFetch` obtains the bytes
69
+ * through the injected fetcher and passes them here.
70
+ */
71
+ export declare function verifyContentHash(pkg: Record<string, unknown>, resolution: ContentCanonicalizationResolution, legacyExternalHash?: string, outputBytes?: Uint8Array): ContentHashCheck;
72
+ /**
73
+ * Check #4 with the raw-bytes/v1 BlobRef path wired to the injected fetcher
74
+ * (hub ADR-0029 §4). Identical to `verifyContentHash` for every other rule and
75
+ * shape. For a raw-bytes/v1 package whose `output` is a BlobRef, it obtains the
76
+ * file's bytes through `options.fetch` (an offline verifier supplies a local
77
+ * copy through that fetcher) and hashes them; bytes it cannot obtain leave the
78
+ * status at `content_bytes_unavailable`.
79
+ */
80
+ export declare function verifyContentHashWithFetch(pkg: Record<string, unknown>, resolution: ContentCanonicalizationResolution, legacyExternalHash?: string, options?: BlobFetchOptions): Promise<ContentHashCheck>;
81
+ /** The known `metadata.contentProfile` values (spec §8.1.2). */
82
+ export declare const KNOWN_CONTENT_PROFILES: readonly string[];
83
+ export declare const CONTENT_PROFILE_STATUSES: readonly ["ok", "contentProfile_absent", "contentProfile_unknown", "contentProfile_inconsistent"];
84
+ export type ContentProfileStatus = (typeof CONTENT_PROFILE_STATUSES)[number];
85
+ export interface ContentProfileCheck {
86
+ status: ContentProfileStatus;
87
+ /** `metadata.contentProfile` as carried, when it is a string. */
88
+ contentProfile?: string;
89
+ /** `producerProfile` as carried, when present. */
90
+ producerProfile?: string;
91
+ }
92
+ /**
93
+ * Check #16 — `metadata.contentProfile` (hub ADR-0029 §5).
94
+ * - key absent → `contentProfile_absent` (read as `"default"`, spec §8.1.2).
95
+ * - present and neither `"default"` nor `"datHere"` → `contentProfile_unknown`
96
+ * (reported, not passed; consistency is not judged).
97
+ * - present, known, `producerProfile` present, and the ADR-0006 §2 invariant
98
+ * fails (`contentProfile === "datHere"` iff `producerProfile` starts with
99
+ * `ai-assisted-analysis/datHere`) → `contentProfile_inconsistent`.
100
+ * - otherwise → `ok`.
101
+ * The invariant is compared only when both fields are present. No status is an
102
+ * integrity failure: both labels are signature-covered.
58
103
  */
59
- export declare function verifyContentHash(pkg: Record<string, unknown>, resolution: ContentCanonicalizationResolution, legacyExternalHash?: string): ContentHashCheck;
104
+ export declare function checkContentProfile(pkg: Record<string, unknown>): ContentProfileCheck;
105
+ export declare const KNOWN_TYPE_URIS: readonly string[];
60
106
  export declare const TYPE_RESOLUTION_STATUSES: readonly ["ok", "implicit", "unknown_type"];
61
107
  export type TypeResolutionStatus = (typeof TYPE_RESOLUTION_STATUSES)[number];
62
108
  export interface TypeResolution {
@@ -70,7 +116,47 @@ export interface TypeResolution {
70
116
  * and renders as such — it does NOT fail verification.
71
117
  */
72
118
  export declare function resolvePackageType(pkg: Record<string, unknown>): TypeResolution;
73
- export declare const SIGNER_IDENTITY_CHECK_STATUSES: readonly ["ok", "signer_identity_mismatch", "no_signer", "no_registry_identity"];
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;
159
+ export declare const SIGNER_IDENTITY_CHECK_STATUSES: readonly ["ok", "signer_identity_mismatch", "no_signer", "no_registry_identity", "key_derived_match", "key_derived_mismatch"];
74
160
  export type SignerIdentityCheckStatus = (typeof SIGNER_IDENTITY_CHECK_STATUSES)[number];
75
161
  export interface SignerIdentityCheck {
76
162
  status: SignerIdentityCheckStatus;
@@ -78,6 +164,10 @@ export interface SignerIdentityCheck {
78
164
  claimed?: string;
79
165
  /** The identifier the registry records for the signing `kid`, when present. */
80
166
  registered?: string;
167
+ /** The identifier derived from the envelope's `publicKey`, under a
168
+ * key-derived `signer.identifier` (hub ADR-0030 §2). Absent on a
169
+ * `key_derived_mismatch` whose key could not be decoded. */
170
+ derived?: string;
81
171
  }
82
172
  /**
83
173
  * Check #14 — `signer.identifier` ↔ trust-registry `signerIdentity` cross-check
@@ -86,8 +176,25 @@ export interface SignerIdentityCheck {
86
176
  * verifier derives the signer from the registry and skips the cross-check. A
87
177
  * registry entry without a `signerIdentity` (legacy registry) yields
88
178
  * `no_registry_identity`.
179
+ *
180
+ * A key-derived `signer.identifier` (one beginning `did:key:`, hub ADR-0030 §3)
181
+ * is checked against the envelope's `publicKey` instead, whatever the
182
+ * `bindingTier` and whatever the registry's source:
183
+ * - the identifier derived from `publicKey` differs → `key_derived_mismatch`,
184
+ * fatal, carrying `claimed` and `derived`;
185
+ * - it matches, and a registry entry for the `kid` records a different
186
+ * identifier → `signer_identity_mismatch`, fatal (a contradiction only
187
+ * lowers, so any registry may report it);
188
+ * - it matches otherwise → `key_derived_match`.
189
+ * `ok` ("matches the registry") is never reported under a key-derived
190
+ * identifier. Without `publicKey` the derivation cannot run; the registry
191
+ * cross-check runs as for any signer, except that its `ok` reads
192
+ * `no_registry_identity`, since nothing established the identifier.
89
193
  */
90
- export declare function checkSignerIdentity(pkg: Record<string, unknown>, kid: string | undefined, registry: TrustRegistry | undefined): SignerIdentityCheck;
194
+ export declare function checkSignerIdentity(pkg: Record<string, unknown>, kid: string | undefined, registry: TrustRegistry | undefined,
195
+ /** The signature envelope's base64 SPKI `publicKey`. Read only when the
196
+ * signer's identifier is key-derived. */
197
+ publicKey?: string): SignerIdentityCheck;
91
198
  export declare const CAPTURE_METHOD_VOCAB_STATUSES: readonly ["ok", "captureMethod_unknown", "producerProfile_bundle_unresolved", "no_capture_method"];
92
199
  export type CaptureMethodVocabStatus = (typeof CAPTURE_METHOD_VOCAB_STATUSES)[number];
93
200
  export interface CaptureMethodVocabCheck {
package/dist/checks.js CHANGED
@@ -1,14 +1,17 @@
1
1
  // Content-canonicalization, content-hash, and typed-standards envelope checks
2
- // (spec §9.2 checks #3, #4, #12, #13, #14, #15) + the package-level blob-ref
3
- // walker (#9) — browser-safe.
2
+ // (spec §9.2 checks #3, #4, #6, #12, #13, #14, #15, #16) + the package-level
3
+ // blob-ref walker (#9) — browser-safe.
4
4
  //
5
5
  // Factored verbatim from the server `verify.ts` (WS2); these functions were
6
6
  // already pure JS over the package object. The only environmental change is the
7
7
  // blob-ref walker now threads an injected fetcher. Each check degrades gracefully
8
8
  // for pre-v0.1 packages that omit the corresponding field.
9
- import { computeEnvelopeHash, computeContentHashSha256, isMultihashContentHash, KNOWN_CANONICALIZATION_RULES, LEGACY_JSON_CANONICALIZATION, DATHERE_AG_JUPYTER_CANONICALIZATION, } from "./canonicalization.js";
9
+ import { computeEnvelopeHash, computeContentHashSha256, isMultihashContentHash, KNOWN_CANONICALIZATION_RULES, LEGACY_JSON_CANONICALIZATION, DATHERE_AG_JUPYTER_CANONICALIZATION, RAW_BYTES_CANONICALIZATION, } from "./canonicalization.js";
10
+ import { sha256Hex } from "./primitives.js";
10
11
  import { captureVocabForProfile, resolveProfileType } from "./profiles.js";
11
- import { isBlobRef, verifyBlobRef, } from "./blob-ref.js";
12
+ import { isBlobRef, parseBlobRef, verifyBlobRef, } from "./blob-ref.js";
13
+ import { isKeyDerivedIdentifier } from "./did-key.js";
14
+ import { compareKeyDerivedIdentifier } from "./self-certifying.js";
12
15
  /**
13
16
  * Recompute a package's envelope hash, routed by the §8.2 detection rule: v0.1
14
17
  * packages (multihash `contentHash` present) hash via RFC 8785 JCS; pre-v0.1
@@ -65,6 +68,7 @@ export const CONTENT_HASH_STATUSES = [
65
68
  'contentHash_no_supported_algorithm',
66
69
  'unresolved_rule',
67
70
  'legacy_relabeled',
71
+ 'content_bytes_unavailable',
68
72
  ];
69
73
  /** Multihash algorithms this verifier can recompute today (spec §8.2 lists
70
74
  * sha256 required + sha3-256 / blake3 as registered alternates). */
@@ -86,8 +90,20 @@ const SUPPORTED_CONTENT_HASH_ALGORITHMS = ['sha256'];
86
90
  * single-SHA-256 is RELABELED as `{ sha256: <hex> }` per §8.2 rather than
87
91
  * recomputed; its integrity is established by check #1, so check #4 reports
88
92
  * `legacy_relabeled`.
93
+ *
94
+ * raw-bytes/v1 with a BlobRef `output` (hub ADR-0029 §4): the fingerprinted
95
+ * bytes live outside the package. `outputBytes` are those bytes when the caller
96
+ * holds them.
97
+ * - `contentHash.sha256` differs from the hex of `output.ref` →
98
+ * `content_hash_mismatch`, decided from the package alone.
99
+ * - no `outputBytes` → `content_bytes_unavailable`: the bytes were not
100
+ * checked. Never `ok` without hashing.
101
+ * - otherwise the SHA-256 of `outputBytes` is compared with
102
+ * `contentHash.sha256` → `ok` / `content_hash_mismatch`.
103
+ * This function does no I/O; `verifyContentHashWithFetch` obtains the bytes
104
+ * through the injected fetcher and passes them here.
89
105
  */
90
- export function verifyContentHash(pkg, resolution, legacyExternalHash) {
106
+ export function verifyContentHash(pkg, resolution, legacyExternalHash, outputBytes) {
91
107
  const contentHash = pkg['contentHash'];
92
108
  if (!isMultihashContentHash(contentHash)) {
93
109
  return {
@@ -107,6 +123,21 @@ export function verifyContentHash(pkg, resolution, legacyExternalHash) {
107
123
  contentHash,
108
124
  };
109
125
  }
126
+ const output = pkg['output'];
127
+ if (resolution.rule === RAW_BYTES_CANONICALIZATION && isBlobRef(output)) {
128
+ // sha256 is the only supported algorithm, so `checkable` is ['sha256'] here.
129
+ const signed = contentHash['sha256'];
130
+ if (signed !== parseBlobRef(output.ref).hash) {
131
+ // No file can hash to two different signed digests.
132
+ return { status: 'content_hash_mismatch', algorithms, contentHash };
133
+ }
134
+ if (outputBytes === undefined) {
135
+ return { status: 'content_bytes_unavailable', algorithms, contentHash };
136
+ }
137
+ return sha256Hex(outputBytes) === signed
138
+ ? { status: 'ok', algorithms, matched: 'sha256', contentHash }
139
+ : { status: 'content_hash_mismatch', algorithms, contentHash };
140
+ }
110
141
  for (const algo of checkable) {
111
142
  let recomputed;
112
143
  try {
@@ -130,16 +161,102 @@ export function verifyContentHash(pkg, resolution, legacyExternalHash) {
130
161
  }
131
162
  return { status: 'content_hash_mismatch', algorithms, contentHash };
132
163
  }
164
+ /** Fetch a BlobRef's bytes through the injected fetcher (defaulting to
165
+ * `globalThis.fetch`, the same default check #9 uses). `undefined` on any
166
+ * failure. */
167
+ async function fetchBlobRefBytes(ref, options) {
168
+ try {
169
+ const fetcher = options.fetch ?? globalThis.fetch;
170
+ const response = await fetcher(ref.url, {
171
+ signal: options.signal ?? AbortSignal.timeout(15_000),
172
+ });
173
+ if (!response.ok)
174
+ return undefined;
175
+ return new Uint8Array(await response.arrayBuffer());
176
+ }
177
+ catch {
178
+ return undefined;
179
+ }
180
+ }
181
+ /**
182
+ * Check #4 with the raw-bytes/v1 BlobRef path wired to the injected fetcher
183
+ * (hub ADR-0029 §4). Identical to `verifyContentHash` for every other rule and
184
+ * shape. For a raw-bytes/v1 package whose `output` is a BlobRef, it obtains the
185
+ * file's bytes through `options.fetch` (an offline verifier supplies a local
186
+ * copy through that fetcher) and hashes them; bytes it cannot obtain leave the
187
+ * status at `content_bytes_unavailable`.
188
+ */
189
+ export async function verifyContentHashWithFetch(pkg, resolution, legacyExternalHash, options = {}) {
190
+ const first = verifyContentHash(pkg, resolution, legacyExternalHash);
191
+ const output = pkg['output'];
192
+ if (first.status !== 'content_bytes_unavailable' || !isBlobRef(output)) {
193
+ return first;
194
+ }
195
+ const bytes = await fetchBlobRefBytes(output, options);
196
+ if (bytes === undefined)
197
+ return first;
198
+ return verifyContentHash(pkg, resolution, legacyExternalHash, bytes);
199
+ }
200
+ // --- Content-profile check (spec §9.2 check #16; hub ADR-0029 §5) ---
201
+ /** The known `metadata.contentProfile` values (spec §8.1.2). */
202
+ export const KNOWN_CONTENT_PROFILES = ['default', 'datHere'];
203
+ export const CONTENT_PROFILE_STATUSES = [
204
+ 'ok',
205
+ 'contentProfile_absent',
206
+ 'contentProfile_unknown',
207
+ 'contentProfile_inconsistent',
208
+ ];
209
+ /**
210
+ * Check #16 — `metadata.contentProfile` (hub ADR-0029 §5).
211
+ * - key absent → `contentProfile_absent` (read as `"default"`, spec §8.1.2).
212
+ * - present and neither `"default"` nor `"datHere"` → `contentProfile_unknown`
213
+ * (reported, not passed; consistency is not judged).
214
+ * - present, known, `producerProfile` present, and the ADR-0006 §2 invariant
215
+ * fails (`contentProfile === "datHere"` iff `producerProfile` starts with
216
+ * `ai-assisted-analysis/datHere`) → `contentProfile_inconsistent`.
217
+ * - otherwise → `ok`.
218
+ * The invariant is compared only when both fields are present. No status is an
219
+ * integrity failure: both labels are signature-covered.
220
+ */
221
+ export function checkContentProfile(pkg) {
222
+ const metadata = pkg['metadata'];
223
+ const raw = typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata)
224
+ ? metadata['contentProfile']
225
+ : undefined;
226
+ const producerProfile = typeof pkg['producerProfile'] === 'string' ? pkg['producerProfile'] : undefined;
227
+ const base = {
228
+ ...(typeof raw === 'string' ? { contentProfile: raw } : {}),
229
+ ...(producerProfile !== undefined ? { producerProfile } : {}),
230
+ };
231
+ if (raw === undefined) {
232
+ return { status: 'contentProfile_absent', ...base };
233
+ }
234
+ if (typeof raw !== 'string' || !KNOWN_CONTENT_PROFILES.includes(raw)) {
235
+ return { status: 'contentProfile_unknown', ...base };
236
+ }
237
+ if (producerProfile !== undefined) {
238
+ const isDatHere = raw === 'datHere';
239
+ const profileIsDatHere = producerProfile.startsWith('ai-assisted-analysis/datHere');
240
+ if (isDatHere !== profileIsDatHere) {
241
+ return { status: 'contentProfile_inconsistent', ...base };
242
+ }
243
+ }
244
+ return { status: 'ok', ...base };
245
+ }
133
246
  // --- Typed-standards envelope checks (spec §9.2 checks #12, #14, #15) ---
134
247
  // v0.1 ratified node type URIs (spec §8.12.1). Recognized so check #12 resolves
135
248
  // them; only `content/analysis/v1` (+ the lifecycle sub-types) are operationalized
136
249
  // today, but the full ratified set is registered so conformant packages don't
137
- // render as `unknown_type`.
138
- 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([
139
255
  'content/analysis/v1',
140
256
  'attestation/withdraws/v1',
141
257
  'attestation/reinstates/v1',
142
258
  'attestation/supersedes/v1',
259
+ 'attestation/revises/v1',
143
260
  'attestation/publishes/v1',
144
261
  'attestation/locatedAt/v1',
145
262
  'attestation/corroborates/v1',
@@ -152,7 +269,7 @@ const KNOWN_TYPE_URIS = [
152
269
  'attestation/certifies/v1',
153
270
  'attestation/evaluates/v1',
154
271
  'attestation/conforms/v1',
155
- ];
272
+ ]);
156
273
  export const TYPE_RESOLUTION_STATUSES = ['ok', 'implicit', 'unknown_type'];
157
274
  /**
158
275
  * Check #12 — `type` resolution. An absent field resolves to the implicit
@@ -169,11 +286,70 @@ export function resolvePackageType(pkg) {
169
286
  }
170
287
  return { status: 'unknown_type', type: raw };
171
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
+ }
172
346
  export const SIGNER_IDENTITY_CHECK_STATUSES = [
173
347
  'ok',
174
348
  'signer_identity_mismatch',
175
349
  'no_signer',
176
350
  'no_registry_identity',
351
+ 'key_derived_match',
352
+ 'key_derived_mismatch',
177
353
  ];
178
354
  /**
179
355
  * Check #14 — `signer.identifier` ↔ trust-registry `signerIdentity` cross-check
@@ -182,14 +358,53 @@ export const SIGNER_IDENTITY_CHECK_STATUSES = [
182
358
  * verifier derives the signer from the registry and skips the cross-check. A
183
359
  * registry entry without a `signerIdentity` (legacy registry) yields
184
360
  * `no_registry_identity`.
361
+ *
362
+ * A key-derived `signer.identifier` (one beginning `did:key:`, hub ADR-0030 §3)
363
+ * is checked against the envelope's `publicKey` instead, whatever the
364
+ * `bindingTier` and whatever the registry's source:
365
+ * - the identifier derived from `publicKey` differs → `key_derived_mismatch`,
366
+ * fatal, carrying `claimed` and `derived`;
367
+ * - it matches, and a registry entry for the `kid` records a different
368
+ * identifier → `signer_identity_mismatch`, fatal (a contradiction only
369
+ * lowers, so any registry may report it);
370
+ * - it matches otherwise → `key_derived_match`.
371
+ * `ok` ("matches the registry") is never reported under a key-derived
372
+ * identifier. Without `publicKey` the derivation cannot run; the registry
373
+ * cross-check runs as for any signer, except that its `ok` reads
374
+ * `no_registry_identity`, since nothing established the identifier.
185
375
  */
186
- export function checkSignerIdentity(pkg, kid, registry) {
376
+ export function checkSignerIdentity(pkg, kid, registry,
377
+ /** The signature envelope's base64 SPKI `publicKey`. Read only when the
378
+ * signer's identifier is key-derived. */
379
+ publicKey) {
187
380
  const signer = pkg['signer'];
188
381
  if (!signer || typeof signer !== 'object' || typeof signer.identifier !== 'string') {
189
382
  return { status: 'no_signer' };
190
383
  }
191
384
  const entry = kid && registry ? registry.keys.find((k) => k.kid === kid) : undefined;
192
385
  const registered = entry?.signerIdentity?.identifier;
386
+ if (isKeyDerivedIdentifier(signer.identifier)) {
387
+ const comparison = compareKeyDerivedIdentifier(signer, publicKey);
388
+ if (comparison && !comparison.match) {
389
+ return {
390
+ status: 'key_derived_mismatch',
391
+ claimed: comparison.claimed,
392
+ ...(comparison.derived !== undefined ? { derived: comparison.derived } : {}),
393
+ };
394
+ }
395
+ if (registered && registered !== signer.identifier) {
396
+ return { status: 'signer_identity_mismatch', claimed: signer.identifier, registered };
397
+ }
398
+ if (comparison) {
399
+ return {
400
+ status: 'key_derived_match',
401
+ claimed: signer.identifier,
402
+ derived: comparison.derived,
403
+ ...(registered ? { registered } : {}),
404
+ };
405
+ }
406
+ return { status: 'no_registry_identity', claimed: signer.identifier };
407
+ }
193
408
  if (!registered) {
194
409
  return { status: 'no_registry_identity', claimed: signer.identifier };
195
410
  }