skunk-crypt 🦨🔐

Transparent, application-level AES-256-GCM encryption for PostgreSQL columns, exposed as drop-in Skunk codecs. Your data is plain text in the application and cipher text in the database — encryption is just another codec.

Highlights

Installation

This library is published for Scala 2.13 and 3.

libraryDependencies += "de.thatscalaguy" %% "skunk-crypt" % "1.0.0"

Skunk, Cats and Cats Effect are provided dependencies, so skunk-crypt uses the versions already on your classpath. Make sure Skunk itself is present:

libraryDependencies += "org.tpolecat" %% "skunk-core" % "1.0.0"

Quick start

1. Generate a key

Keys are raw AES keys, hex-encoded — 64 hex characters for AES-256 (32 or 48 are also accepted, for AES-128/192):

openssl rand -hex 32

2. Build a validated CryptContext

Construction returns an Either, so a malformed or wrong-length key fails fast with a reason instead of surfacing later inside a query:

import de.thatscalaguy.skunkcrypt.*

given CryptContext =
  CryptContext
    .keysFromHex(sys.env("DB_ENC_KEY"))
    .fold(reason => sys.error(s"Invalid encryption key: $reason"), identity)

3. Use the codecs

Encrypted columns are stored as TEXT, regardless of their logical type:

CREATE TABLE users (
  email TEXT,
  age   TEXT
)
import skunk.*
import skunk.implicits.*
import de.thatscalaguy.skunkcrypt.*

// non-deterministic for `age`, deterministic (searchable) for `email`
val insert: Command[(String, Int)] =
  sql"INSERT INTO users (email, age) VALUES (${cryptd.text}, ${crypt.int4})".command

val all: Query[Void, (String, Int)] =
  sql"SELECT email, age FROM users".query(cryptd.text ~ crypt.int4)

crypt vs cryptd

Both objects expose the same codecs.

Key rotation

keysFromHex accepts several keys. Encryption uses the last key; the index of the key used is embedded in each stored value, so older keys keep decrypting older rows. Only ever append keys — never reorder or remove them.

import de.thatscalaguy.skunkcrypt.*

given CryptContext =
  CryptContext.keysFromHex(oldKeyHex, newKeyHex).fold(sys.error, identity)

Supported types

text, int2, int4, int8, float4, float8, bool, uuid, numeric (BigDecimal), date (LocalDate), timestamp (LocalDateTime) and timestamptz (OffsetDateTime).

Errors

Decryption raises a typed CryptError:

See the README for the full security model and the Scaladoc for the API reference.