-
Notifications
You must be signed in to change notification settings - Fork 2
NORM Security
At-rest column encryption, digest siblings, one-way digest columns,
virtual masks, hidden columns, and the crypto override seam: norm's
security surface. One secret on the Norm instance drives every
encrypted column on every registered entity, and the TypeScript types
never change.
- Overview
- Configuration
-
Column encryption —
.encrypt() -
Digest siblings —
.encrypt().hash() - One-way digest columns —
Column.hash() - Password columns —
Column.password() - Virtual masks —
Column.mask() - Hidden columns —
.hidden() - Crypto overrides
- Migrations and crypto
- Read-path decrypt failures —
onDecryptFailure - Key rotation —
rotateKey() - Limitations
- Related Documentation
norm turns column-level cryptography into a declaration. Five builder markers, all read from one schema:
| Marker | What it does | Reversible | Filterable |
|---|---|---|---|
.encrypt() |
Ciphertext at rest, plaintext in TS | Yes (with the secret) | No (see .hash()) |
.encrypt().hash() |
Encrypt and synthesize a <col>_hash digest sibling |
Yes | Yes, equality only |
Column.hash(algo) |
One-way digest column (store only the digest) | No | Yes, equality only, except 'PBKDF2' (see below) |
Column.mask(src, fn) |
Virtual, computed-on-read presentation column | n/a | No; never stored |
.hidden() |
Excluded from default reads, opt-in projectable | n/a | Yes (unless also .unfilterable()) |
Column.password(algo) is the same digest-column builder as
Column.hash(algo), named for credentials; see
Password columns.
Encryption is per cell: every encrypted value carries its own random IV (and, for cells written by older versions, a per-message salt), so two equal plaintexts never produce equal ciphertext. That is the security property, and the reason equality filters, uniqueness, joins, and upsert keys need a deterministic digest rather than the ciphertext itself.
The whole surface is correct by construction. .hash() exists only
after .encrypt(); .guard() must chain before .encrypt() because it
constrains the plaintext; and Column.hash(algo) exposes no
.encrypt(). Invalid combinations do not type-check.
This page expands the At-rest encryption summary in the README.
The secret and algorithm live on the Norm instance, not the schema:
import '@tundralibs/norm/engines/sqlite';
import { Norm } from '@tundralibs/norm';
const norm = new Norm({
database: { dialect: 'sqlite', path: './data' },
secret: process.env.NORM_SECRET, // required if any column .encrypt()s
algorithm: 'AES-256-GCM', // optional; this is the default
});
const db = norm.use(/* ...schemas */);| Option | Type | Default | Notes |
|---|---|---|---|
secret |
string |
— | Symmetric key material. Keep it out of source; load from an env var or secret store. |
algorithm |
EncryptAlgorithm |
'AES-256-GCM' |
Bound per instance, applied to every encrypted column. |
crypto |
CryptoOverrides |
AES + SHA (from @tundralibs/crypt) |
Swap the encrypt / decrypt / hash callbacks; see Crypto overrides. |
onDecryptFailure |
'null' | 'throw'
|
'null' |
What a read does when a cell will not decrypt; see Read-path decrypt failures. |
EncryptAlgorithm is any AES key length crossed with a mode:
AES-{128,192,256}-{GCM,CBC,CTR}. GCM is authenticated natively; CBC
and CTR are wrapped in encrypt-then-MAC by the default helper.
Digest algorithms are not instance config. Encrypt-siblings are pinned
to SHA-256 (SIBLING_HASH_ALGORITHM) so the physical VARCHAR(64)
never moves, and one-way digest columns carry their algorithm in the
definition (Column.hash('SHA-512')).
No secret, but a column asks to be encrypted? norm.use(...) throws a
NormDefinitionError at composition time, so the misconfiguration
never reaches a query.
The instance also exposes crypto helpers for the raw escape hatches:
const cipher = await db.encrypt('ada@example.dev'); // this instance's secret + algorithm
const plain = await db.decrypt(cipher); // → 'ada@example.dev'
const digest = await db.hash('ada@example.dev'); // SHA-256 by default — matches siblingsdb.hash(plaintext, algorithm?) defaults to SHA-256 so its output
matches sibling digests; pass an algorithm to match a
Column.hash(algo) column instead.
.encrypt() works on every value kind: string, number, bigint, date,
boolean, JSON. The logical TypeScript type is unchanged; only the
physical storage becomes ciphertext, migrated to TEXT.
import { Column, Entity } from '@tundralibs/norm';
const Profiles = Entity('profiles', {
userId: Column.uuid(),
bio: Column.text().nullable(),
birthday: Column.timestamp().encrypt().nullable(), // Date in TS, TEXT at rest
website: Column.varchar(255).nullable(),
}, { pk: ['userId'] });profiles.birthday is a Date | null to your code on both write and
read. At rest it is AES ciphertext:
const prof = await db.repo('Profiles').getByPK({ userId });
prof.data?.birthday instanceof Date; // true — decrypted and decoded on readEncryption operates on strings, but the column keeps its declared type. Before encrypting (and digesting), norm canonicalizes the validated value to a deterministic string; on read it decodes back. The canonical forms are timezone-stable and re-digestable, so the same value always yields the same digest.
| Logical type | Canonical string | Decoded back to |
|---|---|---|
DATE / TIME / DATETIME / TIMESTAMP
|
Date.toISOString() |
Date |
BIGINT |
decimal string | bigint |
INTEGER / DECIMAL / FLOAT / DOUBLE / REAL
|
String(n) |
number |
BOOLEAN |
'true' / 'false'
|
boolean |
JSON / JSONB
|
recursively key-sorted JSON text | parsed value |
VARCHAR / CHAR / TEXT / UUID
|
the string as-is | string |
JSON is key-sorted so digests of semantically equal objects agree regardless of insertion order. Decoding is defensive: a corrupted or pre-codec cell falls back to the raw string on every branch, so one bad value neither aborts the whole read nor silently flips into a legal-looking value.
Column.blob() cannot be encrypted, because the codec is
text-canonical. Encode binary to a text form and encrypt that if you
need it.
For GCM algorithms (the default), the encryption key is derived from your secret with PBKDF2-SHA-256 (210,000 iterations) once per process, then reused for every cell. Each encrypt or decrypt is then a plain AES-GCM operation measured in microseconds, so bulk writes and reads are no longer dominated by key derivation. The derivation salt is computed from the secret itself (domain-separated SHA-256). That costs nothing against brute force here, since one secret serves every cell and an attacker guessing passphrases pays the full 210,000 iterations per candidate either way, and it lets every process re-derive the identical key with nothing extra stored.
Two older cell generations remain readable and are told apart by
envelope shape. Cells written before this fast path carry a
per-message salt and pay the ~22 ms PBKDF2 on every read of that cell,
and CBC/CTR configurations still derive per cell, because their
encrypt-then-MAC is keyed off the string secret. Running
rotateKey() re-encrypts old cells, which
also upgrades them to the fast envelope.
If you need different key handling entirely, the crypto override seam lets you supply your own KDF or delegate to a KMS.
Chaining .hash() after .encrypt() synthesizes a <col>_hash digest
sibling, a deterministic SHA-256 VARCHAR(64) column that norm
maintains on every write. It exists so plaintext equality operations
work against a column whose ciphertext is unusable for comparison.
import { Column, Entity } from '@tundralibs/norm';
import { Guardian } from '@tundralibs/guardian';
const Users = Entity('users', {
id: Column.uuid().default({ $$_expression: 'UUID' }),
email: Column.varchar(255)
.guard(Guardian.string().trim().toLowerCase().pattern(/^\S+@\S+\.\S+$/))
.encrypt().hash(), // → ciphertext `email` + digest `email_hash`
apiKey: Column.varchar(256).encrypt(), // encrypted, NOT hashed → not filterable
}, {
pk: ['id'],
unique: { email: ['email_hash'] }, // uniqueness lives on the digest
});An encrypted column with no .hash() (like apiKey above) is readable
but not filterable. Random-IV ciphertext never equals itself, so
where email = <ciphertext> can never match, uniqueness cannot be
enforced, and you cannot group, order, or join on it. Filtering one
throws with a pointed hint:
Column 'apiKey' on entity 'Users' is not filterable
— declare .hash() to enable equality filtering.
With .hash() declared, all of these work transparently: you always
speak plaintext, and norm rewrites to the digest sibling.
Equality-class operators only ($eq, $ne, $in, $nin, $null);
ordering by a digest is meaningless and stays rejected.
// Equality — rewritten to email_hash = sha256('ada@shortly.dev').
// The column's beforeWrite (trim + lowercase) runs on the lookup too,
// so a differently-cased/whitespaced input still finds the row.
await db.repo('Users').findOne({ '@email': ' Ada@Shortly.Dev ' });
// $in — each element digested.
await db.repo('Users').find({
'@email': { $in: ['bob@shortly.dev', 'eve@shortly.dev'] },
});
// The rewrite composes with $or, joins, and update()/delete() filters.
await db.repo('Users').update({ loginCount: 1 }, {
'@email': 'ada@shortly.dev',
});
// Uniqueness — enforced by a real UNIQUE index on the sibling.
// A different-case duplicate collides because it digests identically.
await db.repo('Users').insert({ email: 'ADA@SHORTLY.DEV' /* ... */ }); // rejectsBecause the digest is deterministic, an encrypted and hashed column can be an upsert conflict key through the sibling even though the ciphertext cannot:
// Encrypted `email` cannot be a conflict key directly — ciphertext is
// nondeterministic — so conflict on the id and update email; NORM
// auto-adds `email_hash` to updateOnConflict so the digest re-syncs
// with the new ciphertext and plaintext lookups keep finding the row.
// `email` must be updatable on the entity: a column outside its
// `update` list is refused on conflict too (UPSERT_CONFLICT_KEY).
await db.repo('Users').upsert({
id: userId,
email: 'ada.lovelace@shortly.dev',
apiKey: 'ak-ada-0002',
displayName: 'Ada L.',
passwordHash: 'bcrypt$ada',
}, { conflictKeys: ['id'], updateOnConflict: ['email'] });
// Naming the encrypted column itself as a conflict key is rejected:
// "Column 'email' ... cannot be an upsert conflict key — ciphertext
// is nondeterministic. Use the 'email_hash' sibling ..."Non-string plaintext works too: an encrypted bigint or Date column
with .hash() canonicalizes filter operands exactly like the write
path, so the digests line up. The sibling always uses SHA-256
regardless of the instance's encrypt algorithm.
For values that must be comparable but never readable (passwords, PINs,
recovery codes), use a standalone digest column. Callers write and
filter by plaintext; norm digests on the way in and the column stores
only the hex digest. There is nothing to decrypt, so .encrypt() on a
digest column is a hard error.
const Users = Entity('users', {
// ...
pin: Column.hash('SHA-256').nullable(), // one-way digest, plaintext lookups
});The algorithm ('SHA-256' by default, 'SHA-384', or 'SHA-512')
determines the physical VARCHAR length: 64, 96, or 128 hex
characters. A fourth algorithm, 'PBKDF2', is also accepted here
(Column.hash('PBKDF2')) but behaves differently enough to get its own
section next: it is salted and not filterable.
const row = (await db.repo('Users').insert({ pin: '4471' /* ... */ })).data[0]!;
row.pin; // 64-hex SHA-256 digest — the plaintext '4471' is gone
// Filter by plaintext — the VALUE is digested, the column key stays.
const byPin = await db.repo('Users').findOne({ '@pin': '4471' });Note the difference from an encrypt-sibling: .encrypt().hash()
rewrites the filter key to @email_hash, while a Column.hash(algo)
digest rewrites only the value and keeps the @pin key, because the
column itself already stores the digest. Both are transparent to the
caller. A .guard() on a digest column constrains the plaintext — your
password policy — not the digest.
Column.password(algorithm?) builds the same digest column as
Column.hash(algorithm?), same builder and same physical shape, under
a name that reads better on a credential. It takes two kinds of
algorithm:
-
'SHA-256'(default),'SHA-384', or'SHA-512': a deterministic digest, identical in every respect toColumn.hash(algo). Write and filter by plaintext; brute-forceable if the table leaks. Use it only when you need to look a row up by the credential. -
'PBKDF2': a salted hash, the correct choice for real login passwords. Every hash is unique even for the same plaintext, so the column is not filterable.Column.hash('PBKDF2')carries the same restriction, since it is the identical call. Read the row and verify the candidate instead:
import { Column, Entity, pbkdf2Verify } from '@tundralibs/norm';
import { Guardian } from '@tundralibs/guardian';
const Users = Entity('users', {
id: Column.uuid(),
password: Column.password('PBKDF2').guard(Guardian.string().minLength(8)),
}, { pk: ['id'] });
// db.repo('Users').insert({ id, password: 'hunter2boat' }) stores
// 'pbkdf2-sha256$<iterations>$<salt>$<digest>' — a self-describing,
// non-fixed-width string, so the column is a `VARCHAR(255)`, not the
// 64/96/128-char fixed width the SHA-* algorithms get.
const row = (await db.repo('Users').findOne({ '@id': userId })).data!;
const ok = await pbkdf2Verify('hunter2boat', row.password); // booleanpbkdf2Verify is re-exported from @tundralibs/norm (and
@tundralibs/norm/core) for exactly this check. The KDF itself (hash
family, iteration count) is overridable per instance via
crypto.pbkdf2Hash on new Norm({...}), independent of the hash
override: bare SHA digests (Column.hash() and .password() without
'PBKDF2', and encrypt-sibling digests) still route through hash.
A mask is a presentation column: computed client-side from a sibling
source column after decryption, never stored, never sent to SQL, and
excluded from inserts, updates, filters, and ordering.
const Users = Entity('users', {
apiKey: Column.varchar(256).encrypt(), // the raw, encrypted source
apiKeyHint: Column.mask('apiKey', (v) => `…${v.slice(-4)}`),
// ...
});apiKeyHint is its own first-class key. The raw apiKey and the
masked apiKeyHint are independently projectable, and a default read
carries both:
const row = (await db.repo('Users').insert({ apiKey: 'ak-ada-0001' /* ... */ }))
.data[0]!;
row.apiKey; // 'ak-ada-0001' (decrypted)
row.apiKeyHint; // '…0001'The rules:
- The mask fn receives the decoded stored value: a real
Datefor an encrypted timestamp source, anumberfor a numeric one. Type the fn's parameter to the source's logical type (Column.mask<Date>('birthday', (d) => d.getFullYear().toString())). - Several masks may share one source, with any custom names.
- Whether the raw source projects by default stays the source's own
.hidden()decision. A hidden source is still fetched (and decrypted) to compute the mask, then stripped from the result. - On a
decrypt: falseread, masks over an encrypted source are skipped; the fn must never see ciphertext. - Only
hidden(),comment(), andnullable()chain on a mask. Declare.nullable()when the source is nullable, since a null source yields a null mask. - Masks are not computed by the
db.query()escape hatch, whose sources may be absent. They are a typed-read feature.
.hidden() excludes a column from default read shapes (and from
RETURNING) while keeping it writable and explicitly projectable. It
is the natural home for a stored credential you verify but never
surface.
const Users = Entity('users', {
// ...
passwordHash: Column.varchar(64).hidden().unfilterable(),
});The password-verification pattern: the hash never leaves the database on a default read, but you can opt into it for the one query that checks a login:
// Default read: passwordHash is absent.
const user = await db.repo('Users').findOne({ '@email': 'ada@shortly.dev' });
'passwordHash' in (user.data ?? {}); // false
// Verification: project it explicitly, compare with your own hasher.
const withHash = await db.repo('Users').findOne(
{ '@email': 'ada@shortly.dev' },
{ project: { '@id': true, '@passwordHash': true } },
);
const ok = await verifyPassword(candidate, withHash.data!.passwordHash);.hidden() composes with .unfilterable() (reject it in WHERE and
ORDER BY) and survives every generic-changing modifier in a chain
(nullable, default, encrypt, hash), so the type-level brand and
the runtime strip never diverge.
CryptoOverrides on new Norm({ crypto }) swaps the default crypto
callbacks. Any callback you omit falls back to the built-in AES/SHA
implementation from @tundralibs/crypt.
import type { EncryptAlgorithm, HashAlgorithm } from '@tundralibs/norm';
type CryptoOverrides = {
encrypt?: (
plaintext: string,
secret: string,
algorithm: EncryptAlgorithm,
) => Promise<string>;
decrypt?: (
ciphertext: string,
secret: string,
algorithm: EncryptAlgorithm,
) => Promise<string>;
hash?: (plaintext: string, algorithm: HashAlgorithm) => Promise<string>;
// Salted PBKDF2 KDF for `Column.hash('PBKDF2')` / `Column.password('PBKDF2')`
// columns — see [Password columns](#password-columns--columnpassword).
// Independent of `hash`: it takes no algorithm parameter and never
// affects a bare SHA digest.
pbkdf2Hash?: (plaintext: string) => Promise<string>;
};encrypt and decrypt must be overridden as a pair. If encrypted
columns exist and you override one but not the other, norm.use(...)
rejects it, because rows would be written in one format and read back
in another.
import '@tundralibs/norm/engines/sqlite';
import { Norm } from '@tundralibs/norm';
declare const kms: {
encrypt(plain: string, secret: string, algo: string): Promise<string>;
decrypt(cipher: string, secret: string, algo: string): Promise<string>;
};
const norm = new Norm({
database: { dialect: 'sqlite', path: './data' },
secret: process.env.NORM_SECRET,
crypto: {
// Delegate the symmetric crypto to a KMS-backed helper.
encrypt: (plain, secret, algo) => kms.encrypt(plain, secret, algo),
decrypt: (cipher, secret, algo) => kms.decrypt(cipher, secret, algo),
},
});The hash callback may be overridden alone. It takes no secret in its
default form, which is the seam for hardening low-entropy digests: a
one-way Column.hash() over a small value space (a 4-digit PIN, a
short code) is trivially rainbow-tabled as a bare SHA-256, and swapping
hash for a keyed HMAC binds every digest to your secret:
import '@tundralibs/norm/engines/sqlite';
import { type HashAlgorithm, Norm } from '@tundralibs/norm';
declare function hmac(
plain: string,
key: string,
algo: HashAlgorithm,
): Promise<string>;
const norm = new Norm({
database: { dialect: 'sqlite', path: './data' },
secret: process.env.NORM_SECRET,
crypto: {
// HMAC-with-secret siblings + digests instead of bare SHA.
hash: async (plain, algo) => hmac(plain, process.env.HASH_KEY!, algo),
},
});Because sibling digests and db.hash() both route through
crypto.hash, one override hardens encrypt-siblings and
Column.hash() columns consistently, and plaintext-filter rewrites use
the same callback, so lookups still match.
The Migrator derives crypto changes from your definitions. The encrypt,
hash, and digest markers are crypto facts; when one flips, no in-place
ALTER can express the change, so the Migrator performs a table
rebuild (rename aside, recreate, copy, verify, drop) and runs the copy
step per row in JS:
- Turning
.encrypt()on encrypts every existing row's plaintext. - Turning
.encrypt()off decrypts it back to plaintext. - Adding
.hash()backfills the<col>_hashsibling from the decrypted values.
The reviewable .sql artifact makes the rebuild explicit (-- (copy step runs per-row in the migrator: decrypt/re-encrypt/…)), and
apply() refuses to run a plan whose hash does not match the reviewed
artifact.
Digest algorithm changes are one-way and rejected. Changing a
Column.hash('SHA-256') to 'SHA-384' cannot be migrated: the digests
are one-way and the plaintext is gone, so there is nothing to re-digest
from:
Column 'Users.pin': digest algorithm changes cannot be migrated —
digests are one-way, the plaintext is gone. Add a new column and
backfill from source data instead.
See Migrations for the rebuild engine, stored plans, and the advisory lock.
A stored ciphertext can fail to become plaintext on read: it was
corrupted, tampered with (GCM/MAC authentication fails), or written
under a different key than the instance now holds. onDecryptFailure
decides what a read does with that one cell:
| Policy | Behaviour |
|---|---|
'null' (default)
|
The cell reads as null; the row's other columns are untouched, the rest of the page still flows, and a metadata-only decryptError event fires. One bad cell never fails the whole query. |
'throw' |
The read raises a typed NormCryptoError naming the entity, column, and pk. Use it when a failure must be loud: an operational alarm rather than a silent gap. |
import '@tundralibs/norm/engines/sqlite';
import { Norm } from '@tundralibs/norm';
declare const metrics: {
increment(name: string, tags: Record<string, string>): void;
};
const norm = new Norm({
database: { dialect: 'sqlite', path: './data' },
secret: process.env.NORM_SECRET,
onDecryptFailure: 'null', // the default
});
// Observe degraded cells without failing reads:
norm.on('decryptError', (entity, column, pk, reason) => {
metrics.increment('norm.decrypt_failure', { entity, column, reason });
});reason is 'decrypt' (the ciphertext failed its auth tag, or the key
is wrong) or 'decode' (decrypted, but the canonical plaintext was
malformed). The event never carries the ciphertext or the failed value,
only identifiers. Under 'throw', the same context rides on
NormCryptoError.context:
try {
await db.repo('Vaults').find();
} catch (e) {
if (e instanceof NormCryptoError) {
console.error(
`${e.context.entity}.${e.context.column} (pk ${e.context.pk}) ` +
`failed to ${e.context.reason}`,
);
}
}The 'null' default is deliberate: after a
key rotation that has not finished, or a
partially restored backup, a single unreadable cell degrades gracefully
instead of taking down every list view that touches the table.
Rotating the encryption secret, re-encrypting every stored cell from an
old key to a new one, is an admin activity rather than a migration: no
snapshot, no plan file, no DDL. rotateKey() walks each encrypted
table in primary-key order, decrypts each cell with oldKey, and
re-encrypts it with newKey, streaming in chunks so a
multi-million-row table never lands in memory at once.
import { rotateKey } from '@tundralibs/norm';
// Run during a downtime window (app stopped, or not writing encrypted
// columns), then restart the app configured with newKey.
const report = await rotateKey(db, {
oldKey: process.env.OLD_SECRET!,
newKey: process.env.NEW_SECRET!,
chunkSize: 500, // rows per batch (default)
onProgress: (p) => console.log(`${p.entity}: ${p.rotated} cells`),
});
console.log(
`rotated ${report.rotatedCells} cells across ${report.entities.length} tables`,
);Resumable and idempotent. Every ciphertext is stamped with a short
fingerprint of the key that produced it (k1.<fp>.<body>). Rotation
reads that fingerprint to classify each cell: already under newKey
(skip), under oldKey or legacy and un-stamped (rotate), or under some
third key (leave, and count under unknownCells). A crashed run
therefore resumes safely: re-running skips whatever already moved, and
a mistyped oldKey surfaces as "0 rotated, everything unknown" rather
than silent corruption.
// Preview the job first — classifies + counts, writes nothing:
const preview = await rotateKey(db, { oldKey, newKey, dryRun: true });
console.log(`${preview.rotatedCells} cells would rotate`);Searchable hashes survive rotation. .encrypt().hash() sibling
digests are derived from plaintext, not ciphertext, so rotation never
touches them; hashed-equality filters keep working across a rotation
with no reindex.
Rotation reports a tally per entity (rows, rotatedRows,
rotatedCells, skippedCells, unknownCells) plus grand totals. It
throws a NormCryptoError naming the entity, column, and pk if a cell
will not decrypt with oldKey, having written nothing for that row.
Sizing the window. What a cell costs depends on the envelope it carries (Per-cell cost). A cell already on the per-process key rotates in microseconds: both the decrypt and the re-encrypt are plain AES-GCM operations. A cell written before that fast path, or under a CBC/CTR algorithm, pays one PBKDF2 derivation (~22 ms) to decrypt, so a table of such cells rotates at roughly 45 cells per second per core, and a million of them takes hours. The paging and per-row UPDATE round-trips are small next to that derivation. If the window is too long, a cheaper KDF via the crypto override seam is the lever.
v1 is downtime-first. Rotation rewrites rows without holding a global lock or a transaction spanning the whole table; run it while the app is not writing encrypted columns. An online rotation (a runtime keyring that reads both keys during the sweep) is a future addition, and the stamped key-id envelope is the groundwork for it.
What norm does not do yet:
-
Key rotation is downtime-first.
rotateKey()re-encrypts every stored cell from the old key to the new, resumably and idempotently (see Key rotation), but v1 expects a downtime window. There is no runtime keyring that reads both keys online during the sweep yet. -
Foreign-key columns must stay plaintext. A join compares the
actual column values across tables in its
ONclause, and a digest sibling does not help there, since random-IV ciphertext never matches. Keep FK columns unencrypted. A scope column is the exception: a scope is an equality filter, not a join, so an.encrypt().hash()scope column rewrites to digest equality on its<col>_hashsibling and works. A plain.encrypt()scope column (no hash) is still rejected. See Scoping. - Encrypted columns are not orderable or aggregatable. IV-random ciphertext never groups; aggregating an encrypted column is rejected up front.
-
BLOBcannot be encrypted. The codec is text-canonical. -
Escape hatches bypass decryption.
db.raw()returns rows exactly as the driver does (ciphertext, no afterRead) and emits awarningevent;db.query()stays raw unless you bind it to an entity with{ entity: 'Users' }, which rides the decrypt pipeline but not mask compute or hashed-filter rewrites. -
Events never carry plaintext. The metadata-only event surface
(
call,warning, transaction events) emits entity keys, operations, timings, and ids, never row data, plaintext, or secrets.
-
Schema definition: the full
Column.*builder reference, entities, relations, hooks, and validators. - Querying: filters, projections, and how hashed columns fit the filter language.
- Migrations: the rebuild engine, crypto flips, and stored plans.
-
Scoping: tenant scoping and how scope columns
interact with encryption (plain
.encrypt()rejected;.encrypt().hash()matched via the digest sibling). -
Audit: how an
auditreplica carries an.encrypt()column's ciphertext over unchanged (copied, never decrypted or re-encrypted).