@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
Added
|
|
6
|
+
- `verifyLedgerProof(proof, keys, { receipt?, witnessLog? })`: checks a write-once receipt-ledger proof offline. The leaf is
|
|
7
|
+
rebuilt from your receipt, the path shape and fold are checked against the signed batch root, and the root's signature is
|
|
8
|
+
verified with the regional key. No network unless you ask for it.
|
|
9
|
+
- The public witness. A verified ledger proof reports `result.witness` (`absent`, `off`, `pending`, `logged` or `malformed`).
|
|
10
|
+
A `logged` witness is checked offline against the root (hashes, Ed25519ph signature by the root's own key, the copied
|
|
11
|
+
Rekor entry and its inclusion proof) and, with `witnessLog`, against the log itself. The witness never changes `result.ok`.
|
|
12
|
+
- `verifyEd25519ph` (RFC 8032, SHA-512 prehash), `merkleLeafHash`, `verifyMerkleInclusionFromHash`, `ledgerRootMessage`,
|
|
13
|
+
`WITNESS_KIND`, `DEFAULT_REKOR_URL`.
|
|
14
|
+
|
|
15
|
+
Unchanged
|
|
16
|
+
- Receipt, artifact and bundle verification, the `verifyReceiptOnline` default key URL, and the zero-dependency, WebCrypto-only design.
|
|
17
|
+
|
|
18
|
+
Notes
|
|
19
|
+
- ESM only (`"type": "module"`), Node 20 or later. `require()` works from Node 22.12.
|
|
20
|
+
|
|
21
|
+
## 0.1.0
|
|
22
|
+
|
|
23
|
+
First release: `verifyReceipt`, `verifyReceiptOnline`, `verifyArtifact`, `verifyBundle`, `verifyMerkleInclusion`,
|
|
24
|
+
`fetchToolsKeys`, `canonicalJcs`, `ed25519Supported`.
|
package/README.md
CHANGED
|
@@ -23,6 +23,8 @@ wrapper the API returns.
|
|
|
23
23
|
|
|
24
24
|
## Where it runs
|
|
25
25
|
|
|
26
|
+
The package is ESM only (`import`), on Node 20 or later; `require()` works from Node 22.12. See `CHANGELOG.md` for what each version adds.
|
|
27
|
+
|
|
26
28
|
Any runtime whose WebCrypto verifies Ed25519: Node 20 or later (tested on
|
|
27
29
|
Node 22), Deno, Bun, Cloudflare Workers, and current browsers (Chrome and Edge
|
|
28
30
|
137+, Firefox 129+, Safari 17+). Browsers expose WebCrypto only on secure
|
|
@@ -55,13 +57,17 @@ its `keys` to `verifyReceipt`.
|
|
|
55
57
|
| `verifyArtifact(input, keys)` | Verify a standalone artifact envelope. |
|
|
56
58
|
| `verifyBundle(input, keys)` | Signature, plus Merkle inclusion when anchored. |
|
|
57
59
|
| `verifyMerkleInclusion(leaf, proof, root)` | Fold an inclusion proof locally. |
|
|
60
|
+
| `verifyLedgerProof(proof, keys, { receipt?, witnessLog? })` | Verify a write-once-ledger proof and report its public witness (below). No network unless `witnessLog` is set. |
|
|
61
|
+
| `verifyEd25519ph(publicKey, prehash, signature)` | The raw Ed25519ph (RFC 8032, SHA-512 prehash) check, as a boolean. |
|
|
58
62
|
| `canonicalJcs(value)` | The RFC 8785 canonical string. |
|
|
59
63
|
| `ed25519Supported()` | Whether this runtime can verify Ed25519. |
|
|
60
64
|
| `verifySignature(key, message, signature)` | The raw Ed25519 check, as a boolean. |
|
|
61
65
|
|
|
62
66
|
The `verify*` functions never throw on hostile input. `fetchToolsKeys` and
|
|
63
67
|
`verifyReceiptOnline` throw when the keys cannot be fetched, so "could not
|
|
64
|
-
check" is never reported as a verdict.
|
|
68
|
+
check" is never reported as a verdict. The witness log check does not throw: an
|
|
69
|
+
unreachable log is reported as `checked: false`, beside a ledger proof that has
|
|
70
|
+
already been judged.
|
|
65
71
|
|
|
66
72
|
## Failure reasons
|
|
67
73
|
|
|
@@ -76,6 +82,126 @@ check" is never reported as a verdict.
|
|
|
76
82
|
A bundle without an anchor verifies with `anchored: false`; that is a valid
|
|
77
83
|
result, not a failure.
|
|
78
84
|
|
|
85
|
+
## Ledger proofs
|
|
86
|
+
|
|
87
|
+
A receipt that has been copied to the write-once ledger has a proof at
|
|
88
|
+
`GET /api/v1/tools/receipts/{receiptId}/ledger` (404 `not_anchored_yet` until the
|
|
89
|
+
copy is made). `verifyLedgerProof(proof, keys, { receipt })` checks, offline:
|
|
90
|
+
|
|
91
|
+
1. **the leaf**: the envelope that was locked is recomputed from your saved receipt
|
|
92
|
+
(the SHA-256 of its canonical form, with its signature and key id) and must be
|
|
93
|
+
the leaf the proof starts from (`receiptBound: true`). Without `receipt` the
|
|
94
|
+
envelope in the proof, or just its leaf hash, is used;
|
|
95
|
+
2. **the path**: it has the shape its leaf index and tree size require, and folds,
|
|
96
|
+
leaf to root;
|
|
97
|
+
3. **the root**: it carries a valid signature by the region's receipt key (the same
|
|
98
|
+
published keys that verify your receipts) over a message that names the batch,
|
|
99
|
+
the tree size and the date the lock lapses.
|
|
100
|
+
|
|
101
|
+
This proves the copy was filed under that signed root. That the stored object is
|
|
102
|
+
still locked is checked at the storage service, with read access to the bucket,
|
|
103
|
+
using `s3.key` and `s3.versionId` from the proof.
|
|
104
|
+
|
|
105
|
+
| `reason` | Meaning |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `malformed_proof` | Not a complete ledger proof. |
|
|
108
|
+
| `not_this_receipt` | The proof is for another receipt id. |
|
|
109
|
+
| `receipt_not_in_batch` | Your receipt does not hash to the leaf that was locked (it was altered, or the proof is for different bytes). |
|
|
110
|
+
| `proof_shape_mismatch` | The path does not fit the claimed leaf index and tree size. |
|
|
111
|
+
| `not_in_root` | The path does not reach the root. |
|
|
112
|
+
| `unknown_key` / `bad_root_signature` | The root's signing key is not in the set, or its signature does not verify. |
|
|
113
|
+
|
|
114
|
+
### The public witness
|
|
115
|
+
|
|
116
|
+
A proof may carry `witness`: the batch root's entry in Sigstore's public Rekor log
|
|
117
|
+
(`hashedrekord` v0.0.1). The batch's root message is hashed, `A = SHA-512(message)`,
|
|
118
|
+
and `A` is signed in Ed25519ph mode by the **same** regional key that signed the root,
|
|
119
|
+
so the log records `data.hash = SHA-512(A)` (`witness.rekorSha512`).
|
|
120
|
+
|
|
121
|
+
A verified ledger proof always reports the witness in `result.witness`. The witness
|
|
122
|
+
never changes `result.ok`: the ledger proof stands or falls on its own.
|
|
123
|
+
|
|
124
|
+
| `witness.status` | Meaning |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `absent` | The proof has no witness: the batch has not been submitted yet, or the witness is not switched on. |
|
|
127
|
+
| `off` | Not witnessed, by the workspace's choice: an owner or admin had turned the public witness off (it is on by default) before the receipt was batched, so the batch root was never submitted. Not a failure; the log is never asked. |
|
|
128
|
+
| `pending` | Submitted, not in the log yet; `since`, `attempts`, `lastError` and `nextAttemptAt` as the proof states them. |
|
|
129
|
+
| `logged` | `consistent: true` with `problems: []`, or `consistent: false` with the exact `problems` (below). |
|
|
130
|
+
| `malformed` | A `witness` whose status is none of `off`, `pending` and `logged`. |
|
|
131
|
+
|
|
132
|
+
**Offline (always).** No network. Checked against the root message rebuilt from the
|
|
133
|
+
proof and the root's published key:
|
|
134
|
+
|
|
135
|
+
- `artifactSha512` is SHA-512 of the root message, and `rekorSha512` is SHA-512 of
|
|
136
|
+
that (`artifact_hash_mismatch`, `rekor_hash_mismatch`); `kind` is
|
|
137
|
+
`rekor.hashedrekord.v0.0.1` (`unknown_witness_kind`);
|
|
138
|
+
- `signatureKeyId` is the root's key id (`not_the_root_key`), and `signature`
|
|
139
|
+
verifies as Ed25519ph with the root's key over the **recomputed** hash
|
|
140
|
+
(`bad_witness_signature`);
|
|
141
|
+
- the copy of the log's answer in the proof is that entry: `body` is a `hashedrekord`
|
|
142
|
+
with `data.hash = { sha512, <recomputed hash> }`, the root's public key and the same
|
|
143
|
+
signature (`entry_body_malformed`, `entry_hash_mismatch`, `entry_key_mismatch`,
|
|
144
|
+
`entry_signature_mismatch`); `entryUuid` ends in the RFC 6962 leaf hash of `body`
|
|
145
|
+
(`entry_uuid_mismatch`); the copied `inclusionProof`, when present, folds that leaf
|
|
146
|
+
to the root hash it names (`inclusion_proof_invalid`); `integratedAt` is the instant
|
|
147
|
+
`integratedTime` names (`integrated_at_mismatch`). A missing field is
|
|
148
|
+
`malformed_witness`.
|
|
149
|
+
|
|
150
|
+
That copy came from Entpoind, so the offline check shows the witness is consistent
|
|
151
|
+
with the root, not that the log holds it.
|
|
152
|
+
|
|
153
|
+
**Online (opt-in).** Pass `witnessLog: true`, or options:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
const r = await verifyLedgerProof(proof, keys, {
|
|
157
|
+
receipt,
|
|
158
|
+
witnessLog: { logPublicKey: rekorPem }, // or `true`; also logUrl, fetch, timeoutMs
|
|
159
|
+
});
|
|
160
|
+
if (r.ok && r.witness.status === "logged") console.log(r.witness.consistent, r.witness.logCheck);
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The entry is fetched from the log itself, `GET {log}/api/v1/log/entries/{entryUuid}`,
|
|
164
|
+
where `{log}` is `logUrl`, else the proof's `witness.log`, else
|
|
165
|
+
`https://rekor.sigstore.dev`. Only `https:` is used, and redirects are refused. On the
|
|
166
|
+
log's answer it checks:
|
|
167
|
+
|
|
168
|
+
- the entry holds the recomputed hash and the root's key, and its signature verifies
|
|
169
|
+
as Ed25519ph with that key; the UUID is the entry's leaf hash; its `logIndex` and
|
|
170
|
+
`integratedTime` are the ones the proof states;
|
|
171
|
+
- its **inclusion proof** folds the entry to the tree root it names (RFC 6962);
|
|
172
|
+
- its **checkpoint** (the signed note the inclusion proof ends in) names that tree size
|
|
173
|
+
and root, and is signed by the log;
|
|
174
|
+
- its **signed entry timestamp** is the log's signature over
|
|
175
|
+
`{ body, integratedTime, logID, logIndex }` (RFC 8785).
|
|
176
|
+
|
|
177
|
+
The log's signatures are checked with its public key: `logPublicKey` (SPKI PEM) if you
|
|
178
|
+
pin it (`logKey: "pinned"`), otherwise the key fetched from the same log at
|
|
179
|
+
`/api/v1/log/publicKey?treeID=…` (`logKey: "fetched"`). Either way the key must hash
|
|
180
|
+
(SHA-256) to the entry's `logID`. A fetched key is trust on first use: a log that is not
|
|
181
|
+
Sigstore's could serve its own key. Pin Sigstore's key (from its trust root, for example
|
|
182
|
+
the TUF repository the Sigstore clients use) for the strongest check.
|
|
183
|
+
|
|
184
|
+
`witness.logCheck` is `{ checked: false, reason }` when nothing was judged
|
|
185
|
+
(`log_url_not_https`, `no_entry_uuid`, `log_unreachable`, `log_error` with `status`),
|
|
186
|
+
or `{ checked: true, ok, problems, logKey, integratedTime, inclusionProof, checkpoint,
|
|
187
|
+
signedEntryTimestamp }`, each part `verified`, `failed` or `not_checked`. `ok` is true
|
|
188
|
+
only when all three parts are `verified` and there is no problem. A 404 from the log is
|
|
189
|
+
the finding `entry_not_in_log`.
|
|
190
|
+
|
|
191
|
+
**What is not checked.**
|
|
192
|
+
|
|
193
|
+
- Log keys other than ECDSA P-256 (`log_key_unsupported`). rekor.sigstore.dev uses
|
|
194
|
+
P-256.
|
|
195
|
+
- That the log is consistent over time: no consistency proof between checkpoints, no
|
|
196
|
+
checkpoint gossip or witness cosignatures. A log that showed different trees to
|
|
197
|
+
different people would need that to be caught; Sigstore's monitors do it.
|
|
198
|
+
- The copy's `signedEntryTimestamp` and checkpoint offline: they are checked only on
|
|
199
|
+
the log's fresh answer.
|
|
200
|
+
- Rekor v2 (tile-based) logs: this speaks the v1 API that the ledger submits to.
|
|
201
|
+
- What the witness cannot prove at all: that every receipt was put in a batch, or that
|
|
202
|
+
what a receipt says is true. It shows the root existed, signed by our key, no later
|
|
203
|
+
than the log's `integratedTime`.
|
|
204
|
+
|
|
79
205
|
## Documentation
|
|
80
206
|
|
|
81
207
|
Receipt format, verification procedure and key rotation:
|
package/dist/canonical-json.js
CHANGED
|
@@ -16,17 +16,17 @@ export class NonCanonicalizableError extends Error {
|
|
|
16
16
|
this.name = "NonCanonicalizableError";
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
function
|
|
19
|
+
function serializeValue(value, path) {
|
|
20
20
|
if (value === null)
|
|
21
|
-
return null;
|
|
21
|
+
return "null";
|
|
22
22
|
const t = typeof value;
|
|
23
23
|
if (t === "boolean" || t === "string")
|
|
24
|
-
return value;
|
|
24
|
+
return JSON.stringify(value);
|
|
25
25
|
if (t === "number") {
|
|
26
26
|
if (!Number.isFinite(value)) {
|
|
27
27
|
throw new NonCanonicalizableError(`non-finite number (${String(value)}) is not valid JSON`, path);
|
|
28
28
|
}
|
|
29
|
-
return value;
|
|
29
|
+
return JSON.stringify(value);
|
|
30
30
|
}
|
|
31
31
|
if (t === "bigint") {
|
|
32
32
|
throw new NonCanonicalizableError("bigint cannot be canonicalized (no JSON representation)", path);
|
|
@@ -35,23 +35,24 @@ function canonicalizeValue(value, path) {
|
|
|
35
35
|
throw new NonCanonicalizableError(`${t} cannot be canonicalized`, path);
|
|
36
36
|
}
|
|
37
37
|
if (Array.isArray(value)) {
|
|
38
|
-
return value.map((v, i) =>
|
|
38
|
+
return `[${value.map((v, i) => serializeValue(v, `${path}[${i}]`)).join(",")}]`;
|
|
39
39
|
}
|
|
40
40
|
const proto = Object.getPrototypeOf(value);
|
|
41
41
|
if (proto !== Object.prototype && proto !== null) {
|
|
42
42
|
throw new NonCanonicalizableError(`non-plain object (${value.constructor?.name ?? "unknown"}) cannot be canonicalized; pass plain JSON`, path);
|
|
43
43
|
}
|
|
44
44
|
const obj = value;
|
|
45
|
-
|
|
45
|
+
// Written as text: an object would reorder integer-like keys and swallow a `__proto__` key.
|
|
46
|
+
const members = [];
|
|
46
47
|
for (const key of Object.keys(obj).sort()) {
|
|
47
48
|
const child = obj[key];
|
|
48
49
|
if (child === undefined)
|
|
49
50
|
continue;
|
|
50
|
-
|
|
51
|
+
members.push(`${JSON.stringify(key)}:${serializeValue(child, path ? `${path}.${key}` : key)}`);
|
|
51
52
|
}
|
|
52
|
-
return
|
|
53
|
+
return `{${members.join(",")}}`;
|
|
53
54
|
}
|
|
54
55
|
/** The RFC 8785 canonical string for `value` — the bytes a signature covers. */
|
|
55
56
|
export function canonicalJcs(value) {
|
|
56
|
-
return
|
|
57
|
+
return serializeValue(value, "");
|
|
57
58
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @entpoind/verify — Ed25519ph (RFC 8032 §5.1, "HashEdDSA" with SHA-512) signature verification.
|
|
3
|
+
*
|
|
4
|
+
* Needed for one thing: the receipt ledger's public witness. Rekor accepts an Ed25519 key in a `hashedrekord` entry only as
|
|
5
|
+
* Ed25519ph, so the witness signature is Ed25519ph by the region's receipt key, and WebCrypto has no Ed25519ph. This is
|
|
6
|
+
* the RFC's verification procedure (§5.1.7) in BigInt arithmetic, with WebCrypto supplying SHA-512 and nothing else:
|
|
7
|
+
*
|
|
8
|
+
* k = SHA-512( dom2(1, "") ‖ R ‖ A ‖ PH(M) ) mod L, and accept iff encode([S]B − [k]A) == R
|
|
9
|
+
*
|
|
10
|
+
* the cofactorless check, with S < L and A and R decoded strictly (§5.1.3), as Go's crypto/ed25519 does. Tested against
|
|
11
|
+
* the RFC 8032 §7.3 vector and against signatures made by Go's crypto/ed25519.
|
|
12
|
+
*
|
|
13
|
+
* Verification only touches public values, so the arithmetic need not be constant-time. `ed25519phSign` exists for test
|
|
14
|
+
* fixtures; it is not exported from the package and must never see a real key.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Ed25519ph verification over an already computed prehash `ph` = SHA-512(M) (64 bytes), as Rekor and Go's
|
|
18
|
+
* `ed25519.VerifyWithOptions(pub, ph, sig, &ed25519.Options{Hash: crypto.SHA512})` take it. Never throws.
|
|
19
|
+
*/
|
|
20
|
+
export declare function verifyEd25519ph(publicKey: Uint8Array, ph: Uint8Array, signature: Uint8Array): Promise<boolean>;
|
|
21
|
+
/** TEST FIXTURES ONLY: an Ed25519ph signature (RFC 8032 §5.1.6) by the 32-byte `seed` over the prehash `ph`. */
|
|
22
|
+
export declare function ed25519phSign(seed: Uint8Array, ph: Uint8Array): Promise<{
|
|
23
|
+
publicKey: Uint8Array;
|
|
24
|
+
signature: Uint8Array;
|
|
25
|
+
}>;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @entpoind/verify — Ed25519ph (RFC 8032 §5.1, "HashEdDSA" with SHA-512) signature verification.
|
|
3
|
+
*
|
|
4
|
+
* Needed for one thing: the receipt ledger's public witness. Rekor accepts an Ed25519 key in a `hashedrekord` entry only as
|
|
5
|
+
* Ed25519ph, so the witness signature is Ed25519ph by the region's receipt key, and WebCrypto has no Ed25519ph. This is
|
|
6
|
+
* the RFC's verification procedure (§5.1.7) in BigInt arithmetic, with WebCrypto supplying SHA-512 and nothing else:
|
|
7
|
+
*
|
|
8
|
+
* k = SHA-512( dom2(1, "") ‖ R ‖ A ‖ PH(M) ) mod L, and accept iff encode([S]B − [k]A) == R
|
|
9
|
+
*
|
|
10
|
+
* the cofactorless check, with S < L and A and R decoded strictly (§5.1.3), as Go's crypto/ed25519 does. Tested against
|
|
11
|
+
* the RFC 8032 §7.3 vector and against signatures made by Go's crypto/ed25519.
|
|
12
|
+
*
|
|
13
|
+
* Verification only touches public values, so the arithmetic need not be constant-time. `ed25519phSign` exists for test
|
|
14
|
+
* fixtures; it is not exported from the package and must never see a real key.
|
|
15
|
+
*/
|
|
16
|
+
const P = 2n ** 255n - 19n;
|
|
17
|
+
const L = 2n ** 252n + 27742317777372353535851937790883648493n;
|
|
18
|
+
const D = mod(-121665n * inv(121666n));
|
|
19
|
+
const SQRT_M1 = pow(2n, (P - 1n) / 4n);
|
|
20
|
+
const BASE = (() => {
|
|
21
|
+
const y = mod(4n * inv(5n));
|
|
22
|
+
const x = 15112221349535400772501151409588531511454012693041857206046113283949847762202n;
|
|
23
|
+
return { x, y, z: 1n, t: mod(x * y) };
|
|
24
|
+
})();
|
|
25
|
+
const IDENTITY = { x: 0n, y: 1n, z: 1n, t: 0n };
|
|
26
|
+
/** dom2(phflag = 1, context = ""), RFC 8032 §2. */
|
|
27
|
+
const DOM2_PH = new Uint8Array([...new TextEncoder().encode("SigEd25519 no Ed25519 collisions"), 1, 0]);
|
|
28
|
+
function mod(a, m = P) {
|
|
29
|
+
const r = a % m;
|
|
30
|
+
return r >= 0n ? r : r + m;
|
|
31
|
+
}
|
|
32
|
+
function pow(b, e) {
|
|
33
|
+
let r = 1n;
|
|
34
|
+
let x = mod(b);
|
|
35
|
+
while (e > 0n) {
|
|
36
|
+
if (e & 1n)
|
|
37
|
+
r = mod(r * x);
|
|
38
|
+
x = mod(x * x);
|
|
39
|
+
e >>= 1n;
|
|
40
|
+
}
|
|
41
|
+
return r;
|
|
42
|
+
}
|
|
43
|
+
function inv(a) {
|
|
44
|
+
return pow(a, P - 2n);
|
|
45
|
+
}
|
|
46
|
+
// RFC 8032 §5.1.4, extended coordinates; the addition is complete, so it doubles too.
|
|
47
|
+
function add(p, q) {
|
|
48
|
+
const a = mod((p.y - p.x) * (q.y - q.x));
|
|
49
|
+
const b = mod((p.y + p.x) * (q.y + q.x));
|
|
50
|
+
const c = mod(2n * D * p.t * q.t);
|
|
51
|
+
const d = mod(2n * p.z * q.z);
|
|
52
|
+
const e = b - a;
|
|
53
|
+
const f = d - c;
|
|
54
|
+
const g = d + c;
|
|
55
|
+
const h = b + a;
|
|
56
|
+
return { x: mod(e * f), y: mod(g * h), z: mod(f * g), t: mod(e * h) };
|
|
57
|
+
}
|
|
58
|
+
function mul(p, n) {
|
|
59
|
+
let r = IDENTITY;
|
|
60
|
+
let q = p;
|
|
61
|
+
while (n > 0n) {
|
|
62
|
+
if (n & 1n)
|
|
63
|
+
r = add(r, q);
|
|
64
|
+
q = add(q, q);
|
|
65
|
+
n >>= 1n;
|
|
66
|
+
}
|
|
67
|
+
return r;
|
|
68
|
+
}
|
|
69
|
+
function negate(p) {
|
|
70
|
+
return { x: mod(-p.x), y: p.y, z: p.z, t: mod(-p.t) };
|
|
71
|
+
}
|
|
72
|
+
function leInt(b) {
|
|
73
|
+
let n = 0n;
|
|
74
|
+
for (let i = b.length - 1; i >= 0; i--)
|
|
75
|
+
n = (n << 8n) | BigInt(b[i]);
|
|
76
|
+
return n;
|
|
77
|
+
}
|
|
78
|
+
function leBytes(n, len) {
|
|
79
|
+
const out = new Uint8Array(len);
|
|
80
|
+
for (let i = 0; i < len; i++) {
|
|
81
|
+
out[i] = Number(n & 0xffn);
|
|
82
|
+
n >>= 8n;
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
function encode(p) {
|
|
87
|
+
const zi = inv(p.z);
|
|
88
|
+
const x = mod(p.x * zi);
|
|
89
|
+
const y = mod(p.y * zi);
|
|
90
|
+
const out = leBytes(y, 32);
|
|
91
|
+
if (x & 1n)
|
|
92
|
+
out[31] = out[31] | 0x80;
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
/** RFC 8032 §5.1.3. Null for anything that is not the canonical encoding of a curve point. */
|
|
96
|
+
function decode(b) {
|
|
97
|
+
if (b.length !== 32)
|
|
98
|
+
return null;
|
|
99
|
+
const x0 = (b[31] & 0x80) !== 0;
|
|
100
|
+
const yb = b.slice();
|
|
101
|
+
yb[31] = yb[31] & 0x7f;
|
|
102
|
+
const y = leInt(yb);
|
|
103
|
+
if (y >= P)
|
|
104
|
+
return null;
|
|
105
|
+
const u = mod(y * y - 1n);
|
|
106
|
+
const v = mod(D * y * y + 1n);
|
|
107
|
+
let x = mod(u * pow(v, 3n) * pow(u * pow(v, 7n), (P - 5n) / 8n));
|
|
108
|
+
const vx2 = mod(v * x * x);
|
|
109
|
+
if (vx2 === u) {
|
|
110
|
+
/* x is a root */
|
|
111
|
+
}
|
|
112
|
+
else if (vx2 === mod(-u)) {
|
|
113
|
+
x = mod(x * SQRT_M1);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (x === 0n && x0)
|
|
119
|
+
return null;
|
|
120
|
+
if ((x & 1n) !== (x0 ? 1n : 0n))
|
|
121
|
+
x = mod(-x);
|
|
122
|
+
return { x, y, z: 1n, t: mod(x * y) };
|
|
123
|
+
}
|
|
124
|
+
async function sha512(...parts) {
|
|
125
|
+
const all = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
126
|
+
let off = 0;
|
|
127
|
+
for (const p of parts) {
|
|
128
|
+
all.set(p, off);
|
|
129
|
+
off += p.length;
|
|
130
|
+
}
|
|
131
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-512", all));
|
|
132
|
+
}
|
|
133
|
+
const eq = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
|
|
134
|
+
/**
|
|
135
|
+
* Ed25519ph verification over an already computed prehash `ph` = SHA-512(M) (64 bytes), as Rekor and Go's
|
|
136
|
+
* `ed25519.VerifyWithOptions(pub, ph, sig, &ed25519.Options{Hash: crypto.SHA512})` take it. Never throws.
|
|
137
|
+
*/
|
|
138
|
+
export async function verifyEd25519ph(publicKey, ph, signature) {
|
|
139
|
+
try {
|
|
140
|
+
if (publicKey.length !== 32 || ph.length !== 64 || signature.length !== 64)
|
|
141
|
+
return false;
|
|
142
|
+
const a = decode(publicKey);
|
|
143
|
+
if (!a)
|
|
144
|
+
return false;
|
|
145
|
+
const rBytes = signature.slice(0, 32);
|
|
146
|
+
const s = leInt(signature.slice(32));
|
|
147
|
+
if (s >= L)
|
|
148
|
+
return false;
|
|
149
|
+
const k = mod(leInt(await sha512(DOM2_PH, rBytes, publicKey, ph)), L);
|
|
150
|
+
return eq(encode(add(mul(BASE, s), mul(negate(a), k))), rBytes);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** TEST FIXTURES ONLY: an Ed25519ph signature (RFC 8032 §5.1.6) by the 32-byte `seed` over the prehash `ph`. */
|
|
157
|
+
export async function ed25519phSign(seed, ph) {
|
|
158
|
+
const h = await sha512(seed);
|
|
159
|
+
const sb = h.slice(0, 32);
|
|
160
|
+
sb[0] = sb[0] & 248;
|
|
161
|
+
sb[31] = (sb[31] & 127) | 64;
|
|
162
|
+
const s = leInt(sb);
|
|
163
|
+
const publicKey = encode(mul(BASE, s));
|
|
164
|
+
const r = mod(leInt(await sha512(DOM2_PH, h.slice(32), ph)), L);
|
|
165
|
+
const rBytes = encode(mul(BASE, r));
|
|
166
|
+
const k = mod(leInt(await sha512(DOM2_PH, rBytes, publicKey, ph)), L);
|
|
167
|
+
const signature = new Uint8Array(64);
|
|
168
|
+
signature.set(rBytes, 0);
|
|
169
|
+
signature.set(leBytes(mod(r + k * s, L), 32), 32);
|
|
170
|
+
return { publicKey, signature };
|
|
171
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
* Deno, Bun, and Cloudflare Workers.
|
|
16
16
|
*/
|
|
17
17
|
import { type MerkleProofStep } from "./merkle.js";
|
|
18
|
+
import { type LedgerWitness, type WitnessLogOptions, type WitnessReport } from "./witness.js";
|
|
18
19
|
export { canonicalJcs, NonCanonicalizableError, type JsonValue } from "./canonical-json.js";
|
|
19
|
-
export { merkleLeafHash, verifyMerkleInclusion, type MerkleProofStep } from "./merkle.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";
|
|
20
23
|
/** A public verification key as published in the Tools JWK Set. */
|
|
21
24
|
export type PublishedKey = {
|
|
22
25
|
kid: string;
|
|
@@ -129,3 +132,91 @@ export declare function verifyBundle(input: unknown, publicKeys: ReadonlyArray<P
|
|
|
129
132
|
* check" never reads as a verdict on the receipt.
|
|
130
133
|
*/
|
|
131
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>;
|