Skip to content

Architecture

How the pieces fit, why each boundary is where it is, and what happens on the wire when somebody types a character.

This document is for people changing skm, evaluating it, or writing a second implementation of the protocol. If you only want to use it, start with GUIDE.md. If you want the wire in exact terms, read PROTOCOL.md; this file explains the shape, that file is normative.

Contents

  1. The one-sentence version
  2. The three packages
  3. Identities, graphs and storage keys
  4. The crypto boundary
  5. Anatomy of a record
  6. The sync loop
  7. Key ceremonies
  8. Epochs and rotation
  9. Storage layout
  10. The relay’s request pipeline
  11. Blobs and retention
  12. Failure and recovery
  13. Trust boundaries
  14. Extension seams
  15. Deployment shapes
  16. How it is tested

Every device holds the real database in plaintext; the server holds an ordered log of opaque (nonce, ciphertext) pairs and the wrapped keys it cannot open, and the interesting engineering is all in getting the right key onto the right device without ever handing one to the server.

Device BRelayDevice Asealed opssealed opssealed opssealed ops

app code

SkmEngine

local store plaintext

Fastify plugin

SQLite ciphertext only

app code

SkmEngine

local store plaintext

Green is plaintext. Grey is ciphertext. There is no third colour: the relay never sees a key, a plaintext record, or an author’s name.


@skm/core crypto boundary, wire protocol, key schemas no I/O, isomorphic

@skm/client device engine + local stores browsers, native shells, Node

@skm/relay the blind relay a Fastify plugin you mount

The dependency arrow only ever points at core, and that is enforced: the test suite scans the relay’s own source for any import that could decrypt, and fails the build if one appears. @skm/relay does not depend on @skm/client and never will — the two ends share a protocol, not code paths.

package holds never holds
@skm/core AES/ECDH/ECDSA/HKDF wrappers, canonical JSON, zod schemas, LIMITS, AAD and storage-key naming any network or disk call
@skm/client keyrings, the sync loop, LWW, local stores, blobs, invites, recovery anything the app should decide (schemas, sessions)
@skm/relay ordering, quotas, rate limits, the device roster, wrapped-key custody a decrypt routine, a graph key, a record’s contents

SkmEngine

Transport

GraphStore

KeyStore

KindRegistry

HttpTransport

MemoryGraphStore

IdbGraphStore

OpfsGraphStore SQLite-WASM in a worker

SqlGraphStore

NodeSqliteGraphStore

MemoryKeyStore

IdbKeyStore

FileKeyStore server-side agents

Four seams, four interfaces. SkmEngine is the only thing that knows how they fit together, and it is the only file in the repo where a mistake is expensive.


skm does not own your users. It asks the host app two questions (Directory) and takes one answer per request (authenticate).

names one

SkmIdentity

+memberId: string

+deviceId: string

Directory

+membersOfGroup(groupId)

+isGroupMember(groupId, memberId)

+isActiveMember(memberId)

RosterDevice

+deviceId: string

+memberId: string

+pubJwk: string

+sigJwk: string

+revokedAt: number|null

A member is a person or an account, as your system names them. A device is one installation. Keys belong to devices, never to members — that is what makes revocation mean something.

d:<id> pairwise

sam addresses d:alex alex addresses d:sam storage key: d:alex:sam (sorted)

g:<id> shared

engine.openGraph('g:acme') storage key: g:acme membership from Directory

u personal

engine.openGraph('u') storage key: u:<memberId>

The pairwise case is the one worth staring at. Each side addresses the graph by the other party’s id, but both must land on one row in the relay and one AAD in the crypto — so the storage key sorts the pair. Get this wrong and the two sides seal into slots the other cannot read, with no error anywhere.

storageKeyFor('d:alex', 'sam')

sort(['sam','alex'])

storageKeyFor('d:sam', 'alex')

sort(['alex','sam'])

d:alex:sam

The same function computes it on both ends — it lives in @skm/core precisely so it cannot drift.


Everything cryptographic lives in packages/core/src/crypto.ts, and every primitive is WebCrypto. Nothing here is hand-rolled; the layout around it is.

per personal id

