---
title: "@octabits-io/pii"
summary: "PII encryption for the database you already have: age-format hybrid encryption (X25519 + ChaCha20-Poly1305, a vendored TypeScript age), AES-256-GCM, blind indexes for exact-match search on encrypted fields, envelope-encrypted master keys, and per-scope key management behind a structural store seam."
install: "pnpm add @octabits-io/pii"
availability: "Available from November 2026"
version: "0.18.0"
license: "MIT"
related:
  - name: "@octabits-io/postgres"
    url: "https://octabits.io/platform/postgres.md"
  - name: "@octabits-io/result"
    url: "https://octabits.io/platform/result.md"
canonical_url: "https://octabits.io/platform/pii"
---

# `@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.

```bash
npm i @octabits-io/pii zod
```

```ts
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](https://github.com/FiloSottile/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).

```ts
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.

```ts
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:

```ts
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.

```ts
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](https://age-encryption.org/)
  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.