@entpoind/verify 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +127 -1
- package/dist/canonical-json.js +10 -9
- package/dist/ed25519ph.d.ts +25 -0
- package/dist/ed25519ph.js +171 -0
- package/dist/index.d.ts +92 -1
- package/dist/index.js +131 -2
- package/dist/merkle.d.ts +6 -0
- package/dist/merkle.js +18 -4
- package/dist/witness.d.ts +175 -0
- package/dist/witness.js +523 -0
- package/package.json +2 -2
package/dist/witness.js
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @entpoind/verify — the receipt ledger's public witness: a batch root's entry in the Sigstore Rekor log.
|
|
3
|
+
*
|
|
4
|
+
* What the ledger logs (runtime/shared/rekor-witness.ts, by the hourly cron job):
|
|
5
|
+
*
|
|
6
|
+
* rootMessage the canonical JSON the batch's `rootSignature` is over (ledgerRootMessage)
|
|
7
|
+
* A SHA-512(UTF-8 rootMessage) `witness.artifactSha512`
|
|
8
|
+
* data.hash SHA-512(A) `witness.rekorSha512`, the hash in the `hashedrekord` entry
|
|
9
|
+
* signature Ed25519ph by the region's receipt key, the SAME key (`kid`) as the root's, with A as the message,
|
|
10
|
+
* so its prehash is SHA-512(A) = data.hash
|
|
11
|
+
*
|
|
12
|
+
* Offline (always): the witness's hashes are recomputed from the root message, the witness is by the root's key, its
|
|
13
|
+
* Ed25519ph signature verifies over the recomputed hash with that key, and the copy of the log's answer the proof carries
|
|
14
|
+
* (`body`, `entryUuid`, `inclusionProof`) is that entry and hangs together. That copy came from Entpoind, so it shows the
|
|
15
|
+
* witness is consistent, not that the log holds it.
|
|
16
|
+
*
|
|
17
|
+
* Online (opt-in): the entry is fetched from the log itself and checked again, with the log's own inclusion proof,
|
|
18
|
+
* signed checkpoint and signed entry timestamp verified against the log's public key (ECDSA P-256, as rekor.sigstore.dev
|
|
19
|
+
* uses). The key is pinned by the caller, or fetched from the log, which is then trust on first use; the result says which.
|
|
20
|
+
*
|
|
21
|
+
* Nothing here can fail the receipt or the ledger proof: a witness that is absent, off, pending, inconsistent or
|
|
22
|
+
* unreachable is reported as exactly that, next to a ledger proof that verified on its own. `off` means the receipt's
|
|
23
|
+
* workspace had turned the public witness off before the receipt was batched: the batch's root was never submitted, by
|
|
24
|
+
* the workspace's choice. It is not a failure.
|
|
25
|
+
*/
|
|
26
|
+
import { canonicalJcs } from "./canonical-json.js";
|
|
27
|
+
import { verifyEd25519ph } from "./ed25519ph.js";
|
|
28
|
+
export const WITNESS_KIND = "rekor.hashedrekord.v0.0.1";
|
|
29
|
+
export const DEFAULT_REKOR_URL = "https://rekor.sigstore.dev";
|
|
30
|
+
export const WITNESS_LOG_TIMEOUT_MS = 10_000;
|
|
31
|
+
// ─── bytes ────────────────────────────────────────────────────────────
|
|
32
|
+
const ENC = new TextEncoder();
|
|
33
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
34
|
+
const UUID = /^(?:[0-9a-f]{16})?[0-9a-f]{64}$/;
|
|
35
|
+
const SPKI_ED25519_PREFIX = [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00];
|
|
36
|
+
function hexOf(b) {
|
|
37
|
+
let s = "";
|
|
38
|
+
for (const x of b)
|
|
39
|
+
s += x.toString(16).padStart(2, "0");
|
|
40
|
+
return s;
|
|
41
|
+
}
|
|
42
|
+
function bytesOfHex(h) {
|
|
43
|
+
const out = new Uint8Array(h.length / 2);
|
|
44
|
+
for (let i = 0; i < out.length; i++)
|
|
45
|
+
out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
/** base64 or base64url, padded or not. Throws on anything else. */
|
|
49
|
+
function fromBase64(s) {
|
|
50
|
+
const t = s.replace(/\s+/g, "").replace(/-/g, "+").replace(/_/g, "/");
|
|
51
|
+
const bin = atob(t + "=".repeat((4 - (t.length % 4)) % 4));
|
|
52
|
+
const out = new Uint8Array(bin.length);
|
|
53
|
+
for (let i = 0; i < bin.length; i++)
|
|
54
|
+
out[i] = bin.charCodeAt(i);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
function concat(...parts) {
|
|
58
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
59
|
+
let off = 0;
|
|
60
|
+
for (const p of parts) {
|
|
61
|
+
out.set(p, off);
|
|
62
|
+
off += p.length;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
const eq = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
|
|
67
|
+
async function digest(alg, b) {
|
|
68
|
+
return new Uint8Array(await crypto.subtle.digest(alg, b));
|
|
69
|
+
}
|
|
70
|
+
function derOfPem(pem) {
|
|
71
|
+
const m = /-----BEGIN PUBLIC KEY-----([\s\S]*?)-----END PUBLIC KEY-----/.exec(pem);
|
|
72
|
+
if (!m)
|
|
73
|
+
return null;
|
|
74
|
+
try {
|
|
75
|
+
return fromBase64(m[1]);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** The raw 32-byte key of an Ed25519 SubjectPublicKeyInfo, or null. */
|
|
82
|
+
function ed25519OfSpki(der) {
|
|
83
|
+
if (der.length !== 44 || !SPKI_ED25519_PREFIX.every((x, i) => der[i] === x))
|
|
84
|
+
return null;
|
|
85
|
+
return der.slice(12);
|
|
86
|
+
}
|
|
87
|
+
// ─── the log's Merkle tree (RFC 6962) ─────────────────────────────────
|
|
88
|
+
/** RFC 9162 §2.1.3.2: does `leaf` at `index` fold up `path` to `root` in a tree of `size` leaves? */
|
|
89
|
+
async function verifyRfc6962Inclusion(leaf, index, size, path, root) {
|
|
90
|
+
if (!Number.isSafeInteger(index) || !Number.isSafeInteger(size) || index < 0 || index >= size)
|
|
91
|
+
return false;
|
|
92
|
+
let fn = index;
|
|
93
|
+
let sn = size - 1;
|
|
94
|
+
let r = leaf;
|
|
95
|
+
for (const p of path) {
|
|
96
|
+
if (sn === 0)
|
|
97
|
+
return false;
|
|
98
|
+
if (fn % 2 === 1 || fn === sn) {
|
|
99
|
+
r = await digest("SHA-256", concat(Uint8Array.of(1), p, r));
|
|
100
|
+
if (fn % 2 === 0) {
|
|
101
|
+
while (fn % 2 === 0 && fn !== 0) {
|
|
102
|
+
fn = Math.floor(fn / 2);
|
|
103
|
+
sn = Math.floor(sn / 2);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
r = await digest("SHA-256", concat(Uint8Array.of(1), r, p));
|
|
109
|
+
}
|
|
110
|
+
fn = Math.floor(fn / 2);
|
|
111
|
+
sn = Math.floor(sn / 2);
|
|
112
|
+
}
|
|
113
|
+
return sn === 0 && eq(r, root);
|
|
114
|
+
}
|
|
115
|
+
function isInclusionProof(x) {
|
|
116
|
+
if (!x || typeof x !== "object")
|
|
117
|
+
return false;
|
|
118
|
+
const p = x;
|
|
119
|
+
return (Number.isSafeInteger(p.logIndex) &&
|
|
120
|
+
Number.isSafeInteger(p.treeSize) &&
|
|
121
|
+
typeof p.rootHash === "string" &&
|
|
122
|
+
HEX64.test(p.rootHash) &&
|
|
123
|
+
Array.isArray(p.hashes) &&
|
|
124
|
+
p.hashes.every((h) => typeof h === "string" && HEX64.test(h)) &&
|
|
125
|
+
typeof p.checkpoint === "string");
|
|
126
|
+
}
|
|
127
|
+
async function inclusionHolds(leaf, p) {
|
|
128
|
+
return verifyRfc6962Inclusion(leaf, p.logIndex, p.treeSize, p.hashes.map(bytesOfHex), bytesOfHex(p.rootHash));
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The checks on a stored `hashedrekord` body (base64), against what the root gives: the hash is the recomputed one, the
|
|
132
|
+
* key is the root's, and the signature in it verifies. Returns the problems, the signature it carries and the leaf hash.
|
|
133
|
+
*/
|
|
134
|
+
async function checkEntryBody(bodyB64, want) {
|
|
135
|
+
let bytes;
|
|
136
|
+
let body;
|
|
137
|
+
try {
|
|
138
|
+
bytes = fromBase64(bodyB64);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return { problems: ["entry_body_malformed"], signature: null, leaf: null };
|
|
142
|
+
}
|
|
143
|
+
// The leaf is the stored bytes, whatever they hold: the UUID and the inclusion proof are over them.
|
|
144
|
+
const leaf = await digest("SHA-256", concat(Uint8Array.of(0), bytes));
|
|
145
|
+
try {
|
|
146
|
+
body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return { problems: ["entry_body_malformed"], signature: null, leaf };
|
|
150
|
+
}
|
|
151
|
+
const sp = body?.spec;
|
|
152
|
+
if (!body || body.kind !== "hashedrekord" || body.apiVersion !== "0.0.1" || typeof sp?.signature?.content !== "string" || typeof sp?.signature?.publicKey?.content !== "string") {
|
|
153
|
+
return { problems: ["entry_body_malformed"], signature: null, leaf };
|
|
154
|
+
}
|
|
155
|
+
const problems = [];
|
|
156
|
+
if (sp.data?.hash?.algorithm !== "sha512" || sp.data?.hash?.value !== hexOf(want.rekorSha512))
|
|
157
|
+
problems.push("entry_hash_mismatch");
|
|
158
|
+
let signature = null;
|
|
159
|
+
let key = null;
|
|
160
|
+
try {
|
|
161
|
+
signature = fromBase64(sp.signature.content);
|
|
162
|
+
const der = derOfPem(new TextDecoder().decode(fromBase64(sp.signature.publicKey.content)));
|
|
163
|
+
key = der ? ed25519OfSpki(der) : null;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return { problems: ["entry_body_malformed"], signature: null, leaf };
|
|
167
|
+
}
|
|
168
|
+
if (!key || !eq(key, want.publicKey))
|
|
169
|
+
problems.push("entry_key_mismatch");
|
|
170
|
+
else if (!(await verifyEd25519ph(key, want.rekorSha512, signature)))
|
|
171
|
+
problems.push("bad_witness_signature");
|
|
172
|
+
return { problems, signature, leaf };
|
|
173
|
+
}
|
|
174
|
+
/** Never throws: anything the witness holds that cannot be checked is a problem, reported. */
|
|
175
|
+
export async function checkLedgerWitness(witness, ctx, log) {
|
|
176
|
+
if (witness === null || witness === undefined)
|
|
177
|
+
return { status: "absent" };
|
|
178
|
+
if (typeof witness !== "object" || Array.isArray(witness))
|
|
179
|
+
return { status: "malformed" };
|
|
180
|
+
const w = witness;
|
|
181
|
+
const str = (k) => (typeof w[k] === "string" ? w[k] : null);
|
|
182
|
+
const int = (k) => (Number.isSafeInteger(w[k]) ? w[k] : null);
|
|
183
|
+
if (w.status === "off")
|
|
184
|
+
return { status: "off" };
|
|
185
|
+
if (w.status === "pending")
|
|
186
|
+
return { status: "pending", since: str("since"), attempts: int("attempts"), lastError: str("lastError"), nextAttemptAt: str("nextAttemptAt") };
|
|
187
|
+
if (w.status !== "logged")
|
|
188
|
+
return { status: "malformed" };
|
|
189
|
+
const report = {
|
|
190
|
+
status: "logged",
|
|
191
|
+
consistent: false,
|
|
192
|
+
problems: [],
|
|
193
|
+
log: str("log"),
|
|
194
|
+
logIndex: int("logIndex"),
|
|
195
|
+
entryUuid: str("entryUuid"),
|
|
196
|
+
integratedAt: str("integratedAt"),
|
|
197
|
+
searchUrl: str("searchUrl"),
|
|
198
|
+
};
|
|
199
|
+
const problems = new Set();
|
|
200
|
+
try {
|
|
201
|
+
const a = await digest("SHA-512", ENC.encode(ctx.rootMessage));
|
|
202
|
+
const rekor = await digest("SHA-512", a);
|
|
203
|
+
const publicKey = fromBase64(ctx.publicKeyX);
|
|
204
|
+
// 1. the hashes are the root's
|
|
205
|
+
if (str("kind") !== WITNESS_KIND)
|
|
206
|
+
problems.add("unknown_witness_kind");
|
|
207
|
+
if (str("artifactSha512") !== hexOf(a))
|
|
208
|
+
problems.add("artifact_hash_mismatch");
|
|
209
|
+
if (str("rekorSha512") !== hexOf(rekor))
|
|
210
|
+
problems.add("rekor_hash_mismatch");
|
|
211
|
+
// 2. by the root's key, and its signature verifies over the RECOMPUTED hash
|
|
212
|
+
if (str("signatureKeyId") !== ctx.signatureKeyId)
|
|
213
|
+
problems.add("not_the_root_key");
|
|
214
|
+
const sig = str("signature");
|
|
215
|
+
let sigBytes = null;
|
|
216
|
+
try {
|
|
217
|
+
sigBytes = sig === null ? null : fromBase64(sig);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
sigBytes = null;
|
|
221
|
+
}
|
|
222
|
+
if (sigBytes === null)
|
|
223
|
+
problems.add("malformed_witness");
|
|
224
|
+
else if (!(await verifyEd25519ph(publicKey, rekor, sigBytes)))
|
|
225
|
+
problems.add("bad_witness_signature");
|
|
226
|
+
// 3. the copy of the log's answer is that entry
|
|
227
|
+
const body = str("body");
|
|
228
|
+
const uuid = str("entryUuid");
|
|
229
|
+
const integratedTime = int("integratedTime");
|
|
230
|
+
if (body === null || uuid === null || !UUID.test(uuid) || integratedTime === null || report.logIndex === null)
|
|
231
|
+
problems.add("malformed_witness");
|
|
232
|
+
if (body !== null) {
|
|
233
|
+
const entry = await checkEntryBody(body, { rekorSha512: rekor, publicKey });
|
|
234
|
+
for (const p of entry.problems)
|
|
235
|
+
problems.add(p);
|
|
236
|
+
if (entry.signature && sigBytes && !eq(entry.signature, sigBytes))
|
|
237
|
+
problems.add("entry_signature_mismatch");
|
|
238
|
+
if (entry.leaf && uuid !== null && uuid.slice(-64) !== hexOf(entry.leaf))
|
|
239
|
+
problems.add("entry_uuid_mismatch");
|
|
240
|
+
const ip = w.inclusionProof;
|
|
241
|
+
if (ip !== null && ip !== undefined) {
|
|
242
|
+
if (!isInclusionProof(ip))
|
|
243
|
+
problems.add("malformed_witness");
|
|
244
|
+
else if (!entry.leaf || !(await inclusionHolds(entry.leaf, ip)))
|
|
245
|
+
problems.add("inclusion_proof_invalid");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (integratedTime !== null && report.integratedAt !== null && new Date(integratedTime * 1000).toISOString() !== report.integratedAt)
|
|
249
|
+
problems.add("integrated_at_mismatch");
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
problems.add("malformed_witness");
|
|
253
|
+
}
|
|
254
|
+
report.problems = [...problems];
|
|
255
|
+
report.consistent = report.problems.length === 0;
|
|
256
|
+
if (log)
|
|
257
|
+
report.logCheck = await checkWitnessLog(w, ctx, log);
|
|
258
|
+
return report;
|
|
259
|
+
}
|
|
260
|
+
// ─── online: ask the log ──────────────────────────────────────────────
|
|
261
|
+
async function timedFetch(f, url, init, timeoutMs) {
|
|
262
|
+
const ctl = new AbortController();
|
|
263
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
264
|
+
try {
|
|
265
|
+
// A redirect could leave https or the log's origin; the Rekor API does not redirect.
|
|
266
|
+
return await f(url, { ...init, redirect: "error", signal: ctl.signal });
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** An ECDSA signature as DER (what Rekor emits) to the r ‖ s form WebCrypto verifies. Null if it is not one. */
|
|
273
|
+
function p1363OfDer(der, n = 32) {
|
|
274
|
+
let i = 0;
|
|
275
|
+
if (der[i++] !== 0x30)
|
|
276
|
+
return null;
|
|
277
|
+
let len = der[i++];
|
|
278
|
+
if (len & 0x80) {
|
|
279
|
+
if (len !== 0x81)
|
|
280
|
+
return null;
|
|
281
|
+
len = der[i++];
|
|
282
|
+
}
|
|
283
|
+
if (i + len !== der.length)
|
|
284
|
+
return null;
|
|
285
|
+
const out = new Uint8Array(2 * n);
|
|
286
|
+
for (let part = 0; part < 2; part++) {
|
|
287
|
+
if (der[i++] !== 0x02)
|
|
288
|
+
return null;
|
|
289
|
+
const l = der[i++];
|
|
290
|
+
if (l === undefined || l < 1 || i + l > der.length)
|
|
291
|
+
return null;
|
|
292
|
+
let v = der.slice(i, i + l);
|
|
293
|
+
i += l;
|
|
294
|
+
while (v.length > 1 && v[0] === 0)
|
|
295
|
+
v = v.slice(1);
|
|
296
|
+
if (v.length > n)
|
|
297
|
+
return null;
|
|
298
|
+
out.set(v, part * n + (n - v.length));
|
|
299
|
+
}
|
|
300
|
+
return i === der.length ? out : null;
|
|
301
|
+
}
|
|
302
|
+
async function ecdsaVerify(key, derSig, message) {
|
|
303
|
+
const sig = p1363OfDer(derSig);
|
|
304
|
+
if (!sig)
|
|
305
|
+
return false;
|
|
306
|
+
try {
|
|
307
|
+
return await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, sig, message);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* A Rekor checkpoint (a signed note): the body lines `<origin>\n<tree size>\n<base64 root hash>\n[…]\n`, a blank line,
|
|
315
|
+
* then signature lines `— <name> <base64(4-byte key hint ‖ DER signature)>`. The signature is over the body, its last
|
|
316
|
+
* newline included; the key hint is the first four bytes of the log's `logID` (SHA-256 of its key).
|
|
317
|
+
*/
|
|
318
|
+
async function checkCheckpoint(note, ip, logId, key) {
|
|
319
|
+
const cut = note.indexOf("\n\n");
|
|
320
|
+
if (cut < 0)
|
|
321
|
+
return "checkpoint_malformed";
|
|
322
|
+
const signed = note.slice(0, cut + 1);
|
|
323
|
+
const lines = signed.split("\n");
|
|
324
|
+
if (lines.length < 4 || !/^[0-9]+$/.test(lines[1]))
|
|
325
|
+
return "checkpoint_malformed";
|
|
326
|
+
let root;
|
|
327
|
+
try {
|
|
328
|
+
root = fromBase64(lines[2]);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return "checkpoint_malformed";
|
|
332
|
+
}
|
|
333
|
+
if (Number(lines[1]) !== ip.treeSize || hexOf(root) !== ip.rootHash)
|
|
334
|
+
return "checkpoint_mismatch";
|
|
335
|
+
const sigLines = note.slice(cut + 2).split("\n").filter((l) => l.length > 0);
|
|
336
|
+
for (const l of sigLines) {
|
|
337
|
+
const m = /^— (\S+) (\S+)$/.exec(l);
|
|
338
|
+
if (!m)
|
|
339
|
+
continue;
|
|
340
|
+
let raw;
|
|
341
|
+
try {
|
|
342
|
+
raw = fromBase64(m[2]);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (raw.length < 5 || !eq(raw.slice(0, 4), logId.slice(0, 4)))
|
|
348
|
+
continue;
|
|
349
|
+
if (await ecdsaVerify(key, raw.slice(4), ENC.encode(signed)))
|
|
350
|
+
return "ok";
|
|
351
|
+
}
|
|
352
|
+
return "bad_checkpoint_signature";
|
|
353
|
+
}
|
|
354
|
+
async function checkWitnessLog(w, ctx, o) {
|
|
355
|
+
const f = o.fetch ?? fetch;
|
|
356
|
+
const timeoutMs = o.timeoutMs ?? WITNESS_LOG_TIMEOUT_MS;
|
|
357
|
+
const raw = o.logUrl ?? (typeof w.log === "string" && w.log ? w.log : DEFAULT_REKOR_URL);
|
|
358
|
+
const logUrl = raw.replace(/\/+$/, "");
|
|
359
|
+
try {
|
|
360
|
+
if (new URL(logUrl).protocol !== "https:")
|
|
361
|
+
return { checked: false, logUrl, reason: "log_url_not_https" };
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return { checked: false, logUrl, reason: "log_url_not_https" };
|
|
365
|
+
}
|
|
366
|
+
const uuid = typeof w.entryUuid === "string" ? w.entryUuid.toLowerCase() : "";
|
|
367
|
+
if (!UUID.test(uuid))
|
|
368
|
+
return { checked: false, logUrl, reason: "no_entry_uuid" };
|
|
369
|
+
let res;
|
|
370
|
+
try {
|
|
371
|
+
res = await timedFetch(f, `${logUrl}/api/v1/log/entries/${uuid}`, { headers: { accept: "application/json" } }, timeoutMs);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return { checked: false, logUrl, reason: "log_unreachable" };
|
|
375
|
+
}
|
|
376
|
+
const out = {
|
|
377
|
+
checked: true,
|
|
378
|
+
logUrl,
|
|
379
|
+
ok: false,
|
|
380
|
+
problems: [],
|
|
381
|
+
logKey: "unavailable",
|
|
382
|
+
integratedTime: null,
|
|
383
|
+
inclusionProof: "not_checked",
|
|
384
|
+
checkpoint: "not_checked",
|
|
385
|
+
signedEntryTimestamp: "not_checked",
|
|
386
|
+
};
|
|
387
|
+
const problems = new Set();
|
|
388
|
+
const done = () => {
|
|
389
|
+
out.problems = [...problems];
|
|
390
|
+
out.ok = out.problems.length === 0 && out.inclusionProof === "verified" && out.checkpoint === "verified" && out.signedEntryTimestamp === "verified";
|
|
391
|
+
return out;
|
|
392
|
+
};
|
|
393
|
+
if (res.status === 404) {
|
|
394
|
+
await res.body?.cancel().catch(() => { });
|
|
395
|
+
problems.add("entry_not_in_log");
|
|
396
|
+
return done();
|
|
397
|
+
}
|
|
398
|
+
if (!res.ok) {
|
|
399
|
+
await res.body?.cancel().catch(() => { });
|
|
400
|
+
return { checked: false, logUrl, reason: "log_error", status: res.status };
|
|
401
|
+
}
|
|
402
|
+
// The answer: { "<uuid>": { body, integratedTime, logID, logIndex, verification: { inclusionProof, signedEntryTimestamp } } }
|
|
403
|
+
let v = null;
|
|
404
|
+
try {
|
|
405
|
+
const json = (await res.json());
|
|
406
|
+
const keys = json && typeof json === "object" && !Array.isArray(json) ? Object.keys(json) : [];
|
|
407
|
+
if (keys.length === 1) {
|
|
408
|
+
if (keys[0].toLowerCase().slice(-64) !== uuid.slice(-64))
|
|
409
|
+
problems.add("entry_uuid_mismatch");
|
|
410
|
+
v = json[keys[0]];
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
v = null;
|
|
415
|
+
}
|
|
416
|
+
const verification = v?.verification;
|
|
417
|
+
if (!v ||
|
|
418
|
+
typeof v !== "object" ||
|
|
419
|
+
typeof v.body !== "string" ||
|
|
420
|
+
!Number.isSafeInteger(v.logIndex) ||
|
|
421
|
+
!Number.isSafeInteger(v.integratedTime) ||
|
|
422
|
+
typeof v.logID !== "string" ||
|
|
423
|
+
!HEX64.test(v.logID) ||
|
|
424
|
+
!verification ||
|
|
425
|
+
typeof verification !== "object") {
|
|
426
|
+
problems.add("bad_log_response");
|
|
427
|
+
return done();
|
|
428
|
+
}
|
|
429
|
+
const body = v.body;
|
|
430
|
+
const logIndex = v.logIndex;
|
|
431
|
+
const integratedTime = v.integratedTime;
|
|
432
|
+
const logId = v.logID;
|
|
433
|
+
out.integratedTime = integratedTime;
|
|
434
|
+
// The entry the log holds: the root's hash, the root's key, a signature that verifies.
|
|
435
|
+
const a = await digest("SHA-512", ENC.encode(ctx.rootMessage));
|
|
436
|
+
const rekor = await digest("SHA-512", a);
|
|
437
|
+
const entry = await checkEntryBody(body, { rekorSha512: rekor, publicKey: fromBase64(ctx.publicKeyX) });
|
|
438
|
+
for (const p of entry.problems)
|
|
439
|
+
problems.add(p);
|
|
440
|
+
if (!entry.leaf || uuid.slice(-64) !== hexOf(entry.leaf))
|
|
441
|
+
problems.add("entry_uuid_mismatch");
|
|
442
|
+
if (logIndex !== w.logIndex)
|
|
443
|
+
problems.add("log_index_mismatch");
|
|
444
|
+
if (integratedTime !== w.integratedTime)
|
|
445
|
+
problems.add("integrated_time_mismatch");
|
|
446
|
+
// Its inclusion in the log's tree needs no key.
|
|
447
|
+
const ip = verification.inclusionProof;
|
|
448
|
+
if (ip === undefined || ip === null) {
|
|
449
|
+
problems.add("no_inclusion_proof");
|
|
450
|
+
}
|
|
451
|
+
else if (!isInclusionProof(ip)) {
|
|
452
|
+
problems.add("bad_log_response");
|
|
453
|
+
}
|
|
454
|
+
else if (entry.leaf && (await inclusionHolds(entry.leaf, ip))) {
|
|
455
|
+
out.inclusionProof = "verified";
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
out.inclusionProof = "failed";
|
|
459
|
+
problems.add("inclusion_proof_invalid");
|
|
460
|
+
}
|
|
461
|
+
// The log's key: pinned, or the log's own (for the entry's shard, named by the UUID's 16-hex tree id).
|
|
462
|
+
let der = null;
|
|
463
|
+
if (o.logPublicKey !== undefined) {
|
|
464
|
+
der = derOfPem(o.logPublicKey);
|
|
465
|
+
if (der)
|
|
466
|
+
out.logKey = "pinned";
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
const tree = uuid.length === 80 ? `?treeID=${BigInt(`0x${uuid.slice(0, 16)}`).toString()}` : "";
|
|
470
|
+
try {
|
|
471
|
+
const kr = await timedFetch(f, `${logUrl}/api/v1/log/publicKey${tree}`, { headers: { accept: "application/x-pem-file, text/plain" } }, timeoutMs);
|
|
472
|
+
if (kr.ok) {
|
|
473
|
+
der = derOfPem(await kr.text());
|
|
474
|
+
if (der)
|
|
475
|
+
out.logKey = "fetched";
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
await kr.body?.cancel().catch(() => { });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
der = null;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (!der) {
|
|
486
|
+
problems.add("log_key_unavailable");
|
|
487
|
+
return done();
|
|
488
|
+
}
|
|
489
|
+
if (hexOf(await digest("SHA-256", der)) !== logId) {
|
|
490
|
+
problems.add("log_key_not_log_id");
|
|
491
|
+
return done();
|
|
492
|
+
}
|
|
493
|
+
let key;
|
|
494
|
+
try {
|
|
495
|
+
key = await crypto.subtle.importKey("spki", der, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
problems.add("log_key_unsupported");
|
|
499
|
+
return done();
|
|
500
|
+
}
|
|
501
|
+
// The signed entry timestamp: the log's signature over { body, integratedTime, logID, logIndex } (RFC 8785).
|
|
502
|
+
const set = verification.signedEntryTimestamp;
|
|
503
|
+
let setOk = false;
|
|
504
|
+
if (typeof set === "string") {
|
|
505
|
+
try {
|
|
506
|
+
setOk = await ecdsaVerify(key, fromBase64(set), ENC.encode(canonicalJcs({ body, integratedTime, logID: logId, logIndex })));
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
setOk = false;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
out.signedEntryTimestamp = setOk ? "verified" : "failed";
|
|
513
|
+
if (!setOk)
|
|
514
|
+
problems.add("bad_signed_entry_timestamp");
|
|
515
|
+
// The checkpoint the inclusion proof ends in, signed by the log.
|
|
516
|
+
if (isInclusionProof(ip)) {
|
|
517
|
+
const c = await checkCheckpoint(ip.checkpoint, ip, bytesOfHex(logId), key);
|
|
518
|
+
out.checkpoint = c === "ok" ? "verified" : "failed";
|
|
519
|
+
if (c !== "ok")
|
|
520
|
+
problems.add(c);
|
|
521
|
+
}
|
|
522
|
+
return done();
|
|
523
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entpoind/verify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Offline verification of Entpoind Tools receipts and VerifiableArtifact bundles — Ed25519 over RFC 8785 canonical JSON, plus Merkle inclusion proofs. Zero dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Avonao, Inc.",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"./package.json": "./package.json"
|
|
17
17
|
},
|
|
18
|
-
"files": ["dist", "README.md", "LICENSE"],
|
|
18
|
+
"files": ["dist", "README.md", "CHANGELOG.md", "LICENSE"],
|
|
19
19
|
"sideEffects": false,
|
|
20
20
|
"engines": {
|
|
21
21
|
"node": ">=20"
|