Gistory: building a relay that cannot read your data
End-to-end encrypted multi-device sync for a prompt keeper: a blind relay over Cloudflare D1, and 62 adversarial findings with none exploited.
The problem with sync
A prompt keeper is only useful if it follows you between machines. That usually means an account and a server that holds your data in a form it can read, and every sync feature is a promise that the server will be careful. We wanted a different shape: sync that works across devices while the server stays incapable of reading a single prompt. Gistory ships that, and gistory-sync, the engine underneath it, is the same code the app and its service worker run.
A key that never leaves the device
The encryption key is derived on the device from two inputs: passphrase plus chainId, run through PBKDF2 into an AES-GCM key. The chainId doubles as the derivation salt and travels inside the pairing code, so a second device with the same passphrase derives the same key and decrypts everything, while a stranger holding the chain id alone still has nothing. The passphrase never leaves the device. Writing is stricter than reading: a chain also needs a random write secret minted by the device that created it, and the relay only ever sees that secret as a SHA-256 hash.
The relay stays blind
The server is four small Cloudflare Pages Functions over a D1 database. It assigns a monotonically increasing sequence number per chain, stores ciphertext blobs it cannot read, and hands out diffs it cannot interpret. It has no ability to inspect, query, or reconstruct content, and it never holds anything derived from the passphrase. That design choice pushed all the hard work to the client: merges are pure last-write-wins with a deterministic deviceId tie-breaker, deletes ride as tombstones that merge by timestamp, and the push scheduler debounces, coalesces mid-flight, and honors the server Retry-After so a failed push is never dropped. One bundle runs in the browser, a web worker, and Node 18 or newer.
We attacked our own relay
A blind relay still has edges, so we ran our adversarial QA tool against the real handlers. The campaign is 62 findings: 56 refused, 6 bounded, and none exploited. The refusals include the ones that matter most - a joining handshake cannot replace the stored write secret, a second writer racing to create a chain loses with a 403, spoofed device writes are refused and the victim blob count stays at zero, and hostile body and query shapes never authenticate. The six bounded findings are accepted risks written down with an owner and a measured limit, not quiet exceptions: chain existence is an oracle because handshake status codes differ, device display names are a metadata surface anyone with the chain id can join, and a poison blob can hold a watermark because a blind relay cannot validate ciphertext. Each bound is behaviorally pinned in the sync test suite, so a bound that stops being true in code reads as a finding in CI instead of a stale doc.
The project that grew up
Gistory is also where the struct family learned to leave home. Its repository carries the staging trees for other projects, and its CI is the battery each one must pass before it graduates to a standalone repo. That happened for poststruct and eyestruct during this build, both ferried out with hash-gated receipts and their own release lanes. The engine ships as gistory-sync under MIT, with its docs at docs.creadev.org/gistory, and the app at gistory.creadev.org needs no account and keeps your data on your device. The vault is yours, or it does not ship.
