FACTUM

Factum Verifier

Verify a certificate without us.

One file. It runs offline on your machine and needs only Node 18 or later: no install, no network, no account. Verifying is free. It stays free, because the file is yours once you hold it and it never calls Factum.

The file

File
factum-verify.mjs
Version
1.0.0
Size
32,347 bytes
sha256
6cb8f26560867806b9f4b6a1b0f631eded8861f2672097b6c337e7045bd0bf67

The file is published byte for byte as it was built and tested. Check the hash before you run it, and compare it with the one printed here.

Linux

sha256sum -c factum-verify.mjs.sha256

macOS

shasum -a 256 -c factum-verify.mjs.sha256

Windows

certutil -hashfile factum-verify.mjs SHA256

One command

node factum-verify.mjs <certificate.json> <public-key>

The first argument is a certificate, exactly as it was returned. The second is the issuer's public key: the base64 of the raw 32-byte Ed25519 key, given inline or as a file, or a saved key set. With a key set, the certificate's own key id selects the key, so a certificate signed before a key rotation still verifies.

With no arguments, the file runs a frozen known-answer test of its own canonical form and signature check.

Exit codeMeaning
0Verified
1Does not verify A named code is printed.
2The command was called wrongly. Nothing was checked.

What a verification says

CERTIFIED BY THIS CHECK

The document was signed by the holder of the private half of the key you supplied, over the canonical form named in its signature block, and has not been altered by one byte since.

NOT ATTESTED

That the key you supplied is the issuer's. That comes from where you obtained the key, and from nothing in the file. A valid signature under the wrong key is exactly what a forgery looks like.

A certificate is tamper-evident: a change is detectable. The verifier detects it. Nothing prevents the change.

Try it on a recorded certificate

The demonstration publishes two certificates of one document, the key that signed them and a copy with one digit changed. Download them beside the verifier and run the three commands.

The files: run-1.json, run-2.json, run-1-one-digit-changed.json, public-key.txt.

Recorded outputThe verifier's output on the recorded files. The key is a development key minted for the recording, not the production issuer key.
node factum-verify.mjs run-1.json public-key.txt
VERIFIED
  keyId            vd-418b7ced1cea5f8e
  canonical bytes  59952
  barcode          … (the signed barcode, 479 characters, printed in full when you run it)
  surface          FULL - the body carries no surface block, so every claim the run captured is on it

  CERTIFIED BY THIS CHECK: this document was signed by the holder of the private half of the
  key you supplied, over the canonical form named in its signature block, and has not been
  altered by one byte since.
  NOT CERTIFIED BY THIS CHECK: that the key you supplied is the operator's. That comes from
  WHERE YOU OBTAINED THE KEY and from nothing in this file - a valid signature under the wrong
  key is exactly what a forgery looks like.

Exit code 0.

node factum-verify.mjs run-1-one-digit-changed.json public-key.txt
NOT VERIFIED
  code             signature_does_not_verify
  document keyId   vd-418b7ced1cea5f8e
  your key's keyId vd-418b7ced1cea5f8e
  reason           the signature does not verify over this document under the supplied key. The keyId matched, so the key is the right one: the document does not hold the bytes that were signed.

Exit code 1. One digit of the total was changed.

node factum-verify.mjs
  ok   W1    VD-CANON-1 still produces the FROZEN canonical bytes (213 chars)
  ok   W1b   NON-VACUITY: the frozen string is 213 code units and genuinely sorted, so W1 is not comparing two empty strings
  ok   W1c   the accented key is the TWO-CODE-UNIT combining form, which is what the frozen bytes were minted from
  ok   W2    the FROZEN signature verifies against the FROZEN public key
  ok   W3    and the keyId DERIVES from that key alone - no marker, no second representation
  ok   W4    a ONE-FIELD tamper on the frozen vector is REFUSED by name
  ok   W5    and a stripped block is a FAILURE rather than "nothing to check"
  ok   W6    THE SET PATH: the frozen vector verifies against the published set with its key RETIRED and NOT FIRST - the document's own keyId selects, so a certificate issued before a rotation still verifies
  ok   W7    and a set that does NOT hold its key is still key_id_mismatch - the set widens WHO may verify, never WHETHER
  ok   W8    and a tamper is refused through the set exactly as through a single key - a retired key verifies, it does not excuse
  10/10 checks passed

Exit code 0. The known-answer test, check lines only.

Where the key comes from

The issuer's public keys, current and retired, are published by the service and served without an account. A certificate names the id of the key that signed it. The address of the key set is given with access, and anyone you pass it to can use it.

The verifier is the part of the system that needs no other part. Every certificate on this site, from every worked example, is checked by this one file.