Skip to content

Latest commit

 

History

History
120 lines (93 loc) · 4.36 KB

File metadata and controls

120 lines (93 loc) · 4.36 KB

Verifying a score receipt yourself

You do not have to trust this service. Every score receipt can be verified independently, offline, with any standard Ed25519 library — you need only the content, the receipt, and the service's public key (GET /v1/public-key).


What the signature commits to

The signature is computed over a small set of signed fields — note the score and rubric_version are inside the signature, so neither can be altered without breaking it:

signed_fields = {
  "content_hash":   SHA-256(content) as lowercase hex,
  "score":          <integer 0-100>,
  "rubric_version": "v1",
  "receipt_id":     <uuid>,
  "timestamp":      <iso-8601 utc>
}

A receipt binds: a tamper-evident hash of the scored content, the score, the rubric version it was scored under, a unique id, and a service-set time.

Verifying is two independent checks — a receipt is valid only if both pass:

  1. Hash check — SHA-256(your content) == receipt.content_hash.
  2. Signature check — the Ed25519 signature is valid over signed_fields under the service public key.

What this does not prove: that the score is "correct" in any absolute sense. It proves the service issued this score for this content under this rubric version. The rubric is public (quality-rubric.md) and an LLM judgment against it — see the rubric's "Limits" section.


Canonical serialization (exact algorithm)

signed_fields is serialized deterministically: object keys sorted ascending (recursively), no insignificant whitespace, primitives encoded as JSON.stringify encodes them. The signed/verified bytes are the UTF-8 encoding of that string.

function canonicalize(value) {
  if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
  if (Array.isArray(value)) return "[" + value.map(canonicalize).join(",") + "]";
  const keys = Object.keys(value).sort();
  return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalize(value[k])).join(",") + "}";
}

Reproducible verification

A self-contained verifier (no dependency on this service's code) ships as scripts/verify-receipt.mjs:

import * as ed from "@noble/ed25519";
import { webcrypto } from "node:crypto";

function canonicalize(value) { /* as above */ }
async function sha256Hex(str) {
  const d = await webcrypto.subtle.digest("SHA-256", new TextEncoder().encode(str));
  return ed.etc.bytesToHex(new Uint8Array(d));
}

export async function verifyScoreReceipt(content, receipt, publicKeyHex) {
  const hashMatch = (await sha256Hex(content)) === receipt.content_hash;
  const payload = new TextEncoder().encode(canonicalize({
    content_hash: receipt.content_hash,
    score: receipt.score,
    rubric_version: receipt.rubric_version,
    receipt_id: receipt.receipt_id,
    timestamp: receipt.timestamp,
  }));
  const sigValid = await ed.verifyAsync(
    ed.etc.hexToBytes(receipt.signature), payload, ed.etc.hexToBytes(publicKeyHex),
  );
  return { valid: hashMatch && sigValid, hashMatch, sigValid };
}

Self-check (free — no paid scoring call)

scripts/verify-receipt.mjs includes a self-check that proves the independent verifier agrees with the service's own free POST /v1/verify, including flipping to invalid when the score or content is tampered. To avoid a paid /v1/score call, the self-check mints a receipt locally with the service signing key from .dev.vars (the same key the running server publishes at /v1/public-key), then cross-checks it both ways:

node scripts/verify-receipt.mjs http://localhost:8787
# valid receipt      -> independent: valid:true   | server /v1/verify: valid:true   OK
# tampered score (+9)-> independent: valid:false  | server /v1/verify: valid:false  OK
# tampered content   -> independent: valid:false  | server /v1/verify: valid:false  OK
# public key matches signer  OK
# SELF-CHECK PASSED

Because the live service's verification (its real product code) and an independent re-implementation agree on every case, the "verify it yourself" claim is demonstrable, not just asserted.


Test vectors

test/receipt.test.ts exercises this exact algorithm: unmodified → valid; a forged score → invalid (signature covers the score); tampered content → invalid; wrong key → invalid. Run with npm test.