blindrange · build · demos

blindrange-s3

An S3 endpoint whose operators cannot read what they store — not the contents, not the filenames, not the directory structure. Point anything that already writes to S3 at it: backup tools, compliance archivers, Loki, rclone.
Source & README Backup demo What it costs

The gap it fills

Encrypting before you upload is easy. The problem is that it takes the catalog with it — encrypt the object keys and you have a bucket of opaque blobs plus a local index you now have to keep safe separately, which is the thing you were trying to avoid.

searchable cataloghost cannot read it
Plain S3yes no — the provider reads everything
Encrypt-then-upload no — you encrypted the names tooyes
blindrange-s3yesyes

A prefix listing here is a dyadic index lookup over encrypted keys, not a scan of anything readable. So ls logs/2026/08/ is a native operation and no node learns what it matched.

Point your tools at it

[blindrange]
type              = s3
provider          = Other
endpoint          = http://127.0.0.1:8720
access_key_id     = blindrange
secret_access_key = <yours>
force_path_style  = true

# then, as usual
rclone copy ~/documents blindrange:archive/documents
s3 = boto3.client(
    "s3", endpoint_url="http://127.0.0.1:8720",
    aws_access_key_id="blindrange", aws_secret_access_key="...",
    config=Config(s3={"addressing_style": "path"}))

s3.put_object(Bucket="archive", Key="logs/2026/08/app.log", Body=data)
s3.list_objects_v2(Bucket="archive", Prefix="logs/2026/")
aws configure set default.s3.addressing_style path

aws --endpoint-url http://127.0.0.1:8720 s3 cp file.txt s3://archive/
aws --endpoint-url http://127.0.0.1:8720 s3 ls s3://archive/logs/
python3 examples/s3gateway/gateway.py \
    --state ~/.blindrange/s3.brdb \
    --bootstrap seed.blindrange.dev:7501 \
    --secret blindrange-public \
    --access-key blindrange --secret-key <choose one>
# → http://127.0.0.1:8720

Run it beside your data, never in front of it

Where this process runs is the entire security model. The master key lives in it. Objects are encrypted there, before anything leaves the machine, and the nodes holding them see pseudorandom keys and AEAD blobs.

This is the same line Storj draws between its hosted gateway, where keys sit server-side, and its self-hosted one that keeps encryption end-to-end. Hosting it for you would be more convenient and would remove the only reason to choose it over S3, so we don't offer that half.

Tested, not asserted. Write a payload marked TOP-SECRET-MARKER to confidential/salaries.csv, then scan every key and value on all three nodes' disks: the payload is absent, the filename is absent, and the path is absent — while the object still reads back byte-identical. That check runs in the test suite as test_09_nodes_hold_nothing_readable, not once by hand.

How an object is stored

Two pieces, deliberately split:

That second point matters for the bill: object bodies cost storage and replication but almost no index amplification, so an archive of few large objects sits at the cheap end of the calculator — near object-storage pricing rather than database pricing.

What works

Verified against boto3 — an independent SigV4 implementation, which is the only way to know the signer is right rather than merely self-consistent.

PutObject / GetObject / HeadObject / DeleteObjectyes
ListObjectsV2 — prefix, delimiter, continuationyes
ListBuckets, CreateBucket, DeleteObjectsyes
Multipart uploadyes — 5 MB via boto3 upload_file, byte-identical
SigV4 verificationyes — a wrong secret is refused
aws-chunked streaming bodiesdecoded

Using a real client paid for itself immediately: it found two bugs that would otherwise have shipped. The canonical query string wasn't RFC 3986-encoded, so a prefix like logs/2026/ failed to sign while an unfiltered listing kept working — the bug hid behind the case anyone tries first. And HEAD emitted two Content-Length headers, which makes a strict client abort rather than degrade.

What does not work

An S3 endpoint that fails mysteriously is worse than one with a stated edge, so: no range GETs (a Range: header is ignored and the whole object returned, which will disappoint anything that seeks inside archives); no versioning, ACLs, bucket policies, lifecycle rules, tagging or presigned URLs; buckets are implicitCreateBucket succeeds without creating anything and a bucket exists once it holds an object; multipart parts are held in memory until completion, so a very large multipart upload is bounded by RAM; and streaming payload signatures are not verified, which is why binding to a non-loopback interface with --no-auth is refused outright.

Listing is also bounded by what the index can discriminate — the key field resolves the first 12 characters and exact prefix matching finishes client-side. Correct, but a listing under a very deep shared prefix does more work than S3 would.

A worked example

The backup demo uses plain boto3 and imports nothing from blindrange — it would run unchanged against AWS. Backing up the package source to a three-node network:

snapshot 20260815T180000Z: 14 files, 259,724 bytes in 0.3s

find work --name '*.py'    → 13 of 14 objects
restore + verify           → 14 identical, 0 differ
diff -r original restored  → IDENTICAL to the original tree

what the machine storing it sees:
  B:0c31fd78ff7a2432bd4eec2a0e68d5dd  ->  FkVzGNFZ1d9mPoXmYJUeluoF
  B:5619f394bdb1bd32fb6e1e5d587bec10  ->  46jrSv2aUjqM8ovsEcQFCdvT
  2,059 keys, none readable, none named after your files
blindrange.dev · demos · audit log · source