@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 +21 -0
- package/README.md +85 -2
- package/dist/canonical-json.d.ts +19 -0
- package/dist/canonical-json.js +57 -0
- package/dist/index.d.ts +131 -0
- package/dist/index.js +221 -0
- package/dist/merkle.d.ts +18 -0
- package/dist/merkle.js +62 -0
- package/package.json +30 -4
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
|
-
#
|
|
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
|
+
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/merkle.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|