@typedstandards/verify-core 0.9.0 → 0.10.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,60 @@
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.10.0 — 2026-09-22
10
+
11
+ Specification v0.1.9 (hub ADR-0029 and ADR-0030, anchored at `typedstandards#77`): a Producer Profile
12
+ for deterministic tool output, a raw-bytes content rule, a content-profile check, and a self-certifying
13
+ signer. **A minor bump** — new exports, and every type-level union below gains members, so a consumer
14
+ with an exhaustive `switch` or `Record` over one of them needs the new cases.
15
+
16
+ - **The `scripted-recomputation` Producer Profile (ADR-0029).** `PROFILE_CAPTURE_VOCAB` gains
17
+ `scripted-recomputation: ['script-run', 'tool-emitted']`, a second stand-in entry under hub Q32, and
18
+ `CaptureMethod` gains both values. Check #15 now resolves a `scripted-recomputation/<subtype>`
19
+ package's capture method instead of reporting `producerProfile_bundle_unresolved`. The two signed
20
+ ADR-0028 eval-run packages read `ok`.
21
+ - **`raw-bytes/v1` (`RAW_BYTES_CANONICALIZATION`).** A third content-canonicalization rule, in
22
+ `KNOWN_CANONICALIZATION_RULES`: `contentHash` fingerprints the exact bytes of `output`.
23
+ - With inline `output`, check #4 hashes its UTF-8 bytes.
24
+ - With a BlobRef `output`, check #4 hashes the file's bytes obtained through the injected fetcher
25
+ (`verifyContentHashWithFetch`; `verifyRecord` runs it):
26
+ - `content_hash_mismatch` when they differ, or when `contentHash.sha256` differs from the hex of
27
+ `output.ref`;
28
+ - the new status `content_bytes_unavailable` when the bytes cannot be obtained. It is never `ok`
29
+ without hashing.
30
+ - `verifyContentHash` stays synchronous and I/O-free, with an optional `outputBytes` argument.
31
+ - **Check #16, the content-profile label (`checkContentProfile`, `CONTENT_PROFILE_STATUSES`,
32
+ `KNOWN_CONTENT_PROFILES`).** Reads `metadata.contentProfile` and reports one of:
33
+ - `ok`;
34
+ - `contentProfile_absent` (read as `"default"`);
35
+ - `contentProfile_unknown`;
36
+ - `contentProfile_inconsistent` (compared with `producerProfile` only when both are present).
37
+
38
+ `VerifyResult` gains `contentProfile`.
39
+ - **The self-certifying signer (ADR-0030).**
40
+ - **The identifier.** `deriveKeyDerivedIdentifier` derives `did:key:z…` from an Ed25519 SPKI
41
+ `publicKey`. `isKeyDerivedIdentifier` and `KEY_DERIVED_IDENTIFIER_PREFIX` recognize one.
42
+ - **Key trust.** `KEY_TRUST_STATUSES` gains `self_certified` (`verified: false`) for a
43
+ `pseudonymous` signer whose identifier matches its signing key.
44
+ - **Check #14.** It gains `key_derived_match` and `key_derived_mismatch` (fatal; the result carries
45
+ `claimed` and `derived`), and never reports `ok` under a key-derived identifier.
46
+ `checkSignerIdentity` takes an optional `publicKey`.
47
+ - **Registry provenance.** `VerifyDeps.registryProvenance` (`TRUST_REGISTRY_PROVENANCES`:
48
+ `'declared-url' | 'bundle'`; absent is treated as `'bundle'`) states where the caller's registry
49
+ came from.
50
+ - For a key-derived identifier, only a `'declared-url'` registry can raise the status.
51
+ - A bundle-carried registry contributes only `revoked` and `deprecated_invalid`.
52
+ - This holds at every tier, and under a mismatch too.
53
+ - **Base-58.** The encoder is written in place; there is no new runtime dependency.
54
+ - **Signers whose identifier is not key-derived are unchanged.** Already-signed packages verify
55
+ exactly as before, apart from checks #15 and #16, which now resolve a `scripted-recomputation`
56
+ profile and report a content profile.
57
+
9
58
  ## 0.9.0 — 2026-08-19
10
59
 
