v0.1.0 · 10 tests · real ECDH P-256 + AES-GCM

The server never sees your data.

Encrypted cells, end-to-end. Type a secret. Pick viewers. Each viewer gets a wrapped key. The server only sees ciphertext.

A alice fp: —
— nothing encrypted yet —
B bob fp: —

Bob is in the ACL. He can decrypt. He fetches the ciphertext and his wrapped key, then unwraps.

— no access yet —
E eve not in the ACL

Eve has the ciphertext. She has a valid keypair. But she's not in the ACL. She has no wrapped key for the content key. The server doesn't have the content key. The math says no.

0
cells
0
wrapped keys
0
bytes ciphertext
0
viewers
Under the hood

How it works

1. Alice generates a keypair

Every actor in the vault (owner + viewers) generates an ECDH P-256 keypair. The public half is shared; the private half never leaves the device.

  • P-256: ~256-bit security, fast, standard
  • Public key is a 65-byte uncompressed point
  • 16-hex-char fingerprint for easy identification
const pair = await crypto.subtle.generateKey( { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits'] );

2. Content encrypted with random AES key

Each cell gets its own AES-GCM 256-bit content key. The key is generated freshly. The ciphertext is stored on the server. The key never leaves the owner's device in plaintext.

  • AES-GCM: authenticated encryption
  • 12-byte random IV per encryption
  • Fresh content key per cell — no key reuse
const contentKey = await crypto.subtle.generateKey( { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ); const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, contentKey, plaintext );

3. Content key wrapped per viewer

For each viewer in the ACL, the owner derives a shared secret using ECDH (their private + viewer's public) and uses it to wrap (encrypt) the content key. Each viewer gets a different wrapped key.

  • ECDH shared secret: forward-secret if keys rotate
  • Wrapped key is encrypted with the shared secret
  • Server stores wrapped keys; never sees plaintext
// For each viewer: const shared = await crypto.subtle.deriveKey( { name: 'ECDH', public: viewer.publicKey }, owner.privateKey, { name: 'AES-GCM' } ); const wrapped = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: wrapIv }, shared, rawContentKey );

4. Viewer unwraps and decrypts

To decrypt, the viewer derives the same shared secret (their private + owner's public), unwraps the content key, then decrypts the ciphertext. Bob succeeds. Eve fails: she has no wrapped key.

  • Only the owner can add/remove viewers
  • Revoking a viewer re-encrypts the cell with a new key
  • The old viewer can no longer decrypt, ever
const shared = await crypto.subtle.deriveKey( { name: 'ECDH', public: owner.publicKey }, viewer.privateKey, { name: 'AES-GCM' } ); const rawKey = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: wrapIv }, shared, wrappedKey ); const plaintext = await crypto.subtle.decrypt( { name: 'AES-GCM', iv }, contentKey, ciphertext );

Privacy as a first-class primitive.

The cell model is end-to-end encrypted by default. The server is dumb storage. The intelligence is in the cells, on your devices.

View source → See all 11 repos