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.
Invariants
Section titled “Invariants”Rules any change must uphold. A change that bends 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.
- Honesty in the data table. If the relay can see something, this file says so. We never imply E2E where there is TLS.
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. 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.
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 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>besideg: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: useengine.personalId(), never the target’s own id.
Known limits
Section titled “Known limits”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.
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 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, theDirectoryis where to refuse. - Rate limiting is per process by default.
MemoryRateLimiteris correct for one relay and wrong for four behind a load balancer.RateLimiteris an interface; back it with something shared before you scale out. - 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; very much a 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 skm
Section titled “Operational rules for apps built on skm”- 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. - Show the device roster. 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. - 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”Found something? Open a private security advisory on the repository rather than an issue.