/** * RecordCodec — the per-record envelope build + encrypt/decrypt + per-record-CEK * + sealed-field crypto. * * `Collection` holds one `RecordCodec` instance and delegates the crypto write * path (`encryptRecord` / `encryptJsonString` / `buildDebugEnvelope`), the read * path (`decryptRecord` / `decryptJsonString` / `resolveEnvelopeCek`), and the * sealed-field helpers (`unsealField` / `makeSealedHandle` / `toCacheRecord` / * `classifySealedShred`) to it. The codec receives every `this.*` dependency * it needs via {@link RecordCodecContext}. * * The crux is `cekCache`: it is the SAME `Lru` reference `Collection` owns (not * a copy). `resolveEnvelopeCek` reads/writes it, and tier methods + * `vault.invalidateRecordCaches` keep mutating that same object — a copy would * silently break the "single CEK delete kills the version chain" invariant. * * Internal service — not exported as a `@noy-db/hub/*` subpath. */ import { type EnclaveKey } from '../crypto.js'; import { type EncryptedEnvelope, type CrdtMode, type CrdtStrategy, type VdigFieldPolicy, type SealedHandle } from '../../types.js'; import { type StandardSchemaV1 } from '../../schema.js'; import type { Lru } from '../../cache/index.js'; import type { ViaCryptoCtx } from '../../via/index.js'; import type { ViaPipeline } from '../../via/pipeline.js'; /** * One classified per-slot verdict from {@link RecordCodec.classifySealedShred}. * `shreddable` — CEK-only, tombstone makes it undecryptable. `dekResidue` — * collection-DEK-keyed, survives in synced/backup copies. The third class is * BOTH: a `_bidx` blind-index tag is live-dropped by the tombstone yet retained * under the surviving DEK in any pre-forget backup (honest dual accounting). */ export type SealedShredSlot = { readonly field: string; readonly class: 'shreddable' | 'dekResidue' | 'live-shreddable+dekResidue-in-backups'; }; /** Everything the moving crypto methods touched on `this.*`, as a flat context. */ export interface RecordCodecContext { /** Collection name — the crypto AAD scope and schema-error context. */ readonly name: string; /** Actor id stamped on `_by` (the collection's keyring.userId). */ readonly actor: string; /** False on plaintext collections — selects the no-ciphertext envelope branch. */ readonly storeCiphertext: boolean; /** keyring.debugPlaintext — debug-inline envelope on user collections. */ readonly debugPlaintext: boolean; /** Emit `_source`/`_sourceTs` provenance fields when a source is supplied. */ readonly provenance: boolean; /** Declared `sensitive` fields → sealed into `_sealed[field]`. */ readonly sensitiveFields: ReadonlySet; /** Declared deterministic-index fields, or null. */ readonly deterministicFields: ReadonlySet | null; /** Digest-only classified fields → verify policy (stage 2). Null when none. */ readonly vdigFields: ReadonlyMap | null; /** * C-A / R10 config-drift signal: the persisted `x-classified` marker's * declared digest-only field set (empty when the collection carries no * marker). Lazy, memoized (O(1) per handle after the first call). The R10 * superset guard refuses whenever a field in this set is absent from * `vdigFields`. Consulted once per handle on any encrypted write path — the * cross-session, prev-free signal a fully-naive create has no `prev` for. * Undefined on codecs with no store access (direct-construction tests). */ classifiedMarkerDigestOnly?(): Promise; /** CRDT mode (decrypt resolves CrdtState→snapshot when set). */ readonly crdtMode: CrdtMode | undefined; /** CRDT strategy seam (resolveCrdtSnapshot). */ readonly crdtStrategy: CrdtStrategy; /** Output-schema validator, or undefined. */ readonly schema: StandardSchemaV1 | undefined; /** * Resolve a collection's DEK. Called with no argument for `this.name`'s * own DEK (every ordinary codec use); `viaCryptoCtx`'s `reservedEnvelopes` * door passes an explicit OTHER collection name (e.g. a reserved * `_dict_status` collection) so a binding's at-rest hook can address a * different collection's DEK without ever holding the resolver itself. */ getDEK(collection?: string): Promise; /** * The collection's per-record CEK cache (SHARED reference, not a copy). * Ownership/lifetime stays on Collection; codec reads+writes it in * resolveEnvelopeCek exactly as the inline code did. `null` → no caching. */ readonly cekCache: Lru | null; /** * Compiled Via pipeline (money, i18n, …), or undefined for a collection * with none declared. `encryptRecord`/`decryptRecord` consult * `via?.hasAtRestHooks` at the sealed-slot sub-step to choose between a * binding's `encodeAtRest`/`decodeAtRest` hooks and today's inline path. * The zero-via fast path (`via` undefined, or no binding declares an * at-rest hook) stays byte/behavior-identical (#629 Task 3). * * Mutable (not `readonly`, #638 Task 3): `Collection.via` can be * reassigned post-construction (the taint overlay, `via/graph-wiring.ts# * applyTaintOverlay`) — `RecordCodec.setVia` below keeps this in sync so * at-rest hooks read the LIVE pipeline, not a stale construction-time * snapshot. */ via: ViaPipeline | undefined; } export declare class RecordCodec { private readonly ctx; constructor(ctx: RecordCodecContext); /** * @internal Update the live Via pipeline this codec's at-rest hooks read * (#638 Task 3) — called by `via/graph-wiring.ts#applyTaintOverlay` right * after it reassigns `Collection.via`, so `hasAtRestHooks`/`encodeAtRest`/ * `decodeAtRest` see the newly-added `taint` binding instead of the * pipeline snapshot captured when this codec was constructed. */ setVia(via: ViaPipeline | undefined): void; /** Sealed-slot key material for this codec's collection, for a given per-record CEK (or none). */ private sealKeyMaterial; /** * Build the `ViaCryptoCtx` handed to a binding's `encodeAtRest`/ * `decodeAtRest` hook: `sealedSlots` pre-bound to `(this.ctx.name, * recordId)` — always threading `cek` when the caller has one, so the * capability's record-binding stays cryptographic rather than degrading * to collection-scope. `reservedEnvelopes`'s DEK resolver forwards its * `collection` argument straight to `this.ctx.getDEK` (#629 Task 4) — it * resolves the RESERVED collection's own DEK (e.g. `_dict_status`), never * `this.ctx.name`'s, matching `Vault.getDEK`'s per-collection-name * resolution (the same resolver `DictionaryHandle` used pre-cutover). */ private viaCryptoCtx; /** * Assemble a canonical ciphertext body envelope literal from already-computed * parts. Pure, no crypto. Each call stamps its own `_ts` — provenance carries * a SEPARATE timestamp (computed by the caller), so the two never share one. */ static buildEnvelope(p: { version: number; iv: string; data: string; by?: string; cek?: string; provenance?: { source: string; sourceTs: string; } | undefined; extra?: Partial>; }): EncryptedEnvelope; /** Plaintext (`_iv:''`) body envelope — the `!storeCiphertext` shape. */ static buildPlaintextEnvelope(p: { version: number; data: string; by?: string; provenance?: { source: string; sourceTs: string; } | undefined; }): EncryptedEnvelope; /** * Build a debug-plaintext envelope: the record's own fields inlined as * top-level keys beside the reserved `_`-metadata, with `_debug: 1` and an * empty `_data`. Lets native store tooling read the record without * unwrapping. Only reached for user collections under `debugPlaintext` * (see {@link encryptRecord}). Rejects `_`-prefixed record fields, which * would collide with the reserved metadata namespace. */ buildDebugEnvelope(record: T, version: number, source?: string, sourceTs?: string): EncryptedEnvelope; /** * Encrypt a JSON body into an envelope. * * When `cek` is supplied (per-record CEK collections), the body is * encrypted under the CEK and the CEK is AES-KW-wrapped under the * collection DEK and stamped on `_cek`. When `cek` is omitted, the legacy * path encrypts the body directly under the collection DEK — byte-identical * to pre-CEK behaviour, so non-adopting collections pay nothing. */ encryptJsonString(json: string, version: number, cek?: EnclaveKey, source?: string, sourceTs?: string): Promise; encryptRecord(record: T, version: number, cek?: EnclaveKey, source?: string, sourceTs?: string, vdig?: { readonly id: string; readonly prev: EncryptedEnvelope | null; }, id?: string): Promise; /** * Resolve the per-record CEK for a stored envelope, or `undefined` for a * legacy (`_cek`-absent) envelope. Unwraps `_cek` under the collection DEK and * memoises it in the CEK cache under `id` (when supplied) so repeated reads of * the same record skip the unwrap. Shared by the body-decrypt path * ({@link decryptJsonString}) and the sealed-field path ({@link decryptRecord} * / {@link toCacheRecord}) so both agree on the record's key. */ resolveEnvelopeCek(envelope: EncryptedEnvelope, id?: string): Promise; /** * Low-level: decrypt an envelope and return the raw JSON string. * * `_cek` presence is the format discriminant (NOT `this.perRecordCek`), * so a mixed vault — and a recipient that never opted into * `perRecordKeys` — decrypts both legacy and CEK records: * - `_cek` present → unwrap the CEK under the collection DEK, decrypt the * body under the CEK (cache the unwrapped CEK so repeated reads skip it). * - `_cek` absent → legacy path, body decrypts directly under the * collection DEK. * * The optional `id` lets reads populate the CEK cache; it is omitted by * callers (history, conflict merge) that have only the envelope. */ decryptJsonString(envelope: EncryptedEnvelope, id?: string): Promise; /** * Unseal a single `_sealed[field]` slot to its plaintext value: derive the * per-field key off the collection DEK, AES-GCM-decrypt the `iv:data` blob, * and JSON-parse the result. Shared by both the inline-decrypt path and a * {@link Sealed} handle's `reveal()` — so the on-demand reveal and the eager * materialisation always agree byte-for-byte. */ unsealField(field: string, blob: string, cek?: EnclaveKey): Promise; /** * Build the `ViaCryptoCtx` for forget()'s per-ref via-erase fold (#629 * Task 10) — resolves the live envelope's CEK exactly like * `decryptRecord` does, then delegates to the same capability factory * `encodeAtRest`/`decodeAtRest` use. */ eraseCryptoCtx(id: string, live: EncryptedEnvelope): Promise; /** * Classify a live envelope's `_sealed` slots for crypto-shred completeness. * `forget()` drops `_cek`/`_sealed` but * RETAINS the collection DEK, so only a slot keyed off the per-record CEK is * genuinely shredded by the tombstone; a legacy slot keyed off the * collection DEK survives in any synced/backup copy. * * Mirrors {@link unsealField}'s dual-read split: try the CEK-derived key per * slot — success → `shreddable`, AES-GCM auth failure → `dekResidue`. With no * `_cek` (pure legacy collection-DEK sealing) ALL slots are residue. */ classifySealedShred(live: EncryptedEnvelope): Promise<{ readonly slots: readonly SealedShredSlot[]; }>; /** * Build a non-leaking {@link Sealed} handle over a sealed field's ciphertext. * The handle captures only the ciphertext `blob` and a closure to * {@link unsealField}; the plaintext is never stored on it — so the handle * may sit in the working-set cache (or be logged/serialised) without * exposing the value, which decrypts only on `reveal()`. */ makeSealedHandle(field: string, blob: string, cek?: EnclaveKey): SealedHandle; /** * Replace every field the just-written envelope actually sealed with a * {@link Sealed} handle, leaving every other field as its plaintext value. * Used to populate the cache on the write path without ever materialising * sealed plaintext into it. Returns `record` untouched when nothing was * sealed. Keys off `envelope._sealed` itself (#642 fix) — NOT * `this.ctx.sensitiveFields`, which only names the inline `sensitive:[...]`/ * classified-recoverable field set: a collection whose sealing comes * entirely from the `taint` via-binding (a derivation/MV output or rollup * target folded sealed from a classified SOURCE collection, #642) declares * no local `sensitiveFields` at all, so that stale gate skipped this * conversion and left a just-written taint-sealed field as cached * plaintext — read back correctly ONLY after a cold cache miss (`get()`'s * own `decodeAtRest` call, unaffected by this gate) forced a fresh decrypt. */ toCacheRecord(record: T, envelope: EncryptedEnvelope, id?: string): Promise; /** * Apply `_sealed`-slot post-processing to an ALREADY-DECRYPTED `record`: * restore each `_sealed[field]` blob inline (`sealedAsHandles` falsy) or * as an opaque {@link Sealed} handle (`sealedAsHandles: true`), routing * through a via pipeline's `decodeAtRest` hook when one is declared. * Extracted byte-parity from `decryptRecord`'s inline block (#635) so a * caller that resolves its OWN per-record CEK under a DIFFERENT DEK than * this codec's `this.ctx.getDEK()` — namely the tier>0 `getAtTier` leg in * `with-audit/tiers/index.ts`, whose `_cek` is wrapped under the TIER DEK, * not the collection DEK `decryptRecord`/`resolveEnvelopeCek` always * unwrap under — can still get correct sealed-slot handling without * routing its whole read through `decryptRecord` (which would resolve the * wrong DEK for it). * * Zero-knowledge: takes only the already-resolved per-record `cek` (or * `undefined` for a legacy/no-CEK record) — the same shape `makeSealedHandle` * already takes above. Never the keyring, never a raw DEK: the legacy * fallback key stays fully internal to `sealKeyMaterial`/`viaCryptoCtx`, * which close over THIS codec's own `getDEK` (permanently bound to * `this.ctx.name`'s tier-0 DEK at construction) — so even when called from * a tier>0 context, the legacy-fallback key resolution is correct: sealed * fields are always sealed under the CEK or the collection's tier-0 DEK, * never a tier DEK (tier writes don't seal fields; `_sealed` reaches a * tiered envelope only via a tier-0 write later elevated). */ applySealedSlots(record: T, sealed: Record, cek: EnclaveKey | undefined, opts?: { id?: string; sealedAsHandles?: boolean; }): Promise; /** * Decrypt an envelope under an EXPLICIT `dek` instead of this codec's own * `ctx.getDEK()` / cekCache resolution — for a from-tier>0 pre-move decode * (`with-audit/tiers/index.ts`'s `putAtTier`/`elevate`/`demote` sites, * #722 whole-branch review). Mirrors the manual unwrap-then-decrypt * `getAtTier`'s own tier>0 leg performs there (same primitives, same * `applySealedSlots` extraction point #635 built for exactly this caller). * * `decryptRecord` is tier-UNAWARE: `resolveEnvelopeCek` reads the cekCache * or falls back to `ctx.getDEK()` — this codec's DEFAULT (tier-0/ * collection) DEK — never the envelope's OWN tier. That only happens to * decrypt a from-tier>0 envelope correctly when some other call in the * SAME op already primed the cekCache with this record's CEK (true for an * ordinary put()-then-elevate(0→1); NOT true for a `putAtTier`-origin * envelope, whose body is encrypted directly under its tier DEK with no * `_cek` ever minted, or a non-`perRecordKeys` collection, which has no * `_cek` at all) — those throw `TamperedError`. Decrypting under the * caller-resolved `dek` is correct regardless of cache state or * `perRecordKeys`. * * No CRDT resolution, no schema validation — same scope as `getAtTier`'s * tier>0 branch (the established precedent for a tier-aware read), which * skips both for the same reason: a tier>0 read is an internal/derived- * output-recompute path, not the public per-record read surface schema * validation guards. */ decryptRecordAtDek(envelope: EncryptedEnvelope, dek: EnclaveKey, id?: string): Promise; /** * Decrypt an envelope into a record of type `T`. * * When a schema is attached, the decrypted value is validated before * being returned. A divergence between the stored bytes and the * current schema throws `SchemaValidationError` with * `direction: 'output'` — silently returning drifted data would * propagate garbage into the UI and break the whole point of having * a schema. * * `skipValidation` exists for history reads: when calling * `getVersion()` the caller is explicitly asking for an old snapshot * that may predate a schema change, so validating it would be a * false positive. Every non-history read leaves this flag `false`. */ decryptRecord(envelope: EncryptedEnvelope, opts?: { skipValidation?: boolean; id?: string; sealedAsHandles?: boolean; }): Promise; }