HKDF then HMAC-SHA256 truncated to 132 bits

per passphrase

PBKDF2-SHA256, 310k iterations invites and recovery bundles

per device

ECDH P-256 + HKDF-SHA256 wrap a graph key to a device pubkey

ECDSA P-256 sign the record, inside the ciphertext

per record / per blob

AES-256-GCM fresh 96-bit nonce AAD = storageKey/recordId

Why the signature is inside the ciphertext

Section titled “Why the signature is inside the ciphertext”

If the signature rode outside, the relay would learn which device wrote each record — the social graph, in the clear, forever. So a record is wrapped in an envelope, the envelope is what gets sealed, and the relay sees one opaque blob.

record {id, kind, data, at, by}

canonicalJson

skm-record-v1 · storageKey · canonical JSON newline-separated, as bytes

ECDSA sign

envelope {v, r, d, s}

AES-256-GCM AAD = storageKey/recordId

op {recordId, rev, e, n, c}

canonicalJson sorts keys recursively and drops undefined exactly as JSON.stringify does, so two devices that built the same object in a different order produce the same bytes to sign. Without it, a signature would verify or not depending on property insertion order — a bug that appears months later, on one platform.

Every seal is bound to <storageKey>/<recordId> (or <storageKey>/blob/<id>). A relay that shuffles rows, or replays one record’s ciphertext into another record’s slot, produces a decryption failure, never a wrong answer.

devicemalicious relayciphertext of r1, served in the slot of r2open(key, ct, aad="g:acme/r2")GCM tag mismatchemit record.rejected {reason: shape}, skip, keep syncing

Note the last line: a bad record is skipped, not fatal. One corrupt row must never wedge a sync.

A record in your personal graph that refers to a shared record must never be named after it. pin_<docId> sitting beside g:acme/<docId> lets the relay join the two and read off what every member tracks, without decrypting anything.

personal graph key

HKDF info='skm-pid-v1'

HMAC-SHA256

'pin'

'doc_manifesto'

p_ + 132 bits deterministic, unlinkable

Deterministic, so upsert-by-id still merges across your own devices. Derived from epoch 1 of the personal graph, so it survives rotation, a newly linked device, and a recovery import. Two members pinning the same record produce unrelated ids. engine.personalId(kind, target) is the only correct way to name such a record.


wrapsseals

GraphRecord

+id: string

+kind: string

+data: object

+at: number "revision AND timestamp"

+by: string «member»

+deleted: true "tombstone, optional"

RecordEnvelope

+v: 1

+r: GraphRecord

+d: deviceId "optional"

+s: signature "optional"

GraphOpIn

+recordId: string

+rev: number

+e: epoch "optional"

+n: nonce

+c: ciphertext

+k: kind "optional, LEAKS"

+sh: shard "optional, LEAKS"

GraphOpOut

+seq: number

at is both the record’s timestamp and its revision. That dual role is deliberate — one number, monotonic per device, is all last-write-wins needs — and it carries two rules that are easy to get wrong:

  1. Monotonic per device. Two writes inside one millisecond must not stamp the same number, or the relay drops the second and the device keeps content nobody else has.
  2. Above what it replaces. Editing a record another device wrote from a clock a few milliseconds ahead must stamp above that revision, not just above this device’s own high-water mark. Otherwise the relay drops the edit as a last-write-wins loser, the local store keeps it, and the pull cursor has already passed the relay’s copy — the edit lives on one device forever, with no error anywhere.

The second rule was found by npm run soak, not by thinking about it.

Tombstones are records. The relay never deletes a row; a deletion is a record with deleted: true and an empty data. Anything else would make “deleted on device A, edited on device B” unresolvable.


One graph at a time, coalesced so eight concurrent callers produce one run.

yesnonoyesyesnonoyesokrejectdeferyesnoyesnoyesno

syncGraph

epoch stale or no key?

refreshKeys

pushPending

key now?

sync.done

behind an epoch?

hold writes emit keys.behind

pullAll

seal a batch bounded by count AND bytes

POST push

queue empty?

for each subscription

GET pull?after=cursor

note the relay's epoch

for each op

open + verify

