import type { LRUCache } from 'lru-cache' import { createKeyCache, createTokenCache, DEFAULT_ACTIVE_TTL, DEFAULT_HISTORICAL_TTL, DEFAULT_KEY_CACHE_MAX_SIZE, getGroupKeyCacheKey, type GroupKeyCacheEntry, } from './cache' import { ServiceAuthManager } from './service-auth' import { encryptMessage, decryptMessage } from './crypto' import { KeyserverError, UnauthorizedError, ForbiddenError, NotFoundError, NetworkError, DecryptionError, } from './errors' import type { KeyserverClientConfig, GroupKey, RetryOptions } from './types' /** * Client for interacting with ATP Keyserver */ export class KeyserverClient { private keyserverDid: string private keyserverUrl: string private keyCache: LRUCache private authManager: ServiceAuthManager private retryConfig: Required private pendingRequests: Map> private activeKeyTTL: number private historicalKeyTTL: number constructor(config: KeyserverClientConfig) { this.keyserverDid = config.keyserverDid this.keyserverUrl = config.keyserverUrl || this.deriveKeyserverUrl(config.keyserverDid) // Initialize caches this.activeKeyTTL = config.cache?.historicalKeyTtl || DEFAULT_ACTIVE_TTL this.historicalKeyTTL = config.cache?.historicalKeyTtl || DEFAULT_HISTORICAL_TTL this.keyCache = createKeyCache( config.cache?.maxSize || DEFAULT_KEY_CACHE_MAX_SIZE, ) const tokenCache = createTokenCache() // Initialize auth manager this.authManager = new ServiceAuthManager( tokenCache, config.getServiceAuthToken, ) // Initialize retry config with defaults this.retryConfig = { enabled: config.retry?.enabled ?? true, maxRetries: config.retry?.maxRetries ?? 3, baseDelay: config.retry?.baseDelay ?? 100, } // Initialize pending requests map for deduplication this.pendingRequests = new Map() } /** * Derive keyserver URL from DID */ private deriveKeyserverUrl(did: string): string { if (did.startsWith('did:web:')) { const domain = did.replace('did:web:', '') return `https://${domain}` } if (did.startsWith('did:plc:')) { // For did:plc, we need to resolve the DID document // For now, throw an error as this requires did:plc resolution throw new KeyserverError( 'did:plc resolution not yet implemented. Please provide keyserverUrl explicitly.', ) } throw new KeyserverError(`Unsupported DID method: ${did}`) } /** * Get a group key, with caching and request deduplication */ async getGroupKey( groupId: string, version?: number, ): Promise { const cacheKey = getGroupKeyCacheKey(groupId, version) // Check cache first const cached = this.keyCache.get(cacheKey) if (cached) { return cached } // Check if request is already in-flight (deduplication) const pending = this.pendingRequests.get(cacheKey) if (pending) { return pending } // Start new request const promise = this.fetchGroupKey(groupId, version) this.pendingRequests.set(cacheKey, promise) try { const result = await promise // Cache the result with appropriate TTL const ttl = result.isActive ? this.keyCache.getRemainingTTL(cacheKey) || this.activeKeyTTL : this.historicalKeyTTL this.keyCache.set(cacheKey, result, { ttl }) return result } finally { // Clean up pending request this.pendingRequests.delete(cacheKey) } } /** * Fetch group key from keyserver with retry logic */ private async fetchGroupKey( groupId: string, version?: number, ): Promise { const url = new URL( '/xrpc/dev.atpkeyserver.alpha.group.getKey', this.keyserverUrl, ) url.searchParams.set('group_id', groupId) if (version) { url.searchParams.set('version', version.toString()) } const lxm = 'dev.atpkeyserver.alpha.group.getKey' const token = await this.authManager.getToken(this.keyserverDid, lxm) const response = await this.fetchWithRetry(url.toString(), { method: 'GET', headers: { Authorization: `Bearer ${token}`, }, }) if (!response.ok) { await this.handleErrorResponse(response) } const data = (await response.json()) as GroupKey // Determine if this is the active version (no version specified = latest = active) const isActive = !version return { secretKey: data.secretKey, version: data.version, isActive, } } /** * Fetch with retry logic for network errors */ private async fetchWithRetry( url: string, options: RequestInit, ): Promise { if (!this.retryConfig.enabled) { return fetch(url, options) } let lastError: Error | null = null const maxAttempts = this.retryConfig.maxRetries + 1 for (let attempt = 0; attempt < maxAttempts; attempt++) { try { const response = await fetch(url, options) // Only retry 5xx errors if (response.status >= 500 && response.status < 600) { if (attempt < this.retryConfig.maxRetries) { const delay = this.retryConfig.baseDelay * Math.pow(2, attempt) await this.sleep(delay) continue } } // Return response for all other status codes (including 4xx) return response } catch (error) { // Network error (timeout, connection refused, etc.) lastError = error as Error if (attempt < this.retryConfig.maxRetries) { const delay = this.retryConfig.baseDelay * Math.pow(2, attempt) await this.sleep(delay) continue } } } throw new NetworkError( `Network request failed after ${maxAttempts} attempts: ${lastError?.message}`, ) } /** * Handle error responses from keyserver */ private async handleErrorResponse(response: Response): Promise { let errorMessage = response.statusText try { const text = await response.text() if (text.startsWith('{')) { const data = JSON.parse(text) if (data.error || data.message) { errorMessage = data.error || data.message } } else { errorMessage = text } } catch { // Failed to parse error response, use statusText } switch (response.status) { case 401: throw new UnauthorizedError(errorMessage) case 403: throw new ForbiddenError(errorMessage) case 404: throw new NotFoundError(errorMessage) case 500: case 502: case 503: case 504: throw new NetworkError(errorMessage, response.status) default: throw new KeyserverError(errorMessage, response.status) } } /** * Sleep for specified milliseconds */ private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } /** * Encrypt a message for a group using the latest key */ async encrypt( messageId: string, groupId: string, plaintext: string, ): Promise<{ ciphertext: string; version: number }> { const keyEntry = await this.getGroupKey(groupId) const ciphertext = encryptMessage(messageId, keyEntry.secretKey, plaintext) return { ciphertext, version: keyEntry.version } } /** * Decrypt a message from a group using a specific key version */ async decrypt( messageId: string, groupId: string, ciphertext: string, version: number, ): Promise { try { const keyEntry = await this.getGroupKey(groupId, version) return decryptMessage(messageId, keyEntry.secretKey, ciphertext) } catch (error) { if (error instanceof KeyserverError) { throw error } throw new DecryptionError( `Failed to decrypt message: ${(error as Error).message}`, ) } } /** * Clear all cached keys and tokens */ clearCache(): void { this.keyCache.clear() this.authManager.clearCache() } /** * Add a member to a group * Requires authorization: Must be group owner OR have delegation with 'add_member' permission */ async addMember(params: { group_id: string member_did: string }): Promise<{ groupId: string; memberDid: string; status: string }> { const url = new URL( '/xrpc/dev.atpkeyserver.alpha.group.addMember', this.keyserverUrl, ) const lxm = 'dev.atpkeyserver.alpha.group.addMember' const token = await this.authManager.getToken(this.keyserverDid, lxm) const response = await this.fetchWithRetry(url.toString(), { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(params), }) if (!response.ok) { await this.handleErrorResponse(response) } return await response.json() } /** * Remove a member from a group * Requires authorization: Must be group owner OR have delegation with 'remove_member' permission */ async removeMember(params: { group_id: string member_did: string }): Promise<{ groupId: string; memberDid: string; status: string }> { const url = new URL( '/xrpc/dev.atpkeyserver.alpha.group.removeMember', this.keyserverUrl, ) const lxm = 'dev.atpkeyserver.alpha.group.removeMember' const token = await this.authManager.getToken(this.keyserverDid, lxm) const response = await this.fetchWithRetry(url.toString(), { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(params), }) if (!response.ok) { await this.handleErrorResponse(response) } return await response.json() } /** * Authorize a delegate to manage group membership * Requires authorization: Must be group owner */ async authorizeDelegate(params: { group_id: string delegate_did: string permissions: string[] expires_at?: string }): Promise<{ groupId: string; delegateDid: string; status: string }> { const url = new URL( '/xrpc/dev.atpkeyserver.alpha.group.authorizeDelegate', this.keyserverUrl, ) const lxm = 'dev.atpkeyserver.alpha.group.authorizeDelegate' const token = await this.authManager.getToken(this.keyserverDid, lxm) const response = await this.fetchWithRetry(url.toString(), { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(params), }) if (!response.ok) { await this.handleErrorResponse(response) } return await response.json() } /** * Revoke a delegate's authorization * Requires authorization: Must be group owner */ async revokeDelegate(params: { group_id: string delegate_did: string }): Promise<{ groupId: string; delegateDid: string; status: string }> { const url = new URL( '/xrpc/dev.atpkeyserver.alpha.group.revokeDelegate', this.keyserverUrl, ) const lxm = 'dev.atpkeyserver.alpha.group.revokeDelegate' const token = await this.authManager.getToken(this.keyserverDid, lxm) const response = await this.fetchWithRetry(url.toString(), { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(params), }) if (!response.ok) { await this.handleErrorResponse(response) } return await response.json() } /** * List all delegates for a group * Requires authorization: Must be group owner */ async listDelegates(params: { group_id: string }): Promise<{ groupId: string delegates: Array<{ delegateDid: string permissions: string[] grantedAt: string expiresAt?: string }> }> { const url = new URL( '/xrpc/dev.atpkeyserver.alpha.group.listDelegates', this.keyserverUrl, ) url.searchParams.set('group_id', params.group_id) const lxm = 'dev.atpkeyserver.alpha.group.listDelegates' const token = await this.authManager.getToken(this.keyserverDid, lxm) const response = await this.fetchWithRetry(url.toString(), { method: 'GET', headers: { Authorization: `Bearer ${token}`, }, }) if (!response.ok) { await this.handleErrorResponse(response) } return await response.json() } }