Skip to content

skm — sealed graphs

Local-first storage, sync, and key custody for apps whose server is not allowed to read the data.

Every device holds the real database. The server is a relay that orders and ferries ciphertext it can never open — no plaintext, no keys, no parsing of record contents. Two members on two continents converge; the machine in the middle learns sizes and timing and nothing else.

skm was extracted from xdash, a working end-to-end encrypted workspace, and generalized: the parts that knew about one lab’s chat rooms are gone, and the parts that were hard — the key ceremonies — are the product.

Terminal window
npm i @skm/core @skm/client # devices
npm i @skm/core @skm/relay # your server

The 2026 sync-engine field is crowded and good: ElectricSQL, PowerSync, Zero, InstantDB. Almost all of them sync plaintext through a server the vendor can read, because that is what server-side search, notifications and AI features need.

skm is for the other case: the app where “we cannot read it” is the feature. It is not trying to be the fastest sync engine. It is trying to be the one whose key distribution you do not have to invent yourself — because that, not the sync loop, is why teams give up on end-to-end encryption.

@skm/core crypto boundary + wire protocol isomorphic, zod, WebCrypto only
@skm/client the device engine + local stores browsers, native shells, Node
@skm/relay the blind relay a Fastify plugin you mount
  • Three graph shapes. u (personal), g:<id> (a shared group — teams, workspaces, projects), d:<id> (a pairwise channel with its own key).
  • Per-device keys. Keys are wrapped to devices, not accounts. Adding a device is a ceremony — register a public key, be granted the keyring — not a private key copied between machines. Losing one device is revocable without touching the others.
  • Signed records. Every device signs what it writes (ECDSA P-256, inside the ciphertext, so the relay never learns an author). Receivers verify against the graph’s roster and check the claimed author against the signing device. by is not a string somebody typed.
  • Epoch rotation. Removing someone bumps the epoch and re-wraps to who remains. The relay refuses a rotation that would strand anybody rather than silently splitting a graph in half.
  • Invites that carry keys. The invite string is <doorCode>.<keySecret>. Only the door code reaches your server; the keyring rides along sealed under PBKDF2 of the half that never does.
  • Partial sync, opt in. Declare kind or an app-chosen shard as metadata and pull filtered subsets, with a cursor per subscription. Off by default, because everything you declare is readable by the relay forever.
  • Offline by default. Reads and writes never wait on a network. LWW per record, monotonic revisions, cursor-paged pull, a queue that survives a restart.
  • Blobs with a retention policy: retain, transport (forgotten once every member has a copy), ephemeral (TTL) — over SQLite, the filesystem, or your own store.
  • Unlinkable personal ids. A record in your personal graph that refers to a shared one is named by an HMAC tag, so the relay cannot join the two and read off what you track. Record ids are metadata; this is how you stop metadata from being an index.

Mount the relay in the app you already have — your sessions, your users:

import Fastify from 'fastify';
import { openDb, registerRelay, SqliteDirectory } from '@skm/relay';
const db = openDb('data/relay.db');
const app = Fastify();
await registerRelay(app, {
db,
// skm does not want to own your users. Answer two questions about them.
directory: new SqliteDirectory(db),
// Your session check. The token must name the member AND the device.
authenticate: async (req) => {
const session = await mySessions.verify(req);
return session ? { memberId: session.userId, deviceId: session.deviceId } : null;
},
// Optional: wire to whatever realtime you already run. Pokes carry nothing.
onPoke: (evt) => myHub.toMembers(evt.memberIds, { type: 'skm.poke' }),
onEvent: (evt) => metrics.count(evt.type),
});

On the device:

import { SkmEngine, HttpTransport, bestStore, IdbKeyStore, defineKinds } from '@skm/client';
import { z } from 'zod';
const engine = await SkmEngine.open({
memberId: me.id,
deviceId: localStorage.getItem('skm-device') ?? SkmEngine.newDeviceId(),
transport: new HttpTransport({ baseUrl: '/api/skm', credentials: 'same-origin' }),
store: await bestStore({ name: me.id }), // OPFS-SQLite, or IndexedDB
keyStore: new IdbKeyStore(),
// A decrypted record is untrusted input. Say what your kinds look like.
kinds: defineKinds({
note: z.object({ body: z.string().max(10_000) }),
task: z.object({ title: z.string(), done: z.boolean() }),
}),
});
await engine.openGraph('u', { create: true }); // my own graph
await engine.openGraph('g:acme'); // a shared one
engine.startAutoSync();
await engine.put('g:acme', { kind: 'note', data: { body: 'hello' } });
const notes = await engine.listKind('g:acme', 'note'); // typed, local, instant
// Re-runs when the answer changes, not on every keystroke in the graph.
engine.watch('g:acme', (e) => e.listKind('g:acme', 'task'), render);

See the whole lifecycle on a console — linking a device, sharing a graph, rejecting a forged author, rotating a stolen phone out, and then printing what the relay actually holds:

Terminal window
npm ci
npm run verify # typecheck, lint, test, build, smoke
npm run build && npm run example
SKM_DEV_AUTH=1 npm -w @skm/relay run dev # a relay on :8787
npm run mutate # break each invariant; does a test notice?
npm run soak # hundreds of randomized convergence runs
npm run bench && npm run bench:client # assert the shape of the cost

