argala — sealed graphs
Local-first storage, sync, and key custody for apps whose server is not allowed to read the data.
Every device holds the real database. The server is a relay that orders and carries ciphertext it can never decrypt — no plaintext, no keys, no parsing of record contents. Two members on two continents converge; the machine in the middle learns sizes and timing and nothing else.
npm i @argala/core @argala/client # devicesnpm i @argala/core @argala/relay # your serverWhy this exists
Section titled “Why this exists”There are good sync engines already, and most of them sync plaintext through a server the vendor can read. That is a reasonable trade: server-side search, notifications and AI features all need it.
argala is for cases where that trade is unacceptable because “we cannot read it” is the promise your product makes. The sync loop is the easy half of that. The hard half is key distribution: who holds a key, how a second device gets one without anyone sending a private key between machines, what happens when a laptop is stolen, and what happens when you remove someone. Teams abandon end-to-end encryption at that wall, not at the merge algorithm.
The key ceremonies are therefore the product, and the sync engine is their foundation.
What you get
Section titled “What you get”@argala/core crypto boundary + wire protocol isomorphic, zod, WebCrypto only@argala/client the device engine + local stores browsers, native shells, Node@argala/relay the blind relay a Fastify plugin you mount- Three graph shapes.
u(personal),g:<id>(a shared group — teams, workspaces, projects),d:<id>(a pairwise channel with its own key). - Per-device keys. Keys are wrapped to devices, not accounts. Adding a device is a ceremony: register a public key and receive the keyring, rather than copy a private key between machines. You can revoke one lost device without affecting the others.
- Signed records. Every device signs what it writes (ECDSA P-256, inside
the ciphertext, so the relay never learns an author). Receivers verify
against the graph’s roster and check the claimed author against the signing
device.
byis not a string somebody typed. - Epoch rotation. Removing someone bumps the epoch and re-wraps the key for everyone who remains. The relay refuses any rotation that would strand a device rather than silently splitting the graph in half.
- Invites that carry keys. The invite string is
<doorCode>.<keySecret>. Only the door code reaches your server; the keyring rides along sealed with a key derived via PBKDF2 from the half that never reaches it. - Partial sync, opt in. Declare
kindor an app-chosenshardas metadata and pull filtered subsets, with a cursor per subscription. Off by default, because everything you declare is readable by the relay forever. - Offline by default. Reads and writes never wait on a network. LWW per record, monotonic revisions, cursor-paged pull, a queue that survives a restart.
- Blobs with a retention policy:
retain,transport(forgotten once every member has a copy),ephemeral(TTL) — over SQLite, the filesystem, or your own store. - A read-only seat that is actually read-only. Your roles arrive through the same seam as your users, and the relay refuses a push, a blob or a rotation the member is not permitted to make. The relay enforces the rule because there is no other path between two devices: a rule enforced only by your client is a convention; a rule enforced by the relay is a rule.
- A device is approved before it is trusted. A relay can add a row to the device roster, and every honest device would helpfully wrap the graph key to it. So every wrap now passes a gate: a device is approved silently only when it has proven it already holds the key, and otherwise a person compares a twelve-character pairing code. The gate also covers rotation because a planted device can wait for it.
- A relay you can catch lying. Every write it accepts goes into an append-only log. Devices check that log against what they were actually served, so an omitted or rolled-back record contradicts the relay’s own account. They also fold the log into a chain they sign and exchange, which catches a relay telling two members different stories. Alarms latch, because a relay caught omitting one record is not cleared by behaving afterwards.
- Unlinkable personal IDs. A record in your personal graph that refers to a shared one is named by an HMAC tag, so the relay cannot join the two and infer what you track. Record IDs are metadata; this is how you stop metadata from being an index.
Quickstart
Section titled “Quickstart”Mount the relay in the app you already have — your sessions, your users:
import Fastify from 'fastify';import { openDb, registerRelay, SqliteDirectory } from '@argala/relay';
const db = openDb('data/relay.db');const app = Fastify();
await registerRelay(app, { db, // argala does not want to own your users. Answer two questions about them. directory: new SqliteDirectory(db), // Your session check. The token must name the member AND the device. authenticate: async (req) => { const session = await mySessions.verify(req); return session ? { memberId: session.userId, deviceId: session.deviceId } : null; }, // Optional: wire to whatever realtime you already run. Pokes carry nothing. onPoke: (evt) => myHub.toMembers(evt.memberIds, { type: 'argala.poke' }), onEvent: (evt) => metrics.count(evt.type),});On the device:
import { ArgalaEngine, HttpTransport, bestStore, IdbKeyStore, defineKinds } from '@argala/client';import { z } from 'zod';
const engine = await ArgalaEngine.open({ memberId: me.id, deviceId: localStorage.getItem('argala-device') ?? ArgalaEngine.newDeviceId(), transport: new HttpTransport({ baseUrl: '/api/argala', credentials: 'same-origin' }), store: await bestStore({ name: me.id }), // OPFS-SQLite, or IndexedDB keyStore: new IdbKeyStore(), // A decrypted record is untrusted input. Say what your kinds look like. kinds: defineKinds({ note: z.object({ body: z.string().max(10_000) }), task: z.object({ title: z.string(), done: z.boolean() }), }),});
await engine.openGraph('u', { create: true }); // my own graphawait engine.openGraph('g:acme'); // a shared oneengine.startAutoSync();
await engine.put('g:acme', { kind: 'note', data: { body: 'hello' } });const notes = await engine.listKind('g:acme', 'note'); // typed, local, instant
// Re-runs when the answer changes, not on every keystroke in the graph.engine.watch('g:acme', (e) => e.listKind('g:acme', 'task'), render);Run the entire lifecycle from a console: link a device by comparing a pairing code, refuse one the relay invented for itself, share a graph, reject a forged author, rotate out a stolen phone, and print what the relay actually holds:
npm cinpm run argala:verify # typecheck, lint, test, build, smokenpm run build && npm run argala:exampleARGALA_DEV_AUTH=1 npm -w @argala/relay run dev # a relay on :8787
npm run argala:mutate # break each invariant; does a test notice?npm run argala:soak # hundreds of randomized convergence runsnpm run argala:bench && npm run argala:bench:client # assert the shape of the costThe dev server refuses to start without ARGALA_DEV_AUTH=1, because it
authenticates nobody.
What the tests actually prove
Section titled “What the tests actually prove”The project uses strict TypeScript, type-aware linting, and CI on Node 22 and 24. The suite proves:
- the relay’s own database contains no plaintext and no usable key material — asserted against every table, and against the relay’s source, which is scanned mechanically for any import that could decrypt
- a second device is linked without any private key moving between machines, end to end through the real relay
- a device the relay invented for itself is never handed a graph key — not by a grant, and not by the rotation that answers a stolen laptop — while a device that has proven it already holds the key is approved with no prompt at all
- a member holding the key cannot forge another member’s authorship
- a revoked device reads nothing sealed after its revocation, and what it wrote before stays verifiable
- ciphertext bound to one record will not open under another, and a flipped byte is rejected rather than wedging the sync
- a rotation that would strand a device is refused; a wrap a device cannot open is discarded and re-granted rather than locking it out forever
- a write landing mid-sync is not left in the queue
- a push too large for the wire is split rather than stalling forever
- a device with a permanently wrong clock still syncs
- a member id the relay would build an ambiguous storage key from is refused
- two devices creating or rotating a graph at the same moment end up on the same key, rather than each keeping one the other cannot read
- devices doing randomized work in randomized order converge — three of them and five, across rotations, deletions, revision ties and a device linked halfway through, with tombstones compared as well as visible records
- the relay answers 4xx to every hostile input a fuzz suite can think of, and is still working afterwards
- a device that has not been granted the current key holds its writes rather than sealing them under the superseded one
- a relay that withholds a record, serves the version it replaced, or tells two members different histories is caught — and an honest one is not accused, through a filtered subscription, a refused record, a half-finished pull, a write landing mid-sync, or a pruned log
- a personal record’s derived id survives a rotation, a newly linked device, and a recovery import — so a preference never quietly becomes two
- every store implementation — memory, IndexedDB, SQLite — passes one conformance suite, and an engine survives a restart with its identity, its keyrings and its unflushed queue
- the documentation is answerable to the source: the API reference names only methods that exist, the limits tables carry the real numbers, and every link resolves
Several of these tests were written by breaking the fix first and watching
them go red. npm run argala:mutate does that on purpose and by machine: it holds a
catalogue of 94 mutations to the lines carrying an invariant — accept a bad
signature, drop the AAD’s record id, let a rotation strand a device, resolve
a revision tie the other way — applies each one, and fails the build if the
suite does not go red. All 94 are killed today, and CI keeps it that way. A
green suite is not the claim; a suite that notices is.
npm run argala:soak drives hundreds more randomized convergence scenarios than
the commit suite can afford. It found a real one: an edit to a record written
by a device whose clock ran a minute ahead was stamped BELOW the revision it
replaced, so the relay dropped the push, the local store kept the edit, and
the cursor had already moved past the relay’s copy — the edit existed on one
device and nowhere else, permanently, with no error anywhere. A seed that
fails there becomes a fixed seed in the suite.
What it costs
Section titled “What it costs”These figures were measured on this machine with npm run argala:bench and
npm run argala:bench:client at 20,000 records. Both scripts assert a bound, so a
change in complexity fails the build rather than surprising somebody later.
| per record | what it is | |
|---|---|---|
| write (queue locally) | ~19µs | a store write; the app never waits for more |
| write (push) | ~560µs | canonical JSON + one ECDSA signature + one AES seal |
| receive (pull) | ~510µs | one AES open + one ECDSA verification |
| relay push, 200 records | ~3ms | flat from an empty graph to 100,000 |
| relay pull page, 500 records | ~5ms | flat |
The per-record numbers are dominated by ECDSA P-256 — about 310µs to sign and
300µs to verify in Node’s WebCrypto, and typically faster in a browser. That
is the price of knowing who wrote a record. An app that genuinely does not
need authorship can set sign: false and roughly halve both, at the cost of
by becoming a claim again. The overhead is negligible in interactive use; a
bulk import of a hundred thousand records takes a minute, not a moment.
Status
Section titled “Status”v0.1 — real, extracted from a system in daily use, and narrow on purpose.
What is not here, in the order it matters:
- A relay that shows everybody a different world. Rollback and omission are now detected — the relay is checked against its own append-only log, and devices compare signed chain heads — but a relay that keeps one private world per member, peers’ checkpoints included, is still invisible in-band. That needs out-of-band comparison. See ROADMAP.md.
- Verification is off for partial sync. A device that asked to see a subset cannot tell that from being shown one.
- Key transparency. A device is no longer handed a key just because the relay listed it: every wrap passes an approval gate that auto-approves only on proof of possession and otherwise waits for a human to compare a pairing code. What is still missing is mathematics — an auditable log of device registrations, so a relay showing two members two different rosters is detectable without anybody comparing anything by hand.
- Authorization beyond read-only. There is a real read-only seat now, and admin-only removal, enforced by the relay rather than by your UI. What is missing is anything finer: a member allowed to write can still overwrite or delete another member’s records, and cannot be restricted by kind. Deciding either would mean the relay reading a record, which it cannot do and must not learn to — so those checks have to move onto the devices, where they become non-recognition after the fact rather than prevention.
- Full-text search. Substring scan today; FTS5 is a small change against the SQL store and a bigger one against IndexedDB.
See SECURITY.md for what argala protects, from whom, and where it does not.
Documents
Section titled “Documents”- docs/GUIDE.md — start here to build on argala. Install, concepts, the relay, the device, key custody, sharing, revocation, recovery, blobs, partial sync, operations, recipes, troubleshooting, API reference.
- ARCHITECTURE.md — how it works and why each boundary is where it is, with diagrams of the sync loop, the key ceremonies, the storage layout and the trust boundaries.
- PROTOCOL.md — the wire, spelled out well enough to write another implementation against.
- SECURITY.md — invariants, threat model, and the honest limits. Read before writing code here.
- ROADMAP.md — what is missing and what it would take.
- CONTRIBUTING.md — how to work on it.
The guide and the architecture document are checked against the source by
argala-client/test/docs.test.ts: the API reference must name methods that
exist, the exports lists must name real exports, the limits tables must carry
the real numbers, and every link must resolve. Prose does not fail a build on
its own, so it is made to.
MIT.