newer than local?

emit record.rejected

stop, keep cursor

stage for upsertMany

next op

advance cursor

more?

epoch ahead of keyring?

refreshKeys

grantPending

Four details in that diagram carry their weight:

A push is bounded by bytes as well as by op count. 200 records at the maximum ciphertext size is 140MB, and the 413 that follows is not retryable — a queue that stops draining and stays stopped.

A device that is behind an epoch holds its writes. Sealing under the previous key would be readable by whoever the rotation excluded. Unsent is recoverable; sent under the wrong key is not.

Meeting an op sealed under a missing epoch stops the pull before that op, without advancing the cursor, fetches keys, and tries once more in the same sync. Skipping it would lose the record forever.

“Cannot verify” is not “does not verify.” If the roster cannot be fetched — offline, rate-limited — the batch is deferred, not dropped. Dropping would silently lose data that is sitting right there.

device Brelaydevice Aboth edit r1 in the same millisecond, rev = 500converged on A's contentpush r1 rev 500 (A's content)no existing row → storepush r1 rev 500 (B's content)existing rev 500 >= 500 → drop silentlyaccepted 0pullop r1 rev 500 (A's content)local.at == record.at AND content differs→ take the relay's copy

The relay keeps the first of two equal revisions. The device’s comparison is therefore local.at <= record.at on a difference, not <: a strict < would leave B holding its own version forever, with no error and no way back.

A device that just pushed a record will meet it again on the next pull. Decrypting and verifying our own writes is pure waste, so the engine remembers (graphId, recordId) → rev and skips an op that matches exactly.

The rule: mark echoes only when EVERY op in the batch landed. rejected lists refusals, not the ops that quietly lost a last-write-wins comparison — and a record that lost is one where the relay’s copy is somebody else’s. Marking that as our own echo would skip the one record we most need to read.


relaydevicealt[somebody else got there first][fresh]GET /graphs/g:acme/devicesroster (pubJwk per active device)generate a 256-bit graph keywrap it to every active device's pubJwkPOST /graphs/g:acme/keys {epoch: 1, wraps}200 — it sees "epoch 1" and treats it as a grant200 — epoch 1 createdGET /graphs/g:acme/keyswhatever actually landedadopt what the relay holds, never what we sent

That read-back is not defensive programming, it is the fix for a real race. A concurrent create is answered 200, not 409 — the relay sees a known epoch and treats the request as a grant, ignoring the duplicate wrap. A device that trusted that 200 would keep a key nobody else has: every record it writes unreadable, every record it reads undecipherable, and no error anywhere.

No private key ever moves between machines.

a device holding the keysrelaynew deviceset-once. Rebinding an id is refused (409).pubkeys come from the roster, never fromthe asking device — a forged request cannot redirect a keygenerate ECDH + ECDSA keypairsPUT /devices/<id> {pubJwk, sigJwk}GET /graphs/g:acme/keyslatestEpoch 3, wraps: [] — nothing for me yetstatus = waitingGET /graphs/g:acme/keygaps[{deviceId: new, epochs: [1,2,3]}]wrap each epoch to the PUBLISHED pubkeyPOST /graphs/g:acme/keys {epoch, wraps}GET /graphs/g:acme/keysthree wrapsunwrap with its own private key → status ok

grantPending runs automatically during every sync of a device that holds the keys (autoGrant, on by default). That is what makes linking feel automatic rather than like a ceremony somebody has to remember to perform.

Invites: a keyring that rides beside the door code

Section titled “Invites: a keyring that rides beside the door code”

invite string

split on '.'

doorCode goes to your server

keySecret never leaves the two humans

PBKDF2 310k

open the sealed keyring

your invite table: who is this for?

The server learns the door code and can answer “is this invite valid” — it never sees the half that opens the keyring. Losing the whole string to a network attacker is a real compromise; losing your server’s database is not.

import on a NEW device

every graph keyring on this device

payload {keyrings}

AES-GCM under PBKDF2(passphrase, salt)

base64 bundle

new device keypair + known graph keys

