stable

plaintext-bypass

The fixed set of internal collections that store plain JSON instead of ciphertext, why each needs to, and what a store-only attacker learns from them.

Reference: the fixed set of internal collections in a noy-db vault that store plain JSON, by design.

Clusterdisk-format
Cross-cuts@noy-db/hub core, every @noy-db/to-* storage backend, the .noydb bundle format, @noy-db/on-magic-link.

What this doc is

noy-db's headline guarantee is that a storage backend — S3, IndexedDB, Postgres, whatever adapter you plug in — only ever sees ciphertext. Every record you write through a vault's collection, vault.collection<T>(...).put(id, payload), is wrapped in an envelope ({ _noydb, _v, _ts, _iv, _data }) and encrypted with AES-256-GCM under a per-collection DEK (data-encryption key) before it reaches the store.

That guarantee has one deliberate carve-out. A handful of collections the hub itself uses to operate — unlocking a keyring (the record that carries a user's wrapped keys), reading vault policy, running the sync engine, redeeming an invite — need to be readable before any key material exists to decrypt them with. Encrypting those would create a chicken-and-egg problem: you'd need to unlock the vault to read the document that tells you how to unlock the vault. Those collections store plain JSON instead. This page is the complete list of which ones, why each is exempt, and what a person who compromises only the storage backend (never any key) can learn from them.

Opt in

There is nothing to opt into. Every vault has this fixed set of plaintext collections unconditionally — there is no with*() strategy, no factory, and no createVault option that turns it on or off. The only thing you can opt into is reading one of these envelopes directly, which is what the next section shows.

Usage

Read a plaintext envelope's raw fields

A store backend exposes envelopes however it persists them — a to-file store, for example, writes one JSON file per record. You can inspect the _iv field yourself to confirm a given envelope is a plaintext bypass rather than ciphertext:

import { readFileSync } from 'node:fs'
import type { EncryptedEnvelope } from '@noy-db/hub'

const envelope = JSON.parse(
  readFileSync('data/<vault>/_keyring/alice.json', 'utf8'),
) as EncryptedEnvelope

envelope._iv
// → ''  (empty — this is a plaintext bypass; a non-empty _iv would mean ciphertext)

Unwrap the record with readPlaintextRecord

@noy-db/hub exports a small helper that turns a plaintext envelope into the record it carries, without needing a vault or any key material:

import { readPlaintextRecord } from '@noy-db/hub/debug'

const keyringRecord = readPlaintextRecord(envelope)
console.log(keyringRecord)
// → { role: 'owner', permissions: { '*': 'rw' }, granted_by: 'alice',
//     display_name: 'Alice', deks: { 'default': '…wrapped…' } }

readPlaintextRecord throws if handed an encrypted envelope (non-empty _iv) — there is no key here to decrypt with; go through the vault instead.

API reference

readPlaintextRecord(envelope)

Extracts the record from a plaintext-stored envelope. Handles both layouts a store can hold:

  • Classic plaintext (encrypt: false, or one of the unconditionally-plaintext collections on this page) — the record is a JSON string in envelope._data.
  • Debug-plaintext (encrypt: false + debugPlaintext: true on createNoydb) — the record's fields are inlined beside the envelope metadata instead of being JSON-stringified into _data.

Returns null for an empty/absent body. Throws if envelope._iv is non-empty (an encrypted envelope) — decrypt through the vault instead.

import { readPlaintextRecord } from '@noy-db/hub/debug'

readPlaintextRecord({ _noydb: 1, _v: 1, _ts: 't', _iv: '', _data: '{"id":"d1","n":2}' })
// → { id: 'd1', n: 2 }

The discriminator on the wire

Every envelope read starts by checking one field, _iv:

  • _iv non-empty → the envelope is encrypted with AES-256-GCM under a DEK; _data is ciphertext.
  • _iv: '' (empty string) → the envelope is a plaintext bypass; _data is JSON, readable without any key.

Both paths share the same envelope shape, so the sync engine, conformance tests, and every to-* backend read either kind the same way — a backend never has to know in advance which collections are which. The discriminator lives on each envelope, not declared once for a whole collection: vault.collection<T>(...) data is always encrypted, and a few operational collections (the sync-credential store, the tamper-evident ledger, the consent-audit log, blob metadata) carry their own DEK and are encrypted the same way — the only time they turn up as plaintext is if the entire vault was opened with encrypt: false (a whole-vault, all-collections opt-out documented on the vault-creation options, not a per-collection bypass). The collections catalogued below are different: they are plaintext in every vault, unconditionally, regardless of the encrypt option.

The unconditionally-plaintext collections

Each row is a deliberate exemption: the "Why" column is the argument that a change breaking that assumption would need to overturn.

CollectionSourceWhy it must be plaintext
_keyring/<userId>with-party/team/keyring.tsThe keyring is the file that carries a user's wrapped DEKs and their KEK (key-encryption key) salt. Reading it without keys is the entry point for unlock — encrypting it would mean you'd need a key to find out how to get a key. The fields it exposes (role, permissions, granted_by, expires_at, display_name, and the count of wrapped DEKs) are a documented metadata leak: they let a caller check role and permissions without unwrapping anything.
_meta/policywith-party/policy/storage.tsThe vault's factor/gate policy has to be readable before any unlock decision is made — a gate can require "tier-1 unlock" to even be attempted, and the engine can't know that without reading the policy first.
_meta/recovery-paperwith-party/team/recovery.tsEach entry holds a set of DEKs wrapped under a key derived from the user's paper recovery code (PBKDF2-SHA256, 600,000 iterations) plus its own salt and IV. The wrapping is the security boundary — the document itself is inert without the recovery code, so there's nothing to protect by also encrypting the envelope.
_meta/handlekernel/vault.tsAn opaque ULID identifying a .noydb bundle, generated at first export and reused on every later export of that vault. Read by a multi-vault picker to label bundles before any user is authenticated — and the same ULID already sits unencrypted in the bundle header, so encrypting the vault-side copy would protect nothing.
_meta/coverwith-party/directory/cover/storage.tsOwner-curated label (name, description, icon, locale) that the owner explicitly opts every field into — see cover. Meant to be readable without unlocking the vault, so it can't be encrypted.
_meta/invite-audit-<tokenId>@noy-db/on-magic-link, src/invite.tsAudit record for a magic-link invite, read by the recipient's session before they hold any keyring, and mutated when the issuer revokes the invite. It's also the "revoked-link-shadow-keyring" defense: if acceptInvite finds no audit doc for a token, it throws InviteAuditMissingError and refuses to proceed even if the rest of the link is well-formed — so the check has to run before any key exists to gate it.
_meta/directorywith-party/directory/storage.tsA one-field vault-level toggle ({ enabled: boolean }) read by the user-enumeration helper to decide whether non-admin callers may list vault members — the read has to happen on the same before-any-unlock path as _meta/policy. A UX flag, not a privacy boundary.
_meta/visibility/<keyringId>with-party/directory/visibility.tsA per-user "hide me from the member list" opt-out ({ hidden: boolean }). Has to be readable even when that user's own envelope can't be decrypted (legacy keyring, missing DEK propagation, corrupted ciphertext), so the enumeration helper can still filter it in.
_meta/adoptionwith-cargo/adopt-partition.tsMarker written when a transferable partition is adopted into a vault that has no keyring yet — there is no DEK to encrypt under. While the partition is adopted-but-unowned it carries a transferSeal (the partition's DEK set, sealed with AES-GCM under an out-of-band, never-stored 32-byte transfer key) — the sealing, not envelope encryption, is the security boundary. Once an owner is minted, the seal is destroyed and the row is rewritten to { sealId, adoptedAt, consumedAt } with no key material left in it.
_sync/metawith-party/team/sync.tsThe sync engine's own bookkeeping — last-pushed timestamp, last-pulled cursor, the dirty-record set. Has to be readable before the next pull can run, the same chicken-and-egg constraint as the other pre-unlock reads above.

What is NOT in this set (verified encrypted)

  • Every user-data collection. Anything written through vault.collection<T>(...).put(id, payload) is AES-256-GCM-encrypted under a per-collection DEK. A store sees only ciphertext plus the _v / _ts metadata fields.
  • _users/<keyringId> — the per-user envelope (contact info, presence, user-envelope). Encrypted under its own DEK, propagated to every keyring the same way collection DEKs are. The owner-curated vault label above (_meta/cover) is the plaintext one; the per-user envelope is not.
  • _sync_credentials — per-adapter sync transport secrets (tokens, connection strings). Encrypted under a dedicated DEK and served only through its own owner/admin-gated API — never reachable through the generic vault.collection() handle, and never propagated to a sub-admin grantee, because leaking it would leak a live transport secret rather than metadata.
  • _ledger (the hash-chained tamper-evidence log) and _consent_audit (the per-access consent log) — each is encrypted under its own DEK by default. Both can only be read in plaintext if the whole vault was created with encrypt: false.
  • _blob_index and _blob_chunks — blob metadata and encrypted chunk bytes, both under a vault-shared blob DEK by default; same encrypt: false caveat as above.
  • _history — full-record version snapshots, encrypted under the host collection's own DEK.
  • _idx/<field>/<recordId> — query-index side-car entries, encrypted under the host collection's DEK so a leaked index can't be reverse-engineered into record contents.

What an attacker with store-only access can learn

A compromise of a store backend (a leaked S3 bucket, an IndexedDB dump, a captured WebDAV session) — with no key material at all — yields these plaintext fields:

  • From _keyring/*: every user id with access to the vault, their role, their per-collection permissions, how many DEKs they hold, who granted their access, expiry timestamps, and KEK salts. No raw keys.
  • From _meta/policy: exactly which factors each gate requires. Useful intelligence before attempting a policy bypass; does not help the bypass itself.
  • From _meta/recovery-paper: the wrapped-DEK blob, offline-attackable against the recovery code. The 600,000-iteration PBKDF2 wrap is the entire defense — a short or reused recovery code is the failure mode.
  • From _meta/cover: whatever the owner explicitly chose to publish.
  • From _meta/adoption (only while a partition is adopted-but-unowned): the sealed partition-DEK blob, offline-attackable against the 32-byte transfer key. That key is 256 bits of randomness delivered out-of-band and never itself stored, so brute-forcing it is infeasible — the exposure is the sealed blob plus the sealId and adoption timestamp. Once createOwnerOnAdoptedPartition runs, the blob is gone.

The "stores see only ciphertext" guarantee applies to data that flows through Collection<T>.put. The collections above are a documented, bounded exception to it — infrastructure metadata the hub needs before it has any key, not application data.

Consequences of changing this set

Every row above is load-bearing for something the hub does before a key exists — that's what keeps the set small. Two things follow from that:

  • Adding a collection to this list changes the on-disk format contract: every to-* backend and every conformance test that reads _iv to decide whether to decrypt now has one more collection to special-case, and the new plaintext fields become part of the store-only attacker surface described above.
  • Removing a collection from this list — encrypting something that used to be plaintext — is a breaking on-disk format change for existing vaults: it requires a new envelope-format version (the hub tracks this via NOYDB_FORMAT_VERSION / NOYDB_KEYRING_VERSION) and a migration that rewrites every existing envelope of that shape, since old readers would otherwise try to JSON.parse ciphertext.

See also

  • cover — the one bypass that is consumer-controlled: the owner explicitly opts every field in.
  • user-envelope — the encrypted counterpart: per-user, not per-vault, and not on this list.
  • bundle — the .noydb container format; _meta/handle is shared between the vault and the bundle header.
  • session-tiers — _meta/policy is the input to the tier/gate policy engine.
  • transferable-partitions — the adoption flow that writes _meta/adoption.
  • Proven by showcase 112-debug-plaintext-mode.