11
60
  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,53 @@ 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`.
58
79
  */
59
- export declare function verifyContentHash(pkg: Record<string, unknown>, resolution: ContentCanonicalizationResolution, legacyExternalHash?: string): ContentHashCheck;
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.
103
+ */
104
+ export declare function checkContentProfile(pkg: Record<string, unknown>): ContentProfileCheck;
60
105
  export declare const TYPE_RESOLUTION_STATUSES: readonly ["ok", "implicit", "unknown_type"];
61
106
  export type TypeResolutionStatus = (typeof TYPE_RESOLUTION_STATUSES)[number];
62
107
  export interface TypeResolution {
@@ -70,7 +115,7 @@ export interface TypeResolution {
70
115
  * and renders as such — it does NOT fail verification.
71
116
  */
72
117
  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"];
118
+ export declare const SIGNER_IDENTITY_CHECK_STATUSES: readonly ["ok", "signer_identity_mismatch", "no_signer", "no_registry_identity", "key_derived_match", "key_derived_mismatch"];
74
119
  export type SignerIdentityCheckStatus = (typeof SIGNER_IDENTITY_CHECK_STATUSES)[number];
75
120
  export interface SignerIdentityCheck {
76
121
  status: SignerIdentityCheckStatus;
@@ -78,6 +123,10 @@ export interface SignerIdentityCheck {
78
123
  claimed?: string;
79
124
  /** The identifier the registry records for the signing `kid`, when present. */
80
125
  registered?: string;
126
+ /** The identifier derived from the envelope's `publicKey`, under a
127
+ * key-derived `signer.identifier` (hub ADR-0030 §2). Absent on a
128
+ * `key_derived_mismatch` whose key could not be decoded. */
129
+ derived?: string;
81
130
  }
82
131
  /**
83
132
  * Check #14 — `signer.identifier` ↔ trust-registry `signerIdentity` cross-check
@@ -86,8 +135,25 @@ export interface SignerIdentityCheck {
86
135
  * verifier derives the signer from the registry and skips the cross-check. A
87
136
  * registry entry without a `signerIdentity` (legacy registry) yields
88
137
  * `no_registry_identity`.
138
+ *
139
+ * A key-derived `signer.identifier` (one beginning `did:key:`, hub ADR-0030 §3)
140
+ * is checked against the envelope's `publicKey` instead, whatever the
141
+ * `bindingTier` and whatever the registry's source:
142
+ * - the identifier derived from `publicKey` differs → `key_derived_mismatch`,
143
+ * fatal, carrying `claimed` and `derived`;
144
+ * - it matches, and a registry entry for the `kid` records a different
145
+ * identifier → `signer_identity_mismatch`, fatal (a contradiction only
146
+ * lowers, so any registry may report it);
147
+ * - it matches otherwise → `key_derived_match`.
148
+ * `ok` ("matches the registry") is never reported under a key-derived
149
+ * identifier. Without `publicKey` the derivation cannot run; the registry
150
+ * cross-check runs as for any signer, except that its `ok` reads
151
+ * `no_registry_identity`, since nothing established the identifier.
89
152
  */
90
- export declare function checkSignerIdentity(pkg: Record<string, unknown>, kid: string | undefined, registry: TrustRegistry | undefined): SignerIdentityCheck;
153
+ export declare function checkSignerIdentity(pkg: Record<string, unknown>, kid: string | undefined, registry: TrustRegistry | undefined,
154
+ /** The signature envelope's base64 SPKI `publicKey`. Read only when the
155
+ * signer's identifier is key-derived. */
156
+ publicKey?: string): SignerIdentityCheck;
91
157
  export declare const CAPTURE_METHOD_VOCAB_STATUSES: readonly ["ok", "captureMethod_unknown", "producerProfile_bundle_unresolved", "no_capture_method"];
92
158
  export type CaptureMethodVocabStatus = (typeof CAPTURE_METHOD_VOCAB_STATUSES)[number];
93
159
  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, #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,6 +161,88 @@ 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
@@ -174,6 +287,8 @@ export const SIGNER_IDENTITY_CHECK_STATUSES = [
174
287
  'signer_identity_mismatch',
175
288
  'no_signer',
176
289
  'no_registry_identity',
290
+ 'key_derived_match',
291
+ 'key_derived_mismatch',
177
292
  ];
178
293
  /**
179
294
  * Check #14 — `signer.identifier` ↔ trust-registry `signerIdentity` cross-check
@@ -182,14 +297,53 @@ export const SIGNER_IDENTITY_CHECK_STATUSES = [
182
297
  * verifier derives the signer from the registry and skips the cross-check. A
183
298
  * registry entry without a `signerIdentity` (legacy registry) yields
184
299
  * `no_registry_identity`.
300
+ *
301
+ * A key-derived `signer.identifier` (one beginning `did:key:`, hub ADR-0030 §3)
302
+ * is checked against the envelope's `publicKey` instead, whatever the
303
+ * `bindingTier` and whatever the registry's source:
304
+ * - the identifier derived from `publicKey` differs → `key_derived_mismatch`,
305
+ * fatal, carrying `claimed` and `derived`;
306
+ * - it matches, and a registry entry for the `kid` records a different
307
+ * identifier → `signer_identity_mismatch`, fatal (a contradiction only
308
+ * lowers, so any registry may report it);
309
+ * - it matches otherwise → `key_derived_match`.
310
+ * `ok` ("matches the registry") is never reported under a key-derived
311
+ * identifier. Without `publicKey` the derivation cannot run; the registry
312
+ * cross-check runs as for any signer, except that its `ok` reads
313
+ * `no_registry_identity`, since nothing established the identifier.
185
314
  */
186
- export function checkSignerIdentity(pkg, kid, registry) {
315
+ export function checkSignerIdentity(pkg, kid, registry,
316
+ /** The signature envelope's base64 SPKI `publicKey`. Read only when the
317
+ * signer's identifier is key-derived. */
318
+ publicKey) {
187
319
  const signer = pkg['signer'];
188
320
  if (!signer || typeof signer !== 'object' || typeof signer.identifier !== 'string') {
189
321
  return { status: 'no_signer' };
190
322
  }
191
323
  const entry = kid && registry ? registry.keys.find((k) => k.kid === kid) : undefined;
192
324
  const registered = entry?.signerIdentity?.identifier;
325
+ if (isKeyDerivedIdentifier(signer.identifier)) {
326
+ const comparison = compareKeyDerivedIdentifier(signer, publicKey);
327
+ if (comparison && !comparison.match) {
328
+ return {
329
+ status: 'key_derived_mismatch',
330
+ claimed: comparison.claimed,
331
+ ...(comparison.derived !== undefined ? { derived: comparison.derived } : {}),
332
+ };
333
+ }
334
+ if (registered && registered !== signer.identifier) {
335
+ return { status: 'signer_identity_mismatch', claimed: signer.identifier, registered };
336
+ }
337
+ if (comparison) {
338
+ return {
339
+ status: 'key_derived_match',
340
+ claimed: signer.identifier,
341
+ derived: comparison.derived,
342
+ ...(registered ? { registered } : {}),
343
+ };
344
+ }
345
+ return { status: 'no_registry_identity', claimed: signer.identifier };
346
+ }
193
347
  if (!registered) {
194
348
  return { status: 'no_registry_identity', claimed: signer.identifier };
195
349
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Base-58 encoding, Bitcoin alphabet: big-endian base conversion, one leading
3
+ * `1` per leading zero byte (draft-msporny-base58-03). Not exported from the
4
+ * package entry point; the derivation below is its one shipped caller.
5
+ */
6
+ export declare function base58btcEncode(bytes: Uint8Array): string;
7
+ /** The prefix that makes a `signer.identifier` key-derived (ADR-0030 §3). */
8
+ export declare const KEY_DERIVED_IDENTIFIER_PREFIX = "did:key:";
9
+ /**
10
+ * Derive the key-derived identifier from a base64 SPKI DER Ed25519 public key
11
+ * (the signature envelope's `publicKey`), per ADR-0030 §2:
12
+ * 1. base64-decode to 44 bytes whose first 12 are the Ed25519 SPKI prefix;
13
+ * 2. take the trailing 32 bytes (the raw RFC 8032 public key);
14
+ * 3. prepend `ed 01`;
15
+ * 4. base58btc-encode;
16
+ * 5. prefix `did:key:z`.
17
+ * Steps 1-2 are `extractRawPublicKey`, which throws on a malformed key; so
18
+ * does this function.
19
+ */
20
+ export declare function deriveKeyDerivedIdentifier(publicKeyB64Der: string): string;
21
+ /**
22
+ * Whether a `signer.identifier` is key-derived: it begins `did:key:`
23
+ * (ADR-0030 §3). The prefix alone decides — a `did:key:u…` spelling, or a
24
+ * `did:key:z…` for another key type, is key-derived and can never equal an
25
+ * Ed25519 derivation, so it is a fatal mismatch. `bindingTier` does not enter
26
+ * this decision.
27
+ */
28
+ export declare function isKeyDerivedIdentifier(identifier: unknown): identifier is string;
@@ -0,0 +1,74 @@
1
+ // The key-derived signer identifier (hub ADR-0030 §2, §3) — browser-safe.
2
+ //
3
+ // A signer with no domain names itself by its public key: `did:key` in its
4
+ // base58btc (`z`) form for an Ed25519 key (The did:key Method v0.9, W3C CCG
5
+ // draft; multicodec `ed25519-pub` 0xed; multibase `z`). A verifier recomputes
6
+ // the identifier from the envelope's `publicKey` and compares it with the
7
+ // package's `signer.identifier` byte for byte. It never decodes the claimed
8
+ // string, so this module has an encoder and no decoder.
9
+ //
10
+ // The base-58 encoder is written in place (ADR-0030 §6): no runtime
11
+ // dependency, no `Buffer`, no Node built-in.
12
+ import { extractRawPublicKey } from "./signature.js";
13
+ /** The Bitcoin base-58 alphabet (draft-msporny-base58-03). */
14
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
15
+ /**
16
+ * Base-58 encoding, Bitcoin alphabet: big-endian base conversion, one leading
17
+ * `1` per leading zero byte (draft-msporny-base58-03). Not exported from the
18
+ * package entry point; the derivation below is its one shipped caller.
19
+ */
20
+ export function base58btcEncode(bytes) {
21
+ let zeros = 0;
22
+ while (zeros < bytes.length && bytes[zeros] === 0)
23
+ zeros++;
24
+ // Base-58 digits, least significant first.
25
+ const digits = [];
26
+ for (let i = zeros; i < bytes.length; i++) {
27
+ let carry = bytes[i];
28
+ for (let j = 0; j < digits.length; j++) {
29
+ carry += digits[j] * 256;
30
+ digits[j] = carry % 58;
31
+ carry = Math.floor(carry / 58);
32
+ }
33
+ while (carry > 0) {
34
+ digits.push(carry % 58);
35
+ carry = Math.floor(carry / 58);
36
+ }
37
+ }
38
+ let out = '1'.repeat(zeros);
39
+ for (let k = digits.length - 1; k >= 0; k--)
40
+ out += BASE58_ALPHABET[digits[k]];
41
+ return out;
42
+ }
43
+ /** The prefix that makes a `signer.identifier` key-derived (ADR-0030 §3). */
44
+ export const KEY_DERIVED_IDENTIFIER_PREFIX = 'did:key:';
45
+ /** The unsigned-varint encoding of multicodec `ed25519-pub` (0xed). */
46
+ const ED25519_PUB_MULTICODEC = Uint8Array.from([0xed, 0x01]);
47
+ /**
48
+ * Derive the key-derived identifier from a base64 SPKI DER Ed25519 public key
49
+ * (the signature envelope's `publicKey`), per ADR-0030 §2:
50
+ * 1. base64-decode to 44 bytes whose first 12 are the Ed25519 SPKI prefix;
51
+ * 2. take the trailing 32 bytes (the raw RFC 8032 public key);
52
+ * 3. prepend `ed 01`;
53
+ * 4. base58btc-encode;
54
+ * 5. prefix `did:key:z`.
55
+ * Steps 1-2 are `extractRawPublicKey`, which throws on a malformed key; so
56
+ * does this function.
57
+ */
58
+ export function deriveKeyDerivedIdentifier(publicKeyB64Der) {
59
+ const raw = extractRawPublicKey(publicKeyB64Der);
60
+ const mc = new Uint8Array(ED25519_PUB_MULTICODEC.length + raw.length);
61
+ mc.set(ED25519_PUB_MULTICODEC, 0);
62
+ mc.set(raw, ED25519_PUB_MULTICODEC.length);
63
+ return `${KEY_DERIVED_IDENTIFIER_PREFIX}z${base58btcEncode(mc)}`;
64
+ }
65
+ /**
66
+ * Whether a `signer.identifier` is key-derived: it begins `did:key:`
67
+ * (ADR-0030 §3). The prefix alone decides — a `did:key:u…` spelling, or a
68
+ * `did:key:z…` for another key type, is key-derived and can never equal an
69
+ * Ed25519 derivation, so it is a fatal mismatch. `bindingTier` does not enter
70
+ * this decision.
71
+ */
72
+ export function isKeyDerivedIdentifier(identifier) {
73
+ return typeof identifier === 'string' && identifier.startsWith(KEY_DERIVED_IDENTIFIER_PREFIX);
74
+ }
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export * from './attestation.ts';
6
6
  export * from './blob-ref.ts';
7
7
  export * from './signature.ts';
8
8
  export * from './trust-registry.ts';
9
+ export { deriveKeyDerivedIdentifier, isKeyDerivedIdentifier, KEY_DERIVED_IDENTIFIER_PREFIX, } from './did-key.ts';
9
10
  export * from './checks.ts';
10
11
  export * from './lifecycle.ts';
11
12
  export * from './rekor.ts';
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ export * from "./attestation.js";
20
20
  export * from "./blob-ref.js";
21
21
  export * from "./signature.js";
22
22
  export * from "./trust-registry.js";
23
+ export { deriveKeyDerivedIdentifier, isKeyDerivedIdentifier, KEY_DERIVED_IDENTIFIER_PREFIX, } from "./did-key.js";
23
24
  export * from "./checks.js";
24
25
  export * from "./lifecycle.js";
25
26
  export * from "./rekor.js";
package/dist/profiles.js CHANGED
@@ -10,6 +10,12 @@ export const PROFILE_CAPTURE_VOCAB = {
10
10
  'claude-code-jsonl-readback',
11
11
  'claude-code-self-report',
12
12
  ],
13
+ // Status note, 2026-09-21 (hub ADR-0029 §2, Q32 still open): a second
14
+ // stand-in entry. A program computed the content from inputs pinned by hash;
15
+ // `script-run` = a packaging program read files already on disk, `tool-emitted`
16
+ // = the computing program wrote the package itself. The guidance-bundle routing
17
+ // convention that replaces this table is not settled by this entry.
18
+ 'scripted-recomputation': ['script-run', 'tool-emitted'],
13
19
  };
14
20
  /**
15
21
  * Resolve a package's Producer Profile *type* (the segment before the first `/`
@@ -0,0 +1,46 @@
1
+ import type { KeyTrustResult, TrustRegistryProvenance } from './trust-registry.ts';
2
+ /** The outcome of comparing a key-derived `signer.identifier` with the
3
+ * identifier derived from the envelope's `publicKey` (ADR-0030 §2). */
4
+ export interface KeyDerivedComparison {
5
+ claimed: string;
6
+ /** Absent when the derivation failed (a malformed key). */
7
+ derived?: string;
8
+ match: boolean;
9
+ }
10
+ /**
11
+ * Compare the package's `signer.identifier` with the identifier derived from
12
+ * the envelope's `publicKey`. Returns `null` when the self-certifying check
13
+ * does not run: no signer, an identifier that is not key-derived, or no
14
+ * envelope key. A failed derivation is a mismatch with no `derived` value.
15
+ */
16
+ export declare function compareKeyDerivedIdentifier(signer: unknown, publicKey: string | undefined): KeyDerivedComparison | null;
17
+ /**
18
+ * G2-B for a key-derived identifier, whether or not it matched its envelope
19
+ * key (ADR-0030 §4 rule 3 as amended; spec v0.1.9 §9.4): only a registry
20
+ * fetched from the declared `trustRegistryUrl` can raise the status. Returns
21
+ * `base` unless it is a raising verdict (`active`, `deprecated_valid`) from a
22
+ * bundle-carried or provenance-less registry; that verdict is ignored and the
23
+ * status is what the envelope alone yields with no registry supplied
24
+ * (`registry_unavailable` with a `kid`, `legacy_embedded` without),
25
+ * `verified: false`. Lowering and unlisted verdicts pass through.
26
+ */
27
+ export declare function applyKeyDerivedRegistryRule(base: KeyTrustResult, provenance: TrustRegistryProvenance | undefined): KeyTrustResult;
28
+ /**
29
+ * Apply ADR-0030 §4 rules 2-3, as amended at gate G2, to the registry-path
30
+ * verdict `base`, for a package whose key-derived identifier MATCHED its
31
+ * envelope key. (A mismatch takes `applyKeyDerivedRegistryRule` alone.)
32
+ *
33
+ * For a key-derived identifier, at any `bindingTier`:
34
+ * - A registry from the declared `trustRegistryUrl` that lists the
35
+ * envelope's `(kid, publicKey)` decides: its verdict stands.
36
+ * - Any other registry (bundle-carried, or no stated provenance)
37
+ * contributes only a lowering verdict (`revoked`, `deprecated_invalid`).
38
+ * A raising one (`active`, `deprecated_valid`) is ignored.
39
+ * - With no registry listing the key, or a raising verdict ignored, the
40
+ * status is `self_certified` at `pseudonymous`; at any other tier it is
41
+ * what the envelope alone yields with no registry supplied
42
+ * (`registry_unavailable` with a `kid`, `legacy_embedded` without) — never
43
+ * `active`, never `verified: true`. `self_certified` is reserved for the
44
+ * `pseudonymous` rung (§3).
45
+ */
46
+ export declare function applySelfCertifiedKeyTrust(base: KeyTrustResult, bindingTier: unknown, provenance: TrustRegistryProvenance | undefined): KeyTrustResult;
@@ -0,0 +1,89 @@
1
+ // The self-certifying signer's key-trust rule (hub ADR-0030 §3, §4) —
2
+ // browser-safe. Internal to verify-core: `verifyRecord` applies it; the
3
+ // package entry point does not re-export it.
4
+ import { deriveKeyDerivedIdentifier, isKeyDerivedIdentifier } from "./did-key.js";
5
+ /** The one `bindingTier` under which a derived match is `self_certified`. */
6
+ const SELF_CERTIFYING_TIER = 'pseudonymous';
7
+ /** Verdicts that say the key must not be trusted for this signature. A
8
+ * bundle-carried registry may report these about a self-certified signer. */
9
+ const LOWERING_STATUSES = new Set(['revoked', 'deprecated_invalid']);
10
+ /** Verdicts that mean no registry listed the envelope's `(kid, publicKey)`. */
11
+ const UNLISTED_STATUSES = new Set([
12
+ 'registry_unavailable',
13
+ 'unknown_key',
14
+ 'legacy_embedded',
15
+ ]);
16
+ /**
17
+ * Compare the package's `signer.identifier` with the identifier derived from
18
+ * the envelope's `publicKey`. Returns `null` when the self-certifying check
19
+ * does not run: no signer, an identifier that is not key-derived, or no
20
+ * envelope key. A failed derivation is a mismatch with no `derived` value.
21
+ */
22
+ export function compareKeyDerivedIdentifier(signer, publicKey) {
23
+ if (!signer || typeof signer !== 'object')
24
+ return null;
25
+ const claimed = signer.identifier;
26
+ if (!isKeyDerivedIdentifier(claimed) || !publicKey)
27
+ return null;
28
+ let derived;
29
+ try {
30
+ derived = deriveKeyDerivedIdentifier(publicKey);
31
+ }
32
+ catch {
33
+ return { claimed, match: false };
34
+ }
35
+ return { claimed, derived, match: derived === claimed };
36
+ }
37
+ /**
38
+ * G2-B for a key-derived identifier, whether or not it matched its envelope
39
+ * key (ADR-0030 §4 rule 3 as amended; spec v0.1.9 §9.4): only a registry
40
+ * fetched from the declared `trustRegistryUrl` can raise the status. Returns
41
+ * `base` unless it is a raising verdict (`active`, `deprecated_valid`) from a
42
+ * bundle-carried or provenance-less registry; that verdict is ignored and the
43
+ * status is what the envelope alone yields with no registry supplied
44
+ * (`registry_unavailable` with a `kid`, `legacy_embedded` without),
45
+ * `verified: false`. Lowering and unlisted verdicts pass through.
46
+ */
47
+ export function applyKeyDerivedRegistryRule(base, provenance) {
48
+ if (provenance === 'declared-url')
49
+ return base;
50
+ if (UNLISTED_STATUSES.has(base.status) || LOWERING_STATUSES.has(base.status))
51
+ return base;
52
+ return base.kid !== undefined
53
+ ? { status: 'registry_unavailable', verified: false, kid: base.kid }
54
+ : { status: 'legacy_embedded', verified: false };
55
+ }
56
+ /**
57
+ * Apply ADR-0030 §4 rules 2-3, as amended at gate G2, to the registry-path
58
+ * verdict `base`, for a package whose key-derived identifier MATCHED its
59
+ * envelope key. (A mismatch takes `applyKeyDerivedRegistryRule` alone.)
60
+ *
61
+ * For a key-derived identifier, at any `bindingTier`:
62
+ * - A registry from the declared `trustRegistryUrl` that lists the
63
+ * envelope's `(kid, publicKey)` decides: its verdict stands.
64
+ * - Any other registry (bundle-carried, or no stated provenance)
65
+ * contributes only a lowering verdict (`revoked`, `deprecated_invalid`).
66
+ * A raising one (`active`, `deprecated_valid`) is ignored.
67
+ * - With no registry listing the key, or a raising verdict ignored, the
68
+ * status is `self_certified` at `pseudonymous`; at any other tier it is
69
+ * what the envelope alone yields with no registry supplied
70
+ * (`registry_unavailable` with a `kid`, `legacy_embedded` without) — never
71
+ * `active`, never `verified: true`. `self_certified` is reserved for the
72
+ * `pseudonymous` rung (§3).
73
+ */
74
+ export function applySelfCertifiedKeyTrust(base, bindingTier, provenance) {
75
+ const listed = !UNLISTED_STATUSES.has(base.status);
76
+ if (listed && (provenance === 'declared-url' || LOWERING_STATUSES.has(base.status))) {
77
+ return base;
78
+ }
79
+ if (bindingTier !== SELF_CERTIFYING_TIER) {
80
+ // Unlisted: the registry path's own verdict is unchanged. Listed with a
81
+ // raising verdict from a registry that may not raise: the envelope alone.
82
+ return applyKeyDerivedRegistryRule(base, provenance);
83
+ }
84
+ return {
85
+ status: 'self_certified',
86
+ verified: false,
87
+ ...(base.kid !== undefined ? { kid: base.kid } : {}),
88
+ };
89
+ }
@@ -16,14 +16,30 @@ export interface TrustRegistryKey {
16
16
  export interface TrustRegistry {
17
17
  keys: TrustRegistryKey[];
18
18
  }
19
- export declare const KEY_TRUST_STATUSES: readonly ["active", "deprecated_valid", "deprecated_invalid", "revoked", "unknown_key", "registry_unavailable", "legacy_embedded"];
19
+ export declare const KEY_TRUST_STATUSES: readonly ["active", "deprecated_valid", "deprecated_invalid", "revoked", "unknown_key", "registry_unavailable", "legacy_embedded", "self_certified"];
20
20
  export type KeyTrustStatus = (typeof KEY_TRUST_STATUSES)[number];
21
+ /**
22
+ * Where a supplied registry came from (hub ADR-0030 §4 rule 3). This module
23
+ * still has no opinion on HOW a registry is obtained; the caller states it,
24
+ * because for a signer with a key-derived identifier the answer decides what
25
+ * the registry may do:
26
+ * - `declared-url` — fetched from the view's declared `trustRegistryUrl` (or
27
+ * `trustRegistryUrlLegacy`). Its verdict about the key stands.
28
+ * - `bundle` — carried in the bundle (the `?inline=1` form's
29
+ * `trustRegistry`), or obtained any other way. It may lower a
30
+ * self-certified signer's status (`revoked`, `deprecated_invalid`) and
31
+ * never raise it.
32
+ * A registry supplied with no stated provenance is treated as `bundle`.
33
+ * Signers whose identifier is not key-derived are unaffected by provenance.
34
+ */
35
+ export declare const TRUST_REGISTRY_PROVENANCES: readonly ["declared-url", "bundle"];
36
+ export type TrustRegistryProvenance = (typeof TRUST_REGISTRY_PROVENANCES)[number];
21
37
  export interface KeyTrustResult {
22
38
  status: KeyTrustStatus;
23
39
  /** `true` iff the status is `active` or `deprecated_valid`. Legacy-embedded
24
- * signatures are intentionally surfaced as `verified: false` because the
25
- * trust registry cannot vouch for them — the UI renders them as neutral
26
- * rather than failed. */
40
+ * and self-certified signatures are intentionally surfaced as
41
+ * `verified: false` because no trust registry vouches for them — the UI
42
+ * renders them as neutral rather than failed. */
27
43
  verified: boolean;
28
44
  /** The registry `kid` when available. Omitted for `legacy_embedded` /
29
45
  * pre-registry packages because the signature has no kid to report. */
@@ -22,7 +22,23 @@ export const KEY_TRUST_STATUSES = [
22
22
  'unknown_key', // (kid, publicKey) pair not found in registry
23
23
  'registry_unavailable', // registry could not be loaded
24
24
  'legacy_embedded', // signed with an embedded key, no registry kid stored
25
+ 'self_certified', // key-derived identifier matches the key; no registry vouches (hub ADR-0030 §3)
25
26
  ];
27
+ /**
28
+ * Where a supplied registry came from (hub ADR-0030 §4 rule 3). This module
29
+ * still has no opinion on HOW a registry is obtained; the caller states it,
30
+ * because for a signer with a key-derived identifier the answer decides what
31
+ * the registry may do:
32
+ * - `declared-url` — fetched from the view's declared `trustRegistryUrl` (or
33
+ * `trustRegistryUrlLegacy`). Its verdict about the key stands.
34
+ * - `bundle` — carried in the bundle (the `?inline=1` form's
35
+ * `trustRegistry`), or obtained any other way. It may lower a
36
+ * self-certified signer's status (`revoked`, `deprecated_invalid`) and
37
+ * never raise it.
38
+ * A registry supplied with no stated provenance is treated as `bundle`.
39
+ * Signers whose identifier is not key-derived are unaffected by provenance.
40
+ */
41
+ export const TRUST_REGISTRY_PROVENANCES = ['declared-url', 'bundle'];
26
42
  /**
27
43
  * Build a `KeyTrustResult` for a package whose signature predates the trust
28
44
  * registry — i.e. has a valid public key but no `kid`. We accept that the
package/dist/types.d.ts CHANGED
@@ -16,7 +16,7 @@ export interface SignerIdentity {
16
16
  * the per-profile vocabulary table in `profiles.ts` is the single source that
17
17
  * enumerates these.
18
18
  */
19
- export type CaptureMethod = 'chat-flow-stream' | 'claude-code-jsonl-readback' | 'claude-code-self-report';
19
+ export type CaptureMethod = 'chat-flow-stream' | 'claude-code-jsonl-readback' | 'claude-code-self-report' | 'script-run' | 'tool-emitted';
20
20
  /**
21
21
  * The subset of the WHATWG `fetch` signature verify-core depends on. Network
22
22
  * I/O is INJECTED rather than imported so the same code runs on the server (its
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
- import { type KeyTrustResult, type TrustRegistry } from './trust-registry.ts';
4
- import { type ContentCanonicalizationResolution, type ContentHashCheck, type TypeResolution, type SignerIdentityCheck, type CaptureMethodVocabCheck, type BlobRefVerification } from './checks.ts';
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';
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`). */
@@ -97,7 +97,13 @@ export interface VerifyDeps {
97
97
  /** Parsed trust registry (server: loaded; browser: fetched from the sidecar's
98
98
  * `trustRegistryUrl`). */
99
99
  registry: TrustRegistry | undefined;
100
- /** Injected fetcher for #8 / #9. Defaults to `globalThis.fetch`. */
100
+ /** Where `registry` came from (hub ADR-0030 §4 rule 3): `declared-url` when
101
+ * it was fetched from the view's declared `trustRegistryUrl`, `bundle` when
102
+ * it was carried in the bundle. Read only for a signer whose identifier is
103
+ * key-derived; absent is treated as `bundle`. */
104
+ registryProvenance?: TrustRegistryProvenance;
105
+ /** Injected fetcher for #8 / #9, and for #4 under raw-bytes/v1 when `output`
106
+ * is a BlobRef. Defaults to `globalThis.fetch`. */
101
107
  fetch?: FetchLike;
102
108
  /** Optional server-deeper lifecycle resolution (the signed attestation chain).
103
109
  * When provided it is surfaced verbatim, preserving the server route's current
@@ -144,6 +150,8 @@ export interface VerifyResult {
144
150
  typeResolution: TypeResolution | null;
145
151
  signerIdentity: SignerIdentityCheck | null;
146
152
  captureMethodVocab: CaptureMethodVocabCheck | null;
153
+ /** Check #16 — `metadata.contentProfile` (hub ADR-0029 §5). */
154
+ contentProfile: ContentProfileCheck | null;
147
155
  lifecycle: LifecycleResolution;
148
156
  }