The bundle carries graph keys only — never a device keypair. A restored machine is a new device that happens to already know the graph keys. That is what keeps two machines from signing as one, and what keeps revocation meaningful. Importing wipes the local record store first: a store half-filled from a previous identity is worse than an empty one.


An epoch is a generation of a graph’s key. Rotation mints the next one and wraps it to everybody who remains.

graph exists, nobody hasmade a keycreateGraphKeyrotate (a device wasrevoked)rotategrant a new device iswrapped ingrant

NoKey

Epoch1

Epoch2

Epoch3

relay reports epoch 0

old epochs are KEPT. Records sealed under epoch 1 must still open.

Old epochs are never thrown away. A record written last year is still sealed under the key from last year, so a keyring is a map of epoch → key, and a device that lacks an old epoch appears in keygaps until somebody fills it.

A rotation that would strand somebody is refused

Section titled “A rotation that would strand somebody is refused”
relaydevicerefused, with the ids named —not accepted with a warningPOST keys {epoch: 4, wraps for 3 of the 4 devices}eligible = active devices of current membersmissing = eligible - covered400 incomplete-rotation {missing: ['sam-phone']}

The alternative is a graph silently split in half, which is the failure mode nobody notices until a week of messages is unreadable on one laptop.

A rotation reaches the other devices on their next sync

Section titled “A rotation reaches the other devices on their next sync”
device Brelaydevice AB has not re-opened the graph.It only syncs.revoke sam-phonePOST keys {epoch: 2, wraps to A and B}POST push (still under epoch 1){accepted, cursor, now, epoch: 2}GET pull{ops, cursor, more, epoch: 2}noteEpoch(2) — ahead of my keyringGET keysthe epoch-2 wrapnext write is sealed under epoch 2

Before the relay reported the epoch, B found out only by meeting a record it could not open — or when the periodic check came round up to a minute later — and kept sealing under exactly the key the rotation was performed to take away. A guarantee that takes effect once somebody notices is not much of one.

Anyone holding a graph’s key can rotate it, and the relay cannot check that a wrap actually decrypts — because it cannot decrypt. A malicious member can therefore hand another device a wrap it cannot open. skm makes that recoverable rather than terminal:

okfails

wrap arrives

unwrap

adopt the epoch

DELETE keys/:epoch emit keys.unopenable

reappears in keygaps

an honest holder re-grants

Keeping an unopenable wrap would be worse than discarding it: the device would sit one epoch behind forever, holding every write, with the relay insisting it had been served. Cryptographic write control — signed rotations against a policy — is the real answer and is not built. See SECURITY.md.


graph_keygraph_keygraph_keygraph_keydevice_id

graph_meta

text

graph_key

PK

int

latest_epoch

int

rotation_required

int

records

usage, maintained incrementally

int

bytes

int

blob_bytes

graph_records

text

graph_key

PK

text

record_id

PK

int

seq

ordering

int

rev

LWW

int

epoch

which key sealed it

text

nonce

text

ciphertext

text

kind

NULL unless the app opted in

text

shard

NULL unless the app opted in

int

size

int

at

graph_keys

text

graph_key

PK

int

epoch

PK

text

device_id

PK

text

wrapped

sealed TO a device pubkey

int

created_at

graph_blobs

text

graph_key

PK

text

blob_id

PK

text

nonce

int

size

text

policy

int

expires_at

graph_seq

text

graph_key

PK

int

next_seq

devices

text

device_id

PK

text

member_id

text

pub_jwk

ECDH

text

sig_jwk

ECDSA

text

label

int

created_at

int

revoked_at

Nothing in that schema can hold plaintext, and a test asserts it — by column name and by content, across every table.

Two design notes:

Usage is counted incrementally. records, bytes and blob_bytes on graph_meta exist because enforcing quotas with COUNT(*) and SUM(size) on every push made a graph of 100k records push about five times slower than an empty one. npm run bench fails if a scan comes back.

graph_seq is a counter, never MAX(seq) + 1. A number reused after a delete would make a puller skip a record.

Migrations are append-only. MIGRATIONS runs under PRAGMA user_version; a migration that has shipped is never edited. The relay refuses to run against a database written by a newer build rather than guessing.

KeyStore secrets

