sealed-record
| Subpath | @noy-db/hub/sealed-record |
| Factory | withSealedRecord() |
| Layer | with-audit |
What it does
Sometimes an outside system needs to read one specific record — and only that record —
without becoming a full member of the vault (an encrypted, per-tenant data space that groups
related collections of records — see Core 01 — Vault and collections).
sealed-record lets a vault owner (the grantor, who holds the collection's shared
data-encryption key) seal a single record's own per-record key to an
external host, so that host can decrypt exactly that record and nothing else: not the
collection key, not any other record.
This builds on per-record keys: when a
collection opts into perRecordKeys: true, every record gets its own AES-256-GCM key (the
CEK, content-encryption key) instead of sharing the collection's key. Sealing hands a
copy of one record's CEK to the host, wrapped ("sealed") so only that host can open it —
the same sealing mechanism the at-* key-provider family uses elsewhere in
noy-db. The host never touches the vault's collection key and cannot decrypt anything the
grantor didn't explicitly seal to it.
Two sides make up the feature:
- Grantor side (
vault.sealRecordToHost,vault.revokeSealedRecord,vault.rotateRecordCek) — lives on the vault, because sealing a CEK requires the collection key to unwrap it first. This is the opt-in capability gated bywithSealedRecord(). - Recipient-host side (
openSealedRecord) — a plain function the host runs. It needs no vault, nocreateNoydbinstance, and is not gated by the opt-in — it's exported for use on a remote host that only has the sealed delivery and its own unsealer.
Prerequisites
perRecordKeys: trueon the collection you seal from — only per-record-key records have a CEK to seal.- The recipient host needs none of this:
openSealedRecordis a plain function taking the sealed envelope — no hub, no strategy, no keys beyond what the envelope carries.
Opt in
The grantor side is opt-in and tree-shaken until you enable it. Pass
sealedRecordStrategy: withSealedRecord() to createNoydb, and open the collection you'll
seal records from with perRecordKeys: true (only per-record-key records have a CEK to seal):
import { createNoydb } from '@noy-db/hub'
import { withSealedRecord } from '@noy-db/hub/sealed-record'
import { toMemory } from '@noy-db/to-memory'
const db = await createNoydb({
store: toMemory(),
user: 'alice',
secret: 'owner-secret-long',
sealedRecordStrategy: withSealedRecord(),
})
const vault = await db.openVault('v')
const docs = vault.collection<{ id: string; secret: string }>('docs', { perRecordKeys: true })
Without withSealedRecord(), sealRecordToHost / revokeSealedRecord / rotateRecordCek
all throw SealedRecordNotEnabledError. The openSealedRecord opener works with or without
opting in — it's a separate, ungated function meant to run on the recipient host, which has
no vault of its own.
Usage
Seal a record to a host
The host publishes a "recipient hint" (e.g. its public key); the grantor uses it to seal the
record's CEK into a delivery envelope written at _sealed_cek/<collection>/<id>/<pid>.
MemoryRecipientSealer is the in-memory RSA-OAEP stand-in used below for a self-contained
example — production hosts (a KMS-backed service, say) implement the same RecipientSealer
interface.
import { MemoryRecipientSealer } from '@noy-db/hub'
await docs.put('d-1', { id: 'd-1', secret: 'the eagle lands at dawn' })
const host = new MemoryRecipientSealer({ id: 'kms:host-A' })
const { pid, envelopeKey } = await vault.sealRecordToHost('docs', 'd-1', host, {
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour
})
{ "pid": "kms:host-A", "envelopeKey": "docs/d-1/kms:host-A" }
opts.expiresAt is required — it's sealed inside the delivery so the host can't extend its
own access by tampering with the outer envelope.
Open the sealed record on the host
The host reads the delivery envelope and the record's encrypted body (however those travel
to it — e.g. exported via the vault's store) and calls openSealedRecord with its own
unsealer:
import { openSealedRecord } from '@noy-db/hub/sealed-record'
// `delivery` = the SealedCekDeliveryEnvelope from _sealed_cek/docs/d-1/kms:host-A
// `recordEnvelope` = the record's stored { _iv, _data } body
const json = await openSealedRecord(delivery, recordEnvelope, host, 'docs', 'd-1')
JSON.parse(json)
{ "id": "d-1", "secret": "the eagle lands at dawn" }
openSealedRecord checks, in order: the delivery's fast-path expiry, then unseals the
binding, then verifies the binding's {collection, id} matches what the host expected
(rejecting a CEK sealed for a different record), then re-checks the binding's own
authoritative expiry, then decrypts. A CEK sealed before a rotateRecordCek() call passes
all of those checks but fails decryption against the rotated body, surfacing as the same
TamperedError a tampered ciphertext produces. That collision is why TamperedError is not read
as an accusation on its own: a stale CEK, a pre-0.6.0-pre.18 record and a hostile store all
arrive at the same error. Check err.reason first.
Revoke access — soft vs. hard
revokeSealedRecord is soft by default: it deletes the delivery envelope so no new host
can fetch it, but a host that already unsealed the CEK keeps the ability to decrypt (there's
no way to un-teach a host something it has already read). Pass { hard: true } to also
rotate the record's CEK, which cuts off every previously sealed copy — including ones already
fetched — because they no longer match the record's (now re-encrypted) body:
// Soft (default): stops future fetches only.
await vault.revokeSealedRecord('docs', 'd-1', 'kms:host-A')
// Hard: also rotates the CEK, so any already-fetched sealed copy stops working.
await vault.revokeSealedRecord('docs', 'd-1', 'kms:host-A', { hard: true })
rotateRecordCek (used internally by hard revoke, and callable directly) re-encrypts the
live record body under a fresh CEK and deletes every sealed-CEK delivery envelope for that
record. Prior history versions keep their original CEK and remain openable by hosts sealed
against them — only the live record is cut over.
API reference
withSealedRecord()
function withSealedRecord(): SealedRecordStrategy
Returns the strategy that makes vault.sealRecordToHost / vault.revokeSealedRecord /
vault.rotateRecordCek live. Pass it as sealedRecordStrategy to createNoydb. The
sealing/rotation engine is dynamically imported inside it, so it's excluded from your bundle
until you opt in.
vault.sealRecordToHost(collection, id, hostSealer, opts)
sealRecordToHost(
collection: string,
id: string,
hostSealer: RecipientSealer,
opts: { expiresAt: string },
): Promise<{ pid: string; envelopeKey: string }>
Seals collection/id's CEK to hostSealer and writes the delivery envelope. expiresAt
(ISO 8601) is required and becomes the authoritative expiry sealed inside the binding.
Returns the host's provider id (pid) and the store key (envelopeKey) the delivery was
written under. Throws RecordCekNotFoundError if the record has no CEK — either it doesn't
exist, or its collection wasn't opened with perRecordKeys: true.
vault.revokeSealedRecord(collection, id, pid, opts?)
revokeSealedRecord(
collection: string,
id: string,
pid: string,
opts?: { hard?: boolean },
): Promise<void>
Deletes the pid delivery envelope for collection/id. Soft by default (see Revoke
access above); { hard: true } additionally rotates the
record's CEK.
vault.rotateRecordCek(collection, id)
rotateRecordCek(collection: string, id: string): Promise<void>
Re-encrypts the live record body under a freshly minted CEK and deletes every sealed-CEK
delivery envelope for the record. Throws RecordCekNotFoundError if the record is missing or
has no CEK.
openSealedRecord(sealedCekEnvelope, recordEnvelope, recipientSealer, expectedCollection, expectedId)
function openSealedRecord(
sealedCekEnvelope: SealedCekDeliveryEnvelope,
recordEnvelope: { readonly _iv: string; readonly _data: string },
recipientSealer: { unseal(bytes: Uint8Array): Promise<Uint8Array> },
expectedCollection: string,
expectedId: string,
): Promise<string>
Runs on the recipient host — not gated by withSealedRecord(). Reconstructs the
record's plaintext (returned as a JSON string) from the delivery envelope, the record's
encrypted { _iv, _data } body, and the host's own unsealer. Throws
SealedRecordExpiredError or SealedRecordMismatchError on the checks described in
Usage; a CEK that unseals but no longer matches the
record's ciphertext (e.g. after rotation) surfaces as TamperedError.
Types
| Type | Shape | Purpose |
|---|---|---|
SealedCekDeliveryEnvelope | { v: 1, _noydb_sealed_cek: 1, pid: string, payload: string, expiresAt: string } | The thin envelope written to _sealed_cek/<collection>/<id>/<pid>. payload is the opaque sealed binding; expiresAt here is a fast-path hint only. |
SealedCekBinding | { collection: string, id: string, cek: string, expiresAt: string } | The plaintext struct sealed inside payload. This — not the delivery envelope — is the security boundary: collection/id bind the CEK to one record, and this expiresAt is authoritative. |
Errors
| Error | Thrown when |
|---|---|
SealedRecordNotEnabledError | A grantor-side method is called without sealedRecordStrategy: withSealedRecord(). |
RecordCekNotFoundError | sealRecordToHost / rotateRecordCek target a record with no CEK (missing, or its collection lacks perRecordKeys: true). |
SealedRecordExpiredError | openSealedRecord's fast-path or authoritative expiresAt check fails. |
SealedRecordMismatchError | The unsealed binding's {collection, id} doesn't match what the host expected — a CEK sealed for one record presented against another. |
Limits & gotchas
- Requires
perRecordKeys: true. Legacy records (body encrypted directly under the collection key) have no CEK to seal —sealRecordToHostthrowsRecordCekNotFoundError. Sealing deliberately never exposes the shared collection key itself. - Soft revoke doesn't reach already-fetched copies. If a host unsealed the CEK before you
revoke, deleting the delivery envelope doesn't stop it from decrypting the record it
already has. Use
{ hard: true }when a leaked or misbehaving host must actually lose access. - Hard revoke / rotation only cuts off the live record. History versions keep their original CEK; a host previously sealed against a specific history version can still open that version after the live record is rotated.
expiresAthas two copies for a reason. The clear-text copy on the delivery envelope is only a cheap pre-unseal reject; the copy inside the sealed binding is the one that can't be forged by editing the envelope. Both are checked, and both fail closed on a malformed or empty timestamp.
See also
- Enclave (Core 02) — per-record keys (CEK), the collection DEK, and the envelope format this service builds on.
at-*family — sealing-key providers a productionRecipientSealerwould be built on.- custody — a related sealing pattern for handing off an entire vault's ownership rather than a single record.
- Services overview · Orientation map
- Proven by showcase
133-sealed-record.