@typedstandards/verify-core 0.1.0 → 0.3.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/asn1.d.ts +36 -0
- package/dist/asn1.js +154 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/rekor-inclusion.d.ts +98 -0
- package/dist/rekor-inclusion.js +258 -0
- package/dist/rekor.d.ts +5 -0
- package/dist/rekor.js +12 -5
- package/dist/rfc3161.d.ts +41 -0
- package/dist/rfc3161.js +240 -0
- package/dist/verify.d.ts +17 -0
- package/dist/verify.js +30 -6
- package/package.json +4 -2
package/dist/asn1.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export declare class Asn1Error extends Error {
|
|
2
|
+
}
|
|
3
|
+
export interface DerNode {
|
|
4
|
+
/** The identifier octet (e.g. 0x30 SEQUENCE, 0x02 INTEGER, 0xa0 [0]). */
|
|
5
|
+
tag: number;
|
|
6
|
+
constructed: boolean;
|
|
7
|
+
/** Offset of the identifier octet. */
|
|
8
|
+
start: number;
|
|
9
|
+
/** Offset where content begins (just past the length octets). */
|
|
10
|
+
contentStart: number;
|
|
11
|
+
/** Offset one past the last content octet. */
|
|
12
|
+
contentEnd: number;
|
|
13
|
+
/** Offset one past the whole TLV (= contentEnd). */
|
|
14
|
+
end: number;
|
|
15
|
+
}
|
|
16
|
+
/** Parse a single TLV at `pos`, with strict-DER bounds checks. */
|
|
17
|
+
export declare function readNode(buf: Uint8Array, pos: number): DerNode;
|
|
18
|
+
/** Parse every child TLV of a constructed node; require they exactly tile it. */
|
|
19
|
+
export declare function children(buf: Uint8Array, node: DerNode): DerNode[];
|
|
20
|
+
/** The content octets of a node (a view — copy before mutating). */
|
|
21
|
+
export declare function content(buf: Uint8Array, node: DerNode): Uint8Array;
|
|
22
|
+
/** The whole TLV (identifier + length + content) of a node (a view). */
|
|
23
|
+
export declare function rawTlv(buf: Uint8Array, node: DerNode): Uint8Array;
|
|
24
|
+
/** Assert a node's tag, returning it (for fluent parsing). */
|
|
25
|
+
export declare function expectTag(node: DerNode, tag: number, label?: string): DerNode;
|
|
26
|
+
/** Decode an OBJECT IDENTIFIER (tag 0x06) to its dotted-decimal string. */
|
|
27
|
+
export declare function oidToString(buf: Uint8Array, node: DerNode): string;
|
|
28
|
+
/**
|
|
29
|
+
* Decode a UTCTime (0x17) or GeneralizedTime (0x18) to epoch milliseconds. Only
|
|
30
|
+
* the `Z` (UTC) forms RFC 5280 / RFC 3161 mandate are accepted; local-time and
|
|
31
|
+
* offset forms are rejected. UTCTime two-digit years use the RFC 5280 1950–2049
|
|
32
|
+
* window.
|
|
33
|
+
*/
|
|
34
|
+
export declare function readTime(buf: Uint8Array, node: DerNode): number;
|
|
35
|
+
/** First child whose tag matches, or undefined. */
|
|
36
|
+
export declare function findChild(buf: Uint8Array, node: DerNode, tag: number): DerNode | undefined;
|
package/dist/asn1.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Minimal, bounds-checked DER reader (civic-ai-tools-website#119 P2) — browser-safe.
|
|
2
|
+
//
|
|
3
|
+
// Just enough ASN.1/DER to parse RFC 3161 timestamp tokens (PKCS#7/CMS + TSTInfo)
|
|
4
|
+
// and, later, X.509 certs — NOT a general ASN.1 library. A hand-rolled parser over
|
|
5
|
+
// attacker-supplied bytes is itself an attack surface, so every read is bounds- and
|
|
6
|
+
// shape-checked and the encoding is STRICT DER:
|
|
7
|
+
// - definite lengths only (indefinite `0x80` is rejected — it has no place in DER);
|
|
8
|
+
// - minimal length encoding (no `0x81 0x05`, no leading-zero padding);
|
|
9
|
+
// - lengths may not exceed the buffer; children must exactly tile their parent
|
|
10
|
+
// (no trailing garbage);
|
|
11
|
+
// - high-tag-number form is rejected (none of our structures use it).
|
|
12
|
+
// Malformed input throws `Asn1Error` rather than reading out of bounds or looping.
|
|
13
|
+
// Pure byte logic — no `node:*`, no `Buffer` (browser-safety guard covers this).
|
|
14
|
+
export class Asn1Error extends Error {
|
|
15
|
+
}
|
|
16
|
+
// DER tag classes / numbers we accept in the length/tag header. Context tags used
|
|
17
|
+
// by CMS/X.509 ([0], [1], …) are low-tag-number (0xa0, 0xa1, 0x80, …) so the
|
|
18
|
+
// high-tag-number guard never rejects a well-formed token.
|
|
19
|
+
const HIGH_TAG_NUMBER_MASK = 0x1f;
|
|
20
|
+
const CONSTRUCTED_BIT = 0x20;
|
|
21
|
+
const LONG_FORM_BIT = 0x80;
|
|
22
|
+
/** Parse a single TLV at `pos`, with strict-DER bounds checks. */
|
|
23
|
+
export function readNode(buf, pos) {
|
|
24
|
+
if (pos < 0 || pos + 1 > buf.length)
|
|
25
|
+
throw new Asn1Error('readNode past end of buffer');
|
|
26
|
+
const tag = buf[pos];
|
|
27
|
+
if ((tag & HIGH_TAG_NUMBER_MASK) === HIGH_TAG_NUMBER_MASK) {
|
|
28
|
+
throw new Asn1Error('high-tag-number form unsupported');
|
|
29
|
+
}
|
|
30
|
+
let i = pos + 1;
|
|
31
|
+
if (i >= buf.length)
|
|
32
|
+
throw new Asn1Error('truncated: missing length octet');
|
|
33
|
+
let len = buf[i++];
|
|
34
|
+
if (len & LONG_FORM_BIT) {
|
|
35
|
+
const n = len & 0x7f;
|
|
36
|
+
if (n === 0)
|
|
37
|
+
throw new Asn1Error('indefinite length not allowed in DER');
|
|
38
|
+
if (n > 4)
|
|
39
|
+
throw new Asn1Error('length octet count too large');
|
|
40
|
+
if (i + n > buf.length)
|
|
41
|
+
throw new Asn1Error('truncated long-form length');
|
|
42
|
+
if (buf[i] === 0x00)
|
|
43
|
+
throw new Asn1Error('non-minimal length (leading zero)');
|
|
44
|
+
len = 0;
|
|
45
|
+
for (let k = 0; k < n; k++)
|
|
46
|
+
len = len * 256 + buf[i++]; // *256 avoids 32-bit overflow
|
|
47
|
+
if (len < 0x80)
|
|
48
|
+
throw new Asn1Error('non-minimal length (should be short form)');
|
|
49
|
+
}
|
|
50
|
+
const contentStart = i;
|
|
51
|
+
const contentEnd = i + len;
|
|
52
|
+
if (contentEnd > buf.length)
|
|
53
|
+
throw new Asn1Error('content length exceeds buffer');
|
|
54
|
+
return {
|
|
55
|
+
tag,
|
|
56
|
+
constructed: (tag & CONSTRUCTED_BIT) !== 0,
|
|
57
|
+
start: pos,
|
|
58
|
+
contentStart,
|
|
59
|
+
contentEnd,
|
|
60
|
+
end: contentEnd,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Parse every child TLV of a constructed node; require they exactly tile it. */
|
|
64
|
+
export function children(buf, node) {
|
|
65
|
+
if (!node.constructed)
|
|
66
|
+
throw new Asn1Error('children() on a primitive node');
|
|
67
|
+
const out = [];
|
|
68
|
+
let p = node.contentStart;
|
|
69
|
+
while (p < node.contentEnd) {
|
|
70
|
+
const child = readNode(buf, p);
|
|
71
|
+
if (child.end > node.contentEnd)
|
|
72
|
+
throw new Asn1Error('child overruns parent');
|
|
73
|
+
out.push(child);
|
|
74
|
+
p = child.end;
|
|
75
|
+
}
|
|
76
|
+
if (p !== node.contentEnd)
|
|
77
|
+
throw new Asn1Error('trailing bytes in constructed value');
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
/** The content octets of a node (a view — copy before mutating). */
|
|
81
|
+
export function content(buf, node) {
|
|
82
|
+
return buf.subarray(node.contentStart, node.contentEnd);
|
|
83
|
+
}
|
|
84
|
+
/** The whole TLV (identifier + length + content) of a node (a view). */
|
|
85
|
+
export function rawTlv(buf, node) {
|
|
86
|
+
return buf.subarray(node.start, node.end);
|
|
87
|
+
}
|
|
88
|
+
/** Assert a node's tag, returning it (for fluent parsing). */
|
|
89
|
+
export function expectTag(node, tag, label = 'node') {
|
|
90
|
+
if (node.tag !== tag) {
|
|
91
|
+
throw new Asn1Error(`expected ${label} tag 0x${tag.toString(16)}, got 0x${node.tag.toString(16)}`);
|
|
92
|
+
}
|
|
93
|
+
return node;
|
|
94
|
+
}
|
|
95
|
+
/** Decode an OBJECT IDENTIFIER (tag 0x06) to its dotted-decimal string. */
|
|
96
|
+
export function oidToString(buf, node) {
|
|
97
|
+
expectTag(node, 0x06, 'OID');
|
|
98
|
+
const b = content(buf, node);
|
|
99
|
+
if (b.length === 0)
|
|
100
|
+
throw new Asn1Error('empty OID');
|
|
101
|
+
if (b[b.length - 1] & 0x80)
|
|
102
|
+
throw new Asn1Error('truncated OID subidentifier');
|
|
103
|
+
const subids = [];
|
|
104
|
+
let acc = 0;
|
|
105
|
+
for (const byte of b) {
|
|
106
|
+
acc = acc * 128 + (byte & 0x7f);
|
|
107
|
+
if ((byte & 0x80) === 0) {
|
|
108
|
+
subids.push(acc);
|
|
109
|
+
acc = 0;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const first = subids[0];
|
|
113
|
+
const arc0 = Math.min(2, Math.floor(first / 40));
|
|
114
|
+
const arcs = [arc0, first - arc0 * 40, ...subids.slice(1)];
|
|
115
|
+
return arcs.join('.');
|
|
116
|
+
}
|
|
117
|
+
function asciiOf(bytes) {
|
|
118
|
+
let s = '';
|
|
119
|
+
for (const c of bytes) {
|
|
120
|
+
if (c < 0x20 || c > 0x7e)
|
|
121
|
+
throw new Asn1Error('non-ASCII byte in time string');
|
|
122
|
+
s += String.fromCharCode(c);
|
|
123
|
+
}
|
|
124
|
+
return s;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Decode a UTCTime (0x17) or GeneralizedTime (0x18) to epoch milliseconds. Only
|
|
128
|
+
* the `Z` (UTC) forms RFC 5280 / RFC 3161 mandate are accepted; local-time and
|
|
129
|
+
* offset forms are rejected. UTCTime two-digit years use the RFC 5280 1950–2049
|
|
130
|
+
* window.
|
|
131
|
+
*/
|
|
132
|
+
export function readTime(buf, node) {
|
|
133
|
+
const s = asciiOf(content(buf, node));
|
|
134
|
+
if (node.tag === 0x18) {
|
|
135
|
+
const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\.\d+)?Z$/.exec(s);
|
|
136
|
+
if (!m)
|
|
137
|
+
throw new Asn1Error('malformed GeneralizedTime');
|
|
138
|
+
const ms = m[7] ? Math.round(parseFloat('0' + m[7]) * 1000) : 0;
|
|
139
|
+
return Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6], ms);
|
|
140
|
+
}
|
|
141
|
+
if (node.tag === 0x17) {
|
|
142
|
+
const m = /^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(s);
|
|
143
|
+
if (!m)
|
|
144
|
+
throw new Asn1Error('malformed UTCTime');
|
|
145
|
+
const yy = +m[1];
|
|
146
|
+
const year = yy < 50 ? 2000 + yy : 1900 + yy;
|
|
147
|
+
return Date.UTC(year, +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
|
|
148
|
+
}
|
|
149
|
+
throw new Asn1Error(`not a time tag: 0x${node.tag.toString(16)}`);
|
|
150
|
+
}
|
|
151
|
+
/** First child whose tag matches, or undefined. */
|
|
152
|
+
export function findChild(buf, node, tag) {
|
|
153
|
+
return children(buf, node).find((c) => c.tag === tag);
|
|
154
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -9,4 +9,7 @@ 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';
|
|
13
|
+
export * from './asn1.ts';
|
|
14
|
+
export * from './rfc3161.ts';
|
|
12
15
|
export * from './verify.ts';
|
package/dist/index.js
CHANGED
|
@@ -23,4 +23,7 @@ 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";
|
|
27
|
+
export * from "./asn1.js";
|
|
28
|
+
export * from "./rfc3161.js";
|
|
26
29
|
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`.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pinned TSA signing key — the OFFLINE trust anchor for check #7 (spec §10.3).
|
|
3
|
+
* Pinning the SIGNING key (not the long-lived root) is the P2a "acceptable v1":
|
|
4
|
+
* it must be re-pinned if FreeTSA rotates the signing cert. P2b anchors to the RSA
|
|
5
|
+
* root via the token's embedded chain, which removes the re-pin.
|
|
6
|
+
*/
|
|
7
|
+
export interface TsaAnchor {
|
|
8
|
+
name: string;
|
|
9
|
+
/** EC P-384 signing public key, base64 SPKI DER. */
|
|
10
|
+
signingKeyDer: string;
|
|
11
|
+
/** Signing cert validity window (ISO 8601, UTC). genTime must fall inside it. */
|
|
12
|
+
notBefore: string;
|
|
13
|
+
notAfter: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const FREETSA_TSA_ANCHORS: readonly TsaAnchor[];
|
|
16
|
+
export type Rfc3161FailReason = 'parse_error' | 'not_granted' | 'unexpected_content_type' | 'unexpected_algorithm' | 'no_message_digest' | 'content_not_bound' | 'no_anchor' | 'signature_invalid' | 'imprint_mismatch' | 'genTime_outside_validity';
|
|
17
|
+
export interface Rfc3161VerifyResult {
|
|
18
|
+
/** All checks passed: a pinned TSA's signature binds this package hash to genTime. */
|
|
19
|
+
verified: boolean;
|
|
20
|
+
/** messageImprint.hashedMessage == the expected package hash (sha256). */
|
|
21
|
+
imprintMatches: boolean | null;
|
|
22
|
+
/** The TSA ECDSA-P384 signature over SHA-512(signedAttrs) verifies vs the anchor. */
|
|
23
|
+
signatureValid: boolean | null;
|
|
24
|
+
/** signedAttrs.messageDigest == SHA-512(TSTInfo) — the sig is bound to the TSTInfo. */
|
|
25
|
+
contentBound: boolean | null;
|
|
26
|
+
/** genTime within the pinned signing cert's validity window. */
|
|
27
|
+
withinValidity: boolean | null;
|
|
28
|
+
/** RFC 3161 genTime, epoch ms. */
|
|
29
|
+
genTime?: number;
|
|
30
|
+
/** The matched anchor name (when the signature verified). */
|
|
31
|
+
tsa?: string;
|
|
32
|
+
reason?: Rfc3161FailReason;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Verify an RFC 3161 timestamp token (base64) attests `expectedHashHex` (the package
|
|
36
|
+
* SHA-256), against the pinned TSA anchors. Returns a graded verdict; a parse failure
|
|
37
|
+
* or unknown anchor is reported (not thrown) so the caller can render it calmly,
|
|
38
|
+
* while a real cryptographic failure (`signature_invalid` / `imprint_mismatch` /
|
|
39
|
+
* `content_not_bound`) is the alarm signal.
|
|
40
|
+
*/
|
|
41
|
+
export declare function verifyRfc3161Timestamp(tokenB64: string, expectedHashHex: string, anchors?: readonly TsaAnchor[]): Rfc3161VerifyResult;
|
package/dist/rfc3161.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// RFC 3161 timestamp verification (spec §9.2 check #7, deep) — browser-safe.
|
|
2
|
+
//
|
|
3
|
+
// `verifyEvidence` today does PRESENCE for #7 (`!!rfc3161Timestamp`). This closes
|
|
4
|
+
// the cryptographic gap for the FreeTSA profile our producer uses (#119 P2a): it
|
|
5
|
+
// parses the RFC 3161 token (a PKCS#7/CMS SignedData over a TSTInfo) and verifies,
|
|
6
|
+
// OFFLINE, that a trusted TSA attested the package hash at a point in time:
|
|
7
|
+
// 1. messageImprint.hashedMessage == the package hash (sha256), so the token is
|
|
8
|
+
// ABOUT this package;
|
|
9
|
+
// 2. signedAttrs.messageDigest == SHA-512(TSTInfo), so the signature is BOUND to
|
|
10
|
+
// that TSTInfo;
|
|
11
|
+
// 3. the TSA's ECDSA-P384 signature over SHA-512(signedAttrs) verifies against the
|
|
12
|
+
// PINNED FreeTSA signing key (the trust anchor);
|
|
13
|
+
// 4. genTime falls within that signing cert's validity window.
|
|
14
|
+
//
|
|
15
|
+
// SCOPE (P2a): FreeTSA uses an EC P-384 signing cert and `ecdsa-with-SHA512`, so the
|
|
16
|
+
// TSA-signature check is pure `@noble` ECDSA — no WebCrypto/RSA. The anchor is the
|
|
17
|
+
// PINNED signing-cert public key (re-pin on rotation — documented below). P2b adds
|
|
18
|
+
// full embedded-cert chain validation to FreeTSA's RSA root (which removes the
|
|
19
|
+
// re-pin), and generalizes EKU/validity from the certs themselves. The recipe here
|
|
20
|
+
// was validated byte-for-byte against a real freetsa.org token (cross-checked with
|
|
21
|
+
// OpenSSL `ts -verify`) before shipping; `__fixtures__/rfc3161-token.json` is that
|
|
22
|
+
// token and the tests reproduce it offline.
|
|
23
|
+
import { p384 } from '@noble/curves/nist.js';
|
|
24
|
+
import { sha512 } from '@noble/hashes/sha2.js';
|
|
25
|
+
import { base64ToBytes, bytesToHex } from "./primitives.js";
|
|
26
|
+
import { Asn1Error, readNode, children, content, rawTlv, oidToString, readTime, } from "./asn1.js";
|
|
27
|
+
// --- OIDs ------------------------------------------------------------------
|
|
28
|
+
const OID_SIGNED_DATA = '1.2.840.113549.1.7.2';
|
|
29
|
+
const OID_CT_TSTINFO = '1.2.840.113549.1.9.16.1.4';
|
|
30
|
+
const OID_SHA256 = '2.16.840.1.101.3.4.2.1';
|
|
31
|
+
const OID_SHA512 = '2.16.840.1.101.3.4.2.3';
|
|
32
|
+
const OID_ECDSA_SHA512 = '1.2.840.10045.4.3.4';
|
|
33
|
+
const OID_ATTR_MESSAGE_DIGEST = '1.2.840.113549.1.9.4';
|
|
34
|
+
// PROVENANCE (anchor pinning — spec §10.3). FreeTSA's online TSA (`freetsa.org/tsr`)
|
|
35
|
+
// signs with this EC P-384 key (cert serial C2E986160DA8E9CD, OU=TSA, EKU
|
|
36
|
+
// id-kp-timeStamping, valid 2026-02-15 → 2040-02-02). Captured 2026-06-07 from
|
|
37
|
+
// `https://freetsa.org/files/tsa.crt`; a live token verified against it with both
|
|
38
|
+
// `@noble` (here) and OpenSSL `ts -verify` against FreeTSA's published CA. The cert
|
|
39
|
+
// chains to FreeTSA's self-signed RSA-4096 root (OU=Root CA) — pinning that root and
|
|
40
|
+
// validating the embedded chain is P2b.
|
|
41
|
+
export const FREETSA_TSA_ANCHORS = [
|
|
42
|
+
{
|
|
43
|
+
name: 'freetsa.org',
|
|
44
|
+
signingKeyDer: 'MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEohXhobLWy4cYsdKOFALpjUsB1FJTntwwb7PH2LOfNMAA1ufaKhkgsdeW6etU0pl5MC5tSVuPF52yy4sz+mbY3L7IVdx/bs9m0ud7IA14YNcC2yIQLa2gvnxwsrR3rlWr',
|
|
45
|
+
notBefore: '2026-02-15T19:44:22Z',
|
|
46
|
+
notAfter: '2040-02-02T19:44:22Z',
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
// EC P-384 SPKI DER is a fixed 120-byte structure: a 23-byte prefix (SEQUENCE →
|
|
50
|
+
// AlgorithmIdentifier{ id-ecPublicKey, secp384r1 } → BIT STRING) then the 97-byte
|
|
51
|
+
// uncompressed point (0x04 ‖ X ‖ Y).
|
|
52
|
+
const EC_P384_SPKI_PREFIX = Uint8Array.from([
|
|
53
|
+
0x30, 0x76, 0x30, 0x10, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01,
|
|
54
|
+
0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x22, 0x03, 0x62, 0x00,
|
|
55
|
+
]);
|
|
56
|
+
const EC_P384_SPKI_LENGTH = 120;
|
|
57
|
+
function extractP384Point(publicKeyB64Der) {
|
|
58
|
+
const der = base64ToBytes(publicKeyB64Der);
|
|
59
|
+
if (der.length !== EC_P384_SPKI_LENGTH) {
|
|
60
|
+
throw new Asn1Error(`Unexpected P-384 SPKI length ${der.length}`);
|
|
61
|
+
}
|
|
62
|
+
for (let i = 0; i < EC_P384_SPKI_PREFIX.length; i++) {
|
|
63
|
+
if (der[i] !== EC_P384_SPKI_PREFIX[i])
|
|
64
|
+
throw new Asn1Error('Unexpected P-384 SPKI prefix');
|
|
65
|
+
}
|
|
66
|
+
return der.slice(EC_P384_SPKI_LENGTH - 97);
|
|
67
|
+
}
|
|
68
|
+
/** The AlgorithmIdentifier's OID (its first child). */
|
|
69
|
+
function algorithmOid(buf, alg) {
|
|
70
|
+
return oidToString(buf, children(buf, alg)[0]);
|
|
71
|
+
}
|
|
72
|
+
/** Navigate TimeStampResp → ContentInfo → SignedData → { TSTInfo, signerInfo }. */
|
|
73
|
+
function parseToken(buf) {
|
|
74
|
+
const resp = readNode(buf, 0);
|
|
75
|
+
const respKids = children(buf, resp);
|
|
76
|
+
// PKIStatusInfo.status: 0 granted, 1 grantedWithMods; anything else ⇒ no token.
|
|
77
|
+
const status = children(buf, respKids[0])[0];
|
|
78
|
+
const statusVal = content(buf, status);
|
|
79
|
+
if (statusVal.length !== 1 || statusVal[0] > 1)
|
|
80
|
+
throw new Asn1Error('PKIStatus not granted');
|
|
81
|
+
const contentInfo = respKids[1];
|
|
82
|
+
if (!contentInfo)
|
|
83
|
+
throw new Asn1Error('no timeStampToken');
|
|
84
|
+
const ciKids = children(buf, contentInfo);
|
|
85
|
+
if (oidToString(buf, ciKids[0]) !== OID_SIGNED_DATA)
|
|
86
|
+
throw new Asn1Error('not signedData');
|
|
87
|
+
const signedData = children(buf, ciKids[1])[0]; // [0] EXPLICIT → SignedData
|
|
88
|
+
const sd = children(buf, signedData);
|
|
89
|
+
const encap = sd.find((c) => c.tag === 0x30); // first SEQUENCE = encapContentInfo
|
|
90
|
+
const signerInfos = sd.filter((c) => c.tag === 0x31).pop(); // last SET = signerInfos
|
|
91
|
+
if (!encap || !signerInfos)
|
|
92
|
+
throw new Asn1Error('malformed SignedData');
|
|
93
|
+
const encapKids = children(buf, encap);
|
|
94
|
+
if (oidToString(buf, encapKids[0]) !== OID_CT_TSTINFO)
|
|
95
|
+
throw new Asn1Error('eContent not TSTInfo');
|
|
96
|
+
const tstOctet = children(buf, encapKids[1])[0]; // [0] EXPLICIT → OCTET STRING
|
|
97
|
+
if (tstOctet.tag !== 0x04)
|
|
98
|
+
throw new Asn1Error('TSTInfo not an OCTET STRING');
|
|
99
|
+
const tstInfo = buf.slice(tstOctet.contentStart, tstOctet.contentEnd);
|
|
100
|
+
const signerInfo = children(buf, signerInfos)[0];
|
|
101
|
+
const si = children(buf, signerInfo);
|
|
102
|
+
const signedAttrs = si.find((c) => c.tag === 0xa0);
|
|
103
|
+
const signature = si.find((c) => c.tag === 0x04);
|
|
104
|
+
if (!signedAttrs || !signature)
|
|
105
|
+
throw new Asn1Error('signerInfo missing signedAttrs/signature');
|
|
106
|
+
// SignerInfo has three SEQUENCEs: sid (issuerAndSerialNumber), digestAlgorithm,
|
|
107
|
+
// signatureAlgorithm. The digest alg is the LAST 0x30 before signedAttrs (the sid
|
|
108
|
+
// also precedes it); the signature alg is the first 0x30 after.
|
|
109
|
+
const seqs = si.filter((c) => c.tag === 0x30);
|
|
110
|
+
const digestAlg = seqs.filter((c) => c.end <= signedAttrs.start).pop();
|
|
111
|
+
const signatureAlg = seqs.find((c) => c.start >= signedAttrs.end);
|
|
112
|
+
if (!digestAlg || !signatureAlg)
|
|
113
|
+
throw new Asn1Error('signerInfo missing algorithm ids');
|
|
114
|
+
return {
|
|
115
|
+
tstInfo,
|
|
116
|
+
signedAttrs,
|
|
117
|
+
digestAlgOid: algorithmOid(buf, digestAlg),
|
|
118
|
+
signatureAlgOid: algorithmOid(buf, signatureAlg),
|
|
119
|
+
signature: content(buf, signature),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** The OCTET STRING value of the messageDigest signed attribute. */
|
|
123
|
+
function messageDigestAttr(buf, signedAttrs) {
|
|
124
|
+
for (const attr of children(buf, signedAttrs)) {
|
|
125
|
+
const kids = children(buf, attr);
|
|
126
|
+
if (oidToString(buf, kids[0]) === OID_ATTR_MESSAGE_DIGEST) {
|
|
127
|
+
const value = children(buf, kids[1])[0]; // SET OF → OCTET STRING
|
|
128
|
+
return content(buf, value);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
/** Pull { hashedMessage, hashAlgOid, genTime } out of a TSTInfo. */
|
|
134
|
+
function parseTstInfo(tst) {
|
|
135
|
+
const root = readNode(tst, 0);
|
|
136
|
+
const kids = children(tst, root); // version, policy, messageImprint, serial, genTime, …
|
|
137
|
+
const messageImprint = kids[2];
|
|
138
|
+
const miKids = children(tst, messageImprint);
|
|
139
|
+
const hashAlgOid = algorithmOid(tst, miKids[0]);
|
|
140
|
+
const hashedMessage = content(tst, miKids[1]);
|
|
141
|
+
const genTimeNode = kids.find((c) => c.tag === 0x18);
|
|
142
|
+
if (!genTimeNode)
|
|
143
|
+
throw new Asn1Error('TSTInfo missing genTime');
|
|
144
|
+
return { hashedMessage, hashAlgOid, genTime: readTime(tst, genTimeNode) };
|
|
145
|
+
}
|
|
146
|
+
function bytesEqual(a, b) {
|
|
147
|
+
if (a.length !== b.length)
|
|
148
|
+
return false;
|
|
149
|
+
let diff = 0;
|
|
150
|
+
for (let i = 0; i < a.length; i++)
|
|
151
|
+
diff |= a[i] ^ b[i];
|
|
152
|
+
return diff === 0;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Verify an RFC 3161 timestamp token (base64) attests `expectedHashHex` (the package
|
|
156
|
+
* SHA-256), against the pinned TSA anchors. Returns a graded verdict; a parse failure
|
|
157
|
+
* or unknown anchor is reported (not thrown) so the caller can render it calmly,
|
|
158
|
+
* while a real cryptographic failure (`signature_invalid` / `imprint_mismatch` /
|
|
159
|
+
* `content_not_bound`) is the alarm signal.
|
|
160
|
+
*/
|
|
161
|
+
export function verifyRfc3161Timestamp(tokenB64, expectedHashHex, anchors = FREETSA_TSA_ANCHORS) {
|
|
162
|
+
let buf;
|
|
163
|
+
let token;
|
|
164
|
+
let tst;
|
|
165
|
+
let mdAttr;
|
|
166
|
+
try {
|
|
167
|
+
buf = base64ToBytes(tokenB64);
|
|
168
|
+
token = parseToken(buf);
|
|
169
|
+
tst = parseTstInfo(token.tstInfo);
|
|
170
|
+
mdAttr = messageDigestAttr(buf, token.signedAttrs);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return {
|
|
174
|
+
verified: false,
|
|
175
|
+
imprintMatches: null,
|
|
176
|
+
signatureValid: null,
|
|
177
|
+
contentBound: null,
|
|
178
|
+
withinValidity: null,
|
|
179
|
+
reason: 'parse_error',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const base = {
|
|
183
|
+
verified: false,
|
|
184
|
+
imprintMatches: null,
|
|
185
|
+
signatureValid: null,
|
|
186
|
+
contentBound: null,
|
|
187
|
+
withinValidity: null,
|
|
188
|
+
genTime: tst.genTime,
|
|
189
|
+
};
|
|
190
|
+
// FreeTSA profile (P2a): SHA-512 digest + ECDSA-with-SHA-512 signature.
|
|
191
|
+
if (token.digestAlgOid !== OID_SHA512 || token.signatureAlgOid !== OID_ECDSA_SHA512) {
|
|
192
|
+
return { ...base, reason: 'unexpected_algorithm' };
|
|
193
|
+
}
|
|
194
|
+
// (1) message imprint is about THIS package (sha256).
|
|
195
|
+
const imprintMatches = tst.hashAlgOid === OID_SHA256 && bytesToHex(tst.hashedMessage) === expectedHashHex.toLowerCase();
|
|
196
|
+
base.imprintMatches = imprintMatches;
|
|
197
|
+
if (!imprintMatches)
|
|
198
|
+
return { ...base, reason: 'imprint_mismatch' };
|
|
199
|
+
// (2) the signature is bound to this TSTInfo via the messageDigest attribute.
|
|
200
|
+
if (!mdAttr)
|
|
201
|
+
return { ...base, reason: 'no_message_digest' };
|
|
202
|
+
const contentBound = bytesEqual(mdAttr, sha512(token.tstInfo));
|
|
203
|
+
base.contentBound = contentBound;
|
|
204
|
+
if (!contentBound)
|
|
205
|
+
return { ...base, reason: 'content_not_bound' };
|
|
206
|
+
// (3) ECDSA-P384 signature over SHA-512(signedAttrs re-tagged [0] → SET) verifies
|
|
207
|
+
// against a pinned anchor key. (`@noble` prehashes with SHA-384 by default, so we
|
|
208
|
+
// hash with SHA-512 ourselves and pass `prehash: false`.)
|
|
209
|
+
const signedBytes = Uint8Array.from(rawTlv(buf, token.signedAttrs));
|
|
210
|
+
signedBytes[0] = 0x31; // [0] IMPLICIT (0xa0) → SET OF (0x31) for the signature input
|
|
211
|
+
const digest = sha512(signedBytes);
|
|
212
|
+
let matched;
|
|
213
|
+
for (const anchor of anchors) {
|
|
214
|
+
try {
|
|
215
|
+
const point = extractP384Point(anchor.signingKeyDer);
|
|
216
|
+
if (p384.verify(token.signature, digest, point, { format: 'der', prehash: false })) {
|
|
217
|
+
matched = anchor;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// malformed anchor key ⇒ try the next
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
base.signatureValid = !!matched;
|
|
226
|
+
if (!matched) {
|
|
227
|
+
// Distinguish "no pinned key vouches for this token" (calm — unknown TSA) from a
|
|
228
|
+
// signature that fails under a known key. Here, none verified ⇒ no_anchor.
|
|
229
|
+
return { ...base, reason: 'no_anchor' };
|
|
230
|
+
}
|
|
231
|
+
base.tsa = matched.name;
|
|
232
|
+
// (4) genTime within the pinned signing cert's validity window.
|
|
233
|
+
const notBefore = Date.parse(matched.notBefore);
|
|
234
|
+
const notAfter = Date.parse(matched.notAfter);
|
|
235
|
+
const withinValidity = tst.genTime >= notBefore && tst.genTime <= notAfter;
|
|
236
|
+
base.withinValidity = withinValidity;
|
|
237
|
+
if (!withinValidity)
|
|
238
|
+
return { ...base, reason: 'genTime_outside_validity' };
|
|
239
|
+
return { ...base, verified: true };
|
|
240
|
+
}
|
package/dist/verify.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type RekorInclusionProof, type RekorInclusionResult } from './rekor-inclusion.ts';
|
|
2
|
+
import { type Rfc3161VerifyResult } from './rfc3161.ts';
|
|
1
3
|
import { type KeyTrustResult, type TrustRegistry } from './trust-registry.ts';
|
|
2
4
|
import { type ContentCanonicalizationResolution, type ContentHashCheck, type TypeResolution, type SignerIdentityCheck, type CaptureMethodVocabCheck, type BlobRefVerification } from './checks.ts';
|
|
3
5
|
import { type LifecycleResolution } from './lifecycle.ts';
|
|
@@ -40,6 +42,13 @@ export interface VerifyInput {
|
|
|
40
42
|
rfc3161Timestamp?: string | null;
|
|
41
43
|
/** Rekor entry id (#8 hash-parity, needs a fetcher). */
|
|
42
44
|
rekorEntryId?: string | null;
|
|
45
|
+
/** Carried Rekor inclusion proof (#8 deep, civic-ai-tools-website#119). When
|
|
46
|
+
* supplied with `rekorEntryBody`, Merkle inclusion is verified OFFLINE against
|
|
47
|
+
* the pinned log key — no fetch, no civicaitools.org dependency. */
|
|
48
|
+
rekorInclusionProof?: RekorInclusionProof | null;
|
|
49
|
+
/** The Rekor entry's canonical leaf bytes (its base64 `body`), carried so the
|
|
50
|
+
* inclusion proof can be folded offline (D2). */
|
|
51
|
+
rekorEntryBody?: string | null;
|
|
43
52
|
/** Lifecycle STATE from the sidecar (#10 state-depth). Ignored when the caller
|
|
44
53
|
* supplies `deps.lifecycleResolution`. */
|
|
45
54
|
lifecycle?: CommitmentLifecycleState | null;
|
|
@@ -76,8 +85,16 @@ export interface VerifyResult {
|
|
|
76
85
|
logEntryUrl?: string;
|
|
77
86
|
} | null;
|
|
78
87
|
rekorIntegratedTime?: number;
|
|
88
|
+
/** Cryptographic Merkle-inclusion verdict (#119): null when no proof was
|
|
89
|
+
* available, else the graded result (`inclusionVerified` / `checkpointVerified`).
|
|
90
|
+
* Additive to `rekorVerified` (hash-parity). */
|
|
91
|
+
rekorInclusion: RekorInclusionResult | null;
|
|
79
92
|
hasRekor: boolean;
|
|
80
93
|
hasTimestamp: boolean;
|
|
94
|
+
/** Cryptographic RFC 3161 TSA verdict (#119 P2a): null when no token was present,
|
|
95
|
+
* else the graded result (signature vs pinned TSA anchor, message-imprint match,
|
|
96
|
+
* genTime-in-validity). Additive to `hasTimestamp` (presence). */
|
|
97
|
+
rfc3161: Rfc3161VerifyResult | null;
|
|
81
98
|
keyTrust: KeyTrustResult | null;
|
|
82
99
|
blobRefsVerified: boolean | null;
|
|
83
100
|
blobRefs: BlobRefVerification[];
|
package/dist/verify.js
CHANGED
|
@@ -14,14 +14,20 @@
|
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
17
|
+
// Check depth: fully client-side for #1/#2/#3/#4/#5/#6/#9/#12/#13/#14/#15; #7 (RFC
|
|
18
|
+
// 3161) is PRESENCE (`hasTimestamp`) AND, when a token is present, cryptographic TSA
|
|
19
|
+
// verification against the pinned FreeTSA anchor (civic-ai-tools-website#119 P2a —
|
|
20
|
+
// ECDSA-P384 over the signed TSTInfo, message-imprint match, genTime-in-validity);
|
|
21
|
+
// #8 (Rekor) is hash-PARITY AND, when an inclusion proof is available (carried for
|
|
22
|
+
// offline, or on the fetched entry), cryptographic Merkle inclusion against the
|
|
23
|
+
// pinned log key (#119 P1); lifecycle STATE for #10 unless the caller supplies a
|
|
24
|
+
// deeper resolution. Remaining deeper crypto: TSA full cert-chain #7 (#119 P2b),
|
|
25
|
+
// independent lifecycle-chain #10 (#119 P3).
|
|
22
26
|
import { recomputePackageHash } from "./checks.js";
|
|
23
27
|
import { verifySignature } from "./signature.js";
|
|
24
28
|
import { verifyRekorEntry } from "./rekor.js";
|
|
29
|
+
import { verifyRekorInclusion, } from "./rekor-inclusion.js";
|
|
30
|
+
import { verifyRfc3161Timestamp } from "./rfc3161.js";
|
|
25
31
|
import { verifyKeyTrust, legacyEmbeddedKeyTrust, } from "./trust-registry.js";
|
|
26
32
|
import { resolveContentCanonicalization, verifyContentHash, resolvePackageType, checkSignerIdentity, checkCaptureMethodVocab, verifyPackageBlobRefs, } from "./checks.js";
|
|
27
33
|
import { resolveLifecycleFromLegacyColumns, } from "./lifecycle.js";
|
|
@@ -55,10 +61,14 @@ export async function verifyEvidence(input, deps) {
|
|
|
55
61
|
signatureValid = verifySignature(packageHash, input.signature.signature, input.signature.publicKey, input.signature.algorithm);
|
|
56
62
|
}
|
|
57
63
|
const hasSigning = !!input.signature || !!input.signatureMalformed;
|
|
58
|
-
// Step 3 — Rekor (check #8
|
|
64
|
+
// Step 3 — Rekor (check #8). Hash-parity (online) yields integratedTime for #5;
|
|
65
|
+
// Merkle inclusion (#119) is verified cryptographically. The carried proof + body
|
|
66
|
+
// verify inclusion OFFLINE (no fetch); otherwise the online fetch's entry carries
|
|
67
|
+
// the proof. Carried-offline takes precedence — it is the zero-dependency property.
|
|
59
68
|
let rekorVerified = null;
|
|
60
69
|
let rekorDetails = null;
|
|
61
70
|
let rekorIntegratedTime;
|
|
71
|
+
let rekorInclusion = null;
|
|
62
72
|
if (input.rekorEntryId && packageHash) {
|
|
63
73
|
const rekorResult = await verifyRekorEntry(input.rekorEntryId, packageHash, { fetch: deps.fetch });
|
|
64
74
|
rekorVerified = rekorResult.verified;
|
|
@@ -69,6 +79,18 @@ export async function verifyEvidence(input, deps) {
|
|
|
69
79
|
logEntryUrl: rekorResult.logEntryUrl,
|
|
70
80
|
};
|
|
71
81
|
}
|
|
82
|
+
if (rekorResult.inclusion)
|
|
83
|
+
rekorInclusion = rekorResult.inclusion;
|
|
84
|
+
}
|
|
85
|
+
if (input.rekorInclusionProof && input.rekorEntryBody) {
|
|
86
|
+
rekorInclusion = verifyRekorInclusion(input.rekorEntryBody, input.rekorInclusionProof);
|
|
87
|
+
}
|
|
88
|
+
// Step 3c — RFC 3161 timestamp (check #7, deep). Presence is `hasTimestamp`; when a
|
|
89
|
+
// token + package hash are present, cryptographically verify the TSA signature over
|
|
90
|
+
// the timestamped hash against the pinned TSA anchor (#119 P2a). Pure/offline.
|
|
91
|
+
let rfc3161 = null;
|
|
92
|
+
if (input.rfc3161Timestamp && packageHash) {
|
|
93
|
+
rfc3161 = verifyRfc3161Timestamp(input.rfc3161Timestamp, packageHash);
|
|
72
94
|
}
|
|
73
95
|
// Step 3b — blob references embedded in the package (check #9).
|
|
74
96
|
let blobRefs = [];
|
|
@@ -119,8 +141,10 @@ export async function verifyEvidence(input, deps) {
|
|
|
119
141
|
rekorVerified,
|
|
120
142
|
rekorDetails,
|
|
121
143
|
...(rekorIntegratedTime !== undefined ? { rekorIntegratedTime } : {}),
|
|
144
|
+
rekorInclusion,
|
|
122
145
|
hasRekor: !!input.rekorEntryId,
|
|
123
146
|
hasTimestamp: !!input.rfc3161Timestamp,
|
|
147
|
+
rfc3161,
|
|
124
148
|
keyTrust,
|
|
125
149
|
blobRefsVerified,
|
|
126
150
|
blobRefs,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@typedstandards/verify-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"exports": {
|
|
10
10
|
".": {
|
|
11
11
|
"types": "./dist/index.d.ts",
|
|
12
|
-
"import": "./dist/index.js"
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"default": "./dist/index.js"
|
|
13
14
|
},
|
|
14
15
|
"./package.json": "./package.json"
|
|
15
16
|
},
|
|
@@ -26,6 +27,7 @@
|
|
|
26
27
|
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
|
27
28
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
28
29
|
"test": "node --test --experimental-strip-types \"src/**/*.test.ts\"",
|
|
30
|
+
"prepare": "npm run build",
|
|
29
31
|
"prepublishOnly": "npm run clean && npm run build"
|
|
30
32
|
},
|
|
31
33
|
"dependencies": {
|