Security
argala’s whole claim is that a server can hold an application’s data and be unable to read it. That makes this the most important file in the repository. It is written as a standard to check against, not a statement to admire.
Invariants
Section titled “Invariants”Every change must uphold these rules. A change that weakens one does not land.
- The relay stays blind. No relay code path may import a decrypt routine, receive key material, or parse record contents. Graph data crosses the wire and rests on disk as ciphertext only. This is checked mechanically: the tests scan the relay’s own source for any such import, and assert its database contains no plaintext in any table.
- Hand-roll layout, never crypto. AES-256-GCM, ECDH P-256, ECDSA P-256, HKDF-SHA256, PBKDF2 — all via WebCrypto. Rolling our own is out of scope forever.
- Every inbound frame is schema-validated before any logic touches it. HTTP bodies at the relay, and decrypted records on the device. The relay cannot validate what it cannot read, and any key-holding member can craft any record, so the device validates after opening and skips what fails. Validation lives at the last boundary that can enforce it, not the first one that is convenient.
- Ciphertext is bound to its slot. Every seal carries AAD naming the graph and record (or blob). A relay that shuffles rows produces errors, never wrong answers.
- Never seal under a superseded epoch. A device that has not received the current key holds its writes rather than sealing them under the previous one, which whoever the rotation excluded still holds. Unsent is recoverable; sent under the wrong key is not. The relay reports the graph’s epoch on every push and pull, so a rotation reaches the other devices on their next sync — a guarantee that only takes effect once somebody notices is not much of one.
- Authorship is proven, not claimed. A device signs what it writes, and a receiver checks the signature against the roster and the claimed author against the signing device’s member. An app may switch this off; the default is on.
- Keys belong to devices. No API hands a private key to anything. No ceremony moves one between machines. Adding a device is a grant; the recovery bundle is for when there is no device left to grant from, and it carries graph keys only.
- Set-once device keys. A device ID already bound to public keys is never rebound. Rebinding is the redirect attack.
- A rotation that strands somebody is refused. Not accepted with a
warning — refused, with the missing device IDs named. A device may be left
out only by being named in
excluding, which is a decision somebody made rather than a gap somebody fell through: the relay refuses an exclusion of a device that is not eligible, or that the same request also wraps to. - Honesty in the data table. If the relay can see something, this file says so. We never imply E2E where there is TLS.
- A detector that cries wolf gets switched off. Anything that accuses the relay must first rule out every honest explanation — a subscription that asked for less, a record this device refused, a pull stopped by a missing key, a write that landed mid-sync, an operator reclaiming disk. “I could not check” is a different statement from “I checked and it was wrong”, and the two must never share a code path.
- A key is wrapped to a device, never to a roster row. Every wrap to another device — creating a graph, filling a gap, rotating — passes the same gate, and the gate fingerprints the key material it is HANDED rather than a cached roster. Gating only one of the three accomplishes nothing: a planted device does not need to be granted, it can wait for the rotation that answers a stolen laptop. The gate never throws, because a device nobody approved must not be able to stop a rotation.
What is protected, from whom
Section titled “What is protected, from whom”Assets: graph keys (highest sensitivity, never server-side); record and blob contents; authorship; the social graph of who talks to whom (partially exposed — see below); the device roster.
Adversaries considered:
- (a) The relay operator, or anyone who steals the relay’s disk. This is the primary adversary. They obtain ciphertext, IDs, sizes, and timing.
- (b) A member of the graph. By definition, they hold the key and can read everything in that graph. They cannot forge another member’s authorship or silently corrupt another device’s store, but they can write junk under their own name and deny service (see “a hostile member” below).
- (c) A stolen device. Revocable per device, then rotated. It keeps what it already synced — that is physics.
- (d) A network attacker. TLS is the host app’s job. AAD binding and signatures mean even a relay-level attacker cannot make a record say something else, or say it in somebody else’s name.
- (e) A malicious directory. Can add a member or a device and wait for a grant. See “the ghost device” below.
Out of scope, stated plainly: a compromised host OS, an unlocked stolen device, a malicious build of the app itself (see “the delivery caveat”), and traffic analysis by a global passive observer.
What the relay learns anyway
Section titled “What the relay learns anyway”Being honest about metadata is the difference between a security document and a marketing page.
- Who syncs what, and when. Graph membership, activity times, and volume
are all visible. A relay watching
g:acmecan see that six people are busy on a Sunday night. It cannot see why. - Record IDs and sizes. IDs are stored in the clear because upsert-by-ID is how LWW works. Sizes leak coarse content shape.
- Kinds and shards, if an app opts in. Partial sync works by telling the
relay something it can filter on, and everything you tell it is readable
forever.
kindsays “this is a message, that is a task”;shardsays as much as the label you choose, which is why the label should be a derived tag rather than a channel name. Sending neither is the default and costs only the ability to sync a subset. - The pairwise graph list.
d:<a>:<b>storage keys name both parties. A relay knows Sam and Alex have a channel; it never learns a word of it. Hiding the existence of a conversation needs sealed sender or a mixnet, neither of which argala claims. - What it does NOT learn: authorship, or what you read. Signatures live inside the ciphertext. Read positions live in the personal graph, encrypted.
- Personal-graph record IDs. A record in your personal graph that refers to
a shared record must never be named after it.
pin_<docId>besideg:acme/<docId>lets the relay join the two and infer what each member tracks, stars, mutes, and saves — without decrypting anything. argala derives those IDs instead:p_ + HMAC(HKDF(personal key, "skm-pid-v1"), kind ‖ target)truncated to 132 bits. Deterministic (upsert-by-id still merges across your devices), stable through the recovery bundle, and unlinkable to any shared ID without a key only you hold. Two members pinning the same record produce unrelated IDs. This is a rule apps must follow: useengine.personalId(), never the target’s own ID.
Known limits
Section titled “Known limits”These are the limits it would be dishonest to omit.
-
Rollback and omission are DETECTED, not prevented. The relay cannot read a record, alter one, or forge one, and it can no longer serve an old version or omit records without leaving evidence — but a device discovers that evidence only after syncing far enough to perform the checks. Two checks do this. The relay is checked against its own append-only log: every write it accepted is recorded, and every entry must appear in what it served, at that revision or newer. And devices are checked against each other: folding the log gives a head that depends on the log alone, so every device that read the same range computes the same value, and they publish theirs as signed records. A relay that keeps one private world per member survives the first check perfectly and fails the second.
What still gets through: a relay that shows EVERY member a wholly separate world, peers’ checkpoints included, forever. Nothing in-band can catch that — the members would have to compare heads out of band. Detection is also useless on a device that never syncs, or that never reaches a checkpoint boundary, and it is off for a filtered subscription (see below). So argala’s guarantee remains confidentiality and authenticity; freshness is now evidenced, which is a weaker word than guaranteed on purpose.
-
A member offboarded before the writer table existed remains unverifiable. The roster a device verifies against now covers everybody who has ever pushed to a graph, not only its current members — otherwise offboarding somebody destroys their records for every device that syncs afterwards. The relay has never stored an author, which is the point of it, so that set cannot be reconstructed for pushes that predate the table. On an upgraded relay, a member removed before the upgrade is still unattributable to a device that had not already synced.
-
A pruned log cannot be corroborated. A device that first folds a log which no longer reaches back to the beginning anchors its chain where the history now starts. It still catches an omission or a rollback after that point, but it can never check the earlier span, it corroborates nobody, and it publishes no checkpoint — because a head anchored mid-history is a different quantity from one folded over the whole log, and comparing the two would turn an operator reclaiming disk into a latching alarm naming a colleague.
status.graphs[g].log.fromis where it started;unverifiedis what it reports, and that is not the same word asok. -
Verification is off for partial sync. A device that asked the relay for a subset cannot tell a subset it asked for from a subset it was given, so the log check does not run on a filtered subscription.
status.graphs[g].logreportsoff, and an app that wants both partial sync and rollback detection needs a device somewhere doing an unfiltered pull. -
The log retains edit history.
graph_recordsoverwrites a record’s previous version; the log does not. The relay therefore keeps the shape of how often a record was written and when, whereas it previously kept only the latest. It never learns any more about the CONTENTS, and it saw each write as it happened either way — but it remembers, and for a graph where “this document was edited eleven times last night” is itself sensitive, that is a real change. About sixty bytes per write. -
The ghost device is GATED, and the gate ends at a human. Devices wrap keys to public keys the relay publishes, so a malicious relay could insert a device holding its own private key and wait for a grant — or, worse, for the rotation that answers a stolen laptop. Every wrap now passes an approval gate. A device is approved without asking anybody only when it has proven possession: this device opened a record that device sealed under a graph key AND verified its signature under the signing key the roster publishes for it, and that device’s
selfSigverifies over its own fingerprint under the same signing key. The relay cannot fake either half — the signature lives inside ciphertext it cannot produce, and the attestation binds the published wrapping key to the same device. Everything else raisesdevice.pendingand waits for somebody to compare a twelve-character pairing code.What still gets through: a human who compares twelve characters without looking. The first device on a fleet, which pins itself — trust on first use has no in-band fix. A device already inside the fleet before the gate existed, which possession-pins as soon as it writes. And a deployment left on the default
linking: 'observe', which reports and hands the key over anyway; that default exists for one release, because an older relay dropsexcludingand would then refuse the rotation as incomplete, and breaking revocation is worse than the hole it closes.status.linking.modeand a once-per-bootlinking.unenforcederror exist so this cannot be forgotten. Key transparency is still the stronger answer and still does not exist. -
A key-holding member can raise a false divergence alarm. Checkpoint IDs are derived from the graph key, so every member can compute every other device’s slot. A checkpoint is only believed in the slot derived for the device it names, and only when the record’s proven author owns that device — so nobody can put words in an honest device’s mouth, and corroboration is counted per device rather than per record. What remains is that a member can lie about their OWN history, and no signature check can tell that from a relay having shown them a different one. The alarm names the device and the member, because the operator’s question is which of the two to distrust. A member can also overwrite another device’s checkpoint slot with a record that is then rejected on arrival — destroying the honest one at the relay. That is denial of corroboration, and it is the same hostile-member power as the one below.
-
Authorization stops writes, never reads. A member can be given a read-only seat, and the relay enforces it — a push, a blob, or a rotation they are not permitted to perform is refused, and there is no other path between two devices, so the refusal is prevention rather than disapproval. This does NOT stop them reading, because they hold the graph key and that is arithmetic. It does not provide per-record ownership, because deciding whose record it is would require the relay to read it. Nor does it defend against a hostile RELAY, which decides the answer it then enforces. To stop somebody reading, remove them and rotate. Everything they wrote before a demotion stays exactly where it is.
-
A hostile member can deny service. Anyone holding a graph’s key can rotate it, and the relay cannot check that a wrap actually decrypts because the relay itself cannot decrypt. A malicious member can therefore rotate and hand another device a wrap it cannot open. argala makes this recoverable rather than terminal: a device that cannot open its own wrap discards it, reappears in
keygaps, and is re-granted by an honest holder. It does not make it impossible. Cryptographic write control — signed rotations against a policy — is the real answer and is not built. -
Role rules are not cryptography. “Only admins may edit this” is UI courtesy inside a shared graph. Signatures tell you who wrote a record, never whether they were allowed to. If a distinction must hold cryptographically, use a separate graph.
-
The delivery caveat. In a browser, the code doing the encrypting is JavaScript someone served you. Whoever controls that delivery could ship code that leaks keys. Pinned deploys, a strict CSP, and few audited dependencies raise the bar; native shells with signed builds raise it further. This is trust in a build pipeline, not cryptographic impossibility. Anyone promising a web app “even we can’t ever read it” without this caveat is lying to you.
-
Lost keys mean lost data. With every device gone and no recovery bundle, the ciphertext is unrecoverable. The relay cannot help. That is the deal, and an app built on argala must tell its users so in words, at the moment the first key is created.
-
Blob deletion is best-effort.
transportandephemeralare retention policy executed by a relay you may not control. A relay that keeps a copy is invisible to you. Policies express intent; they are not enforcement. -
Blob egress is limited by request count, not by bytes. The default allows 120 blob requests a minute per member, and a blob may be 25 MB, so a determined member can pull a lot of bandwidth. Records are small enough that this does not apply to them. Bandwidth shaping belongs in front of the relay, not inside it — but it does have to be somewhere.
-
Pairwise graphs are an existence oracle. Addressing
d:<memberId>answers 404 for a member who does not exist or is deactivated, and 200 for one who does. Any authenticated member can therefore test whether an ID is real. In most apps members already know each other; in one where they do not, theDirectoryis where to refuse. -
Rate limiting is per process by default.
MemoryRateLimiteris correct for one relay and wrong for four behind a load balancer — four relays mean four allowances.SharedRateLimiterfixes that against any counter you can increment and read (Redis isINCRBY/PEXPIRE/GET), and it is what a multi-process deployment should be configured with. Two processes racing can still overshoot by one request each, because a rate limiter that takes a distributed lock is a worse idea than a rate limiter that is occasionally off by one. -
A browser can evict the store. Origin storage is evicted under pressure, silently, and what it takes is the only copy of anything not yet pushed. argala asks for persistence at startup and reports the answer in
status.storage, but the browser decides — some grant it on engagement, some prompt, some never grant it. This is not a confidentiality problem and it is very much a durability one: an app that does not surfacestorage.evictableis an app that will one day lose somebody’s afternoon and be unable to say why. -
OPFS is one tab at a time.
bestStorefalls back to IndexedDB in a second tab, so two tabs of the same app hold two different local stores. They converge through the relay, but neither sees the other’s unsynced writes, and a person with two tabs open can watch one of them lag. Not a confidentiality problem, but it is a significant correctness surprise. -
FileKeyStoreis plaintext on disk. It is right for a server-side agent whose disk is already the trust boundary, and wrong for a laptop — there the OS keychain is the answer, andKeyStoreis an interface so that it can be.
Operational rules for apps built on argala
Section titled “Operational rules for apps built on argala”- Bind the session token to the device.
authenticatemust return the device the token was issued to. A token naming only the member would let any of that member’s devices fetch another’s wraps — the relay checks the binding it is given, but it cannot invent one. - Declare your kinds. Ship a
KindRegistry. Without one, a member with the key can write anything into every other member’s store. - Leave
requireSignatureson. Turning it off makesbya claim again. Also give the relay your roles throughDirectory.capabilitiesOf— without it every member of a graph is an owner who may rotate the key and overwrite anyone’s work. A role your UI draws is a convention your own client keeps, not a rule anybody else’s client obeys. - Show the device roster, and build the approval screen. Set
linking: 'approve'once your relay is upgraded, handledevice.pendingwith a sheet that shows the pairing code, the label and the member, and treatdevice.conflictas an incident — the relay is set-once about device keys, so a change to a device’s published keys has no benign explanation. Showviaon a “devices you trust” screen so a person can see which approvals a human actually looked at. A ghost device is only detectable if somebody can see it. - Rotate on removal, immediately. The relay sets
rotationRequired. Treat it as an alarm, not a suggestion, and check BOTH results ofrevokeDevice():failednames graphs whose rotation errored, andowedcounts graphs the revoking device could not rotate because it does not hold them. A non-zeroowedmeans data is still sealed under a key the revoked device has. - Watch for the
keys.behinderror. It means this device is holding writes because it has not been granted the graph’s current key. Another device needs to sync (which grants), or somebody needs to look. - Watch
status.rejectedand therecord.rejectedevent. A record the relay refused exists on one device and nowhere else. That is a bug worth surfacing, not a counter worth ignoring. - Treat a
log.alarmas an incident, not a warning. It means the relay contradicted its own log, or two devices signed different histories for one graph. It latches deliberately — a relay caught omitting one record is not cleared by behaving for the next thousand — and the honest response is to stop trusting that deployment, not to clear the flag. Showstatus.graphs[g].log.statesomewhere a person will see it, and treatunverifiedas “we could not check”, which is not the same as “fine”. - Never log ciphertext with its AAD context, and never log a graph key or a wrap. There is no reason to.
- Serve the app and the relay over TLS from an origin you control, and keep record identifiers out of URLs the relay sees, so it does not learn which record a member has open.
Reporting
Section titled “Reporting”If you find a vulnerability, open a private security advisory on the repository rather than an issue.