prisma-field-encryption

Transparent field-level encryption at rest for Prisma

Usage no npm install needed!

<script type="module">
  import prismaFieldEncryption from 'https://cdn.skypack.dev/prisma-field-encryption';
</script>

README

prisma-field-encryption

NPM MIT License Continuous Integration Coverage Status

Transparent field-level encryption at rest for Prisma.

Context

Demo repository.

See this Twitter thread for more information.

Installation

$ yarn add prisma-field-encryption
# or
$ npm i prisma-field-encryption

Usage

1. Add the middleware to your Prisma client

import { PrismaClient } from '@prisma/client'
import { fieldEncryptionMiddleware } from 'prisma-field-encryption'

export const client = new PrismaClient()

// This is a function, don't forget to call it:
client.$use(fieldEncryptionMiddleware())

Tip: place the middleware as low as you need cleartext data.

Any middleware registered after field encryption will receive encrypted data for the selected fields.

2. Setup your encryption key

Generate an encryption key:

$ cloak generate

The preferred method to provide your key is via the PRISMA_FIELD_ENCRYPTION_KEY environment variable:

# .env
PRISMA_FIELD_ENCRYPTION_KEY=k1.aesgcm256.DbQoar8ZLuUsOHZNyrnjlskInHDYlzF3q6y1KGM7DUM=

You can also pass it directly in the configuration:

client.$use(
  fieldEncryptionMiddleware({
    // Don't version hardcoded keys though, this is an example:
    encryptionKey: 'k1.aesgcm256.DbQoar8ZLuUsOHZNyrnjlskInHDYlzF3q6y1KGM7DUM='
  })
)

Tip: a key provided in code will take precedence over a key from the environment.

3. Annotate your schema

In your Prisma schema, add /// @encrypted to the fields you want to encrypt:

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  content   String? /// @encrypted <- annotate fields to encrypt
  published Boolean @default(false)
  author    User?   @relation(fields: [authorId], references: [id], onDelete: Cascade, onUpdate: Cascade)
  authorId  Int?
}

model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String? /// @encrypted <- can be optional
  posts Post[]
}

Tip: make sure you use a triple-slash. Double slash comments won't work.

4. Regenerate your client

Make sure you have a generator for the Prisma client:

generator client {
  provider = "prisma-client-js"
}

Then generate it using the prisma CLI:

$ prisma generate

You're done!

Migrations

Adding encryption to an existing field is a transparent operation: Prisma will encrypt data on new writes, and decrypt on read when data is encrypted, but your existing data will remain in clear text.

Encrypting existing data should be done in a migration. The package comes with a built-in automatic migration generator, in the form of a Prisma generator:

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["interactiveTransactions"]
}

generator fieldEncryptionMigrations {
  provider = "prisma-field-encryption"
  output   = "./where/you/want/your/migrations"
}

Tip: the migrations generator makes use of the interactiveTransactions preview feature. Make sure it's enabled on your Prisma Client generator.

Your migrations directory will contain:

  • One migration per model
  • An index.ts file that runs them all concurrently

All migrations files follow the same API:

export async function migrate(
  client: PrismaClient,
  reportProgress?: ProgressReportCallback
)

The progress report callback is optional, and will log progress to the console if ommitted.

Following Migrations Progress

A progress report is an object with the following fields:

  • model: The model name
  • processed: How many records have been processed
  • totalCount: How many records were present at the start of the migration
  • performance: How long it took to update the last record (in ms)

Note: because the totalCount is only computed once, additions or deletions while a migration is running may cause the final processedCount to not equal totalCount.

Custom Cursors

By default, records will be iterated upon by increasing order of a model's @id field. Only Int or String IDs are supported.

If you wish to iterate over another field, you can do so by annotating the desired field with @encryption:cursor:

model User {
  id     Int     @id @default(autoincrement())
  email  String  @unique /// @encryption:cursor <- iterate over this field
}

A cursor field has to respect the following constraints:

  • Be @unique
  • Be of type Int or String
  • Not be encrypted itself

Key Management

This library is based on @47ng/cloak, which comes with key management built-in. Here are the basic principles:

  • You have one current encryption key
  • You can have many decryption keys for existing data

This allows seamless rotation of the encryption key:

  1. Generate a new encryption key
  2. Add the old one to the decryption keys

The PRISMA_FIELD_DECRYPTION_KEYS can contain a comma-separated list of keys to use for decryption:

PRISMA_FIELD_DECRYPTION_KEYS=key1,key2,key3

Or specify keys programmatically:

prismaClient.$use(
  fieldEncryptionMiddleware({
    decryptionKeys: [
      'k1.aesgcm256.DbQoar8ZLuUsOHZNyrnjlskInHDYlzF3q6y1KGM7DUM='
      // Add other keys here. Order does not matter.
    ]
  })
)

Tip: the current encryption key is already part of the decryption keys, no need to add it there.

Key rotation on existing fields (decrypt with old key and re-encrypt with the new one) should be done in a migration.

Roadmap:

  • Provide multiple decryption keys
  • Add compatibility with @47ng/cloak keychain environments
  • Add facilities for migrations & key rotation

Caveats & Limitations

You can only encrypt String fields.

You cannot filter on encrypted fields:

// User.name has an /// @encrypted annotation

// This will always return empty results:
prisma.user.findUnique({ where: { name: 'secret' } })

This is because the encryption is not deterministic: encrypting the same input multiple times will yield different outputs, due to the use of random initialisation vectors. Therefore Prisma cannot match the query to the data.

For the same reason, indexes should not be placed on encrypted fields.

Raw database access operations are not supported.

Adding encryption adds overhead, both in storage space and in time to run queries, though its impact hasn't been measured yet.

How Does This Work ?

The middleware reads the Prisma AST (DMMF) to find annotations (only triple-slash comments make it there) and build a list of encrypted Model.field pairs.

When a query is received, if there's input data to encrypt (write operations), the relevant fields are encrypted. Then the encrypted data is sent to the database.

Data returned from the database is scanned for encrypted fields, and those are attempted to be decrypted. Errors will be logged and any unencrypted data will be passed through, allowing seamless setup.

Do I Need This ?

Some data is sensitive, and it's easy to give read access to the database to a contractor or have backups end up somewhere they shouldn't be.

For those cases, encrypting the data per-field can make sense.

An example use-case is Two Factor authentication TOTP secrets: your app needs them to authenticate your users, but nobody else should have access to them.

Cryptography

Cipher used: AES-GCM with 256 bit keys.

Obligatory Disclaimer About Passwords

🚨 DO NOT USE THIS TO ENCRYPT PASSWORDS 🚨

Passwords should be hashed & salted using a slow, constant-time one-way function.

Don't reinvent the wheel: use Argon2id if you can, otherwise scrypt.

License

MIT - Made with ❤️ by François Best

Using this package at work ? Sponsor me to help with support and maintenance.