attestation
| Subpath | @noy-db/hub/attestation |
| Factory | withAttestation() |
| Layer | with-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
attestationschema 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/attestationpackage, 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())
| Method | Returns | Purpose |
|---|---|---|
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) | void | Add an issued attestation to the revocation set; owner only. Throws AttestationError if docId was never issued by this vault. |
vault.unrevokeAttestation(docId) | void | Remove an id from the revocation set; owner only. |
vault.getRevokedDocIds() | string[] | The current revoked ids, sorted. |
vault.publishRevocationList() | RevocationList | Sign 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:
| Normalizer | Canonical form |
|---|---|
trim | String(value).trim() |
lower / upper | trimmed, lower/upper-cased |
alnum-upper | strip non-alphanumerics, upper-case ('inv-1042' → 'INV1042') |
digits | keep digits only |
cents | amount as integer cents; strings may carry currency symbols/commas ('฿1,234.50' → '123450') |
iso-date | UTC 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:
| Export | Purpose |
|---|---|
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). |
| Types | QrPayload, AttestationFieldSchema, VerifyInput, VerifyResult, RevocationList. |
Errors and constants
AttestationNotEnabledError— any of the six vault methods called withoutattestationStrategy: withAttestation().AttestationError— wrong role, missing schema, missing source record or field value, unknowndocIdon 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
ownerrole — 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.
verifyAttestationperforms a membership check only; callverifyRevocationListon a fetched list first. A stale or truncated list lets a revoked document pass — that freshness risk is the verifier's. iso-datenormalizes to the UTC calendar date. A local-timeDatenear 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
- Offline document-attestation verifier — the complete verify-side recipe (static HTML page, six verdict states)
- AWS-KMS PDF attestation — rendering the QR into a generated PDF server-side
- Vaults & collections · Permissions & keyring
- Services overview · Orientation map
- Proven by showcase
89-attestation-revocation.