--:--
notes/commonplace/verifiable-documents.mdx

NOTES / Commonplace ·

Making a document verifiable: canonical JSON, a hash, a signature

The "verify a verdict" widget on /siam shows a sample judgment from the Siam server. Change one word of its sentence and press Verify: the fingerprint and the signature both stop matching. The pieces are standard and small.

1. One document, one byte string

A hash is taken over bytes, but the same JSON object can be written many ways: {"a":1,"b":2} and {"b":2,"a":1} mean the same and hash differently. So both sides agree on a canonical form. The one used here:

export function canonicalJSON(v: unknown): string {
  if (Array.isArray(v)) return `[${v.map(canonicalJSON).join(',')}]`;
  if (v && typeof v === 'object') {
    const o = v as Record<string, unknown>;
    const keys = Object.keys(o)
      .filter((k) => o[k] !== undefined)
      .sort();
    return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJSON(o[k])}`).join(',')}}`;
  }
  return JSON.stringify(v);
}
  • Keys are sorted at every level, not just the top.
  • No whitespace.
  • undefined fields are dropped, as JSON.stringify would.
  • Strings and numbers use JSON.stringify, so the browser and a Node server produce the same text.

RFC 8785 (JSON Canonicalization Scheme) is the formal version of this idea. What matters most is that the server and every verifier run the same function, which is why the page's version is a copy of the server's, and the samples are generated by the server code, never typed by hand.

Add a format number to the document. When the fields change later, a verifier can tell which layout it is looking at.

2. A fingerprint: SHA-256

const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));

SHA-256 turns the canonical text into 32 bytes. Change any character and about half the output bits flip, and nobody can find a second document with the same hash. So the hash stands in for the document.

For people, the page shows a short anti-forgery code: the first 16 hex digits, upper case, in four groups (3F2A-…). That is 64 bits, enough that two real verdicts won't share a code by chance; the full hash is still what gets checked.

3. Who issued it: an Ed25519 signature

A hash alone proves nothing about origin: anyone can hash an edited document. The server therefore signs the hex hash with its private Ed25519 key. Anyone with the public key can check the signature; only the key holder could have made it.

Browsers can check it with no library:

const key = await crypto.subtle.importKey('spki', der, { name: 'Ed25519' }, false, ['verify']);
const ok = await crypto.subtle.verify(
  { name: 'Ed25519' },
  key,
  sig,
  new TextEncoder().encode(hashHex),
);
  • The public key arrives as PEM; strip the -----BEGIN…----- lines and base64-decode it to the DER bytes importKey('spki', …) wants.
  • Ed25519 in WebCrypto is recent. If importKey throws, the widget reports "can't check in this browser" instead of "invalid": an unsupported browser is not a forgery.
  • What exactly is signed must match on both sides: here it is the hex string of the hash, not the raw 32 bytes.

The demo samples are signed with a throwaway key whose private half was discarded, never with the court's real key.

4. When it existed: a timestamp in Bitcoin

A signature says who; it doesn't prove when. OpenTimestamps submits a hash to calendar servers that aggregate many hashes into a Merkle tree and put its root into a Bitcoin transaction. The .ots proof file is the path from your hash to that root, so anyone can later check that the document existed before that block. On /siam this step is shown on the English page only, and only once a real proof exists; until then it says it is coming.

What each check proves

CheckProvesFails when
Hash matchesthe text is exactly what was issuedany field was edited
Signature validthe holder of the court key issued itedited, or signed by someone else
Timestamp proof validit existed before a given Bitcoin blockedited, or the proof is for another hash

The order matters for honest error messages: an edited document fails all three, so the page reports the hash first and shows which code it got instead of the expected one.

Related: the day /siam was built, SSH keys (Ed25519 is the same algorithm).