Developers

Encryption that behaves like a storage API.

CircleKey runs in the browser. It is written in TypeScript with strict on, ships as ESM with named exports only, and has exactly two runtime dependencies — @noble/curves and hash-wasm — both confined to the default cryptography adapter. Everything in the protocol core is dependency-free.

npm install circlekey · Apache-2.0 · Node ≥ 20 for tooling · GitHub

Quick start

Three things happen here that are not optional: keys are generated on the device, recovery is enrolled before any vault opens, and every membership change re-keys the vault. You cannot configure them away, and that is the point.

import { GroupVault } from "circlekey";

// `transport` is your backend adapter — CircleKey Cloud, or your own.
const vault = await GroupVault.open({ transport, userId: "alice" });

// Recovery enrolment is structurally mandatory: every vault operation
// throws until it completes. Show the credential to the user exactly
// once — it is 128 bits of CSPRNG output and no server ever sees it.
const credential = await vault.enrollBackup();
await vault.confirmBackupStored(credential);

// Create a vault. The creator becomes its first manager.
await vault.createGroup("case-4417", { min_managers: 2 });

// Add a member once you know their device public key (exchanged
// out of band — a QR code, your own directory, however you like).
await vault.addMember("case-4417", { userId: "bob", devicePubkey });

// Encrypt and store; fetch and decrypt.
await vault.putJsonRecord("case-4417", "note-1", { body: "…" });
const note = await vault.getJsonRecord("case-4417", "note-1");

// Removing a member re-keys the vault before this promise resolves.
await vault.removeMember("case-4417", "bob");

// Lost device? The credential restores the same identity and every
// vault it belonged to, including history from before the loss.
const recovered = await GroupVault.restore({ transport, userId: "alice", credential });

The API surface

One facade, grouped by what you are doing. Everything returns typed errors — subclasses of CircleKeyError, never a bare Error — so failure modes like a stale write or a detected fork are things you can branch on rather than parse.

Identity & recovery

GroupVault.open · GroupVault.restore · enrollBackup · confirmBackupStored · refreshBackup · isBackupEnrolled · identityPublicKey · devicePublicKey

Vaults & membership

createGroup · addMember · removeMember · promoteMember · demoteMember · setPolicy · getGroupState · syncGroup · watchGroup

Records

putJsonRecord · getJsonRecord · putRecordBytes · getRecordBytes · listRecords

Devices

linkDevice · linkDeviceToGroup · unlinkDevice · unlinkDeviceFromGroup · devicesOf · lostDeviceOptions

Device actions are self-scoped: a signer may only ever change their own device list. A manager cannot touch somebody else’s devices, and an ordinary member does not need a manager to add their own laptop. This is enforced by every client on every incoming change, not by a server-side permission check.

What you should know before you design around it

Metadata is your responsibility

Record identifiers, sizes and timestamps reach the backend in plaintext. If a title, filename or label is sensitive, put it inside the encrypted record rather than alongside it. The library gives you an encrypted metadata channel for exactly this; using it is a design decision only you can make.

Sized for small vaults

Every membership change re-keys and re-seals to every member’s every device. At ten or twenty people that is free. At several hundred it is not, and you should be reaching for a tree-based group protocol instead. Design your vaults around who genuinely needs access.

Multiple tabs share one database

Browser tabs share IndexedDB, so the library treats concurrency as a first-class problem: vault mutations run under a Web Lock, cross-tab messages are treated as hints that trigger a re-read of verified state rather than as data, and key usage counters increment atomically.

Secure contexts only

It fails fast outside a secure context rather than silently degrading, requests persistent storage during onboarding, and works offline against a local cache with automatic fallback when the backend is unreachable.

The backend interface

CircleKey never talks to a network directly. It talks to a Transport you supply. Use ours, or implement these against whatever you already run — the wire format is entirely yours.

interface Transport {
  createGroup(genesis): Promise<GroupHandle>;
  getGroupState(groupId): Promise<GroupStateSnapshot>;   // a routing hint, never trusted
  submitTransition(groupId, transition): Promise<SubmitResult>;
  getTransitions(groupId, sinceEpoch?): Promise<EpochTransition[]>;
  subscribeToTransitions?(groupId, onTransition): Unsubscribe;  // optional

  putRecord(groupId, record): Promise<PutResult>;
  getRecord(groupId, recordId): Promise<EncryptedRecord>;
  listRecords(groupId, cursor?): Promise<RecordPage>;

  putBackupBlob(userId, blob): Promise<void>;
  getBackupBlob(userId): Promise<EncryptedBackupBlob>;
}

Uniqueness, atomically

Only one membership change may ever be accepted per vault version. A database constraint is enough. The loser gets a conflict reply and the client rebuilds and retries. This is the only concurrency control the protocol needs from you.

Reject stale writes

Track a plaintext version integer per vault and refuse record writes tagged below it. Plain integer comparison — no cryptography involved.

Keep opaque fields opaque

Ciphertext, nonces, sealed envelopes and backup blobs are stored and served byte-for-byte. Never parse, normalise or re-encode them.

Append-only history

Membership changes are never mutated, replaced or reordered, and are served in ascending version order. Clients verify the chain regardless; your opinion about a change is never authoritative.

Testing, with no network and no server

The circlekey/testing subpath ships an in-memory store and a full-fidelity mock backend. Your whole encryption path is testable in CI, offline, with no sleeps and nothing to spin up.

MockTransport

Implements the interface above at full fidelity, including version uniqueness and the stale-write gate — and misbehaves on demand, so you can assert that your app handles replay, forks, gaps and swapped signatures the way it should.

runHostIntegrationScenario

Point it at your own Transport and it drives the entire client-observable contract, naming the exact assertion that failed. If it passes, your backend is done.

The protocol underneath

CircleKey implements the GroupVault Protocol, an open specification for end-to-end encrypted group storage. The protocol borrows its shape from MLS — group secrets, versioned epochs, rekey on membership change — and swaps the tree for a linear, signed, auditable chain, on the grounds that a group of ten does not need a structure designed for a group of ten thousand.

You do not need to read it to use CircleKey. It is there because a security claim you cannot check is not a security claim, and because the protocol is deliberately separable from this implementation — the specification, the wire formats and the test vectors are public, and nothing about them is ours to change unilaterally.