@atpkeyserver/client
v0.2.0
Published
Client library for ATP keyserver with end-to-end encryption
Maintainers
Readme
@atpkeyserver/client
TypeScript client library for ATP Keyserver with end-to-end encryption.
Features
- End-to-end encryption with XChaCha20-Poly1305
- Service auth integration with ATProto PDS
- Automatic key caching for performance
- TypeScript-first with full type safety
- Tree-shakeable ESM and CommonJS support
- Zero runtime dependencies (except @noble/ciphers)
Installation
npm install @atpkeyserver/client
# or
bun add @atpkeyserver/clientQuick Start
Using Crypto Functions Only
If you want to handle key management manually:
import { encryptMessage, decryptMessage } from '@atpkeyserver/client/crypto'
// Encrypt a message
const messageId = 'at://did:plc:abc123/app.bsky.feed.post/xyz789'
const secretKey = '0123456789abcdef...' // 64 hex chars (32 bytes)
const plaintext = JSON.stringify({ text: 'Secret message' })
const ciphertext = encryptMessage(messageId, secretKey, plaintext)
// Returns: hex(nonce) + hex(ciphertext)
// Decrypt a message
const decrypted = decryptMessage(messageId, secretKey, ciphertext)
const post = JSON.parse(decrypted)
// Returns: { text: 'Secret message' }Using KeyserverClient
Full-featured client with automatic caching and service auth:
import { KeyserverClient } from '@atpkeyserver/client'
import { AtpAgent } from '@atproto/api'
// 1. Set up PDS client
const agent = new AtpAgent({ service: 'https://bsky.social' })
await agent.login({
identifier: 'user.bsky.social',
password: 'app-password'
})
// 2. Create keyserver client
const keyserver = new KeyserverClient({
keyserverDid: 'did:web:keyserver.example.com',
getServiceAuthToken: async (aud, lxm) => {
const { token } = await agent.com.atproto.server.getServiceAuth({
aud,
lxm,
exp: Math.floor(Date.now() / 1000) + 60
})
return token
}
})
// 3. Encrypt a post
const groupId = `${agent.session.did}#followers`
const postUri = 'at://did:plc:abc123/app.bsky.feed.post/xyz789'
const { ciphertext, version } = await keyserver.encrypt(
postUri,
groupId,
JSON.stringify({ text: 'Secret message for followers' })
)
// 4. Store encrypted post in PDS
await agent.com.atproto.repo.putRecord({
repo: agent.session.did,
collection: 'app.bsky.feed.post',
record: {
encrypted_content: ciphertext,
key_version: version,
encrypted_at: new Date().toISOString(),
createdAt: new Date().toISOString()
}
})
// 5. Decrypt a post from feed
const encryptedPost = await agent.getPost(postUri)
const plaintext = await keyserver.decrypt(
postUri,
groupId,
encryptedPost.encrypted_content,
encryptedPost.key_version
)
const post = JSON.parse(plaintext)Common Usage Patterns
Encrypting a Post for Followers
// 1. Get active group key and encrypt
const groupId = `${userDid}#followers`
const postUri = 'at://did:plc:abc123/app.bsky.feed.post/xyz789'
const plaintext = JSON.stringify({
text: 'Secret message for followers only',
createdAt: new Date().toISOString()
})
const { ciphertext, version } = await keyserver.encrypt(postUri, groupId, plaintext)
// 2. Store encrypted post
await agent.com.atproto.repo.putRecord({
repo: userDid,
collection: 'app.bsky.feed.post',
record: {
encrypted_content: ciphertext,
key_version: version,
encrypted_at: new Date().toISOString(),
visibility: 'followers'
}
})Decrypting Posts from Feed
// Fetch encrypted posts from feed
const feed = await agent.app.bsky.feed.getTimeline()
for (const post of feed.data.feed) {
if (post.post.record.encrypted_content) {
const { encrypted_content, key_version, visibility } = post.post.record
const groupId = `${post.post.author.did}#${visibility}`
const postUri = post.post.uri
try {
const plaintext = await keyserver.decrypt(
postUri,
groupId,
encrypted_content,
key_version
)
const decrypted = JSON.parse(plaintext)
console.log('Decrypted:', decrypted.text)
} catch (error) {
// Handle decryption errors (see next pattern)
console.error('Cannot decrypt post:', post.post.uri)
}
}
}Handling Decryption Errors
import {
ForbiddenError,
NotFoundError,
DecryptionError,
NetworkError
} from '@atpkeyserver/client'
try {
const plaintext = await keyserver.decrypt(postUri, groupId, ciphertext, version)
return JSON.parse(plaintext)
} catch (error) {
if (error instanceof ForbiddenError) {
// User lost access (removed from group)
return { error: 'You no longer have access to this content' }
}
if (error instanceof NotFoundError) {
// Group was deleted by owner
return { error: 'This group no longer exists' }
}
if (error instanceof DecryptionError) {
// Corrupted data, wrong key, or AAD mismatch
// This is permanent - don't retry
return { error: 'Cannot decrypt this message' }
}
if (error instanceof NetworkError) {
// Temporary issue - can retry
console.error('Network error, will retry:', error.message)
throw error
}
// Unknown error
console.error('Unexpected error:', error)
throw error
}Cache Management on Logout
// When user logs out, clear all cached keys and tokens
function logout() {
keyserver.clearCache() // Clear keys and service auth tokens
// Also clear PDS session
await agent.com.atproto.server.deleteSession()
// Clear any other app state
localStorage.clear()
}Handling Key Rotation
// When a post fails to decrypt, check if key was rotated
try {
const plaintext = await keyserver.decrypt(oldPostUri, groupId, ciphertext, latestVersion)
return JSON.parse(plaintext)
} catch (error) {
if (error instanceof DecryptionError) {
// Key might have been rotated - try fetching the specific version
console.log(`Retrying with key version ${oldVersion}`)
// KeyserverClient automatically caches historical versions
// This will fetch from cache or keyserver as needed
const retryPlaintext = await keyserver.decrypt(
oldPostUri,
groupId,
ciphertext,
oldVersion
)
return JSON.parse(retryPlaintext)
}
throw error
}Batch Decryption with Error Handling
async function decryptPosts(posts: EncryptedPost[]): Promise<DecryptedPost[]> {
const results = await Promise.allSettled(
posts.map(async (post) => {
const groupId = `${post.author}#${post.visibility}`
const plaintext = await keyserver.decrypt(
post.uri,
groupId,
post.encrypted_content,
post.key_version
)
return {
...post,
content: JSON.parse(plaintext)
}
})
)
// Filter out failed decryptions and log errors
return results
.filter((result) => {
if (result.status === 'rejected') {
console.error(`Failed to decrypt post ${result.reason}`)
}
return result.status === 'fulfilled'
})
.map((result) => (result as PromiseFulfilledResult<DecryptedPost>).value)
}API Reference
Crypto Functions
encryptMessage(id, key, plaintext)
Encrypts plaintext using XChaCha20-Poly1305 with additional authenticated data.
Parameters:
id(string): Message ID (AT-URI) used as AADkey(string): Hex-encoded 32-byte secret key (64 hex characters)plaintext(string): UTF-8 plaintext to encrypt
Returns: string - Hex-encoded nonce (48 chars) + ciphertext
Example:
const ciphertext = encryptMessage(
'at://did:plc:abc123/app.bsky.feed.post/xyz789',
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
'Secret message'
)decryptMessage(id, key, ciphertext)
Decrypts ciphertext using XChaCha20-Poly1305.
Parameters:
id(string): Message ID (AT-URI) used as AAD (must match encryption)key(string): Hex-encoded 32-byte secret key (64 hex characters)ciphertext(string): Hex-encoded nonce + ciphertext fromencryptMessage
Returns: string - UTF-8 plaintext
Throws: Error if:
- AAD doesn't match (wrong message ID)
- Key is incorrect
- Ciphertext is corrupted
- Authentication tag verification fails
Example:
const plaintext = decryptMessage(
'at://did:plc:abc123/app.bsky.feed.post/xyz789',
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
'48a3f2c1...9b8d7e6f'
)KeyserverClient
Constructor
new KeyserverClient(config: KeyserverClientConfig)Config Options:
interface KeyserverClientConfig {
keyserverDid: string // DID of keyserver
getServiceAuthToken: (aud: string, lxm?: string) => Promise<string> // Token provider
cache?: CacheOptions // Optional cache config
}
interface CacheOptions {
activeKeyTtl?: number // Active key TTL in seconds (default: 3600)
historicalKeyTtl?: number // Historical key TTL in seconds (default: 86400)
maxSize?: number // Max cache entries (default: 1000)
}getGroupKey(groupId: string)
Fetch the active group key with automatic caching.
Parameters:
groupId(string): Group ID in format{owner_did}#{group_name}
Returns: Promise<{ secretKey: string, version: number }>
Throws:
UnauthorizedError- Invalid or expired service auth tokenForbiddenError- User not a member of groupNotFoundError- Group doesn't existNetworkError- Network or server error
getGroupKeyVersion(groupId: string, version: number)
Fetch a specific historical key version with automatic caching.
Parameters:
groupId(string): Group IDversion(number): Key version number
Returns: Promise<{ secretKey: string }>
encrypt(messageId: string, groupId: string, plaintext: string)
Encrypt message with automatic key fetching.
Parameters:
messageId(string): Message ID (AT-URI)groupId(string): Group IDplaintext(string): UTF-8 plaintext
Returns: Promise<{ ciphertext: string, version: number }>
decrypt(messageId: string, groupId: string, ciphertext: string, version: number)
Decrypt message with automatic key fetching.
Parameters:
messageId(string): Message ID (AT-URI)groupId(string): Group IDciphertext(string): Hex-encoded nonce + ciphertextversion(number): Key version from encrypted record
Returns: Promise<string> - Decrypted plaintext
clearCache()
Clear all cached keys and service auth tokens. Call on logout.
keyserver.clearCache()Service Auth Integration
The keyserver uses ATProto service auth for authentication. You need to provide a getServiceAuthToken callback that obtains tokens from the user's PDS.
With @atproto/api
import { AtpAgent } from '@atproto/api'
import { KeyserverClient } from '@atpkeyserver/client'
const agent = new AtpAgent({ service: 'https://bsky.social' })
await agent.login({ identifier: 'user.bsky.social', password: 'app-password' })
const keyserver = new KeyserverClient({
keyserverDid: 'did:web:keyserver.example.com',
getServiceAuthToken: async (aud, lxm) => {
const { token } = await agent.com.atproto.server.getServiceAuth({
aud,
lxm,
exp: Math.floor(Date.now() / 1000) + 60
})
return token
}
})With OAuth
import { OAuthClient } from '@atproto/oauth-client'
import { KeyserverClient } from '@atpkeyserver/client'
import { AtpAgent } from '@atproto/api'
const oauthClient = new OAuthClient({ /* config */ })
// the way to authorize with OAuth might change depending on client platform (web, server or native)
const session = await oauthClient.authorize({ scope: 'atproto transition:generic' })
const agent = new AtpAgent(session)
const keyserver = new KeyserverClient({
keyserverDid: 'did:web:keyserver.example.com',
getServiceAuthToken: async (aud, lxm) => {
const { token } = await agent.com.atproto.server.getServiceAuth({
aud,
lxm,
exp: Math.floor(Date.now() / 1000) + 60
})
return token
}
})See Encryption Protocol for complete details on the authentication flow.
Caching Behavior
Key Caching
The client automatically caches keys in memory for performance:
- Active keys: 1 hour TTL (may change with rotation)
- Historical keys: 24 hour TTL (immutable)
- LRU eviction: When max size reached (default 1000 entries)
Cache keys are formatted as {groupId}:{version} for precise version tracking.
Service Auth Token Caching
Service auth tokens are cached with short TTL:
- TTL: 60 seconds (or server-specified
exp) - Refresh: Automatic when <10 seconds remain
- Memory-only: Never persisted to disk
Cache Security
- All caches are memory-only (never persisted)
- Cleared automatically on logout (call
clearCache()) - No sensitive data in cache keys
See Security Best Practices for details.
Error Handling
Error Types
import {
UnauthorizedError, // 401 - Invalid/expired auth token
ForbiddenError, // 403 - Not authorized for resource
NotFoundError, // 404 - Resource doesn't exist
NetworkError, // Network or server error
DecryptionError // Decryption failed
} from '@atpkeyserver/client'Handling Errors
try {
const plaintext = await keyserver.decrypt(messageId, groupId, ciphertext, version)
return JSON.parse(plaintext)
} catch (error) {
if (error instanceof ForbiddenError) {
// User lost access to group
return { error: 'You no longer have access to this content' }
} else if (error instanceof NotFoundError) {
// Group was deleted
return { error: 'This group no longer exists' }
} else if (error instanceof DecryptionError) {
// Corrupted data or wrong key
return { error: 'Cannot decrypt this message' }
} else if (error instanceof NetworkError) {
// Temporary network issue - could retry
throw error
} else {
// Unknown error
throw error
}
}Automatic Retry
Network errors (5xx, timeouts) are automatically retried with exponential backoff:
- Max retries: 3 attempts
- Backoff: 100ms, 200ms, 400ms
Client errors (4xx) are NOT retried as they indicate permanent issues.
Platform Compatibility
Node.js
Requires Node.js 22+ with native crypto support.
import { encryptMessage } from '@atpkeyserver/client/crypto'Browser
Works in all modern browsers with Web Crypto API:
import { KeyserverClient } from '@atpkeyserver/client'React Native
Requires crypto polyfills. See @noble/ciphers documentation for platform-specific setup.
Bundle Size
Tree-shaking automatically eliminates unused code:
| Import | Bundle Size (minified + gzipped) |
|--------|----------------------------------|
| @atpkeyserver/client/crypto | ~15KB |
| @atpkeyserver/client | ~18KB |
The crypto library (@noble/ciphers) represents 90% of bundle size.
Examples
Check the examples/ directory for a complete implementation:
- basic-usage.ts - Complete example showing crypto functions and KeyserverClient usage with error handling patterns
See examples/README.md for setup instructions.
Security
Best Practices
- Never log keys or tokens to console/files
- Never persist keys to localStorage without encryption
- Always use HTTPS for keyserver communication
- Clear cache on logout with
clearCache() - Use AT-URI as message ID for AAD binding
See Security Best Practices for comprehensive guidelines.
Threat Model
- Client-side encryption: Server never sees plaintext
- Short-lived tokens: 60 second expiry limits attack window
- Audience binding: Tokens valid only for specific keyserver
- No forward secrecy: Old keys retained for compatibility (by design)
See Encryption Protocol for details.
Contributing
Contributions welcome! Please:
- Read Client Implementation Strategy
- Follow TypeScript and Prettier conventions
- Add tests for new features
- Update documentation as needed
License
This client library is licensed under the MIT License - see LICENSE.
You are free to use this library in any project, including commercial applications. The ATP Keyserver uses a dual-licensing model where the client library (MIT) and server implementation (PolyForm Noncommercial) have different licenses. See the project LICENSE for details.
TL;DR: Use this client library however you want. If you need to run the keyserver commercially, separate licensing applies to the server component only.
Resources
- Encryption Protocol - Protocol specification
- Security Best Practices - Security guidelines
- API Reference - Complete endpoint reference
- Client Implementation - Design decisions
- Server Architecture - Server architecture
