stable

attestation

Issue signed, per-field commitments for vault records as QR credentials anyone can verify fully offline — plus revocation with a signed, publishable list.
Subpath@noy-db/hub/attestation
FactorywithAttestation()
Layerwith-audit

What it does

Attestation lets a vault owner prove that a document — an invoice, a certificate, a receipt — is authentic and unaltered, without running a verification server and without revealing the vault's data. A vault is noy-db's end-to-end-encrypted document store (see Vaults & collections); this service adds an issue side to it: vault.issueAttestation() mints a per-field commitment — one salted SHA-256 hash per declared field, so each hash proves a value without revealing it — signs the set with the vault's Ed25519 document-signing key, and packs the result into a compact string you render as a QR code on the document itself.

The verify side is the standalone, dependency-free @noy-db/attestation package: anyone holding the paper recomputes the hashes from the values they can read off it and checks the signature against the issuer's published public key — fully offline, no noy-db, no network. The service also maintains a revocation set (revokeAttestation / unrevokeAttestation) and can publish it as a signed revocation list verifiers can check against. Like every opt-in service, the issue/revoke engines are dynamically imported and stay out of your bundle until you opt in.

Prerequisites

  • An attestation schema on the collection — the issuer side signs only fields the collection-level schema declares.
  • A published Ed25519 public key — verifiers check signatures against it, so you must publish it somewhere verifiers can fetch out-of-band (a well-known URL, a QR poster). The verifying party itself needs none of this page: only the zero-dependency @noy-db/attestation package, the field schema, and that key.

Opt in

Pass the factory to createNoydb, then declare which fields are attestable per collection with the attestation option:

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(),
})
const vault = await db.openVault('books')
const invoices = vault.collection<Invoice>('invoices', { attestation })

Each field spec pairs a dot-path with a normalizer — a canonicalization rule applied identically on issue and on verify, so the human-typed "1,234.50" later matches the stored number 1234.5 (see the normalizer table).

Usage

Issue a QR credential

await invoices.put('inv-1', {
  id: 'inv-1', invoiceNo: 'INV-1042', total: 1234.5, issueDate: '2026-05-29',
})

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

The first issuance lazily mints the vault's Ed25519 document-signing keypair and persists it encrypted inside the vault; every later issuance reuses it, so keyId stays stable. Publish the public key wherever verifiers can fetch it once (your website, the verifier page itself):

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

Verify — offline, without noy-db

Verification needs only @noy-db/attestation, the field schema, and the published public key. It recomputes each field hash from the claimed values and checks the signature:

import { verifyAttestation } from '@noy-db/attestation'

const result = await verifyAttestation({
  qr,                                                    // scanned from the document
  claimedFields: { invoiceNo: 'INV-1042', total: 1234.5, issueDate: '2026-05-29' },
  fieldSchema: attestation,
  publicKeys: { 'bd7e8268071ac41f': 'hpbs5UQivCLgPolFRoj5h1Q12OCXFlZkZfff-mEiAPM' },
})
{
  "valid": true,
  "signatureValid": true,
  "perField": [
    { "path": "invoiceNo", "match": true },
    { "path": "total", "match": true },
    { "path": "issueDate", "match": true }
  ],
  "revoked": null
}

revoked is null because no revocation list was supplied. A tampered value flips its perField entry (and valid) to false with reason: 'field mismatch'; an unknown keyId yields reason: 'unknown keyId'. For a complete static-HTML verifier page, see the offline verifier recipe.

Revoke and publish a revocation list

Revocation is a membership set keyed by docId, kept encrypted in the vault and published as a signed list:

await vault.revokeAttestation(docId)

await vault.getRevokedDocIds()
// → ['01JZK7Y1Q2M4N8R3T5V7X9ZABC']

const list = await vault.publishRevocationList()
{
  "v": 1,
  "revokedDocIds": ["01JZK7Y1Q2M4N8R3T5V7X9ZABC"],
  "asOf": "2026-07-08T09:30:00.000Z",
  "keyId": "bd7e8268071ac41f",
  "sig": "0ghYQP_bd5O6qDth33-Xz08Q7_JbL96z…"
}

Host that JSON anywhere (it contains only opaque ids). Verifiers first check the list's own signature, then pass it into verifyAttestation:

import { verifyRevocationList, verifyAttestation } from '@noy-db/attestation'

const trusted = await verifyRevocationList(list, publicKeyB64)
// → true

