A Rust-powered tool for encrypting files securely using AES-GCM and enabling searchable encryption through deterministic tokens.
BaruDB encrypts files using a password-derived key and stores the encrypted version with an .enc extension. It also optionally generates encrypted search tokens (keywords) that enable secure querying over encrypted data—without revealing the plaintext.
- 📂 Accepts a mandatory file path argument.
- 🔑 Securely prompts for a password (no command-line exposure).
- 🔒 Uses AES-256-GCM for authenticated encryption (confidentiality + integrity).
- 🧂 Derives keys with Argon2id and stores salt in
<filename>.salt. - 🔁 Supports:
- 🔐 Encryption/decryption based on file extension.
- 🔖 Search token generation with
-g/--generate_keywords. - 🔍 Keyword search with
-q <keyword>/--query <keyword>.
If you are using Ubuntu you need curl for downloading the installation script, and you need gcc, pkg-config and libssl-dev to build the source code.
On Ubuntu you can do:
sudo apt install curl gcc pkg-config libssl-dev
Install Rust: https://www.rust-lang.org/tools/install
Then after decompressing the zip file:
cd blindsearch
cargo build --release
cargo test
If you want to install it on you system:
cargo install --path .
BaruDB is growing from a keyword-only tool into a fully encrypted
relational database that answers equality, range, full-text, boolean,
ordering, and aggregation queries directly over ciphertext, using symmetric
property-preserving encryption. The server holds only ciphertext and never sees
the key. See DESIGN.md for the architecture, crypto schemes,
threat model, and the roadmap to FHE (via the sibling
baru project).
Run the end-to-end demo:
cargo run -- demo
This creates an encrypted employees table, inserts encrypted rows, and runs
equality / range / full-text / boolean / ordering / count / sum queries against
a ciphertext-only server. The age column also carries a zero-knowledge range
proof on insert (Bulletproofs): the server verifies each age is in 0..=150
without learning it, and rejects out-of-range inserts. See the committed,
structured-store direction in docs/.
The scenario: a hospital holds encrypted patient records, and a researcher asks
cohort questions — "how many patients aged 40–60 with HbA1c > 7 and diagnosis
code E11?" — which the server answers without holding the key. Four
subcommands, the same output discipline as the archive commands (one JSON object
on stdout, human text on stderr, exit 0 only on success,
BLINDSEARCH_PASSWORD honoured).
barudb table create --db <path> --name <t> --schema <spec> # -> {"table":..,"columns":[..]}
barudb table list --db <path> # -> {"tables": [...]}
barudb insert --db <path> --table <t> --stdin # -> {"rows": N}
barudb cohort --db <path> --table <t> --where <pred> [--count | --ids]
Schema. --schema is a comma-separated list of name:type:mode, where
type is int, fixed2 (decimal with two places), or str, and mode
declares how the column may be queried — and therefore what it leaks:
| mode | queryable with | leaks |
|---|---|---|
ore |
= != < <= > >= |
the column's total order (and more — see below) |
det |
= != |
which rows share a value |
opaque |
nothing | ciphertext length only |
barudb table create --db patients.redb --name patients \
--schema 'age:int:ore,hba1c:fixed2:ore,dx_code:str:det,note:str:opaque'
Filtering on an opaque column is an error naming the column, not a silent
full scan: scanning would decrypt every row and quietly turn a column declared
"never queried" into a queried one. A range on a det column is likewise
refused, pointing at ore as what it would take.
Insert reads JSON lines from stdin, one object per row, whose keys must match the declared columns exactly — a missing or unexpected key is an error, because a silently defaulted patient field is a bug no later query can detect. The batch is all-or-nothing: every line is validated before anything is encrypted, so a malformed row on line 900 leaves the database untouched rather than committing lines 1–899.
printf '%s\n' \
'{"age":45,"hba1c":7.4,"dx_code":"E11","note":"controlled"}' \
'{"age":52,"hba1c":8.1,"dx_code":"E11","note":"on insulin"}' \
| barudb insert --db patients.redb --table patients --stdin
# {"rows":2}
Cohort takes one conjunction string, operators = != < <= > >= joined by
AND (there is no OR — union client-side, and no aggregation beyond --count):
barudb cohort --db patients.redb --table patients \
--where 'age>=40 AND age<=60 AND hba1c>7.0 AND dx_code=E11'
# {"count":2}
--count is the default, because returning identifiers discloses strictly
more; --ids gives {"ids":[...]} and has to be asked for. A fixed2 bound
compares in scaled units, and a literal with more precision than the column
carries (hba1c>7.005) is refused rather than rounded — rounding would move the
boundary and change which rows match.
Read the leakage profile in DESIGN.md before using this on real
records. The short version: an ore column's total order — plus, for any two
values, an upper bound on their distance that is exact for neighbours — is
readable from the stored file with no key and no observed query, so for a
low-entropy column like age ORE should be treated as revealing the column. The
ORE index is even stored pre-sorted, so the file hands over the ranking directly.
And a --count answer does not
hide which rows matched from the server: it fetches exactly the matching rows
before reducing them to a number. That last point is the gap PIR is meant to
close; note that PIR would not touch the order leakage, which is in the
ciphertext at rest.
Scope, deliberately: tens of thousands of rows, a handful of columns, one writer
at a time, read-mostly. No joins, no OR, no aggregation beyond COUNT, no
updates, no deletes.
One insert invocation writes its whole batch in a single storage transaction and
then rewrites the database file, so load in a few large batches rather than a
row at a time — a batch of one costs the same rewrite as a batch of twenty
thousand. For scale: 20 000 four-column rows load in about 3 s and the cohort
query above answers in under 0.1 s (release build), for a ~65 MB file — the
encrypted rows and the ORE index are stored as JSON, which is the current
persistence format's cost, not a property of the crypto.
Any subcommand can run against a plaintext database by prefixing the path with
plain:. Two uses: public reference data (ICD-10 code lists, drug dictionaries,
population tables) does not need encrypting and should not need a second tool; and
— the real reason — an encrypted query's cost means nothing without the same
query's unprotected cost to divide by.
barudb table create --db plain:icd10.plain.redb --name codes --schema 'code:str:det,title:str:opaque'
barudb insert --db plain:icd10.plain.redb --table codes --stdin
barudb cohort --db plain:icd10.plain.redb --table codes --where 'code=E11'
barudb put/get/search/list/stats --db plain:refs.plain.redb ...
Same code path, not a parallel one. Plaintext mode is a null cipher behind
the one seam every keyed operation goes through
(crypto::suite), not a branch in the query evaluator. The
server needs no changes at all: its only cryptographic operation is the public,
keyless ORE comparator, and the null ORE code is the same CLWW construction with
the PRF fixed to zero — so the comparator, the sorted ORE index, and its binary
search are bit-for-bit the same code on both kinds of database. DET equality is a
byte comparison of whatever the suite produced; SSE membership is a lookup of the
token's hex. The consequence is that the encrypted/plaintext ratio measures the
price of encryption rather than the difference between two implementations.
A plaintext database is not a security mode, and is built so that nobody reaches one by accident:
- the mode is part of the database's identity (
plain:<path>), not a flag — there is no--plainto forget, and none left over from a previous command line to reinterpret a real database (typing--plainanyway is an error that points at the scheme); - the path must end in
.plain.redb, so a plaintext store is identifiable without opening it; - the suite is written into the file at creation and re-checked on every open: a mismatch is a hard error, never a reinterpretation, and an unlabelled file is refused rather than guessed at;
statsreports"encrypted": falseandtable create/table listreport"encrypted"too — whether a store protects anything is never inferred;- there is no password at any step, so a plaintext run cannot be made to look like an encrypted one.
--timing on cohort, insert, search, list, or stats adds a timing
object beside the result. Instrumentation is off unless asked for, so an un-timed
run does not pay for the timed one's accuracy:
barudb cohort --db patients.redb --table patients --where 'age>=40 AND dx_code=E11' --timing
# {"count":412,"timing":{"total_us":75026,"crypto_us":16837,"io_us":8652,"key_derivation_us":16684}}
How each number is measured (src/metrics.rs):
| field | what it covers |
|---|---|
total_us |
wall clock around everything: opening the database, deriving the key, answering |
crypto_us |
wall clock inside the cipher suite — AES-GCM, AES-GCM-SIV, the ORE/SSE PRFs — plus Argon2id key derivation. Exactly the work a plaintext database does not do |
key_derivation_us |
the Argon2id share of crypto_us (a subset, not an extra term) |
io_us |
wall clock inside the storage layer: redb open, transactions, key/value reads and writes |
key_derivation_us is not in the shape originally specified; it is broken out
because without it crypto_us for a small query is ~99% password stretching and
the ratio is unexplainable. total_us - crypto_us - io_us is deliberately
unattributed: it is predicate evaluation (index binary search, set intersection)
and the JSON (de)serialization of rows and index metadata.
Same schema, same 20 000 rows, same query — age>=40 AND age<=60 AND hba1c>7.0 AND dx_code=E11, returning 815 rows in both cases. Medians of 9 runs, release
build, warm page cache:
total_us |
crypto_us |
of which key derivation | io_us |
factor | |
|---|---|---|---|---|---|
| query, plaintext | 51 260 | 0 | 0 | 8 791 | 1.00× |
| query, encrypted | 75 026 | 16 837 | 16 684 | 8 652 | 1.46× |
| load 20 000 rows, plaintext | 736 421 | 0 | 0 | 225 179 | 1.00× |
| load 20 000 rows, encrypted | 3 209 969 | 2 270 399 | 17 065 | 371 723 | 4.36× |
Two things worth reading off that table, both of which shape where optimization effort should go:
- A cohort count is nearly free to encrypt. Per-query cipher work is
crypto_us - key_derivation_us= 153 µs — the query encrypts four predicate constants (three ORE codes, one DET token) and decrypts nothing, because the server counts ciphertext with public comparisons. The 1.46× is almost entirely a fixed Argon2id cost (16.7 ms) plus a larger index to load: the encrypted file is 68 MB against 34 MB, since every cell carries a 12-byte nonce and a 16-byte GCM tag, and load time follows size. A long-lived client that derives the key once would see the per-query overhead nearly vanish. - Loading is where encryption actually costs. 4.36×, of which 71 % is cipher time — 20 000 rows × 4 cells of RND, plus ORE codes and DET tokens.
Reproduce with --timing on both databases and divide; the differential test
(tests/plaintext_differential.rs) is what
guarantees the two are answering the same question.
A durable, append-only store for small JSON records, searchable by keyword — built for long-running experiment results that must survive for months. The caller hands over plaintext JSON and a keyword list; BaruDB owns all of the cryptography (AES-256-GCM bodies under an Argon2id-derived key, deterministic HMAC keyword tokens). Nothing is encrypted client-side.
Five subcommands. Each prints exactly one JSON object on stdout; prompts,
warnings and errors go to stderr; exit status is 0 on success, non-zero on any
failure.
barudb put --db <path> --keywords k1,k2,... --stdin # -> {"id": "..."}
barudb get --db <path> --id <id> # -> the record's JSON
barudb search --db <path> --keyword k [--keyword k ...] # -> {"ids": [...]}
barudb list --db <path> [--limit N] [--after <id>] # -> {"ids": [...]}
barudb stats --db <path> # -> {"records":N,"bytes":N}
put reads the record body from stdin and stores it verbatim as opaque
UTF-8 bytes — never reshaped or re-serialized, so get returns the original
bytes. The record's key is the caller's own id: the body must be a JSON
object with a string id field (no other field is required or inspected), and
that id is echoed back unchanged. Ids like 1753605600123-9f3a1c04 sort
lexicographically into chronological order, which is the order list returns.
Records are immutable: putting an existing id fails rather than overwriting or duplicating, and there is deliberately no delete — append-only is a reproducibility property, not an oversight.
search with several --keyword flags is a conjunction (AND); union
client-side if you need OR. An unknown keyword is a normal empty result
({"ids": []}, exit 0), not an error. get on an unknown id exits non-zero
with not found in the stderr message.
Keywords are used verbatim, so field:value strings stay whole (the
full-text tokenizer used elsewhere in the crate would split
backend:fhe-tfhe into three words). Use deterministic metadata only —
never a metric value or anything from the record's configuration or notes —
because keyword equality is visible to anyone holding the file. See the leakage
profile in DESIGN.md.
stats reports the number of records and the total stored ciphertext size of
their bodies in bytes (nonce and GCM tag included), computed without decrypting.
Password: taken from BLINDSEARCH_PASSWORD when set — so unattended benchmark
loops never block — and prompted for interactively otherwise. A wrong password is
detected when the archive is opened, rather than silently returning empty
results.
Example:
export BLINDSEARCH_PASSWORD=…
echo '{"id":"1753605600123-9f3a1c04","case":"mnist-linear","ms":2820000}' \
| barudb put --db results.redb \
--keywords suite:bench,case:mnist-linear,backend:fhe-tfhe,ok:true --stdin
# {"id":"1753605600123-9f3a1c04"}
barudb search --db results.redb --keyword suite:bench --keyword backend:fhe-tfhe
# {"ids":["1753605600123-9f3a1c04"]}
barudb get --db results.redb --id 1753605600123-9f3a1c04
Scope, deliberately: no range or ordering queries over encrypted values, no multi-writer concurrency control, no transactions, no deletion. One writer at a time, thousands of records, bodies under 64 KiB — bulk artefacts (ciphertexts, keys, weights) belong outside the archive, referenced by hash.
The original whole-file tool now lives under the legacy subcommand:
cargo run -- legacy input.txt # encrypt
cargo run -- legacy input.txt.enc # decrypt
cargo run -- legacy input.txt -g # generate tokens
cargo run -- legacy input.txt -q secrets # positive test
cargo run -- legacy input.txt -q secret # negative test
Simple encryption:
> blindsearch input.txt
🔐 Enter your password:
✅ Loaded existing salt from input.txt.salt
Encrypting file: input.txt
✅ Encrypted file: "input.txt.enc"
Blind Search finished successful!
Generate keywords:
> blindsearch input.txt -g
🔐 Enter your password:
✅ Loaded existing salt from input.txt.salt
Encrypting file: input.txt
✅ Encrypted file: "input.txt.enc"
Generating keywords tokens in file: input.txt.keywords
Blind Search finished successful!
Query:
> blindsearch input.txt -q secrets
🔐 Enter your password:
✅ Loaded existing salt from input.txt.salt
Querying for keyword: secrets
Keyword 'secrets' found in the encrypted database.
In order to generate a large.txt file, we did:
head -n 100000 /usr/share/dict/words > large.txt
This resulted in a file of size ˜1 Mb.
Then we got the worst case with regards to the size of the keywords extracted from it, since there is no repeatition, which resulted in file size of 1.59 Mb.
This project implements a simplified form of Searchable Symmetric Encryption (SSE), inspired by the construction in Figure 2 (page 21) of the paper:
Searchable Symmetric Encryption: Improved Definitions and Efficient Constructions by Curtmola et al.
We make the following simplifications and assumptions:
- Since this tool operates on a single document at a time, we fix
n = 1(number of documents). Therefore, we do not append indices likew || ito the keywords, as the paper suggests for multi-document settings. - The document identifier is simply the filename. Avoid embedding sensitive information in filenames, as they are not encrypted.
To enable encrypted search:
- Deterministic encryption (e.g., AES in GCM mode) is used to convert each keyword into a fixed-length (16-byte) search token.
- These tokens are:
- Truncated to a uniform length.
- Shuffled before being written to the
.keywordsfile to prevent revealing search patterns or keyword order.
An adversary:
- Cannot generate valid tokens without the user's encryption key.
- Cannot distinguish between valid and dummy tokens.
To query:
- The user inputs a keyword.
- It is deterministically encrypted using the same process and key.
- The token is compared against the stored list.
- If found, the keyword is confirmed to exist in the document.
-
File encryption method: AES-GCM is part of many different standards, including NIST and FIPS, and is specifically adopted for encryption of data at rest by big companies like Amazon and Google, see the References.
-
Deterministic encryption: The original paper recommends using a PRP for generation of tokens. This means that the function used must be invertible and its output must be indistiguishable from random. Therefore, naive encryption would leak the keyword length. One solution for it is to use fixed length keywords, padding when needed. Here we simply adopt truncated AES-GCM with zero nonce. We leave as a future task to implement padding appropriately for different sizes of keywords like suggested in the paper.
This design assumes the following:
- The adversary may observe:
- The encrypted file (
.enc) - The salt (
.salt) - The keyword tokens (
.keywords)
- The encrypted file (
- The adversary does not know the user's password.
- The adversary cannot:
- Derive the encryption key from the password (thanks to Argon2id).
- Generate valid tokens.
- Access plaintext contents or keywords.
This model does not hide:
- Access/query patterns (i.e., which tokens are queried and how often).
More specifically, the paper improves the model called IND2-CKA, where CPA-security is adapted to protect keywords. The high level idea is that an adversary can not distinguish keywords encryption from random data, therefore keywords are secure. The model must assume the adversary have no information about plaintexts, because deterministic encryption would always output the same result, hence the adversary would win the underlying security game. Beyond the basic model, the paper provides a simulation-based definition, which gives an extra guarantee.
- The number of keywords is revealed by the
.keywordsfile. - This can be mitigated by:
- Defining a maximum keyword capacity.
- Padding unused slots with random dummy tokens.
- ✅ Fast and simple implementation suitable for local single-file use.
- ❌ Does not support complex queries, multi-document indexing, or access pattern privacy.
- Other cryptographic solution could be used to solve all the privacy issues, like for example FHE, however it would be orders of magnitude slower in practice.
Searchable Symmetric Encryption: Improved Definitions and Efficient Constructions. Reza Curtmola, Juan Garay, Seny Kamara, Rafail Ostrovsky.
Practical Challenges with AES-GCM and the need for a new cipher. Panos Kampanakis, Matt Campagna, Eric Crocket, Adam Petcher, Shay Gueron.
Searchable Encryption: The “Just Right” Balance of Data Privacy and Usability Blind Insight