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.
Quick start
Four things happen here that are not optional: keys are generated on the device, recovery is enrolled before any vault opens, every membership change re-keys the vault, and the change itself is sealed so the backend cannot read what it stores. 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. The id is
// generated, not supplied: it is plaintext on every request, so the
// protocol will not let an application name it (spec §6.5, §12).
const { group_id } = await vault.createGroup({ 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(group_id, { userId: "bob", devicePubkey });
// Encrypt and store; fetch and decrypt.
await vault.putJsonRecord(group_id, "note-1", { body: "…" });
const note = await vault.getJsonRecord(group_id, "note-1");
// Removing a member re-keys the vault before this promise resolves.
await vault.removeMember(group_id, "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 · recordIdFor
Devices
linkDevice · linkDeviceToGroup ·
unlinkDevice · unlinkDeviceFromGroup ·
devicesOf · lostDeviceOptions
What you should know before you design around it
You do not name anything the backend sees
Record names are handled for you — putJsonRecord(vault, "q3-layoffs", …)
derives an opaque identifier on the device, so the backend never sees the name.
(listRecords therefore returns derived ids; use
recordIdFor to match them to your own keys.) Vault ids work the same
way from the other end: createGroup(policy) takes no identifier and
returns a random one, because it is plaintext on every request. Keep your
own label against it, as you would any opaque primary key.
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.
// `auth` is the per-request signature described below. `WireTransition`
// is the sealed outer envelope — the change itself is inside it, and
// your backend never opens it.
interface Transport {
createGroup(genesis): Promise<GroupHandle>;
getGroupState(groupId, auth?): Promise<GroupStateSnapshot>; // {id, version} only
submitTransition(groupId, transition, auth?): Promise<SubmitResult>;
getTransitions(groupId, sinceEpoch?, auth?): Promise<WireTransition[]>;
subscribeToTransitions?(groupId, onTransition): Unsubscribe; // optional
putRecord(groupId, record, auth?): Promise<PutResult>;
getRecord(groupId, recordId, auth?): Promise<EncryptedRecord>;
listRecords(groupId, cursor?, auth?): Promise<RecordPage>;
// Keyed by a handle blinded from the user's recovery credential,
// never by a user id — you hold no list of who has a backup.
putBackupBlob(handle, blob): Promise<void>;
getBackupBlob(handle): 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 bodies, key envelopes, removal notices and backup blobs are stored and served byte-for-byte. Never parse, normalise, re-encode — or trim the padding. Those structures are padded to fixed sizes precisely so their length says nothing; a storage layer that compresses the zeroes away puts the leak back.
Verify the request signature
Every group-scoped call carries a signature from a key derived from the vault key, so you can tell a member from a stranger without knowing which member. Reads accept any key the vault has published; writes require the current one. Collapsing that into one rule deadlocks every client that fell behind — it would need the key that the fetch it cannot make would deliver.
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, the stale-write gate and request-signature checking — and misbehaves on demand, so you can assert that your app handles replay, forks, gaps and swapped bodies 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.
Its most recent revision, made before first publication, was to stop putting group composition on the wire at all. The original design justified publishing the membership list on the grounds that the backend needed it for access control — true when the backend is your own server, false when it is a third party’s. Sealing the change body, removing recipient addresses from key envelopes, padding every structure to a fixed size and deriving identifiers rather than accepting them all follow from taking that one assumption away.
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.