const r = await verifyAttestation({ qr, claimedFields, fieldSchema, publicKeys, revocation: { list } })
// → { valid: false, signatureValid: true, perField: [...all true], revoked: true, reason: 'revoked' }

vault.unrevokeAttestation(docId) removes an id from the set again.

API reference

Vault methods (enabled by withAttestation())

MethodReturnsPurpose
vault.issueAttestation(collection, id){ docId, qr, keyId, publicKeyB64 }Mint and sign a per-field commitment for a stored record; owner role only.
vault.getDocumentSigningPublicKey(){ keyId, publicKeyB64 }Read the vault's signing public key. Any role may read an existing key; only the owner may trigger the first-time mint.
vault.revokeAttestation(docId)voidAdd an issued attestation to the revocation set; owner only. Throws AttestationError if docId was never issued by this vault.
vault.unrevokeAttestation(docId)voidRemove an id from the revocation set; owner only.
vault.getRevokedDocIds()string[]The current revoked ids, sorted.
vault.publishRevocationList()RevocationListSign the current set as a publishable { v, revokedDocIds, asOf, keyId, sig } document; owner only.

attestation

vault.collection(name, { attestation }) registers the field schema (AttestationFieldSchema): { fields: [{ path, normalize }, …] }. Issuing against a collection without a declared schema throws AttestationError.

normalize

Each declared field's value is canonicalized before hashing — the same rule on issue and verify:

NormalizerCanonical form
trimString(value).trim()
lower / uppertrimmed, lower/upper-cased
alnum-upperstrip non-alphanumerics, upper-case ('inv-1042' → 'INV1042')
digitskeep digits only
centsamount as integer cents; strings may carry currency symbols/commas ('฿1,234.50' → '123450')
iso-dateUTC calendar date, YYYY-MM-DD

Verifier surface (re-exported)

The subpath re-exports the pure verifier so issue-side code can verify from one import; verifiers that never touch noy-db install @noy-db/attestation directly:

ExportPurpose
verifyAttestation(input)Full check: signature + per-field match + optional revocation → VerifyResult.
decodeQr(s)Decode/validate a scanned QR string into a QrPayload; throws on malformed input.
verifyRevocationList(list, publicKeyB64)Check a fetched revocation list's own signature → boolean.
isRevoked(docId, list)Membership test against an already-verified list.
signRevocationList(ids, asOf, keyId, privateKeyPkcs8B64)Low-level list signing (what publishRevocationList uses).
TypesQrPayload, AttestationFieldSchema, VerifyInput, VerifyResult, RevocationList.

Errors and constants

  • AttestationNotEnabledError — any of the six vault methods called without attestationStrategy: withAttestation().
  • AttestationError — wrong role, missing schema, missing source record or field value, unknown docId on revoke.
  • ATTESTATIONS_COLLECTION ('_attestations') — the internal encrypted collection holding the signer, the revocation set, and one index entry per issued attestation.

Lower-level building blocks (issueAttestationCore, revokeDocCore, unrevokeDocCore, getRevokedDocIdsCore, publishRevocationListCore and their context types) are exported for advanced embedding; typical applications use the vault methods above.

Behavior when NOT opted in

All six vault methods throw AttestationNotEnabledError directing you to attestationStrategy: withAttestation(). The collection-level attestation schema option is still accepted (it only registers the schema), so you can declare schemas before enabling the capability.

Limits & gotchas

  • Owner-only signing. Issuing, revoking, and the first (key-minting) read of the public key require the owner role — signing is the vault owner's identity operation. In a single-user vault the creator is the owner; other roles can read an existing public key (it is not secret) but cannot issue or revoke.
  • The QR commits values, it does not carry them. Verifiers must read the claimed values off the document itself; a hash mismatch only says this field differs, never what the original value was.
  • Trust anchors on key distribution. Verification is only as strong as how verifiers obtained publicKeyB64 — bundle it into the verifier or serve it from a channel the issuer controls.
  • Verify the revocation list before trusting it. verifyAttestation performs a membership check only; call verifyRevocationList on a fetched list first. A stale or truncated list lets a revoked document pass — that freshness risk is the verifier's.
  • iso-date normalizes to the UTC calendar date. A local-time Date near midnight can shift by a day; prefer ISO date strings ('2026-05-29') on both sides.
  • Fresh salt per issuance. Re-issuing the same record yields a different QR and a new docId; two QRs for identical values cannot be correlated, and revocation is per-issuance, not per-record.
  • Issuance hashes the canonical stored record, independent of any display locale — verifiers must claim the stored (raw) values, not a locale-resolved presentation.

See also