Skip to content

ursalock

Passkey-powered E2EE sync for Zustand. No recovery key. Self-hostable.
Terminal window
npm install @ursalock/zustand @ursalock/client @ursalock/crypto

Passkey = Key

Your passkey derives the encryption key via WebAuthn PRF. No recovery key to lose or store.

Zero-Knowledge

Data encrypted client-side before leaving browser. Server stores opaque blobs only.

Cross-Device Sync

Same passkey = same encryption key. Sync seamlessly across devices.

Self-Hostable

Single Docker image, SQLite storage. Your data, your server.

import { create, type StateCreator } from "zustand";
import { vault, type VaultOptionsJwk } from "@ursalock/zustand";
import type { CipherJWK } from "@ursalock/crypto";
interface NotesState {
notes: string[];
addNote: (note: string) => void;
}
// After passkey authentication, you get a cipherJwk
function createStore(cipherJwk: CipherJWK) {
const storeCreator: StateCreator<NotesState> = (set) => ({
notes: [],
addNote: (note) => set((s) => ({ notes: [...s.notes, note] })),
});
return create(vault(storeCreator, {
name: "my-notes",
cipherJwk,
server: "https://vault.example.com",
getToken: () => client.getToken(),
}));
}
// Sync across devices
await store.vault.sync();
import { useSignIn } from "@ursalock/client";
const { signIn } = useSignIn(vaultClient);
// User taps passkey
const result = await signIn({ usePasskey: true });
if (result.success) {
// result.credential.cipherJwk → encryption key
// result.credential.jwt → auth token
initStore(result.credential.cipherJwk);
}
PackageDescriptionSize
@ursalock/cryptoEncryption primitives (AES-256-GCM)~4 KB
@ursalock/zustandZustand middleware~6 KB
@ursalock/clientAuth client + React hooks~7 KB
@ursalock/serverSelf-hosted backend~8 KB