@octabits-io/pii
PII encryption for the database you already have. Encrypt personal data at rest in your own rows — Postgres, MySQL, SQLite, a document store, a KV — keep it searchable where you must, and rotate or shred keys per scope, with no key-management service, no proxy, and no new subprocessor.
npm i @octabits-io/pii zod
import { createPiiEncryptionService, createBlindIndexService } from '@octabits-io/pii';
const pii = createPiiEncryptionService({
recipient: process.env.PII_RECIPIENT!, // age1… public key
identity: process.env.PII_IDENTITY!, // AGE-SECRET-KEY-1… private key
});
const blindIndex = createBlindIndexService(process.env.BLIND_INDEX_KEY!);
// write
const email = await pii.encryptString('john@example.com'); // Result<Uint8Array | null, PiiEncryptionError>
await db.insert(contacts).values({ email: email.value, emailIndex: blindIndex.generateIndex('john@example.com') });
// read
const row = await db.query.contacts.findFirst({ where: eq(contacts.emailIndex, blindIndex.generateIndex(input)) });
const plain = await pii.decryptString(row.email); // Result<string | null, PiiDecryptionError>
Everything returns a Result (@octabits-io/result) and passes null/undefined
through; nothing throws for an expected failure. Bytes are Uint8Array in and
out. No Node dependency: the library runs on web-platform globals only
(TextEncoder, ReadableStream, crypto.subtle for X25519) — Node 22+, Bun,
Deno, Cloudflare Workers, browsers — with @noble/* and @scure/base for the
primitives and zod for validated JSON decryption. Nothing else; a lint and a
Node-typings-free type check keep it so.
What’s inside
| Export | What it is |
|---|---|
createPiiEncryptionService({ recipient, identity }) |
String / JSON / bytes encryption and decryption. JSON decryption validates against a zod schema you pass. createPiiEncryptionOnlyService({ recipient }) for ingestion paths that must never read. |
encryptPiiString … decryptPiiBytes |
The same as stateless functions, for hosts that fetch keys per call (a per-tenant wrapper). |
createBlindIndexService(key) |
HMAC-SHA256 blind indexes: exact-match lookups on encrypted columns without exposing plaintext. Store next to the ciphertext; WHERE email_index = $1. |
createEnvVarMasterKeyProvider(material) |
Envelope encryption: wraps data keys with a master key derived via HKDF-SHA256. Refuses material shorter than 32 characters at startup — generate it (openssl rand -base64 32), never type it; HKDF stretches nothing. |
createScopedKeyService({ store, scope, masterKeyProvider, cache }) |
Per-scope key management: lazily generates an age keypair + blind-index key per scope (a tenant, an org, a workspace — you name the column), stores them master-key-encrypted through a five-method structural ScopedKeyStore seam, serves decrypted keys through a TTL-enforced cache, and shreds them on destroyKeys() — after which the scope stays destroyed until reinstateKeys(). |
./pseudonym (subpath) |
The pseudonymization vault and veil for what you send to a language model — see below. |
Encrypter / Decrypter / generateIdentity / identityToRecipient |
The age primitives underneath — a vendored, modified TypeScript age (typage, BSD-3-Clause; see THIRD-PARTY-NOTICES). |
encryptHybrid … generateSymmetricKey |
The low-level layer: X25519 + ChaCha20-Poly1305 hybrid, AES-256-GCM symmetric. |
When not to use it
- As the compliance story. Ciphertext in a column is one control. Key
custody is still an environment variable and a process that holds decrypted
keys in memory; access control, row-level security, retention
(
@octabits-io/data-lifecycle), the subprocessor list and the records of processing are still yours. A compromised process reads plaintext: this makes a leaked database useless, not a leaked server. - Columns you sort, range,
LIKEor full-text search. A blind index is an exact match on an HMAC and nothing else. Such a column stays plain — with a disposition saying so — or loses the query. - When the keys must not live with the app. No HSM, no KMS, no key ceremony, no per-access audit on a key: the master key comes from an environment variable and the per-scope keys are rows wrapped with it. A regime that demands hardware custody wants a KMS in front, which is a different design.
- Transparent encryption. Your code encrypts before the write and decrypts after the read; the database sees bytes. Joins, unique constraints and defaults over the plaintext do not exist. If the requirement is “the disk is encrypted”, that is the database’s or the volume’s job.
- Scrubbing prose.
./pseudonymmasks the typed fields you list; a name that appears only in free text passes to the model untouched. Entity recognition over arbitrary text is a DLP or NER tool. - Passwords. An HMAC blind index is not a password hash. Use argon2 or bcrypt.
The scoped-key store is a seam, not a database
createScopedKeyService knows nothing about SQL, drivers or ORMs. It depends on
ScopedKeyStore — insert / find / exists / destroy / reinstate, bound to
a scope at construction — and maps the store’s two neutral outcomes (a lost unique
race, scoped_key_store_conflict, which drives concurrent-generation recovery; any
other failure, scoped_key_store_failure) to its own Result-typed errors
(scoped_keys_not_found, scoped_keys_destroyed, scoped_key_generation_error,
scoped_key_storage_error, or a master-key error).
const scope = { column: 'orgId', value: orgId }; // a multi-tenant host binds its tenant column instead
const keyService = createScopedKeyService({
store, // your ScopedKeyStore over your table
scope,
masterKeyProvider,
cache, // storage only — expiry is enforced below
cacheTtlMs: 5 * 60_000, // default: DEFAULT_KEY_CACHE_TTL_MS
onKeysDestroyed: (s) => bus.publish('keys.destroyed', s), // fan out cache invalidation
});
const keys = await keyService.getKeys(); // lazy-generates on first use
// keys.value: { recipient, identity, blindIndexKey, keyVersion }
await db.transaction((tx) => keyService.generateKeyPair(store.withDb(tx))); // explicit, in your tx
await keyService.destroyKeys(); // crypto-shredding: key material gone, cache dropped
await keyService.getKeys(); // err scoped_keys_destroyed — never a fresh pair
await keyService.reinstateKeys(); // start the scope over on purpose; nothing old opens
A destroyed scope stays destroyed. destroy keeps the row as a tombstone: the
key material is overwritten, destroyed_at is set, and the row is written even
for a scope that never had keys. getKeys() then refuses with
scoped_keys_destroyed instead of lazily generating a new pair, so nothing
written after an erasure is quietly encrypted under keys the erased scope gets
back. hasKeys() answers false. When the scope is deleted outright (the
organisation is gone), delete its tombstone with its other rows. A table created
before the tombstone takes encryptionKeyTombstoneDdl() from ./postgres, or a
drizzle-kit migration on a Drizzle host.
One implementation of the seam — Drizzle on Postgres — ships at
./drizzle: encryptionKeyColumns to spread into your table, a
consumer-declared unique scope column, and withDb(tx) to join a
transaction. drizzle-orm is an optional peer loaded by that subpath alone,
and the subpath imports the peer @octabits-io/postgres; the timestamps (here and in
./pseudonym/drizzle’s pseudonymColumns) read back as ISO-8601 in UTC.
Any other database, ORM or KV store is ~40 lines against the four methods.
import { createDrizzleScopedKeyStore, encryptionKeyColumns } from '@octabits-io/pii/drizzle';
export const encryptionKey = pgTable('encryption_key', {
...encryptionKeyColumns,
tenantId: text('tenant_id').notNull().unique(), // your scope column, unique
});
const store = createDrizzleScopedKeyStore({ db, table: encryptionKey, scope: { column: 'tenantId', value: tenantId } });
Cached keys are plaintext. A cache entry holds a decrypted age identity as a
JS string — not zeroizable, readable in a heap dump for as long as it is cached.
The service enforces cacheTtlMs on every read rather than trusting the cache’s
eviction, because the seam is structural and a Map satisfies it; cacheTtlMs: 0
disables expiry and belongs in tests only. Cache entries are keyed by
column:value; use one cache per key store.
The same store over plain SQL — ./postgres
A host with no ORM gets the same store over a structural executor, and the
table as DDL — the rule every table-owning package follows (./postgres = the
store plus xDdl(), ./drizzle = the columns plus the ORM store), so the two
hosts create the same table and a test holds encryptionKeyColumns and
encryptionKeyDdl() equal, column for column:
import { createPostgresScopedKeyStore, encryptionKeyDdl } from '@octabits-io/pii/postgres';
await pool.query(encryptionKeyDdl({ scope: { column: 'org_id' } })); // idempotent; org_id text NOT NULL UNIQUE
const scope = { column: 'org_id', value: orgId }; // the column as the table names it
const store = createPostgresScopedKeyStore({ db: pool, scope });
const keyService = createScopedKeyService({ store, scope, masterKeyProvider, cache }); // the same scope the store is bound to
await withTransaction(pool, (tx) => keyService.generateKeyPair(store.withDb(tx)));
scope.column is the SQL column name here (the Drizzle store takes the
property name); it must be UNIQUE, because the 23505 on a lost generation
race is what scoped_key_store_conflict reads. Both stores pass one
conformance suite on PGlite.
Pseudonymize what you send to a model — ./pseudonym
The model provider is the one subprocessor most deployments keep. This module
limits what reaches it: on the way in, PII fields become stable opaque tokens
([PERSON_x7k2n9ab], one per record and field within a scope); on the way
out, tokens are resolved back through your own loaders, so the vault stores
references and never plaintext.
import { createPseudonymVault, createPseudonymVeil } from '@octabits-io/pii/pseudonym';
import { createDrizzlePseudonymStore } from '@octabits-io/pii/pseudonym/drizzle';
// one vault + veil per request scope
const vault = createPseudonymVault<'customer' | 'order'>({
store: createDrizzlePseudonymStore({ db, table: aiPseudonym, scope: { column: 'orgId', value: orgId } }),
loaders: { customer: (id) => customers.getDecrypted(id), order: (id) => orders.get(id) },
logger,
});
const veil = createPseudonymVeil({
vault,
readEnabled: () => config.read('ai_pii_pseudonymization_enabled'),
fieldKinds: { firstName: 'person', lastName: 'person', email: 'email', phone: 'phone', 'address.street': 'address' },
});
// in a tool, before the result goes to the model
const masked = await veil.mask({ entityType: 'customer', entityId: 41 }, { firstName, lastName, email });
const note = await veil.scrub(freeText); // already-masked values out of prose, longest first
// on the way back to the person
await vault.revealText(answer); // tokens → values; unknown tokens stay visible
await vault.unresolvedTokens(proposedWrite); // non-empty → refuse: a hallucinated token must not land in a record
Each direction has an explicit bias. Minting fails closed: no token means
the field is withheld, never sent under a name that cannot be reversed. Reveal
fails open: an unknown token stays visible, which a person can read. The
one place that must not fail open is a write path, which is what
unresolvedTokens() exists for. Masking is a veil over typed fields, not
a scanner over prose: a name that appears in none of your fields passes
through untouched, and the field table you supply is the list of what can be
reversed.
./pseudonym/ai-sdk binds it to the Vercel AI SDK with type-only imports
(ai is an optional peer): createPseudonymRevealTransform reveals a UI
message stream at the presentation boundary — holding back a token split
across deltas, reasoning parts too, and the outputs of the tools you list —
and withRevealedToolArgs un-masks tool arguments on the way in, with an
exclude list for a write intent that must stay masked all the way to the
client. Token kinds and prefixes are yours to define; the store is a
two-method seam with an in-memory implementation here.
Why this shape
- Your database is the vault. Ciphertext, blind indexes and wrapped keys are strings in your own store, under whatever access control it already has. The only party that ever sees plaintext is your process.
- age, not a bespoke format. Ciphertext is the age
format;
age -don a shell with the identity decrypts it. No lock-in to this library for reading your own data. - Blind indexes are the honest compromise. Exact match on an HMAC of the plaintext is what makes “find the contact with this email” possible without decrypting the table; it is deliberately not prefix or fuzzy search.
- Per-scope keys make offboarding a delete. Destroy a scope’s key row and its data is unreadable — the erasure a data-protection request asks for, without touching the rows.
Grew up inside @octabits-io/framework and left with the four crypto libraries
that were that toolkit’s only hard dependencies. Stored data is identical before
and after the move —
the AES-GCM layout and the HKDF/HMAC derivations are pinned byte-for-byte
against Node’s own primitives in src/node-compat.test.ts.
License
MIT. The vendored age implementation under src/typage/ is BSD-3-Clause —
see THIRD-PARTY-NOTICES.md.
From @octabits-io/pii@0.18.0 in the platform repository.