An attestation is the identity-free profile of a document's export bundle: the states, timestamps, hash anchors and the Verification Grade — with every identity field redacted. Actors become issuer/receiver roles, free text and metadata are dropped, document ids become positional refs. Rivet signs the canonical bytes with a P-256 key held in AWS KMS — ES256, private material never in an application process (the trusted-attestor model — Rivet attests what its bilateral record shows; it does not use zero-knowledge proofs, and says so). Attestations issued before the key-custody cutover carry the retired kid att-hkdf-v1 (EdDSA); that key stays published, so they verify forever.
curl -o envelope.json -D headers.txt \
"https://api.rivet.network/v1/documents/{documentId}/attestation?amounts=bucketed" \
-H "Authorization: Bearer rk_live_…"Verify without membership
Hand a funder or auditor the digest — not a login, not API access. They verify it keyless: genuine, the grade, the attested facts, and whether Rivet's signature stands, straight from the platform. An unknown digest and a mistyped one answer identically — nothing about any artifact is acknowledged until the caller already holds its exact digest, and there is nothing to browse or enumerate.
# no key, no membership — just the digest
curl "https://api.rivet.network/public/verify/{digest}"
# every key Rivet has ever signed with — active and retired, public halves only
curl "https://api.rivet.network/public/keys"
curl "https://api.rivet.network/.well-known/rivet-attestation-keys.json" # the same set, JWKS-shapedVerify offline
Or take Rivet's word for nothing: the script below verifies the file itself — the digest, the signature over the exact canonical bytes, and the hash-anchor structure. Its signature truth comes from a published public key, never from the verify endpoint (the script does not call it): by default it fetches Rivet's key set and picks the key the envelope names by kid; pin a PEM with --public-key and it needs no network at all. Every key Rivet has ever signed with stays published — a retired key still verifies the artifacts it signed, forever, and a key that was never published is refused loudly. It states plainly what it does not check: identity fields sit inside the transition-hash preimage, so this redacted profile can't support per-transition recomputation — that is what the full export bundle does, and any party to the document can produce one whose hashes must match these anchors.
#!/usr/bin/env node
// ── verify-attestation.mjs — verify a Rivet ATTESTATION offline ──────────────
//
// node verify-attestation.mjs <envelope.json> [--digest <sha256hex>]
// node verify-attestation.mjs <envelope.json> --keys-url <url> (default: Rivet's published key set)
// node verify-attestation.mjs <envelope.json> --keys <keys.json> (a pinned copy: PEM or JWKS shape)
// node verify-attestation.mjs <envelope.json> --public-key <key.pem> (one pinned key)
//
// The envelope is what GET /v1/documents/{id}/attestation returned:
// { attestation, signature: { kid, alg, signature, signed_at } }. The keys
// document is GET /.well-known/rivet-attestation-keys.json — pin it once; it
// lists every key Rivet has ever signed attestations with, active and
// retired, by kid. (--public-key takes one PEM instead, for a single pinned key.)
//
// What this proves, in order:
// 1. DIGEST sha256 of the attestation's canonical bytes matches
// --digest (the Rivet-Bundle-Digest header, if you kept it).
// 2. SIGNATURE the signature verifies over those exact bytes under the key
// the envelope names by kid — ES256 (ECDSA P-256, raw r||s) for
// attestations issued from AWS KMS, EdDSA (Ed25519) for those
// issued before the custody cutover under the retired kid
// att-hkdf-v1. This is the attestation's proof (the
// trusted-attestor model): change one byte and it fails.
// 3. STRUCTURE the hash anchors are internally consistent — every
// parent_transition_hash exists among that document's earlier
// anchors, depths follow the DAG rule, and every cross-edge
// resolves to a bundled document or the sealed list.
//
// Said plainly (and in the artifact's own verification block): identity fields
// are REDACTED from this profile and they sit inside the transition-hash
// preimage, so per-transition hash RECOMPUTATION is not possible here — that
// is what the full export bundle (verify-bundle.mjs) does, and any party to
// the document can produce one whose hashes match these anchors.
//
// Exit codes: 0 verified · 1 FAILED · 2 usage/read error.
import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
import { readFileSync } from "node:fs";
const sha256 = (b) => createHash("sha256").update(b).digest("hex");
const canonicalJson = (value) => JSON.stringify(sortKeys(value));
function sortKeys(v) {
if (Array.isArray(v)) return v.map(sortKeys);
if (v && typeof v === "object") return Object.keys(v).sort().reduce((a, k) => ((a[k] = sortKeys(v[k])), a), {});
return v;
}
const DEFAULT_KEYS_URL = "https://api.rivet.network/public/keys";
const args = process.argv.slice(2);
const file = args.find((a) => !a.startsWith("--"));
const opt = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : null; };
const keysFile = opt("--keys");
const keyFile = opt("--public-key");
const keysUrl = opt("--keys-url") ?? (keysFile || keyFile ? null : DEFAULT_KEYS_URL);
const expectedDigest = opt("--digest");
if (!file) {
console.error("usage: node verify-attestation.mjs <envelope.json> [--keys-url <url> | --keys <keys.json> | --public-key <key.pem>] [--digest <sha256hex>]");
process.exit(2);
}
// A published key set in either shape: JWKS (kty/crv/x/y) or PEM (public_key).
function loadKeySet(doc) {
const map = new Map();
const list = Array.isArray(doc) ? doc : doc?.keys ?? [];
for (const k of list) {
const key = k.public_key ? createPublicKey(k.public_key) : k.kty ? createPublicKey({ key: k, format: "jwk" }) : null;
if (key) map.set(k.kid, { key, alg: k.alg, status: k.status ?? "active" });
}
return map;
}
let envelope, keyByKid = new Map(), pinned = null;
try {
envelope = JSON.parse(readFileSync(file, "utf8"));
if (keyFile) pinned = createPublicKey(readFileSync(keyFile, "utf8"));
else if (keysFile) keyByKid = loadKeySet(JSON.parse(readFileSync(keysFile, "utf8")));
else {
const res = await fetch(keysUrl);
if (!res.ok) throw new Error(`${keysUrl} answered ${res.status}`);
keyByKid = loadKeySet(await res.json());
console.log(`keys: ${keysUrl} (${keyByKid.size} key${keyByKid.size === 1 ? "" : "s"})`);
}
} catch (err) {
console.error(`could not read inputs: ${err.message}`);
process.exit(2);
}
let failed = 0;
const ok = (l, d = "") => console.log(` ✓ ${l}${d ? ` — ${d}` : ""}`);
const bad = (l, d = "") => { failed += 1; console.error(` ✗ ${l}${d ? ` — ${d}` : ""}`); };
const body = envelope.attestation;
const sig = envelope.signature;
if (!body || !sig?.signature) {
console.error(" ✗ not an attestation envelope ({attestation, signature} expected)");
process.exit(1);
}
const bytes = canonicalJson(body);
const digest = sha256(Buffer.from(bytes, "utf8"));
// Envelopes issued before the custody cutover carry no `alg`: they are EdDSA.
// The published key set names each key's algorithm — in JWKS vocabulary
// ("EdDSA"/"ES256") or the keyring's own ("ed25519"/"es256"). The KEY decides;
// the envelope's `alg` is the fallback, and a pre-cutover envelope carries none.
const normalizeAlg = (a) => (/^ed/i.test(a ?? "") ? "EdDSA" : /^es/i.test(a ?? "") ? "ES256" : null);
const alg = normalizeAlg(keyByKid.get(sig.kid)?.alg) ?? normalizeAlg(sig.alg) ?? (sig.kid === "att-hkdf-v1" || sig.kid === "att-sm-v1" ? "EdDSA" : "ES256");
console.log(`profile: ${body.profile} · kid: ${sig.kid} · alg: ${alg} · signed_at: ${sig.signed_at}`);
console.log(`digest: ${digest}`);
// 1. DIGEST
if (expectedDigest) {
if (digest === expectedDigest.toLowerCase()) ok("digest matches the export header");
else bad("digest MISMATCH", `expected ${expectedDigest}`);
}
// 2. SIGNATURE — under the key the envelope names
let sigOk = false;
let keyNote = "";
try {
let key = pinned;
if (!pinned) {
const entry = keyByKid.get(sig.kid);
if (!entry) bad("unknown kid NOT PUBLISHED", `${sig.kid} is not in the keys document — Rivet never signed with it, or the document is stale`);
else { key = entry.key; keyNote = entry.status === "retired" ? ", retired — still valid: an artifact verifies under the key it names, forever" : ""; }
}
if (key) {
const data = Buffer.from(bytes, "utf8");
const signature = Buffer.from(sig.signature, "base64url");
sigOk = alg === "EdDSA"
? cryptoVerify(null, data, key, signature)
: cryptoVerify("sha256", data, { key, dsaEncoding: "ieee-p1363" }, signature);
}
} catch { /* stays false */ }
const keySource = pinned ? `pinned PEM ${keyFile} (offline)` : keyNote ? `kid ${sig.kid}` : `kid ${sig.kid} (${keyByKid.get(sig.kid)?.status ?? "unknown"})`;
if (sigOk) ok(`Rivet's ${alg} signature verifies over these exact bytes under ${keySource}${keyNote}`);
else bad("signature INVALID", "the attested facts are not what Rivet signed (or the wrong key)");
// 3. STRUCTURE — the anchors
const refs = new Set((body.documents ?? []).map((d) => d.ref));
const sealed = new Set((body.sealed ?? []).map((s) => s.transition_hash));
let anchors = 0, structureOk = true;
for (const doc of body.documents ?? []) {
const seen = new Map();
for (const [i, t] of (doc.transitions ?? []).entries()) {
anchors += 1;
const parentDepths = [];
for (const p of t.parent_transition_hashes ?? []) {
if (!seen.has(p)) { bad(`${doc.ref} #${i} parent link`, `hash ${String(p).slice(0, 12)}… not among earlier anchors`); structureOk = false; }
else parentDepths.push(seen.get(p));
}
const expected = parentDepths.length ? Math.max(...parentDepths) + 1 : 1;
if (t.graph_depth !== expected) { bad(`${doc.ref} #${i} depth`, `graph_depth ${t.graph_depth}, expected ${expected}`); structureOk = false; }
if (!["issuer", "receiver", "other"].includes(t.actor)) { bad(`${doc.ref} #${i} actor`, `unexpected actor ${t.actor}`); structureOk = false; }
for (const e of t.cross_edges ?? []) {
if (e.parent != null && !refs.has(e.parent) && !sealed.has(e.parent_transition_hash)) {
bad(`${doc.ref} #${i} cross-edge`, "resolves to neither a bundled document nor the sealed list");
structureOk = false;
}
}
seen.set(t.transition_hash, t.graph_depth);
}
}
if (structureOk) ok("anchor structure consistent", `${anchors} anchor(s), ${sealed.size} sealed`);
console.log("");
console.log("Not checked here (by design): per-transition hash recomputation — identity");
console.log("fields are redacted from this profile and sit inside the hash preimage. The");
console.log("full export bundle recomputes them; its hashes must match these anchors.");
console.log("");
if (failed === 0) {
console.log(`VERIFIED — grade ${body.grade?.grade}: Rivet's signature stands over these facts.`);
process.exit(0);
}
console.error(`FAILED — ${failed} problem(s). Do not rely on this artifact.`);
process.exit(1);
- Amounts are exact by default;
?amounts=bucketedcoarsens the subject amount to a bucket at export time. - The body is canonical JSON — the same unchanged document re-exports byte-identically, so the digest is a stable content address.
- The signature carries a
kid. Key rotation is a new kid: it is published as active, the previous key is published as retired, nothing is re-signed, and every artifact verifies under the key it names — forever.