@ar.io/proof 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 +54 -0
- package/dist/crypto.d.ts +6 -0
- package/dist/crypto.js +59 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +15 -0
- package/dist/merkle.d.ts +6 -0
- package/dist/merkle.js +114 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.js +6 -0
- package/dist/verifier.d.ts +8 -0
- package/dist/verifier.js +146 -0
- package/package.json +49 -0
- package/src/crypto.ts +69 -0
- package/src/index.ts +24 -0
- package/src/merkle.ts +125 -0
- package/src/types.ts +50 -0
- package/src/verifier.ts +160 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ar.io
|
|
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
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @ar.io/proof
|
|
2
|
+
|
|
3
|
+
> TypeScript kernel of the ar.io verification stack — verify a Verifiable Event
|
|
4
|
+
> Envelope with no ar.io service in the trust path.
|
|
5
|
+
|
|
6
|
+
The verification primitives for the envelope family specified in
|
|
7
|
+
`ar-io-agent/docs/envelope-spec.md` (ario.agent/v1 profile per `artifact.md`):
|
|
8
|
+
|
|
9
|
+
- **RFC 8785 (JCS)** canonicalization (`jcs`)
|
|
10
|
+
- **SHA-256** (`sha256Hex`) via WebCrypto
|
|
11
|
+
- **Ed25519** signature verification (`ed25519Verify`, via `@noble/ed25519` with
|
|
12
|
+
the SHA-512 hook wired to WebCrypto)
|
|
13
|
+
- **`verifyEnvelope(env, expectedContentHash?)`** — the three load-bearing checks
|
|
14
|
+
(spec-version registry, `payload_hash` recompute, Ed25519 over the signed
|
|
15
|
+
scope) plus the optional content-hash bind
|
|
16
|
+
- **`contentHashes(env)`** — which payload hash(es) an envelope commits to, by
|
|
17
|
+
event type (the reverse-provenance join keys)
|
|
18
|
+
- **RFC 9162 binary Merkle tree** (`leafHash`, `nodeHash`, `merkleRoot`,
|
|
19
|
+
`auditPath`, `verifyInclusion`, `EMPTY_TREE_ROOT_HEX`) — §2.1 domain
|
|
20
|
+
separation, largest-power-of-two split (not the Bitcoin duplicate-leaf
|
|
21
|
+
variant), conformance-gated against the corpus's 7 Merkle vectors; parity
|
|
22
|
+
with the Python kernel's `ario_proof.merkle`
|
|
23
|
+
|
|
24
|
+
This is the TypeScript sibling of the Python [`ar-io-proof`](https://github.com/ar-io/ar-io-proof)
|
|
25
|
+
kernel and the Go reference (`ar-io-agent/pkg/proof`). All three are independent
|
|
26
|
+
implementations of the same algorithm, each conformance-gated **byte-for-byte**
|
|
27
|
+
against the shared `test-vectors-v1.0` corpus — that mutual gate is the
|
|
28
|
+
product's core claim: verification needs no ar.io code in the trust path.
|
|
29
|
+
|
|
30
|
+
## Signed scope
|
|
31
|
+
|
|
32
|
+
The primary signature covers `JCS(envelope minus signature minus co_signatures)`
|
|
33
|
+
(envelope-spec §2, §7.1). The `co_signatures` carve-out lets a countersignature
|
|
34
|
+
be added without invalidating the primary signature; the field is reserved and
|
|
35
|
+
default-absent, and its absence is never a failure.
|
|
36
|
+
|
|
37
|
+
## Spec-version acceptance
|
|
38
|
+
|
|
39
|
+
Fail-closed registry (`specVersionSupported`): exactly the accepted majors —
|
|
40
|
+
`ario.agent/v1` and additive minors within it (`ario.agent/v1.<minor>`),
|
|
41
|
+
matching the Go reference's semantics. Unknown majors and other profiles are
|
|
42
|
+
rejected. The mlflow profile (`ario.mlflow/v1`) is a deliberate later one-entry
|
|
43
|
+
addition, **not** implemented here — mlflow-dialect behaviors (e.g. the
|
|
44
|
+
underscore-key strip) must not leak into the agent profile.
|
|
45
|
+
|
|
46
|
+
## Workspace status
|
|
47
|
+
|
|
48
|
+
Lives as an npm workspace inside `ar-io-proof-checker` (v1.2 wave decision —
|
|
49
|
+
no separate repo yet); the checker consumes it as its verifier. **Not yet
|
|
50
|
+
published to npm.** Before any publish: confirm the real npm scope (`@ar.io/`
|
|
51
|
+
vs `@ar-io/`), flip `exports` to the built `dist/` output (`npm run build`
|
|
52
|
+
emits ESM + type declarations), and get the coordinator's green light.
|
|
53
|
+
|
|
54
|
+
MIT — verification must be open. See `LICENSE`.
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function bytesToHex(bytes: Uint8Array): string;
|
|
2
|
+
export declare function hexToBytes(hex: string): Uint8Array;
|
|
3
|
+
export declare function utf8(s: string): Uint8Array;
|
|
4
|
+
export declare function sha256Bytes(bytes: Uint8Array): Promise<Uint8Array>;
|
|
5
|
+
export declare function sha256Hex(bytes: Uint8Array): Promise<string>;
|
|
6
|
+
export declare function ed25519Verify(signatureHex: string, message: Uint8Array, publicKeyHex: string): Promise<boolean>;
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Low-level cryptographic primitives for the verifier. Deliberately thin: the
|
|
2
|
+
// only third-party crypto dependency is @noble/ed25519 (single-file, audited,
|
|
3
|
+
// zero-dependency). SHA-256 and SHA-512 come from WebCrypto — no extra hashing
|
|
4
|
+
// library to trust.
|
|
5
|
+
import * as ed from "@noble/ed25519";
|
|
6
|
+
// @noble/ed25519 needs SHA-512 to verify. Wire it to WebCrypto rather than
|
|
7
|
+
// pulling in @noble/hashes. globalThis.crypto.subtle exists in every modern
|
|
8
|
+
// browser and in Node >= 19, so the same code path runs in the app and in tests.
|
|
9
|
+
ed.etc.sha512Async = async (...msgs) => new Uint8Array(await crypto.subtle.digest("SHA-512", asBufferSource(ed.etc.concatBytes(...msgs))));
|
|
10
|
+
// WebCrypto's digest() wants a BufferSource backed by a (non-shared) ArrayBuffer.
|
|
11
|
+
// Our byte arrays always are; this keeps TS 5.7's stricter Uint8Array<ArrayBufferLike>
|
|
12
|
+
// typing satisfied without a runtime copy.
|
|
13
|
+
function asBufferSource(bytes) {
|
|
14
|
+
return bytes;
|
|
15
|
+
}
|
|
16
|
+
export function bytesToHex(bytes) {
|
|
17
|
+
let out = "";
|
|
18
|
+
for (const b of bytes)
|
|
19
|
+
out += b.toString(16).padStart(2, "0");
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
export function hexToBytes(hex) {
|
|
23
|
+
if (hex.length % 2 !== 0)
|
|
24
|
+
throw new Error("hexToBytes: odd-length string");
|
|
25
|
+
// Full validation: parseInt() partially parses ("1g" -> 1), which would let
|
|
26
|
+
// malformed hex through. A strict charset check closes that.
|
|
27
|
+
if (!/^[0-9a-fA-F]*$/.test(hex))
|
|
28
|
+
throw new Error("hexToBytes: non-hex characters");
|
|
29
|
+
const out = new Uint8Array(hex.length / 2);
|
|
30
|
+
for (let i = 0; i < out.length; i++) {
|
|
31
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
export function utf8(s) {
|
|
36
|
+
return new TextEncoder().encode(s);
|
|
37
|
+
}
|
|
38
|
+
export async function sha256Bytes(bytes) {
|
|
39
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", asBufferSource(bytes)));
|
|
40
|
+
}
|
|
41
|
+
export async function sha256Hex(bytes) {
|
|
42
|
+
return bytesToHex(await sha256Bytes(bytes));
|
|
43
|
+
}
|
|
44
|
+
// Verify an Ed25519 signature. Inputs are hex (signature, public key) as they
|
|
45
|
+
// appear in the envelope; message is raw bytes. Never throws — a malformed
|
|
46
|
+
// signature or key is "not verified," not an exception, so a hostile envelope
|
|
47
|
+
// can't crash the checker.
|
|
48
|
+
export async function ed25519Verify(signatureHex, message, publicKeyHex) {
|
|
49
|
+
try {
|
|
50
|
+
// zip215:false → strict RFC-8032 verification, matching the Go agent's
|
|
51
|
+
// crypto/ed25519 exactly (noble defaults to the more lenient zip215:true).
|
|
52
|
+
return await ed.verifyAsync(hexToBytes(signatureHex), message, hexToBytes(publicKeyHex), {
|
|
53
|
+
zip215: false,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { contentHashes, jcs, specVersionSupported, verifyEnvelope } from "./verifier";
|
|
2
|
+
export { bytesToHex, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8 } from "./crypto";
|
|
3
|
+
export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyInclusion, } from "./merkle";
|
|
4
|
+
export type { ContentRole, Envelope, Subject, VerificationResult } from "./types";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// @ar.io/proof — TypeScript kernel of the ar.io verification stack.
|
|
2
|
+
//
|
|
3
|
+
// The verification primitives for the Verifiable Event Envelope family
|
|
4
|
+
// (ar-io-agent docs/envelope-spec.md), ario.agent/v1 profile: RFC 8785 (JCS)
|
|
5
|
+
// canonicalization, SHA-256, Ed25519 envelope verification, and the
|
|
6
|
+
// content-hash extraction used for reverse provenance lookup. Sibling of the
|
|
7
|
+
// Python `ar-io-proof` kernel and the Go `pkg/proof` reference; conformance
|
|
8
|
+
// is byte-for-byte against the shared `test-vectors-v1.0` corpus.
|
|
9
|
+
//
|
|
10
|
+
// Scope boundary (envelope-spec §10 #13): this package verifies a SINGLE
|
|
11
|
+
// envelope. Chain walking (previous_hash), checkpoint reconciliation, and
|
|
12
|
+
// gateway/transport concerns are consumer-layer logic composed above it.
|
|
13
|
+
export { contentHashes, jcs, specVersionSupported, verifyEnvelope } from "./verifier";
|
|
14
|
+
export { bytesToHex, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8 } from "./crypto";
|
|
15
|
+
export { EMPTY_TREE_ROOT_HEX, auditPath, leafHash, merkleRoot, nodeHash, verifyInclusion, } from "./merkle";
|
package/dist/merkle.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const EMPTY_TREE_ROOT_HEX = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
2
|
+
export declare function leafHash(leafBytes: Uint8Array): Promise<Uint8Array>;
|
|
3
|
+
export declare function nodeHash(left: Uint8Array, right: Uint8Array): Promise<Uint8Array>;
|
|
4
|
+
export declare function merkleRoot(leafHashes: Uint8Array[]): Promise<Uint8Array>;
|
|
5
|
+
export declare function auditPath(m: number, leafHashes: Uint8Array[]): Promise<Uint8Array[]>;
|
|
6
|
+
export declare function verifyInclusion(leaf: Uint8Array, leafIndex: number, totalLeaves: number, auditPath: Uint8Array[], expectedRoot: Uint8Array): Promise<boolean>;
|
package/dist/merkle.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// RFC 9162 binary Merkle tree — build, audit paths, inclusion verification.
|
|
2
|
+
//
|
|
3
|
+
// A faithful port of the Go reference (ar-io-agent pkg/merkle), in lockstep
|
|
4
|
+
// with the Python kernel's ario_proof.merkle. All hashing is SHA-256 with
|
|
5
|
+
// RFC 9162 §2.1 domain separation: leaves prefixed 0x00, interior nodes 0x01.
|
|
6
|
+
// A tree of n leaves splits into a left subtree of k leaves (the largest
|
|
7
|
+
// power of two < n) and a right subtree of n−k — NOT the Bitcoin
|
|
8
|
+
// duplicate-last-leaf variant, which produces different roots for
|
|
9
|
+
// non-power-of-two leaf counts.
|
|
10
|
+
//
|
|
11
|
+
// The empty tree (zero leaves) hashes to SHA-256("") — EMPTY_TREE_ROOT_HEX.
|
|
12
|
+
// Conformance is byte-for-byte against the 7 merkle-tree-* vectors of
|
|
13
|
+
// test-vectors-v1.0 (test/conformance.test.ts).
|
|
14
|
+
import { sha256Bytes } from "./crypto";
|
|
15
|
+
export const EMPTY_TREE_ROOT_HEX = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
16
|
+
// SHA-256(0x00 || leafBytes) per RFC 9162 §2.1.
|
|
17
|
+
export async function leafHash(leafBytes) {
|
|
18
|
+
return sha256Bytes(prefixed(0x00, leafBytes));
|
|
19
|
+
}
|
|
20
|
+
// SHA-256(0x01 || left || right) per RFC 9162 §2.1.
|
|
21
|
+
export async function nodeHash(left, right) {
|
|
22
|
+
const buf = new Uint8Array(1 + left.length + right.length);
|
|
23
|
+
buf[0] = 0x01;
|
|
24
|
+
buf.set(left, 1);
|
|
25
|
+
buf.set(right, 1 + left.length);
|
|
26
|
+
return sha256Bytes(buf);
|
|
27
|
+
}
|
|
28
|
+
// The RFC 9162 Merkle Tree Hash of already-hashed leaves. Callers pass leaf
|
|
29
|
+
// hashes — the output of leafHash on each leaf's canonical bytes. Zero leaves
|
|
30
|
+
// yields SHA-256("").
|
|
31
|
+
export async function merkleRoot(leafHashes) {
|
|
32
|
+
const n = leafHashes.length;
|
|
33
|
+
if (n === 0)
|
|
34
|
+
return sha256Bytes(new Uint8Array(0));
|
|
35
|
+
if (n === 1)
|
|
36
|
+
return leafHashes[0];
|
|
37
|
+
const k = largestPow2LessThan(n);
|
|
38
|
+
return nodeHash(await merkleRoot(leafHashes.slice(0, k)), await merkleRoot(leafHashes.slice(k)));
|
|
39
|
+
}
|
|
40
|
+
// The inclusion proof for the leaf at index m, per RFC 9162 §2.1.3: sibling
|
|
41
|
+
// hashes bottom-up; empty for a single-leaf tree (the leaf hash itself is the
|
|
42
|
+
// root). Throws RangeError when m is out of range.
|
|
43
|
+
export async function auditPath(m, leafHashes) {
|
|
44
|
+
if (!Number.isInteger(m) || m < 0 || m >= leafHashes.length) {
|
|
45
|
+
throw new RangeError("merkle: leaf index out of range");
|
|
46
|
+
}
|
|
47
|
+
return path(m, leafHashes);
|
|
48
|
+
}
|
|
49
|
+
async function path(m, leafHashes) {
|
|
50
|
+
const n = leafHashes.length;
|
|
51
|
+
if (n === 1)
|
|
52
|
+
return [];
|
|
53
|
+
const k = largestPow2LessThan(n);
|
|
54
|
+
if (m < k) {
|
|
55
|
+
return [...(await path(m, leafHashes.slice(0, k))), await merkleRoot(leafHashes.slice(k))];
|
|
56
|
+
}
|
|
57
|
+
return [...(await path(m - k, leafHashes.slice(k))), await merkleRoot(leafHashes.slice(0, k))];
|
|
58
|
+
}
|
|
59
|
+
// Verify an RFC 9162 §2.1.3 inclusion proof. `leaf` is the leaf hash
|
|
60
|
+
// (leafHash of the canonical leaf bytes); `path` is the audit path bottom-up;
|
|
61
|
+
// `expectedRoot` is the merkleRoot committed by the checkpoint envelope.
|
|
62
|
+
// True iff the path reconstructs the expected root. Never throws.
|
|
63
|
+
export async function verifyInclusion(leaf, leafIndex, totalLeaves, auditPath, expectedRoot) {
|
|
64
|
+
if (!Number.isInteger(leafIndex) || leafIndex < 0 || totalLeaves <= 0 || leafIndex >= totalLeaves) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (totalLeaves === 1)
|
|
68
|
+
return auditPath.length === 0 && bytesEqual(leaf, expectedRoot);
|
|
69
|
+
let fn = leafIndex;
|
|
70
|
+
let sn = totalLeaves - 1;
|
|
71
|
+
let r = leaf;
|
|
72
|
+
for (const p of auditPath) {
|
|
73
|
+
if (sn === 0)
|
|
74
|
+
return false; // path longer than the tree depth — malformed
|
|
75
|
+
if ((fn & 1) === 1 || fn === sn) {
|
|
76
|
+
r = await nodeHash(p, r);
|
|
77
|
+
if ((fn & 1) === 0) {
|
|
78
|
+
while ((fn & 1) === 0 && fn !== 0) {
|
|
79
|
+
fn >>= 1;
|
|
80
|
+
sn >>= 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
r = await nodeHash(r, p);
|
|
86
|
+
}
|
|
87
|
+
fn >>= 1;
|
|
88
|
+
sn >>= 1;
|
|
89
|
+
}
|
|
90
|
+
return sn === 0 && bytesEqual(r, expectedRoot);
|
|
91
|
+
}
|
|
92
|
+
function prefixed(prefix, bytes) {
|
|
93
|
+
const buf = new Uint8Array(1 + bytes.length);
|
|
94
|
+
buf[0] = prefix;
|
|
95
|
+
buf.set(bytes, 1);
|
|
96
|
+
return buf;
|
|
97
|
+
}
|
|
98
|
+
// Largest k = 2**a with k < n (0 for n < 2).
|
|
99
|
+
function largestPow2LessThan(n) {
|
|
100
|
+
if (n < 2)
|
|
101
|
+
return 0;
|
|
102
|
+
let k = 1;
|
|
103
|
+
while (k * 2 < n)
|
|
104
|
+
k *= 2;
|
|
105
|
+
return k;
|
|
106
|
+
}
|
|
107
|
+
function bytesEqual(a, b) {
|
|
108
|
+
if (a.length !== b.length)
|
|
109
|
+
return false;
|
|
110
|
+
let diff = 0;
|
|
111
|
+
for (let i = 0; i < a.length; i++)
|
|
112
|
+
diff |= a[i] ^ b[i];
|
|
113
|
+
return diff === 0;
|
|
114
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface Subject {
|
|
2
|
+
type: string;
|
|
3
|
+
tenant_id: string;
|
|
4
|
+
agent_id: string;
|
|
5
|
+
}
|
|
6
|
+
export interface Envelope {
|
|
7
|
+
spec_version: string;
|
|
8
|
+
event_id: string;
|
|
9
|
+
event_type: string;
|
|
10
|
+
subject: Subject;
|
|
11
|
+
payload_hash: string;
|
|
12
|
+
payload: Record<string, unknown>;
|
|
13
|
+
previous_hash: string;
|
|
14
|
+
signed_at: string;
|
|
15
|
+
public_key: string;
|
|
16
|
+
signature: string;
|
|
17
|
+
co_signatures?: unknown[];
|
|
18
|
+
}
|
|
19
|
+
export type ContentRole = "asset" | "baseline" | "observed";
|
|
20
|
+
export interface VerificationResult {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
specVersionOk: boolean;
|
|
23
|
+
payloadHashOk: boolean;
|
|
24
|
+
signatureOk: boolean;
|
|
25
|
+
contentHashOk: boolean | null;
|
|
26
|
+
contentRole: ContentRole | null;
|
|
27
|
+
errors: string[];
|
|
28
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Wire types for ar.io Verifiable Event Envelopes (ar-io-agent
|
|
2
|
+
// docs/envelope-spec.md; the ario.agent/v1 profile detail is artifact.md
|
|
3
|
+
// §3–§4). We model only the fields the verifier consumes; unknown fields are
|
|
4
|
+
// preserved by structural typing (envelopes are verified over their canonical
|
|
5
|
+
// bytes, not this interface).
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ContentRole, Envelope, VerificationResult } from "./types";
|
|
2
|
+
export declare function jcs(value: unknown): string;
|
|
3
|
+
export declare function specVersionSupported(specVersion: string): boolean;
|
|
4
|
+
export declare function contentHashes(env: Envelope): {
|
|
5
|
+
role: ContentRole;
|
|
6
|
+
hash: string;
|
|
7
|
+
}[];
|
|
8
|
+
export declare function verifyEnvelope(env: Envelope, expectedContentHash?: string): Promise<VerificationResult>;
|
package/dist/verifier.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Independent client-side implementation of the ar.io agent envelope
|
|
2
|
+
// verification algorithm (ar-io-agent docs/artifact.md §6, docs/auditor-recipe.md
|
|
3
|
+
// Recipe 1). This is deliberately a second implementation of the same algorithm
|
|
4
|
+
// the Go agent runs — a second, conformance-tested verifier is what demonstrates
|
|
5
|
+
// the product's claim: verification needs no ar.io code in the trust path.
|
|
6
|
+
//
|
|
7
|
+
// Conformance is enforced by test/conformance.test.ts, which runs every
|
|
8
|
+
// ar-io-agent test vector through this code and asserts byte-for-byte agreement.
|
|
9
|
+
import canonicalize from "canonicalize";
|
|
10
|
+
import { ed25519Verify, sha256Hex, utf8 } from "./crypto";
|
|
11
|
+
// Fail-closed accepted-profile registry (envelope-spec §2, artifact.md §13):
|
|
12
|
+
// exactly the accepted profile majors, nothing else. Minors within an accepted
|
|
13
|
+
// major ("ario.agent/v1.<minor>") are additive and tolerated — matching the Go
|
|
14
|
+
// reference kernel's semantics (pkg/proof isSupportedSpec) so the JS and
|
|
15
|
+
// WASM-Go verifiers agree. Accepting a new profile (e.g. ario.mlflow/v1) is a
|
|
16
|
+
// deliberate one-entry addition HERE and only here — mlflow-dialect behaviors
|
|
17
|
+
// (like its underscore-key strip) must never leak into the agent profile.
|
|
18
|
+
const ACCEPTED_SPEC_MAJORS = ["ario.agent/v1"];
|
|
19
|
+
// RFC 8785 (JCS) canonicalization. The `canonicalize` package is the reference
|
|
20
|
+
// JS implementation; correctness is pinned by the conformance vectors.
|
|
21
|
+
export function jcs(value) {
|
|
22
|
+
const canonical = canonicalize(value);
|
|
23
|
+
if (typeof canonical !== "string") {
|
|
24
|
+
throw new Error("jcs: canonicalize returned a non-string (input not JSON-serializable?)");
|
|
25
|
+
}
|
|
26
|
+
return canonical;
|
|
27
|
+
}
|
|
28
|
+
export function specVersionSupported(specVersion) {
|
|
29
|
+
if (typeof specVersion !== "string" || specVersion === "")
|
|
30
|
+
return false;
|
|
31
|
+
return ACCEPTED_SPEC_MAJORS.some((m) => specVersion === m || specVersion.startsWith(`${m}.`));
|
|
32
|
+
}
|
|
33
|
+
// The content hash(es) an envelope commits to, by event type — the values a
|
|
34
|
+
// reverse lookup can match the user's in-browser file hash against.
|
|
35
|
+
// asset_registered -> payload.hash (the registered, known-good bytes)
|
|
36
|
+
// asset_missing -> payload.baseline.hash (last known-good bytes that vanished)
|
|
37
|
+
// tamper_detected -> payload.observed.hash (the tampered bytes that were flagged)
|
|
38
|
+
// -> payload.baseline.hash (the known-good bytes it diverged from)
|
|
39
|
+
// Other event types commit to no asset content hash.
|
|
40
|
+
export function contentHashes(env) {
|
|
41
|
+
const p = env.payload;
|
|
42
|
+
const asStr = (v) => (typeof v === "string" && v ? v : undefined);
|
|
43
|
+
const out = [];
|
|
44
|
+
switch (env.event_type) {
|
|
45
|
+
case "asset_registered": {
|
|
46
|
+
const h = asStr(p.hash);
|
|
47
|
+
if (h)
|
|
48
|
+
out.push({ role: "asset", hash: h });
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
case "asset_missing": {
|
|
52
|
+
const h = asStr(p.baseline?.hash);
|
|
53
|
+
if (h)
|
|
54
|
+
out.push({ role: "baseline", hash: h });
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case "tamper_detected": {
|
|
58
|
+
const obs = asStr(p.observed?.hash);
|
|
59
|
+
if (obs)
|
|
60
|
+
out.push({ role: "observed", hash: obs });
|
|
61
|
+
const base = asStr(p.baseline?.hash);
|
|
62
|
+
if (base)
|
|
63
|
+
out.push({ role: "baseline", hash: base });
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
// Verify an envelope. The three load-bearing checks (spec_version, payload_hash,
|
|
70
|
+
// signature) establish that the envelope is authentic. When `expectedContentHash`
|
|
71
|
+
// is supplied (the in-browser hash of the user's file), an additional bind check
|
|
72
|
+
// confirms the bytes the user holds are the bytes this envelope is about — this
|
|
73
|
+
// is the step that makes a lying gateway tag worthless.
|
|
74
|
+
export async function verifyEnvelope(env, expectedContentHash) {
|
|
75
|
+
const errors = [];
|
|
76
|
+
// Guard a malformed input (null / non-object / array) up front — every field
|
|
77
|
+
// access below assumes an object. A hostile gateway or a hand-edited report
|
|
78
|
+
// can supply anything; treat it as "not verified," never a thrown exception.
|
|
79
|
+
if (env === null || typeof env !== "object" || Array.isArray(env)) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
specVersionOk: false,
|
|
83
|
+
payloadHashOk: false,
|
|
84
|
+
signatureOk: false,
|
|
85
|
+
contentHashOk: expectedContentHash === undefined ? null : false,
|
|
86
|
+
contentRole: null,
|
|
87
|
+
errors: ["envelope is not a JSON object"],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const specVersionOk = specVersionSupported(env.spec_version);
|
|
91
|
+
if (!specVersionOk)
|
|
92
|
+
errors.push(`unsupported spec_version: ${JSON.stringify(env.spec_version)}`);
|
|
93
|
+
// Check 1 — Record Matches: payload_hash == SHA-256(JCS(payload)).
|
|
94
|
+
let payloadHashOk = false;
|
|
95
|
+
try {
|
|
96
|
+
const recomputed = await sha256Hex(utf8(jcs(env.payload)));
|
|
97
|
+
payloadHashOk = recomputed === env.payload_hash;
|
|
98
|
+
if (!payloadHashOk) {
|
|
99
|
+
errors.push(`payload_hash mismatch: envelope=${env.payload_hash} recomputed=${recomputed}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
errors.push(`payload canonicalization failed: ${stringifyErr(e)}`);
|
|
104
|
+
}
|
|
105
|
+
// Check 2 — Signature Confirmed: Ed25519 over the signed scope, which is
|
|
106
|
+
// JCS(envelope minus `signature` minus `co_signatures`) per envelope-spec §2.
|
|
107
|
+
// The co_signatures carve-out (§7.1) lets a countersignature be added without
|
|
108
|
+
// invalidating the primary signature; the field is reserved/default-absent.
|
|
109
|
+
// The corpus has no co-signed vectors, so this strip is pinned by an explicit
|
|
110
|
+
// unit test rather than by conformance.
|
|
111
|
+
let signatureOk = false;
|
|
112
|
+
try {
|
|
113
|
+
const { signature: _signature, co_signatures: _coSignatures, ...envelopeForSig } = env;
|
|
114
|
+
signatureOk = await ed25519Verify(env.signature, utf8(jcs(envelopeForSig)), env.public_key);
|
|
115
|
+
if (!signatureOk)
|
|
116
|
+
errors.push("Ed25519 signature verification failed");
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
errors.push(`signature verification error: ${stringifyErr(e)}`);
|
|
120
|
+
}
|
|
121
|
+
// Check 3 (optional) — Content Bind: the user's bytes match a hash this
|
|
122
|
+
// envelope commits to. The tag got us here; only this proves the bytes match.
|
|
123
|
+
let contentHashOk = null;
|
|
124
|
+
let contentRole = null;
|
|
125
|
+
if (expectedContentHash !== undefined) {
|
|
126
|
+
const want = expectedContentHash.toLowerCase();
|
|
127
|
+
const match = contentHashes(env).find((c) => c.hash.toLowerCase() === want);
|
|
128
|
+
contentHashOk = match !== undefined;
|
|
129
|
+
contentRole = match ? match.role : null;
|
|
130
|
+
if (!contentHashOk) {
|
|
131
|
+
errors.push("provided content hash does not match any hash this envelope commits to");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
ok: specVersionOk && payloadHashOk && signatureOk,
|
|
136
|
+
specVersionOk,
|
|
137
|
+
payloadHashOk,
|
|
138
|
+
signatureOk,
|
|
139
|
+
contentHashOk,
|
|
140
|
+
contentRole,
|
|
141
|
+
errors,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function stringifyErr(e) {
|
|
145
|
+
return e instanceof Error ? e.message : String(e);
|
|
146
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ar.io/proof",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TypeScript kernel of the ar.io verification stack: RFC 8785 (JCS) canonicalization, SHA-256, and Ed25519 verification for Verifiable Event Envelopes (ario.agent/v1 profile). Conformance-gated byte-for-byte against the test-vectors-v1.0 corpus.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.build.json",
|
|
22
|
+
"prepublishOnly": "npm run build"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@noble/ed25519": "^2.1.0",
|
|
26
|
+
"canonicalize": "^2.0.0"
|
|
27
|
+
},
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/ar-io/ar-io-proof-checker.git",
|
|
33
|
+
"directory": "packages/proof"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"ar.io",
|
|
40
|
+
"arweave",
|
|
41
|
+
"verification",
|
|
42
|
+
"provenance",
|
|
43
|
+
"jcs",
|
|
44
|
+
"rfc8785",
|
|
45
|
+
"ed25519",
|
|
46
|
+
"merkle",
|
|
47
|
+
"rfc9162"
|
|
48
|
+
]
|
|
49
|
+
}
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Low-level cryptographic primitives for the verifier. Deliberately thin: the
|
|
2
|
+
// only third-party crypto dependency is @noble/ed25519 (single-file, audited,
|
|
3
|
+
// zero-dependency). SHA-256 and SHA-512 come from WebCrypto — no extra hashing
|
|
4
|
+
// library to trust.
|
|
5
|
+
|
|
6
|
+
import * as ed from "@noble/ed25519";
|
|
7
|
+
|
|
8
|
+
// @noble/ed25519 needs SHA-512 to verify. Wire it to WebCrypto rather than
|
|
9
|
+
// pulling in @noble/hashes. globalThis.crypto.subtle exists in every modern
|
|
10
|
+
// browser and in Node >= 19, so the same code path runs in the app and in tests.
|
|
11
|
+
ed.etc.sha512Async = async (...msgs: Uint8Array[]): Promise<Uint8Array> =>
|
|
12
|
+
new Uint8Array(await crypto.subtle.digest("SHA-512", asBufferSource(ed.etc.concatBytes(...msgs))));
|
|
13
|
+
|
|
14
|
+
// WebCrypto's digest() wants a BufferSource backed by a (non-shared) ArrayBuffer.
|
|
15
|
+
// Our byte arrays always are; this keeps TS 5.7's stricter Uint8Array<ArrayBufferLike>
|
|
16
|
+
// typing satisfied without a runtime copy.
|
|
17
|
+
function asBufferSource(bytes: Uint8Array): BufferSource {
|
|
18
|
+
return bytes as unknown as BufferSource;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function bytesToHex(bytes: Uint8Array): string {
|
|
22
|
+
let out = "";
|
|
23
|
+
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function hexToBytes(hex: string): Uint8Array {
|
|
28
|
+
if (hex.length % 2 !== 0) throw new Error("hexToBytes: odd-length string");
|
|
29
|
+
// Full validation: parseInt() partially parses ("1g" -> 1), which would let
|
|
30
|
+
// malformed hex through. A strict charset check closes that.
|
|
31
|
+
if (!/^[0-9a-fA-F]*$/.test(hex)) throw new Error("hexToBytes: non-hex characters");
|
|
32
|
+
const out = new Uint8Array(hex.length / 2);
|
|
33
|
+
for (let i = 0; i < out.length; i++) {
|
|
34
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function utf8(s: string): Uint8Array {
|
|
40
|
+
return new TextEncoder().encode(s);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function sha256Bytes(bytes: Uint8Array): Promise<Uint8Array> {
|
|
44
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", asBufferSource(bytes)));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
|
48
|
+
return bytesToHex(await sha256Bytes(bytes));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Verify an Ed25519 signature. Inputs are hex (signature, public key) as they
|
|
52
|
+
// appear in the envelope; message is raw bytes. Never throws — a malformed
|
|
53
|
+
// signature or key is "not verified," not an exception, so a hostile envelope
|
|
54
|
+
// can't crash the checker.
|
|
55
|
+
export async function ed25519Verify(
|
|
56
|
+
signatureHex: string,
|
|
57
|
+
message: Uint8Array,
|
|
58
|
+
publicKeyHex: string,
|
|
59
|
+
): Promise<boolean> {
|
|
60
|
+
try {
|
|
61
|
+
// zip215:false → strict RFC-8032 verification, matching the Go agent's
|
|
62
|
+
// crypto/ed25519 exactly (noble defaults to the more lenient zip215:true).
|
|
63
|
+
return await ed.verifyAsync(hexToBytes(signatureHex), message, hexToBytes(publicKeyHex), {
|
|
64
|
+
zip215: false,
|
|
65
|
+
});
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @ar.io/proof — TypeScript kernel of the ar.io verification stack.
|
|
2
|
+
//
|
|
3
|
+
// The verification primitives for the Verifiable Event Envelope family
|
|
4
|
+
// (ar-io-agent docs/envelope-spec.md), ario.agent/v1 profile: RFC 8785 (JCS)
|
|
5
|
+
// canonicalization, SHA-256, Ed25519 envelope verification, and the
|
|
6
|
+
// content-hash extraction used for reverse provenance lookup. Sibling of the
|
|
7
|
+
// Python `ar-io-proof` kernel and the Go `pkg/proof` reference; conformance
|
|
8
|
+
// is byte-for-byte against the shared `test-vectors-v1.0` corpus.
|
|
9
|
+
//
|
|
10
|
+
// Scope boundary (envelope-spec §10 #13): this package verifies a SINGLE
|
|
11
|
+
// envelope. Chain walking (previous_hash), checkpoint reconciliation, and
|
|
12
|
+
// gateway/transport concerns are consumer-layer logic composed above it.
|
|
13
|
+
|
|
14
|
+
export { contentHashes, jcs, specVersionSupported, verifyEnvelope } from "./verifier";
|
|
15
|
+
export { bytesToHex, ed25519Verify, hexToBytes, sha256Bytes, sha256Hex, utf8 } from "./crypto";
|
|
16
|
+
export {
|
|
17
|
+
EMPTY_TREE_ROOT_HEX,
|
|
18
|
+
auditPath,
|
|
19
|
+
leafHash,
|
|
20
|
+
merkleRoot,
|
|
21
|
+
nodeHash,
|
|
22
|
+
verifyInclusion,
|
|
23
|
+
} from "./merkle";
|
|
24
|
+
export type { ContentRole, Envelope, Subject, VerificationResult } from "./types";
|
package/src/merkle.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// RFC 9162 binary Merkle tree — build, audit paths, inclusion verification.
|
|
2
|
+
//
|
|
3
|
+
// A faithful port of the Go reference (ar-io-agent pkg/merkle), in lockstep
|
|
4
|
+
// with the Python kernel's ario_proof.merkle. All hashing is SHA-256 with
|
|
5
|
+
// RFC 9162 §2.1 domain separation: leaves prefixed 0x00, interior nodes 0x01.
|
|
6
|
+
// A tree of n leaves splits into a left subtree of k leaves (the largest
|
|
7
|
+
// power of two < n) and a right subtree of n−k — NOT the Bitcoin
|
|
8
|
+
// duplicate-last-leaf variant, which produces different roots for
|
|
9
|
+
// non-power-of-two leaf counts.
|
|
10
|
+
//
|
|
11
|
+
// The empty tree (zero leaves) hashes to SHA-256("") — EMPTY_TREE_ROOT_HEX.
|
|
12
|
+
// Conformance is byte-for-byte against the 7 merkle-tree-* vectors of
|
|
13
|
+
// test-vectors-v1.0 (test/conformance.test.ts).
|
|
14
|
+
|
|
15
|
+
import { sha256Bytes } from "./crypto";
|
|
16
|
+
|
|
17
|
+
export const EMPTY_TREE_ROOT_HEX =
|
|
18
|
+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
19
|
+
|
|
20
|
+
// SHA-256(0x00 || leafBytes) per RFC 9162 §2.1.
|
|
21
|
+
export async function leafHash(leafBytes: Uint8Array): Promise<Uint8Array> {
|
|
22
|
+
return sha256Bytes(prefixed(0x00, leafBytes));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// SHA-256(0x01 || left || right) per RFC 9162 §2.1.
|
|
26
|
+
export async function nodeHash(left: Uint8Array, right: Uint8Array): Promise<Uint8Array> {
|
|
27
|
+
const buf = new Uint8Array(1 + left.length + right.length);
|
|
28
|
+
buf[0] = 0x01;
|
|
29
|
+
buf.set(left, 1);
|
|
30
|
+
buf.set(right, 1 + left.length);
|
|
31
|
+
return sha256Bytes(buf);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The RFC 9162 Merkle Tree Hash of already-hashed leaves. Callers pass leaf
|
|
35
|
+
// hashes — the output of leafHash on each leaf's canonical bytes. Zero leaves
|
|
36
|
+
// yields SHA-256("").
|
|
37
|
+
export async function merkleRoot(leafHashes: Uint8Array[]): Promise<Uint8Array> {
|
|
38
|
+
const n = leafHashes.length;
|
|
39
|
+
if (n === 0) return sha256Bytes(new Uint8Array(0));
|
|
40
|
+
if (n === 1) return leafHashes[0];
|
|
41
|
+
const k = largestPow2LessThan(n);
|
|
42
|
+
return nodeHash(await merkleRoot(leafHashes.slice(0, k)), await merkleRoot(leafHashes.slice(k)));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The inclusion proof for the leaf at index m, per RFC 9162 §2.1.3: sibling
|
|
46
|
+
// hashes bottom-up; empty for a single-leaf tree (the leaf hash itself is the
|
|
47
|
+
// root). Throws RangeError when m is out of range.
|
|
48
|
+
export async function auditPath(m: number, leafHashes: Uint8Array[]): Promise<Uint8Array[]> {
|
|
49
|
+
if (!Number.isInteger(m) || m < 0 || m >= leafHashes.length) {
|
|
50
|
+
throw new RangeError("merkle: leaf index out of range");
|
|
51
|
+
}
|
|
52
|
+
return path(m, leafHashes);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function path(m: number, leafHashes: Uint8Array[]): Promise<Uint8Array[]> {
|
|
56
|
+
const n = leafHashes.length;
|
|
57
|
+
if (n === 1) return [];
|
|
58
|
+
const k = largestPow2LessThan(n);
|
|
59
|
+
if (m < k) {
|
|
60
|
+
return [...(await path(m, leafHashes.slice(0, k))), await merkleRoot(leafHashes.slice(k))];
|
|
61
|
+
}
|
|
62
|
+
return [...(await path(m - k, leafHashes.slice(k))), await merkleRoot(leafHashes.slice(0, k))];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Verify an RFC 9162 §2.1.3 inclusion proof. `leaf` is the leaf hash
|
|
66
|
+
// (leafHash of the canonical leaf bytes); `path` is the audit path bottom-up;
|
|
67
|
+
// `expectedRoot` is the merkleRoot committed by the checkpoint envelope.
|
|
68
|
+
// True iff the path reconstructs the expected root. Never throws.
|
|
69
|
+
export async function verifyInclusion(
|
|
70
|
+
leaf: Uint8Array,
|
|
71
|
+
leafIndex: number,
|
|
72
|
+
totalLeaves: number,
|
|
73
|
+
auditPath: Uint8Array[],
|
|
74
|
+
expectedRoot: Uint8Array,
|
|
75
|
+
): Promise<boolean> {
|
|
76
|
+
if (!Number.isInteger(leafIndex) || leafIndex < 0 || totalLeaves <= 0 || leafIndex >= totalLeaves) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
if (totalLeaves === 1) return auditPath.length === 0 && bytesEqual(leaf, expectedRoot);
|
|
80
|
+
|
|
81
|
+
let fn = leafIndex;
|
|
82
|
+
let sn = totalLeaves - 1;
|
|
83
|
+
let r = leaf;
|
|
84
|
+
|
|
85
|
+
for (const p of auditPath) {
|
|
86
|
+
if (sn === 0) return false; // path longer than the tree depth — malformed
|
|
87
|
+
if ((fn & 1) === 1 || fn === sn) {
|
|
88
|
+
r = await nodeHash(p, r);
|
|
89
|
+
if ((fn & 1) === 0) {
|
|
90
|
+
while ((fn & 1) === 0 && fn !== 0) {
|
|
91
|
+
fn >>= 1;
|
|
92
|
+
sn >>= 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
r = await nodeHash(r, p);
|
|
97
|
+
}
|
|
98
|
+
fn >>= 1;
|
|
99
|
+
sn >>= 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return sn === 0 && bytesEqual(r, expectedRoot);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function prefixed(prefix: number, bytes: Uint8Array): Uint8Array {
|
|
106
|
+
const buf = new Uint8Array(1 + bytes.length);
|
|
107
|
+
buf[0] = prefix;
|
|
108
|
+
buf.set(bytes, 1);
|
|
109
|
+
return buf;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Largest k = 2**a with k < n (0 for n < 2).
|
|
113
|
+
function largestPow2LessThan(n: number): number {
|
|
114
|
+
if (n < 2) return 0;
|
|
115
|
+
let k = 1;
|
|
116
|
+
while (k * 2 < n) k *= 2;
|
|
117
|
+
return k;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
121
|
+
if (a.length !== b.length) return false;
|
|
122
|
+
let diff = 0;
|
|
123
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
124
|
+
return diff === 0;
|
|
125
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Wire types for ar.io Verifiable Event Envelopes (ar-io-agent
|
|
2
|
+
// docs/envelope-spec.md; the ario.agent/v1 profile detail is artifact.md
|
|
3
|
+
// §3–§4). We model only the fields the verifier consumes; unknown fields are
|
|
4
|
+
// preserved by structural typing (envelopes are verified over their canonical
|
|
5
|
+
// bytes, not this interface).
|
|
6
|
+
|
|
7
|
+
export interface Subject {
|
|
8
|
+
type: string;
|
|
9
|
+
tenant_id: string;
|
|
10
|
+
agent_id: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Envelope {
|
|
14
|
+
spec_version: string;
|
|
15
|
+
event_id: string;
|
|
16
|
+
event_type: string;
|
|
17
|
+
subject: Subject;
|
|
18
|
+
payload_hash: string;
|
|
19
|
+
payload: Record<string, unknown>;
|
|
20
|
+
previous_hash: string;
|
|
21
|
+
signed_at: string;
|
|
22
|
+
public_key: string;
|
|
23
|
+
signature: string;
|
|
24
|
+
// Reserved, default-absent (envelope-spec §7.1): countersignatures over the
|
|
25
|
+
// same skeleton. OUTSIDE the signed scope — the primary signature covers the
|
|
26
|
+
// envelope minus `signature` AND minus `co_signatures`, so a countersignature
|
|
27
|
+
// can be added without invalidating the primary. Absence implies a single
|
|
28
|
+
// signer and MUST NOT be treated as a failure.
|
|
29
|
+
co_signatures?: unknown[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Which payload field a matched content hash came from. tamper_detected commits
|
|
33
|
+
// to both the tampered ("observed") bytes and the known-good ("baseline") bytes.
|
|
34
|
+
export type ContentRole = "asset" | "baseline" | "observed";
|
|
35
|
+
|
|
36
|
+
export interface VerificationResult {
|
|
37
|
+
// Cryptographic validity: spec_version + payload_hash + Ed25519 signature all
|
|
38
|
+
// passed. This is "the envelope is authentic," independent of the user's bytes.
|
|
39
|
+
ok: boolean;
|
|
40
|
+
specVersionOk: boolean;
|
|
41
|
+
payloadHashOk: boolean;
|
|
42
|
+
signatureOk: boolean;
|
|
43
|
+
// Content bind: did the hash the caller supplied (e.g. the in-browser hash of
|
|
44
|
+
// a user's file) match a hash this envelope commits to? null when no hash was
|
|
45
|
+
// supplied (verifying an envelope on its own). This is the check that defeats
|
|
46
|
+
// a lying gateway — the tag only got us to the candidate.
|
|
47
|
+
contentHashOk: boolean | null;
|
|
48
|
+
contentRole: ContentRole | null;
|
|
49
|
+
errors: string[];
|
|
50
|
+
}
|
package/src/verifier.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Independent client-side implementation of the ar.io agent envelope
|
|
2
|
+
// verification algorithm (ar-io-agent docs/artifact.md §6, docs/auditor-recipe.md
|
|
3
|
+
// Recipe 1). This is deliberately a second implementation of the same algorithm
|
|
4
|
+
// the Go agent runs — a second, conformance-tested verifier is what demonstrates
|
|
5
|
+
// the product's claim: verification needs no ar.io code in the trust path.
|
|
6
|
+
//
|
|
7
|
+
// Conformance is enforced by test/conformance.test.ts, which runs every
|
|
8
|
+
// ar-io-agent test vector through this code and asserts byte-for-byte agreement.
|
|
9
|
+
|
|
10
|
+
import canonicalize from "canonicalize";
|
|
11
|
+
|
|
12
|
+
import { ed25519Verify, sha256Hex, utf8 } from "./crypto";
|
|
13
|
+
import type { ContentRole, Envelope, VerificationResult } from "./types";
|
|
14
|
+
|
|
15
|
+
// Fail-closed accepted-profile registry (envelope-spec §2, artifact.md §13):
|
|
16
|
+
// exactly the accepted profile majors, nothing else. Minors within an accepted
|
|
17
|
+
// major ("ario.agent/v1.<minor>") are additive and tolerated — matching the Go
|
|
18
|
+
// reference kernel's semantics (pkg/proof isSupportedSpec) so the JS and
|
|
19
|
+
// WASM-Go verifiers agree. Accepting a new profile (e.g. ario.mlflow/v1) is a
|
|
20
|
+
// deliberate one-entry addition HERE and only here — mlflow-dialect behaviors
|
|
21
|
+
// (like its underscore-key strip) must never leak into the agent profile.
|
|
22
|
+
const ACCEPTED_SPEC_MAJORS = ["ario.agent/v1"];
|
|
23
|
+
|
|
24
|
+
// RFC 8785 (JCS) canonicalization. The `canonicalize` package is the reference
|
|
25
|
+
// JS implementation; correctness is pinned by the conformance vectors.
|
|
26
|
+
export function jcs(value: unknown): string {
|
|
27
|
+
const canonical = canonicalize(value);
|
|
28
|
+
if (typeof canonical !== "string") {
|
|
29
|
+
throw new Error("jcs: canonicalize returned a non-string (input not JSON-serializable?)");
|
|
30
|
+
}
|
|
31
|
+
return canonical;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function specVersionSupported(specVersion: string): boolean {
|
|
35
|
+
if (typeof specVersion !== "string" || specVersion === "") return false;
|
|
36
|
+
return ACCEPTED_SPEC_MAJORS.some((m) => specVersion === m || specVersion.startsWith(`${m}.`));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The content hash(es) an envelope commits to, by event type — the values a
|
|
40
|
+
// reverse lookup can match the user's in-browser file hash against.
|
|
41
|
+
// asset_registered -> payload.hash (the registered, known-good bytes)
|
|
42
|
+
// asset_missing -> payload.baseline.hash (last known-good bytes that vanished)
|
|
43
|
+
// tamper_detected -> payload.observed.hash (the tampered bytes that were flagged)
|
|
44
|
+
// -> payload.baseline.hash (the known-good bytes it diverged from)
|
|
45
|
+
// Other event types commit to no asset content hash.
|
|
46
|
+
export function contentHashes(env: Envelope): { role: ContentRole; hash: string }[] {
|
|
47
|
+
const p = env.payload as {
|
|
48
|
+
hash?: unknown;
|
|
49
|
+
baseline?: { hash?: unknown };
|
|
50
|
+
observed?: { hash?: unknown };
|
|
51
|
+
};
|
|
52
|
+
const asStr = (v: unknown): string | undefined => (typeof v === "string" && v ? v : undefined);
|
|
53
|
+
|
|
54
|
+
const out: { role: ContentRole; hash: string }[] = [];
|
|
55
|
+
switch (env.event_type) {
|
|
56
|
+
case "asset_registered": {
|
|
57
|
+
const h = asStr(p.hash);
|
|
58
|
+
if (h) out.push({ role: "asset", hash: h });
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case "asset_missing": {
|
|
62
|
+
const h = asStr(p.baseline?.hash);
|
|
63
|
+
if (h) out.push({ role: "baseline", hash: h });
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case "tamper_detected": {
|
|
67
|
+
const obs = asStr(p.observed?.hash);
|
|
68
|
+
if (obs) out.push({ role: "observed", hash: obs });
|
|
69
|
+
const base = asStr(p.baseline?.hash);
|
|
70
|
+
if (base) out.push({ role: "baseline", hash: base });
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Verify an envelope. The three load-bearing checks (spec_version, payload_hash,
|
|
78
|
+
// signature) establish that the envelope is authentic. When `expectedContentHash`
|
|
79
|
+
// is supplied (the in-browser hash of the user's file), an additional bind check
|
|
80
|
+
// confirms the bytes the user holds are the bytes this envelope is about — this
|
|
81
|
+
// is the step that makes a lying gateway tag worthless.
|
|
82
|
+
export async function verifyEnvelope(
|
|
83
|
+
env: Envelope,
|
|
84
|
+
expectedContentHash?: string,
|
|
85
|
+
): Promise<VerificationResult> {
|
|
86
|
+
const errors: string[] = [];
|
|
87
|
+
|
|
88
|
+
// Guard a malformed input (null / non-object / array) up front — every field
|
|
89
|
+
// access below assumes an object. A hostile gateway or a hand-edited report
|
|
90
|
+
// can supply anything; treat it as "not verified," never a thrown exception.
|
|
91
|
+
if (env === null || typeof env !== "object" || Array.isArray(env)) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
specVersionOk: false,
|
|
95
|
+
payloadHashOk: false,
|
|
96
|
+
signatureOk: false,
|
|
97
|
+
contentHashOk: expectedContentHash === undefined ? null : false,
|
|
98
|
+
contentRole: null,
|
|
99
|
+
errors: ["envelope is not a JSON object"],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const specVersionOk = specVersionSupported(env.spec_version);
|
|
104
|
+
if (!specVersionOk) errors.push(`unsupported spec_version: ${JSON.stringify(env.spec_version)}`);
|
|
105
|
+
|
|
106
|
+
// Check 1 — Record Matches: payload_hash == SHA-256(JCS(payload)).
|
|
107
|
+
let payloadHashOk = false;
|
|
108
|
+
try {
|
|
109
|
+
const recomputed = await sha256Hex(utf8(jcs(env.payload)));
|
|
110
|
+
payloadHashOk = recomputed === env.payload_hash;
|
|
111
|
+
if (!payloadHashOk) {
|
|
112
|
+
errors.push(`payload_hash mismatch: envelope=${env.payload_hash} recomputed=${recomputed}`);
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
errors.push(`payload canonicalization failed: ${stringifyErr(e)}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Check 2 — Signature Confirmed: Ed25519 over the signed scope, which is
|
|
119
|
+
// JCS(envelope minus `signature` minus `co_signatures`) per envelope-spec §2.
|
|
120
|
+
// The co_signatures carve-out (§7.1) lets a countersignature be added without
|
|
121
|
+
// invalidating the primary signature; the field is reserved/default-absent.
|
|
122
|
+
// The corpus has no co-signed vectors, so this strip is pinned by an explicit
|
|
123
|
+
// unit test rather than by conformance.
|
|
124
|
+
let signatureOk = false;
|
|
125
|
+
try {
|
|
126
|
+
const { signature: _signature, co_signatures: _coSignatures, ...envelopeForSig } = env;
|
|
127
|
+
signatureOk = await ed25519Verify(env.signature, utf8(jcs(envelopeForSig)), env.public_key);
|
|
128
|
+
if (!signatureOk) errors.push("Ed25519 signature verification failed");
|
|
129
|
+
} catch (e) {
|
|
130
|
+
errors.push(`signature verification error: ${stringifyErr(e)}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check 3 (optional) — Content Bind: the user's bytes match a hash this
|
|
134
|
+
// envelope commits to. The tag got us here; only this proves the bytes match.
|
|
135
|
+
let contentHashOk: boolean | null = null;
|
|
136
|
+
let contentRole: ContentRole | null = null;
|
|
137
|
+
if (expectedContentHash !== undefined) {
|
|
138
|
+
const want = expectedContentHash.toLowerCase();
|
|
139
|
+
const match = contentHashes(env).find((c) => c.hash.toLowerCase() === want);
|
|
140
|
+
contentHashOk = match !== undefined;
|
|
141
|
+
contentRole = match ? match.role : null;
|
|
142
|
+
if (!contentHashOk) {
|
|
143
|
+
errors.push("provided content hash does not match any hash this envelope commits to");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
ok: specVersionOk && payloadHashOk && signatureOk,
|
|
149
|
+
specVersionOk,
|
|
150
|
+
payloadHashOk,
|
|
151
|
+
signatureOk,
|
|
152
|
+
contentHashOk,
|
|
153
|
+
contentRole,
|
|
154
|
+
errors,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function stringifyErr(e: unknown): string {
|
|
159
|
+
return e instanceof Error ? e.message : String(e);
|
|
160
|
+
}
|