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
KeySourcetrait;dbsec-vaultimplements 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
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 taggeduse 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.
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.
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.