API guides

Attestations & public verification

Verify without membership: the signed artifact, the digest, the script.

The API isn’t switched on in this environment yet. Everything below is accurate; the examples come from the published contract rather than a live read.

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.

Export the attestation (keep the digest header)
From the published contract
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.

The keyless reads
From the published contract
# 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-shaped

Verify 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.

verify-attestation.mjs — dependency-light, copy-paste it whole
From the published contract
#!/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);
The artifact, its digest, the keyless endpoint, and this script are the whole sharing mechanism. Delegated third-party API access is a named future, deliberately not built.
  • Amounts are exact by default; ?amounts=bucketed coarsens 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.