stable

Attestation verifier

Issue a signed per-field commitment as a QR code, then verify it fully offline against the firm's published Ed25519 key — six verdicts, revocation included.

Audience: issuers of printed or forwarded documents (invoices, certificates, receipts) who want holders to verify authenticity fully offline — no server contacted, no document contents ever transmitted. Bundle: core + withAttestation() (issuer side, @noy-db/hub/attestation) — the offline verifier itself uses the separate zero-dependency @noy-db/attestation package, no vault required.

Verified by: showcases/src/recipe-attestation-verifier.recipe.test.ts — the runnable form of this recipe.

This recipe verifies that a printed or forwarded document — an invoice, a certificate, a receipt — is authentic and unaltered, fully offline: no server is contacted during verification, and no server ever holds or returns the document's contents. The issuing firm keeps its records in a noy-db vault — an end-to-end-encrypted document store (see Vaults & collections) — and mints a signed, per-field commitment that travels inside a QR code on the document itself. Anyone holding the paper recomputes the commitment from the values they can read off it and checks the firm's Ed25519 signature against a public key bundled into the verifier — a single static HTML page with no backend.

Three parties are involved, and only the first one runs noy-db:

  1. The firm (issuer) — opens its vault, declares which fields of a record are attested, and calls vault.issueAttestation(). This is the attestation service, enabled with withAttestation().
  2. The document — carries the QR. The QR contains no field values, only salted hashes of them plus the signature: holding the QR alone reveals nothing.
  3. The verifier (third party) — a static page or script that imports only @noy-db/attestation, a pure, zero-dependency package that runs in Node and the browser. No vault, no keys to protect, no network.

Issue a signed QR (firm side)

The firm declares an attestation field schema on the collection: which record fields are committed, and how each value is normalized before hashing (so "1,234.50" typed by a human later matches the stored number 1234.5 — see Field normalizers).

import { createNoydb } from '@noy-db/hub'
import { withAttestation } from '@noy-db/hub/attestation'

interface Invoice { id: string; invoiceNo: string; total: number; issueDate: string }

const attestation = {
  fields: [
    { path: 'invoiceNo', normalize: 'alnum-upper' as const },
    { path: 'total',     normalize: 'cents'       as const },
    { path: 'issueDate', normalize: 'iso-date'    as const },
  ],
}

const db = await createNoydb({
  user: 'firm',                            // omitting `store` uses the built-in in-memory store;
  secret: 'firm-secret-2026',          // pass to-file / to-postgres / … in production
  attestationStrategy: withAttestation(),  // opt-in: attestation stays out of the bundle otherwise
})
const vault = await db.openVault('books')

const invoices = vault.collection<Invoice>('invoices', { attestation })
await invoices.put('inv-1', {
  id: 'inv-1', invoiceNo: 'INV-1042', total: 1234.5, issueDate: '2026-05-29',
})

const { docId, qr, keyId } = await vault.issueAttestation('invoices', 'inv-1')
// → docId : '01JZK7Y1Q2M4N8R3T5V7X9ZABC'  — fresh 26-char id of THIS attestation (used for revocation)
// → qr    : 'eyJ2IjoxLCJkb2NJZCI6...'     — base64url payload; render it as a QR on the document
// → keyId : 'bd7e8268071ac41f'            — which signing key produced the signature

const { publicKeyB64 } = await vault.getDocumentSigningPublicKey()
// → { keyId: 'bd7e8268071ac41f', publicKeyB64: 'hpbs5UQivCLgPolFRoj5h1Q12OCXFlZkZfff-mEiAPM' }

Two rules the hub enforces on this side:

  • Only the owner role can issue. issueAttestation and the first (key-minting) call to getDocumentSigningPublicKey throw AttestationError for any other role — signing is the firm's identity operation. Once minted, the signing key is stable: every later issuance reuses the same keyId.
  • The collection must declare the schema. Calling issueAttestation on a collection created without { attestation } throws AttestationError.

The values are hashed from the canonical stored record (independent of any display locale), each issuance uses a fresh random salt, and an encrypted index entry is kept in the vault so the firm can revoke later.

To render the QR into a generated PDF server-side, see the companion recipe AWS-KMS PDF attestation — it is the generation side only and is not required to verify.

What travels in the QR

The qr string is base64url-encoded JSON. Decoded with decodeQr():

