blindrange

Build on blindrange — Python

The full API, nine steps from an empty directory. If you would rather start with familiar verbs, the SQL-shaped guide covers the same ground and is the easier door in. Everything an application needs from a storage provider that cannot read it. Every sample below is quoted from examples/quickstart.py, which the test suite runs — if the code here stopped working, the build would fail.
  1. Install
  2. Describe your data — the one decision that matters
  3. Create the database
  4. Create — writing records
  5. Read — range, prefix, AND, ordering
  6. Read without decrypting — count, histogram, sum
  7. Update — why there isn't one
  8. Delete — and actually reclaiming space
  9. Going further — scale, cost, other machines

1Install

Python 3.10 or newer. Upgrade pip inside the venv first — a fresh venv seeds whatever pip the system Python bundles, and anything older than pip 21 predates the packaging standard used here and fails with a message about a missing setup.py.

git clone https://github.com/alviso/blindrange && cd blindrange
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -e .

You can point at the public demo network and write nothing yourself:

# bootstrap seed.blindrange.dev:7501, secret "blindrange-public"
.venv/bin/python examples/quickstart.py
The demo network is a demo. No durability promise, and the network secret is published — it is an anti-vandal measure, not access control. Run your own nodes for anything you would mind losing. Everything below works identically either way; only --bootstrap and --secret change.

2Describe your data

A schema names the fields you want to query and how precisely. This is the only place in the whole system where you make a privacy decision, so it is worth thirty seconds.

schema = {
    "amount": {"type": "int", "bits": 20, "leaf_width": 64},
    "day":    {"type": "int", "bits": 16, "leaf_width": 16},
    "status": {"type": "str", "bits": 30, "chars": 6, "leaf_width": 16},
}
keywhat it isexample
type "int" for numbers and anything you can turn into one (timestamps, day numbers, cents). "str" for prefix-searchable text. "int"
bits Size of the value domain: values run 0 … 2bits−1. Pick the smallest that fits — every extra bit is another index level per record. For "str" it must be chars × 5. 20 (up to ~1,048,575)
leaf_width The resolution the network can distinguish, and the only key here with a privacy consequence. Must be a power of two; a value that is not gets snapped down, so choose deliberately. 64
chars "str" only: how many leading characters are indexed, at 5 bits each. Beyond that the value is stored but not searchable. 6

Starting points for the fields most applications actually have:

fieldspecwhat the network can tell
money, to the cent, up to ~$10k {"type": "int", "bits": 20, "leaf_width": 64} which 64-cent band
unix timestamp, second precision {"type": "int", "bits": 31, "leaf_width": 4096} which ~1.1-hour window
date as a day number {"type": "int", "bits": 16, "leaf_width": 16} which 16-day window
status, category, short tag {"type": "str", "bits": 30, "chars": 6, "leaf_width": 16} a coarse band of the first 6 characters
Why coarser is better on both axes. A query is answered by fetching whole buckets of leaf_width, so no observer — however many queries it watches — can localise a value more precisely than one bucket. That bound is structural, not statistical. It also means fewer index entries per record, so coarse is cheaper too. Privacy is the cheap direction here; resolution is what costs money.

Fields you do not list are still stored and returned — they are simply not queryable, and nothing about them is indexed. In the example, customer is carried along and never appears on any node.

3Create the database

db = Owner.create(
    "orders.brdb",                        # state_path
    "correct horse battery staple",       # passphrase
    schema,                                # from step 2
    bootstrap=["seed.blindrange.dev:7501"],  # any live peer
    network_secret="blindrange-public",      # which network
)
parameterwhat it isexample
state_path Path to the local file holding your master key, schema, writer id and chain counters. Encrypted at rest with scrypt + AES-GCM. This is the file to back up — it is the database, as far as you are concerned; the network only has ciphertext. "orders.brdb"
passphrase Unlocks that file. Never sent anywhere, by anything. It is stretched with scrypt, so a weak one is slow to attack rather than safe. "correct horse battery staple"
schema The dict from step 2. Validated here, so a bad leaf_width fails at create rather than at query time. Field names may not begin with @. schema
bootstrap A list of host:port for any live peer. One is enough — gossip finds the rest, and the list you pass is merged into the one already stored, so peers accumulate rather than replace. ["seed.blindrange.dev:7501"]
network_secret Which network you are joining. It is an HMAC membership check — anti-vandal, not access control, and the public one is published. Defaults to "", which is its own network. "blindrange-public"

Reopening takes neither the schema nor the secret — both live in the state file:

db = Owner.open(state_path, passphrase, bootstrap=bootstrap)

