# 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

Every change must uphold these rules. A change that weakens one does not land.

1. **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.
2. **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.
3. **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.
4. **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.
5. **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.
6. **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.
7. **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.
8. **Set-once device keys.** A device ID already bound to public keys is never
   rebound. Rebinding is the redirect attack.
9. **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.
10. **Honesty in the data table.** If the relay can see something, this file
    says so. We never imply E2E where there is TLS.
11. **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.
12. **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

**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

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:acme` can 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. `kind` says "this is a message, that is a task"; `shard` says 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>` beside
  `g: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**: use `engine.personalId()`, never
  the target's own ID.

## 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.from` is where it started; `unverified` is
  what it reports, and that is not the same word as `ok`.

- **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].log`
  reports `off`, 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_records` overwrites 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 `selfSig` verifies 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 raises `device.pending` and
  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 drops
  `excluding` and would then refuse the rotation as incomplete, and breaking
  revocation is worse than the hole it closes. `status.linking.mode` and a
  once-per-boot `linking.unenforced` error 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.** `transport` and `ephemeral` are 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, the `Directory` is where to refuse.
- **Rate limiting is per process by default.** `MemoryRateLimiter` is correct
  for one relay and wrong for four behind a load balancer — four relays mean
  four allowances. `SharedRateLimiter` fixes that against any counter you can
  increment and read (Redis is `INCRBY`/`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 surface
  `storage.evictable` is an app that will one day lose somebody's afternoon
  and be unable to say why.
- **OPFS is one tab at a time.** `bestStore` falls 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.
- **`FileKeyStore` is 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, and `KeyStore` is an interface so that it can be.

## Operational rules for apps built on argala

1. **Bind the session token to the device.** `authenticate` must 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.
2. **Declare your kinds.** Ship a `KindRegistry`. Without one, a member with
   the key can write anything into every other member's store.
3. **Leave `requireSignatures` on.** Turning it off makes `by` a claim again.
   Also give the relay your roles through `Directory.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.
4. **Show the device roster, and build the approval screen.** Set
   `linking: 'approve'` once your relay is upgraded, handle `device.pending`
   with a sheet that shows the pairing code, the label and the member, and
   treat `device.conflict` as an incident — the relay is set-once about device
   keys, so a change to a device's published keys has no benign explanation.
   Show `via` on 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.
5. **Rotate on removal, immediately.** The relay sets `rotationRequired`.
   Treat it as an alarm, not a suggestion, and check BOTH results of
   `revokeDevice()`: `failed` names graphs whose rotation errored, and `owed`
   counts graphs the revoking device could not rotate because it does not
   hold them. A non-zero `owed` means data is still sealed under a key the
   revoked device has.
6. **Watch for the `keys.behind` error.** 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.
7. **Watch `status.rejected` and the `record.rejected` event.** A record the
   relay refused exists on one device and nowhere else. That is a bug worth
   surfacing, not a counter worth ignoring.
8. **Treat a `log.alarm` as 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. Show
   `status.graphs[g].log.state` somewhere a person will see it, and treat
   `unverified` as "we could not check", which is not the same as "fine".
9. **Never log ciphertext with its AAD context**, and never log a graph key or
   a wrap. There is no reason to.
10. **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

If you find a vulnerability, open a private security advisory on the
repository rather than an issue.