149
157
  /**
package/dist/verify.js CHANGED
@@ -19,7 +19,7 @@
19
19
  // name is still exported, still the same function object, and still works;
20
20
  // see the deprecated alias at the bottom of this file.
21
21
  //
22
- // Check depth: fully client-side for #1/#2/#3/#4/#5/#6/#9/#12/#13/#14/#15; #7 (RFC
22
+ // Check depth: fully client-side for #1/#2/#3/#4/#5/#6/#9/#12/#13/#14/#15/#16; #7 (RFC
23
23
  // 3161) is PRESENCE (`hasTimestamp`) AND, when a token is present, cryptographic TSA
24
24
  // verification against the pinned FreeTSA anchor (civic-ai-tools-website#119 P2a —
25
25
  // ECDSA-P384 over the signed TSTInfo, message-imprint match, genTime-in-validity);
@@ -34,7 +34,8 @@ import { verifyRekorEntry } from "./rekor.js";
34
34
  import { verifyRekorInclusion, } from "./rekor-inclusion.js";
35
35
  import { verifyRfc3161Timestamp } from "./rfc3161.js";
36
36
  import { verifyKeyTrust, legacyEmbeddedKeyTrust, } from "./trust-registry.js";
37
- import { resolveContentCanonicalization, verifyContentHash, resolvePackageType, checkSignerIdentity, checkCaptureMethodVocab, verifyPackageBlobRefs, } from "./checks.js";
37
+ import { applyKeyDerivedRegistryRule, applySelfCertifiedKeyTrust, compareKeyDerivedIdentifier, } from "./self-certifying.js";
38
+ import { resolveContentCanonicalization, verifyContentHashWithFetch, resolvePackageType, checkSignerIdentity, checkCaptureMethodVocab, checkContentProfile, verifyPackageBlobRefs, } from "./checks.js";
38
39
  import { resolveLifecycleFromLegacyColumns, } from "./lifecycle.js";
39
40
  /**
40
41
  * Run the §9.2 check suite. The step order mirrors the server route exactly
@@ -140,18 +141,38 @@ export async function verifyRecord(input, deps) {
140
141
  else if (sigPublicKey) {
141
142
  keyTrust = legacyEmbeddedKeyTrust();
142
143
  }
143
- // Step 5 — canonicalization, content-hash, and envelope checks (#3/#4/#12/#14/#15).
144
+ // Step 4b — the self-certifying signer (hub ADR-0030 §3-§4, rule 3 as
145
+ // amended at G2; spec v0.1.9 §9.4). Runs only when the package is present,
146
+ // its `signer.identifier` is key-derived, and the envelope carries a
147
+ // `publicKey`. For a key-derived identifier, match or mismatch, only a
148
+ // registry fetched from the declared URL can raise the status; a
149
+ // bundle-carried or provenance-less one contributes only lowering verdicts.
150
+ // On a match, rules 2-3 then decide the status (`self_certified` at
151
+ // `pseudonymous`); on a mismatch (fatal at check #14) the lowering-only rule
152
+ // applies to the registry-path verdict. A signer whose identifier is not
153
+ // key-derived is untouched.
154
+ const pkgSigner = pkg ? pkg['signer'] : undefined;
155
+ const keyDerived = compareKeyDerivedIdentifier(pkgSigner, sigPublicKey);
156
+ if (keyTrust && keyDerived) {
157
+ keyTrust = keyDerived.match
158
+ ? applySelfCertifiedKeyTrust(keyTrust, pkgSigner.bindingTier, deps.registryProvenance)
159
+ : applyKeyDerivedRegistryRule(keyTrust, deps.registryProvenance);
160
+ }
161
+ // 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.
144
163
  let contentCanonicalization = null;
145
164
  let contentHashCheck = null;
146
165
  let typeResolution = null;
147
166
  let signerIdentity = null;
148
167
  let captureMethodVocab = null;
168
+ let contentProfile = null;
149
169
  if (pkg) {
150
170
  contentCanonicalization = resolveContentCanonicalization(pkg);
151
- contentHashCheck = verifyContentHash(pkg, contentCanonicalization, input.legacyExternalHash ?? packageHash);
171
+ contentHashCheck = await verifyContentHashWithFetch(pkg, contentCanonicalization, input.legacyExternalHash ?? packageHash, { fetch: deps.fetch });
152
172
  typeResolution = resolvePackageType(pkg);
153
- signerIdentity = checkSignerIdentity(pkg, sigKid, deps.registry);
173
+ signerIdentity = checkSignerIdentity(pkg, sigKid, deps.registry, sigPublicKey);
154
174
  captureMethodVocab = checkCaptureMethodVocab(pkg);
175
+ contentProfile = checkContentProfile(pkg);
155
176
  }
156
177
  // Step 6 — lifecycle (check #10). Server-deeper resolution wins when supplied;
157
178
  // otherwise derive STATE from the sidecar's lifecycle (the portable depth).
@@ -185,6 +206,7 @@ export async function verifyRecord(input, deps) {
185
206
  typeResolution,
186
207
  signerIdentity,
187
208
  captureMethodVocab,
209
+ contentProfile,
188
210
  lifecycle,
189
211
  };
190
212
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typedstandards/verify-core",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",
@@ -26,7 +26,7 @@
26
26
  "scripts": {
27
27
  "build": "tsc -p tsconfig.json",
28
28
  "clean": "rm -rf dist tsconfig.tsbuildinfo",
29
- "typecheck": "tsc -p tsconfig.json --noEmit",
29
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
30
30
  "test": "node --test --experimental-strip-types \"src/**/*.test.ts\"",
31
31
  "prepublishOnly": "npm run clean && npm run build"
32
32
  },
@@ -36,6 +36,7 @@
36
36
  "canonicalize": "^3.0.0"
37
37
  },
38
38
  "devDependencies": {
39
+ "@types/node": "^20",
39
40
  "typescript": "^5.9.3"
40
41
  },
41
42
  "engines": {