Attestation verifier
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/attestationpackage, 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:
- The firm (issuer) — opens its vault, declares which fields of a record
are attested, and calls
vault.issueAttestation(). This is the attestation service, enabled withwithAttestation(). - 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.
- 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
ownerrole can issue.issueAttestationand the first (key-minting) call togetDocumentSigningPublicKeythrowAttestationErrorfor any other role — signing is the firm's identity operation. Once minted, the signing key is stable: every later issuance reuses the samekeyId. - The collection must declare the schema. Calling
issueAttestationon a collection created without{ attestation }throwsAttestationError.
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:
| Verdict | Meaning |
|---|---|
authentic-valid | Signature verifies with a bundled key, every field matches, not revoked. |
authentic-revoked | Genuinely issued by the firm, later withdrawn (on the trusted revocation list). |
altered | Signature is valid but at least one typed value does not match its committed hash — perField localizes which. |
signature-invalid | The payload does not verify against the key it names — forged or corrupted. |
unknown-key | The QR names a keyId this verifier build does not bundle — update the verifier. |
unreadable-qr | The 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:
| Normalizer | Canonical form | Example |
|---|---|---|
trim | trimmed string | ' ACME Co ' → 'ACME Co' |
lower | trimmed, lowercase | 'ACME' → 'acme' |
upper | trimmed, uppercase | 'acme' → 'ACME' |
alnum-upper | alphanumerics only, uppercase | 'inv-1042' → 'INV1042' |
digits | digits only | '+66 2-123-4567' → '6621234567' |
cents | integer of cents | 1234.5 and '฿1,234.50' → '123450' |
iso-date | UTC 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.
publicKeysis a map keyed bykeyIdand 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
verifyDocumentcan still throw on bad claimed values: typing a non-numeric string into acentsfield or an unparseable date into aniso-datefield throws from the normalizer, and omitting a declared field fromclaimedFieldsentirely throws before hashing. Wrap the call intry/catchin 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
alteredwith every field marked non-matching — not as a distinct verdict. - Prefer ISO date strings over
Dateobjects foriso-datefields: the normalizer takes the UTC calendar date, so a local-timeDatenear midnight can shift by a day in non-UTC environments. - Revocation is per attestation, not per record:
revokeAttestationtakes thedocIdreturned byissueAttestation. Re-issuing the same record mints a newdocId.
See also
- attestation service — issue-side reference
(
@noy-db/hub/attestation,withAttestation()). - AWS-KMS PDF attestation — the generation side: render the QR into a server-generated PDF.
- Vaults & collections — what a vault is and how collections are declared.