@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, LIKE or 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. ./pseudonym masks 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 -d on 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.