Skip to content

Repository files navigation

🔍 BaruDB

A Rust-powered tool for encrypting files securely using AES-GCM and enabling searchable encryption through deterministic tokens.


🔐 Overview

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.

Highlights

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

🚀 Installation

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 .

Encrypted relational database

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

Patient data and cohort queries

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.

Plaintext mode — the baseline every measurement is divided by

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 --plain to forget, and none left over from a previous command line to reinterpret a real database (typing --plain anyway 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;
  • stats reports "encrypted": false and table create / table list report "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

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

Worked example

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:

  1. 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.
  2. 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.

Encrypted archive (record store)

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.

Legacy whole-file searchable encryption

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

Examples:

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.

Large file

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.

🧠 Design

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 like w || i to 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:

  1. Deterministic encryption (e.g., AES in GCM mode) is used to convert each keyword into a fixed-length (16-byte) search token.
  2. These tokens are:
    • Truncated to a uniform length.
    • Shuffled before being written to the .keywords file 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.

Choice of algorithms

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

🛡️ Threat Model

This design assumes the following:

  • The adversary may observe:
    • The encrypted file (.enc)
    • The salt (.salt)
    • The keyword tokens (.keywords)
  • 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.


⚠️ Limitations of current implementation

  • The number of keywords is revealed by the .keywords file.
  • This can be mitigated by:
    • Defining a maximum keyword capacity.
    • Padding unused slots with random dummy tokens.

⚖️ Trade-offs

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

References

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.

Google cloud uses GCM

Searchable Encryption: The “Just Right” Balance of Data Privacy and Usability Blind Insight

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages