npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@atpkeyserver/client

v0.2.0

Published

Client library for ATP keyserver with end-to-end encryption

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