@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.
- package/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +211 -2
- package/dist/canonical-json.d.ts +19 -0
- package/dist/canonical-json.js +58 -0
- package/dist/ed25519ph.d.ts +25 -0
- package/dist/ed25519ph.js +171 -0
- package/dist/index.d.ts +222 -0
- package/dist/index.js +350 -0
- package/dist/merkle.d.ts +24 -0
- package/dist/merkle.js +76 -0
- package/dist/witness.d.ts +175 -0
- package/dist/witness.js +523 -0
- package/package.json +30 -4
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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Avonao, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,212 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @entpoind/verify
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Offline verification of **Entpoind Tools** receipts and **VerifiableArtifact**
|
|
4
|
+
bundles. Zero dependencies; it uses only the runtime's own WebCrypto.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install @entpoind/verify
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { verifyReceiptOnline } from "@entpoind/verify";
|
|
12
|
+
|
|
13
|
+
const result = await verifyReceiptOnline(receiptJson);
|
|
14
|
+
if (result.ok) {
|
|
15
|
+
console.log("signed at", result.signedAt, "by key", result.signatureKeyId);
|
|
16
|
+
} else {
|
|
17
|
+
console.log("not verified:", result.reason);
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`receiptJson` can be the receipt object, its JSON text, or the `{ "receipt": … }`
|
|
22
|
+
wrapper the API returns.
|
|
23
|
+
|
|
24
|
+
## Where it runs
|
|
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
|
+
|
|
28
|
+
Any runtime whose WebCrypto verifies Ed25519: Node 20 or later (tested on
|
|
29
|
+
Node 22), Deno, Bun, Cloudflare Workers, and current browsers (Chrome and Edge
|
|
30
|
+
137+, Firefox 129+, Safari 17+). Browsers expose WebCrypto only on secure
|
|
31
|
+
(`https:` or `localhost`) pages. Where Ed25519 is missing, verification returns
|
|
32
|
+
`unsupported_runtime` instead of judging the receipt; `ed25519Supported()` tells
|
|
33
|
+
you in advance.
|
|
34
|
+
|
|
35
|
+
## What it verifies
|
|
36
|
+
|
|
37
|
+
- **Tools receipts**: the signed envelope every Tools call returns, of every
|
|
38
|
+
receipt `type` (`agent.call`, `content.attest`, `monitor.http`,
|
|
39
|
+
`heartbeat.attest`, …). Scheduled-watch digest receipts verify the same way.
|
|
40
|
+
- **VerifiableArtifact envelopes and bundles**: the signature, and, when a bundle
|
|
41
|
+
carries an `anchor`, that the artifact is included under the anchored Merkle
|
|
42
|
+
root.
|
|
43
|
+
|
|
44
|
+
The signature is Ed25519 over the **RFC 8785 (JCS)** canonical form of the
|
|
45
|
+
envelope minus `signature`. Public keys come from the published JWK Set at
|
|
46
|
+
`https://entpoind.com/.well-known/tools-keys.json`, which any origin may fetch
|
|
47
|
+
(`access-control-allow-origin: *`). For air-gapped checks, save that file and pass
|
|
48
|
+
its `keys` to `verifyReceipt`.
|
|
49
|
+
|
|
50
|
+
## API
|
|
51
|
+
|
|
52
|
+
| Function | Purpose |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `verifyReceipt(input, keys)` | Verify a receipt against keys you supply. No network. |
|
|
55
|
+
| `verifyReceiptOnline(input, url?)` | Fetch the published keys, then verify. |
|
|
56
|
+
| `fetchToolsKeys(url)` | Fetch the published JWK Set and keep its Ed25519 keys. |
|
|
57
|
+
| `verifyArtifact(input, keys)` | Verify a standalone artifact envelope. |
|
|
58
|
+
| `verifyBundle(input, keys)` | Signature, plus Merkle inclusion when anchored. |
|
|
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. |
|
|
62
|
+
| `canonicalJcs(value)` | The RFC 8785 canonical string. |
|
|
63
|
+
| `ed25519Supported()` | Whether this runtime can verify Ed25519. |
|
|
64
|
+
| `verifySignature(key, message, signature)` | The raw Ed25519 check, as a boolean. |
|
|
65
|
+
|
|
66
|
+
The `verify*` functions never throw on hostile input. `fetchToolsKeys` and
|
|
67
|
+
`verifyReceiptOnline` throw when the keys cannot be fetched, so "could not
|
|
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.
|
|
71
|
+
|
|
72
|
+
## Failure reasons
|
|
73
|
+
|
|
74
|
+
| `reason` | Meaning |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `malformed_envelope` | Not a complete receipt or artifact. |
|
|
77
|
+
| `unknown_key` | `signatureKeyId` matches no Ed25519 key in the set. Refresh the keys; if it is still missing, the document was not signed by Entpoind. |
|
|
78
|
+
| `bad_signature` | The signature does not match the canonical bytes: the document was altered, or is not Entpoind's. |
|
|
79
|
+
| `unsupported_runtime` | This runtime cannot verify Ed25519. Nothing was judged. |
|
|
80
|
+
| `not_in_anchored_root` | Bundles only: the signature is valid but the Merkle proof does not reach the anchored root. |
|
|
81
|
+
|
|
82
|
+
A bundle without an anchor verifies with `anchored: false`; that is a valid
|
|
83
|
+
result, not a failure.
|
|
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
|
+
|
|
205
|
+
## Documentation
|
|
206
|
+
|
|
207
|
+
Receipt format, verification procedure and key rotation:
|
|
208
|
+
<https://entpoind.com/docs/verification>.
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
MIT © Avonao, Inc.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @entpoind/verify — RFC 8785 (JCS) canonicalization.
|
|
3
|
+
*
|
|
4
|
+
* Byte-identical port of the signer's canonicalizer
|
|
5
|
+
* (runtime/shared/canonical-json.ts). A receipt verifies only when the verifier
|
|
6
|
+
* re-derives the EXACT bytes the signature covers, so this file must never
|
|
7
|
+
* drift from that source: recursive key sort by UTF-16 code unit, ECMAScript
|
|
8
|
+
* Number→String formatting, JSON.stringify escaping, loud rejection of values
|
|
9
|
+
* two implementations couldn't serialize identically.
|
|
10
|
+
*/
|
|
11
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | {
|
|
12
|
+
[key: string]: JsonValue;
|
|
13
|
+
};
|
|
14
|
+
export declare class NonCanonicalizableError extends Error {
|
|
15
|
+
readonly path: string;
|
|
16
|
+
constructor(message: string, path: string);
|
|
17
|
+
}
|
|
18
|
+
/** The RFC 8785 canonical string for `value` — the bytes a signature covers. */
|
|
19
|
+
export declare function canonicalJcs(value: unknown): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @entpoind/verify — RFC 8785 (JCS) canonicalization.
|
|
3
|
+
*
|
|
4
|
+
* Byte-identical port of the signer's canonicalizer
|
|
5
|
+
* (runtime/shared/canonical-json.ts). A receipt verifies only when the verifier
|
|
6
|
+
* re-derives the EXACT bytes the signature covers, so this file must never
|
|
7
|
+
* drift from that source: recursive key sort by UTF-16 code unit, ECMAScript
|
|
8
|
+
* Number→String formatting, JSON.stringify escaping, loud rejection of values
|
|
9
|
+
* two implementations couldn't serialize identically.
|
|
10
|
+
*/
|
|
11
|
+
export class NonCanonicalizableError extends Error {
|
|
12
|
+
path;
|
|
13
|
+
constructor(message, path) {
|
|
14
|
+
super(`canonical-json: ${message} (at ${path || "<root>"})`);
|
|
15
|
+
this.path = path;
|
|
16
|
+
this.name = "NonCanonicalizableError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function serializeValue(value, path) {
|
|
20
|
+
if (value === null)
|
|
21
|
+
return "null";
|
|
22
|
+
const t = typeof value;
|
|
23
|
+
if (t === "boolean" || t === "string")
|
|
24
|
+
return JSON.stringify(value);
|
|
25
|
+
if (t === "number") {
|
|
26
|
+
if (!Number.isFinite(value)) {
|
|
27
|
+
throw new NonCanonicalizableError(`non-finite number (${String(value)}) is not valid JSON`, path);
|
|
28
|
+
}
|
|
29
|
+
return JSON.stringify(value);
|
|
30
|
+
}
|
|
31
|
+
if (t === "bigint") {
|
|
32
|
+
throw new NonCanonicalizableError("bigint cannot be canonicalized (no JSON representation)", path);
|
|
33
|
+
}
|
|
34
|
+
if (t === "undefined" || t === "function" || t === "symbol") {
|
|
35
|
+
throw new NonCanonicalizableError(`${t} cannot be canonicalized`, path);
|
|
36
|
+
}
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
return `[${value.map((v, i) => serializeValue(v, `${path}[${i}]`)).join(",")}]`;
|
|
39
|
+
}
|
|
40
|
+
const proto = Object.getPrototypeOf(value);
|
|
41
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
42
|
+
throw new NonCanonicalizableError(`non-plain object (${value.constructor?.name ?? "unknown"}) cannot be canonicalized; pass plain JSON`, path);
|
|
43
|
+
}
|
|
44
|
+
const obj = value;
|
|
45
|
+
// Written as text: an object would reorder integer-like keys and swallow a `__proto__` key.
|
|
46
|
+
const members = [];
|
|
47
|
+
for (const key of Object.keys(obj).sort()) {
|
|
48
|
+
const child = obj[key];
|
|
49
|
+
if (child === undefined)
|
|
50
|
+
continue;
|
|
51
|
+
members.push(`${JSON.stringify(key)}:${serializeValue(child, path ? `${path}.${key}` : key)}`);
|
|
52
|
+
}
|
|
53
|
+
return `{${members.join(",")}}`;
|
|
54
|
+
}
|
|
55
|
+
/** The RFC 8785 canonical string for `value` — the bytes a signature covers. */
|
|
56
|
+
export function canonicalJcs(value) {
|
|
57
|
+
return serializeValue(value, "");
|
|
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
|
+
}
|