@intyga/verify 0.0.0-bootstrap.0 → 1.0.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 +162 -0
- package/LICENSE +201 -0
- package/README.md +296 -2
- package/dist/approval-policy.d.ts +37 -0
- package/dist/approval-policy.js +149 -0
- package/dist/index.d.ts +803 -0
- package/dist/index.js +2236 -0
- package/dist/ledger-anchor.d.ts +195 -0
- package/dist/ledger-anchor.js +314 -0
- package/dist/ledger-bundle.d.ts +235 -0
- package/dist/ledger-bundle.js +419 -0
- package/dist/ledger-chain.d.ts +58 -0
- package/dist/ledger-chain.js +121 -0
- package/dist/ledger-evidence.d.ts +193 -0
- package/dist/ledger-evidence.js +613 -0
- package/dist/ledger-leaf.d.ts +25 -0
- package/dist/ledger-leaf.js +44 -0
- package/dist/ledger-merkle.d.ts +46 -0
- package/dist/ledger-merkle.js +183 -0
- package/dist/ledger-proof.d.ts +40 -0
- package/dist/ledger-proof.js +29 -0
- package/dist/ledger-rekor.d.ts +49 -0
- package/dist/ledger-rekor.js +187 -0
- package/dist/ledger-rfc3161.d.ts +25 -0
- package/dist/ledger-rfc3161.js +345 -0
- package/dist/ledger-signature.d.ts +16 -0
- package/dist/ledger-signature.js +61 -0
- package/package.json +57 -4
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { hashLeaf } from "./ledger-merkle.js";
|
|
2
|
+
/**
|
|
3
|
+
* JCS (RFC 8785) serialization of the `metadata` value — every object key sorted recursively by
|
|
4
|
+
* UTF-16 code unit. DEWP §4.2 requires the metadata element to be a *canonical* string, so an equal
|
|
5
|
+
* metadata object hashes identically regardless of the producer's key insertion order or language
|
|
6
|
+
* (JS/Go/Rust/Python). Do NOT replace with plain JSON.stringify — that reintroduces order dependence.
|
|
7
|
+
*/
|
|
8
|
+
function jcsStringify(value) {
|
|
9
|
+
if (value === null || typeof value !== "object")
|
|
10
|
+
return JSON.stringify(value) ?? "null";
|
|
11
|
+
if (Array.isArray(value))
|
|
12
|
+
return `[${value.map(jcsStringify).join(",")}]`;
|
|
13
|
+
const obj = value;
|
|
14
|
+
const keys = Object.keys(obj).sort();
|
|
15
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${jcsStringify(obj[k])}`).join(",")}}`;
|
|
16
|
+
}
|
|
17
|
+
/** The 18-field ordered array that gets JSON-stringified into the leaf preimage. Keep in lockstep
|
|
18
|
+
* with the producer's `leafHash` (packages/db/src/checkpoint.ts). */
|
|
19
|
+
export function canonicalPreimage(row) {
|
|
20
|
+
return JSON.stringify([
|
|
21
|
+
row.seq,
|
|
22
|
+
row.createdAt,
|
|
23
|
+
row.event,
|
|
24
|
+
row.outcome,
|
|
25
|
+
row.detail,
|
|
26
|
+
jcsStringify(row.metadata ?? null),
|
|
27
|
+
row.signerDid,
|
|
28
|
+
row.signerPublicKey,
|
|
29
|
+
row.signedPayload,
|
|
30
|
+
row.signature,
|
|
31
|
+
row.sigAlg,
|
|
32
|
+
row.isBillable,
|
|
33
|
+
row.tenantId,
|
|
34
|
+
row.actorNodeId,
|
|
35
|
+
row.subjectNodeId,
|
|
36
|
+
row.edgeId,
|
|
37
|
+
row.challengeId,
|
|
38
|
+
row.tenantSeq ?? null,
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
/** Domain-separated leaf digest over the full event content. */
|
|
42
|
+
export function leafHash(row) {
|
|
43
|
+
return hashLeaf(canonicalPreimage(row));
|
|
44
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export declare function sha256Hex(s: string): string;
|
|
2
|
+
export declare const LEAF_TAG = 0;
|
|
3
|
+
export declare const NODE_TAG = 1;
|
|
4
|
+
export declare const EMPTY_TAG = 2;
|
|
5
|
+
/** Domain-separated leaf digest: sha256(0x00 || UTF8(preimage)). */
|
|
6
|
+
export declare function hashLeaf(data: string): string;
|
|
7
|
+
/** Domain-separated node: sha256(0x01 || rawBytes(left) || rawBytes(right)). Order encodes position — never sort. */
|
|
8
|
+
export declare function hashPair(left: string, right: string): string;
|
|
9
|
+
/** Empty-tree root (DEWP §5.1.1): sha256(0x02). */
|
|
10
|
+
export declare function emptyRoot(): string;
|
|
11
|
+
/** Merkle root over ordered leaves (duplicate-last on odd levels). Empty tree ⇒ sha256(0x02). */
|
|
12
|
+
export declare function merkleRoot(leaves: string[]): string;
|
|
13
|
+
/**
|
|
14
|
+
* One step on the path from a leaf to the root (DEWP §5.1.6): the SIBLING's hash and the side the
|
|
15
|
+
* sibling sits on relative to the running node. Ordered leaf → root.
|
|
16
|
+
*/
|
|
17
|
+
export interface ProofStep {
|
|
18
|
+
siblingHash: string;
|
|
19
|
+
siblingPosition: "LEFT" | "RIGHT";
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Inclusion proof for the leaf at `index` — the sibling hashes needed to recompute the root.
|
|
23
|
+
* The producer emits these; the verifier only needs `verifyMerkleProof`. Included here so the same
|
|
24
|
+
* file can generate test vectors and so auditors can reproduce a proof from raw leaves if they wish.
|
|
25
|
+
*/
|
|
26
|
+
export declare function merkleProof(leaves: string[], index: number): ProofStep[];
|
|
27
|
+
/**
|
|
28
|
+
* The audit-path length for `index` in a duplicate-last tree of `leafCount` leaves.
|
|
29
|
+
* One sibling per level, and the tree has ceil(log2(n)) levels above the leaves.
|
|
30
|
+
*/
|
|
31
|
+
export declare function expectedPathLength(leafCount: number): number;
|
|
32
|
+
/**
|
|
33
|
+
* Position of the leaf a proof is for, and how many leaves its tree had. Supplying these turns an
|
|
34
|
+
* "is there SOME path from this leaf to this root" check into "is this leaf at this position".
|
|
35
|
+
*
|
|
36
|
+
* REQUIRED, per DEWP §3 invariant 3 ("The bounds are REQUIRED, not advisory") and §11.1. This was
|
|
37
|
+
* an optional parameter, which made the unbounded check reachable by omission from outside this
|
|
38
|
+
* package — the one place it must not be reachable, since external relying parties are exactly who
|
|
39
|
+
* this package exists for.
|
|
40
|
+
*/
|
|
41
|
+
export interface ProofBounds {
|
|
42
|
+
index: number;
|
|
43
|
+
leafCount: number;
|
|
44
|
+
}
|
|
45
|
+
/** Recompute the root from a leaf + its proof and compare. This is what a third party runs. */
|
|
46
|
+
export declare function verifyMerkleProof(leaf: string, proof: ProofStep[], root: string, bounds: ProofBounds): boolean;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
// Pure Merkle primitives — zero dependencies beyond node:crypto. This file is the trust root of the
|
|
3
|
+
// verifier: an auditor should be able to read it end to end and be convinced. It is a byte-for-byte
|
|
4
|
+
// port of the tree construction used to build Intyga's two-tier witness anchor
|
|
5
|
+
// (per-event leaves → block roots → daily root). If this and the producer ever disagree, proofs fail
|
|
6
|
+
// closed (verification returns false), never open.
|
|
7
|
+
export function sha256Hex(s) {
|
|
8
|
+
return crypto.createHash("sha256").update(s).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
// DEWP domain separation (docs/DEWP.md §4.4): leaves, interior nodes, and the empty root are hashed
|
|
11
|
+
// under DISTINCT one-byte prefixes so an interior-node hash can never be reinterpreted as a leaf
|
|
12
|
+
// (blocks second-preimage / proof malleability where a subtree root is passed off as a leaf).
|
|
13
|
+
// 0x00 = leaf, 0x01 = node, 0x02 = empty root. Node children are HEX-DECODED to their raw 32 bytes
|
|
14
|
+
// before hashing (NOT concatenated as hex text). Must stay byte-identical to the @intyga/db producer.
|
|
15
|
+
export const LEAF_TAG = 0x00;
|
|
16
|
+
export const NODE_TAG = 0x01;
|
|
17
|
+
export const EMPTY_TAG = 0x02;
|
|
18
|
+
/** Domain-separated leaf digest: sha256(0x00 || UTF8(preimage)). */
|
|
19
|
+
export function hashLeaf(data) {
|
|
20
|
+
return crypto
|
|
21
|
+
.createHash("sha256")
|
|
22
|
+
.update(Buffer.from([LEAF_TAG]))
|
|
23
|
+
.update(Buffer.from(data, "utf8"))
|
|
24
|
+
.digest("hex");
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Exactly 64 LOWERCASE hex characters (DEWP §4.4). Verification inputs must pass this before they
|
|
28
|
+
* reach `hashPair`, because `Buffer.from(s, "hex")` is lenient in two ways that both break the
|
|
29
|
+
* proof: it accepts uppercase, and it silently stops at the first non-hex character instead of
|
|
30
|
+
* failing. So distinct proof strings collapse onto the same bytes.
|
|
31
|
+
*
|
|
32
|
+
* That is not cosmetic — it defeats the padding check in `verifyMerkleProof`. That check rejects a
|
|
33
|
+
* step whose sibling equals the running node anywhere but the unpaired end of an odd level, which is
|
|
34
|
+
* what makes a path to a never-existent index fail. The comparison is on the STRING, so uppercasing
|
|
35
|
+
* a sibling makes `selfPaired` false while `hashPair` still decodes it to the identical 32 bytes:
|
|
36
|
+
* the padding forgery recomputes the genuine root and verifies. Reproduced against the real 3-leaf
|
|
37
|
+
* tree before this gate existed. The Go, Rust, Python and Java ports already gate on this; the
|
|
38
|
+
* reference implementation did not, so it was the only port accepting the forgery.
|
|
39
|
+
*/
|
|
40
|
+
function isHash64(s) {
|
|
41
|
+
if (typeof s !== "string" || s.length !== 64)
|
|
42
|
+
return false;
|
|
43
|
+
for (let i = 0; i < 64; i++) {
|
|
44
|
+
const c = s.charCodeAt(i);
|
|
45
|
+
const isDigit = c >= 0x30 && c <= 0x39;
|
|
46
|
+
const isLowerAf = c >= 0x61 && c <= 0x66;
|
|
47
|
+
if (!isDigit && !isLowerAf)
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
// Checked indexed read. The loops below keep indices in range by construction; if that invariant
|
|
53
|
+
// ever breaks, throwing beats fabricating a hash — a wrong tree must never verify.
|
|
54
|
+
function at(level, i) {
|
|
55
|
+
const v = level[i];
|
|
56
|
+
if (v === undefined)
|
|
57
|
+
throw new Error("merkle: index out of range");
|
|
58
|
+
return v;
|
|
59
|
+
}
|
|
60
|
+
/** Domain-separated node: sha256(0x01 || rawBytes(left) || rawBytes(right)). Order encodes position — never sort. */
|
|
61
|
+
export function hashPair(left, right) {
|
|
62
|
+
return crypto
|
|
63
|
+
.createHash("sha256")
|
|
64
|
+
.update(Buffer.from([NODE_TAG]))
|
|
65
|
+
.update(Buffer.from(left, "hex"))
|
|
66
|
+
.update(Buffer.from(right, "hex"))
|
|
67
|
+
.digest("hex");
|
|
68
|
+
}
|
|
69
|
+
/** Empty-tree root (DEWP §5.1.1): sha256(0x02). */
|
|
70
|
+
export function emptyRoot() {
|
|
71
|
+
return crypto
|
|
72
|
+
.createHash("sha256")
|
|
73
|
+
.update(Buffer.from([EMPTY_TAG]))
|
|
74
|
+
.digest("hex");
|
|
75
|
+
}
|
|
76
|
+
/** Merkle root over ordered leaves (duplicate-last on odd levels). Empty tree ⇒ sha256(0x02). */
|
|
77
|
+
export function merkleRoot(leaves) {
|
|
78
|
+
if (leaves.length === 0)
|
|
79
|
+
return emptyRoot();
|
|
80
|
+
let level = leaves;
|
|
81
|
+
while (level.length > 1) {
|
|
82
|
+
const next = [];
|
|
83
|
+
for (let i = 0; i < level.length; i += 2) {
|
|
84
|
+
const left = at(level, i);
|
|
85
|
+
next.push(hashPair(left, level[i + 1] ?? left));
|
|
86
|
+
}
|
|
87
|
+
level = next;
|
|
88
|
+
}
|
|
89
|
+
return at(level, 0);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Inclusion proof for the leaf at `index` — the sibling hashes needed to recompute the root.
|
|
93
|
+
* The producer emits these; the verifier only needs `verifyMerkleProof`. Included here so the same
|
|
94
|
+
* file can generate test vectors and so auditors can reproduce a proof from raw leaves if they wish.
|
|
95
|
+
*/
|
|
96
|
+
export function merkleProof(leaves, index) {
|
|
97
|
+
if (index < 0 || index >= leaves.length)
|
|
98
|
+
throw new Error("merkleProof: index out of range");
|
|
99
|
+
const proof = [];
|
|
100
|
+
let level = leaves;
|
|
101
|
+
let idx = index;
|
|
102
|
+
while (level.length > 1) {
|
|
103
|
+
const isRightChild = idx % 2 === 1;
|
|
104
|
+
const siblingIdx = isRightChild ? idx - 1 : idx + 1;
|
|
105
|
+
// duplicate-last: an unpaired right node is hashed against itself.
|
|
106
|
+
const sibling = level[siblingIdx] ?? at(level, idx);
|
|
107
|
+
// If the running node is the RIGHT child, its sibling sits on the LEFT, and vice versa.
|
|
108
|
+
proof.push({ siblingHash: sibling, siblingPosition: isRightChild ? "LEFT" : "RIGHT" });
|
|
109
|
+
const next = [];
|
|
110
|
+
for (let i = 0; i < level.length; i += 2) {
|
|
111
|
+
const left = at(level, i);
|
|
112
|
+
next.push(hashPair(left, level[i + 1] ?? left));
|
|
113
|
+
}
|
|
114
|
+
level = next;
|
|
115
|
+
idx = Math.floor(idx / 2);
|
|
116
|
+
}
|
|
117
|
+
return proof;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The audit-path length for `index` in a duplicate-last tree of `leafCount` leaves.
|
|
121
|
+
* One sibling per level, and the tree has ceil(log2(n)) levels above the leaves.
|
|
122
|
+
*/
|
|
123
|
+
export function expectedPathLength(leafCount) {
|
|
124
|
+
return leafCount <= 1 ? 0 : Math.ceil(Math.log2(leafCount));
|
|
125
|
+
}
|
|
126
|
+
/** Recompute the root from a leaf + its proof and compare. This is what a third party runs. */
|
|
127
|
+
export function verifyMerkleProof(leaf, proof, root, bounds) {
|
|
128
|
+
// Bounds are what make this a proof of membership rather than a proof that A path exists.
|
|
129
|
+
//
|
|
130
|
+
// This tree pads an unpaired trailing node by hashing it against ITSELF (DEWP §5.1.1 rule 3), so
|
|
131
|
+
// merkleRoot([a,b,c]) === merkleRoot([a,b,c,c]) and a path for the nonexistent index 3 recomputes
|
|
132
|
+
// the 3-leaf root exactly. Without a length and index there is nothing to reject it with, which
|
|
133
|
+
// falsifies DEWP §17.1's claim that forging an inclusion path needs a SHA-256 second preimage.
|
|
134
|
+
// DEWP §11.1 states outright that an implementation stopping at root recomputation is
|
|
135
|
+
// non-conformant, so this is a rejection, never a skipped check.
|
|
136
|
+
//
|
|
137
|
+
// `bounds` is required for every TS caller, but this package is the dependency-free, plain-JS-
|
|
138
|
+
// callable trust anchor (see packages/verify/README.md) — nothing enforces that for a legacy
|
|
139
|
+
// 3-argument call from an untyped consumer. Refusing here keeps this a boolean predicate for that
|
|
140
|
+
// caller instead of a thrown TypeError.
|
|
141
|
+
if (!bounds)
|
|
142
|
+
return false;
|
|
143
|
+
// Canonical hex FIRST: the self-pairing check below compares sibling to node as STRINGS, so an
|
|
144
|
+
// uppercased sibling slips past it while hashing to the identical bytes (see `isHash64`). Every
|
|
145
|
+
// hash that reaches `hashPair` is gated here and at each step.
|
|
146
|
+
if (!isHash64(leaf) || !isHash64(root))
|
|
147
|
+
return false;
|
|
148
|
+
const { index, leafCount } = bounds;
|
|
149
|
+
if (!Number.isInteger(index) || !Number.isInteger(leafCount))
|
|
150
|
+
return false;
|
|
151
|
+
if (leafCount < 1 || index < 0 || index >= leafCount)
|
|
152
|
+
return false;
|
|
153
|
+
if (proof.length !== expectedPathLength(leafCount))
|
|
154
|
+
return false;
|
|
155
|
+
// Each sibling's side follows from the index; letting the prover choose it freely would hand
|
|
156
|
+
// back the flexibility the length check just removed.
|
|
157
|
+
//
|
|
158
|
+
// The self-pairing check is what actually closes the padding forgery. `leafCount` comes from the
|
|
159
|
+
// proof, so a prover can simply inflate it: claiming leafCount 4 on a 3-leaf tree makes index 3
|
|
160
|
+
// "in range" and the path length correct, and the duplicate-last root is identical — so range
|
|
161
|
+
// and length alone still accept it (verified). But padding is observable: a node hashed against
|
|
162
|
+
// ITSELF only legitimately occurs at the unpaired END of an odd level. A step whose sibling
|
|
163
|
+
// equals the running node anywhere else is the signature of an index pointing into padding.
|
|
164
|
+
let idx = index;
|
|
165
|
+
let levelSize = leafCount;
|
|
166
|
+
let node = leaf;
|
|
167
|
+
for (const step of proof) {
|
|
168
|
+
if (!isHash64(step.siblingHash))
|
|
169
|
+
return false;
|
|
170
|
+
const expectedSide = idx % 2 === 1 ? "LEFT" : "RIGHT";
|
|
171
|
+
if (step.siblingPosition !== expectedSide)
|
|
172
|
+
return false;
|
|
173
|
+
const selfPaired = step.siblingHash === node;
|
|
174
|
+
const legitimatelyUnpaired = idx === levelSize - 1 && levelSize % 2 === 1;
|
|
175
|
+
if (selfPaired && !legitimatelyUnpaired)
|
|
176
|
+
return false;
|
|
177
|
+
node =
|
|
178
|
+
step.siblingPosition === "LEFT" ? hashPair(step.siblingHash, node) : hashPair(node, step.siblingHash);
|
|
179
|
+
idx = Math.floor(idx / 2);
|
|
180
|
+
levelSize = Math.ceil(levelSize / 2);
|
|
181
|
+
}
|
|
182
|
+
return node === root;
|
|
183
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type ProofStep } from "./ledger-merkle.js";
|
|
2
|
+
export interface InclusionProof {
|
|
3
|
+
seq: string;
|
|
4
|
+
leaf: string;
|
|
5
|
+
blockIndex: string;
|
|
6
|
+
blockRoot: string;
|
|
7
|
+
blockProof: ProofStep[];
|
|
8
|
+
/** Position of this event's leaf within its block, and how many leaves that block had. */
|
|
9
|
+
leafIndex: number;
|
|
10
|
+
blockLeafCount: number;
|
|
11
|
+
checkpointId: string | null;
|
|
12
|
+
checkpointRoot: string | null;
|
|
13
|
+
checkpointProof: ProofStep[];
|
|
14
|
+
/** Position of the block root within the daily tree, and that tree's leaf count. */
|
|
15
|
+
checkpointLeafIndex: number;
|
|
16
|
+
checkpointLeafCount: number;
|
|
17
|
+
anchorRef: string | null;
|
|
18
|
+
/** Producer claim: a self publication receipt exists. Commitment only — NOT independence. */
|
|
19
|
+
anchored: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Producer claim: the producer says a §5.3 external anchor quorum (distinct INDEPENDENT issuers >=
|
|
22
|
+
* its configured requirement) exists for this checkpoint. Absent on bundles exported before the
|
|
23
|
+
* field shipped. Display/triage only — independence is established by THIS verifier's own anchor
|
|
24
|
+
* quorum evaluation (`anchorVerified`), never by trusting the flag.
|
|
25
|
+
*/
|
|
26
|
+
externallyAnchored?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* The quorum size the producer evaluated that claim against. Absent on bundles exported before it
|
|
29
|
+
* shipped. Without it the boolean cannot be read: a 1-of-1 deployment and a 2-of-N deployment both
|
|
30
|
+
* publish `true`. Still a producer claim — it says what the producer required, not what happened.
|
|
31
|
+
*/
|
|
32
|
+
externallyAnchoredRequired?: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Verify a two-hop inclusion proof against a KNOWN daily root (event → block → day).
|
|
36
|
+
* Pass a daily root you obtained independently — recorded earlier, or from the published roots file —
|
|
37
|
+
* NOT proof.checkpointRoot: trusting the root shipped inside the proof would let a forged bundle vouch
|
|
38
|
+
* for itself. (An external anchor cannot supply the root; it holds only a digest committing to it.)
|
|
39
|
+
*/
|
|
40
|
+
export declare function verifyInclusionProof(proof: InclusionProof, dailyRoot: string): boolean;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { hashLeaf, verifyMerkleProof } from "./ledger-merkle.js";
|
|
2
|
+
/**
|
|
3
|
+
* Verify a two-hop inclusion proof against a KNOWN daily root (event → block → day).
|
|
4
|
+
* Pass a daily root you obtained independently — recorded earlier, or from the published roots file —
|
|
5
|
+
* NOT proof.checkpointRoot: trusting the root shipped inside the proof would let a forged bundle vouch
|
|
6
|
+
* for itself. (An external anchor cannot supply the root; it holds only a digest committing to it.)
|
|
7
|
+
*/
|
|
8
|
+
export function verifyInclusionProof(proof, dailyRoot) {
|
|
9
|
+
// Position is REQUIRED, and its absence is a rejection rather than a skipped check. This tree pads
|
|
10
|
+
// an unpaired trailing node against itself, so without an index and a leaf count a path to a leaf
|
|
11
|
+
// that was never in the tree recomputes the real root (verified: a proof for index 3 verifies
|
|
12
|
+
// against a 3-leaf root). A proof that cannot say where its leaf sits does not establish inclusion.
|
|
13
|
+
if (!Number.isInteger(proof.leafIndex) ||
|
|
14
|
+
!Number.isInteger(proof.blockLeafCount) ||
|
|
15
|
+
!Number.isInteger(proof.checkpointLeafIndex) ||
|
|
16
|
+
!Number.isInteger(proof.checkpointLeafCount)) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
if (!verifyMerkleProof(proof.leaf, proof.blockProof, proof.blockRoot, {
|
|
20
|
+
index: proof.leafIndex,
|
|
21
|
+
leafCount: proof.blockLeafCount,
|
|
22
|
+
})) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return verifyMerkleProof(hashLeaf(proof.blockRoot), proof.checkpointProof, dailyRoot, {
|
|
26
|
+
index: proof.checkpointLeafIndex,
|
|
27
|
+
leafCount: proof.checkpointLeafCount,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type AnchorInput } from "./ledger-anchor.js";
|
|
2
|
+
/** The parts of a Rekor log entry we need. Mirrors what packages/db persists as `evidence`. */
|
|
3
|
+
export interface RekorEvidence {
|
|
4
|
+
uuid?: string;
|
|
5
|
+
/** Base64 of the canonicalized entry Rekor stored. The SET signs this — without it, nothing. */
|
|
6
|
+
body?: string;
|
|
7
|
+
logID?: string;
|
|
8
|
+
logIndex?: number;
|
|
9
|
+
integratedTime?: number;
|
|
10
|
+
verification?: {
|
|
11
|
+
signedEntryTimestamp?: string;
|
|
12
|
+
inclusionProof?: {
|
|
13
|
+
logIndex?: number;
|
|
14
|
+
rootHash?: string;
|
|
15
|
+
treeSize?: number;
|
|
16
|
+
hashes?: string[];
|
|
17
|
+
checkpoint?: string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export interface RekorVerification {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
reason?: string;
|
|
24
|
+
/** Rekor's log index for this entry — where a third party can retrieve it themselves. */
|
|
25
|
+
logIndex?: number;
|
|
26
|
+
logID?: string;
|
|
27
|
+
integratedTime?: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The hash Rekor was asked to bind the anchor signature to.
|
|
31
|
+
*
|
|
32
|
+
* MUST match the producer (packages/db/src/anchors/rekor.ts): `signAnchorDigest` signs the RAW
|
|
33
|
+
* 32-byte anchor digest, and the signature scheme applies SHA-256 to those bytes itself — so the
|
|
34
|
+
* value Rekor holds is SHA-256 OF the anchor digest, not the digest.
|
|
35
|
+
*/
|
|
36
|
+
export declare function rekorPayloadHashFor(anchor: AnchorInput): string;
|
|
37
|
+
/**
|
|
38
|
+
* Verify a Rekor anchor: Rekor's signature over the entry, AND that the entry is about this root.
|
|
39
|
+
*
|
|
40
|
+
* `rekorPublicKey` is PEM or base64 SPKI for the log's key, supplied by the CALLER from its own
|
|
41
|
+
* configuration — never from the bundle, for the same reason approver keys are not read from a
|
|
42
|
+
* receipt. Sigstore publishes it via TUF; pin it at deploy time.
|
|
43
|
+
*/
|
|
44
|
+
export declare function verifyRekorAnchor(evidence: RekorEvidence, anchor: AnchorInput, rekorPublicKey: string, opts?: {
|
|
45
|
+
/** The producer's pinned Rekor submission key(s); see ExternalAnchorKeys.rekorSubmitterKeys. */
|
|
46
|
+
submitterKeys?: string[];
|
|
47
|
+
}): RekorVerification;
|
|
48
|
+
/** Parse a stored base64 evidence blob into a RekorEvidence, or null if it is not one. */
|
|
49
|
+
export declare function parseRekorEvidence(evidenceB64: string | null | undefined): RekorEvidence | null;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Sigstore Rekor transparency-log anchor verification.
|
|
2
|
+
//
|
|
3
|
+
// This is what turns an external anchor from "Intyga stored a blob it says came from Rekor" into
|
|
4
|
+
// evidence. Before it existed, `anchorCheckpoints` recorded REKOR anchors with an EMPTY DEWP
|
|
5
|
+
// signature and the export dropped their evidence entirely — so a REKOR anchor could not contribute
|
|
6
|
+
// to a §5.3 quorum, and `AUDIT_ANCHOR_REQUIRED=2` was satisfiable only by anchors signed with
|
|
7
|
+
// Intyga's own key. The independence guarantee never reached the verifier.
|
|
8
|
+
//
|
|
9
|
+
// Two things must hold, and BOTH matter:
|
|
10
|
+
//
|
|
11
|
+
// 1. Rekor really signed this entry — its Signed Entry Timestamp (SET) verifies under Rekor's
|
|
12
|
+
// public key, which the caller supplies from its own trust configuration.
|
|
13
|
+
// 2. The entry is about THIS checkpoint — the logged hashedrekord binds the payload hash to
|
|
14
|
+
// SHA-256(anchorDigest) for the root being verified.
|
|
15
|
+
//
|
|
16
|
+
// Without (2) any valid Rekor entry — there are millions, all public — would "verify". That is the
|
|
17
|
+
// same self-referential trap as verifying a receipt against the key inside it.
|
|
18
|
+
import crypto from "node:crypto";
|
|
19
|
+
import { anchorDigest } from "./ledger-anchor.js";
|
|
20
|
+
/**
|
|
21
|
+
* RFC 8785 JCS over the SET payload.
|
|
22
|
+
*
|
|
23
|
+
* Rekor signs the canonicalized JSON of {body, integratedTime, logID, logIndex} — keys sorted, no
|
|
24
|
+
* whitespace. Reusing the ledger's own JCS habit rather than JSON.stringify with a hand-written key
|
|
25
|
+
* order, because the order IS the contract and hand-ordering is exactly how these drift.
|
|
26
|
+
*/
|
|
27
|
+
function setPayload(e) {
|
|
28
|
+
return JSON.stringify({
|
|
29
|
+
body: e.body,
|
|
30
|
+
integratedTime: e.integratedTime,
|
|
31
|
+
logID: e.logID,
|
|
32
|
+
logIndex: e.logIndex,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** Decode the base64 `body` into a hashedrekord, or null if it is not one. */
|
|
36
|
+
function decodeHashedRekord(bodyB64) {
|
|
37
|
+
try {
|
|
38
|
+
const decoded = JSON.parse(Buffer.from(bodyB64, "base64").toString("utf-8"));
|
|
39
|
+
return decoded && decoded.kind === "hashedrekord" ? decoded : null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** The payload hash a hashedrekord attests to, lowercased, or null. */
|
|
46
|
+
function hashedRekordPayloadHash(decoded) {
|
|
47
|
+
const hash = decoded.spec?.data?.hash;
|
|
48
|
+
if (hash?.algorithm !== "sha256" || typeof hash.value !== "string")
|
|
49
|
+
return null;
|
|
50
|
+
return hash.value.toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
/** DER SPKI bytes of a PEM or base64 key, or null. */
|
|
53
|
+
function spkiDer(key) {
|
|
54
|
+
try {
|
|
55
|
+
const obj = key.includes("BEGIN")
|
|
56
|
+
? crypto.createPublicKey(key)
|
|
57
|
+
: crypto.createPublicKey({ key: Buffer.from(key, "base64"), format: "der", type: "spki" });
|
|
58
|
+
return obj.export({ format: "der", type: "spki" });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Was this entry submitted by a key the caller pinned, and does that key's signature cover the
|
|
66
|
+
* anchor digest? Rekor itself checks the signature at submission, but it accepts ANY key — so without
|
|
67
|
+
* this, anyone who can compute an anchor digest (it is built from public fields) can get it logged
|
|
68
|
+
* under a throwaway key. `publicKey.content` is base64 of the PEM, as hashedrekord requires.
|
|
69
|
+
*/
|
|
70
|
+
function submittedByPinnedKey(decoded, anchor, pinned) {
|
|
71
|
+
const content = decoded.spec?.signature?.publicKey?.content;
|
|
72
|
+
const sig = decoded.spec?.signature?.content;
|
|
73
|
+
if (typeof content !== "string" || typeof sig !== "string")
|
|
74
|
+
return false;
|
|
75
|
+
const submitted = spkiDer(Buffer.from(content, "base64").toString("utf-8"));
|
|
76
|
+
if (!submitted)
|
|
77
|
+
return false;
|
|
78
|
+
if (!pinned.some((k) => spkiDer(k)?.equals(submitted) === true))
|
|
79
|
+
return false;
|
|
80
|
+
try {
|
|
81
|
+
const key = crypto.createPublicKey({ key: submitted, format: "der", type: "spki" });
|
|
82
|
+
if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1")
|
|
83
|
+
return false;
|
|
84
|
+
const digest = anchorDigest(anchor);
|
|
85
|
+
const signature = Buffer.from(sig, "base64");
|
|
86
|
+
const verify = (dsaEncoding) => {
|
|
87
|
+
try {
|
|
88
|
+
return crypto.verify("sha256", digest, { key, dsaEncoding }, signature);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
return (signature.length === 64 && verify("ieee-p1363")) || verify("der");
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The hash Rekor was asked to bind the anchor signature to.
|
|
102
|
+
*
|
|
103
|
+
* MUST match the producer (packages/db/src/anchors/rekor.ts): `signAnchorDigest` signs the RAW
|
|
104
|
+
* 32-byte anchor digest, and the signature scheme applies SHA-256 to those bytes itself — so the
|
|
105
|
+
* value Rekor holds is SHA-256 OF the anchor digest, not the digest.
|
|
106
|
+
*/
|
|
107
|
+
export function rekorPayloadHashFor(anchor) {
|
|
108
|
+
return crypto.createHash("sha256").update(anchorDigest(anchor)).digest("hex");
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Verify a Rekor anchor: Rekor's signature over the entry, AND that the entry is about this root.
|
|
112
|
+
*
|
|
113
|
+
* `rekorPublicKey` is PEM or base64 SPKI for the log's key, supplied by the CALLER from its own
|
|
114
|
+
* configuration — never from the bundle, for the same reason approver keys are not read from a
|
|
115
|
+
* receipt. Sigstore publishes it via TUF; pin it at deploy time.
|
|
116
|
+
*/
|
|
117
|
+
export function verifyRekorAnchor(evidence, anchor, rekorPublicKey, opts = {}) {
|
|
118
|
+
if (!evidence.body)
|
|
119
|
+
return { ok: false, reason: "rekor evidence carries no entry body" };
|
|
120
|
+
const set = evidence.verification?.signedEntryTimestamp;
|
|
121
|
+
if (!set)
|
|
122
|
+
return { ok: false, reason: "rekor evidence carries no signedEntryTimestamp (SET)" };
|
|
123
|
+
if (typeof evidence.logIndex !== "number" || typeof evidence.integratedTime !== "number") {
|
|
124
|
+
return { ok: false, reason: "rekor evidence is missing logIndex/integratedTime" };
|
|
125
|
+
}
|
|
126
|
+
// (2) first — cheap, and it is the check that stops an unrelated (but perfectly valid) public
|
|
127
|
+
// Rekor entry from being presented as evidence for this checkpoint.
|
|
128
|
+
const decoded = decodeHashedRekord(evidence.body);
|
|
129
|
+
const logged = decoded ? hashedRekordPayloadHash(decoded) : null;
|
|
130
|
+
if (!decoded || !logged)
|
|
131
|
+
return { ok: false, reason: "rekor entry body is not a readable hashedrekord" };
|
|
132
|
+
const expected = rekorPayloadHashFor(anchor);
|
|
133
|
+
if (logged !== expected) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
reason: `rekor entry attests a different payload (logged ${logged.slice(0, 16)}…, expected ${expected.slice(0, 16)}…) — this entry is not about this checkpoint`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// (2b) Who submitted it — only when the caller pinned the producer's key.
|
|
140
|
+
if (opts.submitterKeys &&
|
|
141
|
+
opts.submitterKeys.length > 0 &&
|
|
142
|
+
!submittedByPinnedKey(decoded, anchor, opts.submitterKeys)) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
reason: "rekor entry was not submitted under a pinned producer key with a valid signature over this anchor",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// (1) Rekor's own signature over the entry.
|
|
149
|
+
try {
|
|
150
|
+
const keyObject = rekorPublicKey.includes("BEGIN")
|
|
151
|
+
? crypto.createPublicKey(rekorPublicKey)
|
|
152
|
+
: crypto.createPublicKey({
|
|
153
|
+
key: Buffer.from(rekorPublicKey, "base64"),
|
|
154
|
+
format: "der",
|
|
155
|
+
type: "spki",
|
|
156
|
+
});
|
|
157
|
+
// Rekor's log key is ECDSA P-256; pin it rather than letting the key material choose the
|
|
158
|
+
// algorithm, exactly as verifyEcdsaP256 does for approver keys.
|
|
159
|
+
if (keyObject.asymmetricKeyType !== "ec" || keyObject.asymmetricKeyDetails?.namedCurve !== "prime256v1") {
|
|
160
|
+
return { ok: false, reason: "rekor public key is not an EC P-256 key" };
|
|
161
|
+
}
|
|
162
|
+
const verified = crypto.verify("sha256", Buffer.from(setPayload(evidence), "utf-8"), { key: keyObject, dsaEncoding: "der" }, Buffer.from(set, "base64"));
|
|
163
|
+
if (!verified)
|
|
164
|
+
return { ok: false, reason: "rekor SET does not verify under the supplied log key" };
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
return { ok: false, reason: `rekor SET verification failed: ${err.message}` };
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
ok: true,
|
|
171
|
+
logIndex: evidence.logIndex,
|
|
172
|
+
logID: evidence.logID,
|
|
173
|
+
integratedTime: evidence.integratedTime,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** Parse a stored base64 evidence blob into a RekorEvidence, or null if it is not one. */
|
|
177
|
+
export function parseRekorEvidence(evidenceB64) {
|
|
178
|
+
if (!evidenceB64)
|
|
179
|
+
return null;
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(Buffer.from(evidenceB64, "base64").toString("utf-8"));
|
|
182
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type SignedAnchor } from "./ledger-anchor.js";
|
|
2
|
+
/** Caller-owned TSA trust. Never populate this from an evidence bundle. Requires OpenSSL 3. */
|
|
3
|
+
export interface Rfc3161Trust {
|
|
4
|
+
caPem: string;
|
|
5
|
+
/** SHA-256 of the DER signer certificate, pinned specifically to this issuer. */
|
|
6
|
+
signerCertificateSha256: string;
|
|
7
|
+
/** Explicit choice: check caller-supplied offline CRLs, or make no revocation assertion. */
|
|
8
|
+
revocation: "crl" | "unchecked";
|
|
9
|
+
crlPem?: string;
|
|
10
|
+
untrustedPem?: string;
|
|
11
|
+
/** Certificate/CRL evaluation time, Unix seconds. Defaults to current time rounded up to the next second. */
|
|
12
|
+
verificationTime?: number;
|
|
13
|
+
opensslPath?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface Rfc3161Verification {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
reason?: string;
|
|
18
|
+
/** Authenticated TSA time, Unix seconds (fractional seconds are discarded). */
|
|
19
|
+
genTime?: number;
|
|
20
|
+
}
|
|
21
|
+
/** Offline RFC 3161 verification. No shell, downloads, OS trust store, or implicit TSA trust. */
|
|
22
|
+
export declare function verifyRfc3161Anchor(anchor: SignedAnchor, trust: Rfc3161Trust): Rfc3161Verification;
|
|
23
|
+
export declare function verifyRfc3161Timestamp(evidence: string | null | undefined, digest: Uint8Array, trust: Rfc3161Trust, request?: Uint8Array): Rfc3161Verification;
|
|
24
|
+
/** Asynchronous producer equivalent: identical checks without blocking the gateway event loop. */
|
|
25
|
+
export declare function verifyRfc3161TimestampAsync(evidence: string | null | undefined, digest: Uint8Array, trust: Rfc3161Trust, request?: Uint8Array): Promise<Rfc3161Verification>;
|