Secure storage and distribution of cryptographic keys in ATProto applications

Client Implementation Strategy#

Purpose#

This document outlines the design philosophy and implementation strategy for the ATP Keyserver client library. It serves as a roadmap for contributors and explains key architectural decisions.

For protocol details, see ENCRYPTION_PROTOCOL.md. For security guidelines, see SECURITY.md. For usage instructions, see Client README.

Design Philosophy#

Client-Side Encryption#

Decision: Cryptographic operations (encryption/decryption) happen on the client, not the server.

Rationale:

  • True end-to-end encryption: Server never sees plaintext
  • Better performance: <1ms local crypto vs 100-500ms server round-trip
  • Lower server load: 50% less CPU, 97% less bandwidth
  • Aligned with ATProto: Decentralized architecture, user sovereignty
  • Cost efficiency: Scales with user devices, not server capacity

Trade-offs:

  • Clients must handle cryptography correctly
  • Requires distributing crypto library (~52KB)
  • Key caching responsibility on client

Alternative considered: Server-side encryption (rejected due to trust model and performance)

Single Package Architecture#

Decision: One npm package @atpkeyserver/client with optional imports via subpath exports.

Rationale:

  • Crypto library (@noble/ciphers) is ~52KB and represents 90% of bundle
  • High-level client adds only ~8KB on top of crypto functions
  • Maintenance overhead of separate packages not justified by small size difference
  • Subpath exports allow tree-shaking for minimal bundle impact

Package structure:

@atpkeyserver/client
├── /crypto      → Just crypto functions (52KB)
├── /client      → Just KeyserverClient (60KB, includes crypto)
└── / (main)     → Both exports (60KB, tree-shaken)

Alternative considered: Separate @atpkeyserver/client-crypto and @atpkeyserver/client packages (rejected due to minimal size benefit)

Service Auth Integration#

Decision: Client library abstracts service auth token management via callback pattern.

Rationale:

  • PDS clients vary by platform (@atproto/api, custom implementations)
  • Callback pattern allows any PDS client integration
  • Client library handles token caching automatically
  • Separates concerns: client lib = crypto + keyserver API, user provides = PDS auth

Implementation:

new KeyserverClient({
  keyserverDid: 'did:web:keyserver.example.com',
  getServiceAuthToken: async (aud, lxm) => {
    // User provides: obtain token from their PDS client
    return await pdsClient.getServiceAuth({ aud, lxm })
  }
})

See ENCRYPTION_PROTOCOL.md for service auth details.

Component Architecture#

Core Components#

1. Crypto Module (crypto.ts)

  • XChaCha20-Poly1305 encryption/decryption
  • Uses @noble/ciphers library
  • Stateless, pure functions
  • No network dependencies

2. KeyserverClient (client.ts)

  • HTTP client for keyserver API
  • Service auth token management
  • Key caching with TTL
  • Error handling and retries

3. Cache Module (cache.ts)

  • In-memory LRU cache
  • Separate TTLs for active/historical keys
  • Service auth token caching
  • Automatic expiry

4. Service Auth Module (service-auth.ts)

  • Token cache management
  • Expiry checking with safety margin
  • Automatic refresh logic

5. Types Module (types.ts)

  • TypeScript interfaces
  • Configuration types
  • Response types

6. Errors Module (errors.ts)

  • Custom error classes
  • Structured error handling
  • HTTP status code mapping

Dependency Graph#

index.ts
├── crypto.ts
│   └── @noble/ciphers
├── client.ts
│   ├── crypto.ts
│   ├── cache.ts
│   ├── service-auth.ts
│   ├── types.ts
│   └── errors.ts
├── types.ts
└── errors.ts

Key Features#

Automatic Caching#

Key Caching:

  • Active keys: 1 hour TTL (may change with rotation)
  • Historical keys: 24 hour TTL (immutable)
  • LRU eviction when max size reached

Service Auth Token Caching:

  • 60 second TTL (or server-specified exp)
  • Refresh when <10 seconds remain
  • Memory-only storage

Benefits:

  • Reduces keyserver load
  • Improves client performance
  • Handles token refresh automatically

See SECURITY.md for security considerations.

Request Deduplication#

Multiple simultaneous requests for the same key are collapsed into a single network request:

// Both calls trigger only one network request
const [key1, key2] = await Promise.all([
  client.getGroupKey('did:plc:abc#followers'),
  client.getGroupKey('did:plc:abc#followers')
])

Automatic Retry#

Network failures retry with exponential backoff:

  • Retry on: 5xx errors, network timeouts
  • Don't retry on: 4xx errors (client errors are permanent)
  • Max retries: 3 attempts
  • Backoff: 100ms, 200ms, 400ms

Type Safety#

Full TypeScript support:

  • Strict typing for all APIs
  • IntelliSense support
  • Compile-time error detection
  • Generated type definitions

Bundle Size Analysis#

Tree-shaking eliminates unused code. Actual bundle sizes depend on what you import:

Scenario 1: Crypto Functions Only#

import { encryptMessage, decryptMessage } from '@atpkeyserver/client/crypto'

Bundled: ~52KB (minified + gzipped ~15KB)

  • @noble/ciphers: 50KB
  • crypto.ts: 2KB

