Skip to content

dbsec

Field-level encryption for PostgreSQL columns, as a Rust library — with a transparent proxy for the applications that cannot be changed. Both write the same bytes, so one table can serve an application that links the library and clients that go through the proxy.

  • AES-256-GCM ciphertext envelope with key ids (rotation-friendly), bound to its column and, opt-in, to its row — stored bytes moved elsewhere stop authenticating
  • Equality search over ciphertext via a deterministic blind index
  • Storage-free pseudonymization (FF1 FPE + HMAC tokens) and read-path masking
  • Keys behind a KeySource trait; dbsec-vault implements it over HashiCorp Vault / OpenBao (Transit-wrapped DEKs, KV-stored index keys); unsafe_code = "forbid" workspace-wide
  • PostgreSQL only; TLS on both proxy hops (rustls); flat TOML config

The library

dbsec-core is the product; the policy is declared on the struct that holds the data. Until the crates are on crates.io, depend on the repository:

[dependencies]
dbsec-core = { git = "https://github.com/rsvalerio/dbsec", features = ["derive"] }
# pin a `tag = "vX.Y.Z"` for reproducible builds; every merge to main is tagged
use dbsec_core::{protector::Protector, Protect};

#[derive(Protect, Clone)]
#[dbsec(table = "users", row_key = "id", sealed_derive(sqlx::FromRow))]
struct User {
    id: i64,
    #[dbsec(searchable)]               email: String,
    #[dbsec(fpe, mask(keep_last = 4))] phone: String,
    display_name: String,
}

let p = Protector::new(User::policy(), keys)?;    // keys: Arc<dyn KeySource> — your KMS

let sealed = user.seal(&p)?;                      // write: UserSealed, email is Vec<u8>
let rows: Vec<UserSealed> =                       // search by equality, over ciphertext
    sqlx::query_as("SELECT * FROM users WHERE substring(email from 1 for 32) = $1")
        .bind(User::email_term(&p, b"a@b.io")?).fetch_all(&mut conn).await?;
let user = rows[0].clone().open(&p)?;             // read: wrong row is Error::Decrypt
let shown = user.masked(&p)?;                     // UserMasked: phone = "********4567"

Getting it wrong is an error, not a weaker seal: a column the policy does not name, a row-bound column without its row key, an attempt to open a value that was never sealed. Without the derive, Protector offers seal / open / search_term / mask by column name, and a policy can be read from the proxy's TOML (serde feature) so the two share one file. crates/core/examples/embedded.rs is the complete sqlx application, run by make e2e; the crate docs carry the runnable example, the stored-format table and what the library does not protect.

One thing a KeySource must get right: the deterministic transforms take no column into their derivation, so every column name must resolve to its own index key — a key shared by two columns makes equal plaintexts store equal bytes across them. dbsec-vault gets this right; a custom implementation has to.

The proxy

For an application that cannot be changed, the dbsec binary sits between the client and PostgreSQL and does the same work on the wire: it seals values in INSERT/UPDATE, rewrites equality predicates over searchable columns to the blind index, and opens and masks result columns — with TLS on both hops and the same policy, in TOML:

listen      = "127.0.0.1:6432"
upstream    = "127.0.0.1:5432"
control_dsn = "postgres://dbsec@127.0.0.1:5432/app?sslmode=require"
keys_file   = "/etc/dbsec/keys.toml"      # or a [vault] section

[[column]]
table = "users"
column = "email"
searchable = true

[[column]]
table = "users"
column = "phone"
transform = "fpe"
mask = { keep_last = 4 }

[[table]]
table = "users"
row_key = "id"

A proxy cannot see everything a library can. What it refuses, what it warns about, and how to run it are documented in docs/:

Document Covers
docs/operating.md on_unprotected and every site it governs, search_path / encoding / string-conformance watching, row binding and its constraints, identifier folding, the resource limits (max_*), COPY, and read-path refusal semantics
docs/deploying.md Fail-closed startup, TLS on all four hops, secret file modes, core dumps and swap, SCRAM channel binding, the systemd unit
docs/runbooks.md Key rotation and compromise recovery, Vault token lease and revocation, upgrading DBS1 rows, renaming a bound column, retiring the shared-map layout
plans/PLAN.md Design record: scope decisions, the envelope format, milestones, accepted trade-offs
plans/COMPARISON.md How it compares to Acra, CipherStash, pgcrypto, Vault Transit and the AWS DB Encryption SDK

Two defaults worth knowing before anything touches production: startup with no config file refuses to run rather than relaying plaintext, and on_unprotected = "warn" (the default) logs-and-relays statements the rewrite cannot cover — run on warn, collect the warnings, fix them, then switch to reject. Both are explained in the docs above.

Develop

make help      # all targets
make check     # QA gates via `ops qa` (fmt, clippy, build, deps, test, doctests)
make deny      # license/advisory audit
make e2e       # driver matrix through the real binary (needs docker)
make e2e-vault # OpenBao-backed keys against a live dev-mode server (needs docker)

Both e2e targets also run in CI (.github/workflows/e2e.yml) against service containers; they reuse services you already run when DBSEC_E2E_DSN / DBSEC_E2E_VAULT_ADDR are set, and start throwaway containers otherwise. No suite names a listen port — every proxy binds 127.0.0.1:0 and the harness reads the bound port from the dbsec listening line, so parallel checkouts never contend.

CI and release run through forge reusable workflows; lint configs and CONTRIBUTING.md are copies of forge's canonical versions, kept honest by make forge-sync (also a CI job). Versioning is conventional-commit driven (cocogitto bumps on every merge to main); releasing to crates.io is a separate, manual decision — see docs/releasing.md.

About

Transparent PostgreSQL proxy for field-level encryption, in Rust

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages