Encrypted, ephemeral, private memos on atproto

CLAUDE.md#

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Commands#

Testing#

# Run all tests across the monorepo
deno test --allow-env

# Run tests for a specific package
deno test packages/crypto/

# Run E2E tests (requires CISTERN_HANDLE and CISTERN_APP_PASSWORD environment variables)
deno test --allow-env --allow-net e2e.test.ts

Lexicon Code Generation#

# Generate TypeScript types from JSON lexicon definitions
cd packages/lexicon
deno task generate

This generates types in packages/lexicon/src/types/ from the JSON schemas in packages/lexicon/lexicons/. Run this after modifying any .json files in the lexicons directory.

Type Checking#

# Deno executes TypeScript directly - no build step needed
# Check types explicitly with:
deno check <file.ts>

Architecture Overview#

Cistern is a Deno monorepo implementing a private, encrypted quick-capture system on AT Protocol. Items are end-to-end encrypted using post-quantum cryptography and stored temporarily in the user's PDS (Personal Data Server).

Monorepo Structure#

Five packages with clear separation of concerns:

  • @cistern/crypto - Core cryptographic primitives (encryption/decryption/keys)
  • @cistern/lexicon - AT Protocol schema definitions (pubkey + item records)
  • @cistern/shared - Authentication utilities and common code
  • @cistern/producer - Creates and encrypts items for storage
  • @cistern/consumer - Retrieves, decrypts, and deletes items

Internal imports use the @cistern/* namespace defined in each package's deno.jsonc.

Producer/Consumer Pattern#

Producer workflow (packages/producer/mod.ts):

  1. Select a public key from those registered in the user's PDS
  2. Encrypt plaintext using the public key
  3. Create an app.cistern.lexicon.item record with the encrypted payload
  4. Upload to PDS

Consumer workflow (packages/consumer/mod.ts):

  1. Generate an X-Wing keypair (post-quantum)
  2. Upload public key to PDS as app.cistern.lexicon.pubkey record
  3. Keep private key locally (never uploaded)
  4. Retrieve items via polling (listItems()) or streaming (subscribeToItems())
  5. Decrypt items matching the local keypair
  6. Delete items after consumption

Encryption Architecture#

Algorithm: x_wing-xchacha20_poly1305-sha3_512

The encryption system uses a hybrid approach combining:

  • X-Wing KEM (Key Encapsulation Mechanism) - post-quantum hybrid combining ML-KEM-768 and X25519
  • XChaCha20-Poly1305 - authenticated encryption cipher
  • SHA3-512 - content integrity verification

Encryption flow (packages/crypto/src/encrypt.ts):

  1. X-Wing encapsulation generates a shared secret from the public key
  2. XChaCha20-Poly1305 encrypts the plaintext using the shared secret
  3. SHA3-512 hash computed for integrity verification
  4. Returns EncryptedPayload containing ciphertext, nonce, hash, and metadata

Decryption flow (packages/crypto/src/decrypt.ts):

  1. X-Wing decapsulation recovers the shared secret using the private key
  2. XChaCha20-Poly1305 decrypts the content
  3. Integrity verification: check content length and SHA3-512 hash match
  4. Returns plaintext or throws error if verification fails

AT Protocol Integration#

Cistern uses two record types in the user's PDS:

app.cistern.lexicon.pubkey (packages/lexicon/lexicons/app/cistern/lexicon/pubkey.json):

  • Stores public keys with human-readable names
  • Referenced by items via AT-URI
  • Schema: {name, algorithm, content, createdAt}

app.cistern.lexicon.item (packages/lexicon/lexicons/app/cistern/lexicon/item.json):

  • Stores encrypted items temporarily
  • Schema: {tid, ciphertext, nonce, algorithm, pubkey, payload, contentLength, contentHash}
  • The pubkey field is an AT-URI reference to the public key record

Real-time Streaming#

The consumer can subscribe to new items via Jetstream (packages/consumer/mod.ts:150-190):

  • Connects to Bluesky's Jetstream WebSocket service
  • Filters for app.cistern.lexicon.item creates matching user DID
  • Decrypts items as they arrive in real-time
  • Used for instant delivery (e.g., Obsidian plugin waiting for new memos)

Key Management#

Private keys never leave the consumer's device. The security model depends on:

  • Private key stored off-protocol (e.g., in an Obsidian vault)
  • Public key stored in PDS as a record
  • Items encrypted with public key can only be decrypted by matching private key
  • Each keypair can have a human-readable name (e.g., "Work Laptop", "Phone")

Dependencies#

Cryptography (JSR packages):

  • @noble/post-quantum - X-Wing KEM implementation
  • @noble/ciphers - XChaCha20-Poly1305
  • @noble/hashes - SHA3-512

AT Protocol (npm packages):

  • @atcute/client - RPC client for PDS communication
  • @atcute/jetstream - Real-time event streaming
  • @atcute/lexicons - Schema validation
  • @atcute/tid - Timestamp identifiers

Key Files and Locations#

Cryptographic Operations#

  • packages/crypto/src/keys.ts - Keypair generation (X-Wing)
  • packages/crypto/src/encrypt.ts - Encryption logic
  • packages/crypto/src/decrypt.ts - Decryption + integrity verification
  • packages/crypto/src/*.test.ts - Crypto unit tests

Producer Implementation#

  • packages/producer/mod.ts - Main producer class and encryption workflow

Consumer Implementation#

  • packages/consumer/mod.ts - Keypair management, item retrieval, Jetstream subscription

Authentication#

  • packages/shared/produce-requirements.ts - DID resolution and session creation
  • Uses Slingshot service for handle → DID resolution
  • Creates authenticated RPC client with app password

Schema Definitions#

  • packages/lexicon/lexicons/app/cistern/lexicon/*.json - AT Protocol record schemas
  • packages/lexicon/src/types/ - Generated TypeScript types (run deno task generate to update)
  • packages/lexicon/lex.config.ts - Lexicon generator configuration

Important Patterns#

Error Handling in Decryption#

Decryption can fail for multiple reasons (packages/crypto/src/decrypt.ts):

  • Wrong private key (decapsulation fails)
  • Corrupted ciphertext (authentication fails)
  • Length mismatch (integrity check fails)
  • Hash mismatch (integrity check fails)

Always wrap decrypt calls in try-catch and handle gracefully.

Pagination in Consumer#

listItems() returns an async generator that handles pagination automatically. It yields decrypted items and internally manages cursors. Consumers should iterate with for await loops.

Resource URIs#

AT Protocol uses AT-URIs to reference records: at://<did>/<collection>/<rkey>

The consumer caches the public key's AT-URI with the local keypair to filter which items it can decrypt.

Testing#

Unit Tests#

Each package contains unit tests following these conventions:

  • Test files use .test.ts suffix
  • Use @std/expect for assertions
  • Mock external dependencies (RPC clients, credentials)
  • Test both success and error paths

Test locations:

  • packages/crypto/src/*.test.ts - Cryptographic operations
  • packages/consumer/mod.test.ts - Consumer functionality
  • packages/producer/mod.test.ts - Producer functionality

End-to-End Tests#

e2e.test.ts contains integration tests that use real AT Protocol credentials:

  • Requires CISTERN_HANDLE and CISTERN_APP_PASSWORD environment variables
  • Tests full workflow: keypair generation, encryption, decryption, deletion
  • Uses Deno test steps to segment each phase
  • Automatically skipped if environment variables are not set
  • Cleans up all test data after execution