Use case: You handle keyserver API calls manually, just need crypto.

Scenario 2: Full Client#

import { KeyserverClient } from '@atpkeyserver/client'

Bundled: ~60KB (minified + gzipped ~18KB)

  • @noble/ciphers: 50KB
  • Client code: 10KB (includes crypto, caching, HTTP, errors)

Use case: Full-featured client with automatic caching and token management.

Scenario 3: Both#

import { KeyserverClient, encryptMessage } from '@atpkeyserver/client'

Bundled: ~60KB (same as scenario 2)

  • Client includes crypto, no duplication

Use case: Mix high-level and low-level APIs as needed.

Bundle Optimization#

Automatic:

  • Tree-shaking removes unused exports
  • Minification reduces code size
  • Gzip compresses for network transfer

Manual optimization:

  • Use subpath imports (/crypto, /client) for smaller bundles
  • Import only what you need
  • Modern bundlers (webpack, vite, rollup) handle this automatically

Conclusion: Crypto library dominates bundle size. Single package vs separate packages saves only ~8KB, not worth maintenance complexity.

Implementation Phases#

Phase 1: Foundation (Complete)#

  • Crypto functions (encryptMessage, decryptMessage)
  • Type definitions
  • Package structure with subpath exports
  • Build configuration (ESM + CJS)

Phase 2: Client Library (Complete)#

  • KeyserverClient class
  • Service auth token management
  • Key caching with TTL
  • HTTP client with retry logic
  • Error handling
  • Request deduplication

Phase 3: Documentation (Complete)#

  • Protocol specification (ENCRYPTION_PROTOCOL.md)
  • Security guidelines (SECURITY.md)
  • API reference (API_REFERENCE.md)
  • Client README with usage examples
  • Minimal example implementation (basic-usage.ts)

Phase 4: Testing#

  • Unit tests for crypto functions
  • Integration tests for client
  • Mock keyserver for testing
  • E2E encryption flow test
  • Cache behavior tests
  • Service auth token refresh tests

Phase 5: Polish#

  • Performance benchmarks
  • Bundle size analysis
  • Documentation review
  • Example applications
  • npm package publication

Success Metrics#

Performance Targets#

Operation Target Measurement
Encryption <1ms Local crypto operation
Decryption (cached key) <5ms Cache lookup + crypto
Decryption (cache miss) <200ms Network fetch + crypto
Service auth (cached) <1ms Cache lookup
Service auth (cache miss) <100ms PDS round-trip

Developer Experience#

  • Complete TypeScript types
  • Comprehensive JSDoc comments
  • Working examples for 3+ platforms
  • <5 minutes from install to first encrypted message
  • Clear error messages with actionable guidance

Security#

  • Zero plaintext exposure to server
  • Keys cached in memory only
  • Service auth tokens cached for max 60 seconds
  • Proper cleanup on logout
  • No key or token leakage in logs
  • Audience binding prevents token misuse
  • Short-lived tokens limit attack window

Code Quality#

  • >80% test coverage
  • Zero runtime dependencies beyond @noble/ciphers
  • Tree-shakeable exports
  • CommonJS + ESM support
  • Works in Node.js, browser, React Native

Testing Strategy#

Unit Tests#

Crypto module:

  • Encrypt/decrypt round-trip
  • Nonce uniqueness
  • AAD binding
  • Error handling

Cache module:

  • TTL expiry
  • LRU eviction
  • Get/set operations
  • Clear operation

Service auth module:

  • Token expiry checking
  • Refresh logic
  • Cache key generation

Integration Tests#

Client:

  • Key fetch with caching
  • Service auth integration
  • Retry logic
  • Error handling

E2E:

  • Full encryption flow (PDS → keyserver → encrypt → store)
  • Full decryption flow (fetch → keyserver → decrypt → display)
  • Key rotation handling

Mock Server#

Build lightweight mock keyserver for testing:

  • Simulates auth verification
  • Returns mock keys
  • Configurable latency
  • Error injection

Open Questions#

Rate Limiting#

Question: Should client enforce rate limits?

Options:

  1. No client-side limits (rely on server)
  2. Simple debouncing (prevent rapid duplicate requests)
  3. Full rate limit tracking (mirror server limits)

Current decision: Option 2 (debouncing via request deduplication)

Cache Persistence#

Question: Should keys be persisted to disk?

Options:

  1. Memory-only (current implementation)
  2. Encrypted disk cache (requires key derivation)
  3. Platform-specific secure storage (iOS Keychain, Android KeyStore)

Current decision: Memory-only for security and simplicity

Rationale:

  • Simpler implementation
  • Better security (no disk persistence)
  • Works across all platforms
  • User can implement custom cache if needed

Error Recovery#

Question: How should clients handle persistent decryption failures?

Options:

  1. Fail silently (hide post)
  2. Show error message
  3. Retry with exponential backoff
  4. Prompt user action (e.g., "Request access")

Current decision: Show error message, no retry (decryption failures are permanent)

Contributing#

Contributions welcome! Please:

  1. Read existing documentation
  2. Follow code style (Prettier)
  3. Add tests for new features
  4. Update documentation as needed
  5. Submit PR with clear description

For questions, open an issue in the Codeberg repository.