{
  "v": 1,
  "docId": "01JZK7Y1Q2M4N8R3T5V7X9ZABC",
  "salt": "fjFzQREHeuT_sow-L2GkgA",
  "alg": "ed25519",
  "keyId": "bd7e8268071ac41f",
  "fieldHashes": ["WsR5Ol9nkUCpjbDNn47Z5mMAaM1…", "PUOQQylsmF2G1m069L8ojsFv8iQ…", "hBUvDo8iEFc8HPDzdmMoxJF18lD…"],
  "sig": "0ghYQP_bd5O6qDth33-Xz08Q7_JbL96zX4HfVPKLYP8t…"
}

One SHA-256 hash per schema field, in schema order — never the values themselves. Each hash covers the per-document salt, the field path, and the normalized value, so a low-entropy value cannot be brute-forced across documents and two fields sharing a value still hash differently. The signature covers {v, docId, salt, keyId, fieldHashes}.

The offline verifier

The verify side composes three functions from @noy-db/attestation — decodeQr, verifyAttestation, and verifyRevocationList — into a single verifyDocument() that always returns one of six verdicts instead of throwing on bad input. This is the complete verifier core:

import {
  decodeQr, verifyAttestation, verifyRevocationList,
  type AttestationFieldSchema, type QrPayload, type RevocationList,
} from '@noy-db/attestation'

export interface VerifierConfig {
  publicKeys: Record<string, string>   // keyId → publicKeyB64, bundled at build time
  fieldSchema: AttestationFieldSchema  // same schema the firm declared
  revocationList?: RevocationList      // optional, signed by the firm
}

export type VerifierOutcome =
  | 'authentic-valid'
  | 'authentic-revoked'
  | 'altered'
  | 'signature-invalid'
  | 'unknown-key'
  | 'unreadable-qr'

export interface Verdict {
  outcome: VerifierOutcome
  perField: Array<{ path: string; match: boolean }>
  revocationTrusted: boolean | null    // null: no list · false: list failed its own signature
  keyId?: string
  docId?: string
}

export async function verifyDocument(
  qr: string,
  claimedFields: Record<string, unknown>,
  config: VerifierConfig,
): Promise<Verdict> {
  let payload: QrPayload
  try {
    payload = decodeQr(qr)
  } catch {
    return { outcome: 'unreadable-qr', perField: [], revocationTrusted: null }
  }

  const matchedKey = config.publicKeys[payload.keyId]
  if (matchedKey === undefined) {
    return { outcome: 'unknown-key', perField: [], revocationTrusted: null, keyId: payload.keyId, docId: payload.docId }
  }

  // The revocation list is untrusted input (often network-fetched): verify its
  // OWN signature first, and only honor it if that check passes.
  let revocationTrusted: boolean | null = null
  if (config.revocationList) {
    revocationTrusted = await verifyRevocationList(config.revocationList, matchedKey)
  }

  const result = await verifyAttestation({
    qr,
    claimedFields,
    fieldSchema: config.fieldSchema,
    publicKeys: config.publicKeys,
    ...(revocationTrusted === true && config.revocationList ? { revocation: { list: config.revocationList } } : {}),
  })

  const perField = result.perField.map((f) => ({ path: f.path, match: f.match }))
  const allMatch = perField.length > 0 && perField.every((f) => f.match)

  let outcome: VerifierOutcome
  if (!result.signatureValid) outcome = 'signature-invalid'
  else if (!allMatch) outcome = 'altered'
  else if (result.revoked === true) outcome = 'authentic-revoked'
  else outcome = 'authentic-valid'

  return { outcome, perField, revocationTrusted, keyId: payload.keyId, docId: payload.docId }
}

The six verdicts:

VerdictMeaning
authentic-validSignature verifies with a bundled key, every field matches, not revoked.
authentic-revokedGenuinely issued by the firm, later withdrawn (on the trusted revocation list).
alteredSignature is valid but at least one typed value does not match its committed hash — perField localizes which.
signature-invalidThe payload does not verify against the key it names — forged or corrupted.
unknown-keyThe QR names a keyId this verifier build does not bundle — update the verifier.
unreadable-qrThe scanned/pasted text is not a valid attestation payload.

Run a verification

The third party scans the QR and types the values as printed on the paper:

const config = {
  publicKeys: { [keyId]: publicKeyB64 },  // published by the firm, bundled at build time
  fieldSchema: attestation,               // the same three-field schema
}

