AWS-KMS attestation
Audience: teams that issue PDF documents (invoices, certificates, statements) and want to render the attestation QR server-side, sealed under a key held in AWS KMS. Bundle: core +
withAttestation()(@noy-db/hub/attestation) +@noy-db/at-aws-kms(atAwsKms) — the render Lambda itself is hub-free.
Verified by: showcases/src/recipe-aws-kms-pdf-attestation.recipe.test.ts — the CI-safe generation-side slice of this recipe.
You issue documents — invoices, certificates, statements — and want anyone holding a printed or forwarded copy to check, fully offline, that it is authentic and unaltered. noy-db's attestation service covers the trust part: the issuing firm signs a per-field commitment over the document's declared fields and encodes it as a QR credential. This recipe is the generation half: a small, deployable AWS Lambda that takes a KMS-encrypted document record from S3, decrypts it, and renders an HTML invoice to PDF with the attestation QR embedded as vector SVG — so the QR survives print and rescan. It pairs with the offline verifier recipe, which checks the QR with no server involved.
Along the way it exercises sealing — encrypting a payload under a key held
by infrastructure you control, here AWS KMS via
@noy-db/at-aws-kms. The Lambda itself never opens a noy-db
vault: it only decrypts one small render payload per request.
recipes/aws-kms-pdf-attestation;
copy what you need into your project. The published packages it builds on
(@noy-db/hub, @noy-db/at-aws-kms, @noy-db/attestation) are on npm.What it shows
- Issue (hub):
vault.issueAttestation()— the firm mints the signed QR credential for a record. Opt-in viawithAttestation(). - Seal (at-aws-kms):
atAwsKmsfrom@noy-db/at-aws-kmsKMS-encrypts the render payload{ docId, fields, qr }before it touches S3. - Render (hub-free Lambda): S3
GetObject→ KMSDecrypt→ HTML with an inline-SVG QR → headless Chromium (puppeteer-core+@sparticuz/chromium) → PDF. - Verify (third party): the embedded QR still verifies offline via the
verifier recipe's
verifyDocument().
Data flow
- Issue (firm, hub):
vault.issueAttestation('invoices', id)→{ docId, qr, keyId, publicKeyB64 }. - Seal (firm):
sealAndUpload({ docId, fields, qr })— KMS Encrypt → S3docs/<docId>. - Render (Lambda):
GET <function-url>?d=<docId>&exp=…&sig=…→ verify the signed link → S3 GetObject → KMS Decrypt → PDF with the vector QR. - Verify (third party, offline): scan the QR on the PDF →
verifyDocument()→authentic-valid.
Step 1 — Issue the attestation (firm side, hub)
The firm declares which fields the QR commits to (an attestation
field-schema), writes the record, and mints the credential. Normalizers make
the stored value and what a human later reads off the paper compare equal
(1234.5 and "1234.50" both normalize to the same cents value).
import { createNoydb } from '@noy-db/hub'
import { withAttestation } from '@noy-db/hub/attestation'
import { toMemory } from '@noy-db/to-memory'
import type { AttestationFieldSchema } from '@noy-db/attestation'
const attestation: AttestationFieldSchema = {
fields: [
{ path: 'invoiceNo', normalize: 'alnum-upper' },
{ path: 'total', normalize: 'cents' },
{ path: 'issueDate', normalize: 'iso-date' },
],
}
const db = await createNoydb({
store: toMemory(),
user: 'firm',
secret: 'firm-pass-2026',
attestationStrategy: withAttestation(), // attestation is opt-in
})
const vault = await db.openVault('books')
await vault
.collection('invoices', { attestation })
.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": "01JWK4Y4B7Q6…",
"qr": "eyJ2IjoxLCJkb2NJZCI6IjAxSldLNFk0…",
"keyId": "k1_Zm5vcXAx…",
"publicKeyB64": "hV3nJq0T…"
}
qr is a base64url-encoded JSON payload
{ v, docId, salt, alg: 'ed25519', keyId, fieldHashes, sig } — salted
per-field hashes plus the firm's Ed25519 signature. keyId is a fingerprint
of the signing public key, so the verifier can pick the right key after
rotation. publicKeyB64 (also available later via
vault.getDocumentSigningPublicKey()) is what you hand to verifiers.
Step 2 — Seal and upload the render payload
The firm KMS-encrypts (seals) the small render payload and puts the ciphertext
in S3. This is @noy-db/at-aws-kms in action — the same
atAwsKms used to seal vault secrets at a trusted host:
import { sealAndUpload } from '@noy-db/recipe-aws-kms-pdf-attestation/seal'
import type { RenderPayload } from '@noy-db/recipe-aws-kms-pdf-attestation'
const payload: RenderPayload = {
docId,
qr,
fields: { invoiceNo: 'INV-1042', total: 1234.5, issueDate: '2026-05-29' },
}
await sealAndUpload(payload, {
keyId: 'arn:aws:kms:eu-west-1:123456789012:key/abc',
bucket: 'firm-docs',
key: `docs/${docId}`,
})
// → S3 object docs/<docId> now holds the KMS ciphertext; AWS credentials
// come from the SDK's ambient chain (IAM role, env vars, ~/.aws/credentials)
Under the hood it is three lines: atAwsKms({ keyId }).seal(bytes)
→ PutObjectCommand. The payload is capped at 4 KB (the AWS KMS Encrypt
plaintext limit); encodeRenderPayload throws past that. Attestation payloads
(declared fields + QR string) are normally far under it.
Step 3 — The render Lambda
The deployed handler is hub-free — it never opens a vault. Per request it:
- verifies the signed share link (see below) — otherwise 403;
GetObjectondocs/<docId>— missing → 404;- KMS
Decrypt→decodeRenderPayload→buildInvoiceHtml(QR as inline<svg>, drawn withqrcode) →renderPdfvia headless Chromium; - returns the PDF:
{
"statusCode": 200,
"headers": { "content-type": "application/pdf" },
"isBase64Encoded": true,
"body": "JVBERi0xLjQK…"
}
Configuration is four environment variables: DOCS_BUCKET, KMS_KEY_ID,
DOCS_PREFIX (default docs), and SHARE_SECRET_ARN (a Secrets Manager ARN;
the secret value is fetched once per cold start and cached). The handler
factory makeHandler(deps) takes the S3/KMS clients and renderPdf as
injected dependencies, so the whole flow unit-tests with mocks and no AWS.
Step 4 — Verify the QR offline
Anyone with the PDF (or a print of it) recomputes the commitment from what they can read on the paper and checks the signature — no network, no firm involvement:
import { verifyDocument } from '@noy-db/recipe-attestation-verifier'
const printed = { invoiceNo: 'INV-1042', total: '1234.50', issueDate: '2026-05-29' }
const verdict = await verifyDocument(qr, printed, {
publicKeys: { [keyId]: publicKeyB64 },
fieldSchema: attestation,
})
verdict.outcome // → 'authentic-valid'
Other outcomes: altered (with the differing field localized),
signature-invalid, authentic-revoked, unknown-key, unreadable-qr. The
full verifier — including a self-contained static HTML page — is the
offline verifier recipe.
Gating the endpoint — stateless signed share links
The render endpoint returns decrypted document content, so it must not be
open. Instead of an AWS authorizer, Cognito, or any identity provider, the
Lambda gates itself with a stateless signed magic link: an HMAC-SHA256
over canonicalJson({ v, docId, exp }) keyed by a Secrets Manager secret.
import { mintShareLink } from '@noy-db/recipe-aws-kms-pdf-attestation/share-link'
// The share-signing secret is an ASCII string (here from Secrets Manager or an
// env var); its utf8 bytes are the HMAC key — mint and verify must encode it
// identically.
const shareSecret = new TextEncoder().encode(process.env.SHARE_SECRET!)
const url = await mintShareLink(docId, {
secret: shareSecret,
baseUrl: 'https://abc123.lambda-url.eu-west-1.on.aws/',
ttlMs: 60 * 60 * 1000, // optional; default 24 h, capped at 7 d
})
// → 'https://abc123.lambda-url.eu-west-1.on.aws/?d=01JWK4Y4B7Q6…&exp=1751975432000&sig=tJ9vJq…'
On the Lambda side, verifyShareToken checks the signature in constant time
and returns either { ok: true, docId } or { ok: false, reason } with
reason one of missing-token / malformed / expired /
invalid-signature — each rejected with 403. A bare ?docId= request is
missing-token: there is no unauthenticated path. Properties to be aware of:
- Links are bearer capabilities: multi-use within their TTL, safe to forward by design.
- Revocation = rotate the share secret — every live link is invalidated at once. Rotating the share secret never touches the KMS data key, so document access and link revocation stay independent.
- The Lambda Function URL stays
authType: NONEat the AWS layer — the Lambda itself is the gate.
Trust model and limits
- The QR carries the signed per-field commitment; the PDF is generated, never verified server-side. Trust lives in the offline verification, not in the rendering endpoint.
- The KMS key holder (your AWS account) can decrypt the render payloads —
that is the deliberate
at-*trust boundary. The payload is only the declared fields plus the QR, not the vault. - Render payloads are limited to 4 KB by KMS
Encrypt; envelope encryption for larger payloads is out of this recipe's scope. renderPdfneeds a Chromium binary, so the PDF step only runs deployed (or locally with Chromium available) — not in plain CI.
Deploying and testing
The recipe ships a CDK stack (infra/) that provisions the whole thing with
least-privilege grants: a rotation-enabled KMS key, a private S3 bucket, a
Secrets Manager–generated share secret (256-bit, never present in the
template), and the render Lambda as an arm64 zip function + public Chromium
Lambda layer — ~1.7 MB artifact, no Docker or ECR in the deploy path (a
container variant is included for air-gapped setups). Step-by-step deploy,
live verification, and teardown are in the recipe's
RUNBOOK.
The data path — issue → seal → decrypt → render HTML → offline verify — is
covered by an AWS-free test slice in the repository's test suite (mock KMS and
S3 clients, no Chromium); the recipe's own pnpm test exercises each module
the same way, with the S3/KMS clients injected as mocks.
See also
- attestation service — the issue/revoke API inside the hub
- Offline document-attestation verifier — the other half
@noy-db/at-*family — sealing-key providers and their trust boundary