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
--bootstrap and --secret change.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},
}
| key | what it is | example |
|---|---|---|
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:
| field | spec | what 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 |
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.
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
)
| parameter | what it is | example |
|---|---|---|
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.
state_path; the network is not a backup of
it.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.
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.
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
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.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.
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()
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.
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.
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.
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.