stable

sealed-record

Seal a single record's decryption key to an external host (e.g.
Subpath@noy-db/hub/sealed-record
FactorywithSealedRecord()
Layerwith-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 by withSealedRecord().
  • Recipient-host side (openSealedRecord) — a plain function the host runs. It needs no vault, no createNoydb instance, 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: true on the collection you seal from — only per-record-key records have a CEK to seal.
  • The recipient host needs none of this: openSealedRecord is 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.

Sealing ceremony — grantor side
Record d-1
its envelope carries _cek — the record’s key, wrapped under the collection DEK
AES-KW unwrap under the collection DEK
▼
Binding
{ collection, id, cek, expiresAt }
sealed under the host’s published RSA-OAEP public key
▼
Delivery envelope
written to _sealed_cek/docs/d-1/kms:host-A — opaque to everyone but the host
The collection DEK — and every other record’s CEK — never leaves the grantor side.

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.

Unlock ceremony — host side
Delivery envelope arrives
sealed payload + clear-text expiresAt hint
1 · fast-path expiry check — expired → SealedRecordExpiredError
2 · host’s own unsealer opens payload
▼
Binding
{ collection, id, cek, expiresAt }
3 · {collection, id} must name the record being opened — mismatch → SealedRecordMismatchError
4 · authoritative expiresAt re-check — expired → SealedRecordExpiredError
5 · CEK decrypts { _iv, _data } — a pre-rotation CEK fails the GCM tag → TamperedError
▼
Plaintext record
exactly this one record — nothing else unlocks
The vault’s collection DEK is never present on this side.

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

TypeShapePurpose
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

ErrorThrown when
SealedRecordNotEnabledErrorA grantor-side method is called without sealedRecordStrategy: withSealedRecord().
RecordCekNotFoundErrorsealRecordToHost / rotateRecordCek target a record with no CEK (missing, or its collection lacks perRecordKeys: true).
SealedRecordExpiredErroropenSealedRecord's fast-path or authoritative expiresAt check fails.
SealedRecordMismatchErrorThe 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 — sealRecordToHost throws RecordCekNotFoundError. 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.
  • expiresAt has 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