device:<id> → ECDH keypair

sign:<id> → ECDSA keypair

keyring:<graphId> → {epochs, current}

GraphStore records, plaintext

records: graphId + id → GraphRecord

queue: ids waiting to push

cursors: per SUBSCRIPTION, not per graph

They are deliberately separate. Wiping records on a recovery import must not take the keys with it, and a native shell wants the keys in the OS keychain while records stay in a database file.

Cursors are keyed by subscription because a device pulling g:acme unfiltered and g:acme filtered to one kind is at two different places in the same sequence. Sharing one cursor would make each subscription skip what the other consumed.

yesno

bestStore

OPFS + SharedArrayBuffer + worker available?

OpfsGraphStore SQLite-WASM, SAH-pool VFS, in a worker

IdbGraphStore indexed by graph and kind

Node / Electron main

NodeSqliteGraphStore node:sqlite on a real file

tests, server agents

MemoryGraphStore

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. Not a confidentiality problem; very much a correctness surprise, and it is in SECURITY.md for that reason.


overoknullidentitynoyesnorevokedyesnot a memberunknown memberokfailok

HTTP request

rate limiter per member, per bucket

429

authenticate

401

isSafeId member AND device

403 malformed-identity

device registered to THIS member?

403 device-not-registered

403 device-revoked

resolve the graph

403

404

zod body schema

400 invalid

transact: BEGIN IMMEDIATE

route logic

poke(graph) — carries no content

response

isSafeId is not cosmetic. A member id containing a colon would let two different pairs collide on one d:a:b storage key — an isolation break, not a typo — so ids are validated before anything builds a key from them.

Every inbound frame is schema-validated before any logic touches it. At the relay that means the HTTP body; on the device it means the decrypted record, because the relay cannot validate what it cannot read and any key-holding member can craft anything. Validation lives at the last boundary that can enforce it, not the first one that is convenient.

blobs

POST /graphs/:gid/blobs/:id

GET /graphs/:gid/blobs/:id

POST /graphs/:gid/blobs/:id/ack

DELETE /graphs/:gid/blobs/:id

keys

GET /graphs/:gid/keys

GET /graphs/:gid/keygaps

POST /graphs/:gid/keys

DELETE /graphs/:gid/keys/:epoch

GET /graphs/:gid/devices

records

POST /graphs/:gid/push

GET /graphs/:gid/pull

devices

PUT /devices/:id

GET /devices

POST /devices/:id/revoke

Fourteen routes. None of them parses a record’s contents; four of them handle key material and none of them can open any of it.


Large content does not belong in a record — the record limit is 512KB and LWW on a 20MB payload is a bad idea. Blobs are sealed bytes beside sealed records, addressed by a random id a record can point at.

policy retain (default)policy transportpolicy ephemeral (ttl)explicit DELETEevery member ackedttl passed

retain

transport

ephemeral

deleted

forgotten

The sweeper runs inside the relay by default (sweepIntervalMs, 60s), because retention policy that only runs when somebody remembers to schedule it is not retention policy. A multi-process deployment wants exactly one sweeper, not one per process — set sweepIntervalMs: 0 and call sweepBlobs() from a single job.

Retention is intent, not enforcement. transport and ephemeral are executed by a relay you may not control; a relay that keeps a copy is invisible to you. This is stated plainly in SECURITY.md and should be stated plainly to your users.


Everything here is designed around one assumption: the network is not there, and the app must not care.

debounced, backgroundoffline5xx / 429 / network4xxoknext sync

app writes

local store, synchronously

queue the id

app continues

sync

stays queued; status.lastError set

jittered backoff, bounded attempts

not retryable: surface it

dequeue

