skunk-crypt πŸ¦¨πŸ”

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.

Maven Central Cats Friendly CI javadoc

Scala 2.13 | 3 JDK 8+ License: Apache 2.0

✨ Highlights

  • πŸ”’ AES-256-GCM authenticated encryption β€” values are confidential and tamper-evident.
  • 🧩 Drop-in Skunk codecs β€” swap text/int4/… for crypt.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 beyond TEXT columns.
  • βœ… Validated by construction β€” keys are checked up front; decryption failures are typed.

🧩 Compatibility

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 provided dependencies, so skunk-crypt inherits the exact versions already on your classpath and never drags in a conflicting one.

πŸ“¦ Installation

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.0

You also need Skunk itself on the classpath (it is a provided dependency):

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

πŸš€ Quick Start

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

Keep the key out of source control β€” load it from an environment variable, a secrets manager, or your config of choice.

Define a CryptContext

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)

Use the codecs

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)

πŸ”€ crypt vs cryptd

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 = ?

πŸ”‘ Key Rotation

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.

🧬 Supported Types

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

πŸ›‘οΈ Security

  • 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.

🧯 Error Handling

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

πŸ§ͺ Testing

sbt test

Pure codec tests run anywhere; the Skunk integration suite uses Testcontainers and needs a running Docker daemon to start a PostgreSQL instance.

πŸ“– Documentation

Full guide and API reference: https://thatscalaguy.github.io/skunk-crypt/ Β· Scaladoc

πŸ’Ό Commercial Support

skunk-crypt is built and maintained by ThatScalaGuy

Need extended or help on your project? Get in touch at thatscalaguy.de.

🀝 Contributing

Issues and pull requests are welcome at ThatScalaGuy/skunk-crypt. Please run sbt headerCheckAll scalafmtCheckAll test before opening a PR.

πŸ“„ License

Licensed under the Apache License 2.0.

Upgrading from 0.0.1: the deterministic mode previously used a fixed IV. Data written by cryptd before this change must be re-encrypted (read with the old version, write with the new). crypt data is unaffected.