ATP Keyserver Architecture#
Documentation Guide#
This document focuses on server-side architecture. For other topics:
- End-to-end encryption protocol: See ENCRYPTION_PROTOCOL.md
- Security best practices: See SECURITY.md
- Complete API reference: See API_REFERENCE.md
- Client library usage: See Client README
- Implementation strategy: See CLIENT_IMPLEMENTATION.md
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:
- Validates request path starts with
/xrpc/ - Extracts Bearer token from Authorization header
- Parses XRPC lexicon (lxm) from URL path
- Verifies JWT signature using issuer's signing key via
getSigningKey() - Resolves DID to retrieve signing key (with caching via IdResolver)
- Attaches JWT payload and DID to request context
DID Resolution:
- In-memory cache: 1 hour TTL, 24 hour maximum
- Uses
@atcute/identity-resolverwith 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 keypairgetKeypair(did)- Retrieves or creates keypair for DIDgetPublicKey(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 AADdecryptMessage(id, key, ciphertext)- Decrypt with AADgetGroupKey(group_id, authed_did)- Get key with access controladdMember(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 databasecheckMembership(group_id, member_did)- Verify member statuscreateGroup(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 metadataGET /.well-known/did.json- Service DID documentGET /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 keypairPOST /xrpc/dev.atpkeyserver.alpha.keypair.rotate- Rotate personal keypairGET /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 keyPOST /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 encryptionrevoked- 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:
- Call
/xrpc/dev.atpkeyserver.alpha.keypair.rotatewith reason - Old version marked as
revoked, new version created asactive - All new encryptions use new version
- 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:
-
All Asymmetric Keypairs:
- All versions (1 through N)
- Public and private keys
- Status and metadata
-
All Symmetric Group Keys:
- All groups owned by the user
- All versions of those group keys
- CASCADE deletion removes all memberships
-
All Group Memberships:
- User removed from groups owned by others
- No longer able to decrypt group content
-
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:
- All encrypted posts become permanently unreadable
- Groups owned by user are deleted (affects all members)
- User loses access to groups they were a member of
- Historical encrypted content lost forever
- No recovery mechanism (by design)
Client Responsibilities:
- 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:
- 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
})
}
- 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#
- Public Key Distribution: Open access for discovering public keys
- Private Key Access: Strict JWT authentication required
- DID-Based Identity: All operations tied to AT Protocol DIDs
- 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 identifierPORT(optional) - HTTP server port (default: 4000)
Development Workflow#
To create a new endpoint for the server, the development workflow would be something like this:
- Define the endpoint route in
main.tsand decide whether it requires authentication or not. - Implement the new endpoint inner logic in a file in the
libfolder. - Create a lexicon definition file for the new endpoint in the
lexiconsfolder. - 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