failure what happens
relay unreachable writes queue; reads are local and unaffected; status.lastError is set
relay returns 429 or 5xx retried with jittered backoff, bounded attempts
relay returns 4xx not retried — surfaced as status.rejected and a record.rejected event
device clock is wrong the relay’s now is adopted once, and the monotonic mark is reset with it
a record fails the app’s schema skipped with record.rejected {reason: 'schema'}; the rest of the batch lands
the roster cannot be fetched the batch is deferred, cursor kept — never dropped
a wrap cannot be opened discarded, reported, re-granted
the process dies mid-flush the queue is in the store; the next boot drains it
two tabs, OPFS taken the second falls back to IndexedDB — see the note above
relaydevice (clock 3 days fast)BOTH halves matter. Correcting the skewwithout resetting the high-water mark leavesevery later record rejected forever.push rev = now + 3 daysrejected {reason: 'rev-too-far-ahead'}, now = <relay clock>clockSkewMs = myClock - relayNowlastAt = min(lastAt, corrected now)push rev = correctedaccepted

untrusted: the networkuntrusted: the relaysemi-trusted: the host app's servertrusted: the devicesealed opsnames member + deviceanswers membership

app code

SkmEngine

plaintext store

your sessions authenticate()

your users Directory

ciphertext + wrapped keys

TLS is the host app's job

What the relay learns anyway, stated plainly: who syncs what and when, record ids and sizes, the pairwise graph list, and — only if an app opts in — kinds and shards. What it does not learn: record contents, authorship (signatures live inside the ciphertext), and what you read (read positions live in the personal graph, encrypted).

The honest limits — rollback and omission by a malicious relay, the ghost device, a hostile member’s denial of service, the browser delivery caveat — are in SECURITY.md, which is the document to read before changing anything here.


Every one of these is an interface because the alternative was guessing what your deployment looks like.

client

Transport HTTP, or your own

GraphStore five implementations

KeyStore keychain, file, IDB

KindRegistry your schemas

now() your clock

relay

Directory your users

authenticate your sessions

BlobStore SQLite, filesystem, S3

RateLimiter memory, Redis

onPoke your realtime

onEvent your metrics

Two of them deserve a warning:

  • MemoryRateLimiter is per process. Correct for one relay, wrong for four behind a load balancer. Back RateLimiter with something shared before you scale out.
  • FileKeyStore is plaintext on disk. Right for a server-side agent whose disk is already the trust boundary, wrong for a laptop — there the OS keychain is the answer, and KeyStore is an interface so that it can be.

same originonPoke

browser app

your Fastify server

registerRelay(app, ...)

your existing routes

SQLite / your DB

your websocket hub

The common case, and the one skm is designed for. Your sessions, your users, your realtime; skm adds routes under a prefix and a table set.

app instance

load balancer

app instance

relay 1

relay 2

shared database

shared rate limiter

one sweeper job

Two things change when you scale out: the rate limiter must be shared, and exactly one process should sweep blobs (sweepIntervalMs: 0 everywhere else).

a bot / worker

SkmEngine MemoryGraphStore or NodeSqliteGraphStore FileKeyStore

relay

A worker that needs to read a graph is just another device: register it, grant it, revoke it like any other. It holds real keys, so its disk is a real trust boundary.


The bugs in a system like this are the ones where everything still appears to work, so the suite is built to be adversarial rather than exhaustive.

unit — core: crypto, canonical JSON, schemas

npm run verify typecheck, lint, format, test, build

integration — real relay, real crypto, injected HTTP

fuzz — hostile input at every relay route

convergence — seeded random scenarios, 3–5 devices

smoke — the BUILT packages, over a real socket

CI

npm run mutate every invariant broken on purpose

npm run soak hundreds of randomized scenarios

npm run bench asserts a bound, not a number

Three of those are unusual enough to explain:

npm run smoke drives the built packages over a real socket. Tests run against src through a vitest alias; the published packages resolve each other through exports, which point at dist. The smoke test is what closes that gap — if you change an exports map or add a file, it is the one that notices.

npm run mutate breaks each guarded line in turn — accept a bad signature, drop the record id from the AAD, let a rotation strand a device, resolve a revision tie the other way — and fails if no test goes red. A test that has never been red is not evidence of anything. Its first run left four mutations alive, and each one was a real gap in the suite.

npm run soak runs hundreds of randomized convergence scenarios on a virtual clock the seed advances, so a failing seed reproduces exactly. It found the revision-floor bug and the rotation-propagation delay described above. Failing seeds are promoted into the commit suite, so a bug found once is a bug tested forever.