Transparent, application-level AES-GCM encryption for PostgreSQL columns β as drop-in Skunk codecs.
Plaintext in your application. Ciphertext in your database. Encryption is just another codec.
- π AES-256-GCM authenticated encryption β values are confidential and tamper-evident.
- π§© Drop-in Skunk codecs β swap
text/int4/β¦ forcrypt.text/crypt.int4; the rest of your query is unchanged. - π Deterministic mode for equality search, using a synthetic IV (AES-GCM-SIV style) β searchable without the fixed-IV footgun.
- π Built-in key rotation β encrypt with the newest key, transparently decrypt with any previous one.
- 𧬠Rich type support β text, ints, floats,
Boolean,UUID,BigDecimal, dates and timestamps. - β‘ Zero ceremony β one implicit
CryptContext, no effect wrappers, no schema changes beyondTEXTcolumns. - β Validated by construction β keys are checked up front; decryption failures are typed.
| Dependency | Version |
|---|---|
| Scala | 2.13, 3.3 |
| Skunk | 1.0 |
| Cats Effect | 3.x |
| JDK | 8+ |
| PostgreSQL | any β encrypted columns are TEXT |
Skunk, Cats and Cats Effect are declared as
provideddependencies, so skunk-crypt inherits the exact versions already on your classpath and never drags in a conflicting one.
sbt
libraryDependencies += "de.thatscalaguy" %% "skunk-crypt" % "1.0.0"Mill
ivy"de.thatscalaguy::skunk-crypt:1.0.0"scala-cli
//> using dep de.thatscalaguy::skunk-crypt:1.0.0You also need Skunk itself on the classpath (it is a provided dependency):
libraryDependencies += "org.tpolecat" %% "skunk-core" % "1.0.0"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 32Keep the key out of source control β load it from an environment variable, a secrets manager, or your config of choice.
Construction is validated and returns an Either, so a malformed or wrong-length
key fails fast with a reason instead of blowing up 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)Encrypted columns are stored as TEXT, regardless of their logical type:
CREATE TABLE users (
email TEXT,
age TEXT
)import cats.effect.*
import skunk.*
import skunk.implicits.*
import org.typelevel.otel4s.trace.Tracer
import org.typelevel.otel4s.metrics.Meter
import de.thatscalaguy.skunkcrypt.*
object Demo extends IOApp.Simple:
given Tracer[IO] = Tracer.Implicits.noop
given Meter[IO] = Meter.Implicits.noop
// Generate with: openssl rand -hex 32
given CryptContext =
CryptContext
.keysFromHex(sys.env("DB_ENC_KEY"))
.fold(reason => sys.error(s"Invalid encryption key: $reason"), identity)
val session: Resource[IO, Session[IO]] =
Session
.Builder[IO]
.withHost("localhost")
.withPort(5432)
.withUserAndPassword("postgres", "postgres")
.withDatabase("postgres")
.single
def run: IO[Unit] = session.use: s =>
for
_ <- s.execute(
sql"INSERT INTO users (email, age) VALUES (${cryptd.text}, ${crypt.int4})".command
)(("[email protected]", 30))
// The database now holds ciphertext; we read it back as plain values:
rows <- s.execute(
sql"SELECT email, age FROM users".query(cryptd.text ~ crypt.int4)
)
_ <- IO.println(rows) // List(([email protected], 30))
yield ()Because email was written with the deterministic codec, you can also look it up
by its encrypted value:
// the parameter is encrypted deterministically, so it matches the stored cipher text
val byEmail: Query[String, Int] =
sql"SELECT age FROM users WHERE email = ${cryptd.text}".query(crypt.int4)Both objects expose the same set of codecs; pick per column based on whether you need to query by the encrypted value.
| Object | Mode | Same input β same cipher text? | Use it for |
|---|---|---|---|
crypt |
Non-deterministic | No (random IV) | The safe default β anything you don't search |
cryptd |
Deterministic | Yes (synthetic IV) | Columns you need to match with WHERE x = ? |
keysFromHex accepts more than one key. Encryption always uses the last key;
the index of the key used is embedded in the stored value, so any earlier key can
still decrypt the rows it originally encrypted:
// new key encrypts; both keys decrypt
given CryptContext =
CryptContext.keysFromHex(oldKeyHex, newKeyHex).fold(sys.error, identity)This gives a simple manual rotation path: append a new key, and existing rows keep decrypting until you re-encrypt them. Only ever append keys β never reorder or remove them, or the embedded indices of existing rows will no longer match.
Available on both crypt and cryptd:
| Codec | Scala type |
|---|---|
text |
String |
int2 |
Short |
int4 |
Int |
int8 |
Long |
float4 |
Float |
float8 |
Double |
bool |
Boolean |
uuid |
java.util.UUID |
numeric |
BigDecimal |
date |
java.time.LocalDate |
timestamp |
java.time.LocalDateTime |
timestamptz |
java.time.OffsetDateTime |
- Values are encrypted with AES-256-GCM, an authenticated cipher: a modified or truncated cipher text fails to decrypt rather than returning garbage.
- The stored format is
base64(iv).keyIndex.base64(cipherText). The IV and key index travel with the value, so rotation and per-row IVs work without extra bookkeeping. crypt(non-deterministic) is the safe default: a fresh random IV per write means equal values are indistinguishable in the database.cryptd(deterministic) derives its IV from the plain text (a synthetic IV, as in AES-GCM-SIV), so equal values encrypt identically and stay searchable while distinct values still get distinct keystreams. By design it reveals which rows share the same value β only use it where that is acceptable.- skunk-crypt encrypts column values; it does not hide column names, row counts, or access patterns, and it is not a substitute for transport (TLS) or at-rest disk encryption.
Key construction returns Either[String, CryptContext] β a bad key never reaches a
query. Decryption raises a typed CryptError (both subtypes extend
RuntimeException, so they propagate through Skunk's codec path):
| Error | Meaning |
|---|---|
MalformedCiphertext |
The stored value isn't iv.keyIndex.data (e.g. legacy plaintext) |
DecryptionFailure |
Wrong key, unknown key index, or a failed authentication tag |
sbt testPure codec tests run anywhere; the Skunk integration suite uses Testcontainers and needs a running Docker daemon to start a PostgreSQL instance.
Full guide and API reference: https://thatscalaguy.github.io/skunk-crypt/ Β· Scaladoc
skunk-crypt is built and maintained by ThatScalaGuy
Need extended or help on your project? Get in touch at thatscalaguy.de.
Issues and pull requests are welcome at
ThatScalaGuy/skunk-crypt. Please run
sbt headerCheckAll scalafmtCheckAll test before opening a PR.
Licensed under the Apache License 2.0.
Upgrading from 0.0.1: the deterministic mode previously used a fixed IV. Data written by
cryptdbefore this change must be re-encrypted (read with the old version, write with the new).cryptdata is unaffected.
