Secure storage and distribution of cryptographic keys in ATProto applications
README.md

@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/client

Quick 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 AAD
  • key (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 from encryptMessage

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 token
  • ForbiddenError - User not a member of group
  • NotFoundError - Group doesn't exist
  • NetworkError - Network or server error

getGroupKeyVersion(groupId: string, version: number)#

Fetch a specific historical key version with automatic caching.

Parameters:

  • groupId (string): Group ID
  • version (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 ID
  • plaintext (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 ID
  • ciphertext (string): Hex-encoded nonce + ciphertext
  • version (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:

  1. Read Client Implementation Strategy
  2. Follow TypeScript and Prettier conventions
  3. Add tests for new features
  4. 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#