@typedstandards/verify-core 0.1.0 → 0.2.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/dist/index.d.ts CHANGED
@@ -9,4 +9,5 @@ export * from './trust-registry.ts';
9
9
  export * from './checks.ts';
10
10
  export * from './lifecycle.ts';
11
11
  export * from './rekor.ts';
12
+ export * from './rekor-inclusion.ts';
12
13
  export * from './verify.ts';
package/dist/index.js CHANGED
@@ -23,4 +23,5 @@ export * from "./trust-registry.js";
23
23
  export * from "./checks.js";
24
24
  export * from "./lifecycle.js";
25
25
  export * from "./rekor.js";
26
+ export * from "./rekor-inclusion.js";
26
27
  export * from "./verify.js";
@@ -0,0 +1,98 @@
1
+ /**
2
+ * A pinned Rekor transparency-log public key — the OFFLINE trust anchor for
3
+ * check #8. A verifier with this key (and the carried proof + checkpoint) needs no
4
+ * network and no reference-implementation dependency to confirm log inclusion.
5
+ */
6
+ export interface RekorLogAnchor {
7
+ /** The checkpoint/note signer name (the note's signature-line identity). */
8
+ origin: string;
9
+ /** The log id: hex SHA-256 of `publicKeyDer`. The note's 4-byte key hint is its
10
+ * first 4 bytes; we match anchors on that. */
11
+ logId: string;
12
+ /** The log public key, base64 SPKI DER (as `/api/v1/log/publicKey` returns). */
13
+ publicKeyDer: string;
14
+ /** Signature curve. Only P-256 is used by the public-good instance today. */
15
+ curve: 'p256';
16
+ }
17
+ export declare const REKOR_LOG_ANCHORS: readonly RekorLogAnchor[];
18
+ /** A Rekor inclusion proof, as carried in `verification.inclusionProof`. */
19
+ export interface RekorInclusionProof {
20
+ /** The leaf index WITHIN the shard tree of size `treeSize` (NOT the global
21
+ * `logIndex` of the entry). This is the index RFC 6962 folds the path against. */
22
+ logIndex: number;
23
+ /** The tree size the proof (and checkpoint) are relative to. */
24
+ treeSize: number;
25
+ /** The Merkle root the proof folds to, hex. */
26
+ rootHash: string;
27
+ /** The audit path: sibling hashes from leaf to root, hex. */
28
+ hashes: string[];
29
+ /** The signed checkpoint (Go note) committing to `rootHash` at `treeSize`. */
30
+ checkpoint: string;
31
+ }
32
+ export type RekorInclusionFailReason = 'leaf_index_out_of_range' | 'proof_wrong_length' | 'root_mismatch' | 'checkpoint_unparseable' | 'checkpoint_root_mismatch' | 'checkpoint_unknown_anchor' | 'checkpoint_signature_invalid';
33
+ export interface RekorInclusionResult {
34
+ /** The entry body + audit path reproduce the proof's `rootHash` (RFC 6962). */
35
+ inclusionVerified: boolean;
36
+ /** The checkpoint commits to that same root/size AND its signature verifies
37
+ * against a pinned anchor — i.e. a vouched-for log head, offline. */
38
+ checkpointVerified: boolean;
39
+ treeSize?: number;
40
+ leafIndex?: number;
41
+ /** The checkpoint origin whose pinned key verified the signature. */
42
+ origin?: string;
43
+ /** The matched anchor's key hint (hex), for display / provenance. */
44
+ keyHint?: string;
45
+ /** The first thing that failed, when not fully verified. */
46
+ reason?: RekorInclusionFailReason;
47
+ }
48
+ /**
49
+ * Fold an inclusion path to a root per RFC 6962 §2.1.1, verbatim. Returns the
50
+ * computed root, or `null` if the index is out of range or the path is the wrong
51
+ * length for the tree. Arithmetic is Number `%`/`Math.floor` (NOT 32-bit bitwise)
52
+ * so leaf indices and tree sizes above 2³¹ fold correctly.
53
+ */
54
+ export declare function computeInclusionRoot(leafIndex: number, treeSize: number, leaf: Uint8Array, path: Uint8Array[]): Uint8Array | null;
55
+ interface CheckpointSignature {
56
+ name: string;
57
+ keyHint: Uint8Array;
58
+ sig: Uint8Array;
59
+ }
60
+ interface ParsedCheckpoint {
61
+ origin: string;
62
+ treeSize: number;
63
+ rootHash: Uint8Array;
64
+ /** The exact bytes the signature covers: the note body + its trailing newline.
65
+ * The blank-line separator before the signature lines is NOT signed (verified
66
+ * against OpenSSL). */
67
+ signedBody: Uint8Array;
68
+ signatures: CheckpointSignature[];
69
+ }
70
+ /**
71
+ * Parse a Rekor checkpoint. Body = the lines before the first blank line:
72
+ * `<origin>\n<treeSize>\n<base64 rootHash>\n`. Signature lines follow the blank
73
+ * separator: `— <name> <base64(keyHint[4] ‖ DER-sig)>`. Returns `null` if the
74
+ * structure is not a parseable note.
75
+ */
76
+ export declare function parseRekorCheckpoint(text: string): ParsedCheckpoint | null;
77
+ /**
78
+ * Verify a checkpoint's signature against the pinned anchors. The note signs the
79
+ * SHA-256 of `signedBody`; the signature is DER ECDSA P-256. We select the anchor
80
+ * by the note's key hint (= log-id prefix) and signer name. `@noble`'s `verify`
81
+ * prehashes by default — we pass the message bytes and let it hash, equivalently
82
+ * to `{ prehash: false }` over `sha256(signedBody)` (both validated against OpenSSL).
83
+ */
84
+ export declare function verifyCheckpointSignature(checkpoint: ParsedCheckpoint, anchors: readonly RekorLogAnchor[]): {
85
+ verified: boolean;
86
+ origin?: string;
87
+ keyHint?: string;
88
+ };
89
+ /**
90
+ * Verify Rekor log inclusion for an entry, OFFLINE. `entryBody` is the entry's
91
+ * canonical leaf bytes — Rekor's base64 `body`, or the raw bytes. `inclusionProof`
92
+ * is the carried `verification.inclusionProof`. Returns a graded verdict:
93
+ * `inclusionVerified` (the proof folds to its root) and `checkpointVerified` (that
94
+ * root is in a checkpoint a pinned log key signed). The strong property — "this
95
+ * entry is in a log the sigstore key vouches for" — is both being true.
96
+ */
97
+ export declare function verifyRekorInclusion(entryBody: string | Uint8Array, inclusionProof: RekorInclusionProof, anchors?: readonly RekorLogAnchor[]): RekorInclusionResult;
98
+ export {};
@@ -0,0 +1,258 @@
1
+ // Rekor transparency-log MERKLE-INCLUSION verification (spec §9.2 check #8, deep)
2
+ // — browser-safe, OFFLINE-capable (civic-ai-tools-website#119 P1).
3
+ //
4
+ // `rekor.ts` does hash-PARITY: re-fetch the entry and compare the stored SHA-512
5
+ // prehash. This module closes the cryptographic gap — it verifies, with no trust
6
+ // in civicaitools.org and no network, that a Rekor entry is *included* in a log
7
+ // the sigstore log key vouches for:
8
+ //
9
+ // 1. RFC 6962 §2.1.1 inclusion proof: leaf = SHA-256(0x00 ‖ entry-body); fold the
10
+ // audit path to a root; that root MUST equal the proof's `rootHash`.
11
+ // 2. Checkpoint (a Go signed-note / "checkpoint" per the transparency-dev format):
12
+ // its committed root + tree size MUST equal the proof's, and its signature MUST
13
+ // verify against a PINNED Rekor log public key (the trust anchor) — selected by
14
+ // the note's 4-byte key hint, which is the first 4 bytes of the log id
15
+ // (SHA-256 of the key's DER).
16
+ //
17
+ // Everything is pure `@noble/hashes` (SHA-256 Merkle hashing) + `@noble/curves`
18
+ // (ECDSA P-256 checkpoint signature). No `node:*`, no `Buffer`, no fetch — the
19
+ // proof + checkpoint + entry body are supplied by the caller (carried in the
20
+ // offline bundle per D2). The recipe here was validated byte-for-byte against a
21
+ // real `rekor.sigstore.dev` entry (and cross-checked with OpenSSL) before shipping;
22
+ // `__fixtures__/rekor-inclusion.json` is that entry, and the tests reproduce it.
23
+ import { sha256 } from '@noble/hashes/sha2.js';
24
+ import { p256 } from '@noble/curves/nist.js';
25
+ import { base64ToBytes, hexToBytes, bytesToHex, utf8ToBytes } from "./primitives.js";
26
+ // PROVENANCE (anchor pinning — spec §10.3). The active public-good Rekor shard
27
+ // (`rekor.sigstore.dev`, tree id 1193050959916656506) signs its checkpoints with
28
+ // this ECDSA P-256 key. Captured 2026-06-06 from
29
+ // `https://rekor.sigstore.dev/api/v1/log/publicKey`; the SHA-256 of its DER is the
30
+ // log id `c0d23d6a…`, which equals the `logID` every entry reports and the note's
31
+ // key-hint prefix (independently corroborated by sigstore's TUF `trusted_root.json`,
32
+ // which is the authoritative, rotation-aware source of this key set). Our packages
33
+ // are all published to this active shard; older inactive shards carry their own
34
+ // keys (their public keys are NOT served by `/api/v1/log` and must come from
35
+ // `trusted_root.json`) — pinning those is a documented extension point, added here
36
+ // when a package that predates the active shard needs offline check #8.
37
+ export const REKOR_LOG_ANCHORS = [
38
+ {
39
+ origin: 'rekor.sigstore.dev',
40
+ logId: 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d',
41
+ publicKeyDer: 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==',
42
+ curve: 'p256',
43
+ },
44
+ ];
45
+ // --- RFC 6962 Merkle hashing ----------------------------------------------
46
+ /** Leaf hash: `SHA-256(0x00 ‖ leaf)` (RFC 6962 §2.1). */
47
+ function leafHash(leaf) {
48
+ const buf = new Uint8Array(1 + leaf.length);
49
+ buf[0] = 0x00;
50
+ buf.set(leaf, 1);
51
+ return sha256(buf);
52
+ }
53
+ /** Interior node hash: `SHA-256(0x01 ‖ left ‖ right)` (RFC 6962 §2.1). */
54
+ function nodeHash(left, right) {
55
+ const buf = new Uint8Array(1 + left.length + right.length);
56
+ buf[0] = 0x01;
57
+ buf.set(left, 1);
58
+ buf.set(right, 1 + left.length);
59
+ return sha256(buf);
60
+ }
61
+ function bytesEqual(a, b) {
62
+ if (a.length !== b.length)
63
+ return false;
64
+ let diff = 0;
65
+ for (let i = 0; i < a.length; i++)
66
+ diff |= a[i] ^ b[i];
67
+ return diff === 0;
68
+ }
69
+ /**
70
+ * Fold an inclusion path to a root per RFC 6962 §2.1.1, verbatim. Returns the
71
+ * computed root, or `null` if the index is out of range or the path is the wrong
72
+ * length for the tree. Arithmetic is Number `%`/`Math.floor` (NOT 32-bit bitwise)
73
+ * so leaf indices and tree sizes above 2³¹ fold correctly.
74
+ */
75
+ export function computeInclusionRoot(leafIndex, treeSize, leaf, path) {
76
+ if (!Number.isInteger(leafIndex) || !Number.isInteger(treeSize))
77
+ return null;
78
+ if (leafIndex < 0 || leafIndex >= treeSize)
79
+ return null;
80
+ let fn = leafIndex; // node index of the running hash, within its tree level
81
+ let sn = treeSize - 1; // index of the last node at that level
82
+ let r = leaf;
83
+ for (const p of path) {
84
+ if (sn === 0)
85
+ return null; // path longer than the tree is deep — reject
86
+ if (fn % 2 === 1 || fn === sn) {
87
+ r = nodeHash(p, r);
88
+ if (fn % 2 === 0) {
89
+ // ascend past the all-right edge until the running node is a left child
90
+ do {
91
+ fn = Math.floor(fn / 2);
92
+ sn = Math.floor(sn / 2);
93
+ } while (fn % 2 === 0 && fn !== 0);
94
+ }
95
+ }
96
+ else {
97
+ r = nodeHash(r, p);
98
+ }
99
+ fn = Math.floor(fn / 2);
100
+ sn = Math.floor(sn / 2);
101
+ }
102
+ return sn === 0 ? r : null; // leftover path ⇒ wrong length
103
+ }
104
+ /**
105
+ * Parse a Rekor checkpoint. Body = the lines before the first blank line:
106
+ * `<origin>\n<treeSize>\n<base64 rootHash>\n`. Signature lines follow the blank
107
+ * separator: `— <name> <base64(keyHint[4] ‖ DER-sig)>`. Returns `null` if the
108
+ * structure is not a parseable note.
109
+ */
110
+ export function parseRekorCheckpoint(text) {
111
+ const sep = text.indexOf('\n\n');
112
+ if (sep < 0)
113
+ return null;
114
+ const body = text.slice(0, sep);
115
+ const lines = body.split('\n');
116
+ if (lines.length < 3)
117
+ return null;
118
+ // line 0 is "<origin> - <treeId>"; the note signer name is the origin token.
119
+ const origin = lines[0].split(' - ')[0];
120
+ const treeSize = Number(lines[1]);
121
+ if (!Number.isInteger(treeSize))
122
+ return null;
123
+ let rootHash;
124
+ try {
125
+ rootHash = base64ToBytes(lines[2]);
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ if (rootHash.length !== 32)
131
+ return null;
132
+ const signatures = [];
133
+ for (const line of text.slice(sep + 2).split('\n')) {
134
+ if (!line.startsWith('— '))
135
+ continue; // em-dash + space
136
+ const parts = line.split(' ');
137
+ if (parts.length < 3)
138
+ continue;
139
+ let blob;
140
+ try {
141
+ blob = base64ToBytes(parts[parts.length - 1]);
142
+ }
143
+ catch {
144
+ continue;
145
+ }
146
+ if (blob.length < 5)
147
+ continue;
148
+ signatures.push({
149
+ name: parts.slice(1, -1).join(' '),
150
+ keyHint: blob.slice(0, 4),
151
+ sig: blob.slice(4),
152
+ });
153
+ }
154
+ return { origin, treeSize, rootHash, signedBody: utf8ToBytes(body + '\n'), signatures };
155
+ }
156
+ // An ECDSA P-256 public key in SPKI DER is a fixed 91-byte structure: a 26-byte
157
+ // prefix (SEQUENCE → AlgorithmIdentifier{ id-ecPublicKey, prime256v1 } → BIT
158
+ // STRING) then the 65-byte uncompressed point (0x04 ‖ X ‖ Y). Asserting the prefix
159
+ // before slicing makes a malformed/foreign key throw rather than yield 65 arbitrary
160
+ // bytes.
161
+ const EC_P256_SPKI_PREFIX = Uint8Array.from([
162
+ 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01,
163
+ 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00,
164
+ ]);
165
+ const EC_P256_SPKI_LENGTH = 91;
166
+ function extractP256Point(publicKeyB64Der) {
167
+ const der = base64ToBytes(publicKeyB64Der);
168
+ if (der.length !== EC_P256_SPKI_LENGTH) {
169
+ throw new Error(`Unexpected P-256 SPKI length ${der.length}; expected ${EC_P256_SPKI_LENGTH}`);
170
+ }
171
+ for (let i = 0; i < EC_P256_SPKI_PREFIX.length; i++) {
172
+ if (der[i] !== EC_P256_SPKI_PREFIX[i]) {
173
+ throw new Error('Unexpected P-256 SPKI prefix; not a bare prime256v1 public key');
174
+ }
175
+ }
176
+ return der.slice(EC_P256_SPKI_LENGTH - 65);
177
+ }
178
+ /**
179
+ * Verify a checkpoint's signature against the pinned anchors. The note signs the
180
+ * SHA-256 of `signedBody`; the signature is DER ECDSA P-256. We select the anchor
181
+ * by the note's key hint (= log-id prefix) and signer name. `@noble`'s `verify`
182
+ * prehashes by default — we pass the message bytes and let it hash, equivalently
183
+ * to `{ prehash: false }` over `sha256(signedBody)` (both validated against OpenSSL).
184
+ */
185
+ export function verifyCheckpointSignature(checkpoint, anchors) {
186
+ for (const sig of checkpoint.signatures) {
187
+ const hint = bytesToHex(sig.keyHint);
188
+ const anchor = anchors.find((a) => a.logId.startsWith(hint) && a.origin === sig.name);
189
+ if (!anchor)
190
+ continue;
191
+ try {
192
+ const point = extractP256Point(anchor.publicKeyDer);
193
+ if (p256.verify(sig.sig, checkpoint.signedBody, point, { format: 'der' })) {
194
+ return { verified: true, origin: anchor.origin, keyHint: hint };
195
+ }
196
+ }
197
+ catch {
198
+ // malformed anchor key or signature ⇒ this anchor doesn't verify; try next.
199
+ }
200
+ }
201
+ return { verified: false };
202
+ }
203
+ /**
204
+ * Verify Rekor log inclusion for an entry, OFFLINE. `entryBody` is the entry's
205
+ * canonical leaf bytes — Rekor's base64 `body`, or the raw bytes. `inclusionProof`
206
+ * is the carried `verification.inclusionProof`. Returns a graded verdict:
207
+ * `inclusionVerified` (the proof folds to its root) and `checkpointVerified` (that
208
+ * root is in a checkpoint a pinned log key signed). The strong property — "this
209
+ * entry is in a log the sigstore key vouches for" — is both being true.
210
+ */
211
+ export function verifyRekorInclusion(entryBody, inclusionProof, anchors = REKOR_LOG_ANCHORS) {
212
+ const base = {
213
+ inclusionVerified: false,
214
+ checkpointVerified: false,
215
+ treeSize: inclusionProof.treeSize,
216
+ leafIndex: inclusionProof.logIndex,
217
+ };
218
+ let body;
219
+ let proofRoot;
220
+ let path;
221
+ try {
222
+ body = typeof entryBody === 'string' ? base64ToBytes(entryBody) : entryBody;
223
+ proofRoot = hexToBytes(inclusionProof.rootHash);
224
+ path = inclusionProof.hashes.map((h) => hexToBytes(h));
225
+ }
226
+ catch {
227
+ return { ...base, reason: 'proof_wrong_length' };
228
+ }
229
+ const computed = computeInclusionRoot(inclusionProof.logIndex, inclusionProof.treeSize, leafHash(body), path);
230
+ if (computed === null) {
231
+ const inRange = inclusionProof.logIndex >= 0 && inclusionProof.logIndex < inclusionProof.treeSize;
232
+ return { ...base, reason: inRange ? 'proof_wrong_length' : 'leaf_index_out_of_range' };
233
+ }
234
+ if (!bytesEqual(computed, proofRoot)) {
235
+ return { ...base, reason: 'root_mismatch' };
236
+ }
237
+ base.inclusionVerified = true;
238
+ const checkpoint = parseRekorCheckpoint(inclusionProof.checkpoint);
239
+ if (!checkpoint)
240
+ return { ...base, reason: 'checkpoint_unparseable' };
241
+ if (!bytesEqual(checkpoint.rootHash, proofRoot) || checkpoint.treeSize !== inclusionProof.treeSize) {
242
+ return { ...base, reason: 'checkpoint_root_mismatch' };
243
+ }
244
+ const sig = verifyCheckpointSignature(checkpoint, anchors);
245
+ if (!sig.verified) {
246
+ const knownAnchor = checkpoint.signatures.some((s) => anchors.some((a) => a.logId.startsWith(bytesToHex(s.keyHint)) && a.origin === s.name));
247
+ return {
248
+ ...base,
249
+ reason: knownAnchor ? 'checkpoint_signature_invalid' : 'checkpoint_unknown_anchor',
250
+ };
251
+ }
252
+ return {
253
+ ...base,
254
+ checkpointVerified: true,
255
+ ...(sig.origin ? { origin: sig.origin } : {}),
256
+ ...(sig.keyHint ? { keyHint: sig.keyHint } : {}),
257
+ };
258
+ }
package/dist/rekor.d.ts CHANGED
@@ -1,9 +1,14 @@
1
+ import { type RekorInclusionResult } from './rekor-inclusion.ts';
1
2
  import type { FetchLike } from './types.ts';
2
3
  export interface RekorVerifyResult {
4
+ /** Hash-parity: the entry's recorded SHA-512 prehash matches the package. */
3
5
  verified: boolean;
4
6
  logIndex?: number;
5
7
  integratedTime?: number;
6
8
  logEntryUrl?: string;
9
+ /** Cryptographic Merkle-inclusion verdict (#119), when the fetched entry carries
10
+ * an inclusion proof. Additive — `verified` (hash-parity) is unchanged. */
11
+ inclusion?: RekorInclusionResult;
7
12
  }
8
13
  /**
9
14
  * Verify that a Rekor transparency log entry is consistent with a published
package/dist/rekor.js CHANGED
@@ -1,14 +1,15 @@
1
1
  // Rekor transparency-log check (spec §9.2 check #8) — browser-safe.
2
2
  //
3
- // Factored from the server `verify.ts`. The check depth is unchanged from what
4
- // the server does today: re-fetch the entry and compare the stored SHA-512 hash
5
- // (hash-PARITY). It does NOT cryptographically verify the Merkle inclusion proof
6
- // — that, and full offline verification against the log checkpoint, is
7
- // civic-ai-tools-website#119. Node swaps: the SHA-512 prehash uses `@noble/hashes`
3
+ // Factored from the server `verify.ts`. This module does hash-PARITY: re-fetch the
4
+ // entry and compare the stored SHA-512 hash. When the fetched entry carries an
5
+ // inclusion proof it ALSO runs cryptographic Merkle-inclusion verification against
6
+ // the pinned log key (`rekor-inclusion.ts`, civic-ai-tools-website#119 P1) — the
7
+ // same offline check a carried bundle runs. Node swaps: the SHA-512 prehash uses `@noble/hashes`
8
8
  // (via `rekorHashForPackage`), the base64 body decode uses `atob` + `TextDecoder`
9
9
  // instead of `Buffer`, and the network fetch is injected.
10
10
  import { rekorHashForPackage } from "./signature.js";
11
11
  import { base64ToBytes } from "./primitives.js";
12
+ import { verifyRekorInclusion, } from "./rekor-inclusion.js";
12
13
  /**
13
14
  * Verify that a Rekor transparency log entry is consistent with a published
14
15
  * package.
@@ -40,11 +41,17 @@ export async function verifyRekorEntry(entryId, packageHash, options = {}) {
40
41
  const recordedAlgo = body?.spec?.data?.hash?.algorithm;
41
42
  const expectedHash = rekorHashForPackage(packageHash);
42
43
  const verified = recordedAlgo === 'sha512' && recordedHash === expectedHash;
44
+ // Deepen #8 (civic-ai-tools-website#119): when the entry carries an inclusion
45
+ // proof, cryptographically verify Merkle inclusion against the pinned log key —
46
+ // the same offline check a carried bundle runs, here over the fetched entry.
47
+ const proof = entry.verification?.inclusionProof;
48
+ const inclusion = proof && entry.body ? verifyRekorInclusion(entry.body, proof) : undefined;
43
49
  return {
44
50
  verified,
45
51
  logIndex: entry.logIndex,
46
52
  integratedTime: entry.integratedTime,
47
53
  logEntryUrl: `https://search.sigstore.dev/?logIndex=${entry.logIndex}`,
54
+ ...(inclusion ? { inclusion } : {}),
48
55
  };
49
56
  }
50
57
  catch (err) {
package/dist/verify.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type RekorInclusionProof, type RekorInclusionResult } from './rekor-inclusion.ts';
1
2
  import { type KeyTrustResult, type TrustRegistry } from './trust-registry.ts';
2
3
  import { type ContentCanonicalizationResolution, type ContentHashCheck, type TypeResolution, type SignerIdentityCheck, type CaptureMethodVocabCheck, type BlobRefVerification } from './checks.ts';
3
4
  import { type LifecycleResolution } from './lifecycle.ts';
@@ -40,6 +41,13 @@ export interface VerifyInput {
40
41
  rfc3161Timestamp?: string | null;
41
42
  /** Rekor entry id (#8 hash-parity, needs a fetcher). */
42
43
  rekorEntryId?: string | null;
44
+ /** Carried Rekor inclusion proof (#8 deep, civic-ai-tools-website#119). When
45
+ * supplied with `rekorEntryBody`, Merkle inclusion is verified OFFLINE against
46
+ * the pinned log key — no fetch, no civicaitools.org dependency. */
47
+ rekorInclusionProof?: RekorInclusionProof | null;
48
+ /** The Rekor entry's canonical leaf bytes (its base64 `body`), carried so the
49
+ * inclusion proof can be folded offline (D2). */
50
+ rekorEntryBody?: string | null;
43
51
  /** Lifecycle STATE from the sidecar (#10 state-depth). Ignored when the caller
44
52
  * supplies `deps.lifecycleResolution`. */
45
53
  lifecycle?: CommitmentLifecycleState | null;
@@ -76,6 +84,10 @@ export interface VerifyResult {
76
84
  logEntryUrl?: string;
77
85
  } | null;
78
86
  rekorIntegratedTime?: number;
87
+ /** Cryptographic Merkle-inclusion verdict (#119): null when no proof was
88
+ * available, else the graded result (`inclusionVerified` / `checkpointVerified`).
89
+ * Additive to `rekorVerified` (hash-parity). */
90
+ rekorInclusion: RekorInclusionResult | null;
79
91
  hasRekor: boolean;
80
92
  hasTimestamp: boolean;
81
93
  keyTrust: KeyTrustResult | null;
package/dist/verify.js CHANGED
@@ -14,14 +14,17 @@
14
14
  // sidecar (`buildCommitmentView`) so WS3 can wire sidecar → verifyEvidence with
15
15
  // no glue. The server route adapts its DB row to the same shape.
16
16
  //
17
- // Check depth matches the current server exactly: fully client-side for
18
- // #1/#2/#3/#4/#5/#6/#9/#12/#13/#14/#15; PRESENCE for #7 (RFC 3161); hash-PARITY
19
- // for #8 (Rekor); lifecycle STATE for #10 unless the caller supplies a deeper
20
- // resolution. The deeper offline crypto (TSA chain, Merkle inclusion, independent
21
- // lifecycle-chain verification) is civic-ai-tools-website#119.
17
+ // Check depth: fully client-side for #1/#2/#3/#4/#5/#6/#9/#12/#13/#14/#15; PRESENCE
18
+ // for #7 (RFC 3161); #8 (Rekor) is hash-PARITY AND, when an inclusion proof is
19
+ // available (carried for offline, or on the fetched entry), cryptographic Merkle
20
+ // inclusion against the pinned log key (civic-ai-tools-website#119 P1); lifecycle
21
+ // STATE for #10 unless the caller supplies a deeper resolution. The remaining
22
+ // deeper offline crypto (TSA chain #7, independent lifecycle-chain #10) is #119
23
+ // P2 / P3.
22
24
  import { recomputePackageHash } from "./checks.js";
23
25
  import { verifySignature } from "./signature.js";
24
26
  import { verifyRekorEntry } from "./rekor.js";
27
+ import { verifyRekorInclusion, } from "./rekor-inclusion.js";
25
28
  import { verifyKeyTrust, legacyEmbeddedKeyTrust, } from "./trust-registry.js";
26
29
  import { resolveContentCanonicalization, verifyContentHash, resolvePackageType, checkSignerIdentity, checkCaptureMethodVocab, verifyPackageBlobRefs, } from "./checks.js";
27
30
  import { resolveLifecycleFromLegacyColumns, } from "./lifecycle.js";
@@ -55,10 +58,14 @@ export async function verifyEvidence(input, deps) {
55
58
  signatureValid = verifySignature(packageHash, input.signature.signature, input.signature.publicKey, input.signature.algorithm);
56
59
  }
57
60
  const hasSigning = !!input.signature || !!input.signatureMalformed;
58
- // Step 3 — Rekor (check #8, hash-parity). Also yields integratedTime for #5.
61
+ // Step 3 — Rekor (check #8). Hash-parity (online) yields integratedTime for #5;
62
+ // Merkle inclusion (#119) is verified cryptographically. The carried proof + body
63
+ // verify inclusion OFFLINE (no fetch); otherwise the online fetch's entry carries
64
+ // the proof. Carried-offline takes precedence — it is the zero-dependency property.
59
65
  let rekorVerified = null;
60
66
  let rekorDetails = null;
61
67
  let rekorIntegratedTime;
68
+ let rekorInclusion = null;
62
69
  if (input.rekorEntryId && packageHash) {
63
70
  const rekorResult = await verifyRekorEntry(input.rekorEntryId, packageHash, { fetch: deps.fetch });
64
71
  rekorVerified = rekorResult.verified;
@@ -69,6 +76,11 @@ export async function verifyEvidence(input, deps) {
69
76
  logEntryUrl: rekorResult.logEntryUrl,
70
77
  };
71
78
  }
79
+ if (rekorResult.inclusion)
80
+ rekorInclusion = rekorResult.inclusion;
81
+ }
82
+ if (input.rekorInclusionProof && input.rekorEntryBody) {
83
+ rekorInclusion = verifyRekorInclusion(input.rekorEntryBody, input.rekorInclusionProof);
72
84
  }
73
85
  // Step 3b — blob references embedded in the package (check #9).
74
86
  let blobRefs = [];
@@ -119,6 +131,7 @@ export async function verifyEvidence(input, deps) {
119
131
  rekorVerified,
120
132
  rekorDetails,
121
133
  ...(rekorIntegratedTime !== undefined ? { rekorIntegratedTime } : {}),
134
+ rekorInclusion,
122
135
  hasRekor: !!input.rekorEntryId,
123
136
  hasTimestamp: !!input.rfc3161Timestamp,
124
137
  keyTrust,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typedstandards/verify-core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Portable, browser-safe verification core for Typed Standards evidence 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,6 +26,7 @@
26
26
  "clean": "rm -rf dist tsconfig.tsbuildinfo",
27
27
  "typecheck": "tsc -p tsconfig.json --noEmit",
28
28
  "test": "node --test --experimental-strip-types \"src/**/*.test.ts\"",
29
+ "prepare": "npm run build",
29
30
  "prepublishOnly": "npm run clean && npm run build"
30
31
  },
31
32
  "dependencies": {