Then say local-first once — the SQL layer and the npm package default it, the raw API asks:

db.enable_mirror()

Reads — including proving something absent — now answer from a complete encrypted mirror beside the state file; only writes touch the network.

Both calls are exercised by quickstart.py, which takes these as command-line arguments so you can point it at your own network.

Nobody can recover this for you. The passphrase never leaves your process. It unlocks the state file holding the master key, and every index key on the network is an HMAC under that key. Lose both the file and the passphrase and the data is gone — that is the same property that stops the storage provider reading it, seen from the other side. Back up state_path; the network is not a backup of it.

4Create — writing records

db.insert_many(rows)
db.drain()

insert_many returns once a quorum of replicas holds each key; drain() waits for the rest. Call it before you measure anything, and before the process exits — not after every insert, which would throw away the batching.

5Read — range, prefix, AND, ordering

hits = db.query("amount", 300, 500)
prefix = db.query_prefix("status", "ref")
both = db.query_multi([{"field": "amount", "lo": 300, "hi": 500},
                       {"field": "day", "lo": 200, "hi": 210}])

For large or unbounded results, stream in bounded memory:

newest = list(db.query_stream([{"field": "day", "lo": 0, "hi": 65535}],
                              limit=5, order="-day"))

order="-day" asks for the newest matches; ascending with a limit answers with the oldest, which is a different question.

6Read without decrypting anything

These are answered from index metadata alone. Nothing is fetched, nothing is decrypted, and the cost does not grow with the number of matches.

est, err, n = db.approx_sum("amount", 0, 1023)
# count(amount 300..500)   → 35
# histogram                → [(0, 22), (256, 35), (512, 35), (768, 28)]
approx_sum(amount)       → 64,644 ± 3,840 over 120 rows
The estimate comes with its error bar, and the API will not hand you one without the other. Each bucket contributes count × midpoint, so the per-record error is at most leaf_width/2: the resolution you traded away for privacy is the error. That is the honest way round, and it is why the number is called approx_sum.

7Update — why there isn't one

There is no update call. A record is one sealed blob under a random handle, and editing in place would mean asking a node to modify ciphertext it cannot read. So:

db.delete_many([victim["_rid"]])
db.insert_many([changed])
db.drain()

Delete first, then insert. A crash between the two costs you a duplicate rather than a hole, and a duplicate is something you can notice and fix.

8Delete — and actually reclaiming space

db.delete_many(doomed)
db.drain()

The rows stop being returned immediately. But a delete writes a tombstone: the index entries are still out there and still counted, which is why count() can exceed what queries return.

# deleted 12 rows — gone from queries immediately
# count() still says       → 12  (tombstoned, not yet reclaimed)
compact()                → kept 5,616 index entries, dropped 676
# count() now says         → 0
stats = db.compact()
compact() is the only operation that forgets. Until it runs, deleted data is unreadable but its index entries remain. Schedule it — every couple of hundred thousand deletes, not once a week. Tombstone backlog does not hurt in proportion to its size: on our own demo network a day of skipped compactions turned a 20-second delete phase into 481 seconds and needed 2.8 GB of RAM to clear.

9Going further

Other machines, same database

invite = db.invite()          # hand this to the other device
other = Owner.accept(path, passphrase, invite)

Multiple writers work concurrently; each owns its own append chains, so they never collide.

When one client is not enough

Data shards across nodes automatically — the ring handles that, and adding nodes adds capacity. The client is what runs out first, because compaction rewrites an epoch in memory. When that bites, split across independent master keys:

from blindrange.sharded import ShardedOwner
db = ShardedOwner.create(path, passphrase, schema, bootstrap, shards=4)

Same API. Measured at 4 shards: compaction peak memory 28.4 MB → 7.9 MB. Every range query visits every shard, so keep the count low.

What it costs

A record costs one blob plus one index entry per dyadic level per indexed field, times the replication factor. Coarse leaf_width means fewer levels. There is a calculator on the front page with the real numbers.

Check the claims yourself

Step 8 of the quickstart prints what a node actually holds for the data you just wrote:

I:0003444dfc39de14496d98436571a361  →  S/4cP9LvVoA=
I:0018472b150a157133733359a041a84e  →  F6QGdxhzbFg=
# no field names, no values, no ordering — and none of the
# customer ids appear anywhere on any node

The attack harness runs the real attacks from the literature against this design and prints the numbers, including the ones that work. Read the threat model before deciding this fits: it is explicit that this is not for healthcare or high-stakes PII, and why.

blindrange.dev · demos · the web guide · built on it · audit log · S3 gateway · live network · source