@entpoind/verify 0.0.0-stage → 0.1.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/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,86 @@
1
- # Temporary Holding Version
1
+ # @entpoind/verify
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ Any runtime whose WebCrypto verifies Ed25519: Node 20 or later (tested on
27
+ Node 22), Deno, Bun, Cloudflare Workers, and current browsers (Chrome and Edge
28
+ 137+, Firefox 129+, Safari 17+). Browsers expose WebCrypto only on secure
29
+ (`https:` or `localhost`) pages. Where Ed25519 is missing, verification returns
30
+ `unsupported_runtime` instead of judging the receipt; `ed25519Supported()` tells
31
+ you in advance.
32
+
33
+ ## What it verifies
34
+
35
+ - **Tools receipts**: the signed envelope every Tools call returns, of every
36
+ receipt `type` (`agent.call`, `content.attest`, `monitor.http`,
37
+ `heartbeat.attest`, …). Scheduled-watch digest receipts verify the same way.
38
+ - **VerifiableArtifact envelopes and bundles**: the signature, and, when a bundle
39
+ carries an `anchor`, that the artifact is included under the anchored Merkle
40
+ root.
41
+
42
+ The signature is Ed25519 over the **RFC 8785 (JCS)** canonical form of the
43
+ envelope minus `signature`. Public keys come from the published JWK Set at
44
+ `https://entpoind.com/.well-known/tools-keys.json`, which any origin may fetch
45
+ (`access-control-allow-origin: *`). For air-gapped checks, save that file and pass
46
+ its `keys` to `verifyReceipt`.
47
+
48
+ ## API
49
+
50
+ | Function | Purpose |
51
+ |---|---|
52
+ | `verifyReceipt(input, keys)` | Verify a receipt against keys you supply. No network. |
53
+ | `verifyReceiptOnline(input, url?)` | Fetch the published keys, then verify. |
54
+ | `fetchToolsKeys(url)` | Fetch the published JWK Set and keep its Ed25519 keys. |
55
+ | `verifyArtifact(input, keys)` | Verify a standalone artifact envelope. |
56
+ | `verifyBundle(input, keys)` | Signature, plus Merkle inclusion when anchored. |
57
+ | `verifyMerkleInclusion(leaf, proof, root)` | Fold an inclusion proof locally. |
58
+ | `canonicalJcs(value)` | The RFC 8785 canonical string. |
59
+ | `ed25519Supported()` | Whether this runtime can verify Ed25519. |
60
+ | `verifySignature(key, message, signature)` | The raw Ed25519 check, as a boolean. |
61
+
62
+ The `verify*` functions never throw on hostile input. `fetchToolsKeys` and
63
+ `verifyReceiptOnline` throw when the keys cannot be fetched, so "could not
64
+ check" is never reported as a verdict.
65
+
66
+ ## Failure reasons
67
+
68
+ | `reason` | Meaning |
69
+ |---|---|
70
+ | `malformed_envelope` | Not a complete receipt or artifact. |
71
+ | `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. |
72
+ | `bad_signature` | The signature does not match the canonical bytes: the document was altered, or is not Entpoind's. |
73
+ | `unsupported_runtime` | This runtime cannot verify Ed25519. Nothing was judged. |
74
+ | `not_in_anchored_root` | Bundles only: the signature is valid but the Merkle proof does not reach the anchored root. |
75
+
76
+ A bundle without an anchor verifies with `anchored: false`; that is a valid
77
+ result, not a failure.
78
+
79
+ ## Documentation
80
+
81
+ Receipt format, verification procedure and key rotation:
82
+ <https://entpoind.com/docs/verification>.
83
+
84
+ ## License
85
+
86
+ 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,57 @@
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 canonicalizeValue(value, path) {
20
+ if (value === null)
21
+ return null;
22
+ const t = typeof value;
23
+ if (t === "boolean" || t === "string")
24
+ return 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 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) => canonicalizeValue(v, `${path}[${i}]`));
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
+ const out = {};
46
+ for (const key of Object.keys(obj).sort()) {
47
+ const child = obj[key];
48
+ if (child === undefined)
49
+ continue;
50
+ out[key] = canonicalizeValue(child, path ? `${path}.${key}` : key);
51
+ }
52
+ return out;
53
+ }
54
+ /** The RFC 8785 canonical string for `value` — the bytes a signature covers. */
55
+ export function canonicalJcs(value) {
56
+ return JSON.stringify(canonicalizeValue(value, ""));
57
+ }
@@ -0,0 +1,131 @@
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
+ export { canonicalJcs, NonCanonicalizableError, type JsonValue } from "./canonical-json.js";
19
+ export { merkleLeafHash, verifyMerkleInclusion, type MerkleProofStep } from "./merkle.js";
20
+ /** A public verification key as published in the Tools JWK Set. */
21
+ export type PublishedKey = {
22
+ kid: string;
23
+ kty: string;
24
+ crv: string;
25
+ x: string;
26
+ alg?: string;
27
+ use?: string;
28
+ };
29
+ /** The `/.well-known/tools-keys.json` document shape. */
30
+ export type ToolsKeySet = {
31
+ keys: PublishedKey[];
32
+ };
33
+ /**
34
+ * Where the production key set is published. The same document is mirrored on
35
+ * the Tools marketing domain; either origin serves it CORS-open.
36
+ */
37
+ export declare const DEFAULT_KEYS_URL = "https://entpoind.com/.well-known/tools-keys.json";
38
+ /**
39
+ * Fetch the published key set. `keysUrl` is absolute — e.g.
40
+ * `https://entpoind.com/.well-known/tools-keys.json` or the `keysUrl` field a
41
+ * runtime embeds in its receipts.
42
+ *
43
+ * Throws on transport failure — callers that distinguish "couldn't fetch keys"
44
+ * from "bad signature" should catch and report that separately.
45
+ */
46
+ export declare function fetchToolsKeys(keysUrl: string): Promise<PublishedKey[]>;
47
+ /** Whether this runtime's WebCrypto can verify Ed25519 signatures. Cached after the first call. */
48
+ export declare function ed25519Supported(): Promise<boolean>;
49
+ /**
50
+ * Verify a base64url Ed25519 signature. Never throws on hostile input. Returns
51
+ * false both for a wrong signature and for a runtime without Ed25519; use
52
+ * `ed25519Supported()` to tell those apart, or the `verify*` functions, which
53
+ * report `unsupported_runtime`.
54
+ */
55
+ export declare function verifySignature(publicKey: {
56
+ kty: string;
57
+ crv: string;
58
+ x: string;
59
+ }, message: string, signatureB64Url: string): Promise<boolean>;
60
+ /** Why a verification failed. `unsupported_runtime` means the check could not run, not that the document is bad. */
61
+ export type FailureReason = "bad_signature" | "unknown_key" | "malformed_envelope" | "unsupported_runtime";
62
+ export type ReceiptVerification = {
63
+ ok: true;
64
+ signedAt: string;
65
+ signatureKeyId: string;
66
+ } | {
67
+ ok: false;
68
+ reason: FailureReason;
69
+ };
70
+ /** The canonical bytes a receipt's signature covers: JCS(envelope − signature). */
71
+ export declare function canonicalReceiptString(envelope: Record<string, unknown>): string;
72
+ /** Runtime shape guard for an untrusted receipt envelope. */
73
+ export declare function isReceiptEnvelope(x: unknown): x is Record<string, unknown>;
74
+ /**
75
+ * Verify a Tools receipt against the published public keys. Accepts the parsed
76
+ * envelope, its JSON text, or the `{ receipt }` wrapper the API returns. Pure
77
+ * public-key crypto — no account, no network.
78
+ */
79
+ export declare function verifyReceipt(input: unknown, publicKeys: ReadonlyArray<PublishedKey>): Promise<ReceiptVerification>;
80
+ export type ArtifactVerification = {
81
+ ok: true;
82
+ signedAt: string;
83
+ signatureKeyId: string;
84
+ kind: string;
85
+ } | {
86
+ ok: false;
87
+ reason: FailureReason;
88
+ };
89
+ /** The canonical bytes an artifact's signature covers: JCS(envelope − signature). */
90
+ export declare function canonicalArtifactString(envelope: Record<string, unknown>): string;
91
+ /** Runtime shape guard for an untrusted artifact envelope. */
92
+ export declare function isArtifactEnvelope(x: unknown): x is Record<string, unknown>;
93
+ /** Verify a standalone VerifiableArtifact against the published public keys. */
94
+ export declare function verifyArtifact(input: unknown, publicKeys: ReadonlyArray<PublishedKey>): Promise<ArtifactVerification>;
95
+ /** A self-contained artifact bundle: the signed artifact, its index id, and —
96
+ * once the batch is anchored — the Merkle proof + anchored root. `leafHash` is
97
+ * the plain SHA-256 of the leaf string, an index id; verification does not use it. */
98
+ export type ArtifactBundle = {
99
+ artifact: Record<string, unknown>;
100
+ leafHash: string;
101
+ anchor?: {
102
+ merkleRoot: string;
103
+ proof: MerkleProofStep[];
104
+ topsRef?: string;
105
+ };
106
+ };
107
+ export type BundleVerification = {
108
+ ok: true;
109
+ anchored: boolean;
110
+ signedAt: string;
111
+ kind: string;
112
+ } | {
113
+ ok: false;
114
+ anchored: boolean;
115
+ reason: FailureReason | "not_in_anchored_root";
116
+ };
117
+ /**
118
+ * Verify a bundle offline: the artifact's Ed25519 signature, then — when an
119
+ * anchor is present — that JCS(signed artifact) is included under the anchored
120
+ * Merkle root. `anchored:false` on a pre-anchor bundle is a valid result, not a
121
+ * failure.
122
+ */
123
+ export declare function verifyBundle(input: unknown, publicKeys: ReadonlyArray<PublishedKey>): Promise<BundleVerification>;
124
+ /**
125
+ * One-shot receipt verification against the published key set — fetch keys,
126
+ * resolve `signatureKeyId`, verify. `keysUrl` defaults to the production
127
+ * well-known document; pass an override for staging / self-hosted runtimes.
128
+ * Throws (like `fetchToolsKeys`) when the keys cannot be fetched, so "could not
129
+ * check" never reads as a verdict on the receipt.
130
+ */
131
+ export declare function verifyReceiptOnline(input: unknown, keysUrl?: string): Promise<ReceiptVerification>;
package/dist/index.js ADDED
@@ -0,0 +1,221 @@
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 { verifyMerkleInclusion } from "./merkle.js";
19
+ export { canonicalJcs, NonCanonicalizableError } from "./canonical-json.js";
20
+ export { merkleLeafHash, verifyMerkleInclusion } from "./merkle.js";
21
+ /**
22
+ * Where the production key set is published. The same document is mirrored on
23
+ * the Tools marketing domain; either origin serves it CORS-open.
24
+ */
25
+ export const DEFAULT_KEYS_URL = "https://entpoind.com/.well-known/tools-keys.json";
26
+ /**
27
+ * Fetch the published key set. `keysUrl` is absolute — e.g.
28
+ * `https://entpoind.com/.well-known/tools-keys.json` or the `keysUrl` field a
29
+ * runtime embeds in its receipts.
30
+ *
31
+ * Throws on transport failure — callers that distinguish "couldn't fetch keys"
32
+ * from "bad signature" should catch and report that separately.
33
+ */
34
+ export async function fetchToolsKeys(keysUrl) {
35
+ const res = await fetch(keysUrl, { headers: { accept: "application/jwk-set+json, application/json" } });
36
+ if (!res.ok)
37
+ throw new Error(`tools keys: HTTP ${res.status} from ${keysUrl}`);
38
+ const doc = (await res.json());
39
+ if (!doc || !Array.isArray(doc.keys))
40
+ throw new Error("tools keys: response is not a JWK Set");
41
+ return doc.keys.filter((k) => k && typeof k.kid === "string" && k.kty === "OKP" && k.crv === "Ed25519" && typeof k.x === "string");
42
+ }
43
+ // ─── Ed25519 ──────────────────────────────────────────────────────────
44
+ function bytesFromBase64Url(s) {
45
+ const pad = (4 - (s.length % 4)) % 4;
46
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(pad);
47
+ const bin = atob(b64);
48
+ const out = new Uint8Array(bin.length);
49
+ for (let i = 0; i < bin.length; i++)
50
+ out[i] = bin.charCodeAt(i);
51
+ return out;
52
+ }
53
+ // RFC 8032 §7.1 test 1 public key: importing it fails only where WebCrypto has no Ed25519.
54
+ const PROBE_KEY = { kty: "OKP", crv: "Ed25519", x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", ext: true };
55
+ let ed25519Probe;
56
+ /** Whether this runtime's WebCrypto can verify Ed25519 signatures. Cached after the first call. */
57
+ export function ed25519Supported() {
58
+ ed25519Probe ??= (async () => {
59
+ try {
60
+ await crypto.subtle.importKey("jwk", PROBE_KEY, { name: "Ed25519" }, false, ["verify"]);
61
+ return true;
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ })();
67
+ return ed25519Probe;
68
+ }
69
+ async function checkSignature(publicKey, message, signatureB64Url) {
70
+ let key;
71
+ try {
72
+ key = await crypto.subtle.importKey("jwk", { kty: publicKey.kty, crv: publicKey.crv, x: publicKey.x, ext: true }, { name: "Ed25519" }, false, ["verify"]);
73
+ }
74
+ catch {
75
+ // A key that will not import is a bad key, unless the runtime cannot do Ed25519 at all.
76
+ return (await ed25519Supported()) ? "invalid" : "unsupported";
77
+ }
78
+ try {
79
+ const ok = await crypto.subtle.verify({ name: "Ed25519" }, key, bytesFromBase64Url(signatureB64Url), new TextEncoder().encode(message));
80
+ return ok ? "valid" : "invalid";
81
+ }
82
+ catch {
83
+ return "invalid";
84
+ }
85
+ }
86
+ /**
87
+ * Verify a base64url Ed25519 signature. Never throws on hostile input. Returns
88
+ * false both for a wrong signature and for a runtime without Ed25519; use
89
+ * `ed25519Supported()` to tell those apart, or the `verify*` functions, which
90
+ * report `unsupported_runtime`.
91
+ */
92
+ export async function verifySignature(publicKey, message, signatureB64Url) {
93
+ return (await checkSignature(publicKey, message, signatureB64Url)) === "valid";
94
+ }
95
+ /** The canonical bytes a receipt's signature covers: JCS(envelope − signature). */
96
+ export function canonicalReceiptString(envelope) {
97
+ const rest = { ...envelope };
98
+ delete rest.signature;
99
+ return canonicalJcs(rest);
100
+ }
101
+ /** Runtime shape guard for an untrusted receipt envelope. */
102
+ export function isReceiptEnvelope(x) {
103
+ if (!x || typeof x !== "object")
104
+ return false;
105
+ const e = x;
106
+ return (e.version === "1" &&
107
+ typeof e.receiptId === "string" &&
108
+ typeof e.issuedAt === "string" &&
109
+ typeof e.type === "string" &&
110
+ typeof e.signatureKeyId === "string" &&
111
+ typeof e.signature === "string" &&
112
+ !!e.body &&
113
+ typeof e.body === "object" &&
114
+ e.body.type === e.type);
115
+ }
116
+ /**
117
+ * Verify a Tools receipt against the published public keys. Accepts the parsed
118
+ * envelope, its JSON text, or the `{ receipt }` wrapper the API returns. Pure
119
+ * public-key crypto — no account, no network.
120
+ */
121
+ export async function verifyReceipt(input, publicKeys) {
122
+ let envelope = typeof input === "string" ? safeParse(input) : input;
123
+ if (!isReceiptEnvelope(envelope) && envelope && typeof envelope === "object") {
124
+ envelope = envelope.receipt;
125
+ }
126
+ if (!isReceiptEnvelope(envelope))
127
+ return { ok: false, reason: "malformed_envelope" };
128
+ const key = findKey(publicKeys, envelope.signatureKeyId);
129
+ if (!key)
130
+ return { ok: false, reason: "unknown_key" };
131
+ const check = await checkSignature(key, canonicalReceiptString(envelope), envelope.signature);
132
+ if (check === "unsupported")
133
+ return { ok: false, reason: "unsupported_runtime" };
134
+ if (check === "invalid")
135
+ return { ok: false, reason: "bad_signature" };
136
+ return { ok: true, signedAt: envelope.issuedAt, signatureKeyId: envelope.signatureKeyId };
137
+ }
138
+ /** The canonical bytes an artifact's signature covers: JCS(envelope − signature). */
139
+ export function canonicalArtifactString(envelope) {
140
+ const rest = { ...envelope };
141
+ delete rest.signature;
142
+ return canonicalJcs(rest);
143
+ }
144
+ /** Runtime shape guard for an untrusted artifact envelope. */
145
+ export function isArtifactEnvelope(x) {
146
+ if (!x || typeof x !== "object")
147
+ return false;
148
+ const e = x;
149
+ return (e.version === "1" &&
150
+ typeof e.artifactId === "string" &&
151
+ typeof e.issuedAt === "string" &&
152
+ typeof e.kind === "string" &&
153
+ typeof e.signatureKeyId === "string" &&
154
+ typeof e.signature === "string" &&
155
+ "subject" in e &&
156
+ "claims" in e);
157
+ }
158
+ /** Verify a standalone VerifiableArtifact against the published public keys. */
159
+ export async function verifyArtifact(input, publicKeys) {
160
+ const envelope = typeof input === "string" ? safeParse(input) : input;
161
+ if (!isArtifactEnvelope(envelope))
162
+ return { ok: false, reason: "malformed_envelope" };
163
+ const key = findKey(publicKeys, envelope.signatureKeyId);
164
+ if (!key)
165
+ return { ok: false, reason: "unknown_key" };
166
+ const check = await checkSignature(key, canonicalArtifactString(envelope), envelope.signature);
167
+ if (check === "unsupported")
168
+ return { ok: false, reason: "unsupported_runtime" };
169
+ if (check === "invalid")
170
+ return { ok: false, reason: "bad_signature" };
171
+ return {
172
+ ok: true,
173
+ signedAt: envelope.issuedAt,
174
+ signatureKeyId: envelope.signatureKeyId,
175
+ kind: envelope.kind,
176
+ };
177
+ }
178
+ /**
179
+ * Verify a bundle offline: the artifact's Ed25519 signature, then — when an
180
+ * anchor is present — that JCS(signed artifact) is included under the anchored
181
+ * Merkle root. `anchored:false` on a pre-anchor bundle is a valid result, not a
182
+ * failure.
183
+ */
184
+ export async function verifyBundle(input, publicKeys) {
185
+ const bundle = (typeof input === "string" ? safeParse(input) : input);
186
+ if (!bundle || typeof bundle !== "object" || !isArtifactEnvelope(bundle.artifact)) {
187
+ return { ok: false, anchored: false, reason: "malformed_envelope" };
188
+ }
189
+ const sig = await verifyArtifact(bundle.artifact, publicKeys);
190
+ if (!sig.ok)
191
+ return { ok: false, anchored: false, reason: sig.reason };
192
+ if (!bundle.anchor)
193
+ return { ok: true, anchored: false, signedAt: sig.signedAt, kind: sig.kind };
194
+ const leaf = canonicalJcs(bundle.artifact);
195
+ const included = await verifyMerkleInclusion(leaf, bundle.anchor.proof ?? [], bundle.anchor.merkleRoot);
196
+ if (!included)
197
+ return { ok: false, anchored: true, reason: "not_in_anchored_root" };
198
+ return { ok: true, anchored: true, signedAt: sig.signedAt, kind: sig.kind };
199
+ }
200
+ /**
201
+ * One-shot receipt verification against the published key set — fetch keys,
202
+ * resolve `signatureKeyId`, verify. `keysUrl` defaults to the production
203
+ * well-known document; pass an override for staging / self-hosted runtimes.
204
+ * Throws (like `fetchToolsKeys`) when the keys cannot be fetched, so "could not
205
+ * check" never reads as a verdict on the receipt.
206
+ */
207
+ export async function verifyReceiptOnline(input, keysUrl = DEFAULT_KEYS_URL) {
208
+ return verifyReceipt(input, await fetchToolsKeys(keysUrl));
209
+ }
210
+ // Only a well-formed Ed25519 public key is ever used, whatever the caller's key list holds.
211
+ function findKey(keys, kid) {
212
+ return keys.find((k) => !!k && k.kid === kid && k.kty === "OKP" && k.crv === "Ed25519" && typeof k.x === "string");
213
+ }
214
+ function safeParse(text) {
215
+ try {
216
+ return JSON.parse(text);
217
+ }
218
+ catch {
219
+ return null;
220
+ }
221
+ }
@@ -0,0 +1,18 @@
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 merkleLeafHash(leaf: string): Promise<string>;
10
+ export type MerkleProofStep = {
11
+ sibling: string;
12
+ position: "left" | "right";
13
+ };
14
+ /**
15
+ * Verify that `leaf` (the original leaf STRING — the canonical signed envelope)
16
+ * folds along `proof` into `root`. Never throws on a malformed proof.
17
+ */
18
+ export declare function verifyMerkleInclusion(leaf: string, proof: ReadonlyArray<MerkleProofStep>, root: string): Promise<boolean>;
package/dist/merkle.js ADDED
@@ -0,0 +1,62 @@
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
+ 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" || typeof root !== "string" || !Array.isArray(proof))
44
+ return false;
45
+ const want = root.toLowerCase();
46
+ if (!SHA256_HEX.test(want))
47
+ return false;
48
+ let h = await merkleLeafHash(leaf);
49
+ for (const step of proof) {
50
+ if (!step || (step.position !== "left" && step.position !== "right"))
51
+ return false;
52
+ if (typeof step.sibling !== "string" || !SHA256_HEX.test(step.sibling.toLowerCase()))
53
+ return false;
54
+ const sibling = step.sibling.toLowerCase();
55
+ h = step.position === "left" ? await merkleNodeHash(sibling, h) : await merkleNodeHash(h, sibling);
56
+ }
57
+ return h === want;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@entpoind/verify",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Offline verification of Entpoind Tools receipts and VerifiableArtifact bundles — Ed25519 over RFC 8785 canonical JSON, plus Merkle inclusion proofs. Zero dependencies.",
5
+ "license": "MIT",
6
+ "author": "Avonao, Inc.",
7
+ "homepage": "https://entpoind.com/docs/sdks",
8
+ "type": "module",
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": ["dist", "README.md", "LICENSE"],
19
+ "sideEffects": false,
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "scripts": {
24
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
25
+ "prepublishOnly": "npm run build"
26
+ },
27
+ "keywords": ["entpoind", "tools", "receipt", "ed25519", "verification", "jcs", "rfc8785", "merkle", "webcrypto"],
28
+ "devDependencies": {
29
+ "typescript": "^5.6.3",
30
+ "vitest": "^4.1.11"
31
+ }
32
+ }