// A human reads "1,234.50" off the printed invoice. The firm stored the
// number 1234.5 — the 'cents' normalizer canonicalizes both to the same
// integer of cents, so the commitment matches with no float math.
const printed = { invoiceNo: 'INV-1042', total: '1234.50', issueDate: '2026-05-29' }

const verdict = await verifyDocument(qr, printed, config)
{
  "outcome": "authentic-valid",
  "perField": [
    { "path": "invoiceNo", "match": true },
    { "path": "total", "match": true },
    { "path": "issueDate", "match": true }
  ],
  "revocationTrusted": null,
  "keyId": "bd7e8268071ac41f",
  "docId": "01JZK7Y1Q2M4N8R3T5V7X9ZABC"
}

Tamper with a single value and the verdict flips to altered, naming the field:

const tampered = await verifyDocument(qr, { ...printed, total: '9999.00' }, config)
// → tampered.outcome                                          === 'altered'
// → tampered.perField.find((f) => f.path === 'total')?.match  === false
// → tampered.perField.find((f) => f.path === 'invoiceNo')?.match === true

Revocation — "is it still valid today?"

A signature never expires, so the firm answers "still valid?" with a signed revocation list. Firm side:

await vault.revokeAttestation(docId)          // vault.unrevokeAttestation(docId) undoes it
const list = await vault.publishRevocationList()
{
  "v": 1,
  "revokedDocIds": ["01JZK7Y1Q2M4N8R3T5V7X9ZABC"],
  "asOf": "2026-05-29T00:00:00.000Z",
  "keyId": "bd7e8268071ac41f",
  "sig": "JzGDN88XzGYB6Im0NC-XLUVAUEjQTHCqqyOhuoT72sFU…"
}

The list is signed with the same document-signing key, so the verifier can trust a copy from anywhere — bundle it at build time or fetch it when online. Pass it as revocationList in the config; a revoked document then reads:

const v = await verifyDocument(qr, printed, { ...config, revocationList: list })
// → v.outcome === 'authentic-revoked'   (issued by the firm, since withdrawn)

If the list fails its own signature check, revocationTrusted is false, the list is ignored, and an otherwise-authentic document stays authentic-valid — a UI should downgrade the wording ("revocation status could not be confirmed") but a bad list can never flip a real authenticity pass into a failure, and never hide a signature or field mismatch.

Field normalizers

Both sides normalize a value to a canonical string before hashing, with the same function — so a value presented identically on both sides always round-trips. The seven built-in normalizers:

NormalizerCanonical formExample
trimtrimmed string' ACME Co ' → 'ACME Co'
lowertrimmed, lowercase'ACME' → 'acme'
uppertrimmed, uppercase'acme' → 'ACME'
alnum-upperalphanumerics only, uppercase'inv-1042' → 'INV1042'
digitsdigits only'+66 2-123-4567' → '6621234567'
centsinteger of cents1234.5 and '฿1,234.50' → '123450'
iso-dateUTC calendar date'2026-05-29' → '2026-05-29'

Trust model

  • The trust anchor is bundled, not fetched. The verifier ships the firm's public key(s) and the field schema inside its build — there is nothing to trust at verification time. publicKeys is a map keyed by keyId and each QR names the key that signed it, so one verifier build can bundle several firm keys (or several firms) and route each document to the right one.
  • The QR is not a data leak. It carries salted hashes and a signature — no field values. Verification requires the paper (or a copy of the values).
  • Verification proves issuance, not truth. A green verdict means "the firm signed exactly these values for this document" — nothing more.

Limits & gotchas

  • verifyDocument can still throw on bad claimed values: typing a non-numeric string into a cents field or an unparseable date into an iso-date field throws from the normalizer, and omitting a declared field from claimedFields entirely throws before hashing. Wrap the call in try/catch in a UI and render a "could not verify" state.
  • The verifier's schema must match the issuing schema exactly (same fields, same order). A field-count mismatch is reported as altered with every field marked non-matching — not as a distinct verdict.
  • Prefer ISO date strings over Date objects for iso-date fields: the normalizer takes the UTC calendar date, so a local-time Date near midnight can shift by a day in non-UTC environments.
  • Revocation is per attestation, not per record: revokeAttestation takes the docId returned by issueAttestation. Re-issuing the same record mints a new docId.

See also