The dev server refuses to start without SKM_DEV_AUTH=1, because it authenticates nobody.

Strict TypeScript, type-aware lint, CI on Node 22 and 24. The suite proves:

  • the relay’s own database contains no plaintext and no usable key material — asserted against every table, and against the relay’s source, which is checked mechanically for any import that could decrypt
  • a second device is linked without any private key moving between machines, end to end through the real relay
  • a member holding the key cannot forge another member’s authorship
  • a revoked device reads nothing sealed after its revocation, and what it wrote before stays verifiable
  • ciphertext bound to one record will not open under another, and a flipped byte is rejected rather than wedging the sync
  • a rotation that would strand a device is refused; a wrap a device cannot open is discarded and re-granted rather than locking it out forever
  • a write landing mid-sync is not left in the queue
  • a push too large for the wire is split rather than stalling forever
  • a device with a permanently wrong clock still syncs
  • a member id the relay would build an ambiguous storage key from is refused
  • two devices creating or rotating a graph at the same moment end up on the same key, rather than each keeping one the other cannot read
  • devices doing randomized work in randomized order converge — three of them and five, across rotations, deletions, revision ties and a device linked halfway through, with tombstones compared as well as visible records
  • the relay answers 4xx to every hostile input a fuzz suite can think of, and is still working afterwards
  • a device that has not been granted the current key holds its writes rather than sealing them under the superseded one
  • a personal record’s derived id survives a rotation, a newly linked device, and a recovery import — so a preference never quietly becomes two
  • every store implementation — memory, IndexedDB, SQLite — passes one conformance suite, and an engine survives a restart with its identity, its keyrings and its unflushed queue
  • the documentation is answerable to the source: the API reference names only methods that exist, the limits tables carry the real numbers, and every link resolves

Several of those tests were written by breaking the fix first and watching them go red. npm run mutate does that on purpose and by machine: it holds a catalogue of 52 mutations to the lines carrying an invariant — accept a bad signature, drop the AAD’s record id, let a rotation strand a device, resolve a revision tie the other way — applies each one, and fails the build if the suite does not go red. All 52 are killed today, and CI keeps it that way. A green suite is not the claim; a suite that notices is.

npm run soak drives hundreds more randomized convergence scenarios than the commit suite can afford. It found a real one: an edit to a record written by a device whose clock ran a minute ahead was stamped BELOW the revision it replaced, so the relay dropped the push, the local store kept the edit, and the cursor had already moved past the relay’s copy — the edit existed on one device and nowhere else, permanently, with no error anywhere. A seed that fails there becomes a fixed seed in the suite.

Measured on this machine with npm run bench and npm run bench:client, at 20,000 records. Both scripts assert a bound, so a change of shape fails the build rather than surprising somebody later.

per record what it is
write (queue locally) ~19µs a store write; the app never waits for more
write (push) ~560µs canonical JSON + one ECDSA signature + one AES seal
receive (pull) ~510µs one AES open + one ECDSA verification
relay push, 200 records ~3ms flat from an empty graph to 100,000
relay pull page, 500 records ~5ms flat

The per-record numbers are dominated by ECDSA P-256 — about 310µs to sign and 300µs to verify in Node’s WebCrypto, and typically faster in a browser. That is the price of knowing who wrote a record. An app that genuinely does not need authorship can set sign: false and roughly halve both, at the cost of by becoming a claim again. Interactive use never notices either way; a bulk import of a hundred thousand records is a minute, not a moment.

v0.1 — real, extracted from a system in daily use, and narrow on purpose.

What is not here, in the order it matters:

  1. Rollback and omission detection. A malicious relay cannot read or forge a record, but it can serve an old one to a device that has never seen the new one, or quietly omit records entirely. Detecting that needs a signed log — see ROADMAP.md.
  2. Key transparency. A malicious directory can add a device and wait for a grant. The defense today is visibility — the roster is public and auditable — not mathematics.
  3. Full-text search. Substring scan today; FTS5 is a small change against the SQL store and a bigger one against IndexedDB.

See SECURITY.md for what skm protects, from whom, and where it does not.

  • docs/GUIDE.mdstart here to build on skm. Install, concepts, the relay, the device, key custody, sharing, revocation, recovery, blobs, partial sync, operations, recipes, troubleshooting, API reference.
  • ARCHITECTURE.md — how it works and why each boundary is where it is, with diagrams of the sync loop, the key ceremonies, the storage layout and the trust boundaries.
  • PROTOCOL.md — the wire, spelled out well enough to write another implementation against.
  • SECURITY.md — invariants, threat model, and the honest limits. Read before writing code here.
  • ROADMAP.md — what is missing and what it would take.
  • CONTRIBUTING.md — how to work on it.

The guide and the architecture document are checked against the source by packages/client/test/docs.test.ts: the API reference must name methods that exist, the exports lists must name real exports, the limits tables must carry the real numbers, and every link must resolve. Prose does not fail a build on its own, so it is made to.

MIT.