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
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
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.