Skip to content

Security

skm’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 repo. It is written to be checked against, not admired.

Rules any change must uphold. A change that bends 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.
  10. Honesty in the data table. If the relay can see something, this file says so. We never imply E2E where there is TLS.

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. The primary adversary. They get ciphertext, ids, sizes, and timing.
  • (b) A member of the graph. Has the key, by definition. Can read everything in that graph. Cannot forge another member’s authorship, and cannot silently corrupt another device’s store — but can write junk under their own name, and can 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.

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 skm 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 read off what each member tracks, stars, mutes, and saves — without decrypting anything. skm 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.

The ones that would be dishonest to omit.

  • Rollback and omission. The relay cannot read a record, alter one, or forge one — but it can serve an OLD version to a device that has never seen the new one, or omit records entirely, and neither is detectable today. A device that already holds a newer revision is safe (LWW ignores the older one); a fresh device is not. Closing this needs a signed, append-only log per graph that devices can check for consistency — see ROADMAP.md. Until then, skm’s guarantee is confidentiality and authenticity, not availability or freshness.
  • The ghost device. Devices wrap keys to public keys the relay publishes. A malicious relay could insert a device with its own key and wait for a grant or rotation to include it. The defenses are visibility, not mathematics: the roster is queryable, apps are expected to show it, and device keys are set-once. Key transparency and safety-number verification are the stronger future answers, and neither exists yet.
  • 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 it cannot decrypt. So a malicious member can rotate and hand another device a wrap it cannot open. skm 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 skm 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 25MB, 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. RateLimiter is an interface; back it with something shared before you scale out.
  • 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; very much a 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.
  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.
  4. Show the device roster. 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. Never log ciphertext with its AAD context, and never log a graph key or a wrap. There is no reason to.
  9. 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.

Found something? Open a private security advisory on the repository rather than an issue.