@entpoind/verify 0.0.0-stage → 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.
@@ -0,0 +1,222 @@
1
+ /**
2
+ * @entpoind/verify — offline verification of Entpoind Tools receipts and
3
+ * VerifiableArtifact bundles.
4
+ *
5
+ * What a customer pastes into this library was signed by the Tools worker's
6
+ * per-jurisdiction Ed25519 key; the matching public keys are published as a
7
+ * JWK Set at `/.well-known/tools-keys.json` (CORS-open on purpose — this
8
+ * library is meant to run from the customer's own page or backend).
9
+ *
10
+ * No trust in us is required beyond the published key set: the signature is
11
+ * verified locally over the RFC 8785 canonical form of the envelope, and an
12
+ * artifact bundle's Merkle inclusion proof is folded locally too.
13
+ *
14
+ * Zero dependencies. WebCrypto only — runs in every modern browser, Node ≥ 20,
15
+ * Deno, Bun, and Cloudflare Workers.
16
+ */
17
+ import { type MerkleProofStep } from "./merkle.js";
18
+ import { type LedgerWitness, type WitnessLogOptions, type WitnessReport } from "./witness.js";
19
+ export { canonicalJcs, NonCanonicalizableError, type JsonValue } from "./canonical-json.js";
20
+ export { merkleLeafHash, verifyMerkleInclusion, verifyMerkleInclusionFromHash, type MerkleProofStep } from "./merkle.js";
21
+ export { verifyEd25519ph } from "./ed25519ph.js";
22
+ export { DEFAULT_REKOR_URL, WITNESS_KIND, type LedgerWitness, type WitnessLogCheck, type WitnessLogOptions, type WitnessLogProblem, type WitnessPart, type WitnessProblem, type WitnessReport, } from "./witness.js";
23
+ /** A public verification key as published in the Tools JWK Set. */
24
+ export type PublishedKey = {
25
+ kid: string;
26
+ kty: string;
27
+ crv: string;
28
+ x: string;
29
+ alg?: string;
30
+ use?: string;
31
+ };
32
+ /** The `/.well-known/tools-keys.json` document shape. */
33
+ export type ToolsKeySet = {
34
+ keys: PublishedKey[];
35
+ };
36
+ /**
37
+ * Where the production key set is published. The same document is mirrored on
38
+ * the Tools marketing domain; either origin serves it CORS-open.
39
+ */
40
+ export declare const DEFAULT_KEYS_URL = "https://entpoind.com/.well-known/tools-keys.json";
41
+ /**
42
+ * Fetch the published key set. `keysUrl` is absolute — e.g.
43
+ * `https://entpoind.com/.well-known/tools-keys.json` or the `keysUrl` field a
44
+ * runtime embeds in its receipts.
45
+ *
46
+ * Throws on transport failure — callers that distinguish "couldn't fetch keys"
47
+ * from "bad signature" should catch and report that separately.
48
+ */
49
+ export declare function fetchToolsKeys(keysUrl: string): Promise<PublishedKey[]>;
50
+ /** Whether this runtime's WebCrypto can verify Ed25519 signatures. Cached after the first call. */
51
+ export declare function ed25519Supported(): Promise<boolean>;
52
+ /**
53
+ * Verify a base64url Ed25519 signature. Never throws on hostile input. Returns
54
+ * false both for a wrong signature and for a runtime without Ed25519; use
55
+ * `ed25519Supported()` to tell those apart, or the `verify*` functions, which
56
+ * report `unsupported_runtime`.
57
+ */
58
+ export declare function verifySignature(publicKey: {
59
+ kty: string;
60
+ crv: string;
61
+ x: string;
62
+ }, message: string, signatureB64Url: string): Promise<boolean>;
63
+ /** Why a verification failed. `unsupported_runtime` means the check could not run, not that the document is bad. */
64
+ export type FailureReason = "bad_signature" | "unknown_key" | "malformed_envelope" | "unsupported_runtime";
65
+ export type ReceiptVerification = {
66
+ ok: true;
67
+ signedAt: string;
68
+ signatureKeyId: string;
69
+ } | {
70
+ ok: false;
71
+ reason: FailureReason;
72
+ };
73
+ /** The canonical bytes a receipt's signature covers: JCS(envelope − signature). */
74
+ export declare function canonicalReceiptString(envelope: Record<string, unknown>): string;
75
+ /** Runtime shape guard for an untrusted receipt envelope. */
76
+ export declare function isReceiptEnvelope(x: unknown): x is Record<string, unknown>;
77
+ /**
78
+ * Verify a Tools receipt against the published public keys. Accepts the parsed
79
+ * envelope, its JSON text, or the `{ receipt }` wrapper the API returns. Pure
80
+ * public-key crypto — no account, no network.
81
+ */
82
+ export declare function verifyReceipt(input: unknown, publicKeys: ReadonlyArray<PublishedKey>): Promise<ReceiptVerification>;
83
+ export type ArtifactVerification = {
84
+ ok: true;
85
+ signedAt: string;
86
+ signatureKeyId: string;
87
+ kind: string;
88
+ } | {
89
+ ok: false;
90
+ reason: FailureReason;
91
+ };
92
+ /** The canonical bytes an artifact's signature covers: JCS(envelope − signature). */
93
+ export declare function canonicalArtifactString(envelope: Record<string, unknown>): string;
94
+ /** Runtime shape guard for an untrusted artifact envelope. */
95
+ export declare function isArtifactEnvelope(x: unknown): x is Record<string, unknown>;
96
+ /** Verify a standalone VerifiableArtifact against the published public keys. */
97
+ export declare function verifyArtifact(input: unknown, publicKeys: ReadonlyArray<PublishedKey>): Promise<ArtifactVerification>;
98
+ /** A self-contained artifact bundle: the signed artifact, its index id, and —
99
+ * once the batch is anchored — the Merkle proof + anchored root. `leafHash` is
100
+ * the plain SHA-256 of the leaf string, an index id; verification does not use it. */
101
+ export type ArtifactBundle = {
102
+ artifact: Record<string, unknown>;
103
+ leafHash: string;
104
+ anchor?: {
105
+ merkleRoot: string;
106
+ proof: MerkleProofStep[];
107
+ topsRef?: string;
108
+ };
109
+ };
110
+ export type BundleVerification = {
111
+ ok: true;
112
+ anchored: boolean;
113
+ signedAt: string;
114
+ kind: string;
115
+ } | {
116
+ ok: false;
117
+ anchored: boolean;
118
+ reason: FailureReason | "not_in_anchored_root";
119
+ };
120
+ /**
121
+ * Verify a bundle offline: the artifact's Ed25519 signature, then — when an
122
+ * anchor is present — that JCS(signed artifact) is included under the anchored
123
+ * Merkle root. `anchored:false` on a pre-anchor bundle is a valid result, not a
124
+ * failure.
125
+ */
126
+ export declare function verifyBundle(input: unknown, publicKeys: ReadonlyArray<PublishedKey>): Promise<BundleVerification>;
127
+ /**
128
+ * One-shot receipt verification against the published key set — fetch keys,
129
+ * resolve `signatureKeyId`, verify. `keysUrl` defaults to the production
130
+ * well-known document; pass an override for staging / self-hosted runtimes.
131
+ * Throws (like `fetchToolsKeys`) when the keys cannot be fetched, so "could not
132
+ * check" never reads as a verdict on the receipt.
133
+ */
134
+ export declare function verifyReceiptOnline(input: unknown, keysUrl?: string): Promise<ReceiptVerification>;
135
+ /** The envelope a ledger batch locks for one receipt: hashes and the signature, never the body. */
136
+ export type LedgerEnvelope = {
137
+ v: 1;
138
+ receipt_id: string;
139
+ kind: string;
140
+ receipt_hash: string;
141
+ signature: string;
142
+ signature_key_id: string;
143
+ issued_at: string;
144
+ region: string;
145
+ retain_until: string;
146
+ };
147
+ /** What `GET /api/v1/tools/receipts/{id}/ledger` answers (the 200 body). */
148
+ export type LedgerProof = {
149
+ kind: "entpoind.tools.receipt-ledger.proof";
150
+ v: 1;
151
+ receiptId: string;
152
+ batchId: string;
153
+ leafIndex: number;
154
+ treeSize: number;
155
+ leafHash: string;
156
+ /** The envelope that leaf hashes; null for a redacted receipt. */
157
+ envelope: LedgerEnvelope | null;
158
+ proof: MerkleProofStep[];
159
+ root: string;
160
+ rootSignature: string;
161
+ signatureKeyId: string;
162
+ region: string;
163
+ retentionClass: string;
164
+ bucketHour: string;
165
+ retainUntil: string;
166
+ anchoredAt: string;
167
+ s3: {
168
+ bucket: string | null;
169
+ key: string;
170
+ versionId: string | null;
171
+ };
172
+ /**
173
+ * The batch root's entry in the public Sigstore Rekor log; null (or absent, before LED-5) until it has been submitted;
174
+ * `{ status: "off" }` when the workspace had turned the public witness off before the receipt was batched.
175
+ */
176
+ witness?: LedgerWitness | null;
177
+ };
178
+ export type LedgerFailureReason = "malformed_proof" | "malformed_envelope" | "not_this_receipt" | "receipt_not_in_batch" | "proof_shape_mismatch" | "not_in_root" | "unknown_key" | "bad_root_signature" | "unsupported_runtime";
179
+ export type LedgerVerification = {
180
+ ok: true;
181
+ /** True when the proof was tied to the receipt you supplied (or its envelope was recomputed from it). False when only the leaf hash was folded. */
182
+ receiptBound: boolean;
183
+ batchId: string;
184
+ leafIndex: number;
185
+ treeSize: number;
186
+ root: string;
187
+ signatureKeyId: string;
188
+ /** When the compliance lock on the stored copy lapses. */
189
+ retainUntil: string;
190
+ /**
191
+ * The public witness, judged on its own: `absent` / `off` / `pending` / `malformed`, or `logged` with `consistent` and the exact
192
+ * `problems`. It never changes `ok`: the ledger proof above stands or falls without it.
193
+ */
194
+ witness: WitnessReport;
195
+ } | {
196
+ ok: false;
197
+ reason: LedgerFailureReason;
198
+ };
199
+ /** The bytes the region's receipt key signed over a batch root. */
200
+ export declare function ledgerRootMessage(p: Pick<LedgerProof, "region" | "batchId" | "retentionClass" | "bucketHour" | "treeSize" | "root" | "retainUntil">): string;
201
+ /**
202
+ * Verify a receipt-ledger proof offline. Pure public-key crypto: no account, no network, and no S3 access.
203
+ *
204
+ * 1. (with `receipt`) the envelope the ledger locked is RECOMPUTED from your saved receipt (its canonical form,
205
+ * hashed, with its signature and key id) and must be the leaf the proof starts from. Without a receipt the leaf is
206
+ * the envelope the proof carries, or just the leaf hash; `receiptBound` says which.
207
+ * 2. the sibling path has exactly the shape its leaf index and tree size require, and folds, leaf to root;
208
+ * 3. the root carries a valid signature by the region's receipt key (the key set that verifies your receipts), over a
209
+ * message that also names the batch, the tree size and the date the lock lapses.
210
+ *
211
+ * It proves the copy was FILED, under that root, by that key. That the object is still locked at S3 is S3's guarantee,
212
+ * checked there (GET object-retention on `s3.key` / `s3.versionId`) by someone holding read access to the bucket.
213
+ *
214
+ * Once the root verifies, the proof's `witness` (the root's entry in the public Rekor log) is checked too and reported in
215
+ * `witness` (see witness.ts): offline, that its hashes are the root message's, that it is by the root's key and its
216
+ * Ed25519ph signature verifies, and that the copy of the log's answer is that entry. With `witnessLog` (opt-in, the only
217
+ * network call here) the entry is also fetched from the log and checked there, with the log's own signatures.
218
+ */
219
+ export declare function verifyLedgerProof(input: unknown, publicKeys: ReadonlyArray<PublishedKey>, options?: {
220
+ receipt?: unknown;
221
+ witnessLog?: boolean | WitnessLogOptions;
222
+ }): Promise<LedgerVerification>;
package/dist/index.js ADDED
@@ -0,0 +1,350 @@
1
+ /**
2
+ * @entpoind/verify — offline verification of Entpoind Tools receipts and
3
+ * VerifiableArtifact bundles.
4
+ *
5
+ * What a customer pastes into this library was signed by the Tools worker's
6
+ * per-jurisdiction Ed25519 key; the matching public keys are published as a
7
+ * JWK Set at `/.well-known/tools-keys.json` (CORS-open on purpose — this
8
+ * library is meant to run from the customer's own page or backend).
9
+ *
10
+ * No trust in us is required beyond the published key set: the signature is
11
+ * verified locally over the RFC 8785 canonical form of the envelope, and an
12
+ * artifact bundle's Merkle inclusion proof is folded locally too.
13
+ *
14
+ * Zero dependencies. WebCrypto only — runs in every modern browser, Node ≥ 20,
15
+ * Deno, Bun, and Cloudflare Workers.
16
+ */
17
+ import { canonicalJcs } from "./canonical-json.js";
18
+ import { merkleLeafHash, sha256Hex, verifyMerkleInclusion, verifyMerkleInclusionFromHash } from "./merkle.js";
19
+ import { checkLedgerWitness } from "./witness.js";
20
+ export { canonicalJcs, NonCanonicalizableError } from "./canonical-json.js";
21
+ export { merkleLeafHash, verifyMerkleInclusion, verifyMerkleInclusionFromHash } from "./merkle.js";
22
+ export { verifyEd25519ph } from "./ed25519ph.js";
23
+ export { DEFAULT_REKOR_URL, WITNESS_KIND, } from "./witness.js";
24
+ /**
25
+ * Where the production key set is published. The same document is mirrored on
26
+ * the Tools marketing domain; either origin serves it CORS-open.
27
+ */
28
+ export const DEFAULT_KEYS_URL = "https://entpoind.com/.well-known/tools-keys.json";
29
+ /**
30
+ * Fetch the published key set. `keysUrl` is absolute — e.g.
31
+ * `https://entpoind.com/.well-known/tools-keys.json` or the `keysUrl` field a
32
+ * runtime embeds in its receipts.
33
+ *
34
+ * Throws on transport failure — callers that distinguish "couldn't fetch keys"
35
+ * from "bad signature" should catch and report that separately.
36
+ */
37
+ export async function fetchToolsKeys(keysUrl) {
38
+ const res = await fetch(keysUrl, { headers: { accept: "application/jwk-set+json, application/json" } });
39
+ if (!res.ok)
40
+ throw new Error(`tools keys: HTTP ${res.status} from ${keysUrl}`);
41
+ const doc = (await res.json());
42
+ if (!doc || !Array.isArray(doc.keys))
43
+ throw new Error("tools keys: response is not a JWK Set");
44
+ return doc.keys.filter((k) => k && typeof k.kid === "string" && k.kty === "OKP" && k.crv === "Ed25519" && typeof k.x === "string");
45
+ }
46
+ // ─── Ed25519 ──────────────────────────────────────────────────────────
47
+ function bytesFromBase64Url(s) {
48
+ const pad = (4 - (s.length % 4)) % 4;
49
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(pad);
50
+ const bin = atob(b64);
51
+ const out = new Uint8Array(bin.length);
52
+ for (let i = 0; i < bin.length; i++)
53
+ out[i] = bin.charCodeAt(i);
54
+ return out;
55
+ }
56
+ // RFC 8032 §7.1 test 1 public key: importing it fails only where WebCrypto has no Ed25519.
57
+ const PROBE_KEY = { kty: "OKP", crv: "Ed25519", x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", ext: true };
58
+ let ed25519Probe;
59
+ /** Whether this runtime's WebCrypto can verify Ed25519 signatures. Cached after the first call. */
60
+ export function ed25519Supported() {
61
+ ed25519Probe ??= (async () => {
62
+ try {
63
+ await crypto.subtle.importKey("jwk", PROBE_KEY, { name: "Ed25519" }, false, ["verify"]);
64
+ return true;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ })();
70
+ return ed25519Probe;
71
+ }
72
+ async function checkSignature(publicKey, message, signatureB64Url) {
73
+ let key;
74
+ try {
75
+ key = await crypto.subtle.importKey("jwk", { kty: publicKey.kty, crv: publicKey.crv, x: publicKey.x, ext: true }, { name: "Ed25519" }, false, ["verify"]);
76
+ }
77
+ catch {
78
+ // A key that will not import is a bad key, unless the runtime cannot do Ed25519 at all.
79
+ return (await ed25519Supported()) ? "invalid" : "unsupported";
80
+ }
81
+ try {
82
+ const ok = await crypto.subtle.verify({ name: "Ed25519" }, key, bytesFromBase64Url(signatureB64Url), new TextEncoder().encode(message));
83
+ return ok ? "valid" : "invalid";
84
+ }
85
+ catch {
86
+ return "invalid";
87
+ }
88
+ }
89
+ /**
90
+ * Verify a base64url Ed25519 signature. Never throws on hostile input. Returns
91
+ * false both for a wrong signature and for a runtime without Ed25519; use
92
+ * `ed25519Supported()` to tell those apart, or the `verify*` functions, which
93
+ * report `unsupported_runtime`.
94
+ */
95
+ export async function verifySignature(publicKey, message, signatureB64Url) {
96
+ return (await checkSignature(publicKey, message, signatureB64Url)) === "valid";
97
+ }
98
+ /** The canonical bytes a receipt's signature covers: JCS(envelope − signature). */
99
+ export function canonicalReceiptString(envelope) {
100
+ const rest = { ...envelope };
101
+ delete rest.signature;
102
+ return canonicalJcs(rest);
103
+ }
104
+ /** Runtime shape guard for an untrusted receipt envelope. */
105
+ export function isReceiptEnvelope(x) {
106
+ if (!x || typeof x !== "object")
107
+ return false;
108
+ const e = x;
109
+ return (e.version === "1" &&
110
+ typeof e.receiptId === "string" &&
111
+ typeof e.issuedAt === "string" &&
112
+ typeof e.type === "string" &&
113
+ typeof e.signatureKeyId === "string" &&
114
+ typeof e.signature === "string" &&
115
+ !!e.body &&
116
+ typeof e.body === "object" &&
117
+ e.body.type === e.type);
118
+ }
119
+ /**
120
+ * Verify a Tools receipt against the published public keys. Accepts the parsed
121
+ * envelope, its JSON text, or the `{ receipt }` wrapper the API returns. Pure
122
+ * public-key crypto — no account, no network.
123
+ */
124
+ export async function verifyReceipt(input, publicKeys) {
125
+ let envelope = typeof input === "string" ? safeParse(input) : input;
126
+ if (!isReceiptEnvelope(envelope) && envelope && typeof envelope === "object") {
127
+ envelope = envelope.receipt;
128
+ }
129
+ if (!isReceiptEnvelope(envelope))
130
+ return { ok: false, reason: "malformed_envelope" };
131
+ const key = findKey(publicKeys, envelope.signatureKeyId);
132
+ if (!key)
133
+ return { ok: false, reason: "unknown_key" };
134
+ const check = await checkSignature(key, canonicalReceiptString(envelope), envelope.signature);
135
+ if (check === "unsupported")
136
+ return { ok: false, reason: "unsupported_runtime" };
137
+ if (check === "invalid")
138
+ return { ok: false, reason: "bad_signature" };
139
+ return { ok: true, signedAt: envelope.issuedAt, signatureKeyId: envelope.signatureKeyId };
140
+ }
141
+ /** The canonical bytes an artifact's signature covers: JCS(envelope − signature). */
142
+ export function canonicalArtifactString(envelope) {
143
+ const rest = { ...envelope };
144
+ delete rest.signature;
145
+ return canonicalJcs(rest);
146
+ }
147
+ /** Runtime shape guard for an untrusted artifact envelope. */
148
+ export function isArtifactEnvelope(x) {
149
+ if (!x || typeof x !== "object")
150
+ return false;
151
+ const e = x;
152
+ return (e.version === "1" &&
153
+ typeof e.artifactId === "string" &&
154
+ typeof e.issuedAt === "string" &&
155
+ typeof e.kind === "string" &&
156
+ typeof e.signatureKeyId === "string" &&
157
+ typeof e.signature === "string" &&
158
+ "subject" in e &&
159
+ "claims" in e);
160
+ }
161
+ /** Verify a standalone VerifiableArtifact against the published public keys. */
162
+ export async function verifyArtifact(input, publicKeys) {
163
+ const envelope = typeof input === "string" ? safeParse(input) : input;
164
+ if (!isArtifactEnvelope(envelope))
165
+ return { ok: false, reason: "malformed_envelope" };
166
+ const key = findKey(publicKeys, envelope.signatureKeyId);
167
+ if (!key)
168
+ return { ok: false, reason: "unknown_key" };
169
+ const check = await checkSignature(key, canonicalArtifactString(envelope), envelope.signature);
170
+ if (check === "unsupported")
171
+ return { ok: false, reason: "unsupported_runtime" };
172
+ if (check === "invalid")
173
+ return { ok: false, reason: "bad_signature" };
174
+ return {
175
+ ok: true,
176
+ signedAt: envelope.issuedAt,
177
+ signatureKeyId: envelope.signatureKeyId,
178
+ kind: envelope.kind,
179
+ };
180
+ }
181
+ /**
182
+ * Verify a bundle offline: the artifact's Ed25519 signature, then — when an
183
+ * anchor is present — that JCS(signed artifact) is included under the anchored
184
+ * Merkle root. `anchored:false` on a pre-anchor bundle is a valid result, not a
185
+ * failure.
186
+ */
187
+ export async function verifyBundle(input, publicKeys) {
188
+ const bundle = (typeof input === "string" ? safeParse(input) : input);
189
+ if (!bundle || typeof bundle !== "object" || !isArtifactEnvelope(bundle.artifact)) {
190
+ return { ok: false, anchored: false, reason: "malformed_envelope" };
191
+ }
192
+ const sig = await verifyArtifact(bundle.artifact, publicKeys);
193
+ if (!sig.ok)
194
+ return { ok: false, anchored: false, reason: sig.reason };
195
+ if (!bundle.anchor)
196
+ return { ok: true, anchored: false, signedAt: sig.signedAt, kind: sig.kind };
197
+ const leaf = canonicalJcs(bundle.artifact);
198
+ const included = await verifyMerkleInclusion(leaf, bundle.anchor.proof ?? [], bundle.anchor.merkleRoot);
199
+ if (!included)
200
+ return { ok: false, anchored: true, reason: "not_in_anchored_root" };
201
+ return { ok: true, anchored: true, signedAt: sig.signedAt, kind: sig.kind };
202
+ }
203
+ /**
204
+ * One-shot receipt verification against the published key set — fetch keys,
205
+ * resolve `signatureKeyId`, verify. `keysUrl` defaults to the production
206
+ * well-known document; pass an override for staging / self-hosted runtimes.
207
+ * Throws (like `fetchToolsKeys`) when the keys cannot be fetched, so "could not
208
+ * check" never reads as a verdict on the receipt.
209
+ */
210
+ export async function verifyReceiptOnline(input, keysUrl = DEFAULT_KEYS_URL) {
211
+ return verifyReceipt(input, await fetchToolsKeys(keysUrl));
212
+ }
213
+ const LEDGER_ROOT_KIND = "entpoind.tools.receipt-ledger.root";
214
+ const HEX64 = /^[0-9a-f]{64}$/;
215
+ function isLedgerProof(x) {
216
+ if (!x || typeof x !== "object")
217
+ return false;
218
+ const p = x;
219
+ const str = (k) => typeof p[k] === "string" && p[k].length > 0;
220
+ if (p.kind !== "entpoind.tools.receipt-ledger.proof" || p.v !== 1)
221
+ return false;
222
+ for (const k of ["receiptId", "batchId", "rootSignature", "signatureKeyId", "region", "retentionClass", "bucketHour", "retainUntil"])
223
+ if (!str(k))
224
+ return false;
225
+ if (typeof p.root !== "string" || !HEX64.test(p.root) || typeof p.leafHash !== "string" || !HEX64.test(p.leafHash))
226
+ return false;
227
+ if (!Number.isInteger(p.leafIndex) || !Number.isInteger(p.treeSize) || p.treeSize < 1 || p.leafIndex < 0 || p.leafIndex >= p.treeSize)
228
+ return false;
229
+ if (!Array.isArray(p.proof))
230
+ return false;
231
+ return true;
232
+ }
233
+ /**
234
+ * The positions (`left`/`right` of the sibling) a proof for leaf `index` in a tree of `size` leaves must have, from the
235
+ * leaf up. An odd node at a level is carried up and contributes no step. Checking the served path against this binds the
236
+ * claimed leaf index and tree size to the proof: a path for a different place cannot be passed off as this one's.
237
+ */
238
+ function expectedPositions(index, size) {
239
+ const out = [];
240
+ let idx = index;
241
+ let n = size;
242
+ while (n > 1) {
243
+ const sibling = idx % 2 === 0 ? idx + 1 : idx - 1;
244
+ if (sibling < n)
245
+ out.push(idx % 2 === 0 ? "right" : "left");
246
+ idx = Math.floor(idx / 2);
247
+ n = Math.ceil(n / 2);
248
+ }
249
+ return out;
250
+ }
251
+ /** The bytes the region's receipt key signed over a batch root. */
252
+ export function ledgerRootMessage(p) {
253
+ return canonicalJcs({
254
+ kind: LEDGER_ROOT_KIND,
255
+ v: 1,
256
+ region: p.region,
257
+ batchId: p.batchId,
258
+ retentionClass: p.retentionClass,
259
+ bucketHour: p.bucketHour,
260
+ treeSize: p.treeSize,
261
+ root: p.root,
262
+ retainUntil: p.retainUntil,
263
+ });
264
+ }
265
+ /**
266
+ * Verify a receipt-ledger proof offline. Pure public-key crypto: no account, no network, and no S3 access.
267
+ *
268
+ * 1. (with `receipt`) the envelope the ledger locked is RECOMPUTED from your saved receipt (its canonical form,
269
+ * hashed, with its signature and key id) and must be the leaf the proof starts from. Without a receipt the leaf is
270
+ * the envelope the proof carries, or just the leaf hash; `receiptBound` says which.
271
+ * 2. the sibling path has exactly the shape its leaf index and tree size require, and folds, leaf to root;
272
+ * 3. the root carries a valid signature by the region's receipt key (the key set that verifies your receipts), over a
273
+ * message that also names the batch, the tree size and the date the lock lapses.
274
+ *
275
+ * It proves the copy was FILED, under that root, by that key. That the object is still locked at S3 is S3's guarantee,
276
+ * checked there (GET object-retention on `s3.key` / `s3.versionId`) by someone holding read access to the bucket.
277
+ *
278
+ * Once the root verifies, the proof's `witness` (the root's entry in the public Rekor log) is checked too and reported in
279
+ * `witness` (see witness.ts): offline, that its hashes are the root message's, that it is by the root's key and its
280
+ * Ed25519ph signature verifies, and that the copy of the log's answer is that entry. With `witnessLog` (opt-in, the only
281
+ * network call here) the entry is also fetched from the log and checked there, with the log's own signatures.
282
+ */
283
+ export async function verifyLedgerProof(input, publicKeys, options = {}) {
284
+ const proof = typeof input === "string" ? safeParse(input) : input;
285
+ if (!isLedgerProof(proof))
286
+ return { ok: false, reason: "malformed_proof" };
287
+ // 1. the leaf
288
+ let receiptBound = false;
289
+ let leaf = null;
290
+ let receipt = typeof options.receipt === "string" ? safeParse(options.receipt) : options.receipt;
291
+ if (options.receipt !== undefined) {
292
+ if (!isReceiptEnvelope(receipt) && receipt && typeof receipt === "object")
293
+ receipt = receipt.receipt;
294
+ if (!isReceiptEnvelope(receipt))
295
+ return { ok: false, reason: "malformed_envelope" };
296
+ if (receipt.receiptId !== proof.receiptId)
297
+ return { ok: false, reason: "not_this_receipt" };
298
+ }
299
+ if (isReceiptEnvelope(receipt) && receipt.redacted !== true) {
300
+ const envelope = {
301
+ v: 1,
302
+ receipt_id: receipt.receiptId,
303
+ kind: receipt.type,
304
+ receipt_hash: await sha256Hex(canonicalReceiptString(receipt)),
305
+ signature: receipt.signature,
306
+ signature_key_id: receipt.signatureKeyId,
307
+ issued_at: receipt.issuedAt,
308
+ region: proof.region,
309
+ retain_until: proof.retainUntil,
310
+ };
311
+ leaf = canonicalJcs(envelope);
312
+ receiptBound = true;
313
+ }
314
+ else if (proof.envelope) {
315
+ leaf = canonicalJcs(proof.envelope);
316
+ }
317
+ if (leaf !== null && (await merkleLeafHash(leaf)) !== proof.leafHash)
318
+ return { ok: false, reason: "receipt_not_in_batch" };
319
+ // 2. the path
320
+ const want = expectedPositions(proof.leafIndex, proof.treeSize);
321
+ if (proof.proof.length !== want.length || proof.proof.some((s, i) => !s || s.position !== want[i]))
322
+ return { ok: false, reason: "proof_shape_mismatch" };
323
+ if (!(await verifyMerkleInclusionFromHash(proof.leafHash, proof.proof, proof.root)))
324
+ return { ok: false, reason: "not_in_root" };
325
+ // 3. the signed root
326
+ const key = findKey(publicKeys, proof.signatureKeyId);
327
+ if (!key)
328
+ return { ok: false, reason: "unknown_key" };
329
+ const check = await checkSignature(key, ledgerRootMessage(proof), proof.rootSignature);
330
+ if (check === "unsupported")
331
+ return { ok: false, reason: "unsupported_runtime" };
332
+ if (check === "invalid")
333
+ return { ok: false, reason: "bad_root_signature" };
334
+ // 4. the public witness: reported, never a verdict on the proof
335
+ const witnessLog = options.witnessLog === true ? {} : options.witnessLog || undefined;
336
+ const witness = await checkLedgerWitness(proof.witness, { rootMessage: ledgerRootMessage(proof), signatureKeyId: proof.signatureKeyId, publicKeyX: key.x }, witnessLog);
337
+ return { ok: true, receiptBound, batchId: proof.batchId, leafIndex: proof.leafIndex, treeSize: proof.treeSize, root: proof.root, signatureKeyId: proof.signatureKeyId, retainUntil: proof.retainUntil, witness };
338
+ }
339
+ // Only a well-formed Ed25519 public key is ever used, whatever the caller's key list holds.
340
+ function findKey(keys, kid) {
341
+ return keys.find((k) => !!k && k.kid === kid && k.kty === "OKP" && k.crv === "Ed25519" && typeof k.x === "string");
342
+ }
343
+ function safeParse(text) {
344
+ try {
345
+ return JSON.parse(text);
346
+ }
347
+ catch {
348
+ return null;
349
+ }
350
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @entpoind/verify — domain-separated Merkle inclusion proofs.
3
+ *
4
+ * Byte-identical port of runtime/shared/merkle.ts's verify path:
5
+ * leaf hash = SHA-256( 0x00 ‖ utf8(leaf) )
6
+ * node hash = SHA-256( 0x01 ‖ leftBytes ‖ rightBytes )
7
+ * odd nodes are carried up unchanged (never duplicated).
8
+ */
9
+ export declare function sha256Hex(input: string | Uint8Array): Promise<string>;
10
+ export declare function merkleLeafHash(leaf: string): Promise<string>;
11
+ export type MerkleProofStep = {
12
+ sibling: string;
13
+ position: "left" | "right";
14
+ };
15
+ /**
16
+ * Verify that `leaf` (the original leaf STRING — the canonical signed envelope)
17
+ * folds along `proof` into `root`. Never throws on a malformed proof.
18
+ */
19
+ export declare function verifyMerkleInclusion(leaf: string, proof: ReadonlyArray<MerkleProofStep>, root: string): Promise<boolean>;
20
+ /**
21
+ * The same fold, starting from a leaf HASH (hex) rather than the leaf string — for a proof whose leaf string is not
22
+ * available (a ledger proof for a redacted receipt). Never throws on a malformed proof.
23
+ */
24
+ export declare function verifyMerkleInclusionFromHash(leafHash: string, proof: ReadonlyArray<MerkleProofStep>, root: string): Promise<boolean>;
package/dist/merkle.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @entpoind/verify — domain-separated Merkle inclusion proofs.
3
+ *
4
+ * Byte-identical port of runtime/shared/merkle.ts's verify path:
5
+ * leaf hash = SHA-256( 0x00 ‖ utf8(leaf) )
6
+ * node hash = SHA-256( 0x01 ‖ leftBytes ‖ rightBytes )
7
+ * odd nodes are carried up unchanged (never duplicated).
8
+ */
9
+ const ENC = new TextEncoder();
10
+ export async function sha256Hex(input) {
11
+ const bytes = typeof input === "string" ? ENC.encode(input) : input;
12
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
13
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
14
+ }
15
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
16
+ function hexBytes(hex) {
17
+ const out = new Uint8Array(hex.length / 2);
18
+ for (let i = 0; i < out.length; i++)
19
+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
20
+ return out;
21
+ }
22
+ function concat(...parts) {
23
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
24
+ let off = 0;
25
+ for (const p of parts) {
26
+ out.set(p, off);
27
+ off += p.length;
28
+ }
29
+ return out;
30
+ }
31
+ export async function merkleLeafHash(leaf) {
32
+ return sha256Hex(concat(Uint8Array.of(0x00), ENC.encode(leaf)));
33
+ }
34
+ async function merkleNodeHash(leftHex, rightHex) {
35
+ return sha256Hex(concat(Uint8Array.of(0x01), hexBytes(leftHex), hexBytes(rightHex)));
36
+ }
37
+ /**
38
+ * Verify that `leaf` (the original leaf STRING — the canonical signed envelope)
39
+ * folds along `proof` into `root`. Never throws on a malformed proof.
40
+ */
41
+ export async function verifyMerkleInclusion(leaf, proof, root) {
42
+ try {
43
+ if (typeof leaf !== "string")
44
+ return false;
45
+ return await verifyMerkleInclusionFromHash(await merkleLeafHash(leaf), proof, root);
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ /**
52
+ * The same fold, starting from a leaf HASH (hex) rather than the leaf string — for a proof whose leaf string is not
53
+ * available (a ledger proof for a redacted receipt). Never throws on a malformed proof.
54
+ */
55
+ export async function verifyMerkleInclusionFromHash(leafHash, proof, root) {
56
+ try {
57
+ if (typeof leafHash !== "string" || typeof root !== "string" || !Array.isArray(proof))
58
+ return false;
59
+ const want = root.toLowerCase();
60
+ if (!SHA256_HEX.test(want) || !SHA256_HEX.test(leafHash.toLowerCase()))
61
+ return false;
62
+ let h = leafHash.toLowerCase();
63
+ for (const step of proof) {
64
+ if (!step || (step.position !== "left" && step.position !== "right"))
65
+ return false;
66
+ if (typeof step.sibling !== "string" || !SHA256_HEX.test(step.sibling.toLowerCase()))
67
+ return false;
68
+ const sibling = step.sibling.toLowerCase();
69
+ h = step.position === "left" ? await merkleNodeHash(sibling, h) : await merkleNodeHash(h, sibling);
70
+ }
71
+ return h === want;
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }