Secure storage and distribution of cryptographic keys in ATProto applications

ATP Keyserver Architecture#

Documentation Guide#

This document focuses on server-side architecture. For other topics:

Overview#

A specialized AT Protocol (ATProto) service for secure storage and distribution of cryptographic keys. This keyserver manages both asymmetric keypairs (Ed25519) for public key cryptography and symmetric keys (XChaCha20-Poly1305) for group-based encrypted communication.

Key principle: The server manages keys but never performs encryption/decryption. All cryptographic operations happen client-side for true end-to-end encryption.

Technology Stack#

Runtime & Core#

  • Bun - JavaScript runtime and toolkit
  • itty-router - Lightweight HTTP router with CORS support
  • SQLite - Embedded database with WAL mode for persistence

AT Protocol Integration#

  • @atcute/identity - DID validation and parsing
  • @atcute/identity-resolver - DID resolution with caching
  • @atcute/xrpc-server - JWT verification for XRPC protocol
  • @atcute/lexicons - Lexicon type definitions

Cryptography#

  • @noble/ed25519 - Ed25519 asymmetric key operations
  • @noble/ciphers - XChaCha20-Poly1305 symmetric encryption

Architecture Components#

1. Entry Point (main.ts)#

The main application file that:

  • Initializes the HTTP router with CORS middleware
  • Exposes service identity via .well-known/did.json
  • Defines public and authenticated API endpoints
  • Implements a middleware authentication wall
  • Serves HTTP requests on configurable port (default: 4000)

2. Database Layer (lib/db.ts)#

SQLite database with four tables supporting key versioning and access logging:

-- Asymmetric keypairs (Ed25519)
keys
├── did (TEXT, NOT NULL)
├── version (INTEGER, NOT NULL, DEFAULT 1)
├── public_key (TEXT, NOT NULL)
├── private_key (TEXT, NOT NULL)
├── created_at (TEXT, NOT NULL)
├── revoked_at (TEXT, nullable)
├── status (TEXT, NOT NULL, DEFAULT 'active')
├── status_comment (TEXT, nullable)
└── PRIMARY KEY (did, version)
    INDEX idx_keys_did_status ON (did, status)

-- Symmetric group keys (XChaCha20-Poly1305)
groups
├── id (TEXT, NOT NULL)
├── version (INTEGER, NOT NULL, DEFAULT 1)
├── owner_did (TEXT, NOT NULL)
├── secret_key (TEXT, NOT NULL)
├── created_at (TEXT, NOT NULL)
├── revoked_at (TEXT, nullable)
├── status (TEXT, NOT NULL, DEFAULT 'active')
├── status_comment (TEXT, nullable)
└── PRIMARY KEY (id, version)
    INDEX idx_groups_id_status ON (id, status)

-- Group membership
group_members
├── group_id (TEXT, NOT NULL)
├── member_did (TEXT, NOT NULL)
└── PRIMARY KEY (group_id, member_did)
    FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE

-- Key access audit log
key_access_log
├── id (INTEGER, PRIMARY KEY AUTOINCREMENT)
├── did (TEXT, NOT NULL)
├── version (INTEGER, NOT NULL)
├── accessed_at (TEXT, NOT NULL)
├── ip (TEXT, nullable)
└── user_agent (TEXT, nullable)

3. Authentication Middleware (lib/authMiddleware.ts)#

JWT-based authentication following AT Protocol standards:

Flow:

  1. Validates request path starts with /xrpc/
  2. Extracts Bearer token from Authorization header
  3. Parses XRPC lexicon (lxm) from URL path
  4. Verifies JWT signature using issuer's signing key via getSigningKey()
  5. Resolves DID to retrieve signing key (with caching via IdResolver)
  6. Attaches JWT payload and DID to request context

DID Resolution:

  • In-memory cache: 1 hour TTL, 24 hour maximum
  • Uses @atcute/identity-resolver with MemoryCache
  • Resolves AT Protocol-specific DID data
  • Extracts signing key from DID document
  • Returns 403 errors for invalid or missing authorization

4. Asymmetric Cryptography (lib/keys/asymmetric.ts)#

Ed25519 keypair management:

Key Features:

  • Lazy Generation: Keypairs created on first request per DID
  • Persistent Storage: Keys stored in SQLite for reuse
  • Hex Encoding: Keys encoded as hexadecimal strings
  • Per-DID Isolation: Each DID has a unique keypair

Functions:

  • createKeypair() - Generates new random Ed25519 keypair
  • getKeypair(did) - Retrieves or creates keypair for DID
  • getPublicKey(did) - Gets just the public key

5. Symmetric Cryptography (lib/keys/symmetric.ts)#

XChaCha20-Poly1305 authenticated encryption for group messaging:

Encryption Scheme:

  • 32-byte secret keys (256-bit) encoded as hex
  • 24-byte nonces (unique per message)
  • Additional authenticated data (AAD) using message ID
  • Format: hex(nonce) + hex(ciphertext)

Group Management:

  • Groups identified by {owner_did}#{group_name} format
  • Owner has full control (add/remove members)
  • Members can retrieve group keys
  • Lazy group creation on first access by owner
  • Type-safe database queries with DBSchema

Error Handling:

  • Uses itty-router's error() function for HTTP errors
  • 404 errors for non-existent groups
  • 403 errors for unauthorized access
  • 409 errors for duplicate member additions

Exported Functions:

  • createSecretKey() - Generate random 256-bit key (hex-encoded)
  • encryptMessage(id, key, plaintext) - Encrypt with AAD
  • decryptMessage(id, key, ciphertext) - Decrypt with AAD
  • getGroupKey(group_id, authed_did) - Get key with access control
  • addMember(group_id, member_did, authed_did) - Add member (owner only)
  • removeMember(group_id, member_did, authed_did) - Remove member (owner only)

Internal Helpers:

  • getGroup(group_id) - Fetch group from database
  • checkMembership(group_id, member_did) - Verify member status
  • createGroup(group_id, owner_did) - Create new group with secret key

API Endpoints#

The keyserver exposes the following endpoint categories:

Public Endpoints (No Authentication)#

  • GET / - Service metadata
  • GET /.well-known/did.json - Service DID document
  • GET /xrpc/dev.atpkeyserver.alpha.keypair.getPublicKey - Get any user's public key

Protected Endpoints (Require Service Auth Token)#

Personal Key Management:

  • GET /xrpc/dev.atpkeyserver.alpha.keypair.getKeypair - Get personal keypair
  • POST /xrpc/dev.atpkeyserver.alpha.keypair.rotate - Rotate personal keypair
  • GET /xrpc/dev.atpkeyserver.alpha.keypair.listVersions - List key versions

Access Logs:

  • GET /xrpc/dev.atpkeyserver.alpha.accessLogs.getLogs - Get access logs

Group Key Management:

  • GET /xrpc/dev.atpkeyserver.alpha.group.getKey - Get group key
  • POST /xrpc/dev.atpkeyserver.alpha.group.rotateKey - Rotate group key (owner only)
  • GET /xrpc/dev.atpkeyserver.alpha.group.listVersions - List group key versions

Group Membership Management:

  • POST /xrpc/dev.atpkeyserver.alpha.group.addMember - Add member (owner only)
  • POST /xrpc/dev.atpkeyserver.alpha.group.removeMember - Remove member (owner only)

For complete endpoint documentation including request/response formats, parameters, error codes, and examples, see API_REFERENCE.md.

Key Versioning & Revocation#

Overview#

All key versions are retained to ensure backward compatibility and prevent data loss. This design is optimized for ATProto's distributed architecture where encrypted posts are permanently stored across PDSes and relays.

Why Versioning?#

In ATProto microblogging:

  • Posts are encrypted and stored permanently in public relays
  • Followers cache encrypted posts locally
  • Signatures must remain verifiable for thread integrity
  • Forward secrecy is architecturally impossible

Key rotation limits damage radius: When a key leaks, rotation prevents new posts from being decrypted, while old posts remain readable to authorized parties.

Key Lifecycle#

Status Values:

  • active - Current version, used for encryption
  • revoked - No longer active (compromised or rotated out), still available for decryption

Default Behavior:

  • New keys start as version 1 with status active
  • API requests without version parameter return active key
  • Specific versions can be requested for decrypting old content

Client Integration#

Encrypting New Content:

// Always fetch active key
const { secretKey, version } = await fetch('/xrpc/dev.atpkeyserver.alpha.group.getKey?group_id=...')

// Embed version in post metadata
const post = {
  encrypted_content: encrypt(content, secretKey),
  key_version: version,  // Critical for decryption
  encrypted_at: new Date().toISOString()
}

Decrypting Old Content:

// Read version from post metadata
const keyVersion = post.key_version

// Fetch specific version
const { secretKey } = await fetch(
  `/xrpc/dev.atpkeyserver.alpha.group.getKey?group_id=...&version=${keyVersion}`
)

const decrypted = decrypt(post.encrypted_content, secretKey)

Rotation Workflow#

User Suspects Compromise:

  1. Call /xrpc/dev.atpkeyserver.alpha.keypair.rotate with reason
  2. Old version marked as revoked, new version created as active
  3. All new encryptions use new version
  4. Old encrypted content remains decryptable with old version

What's Protected:

  • Future posts (encrypted with new key)
  • Signature verification (old keys still available)
  • Thread continuity (all messages remain readable)

What's Not Protected:

  • Already-distributed encrypted posts (attacker already has them)
  • Cached keys on compromised devices (can't remotely delete)

Access Logging#

Every time a key is accessed, a record like this is stored in the database for security monitoring:

// Logged automatically on key retrieval
{
  did: "did:plc:abc123",
  version: 2,
  accessed_at: "2025-01-23T10:30:00.000Z",
  ip: "192.0.2.1",
  user_agent: "Mozilla/5.0..."
}

Use Cases:

  • Detect unusual access patterns (e.g., 10,000 requests/hour)
  • Identify compromised accounts
  • Audit compliance requirements
  • Incident response investigation

API Access to logs: Users can retrieve access logs for their keys via the authenticated /xrpc/dev.atpkeyserver.alpha.accessLogs.getLogs endpoint. This allows users to:

  • Monitor their own key usage
  • Detect unauthorized access attempts
  • Review historical access patterns
  • Verify legitimate access from their devices

Account Deletion & GDPR Compliance#

Overview#

The keyserver implements GDPR Article 17 (Right to Erasure) through cryptographic erasure, a technique recognized by EU data protection authorities as effective deletion.

Deletion Endpoint#

Endpoint: POST /xrpc/dev.atpkeyserver.alpha.account.delete

Requirements:

  • Authentication required (service auth token)
  • Explicit confirmation: { "confirmation": "DELETE_ALL_MY_DATA" }
  • Irreversible action (no undo)

What Gets Deleted#

When a user deletes their account, the following data is permanently removed:

  1. All Asymmetric Keypairs:

    • All versions (1 through N)
    • Public and private keys
    • Status and metadata
  2. All Symmetric Group Keys:

    • All groups owned by the user
    • All versions of those group keys
    • CASCADE deletion removes all memberships
  3. All Group Memberships:

    • User removed from groups owned by others
    • No longer able to decrypt group content
  4. All Access Logs:

    • Complete audit trail deleted
    • IP addresses and user agents removed
    • GDPR right to erasure satisfied

Cryptographic Erasure#

Concept: Encrypted data without decryption keys is computationally infeasible to decrypt. Deleting keys makes encrypted content effectively useless—indistinguishable from random bytes.

Keyserver Scope:

  • What it deletes: Encryption keys, group keys, memberships, access logs
  • What it does NOT delete: Encrypted posts from PDSes, relays, or caches
  • Content deletion: Handled separately by ATProto's deletion protocol
  • Client responsibility: Clients should delete encrypted posts from PDS when account is deleted

Why Cryptographic Erasure:

  • Keyserver's domain: The keyserver manages keys, not content
  • ATProto deletion protocol: Separate mechanism for content deletion (not guaranteed everywhere)
  • Distributed architecture: Encrypted posts may exist across relays and caches
  • Ultimate guarantee: Even if content persists, it's unreadable without keys
  • GDPR Compliance: Ciphertext without keys is no longer "personal data"
  • Industry Standard: Used by AWS, Azure, Google Cloud

Legal Recognition:

  • EU data protection authorities accept cryptographic erasure
  • Satisfies GDPR Article 17 (Right to Erasure)
  • Ciphertext considered "effectively deleted"
  • No practical means of recovery

Consequences & User Warnings#

Consequences:

  1. All encrypted posts become permanently unreadable
  2. Groups owned by user are deleted (affects all members)
  3. User loses access to groups they were a member of
  4. Historical encrypted content lost forever
  5. No recovery mechanism (by design)

Client Responsibilities:

  1. Warn users before deletion:
⚠️ WARNING: This action cannot be undone

Deleting your account will:
- Delete ALL encryption keys (X versions)
- Delete groups you own (affects Y members)
- Make all encrypted posts permanently unreadable
- Remove you from Z groups

Type "DELETE_ALL_MY_DATA" to confirm:
  1. Delete encrypted posts from PDS (recommended):
// Optional but recommended: delete posts from PDS before deleting keys
for (const post of userEncryptedPosts) {
  await agent.com.atproto.repo.deleteRecord({
    repo: userDid,
    collection: 'app.bsky.feed.post',
    rkey: post.rkey
  })
}
  1. Delete keys from keyserver (required for GDPR):
await keyserver.deleteAccount({ confirmation: 'DELETE_ALL_MY_DATA' })

Note: Deleting posts from PDS (step 2) will propagate deletion notices to relays, but this is not guaranteed to be honored everywhere. Cryptographic erasure (step 3) ensures content is unreadable regardless.

Access Log Retention#

Policy:

  • 90-day automatic retention (configurable)
  • Cleanup runs on server startup
  • Can be adjusted for jurisdiction (30-180 days)

Rationale:

  • Balance security monitoring with privacy
  • Industry standard for audit logs
  • GDPR requires retention limits
  • Allows incident investigation

Security Model#

Authentication & Authorization#

  1. Public Key Distribution: Open access for discovering public keys
  2. Private Key Access: Strict JWT authentication required
  3. DID-Based Identity: All operations tied to AT Protocol DIDs
  4. Self-Service: Users can only access their own private keys via authenticated endpoint

Cryptographic Security#

  • Ed25519: Modern elliptic curve signature scheme (128-bit security)
  • XChaCha20-Poly1305: Authenticated encryption (256-bit keys)
  • Random Generation: Cryptographically secure randomness via @noble/*
  • Nonce Uniqueness: Fresh random nonce per encrypted message

Group Access Control#

  • Owner Authorization: Only group owners can modify membership
  • Member Verification: Key access validated against membership table
  • Namespace Isolation: Group IDs scoped by owner DID

For more insight on the security model, see SECURITY.md

Configuration#

There is not much to configure yet in this first version. The only thing would be the DID of the service and the port this service is running on, which are configured with environment variables as described here:

  • DID (required) - Service's own DID identifier
  • PORT (optional) - HTTP server port (default: 4000)

Development Workflow#

To create a new endpoint for the server, the development workflow would be something like this:

  1. Define the endpoint route in main.ts and decide whether it requires authentication or not.
  2. Implement the new endpoint inner logic in a file in the lib folder.
  3. Create a lexicon definition file for the new endpoint in the lexicons folder.
  4. Update the related documentation about API endpoints.

Design Patterns#

Lazy Initialization#

Keypairs are generated on-demand rather than during registration:

  • Reduces upfront computational cost
  • Simplifies user onboarding
  • Maintains backward compatibility with existing DIDs

Middleware Authentication Wall#

Clear separation between public and protected endpoints:

  • All routes after router.all('*', authMiddleware) require authentication
  • Makes security boundaries explicit in code
  • Prevents accidental exposure of protected endpoints

Hex-Encoded Keys#

All cryptographic material encoded as hexadecimal strings:

  • Consistent encoding across the API
  • URL-safe without additional encoding
  • Human-readable for debugging

Project Structure#

atp-keyserver/                        # Monorepo root
├── lexicons/                         # XRPC lexicon definitions (API contract)
│   └── dev/atpkeyserver/alpha/       # ATP Keyserver lexicons
│       ├── defs.json                 # Shared type definitions
│       ├── keypair.getKeypair.json   # Get personal keypair
│       ├── keypair.getPublicKey.json # Get public key
│       ├── keypair.listVersions.json # Key version history
│       ├── keypair.rotate.json       # Key rotation
│       ├── accessLogs.getLogs.json   # Access log retrieval
│       ├── group.getKey.json         # Get group key
│       ├── group.rotateKey.json      # Group key rotation
│       ├── group.listVersions.json   # Group key versions
│       ├── group.addMember.json      # Add group member
│       ├── group.removeMember.json   # Remove group member
│       └── account.delete.json       # Account deletion (GDPR)
├── packages/
│   ├── server/                       # Keyserver service
│   │   ├── main.ts                   # Entry point and routing
│   │   ├── package.json              # Server dependencies
│   │   ├── tsconfig.json             # Server TypeScript config
│   │   ├── README.md                 # Server starting documentation
│   │   ├── lib/
│   │   │   ├── db.ts                 # Database schema and connection
│   │   │   ├── authMiddleware.ts     # JWT authentication with DID resolution
│   │   │   └── keys/
│   │   │       ├── asymmetric.ts     # Ed25519 keypair management with versioning
│   │   │       ├── symmetric.ts      # XChaCha20 encryption & group management
│   │   │       ├── access-log.ts     # Key access logging with retention
│   │   │       └── deletion.ts       # Account deletion (GDPR compliance)
│   │   └── keyserver.db              # SQLite database (generated at runtime)
│   └── client/                       # Client library
│       ├── src/
│       │   ├── index.ts              # Package entry point
│       │   ├── crypto.ts             # Encryption/decryption functions
│       │   ├── types.ts              # Client config types and re-exports
│       │   ├── errors.ts             # Custom error classes
│       │   ├── cache.ts              # LRU cache for keys and tokens
│       │   ├── service-auth.ts       # Service auth token management
│       │   ├── client.ts             # KeyserverClient class
│       │   └── lexicon/              # Generated types from lexicons
│       │       ├── index.ts          # Generated client API
│       │       ├── lexicons.ts       # Lexicon schemas and validation
│       │       ├── util.ts           # Generated utilities
│       │       └── types/            # Generated TypeScript types
│       ├── dist/                     # Compiled output (generated)
│       ├── package.json              # Client dependencies
│       ├── tsconfig.json             # ESM build config
│       ├── tsconfig.cjs.json         # CommonJS build config
│       └── README.md                 # Client starting documentation
├── docs/                             # Centralized documentation
│   ├── ARCHITECTURE.md               # Server architecture (this file)
│   ├── ENCRYPTION_PROTOCOL.md        # E2E encryption protocol specification
│   ├── SECURITY.md                   # Security best practices for new clients
│   ├── API_REFERENCE.md              # Complete API endpoint reference
│   └── CLIENT_IMPLEMENTATION.md      # Client library design decisions
├── package.json                      # Workspace root (includes gen-lexicon script)
├── tsconfig.json                     # Base TypeScript configuration
├── bun.lock                          # Dependency lock file
├── README.md                         # Project overview
└── LICENSE.md                        # License information

Lexicon Type Generation#

TypeScript types are automatically generated from lexicon definitions:

# Generate client types from lexicons
bun run gen-lexicon

This runs @atproto/lex-cli gen-api to create TypeScript interfaces in packages/client/src/lexicon/, ensuring the client types stay synchronized with the API contract defined in lexicons.

Code Quality#

Strengths#

  • Clean separation of concerns (database, auth, crypto)
  • Type-safe database queries with TypeScript
  • Modern cryptography libraries (@noble/ed25519, @noble/ciphers)
  • Consistent error handling using itty-router's error()
  • Good use of AT Protocol standards
  • Proper DID validation and method checking
  • WAL mode enabled for SQLite (better concurrency)

Considerations for Future Development#

  • Database error handling could be more comprehensive
  • Input validation beyond DID format checking
  • Lexicon names are hard-coded (dev.atpkeyserver.alpha)
  • No unit tests or integration tests currently
  • Rate limiting not implemented

Scalability Considerations#

Current Limitations#

  • Single SQLite database limits horizontal scaling
  • In-memory DID cache not shared across instances
  • No connection pooling (single-file SQLite)

Potential Improvements#

  • Consider distributed database for multi-instance deployments
  • External cache (Redis) for DID resolution across instances
  • Load balancer with sticky sessions for single-instance deployments

Key Rotation & Recovery#

Current Implementation#

  • Full key versioning system for asymmetric and symmetric keys
  • Key rotation API endpoints for both user and group keys
  • All historical key versions retained for backward compatibility
  • Access logging for security monitoring and incident response

Key Rotation Features#

  • User Key Rotation: /xrpc/dev.atpkeyserver.alpha.keypair.rotate
  • Group Key Rotation: /xrpc/dev.atpkeyserver.alpha.group.rotateKey (owner only)
  • Version Listing: View all key versions with status and timestamps
  • Specific Version Access: Retrieve historical keys for decrypting old content
  • Status Tracking: Active or revoked status per version (with reason in metadata)

Design Trade-offs#

  • Backward Compatibility Over Forward Secrecy: All versions retained to prevent data loss
  • ATProto-Optimized: Designed for distributed public storage architecture
  • No Re-encryption: Old content uses old keys, new content uses new keys
  • Thread Integrity: Signatures remain verifiable across all versions

Future Considerations#

  • Key escrow or recovery mechanism for lost access
  • Encrypted backup strategy for database
  • Scheduled/automatic key rotation policies
  • Enhanced anomaly detection based on access logs