Secure storage and distribution of cryptographic keys in ATProto applications
at main 467 lines 13 kB view raw
1import type { LRUCache } from 'lru-cache' 2import { 3 createKeyCache, 4 createTokenCache, 5 DEFAULT_ACTIVE_TTL, 6 DEFAULT_HISTORICAL_TTL, 7 DEFAULT_KEY_CACHE_MAX_SIZE, 8 getGroupKeyCacheKey, 9 type GroupKeyCacheEntry, 10} from './cache' 11import { ServiceAuthManager } from './service-auth' 12import { encryptMessage, decryptMessage } from './crypto' 13import { 14 KeyserverError, 15 UnauthorizedError, 16 ForbiddenError, 17 NotFoundError, 18 NetworkError, 19 DecryptionError, 20} from './errors' 21import type { KeyserverClientConfig, GroupKey, RetryOptions } from './types' 22 23/** 24 * Client for interacting with ATP Keyserver 25 */ 26export class KeyserverClient { 27 private keyserverDid: string 28 private keyserverUrl: string 29 private keyCache: LRUCache<string, GroupKeyCacheEntry> 30 private authManager: ServiceAuthManager 31 private retryConfig: Required<RetryOptions> 32 private pendingRequests: Map<string, Promise<GroupKeyCacheEntry>> 33 private activeKeyTTL: number 34 private historicalKeyTTL: number 35 36 constructor(config: KeyserverClientConfig) { 37 this.keyserverDid = config.keyserverDid 38 this.keyserverUrl = 39 config.keyserverUrl || this.deriveKeyserverUrl(config.keyserverDid) 40 41 // Initialize caches 42 this.activeKeyTTL = config.cache?.historicalKeyTtl || DEFAULT_ACTIVE_TTL 43 this.historicalKeyTTL = 44 config.cache?.historicalKeyTtl || DEFAULT_HISTORICAL_TTL 45 this.keyCache = createKeyCache( 46 config.cache?.maxSize || DEFAULT_KEY_CACHE_MAX_SIZE, 47 ) 48 const tokenCache = createTokenCache() 49 50 // Initialize auth manager 51 this.authManager = new ServiceAuthManager( 52 tokenCache, 53 config.getServiceAuthToken, 54 ) 55 56 // Initialize retry config with defaults 57 this.retryConfig = { 58 enabled: config.retry?.enabled ?? true, 59 maxRetries: config.retry?.maxRetries ?? 3, 60 baseDelay: config.retry?.baseDelay ?? 100, 61 } 62 63 // Initialize pending requests map for deduplication 64 this.pendingRequests = new Map() 65 } 66 67 /** 68 * Derive keyserver URL from DID 69 */ 70 private deriveKeyserverUrl(did: string): string { 71 if (did.startsWith('did:web:')) { 72 const domain = did.replace('did:web:', '') 73 return `https://${domain}` 74 } 75 if (did.startsWith('did:plc:')) { 76 // For did:plc, we need to resolve the DID document 77 // For now, throw an error as this requires did:plc resolution 78 throw new KeyserverError( 79 'did:plc resolution not yet implemented. Please provide keyserverUrl explicitly.', 80 ) 81 } 82 throw new KeyserverError(`Unsupported DID method: ${did}`) 83 } 84 85 /** 86 * Get a group key, with caching and request deduplication 87 */ 88 async getGroupKey( 89 groupId: string, 90 version?: number, 91 ): Promise<GroupKeyCacheEntry> { 92 const cacheKey = getGroupKeyCacheKey(groupId, version) 93 94 // Check cache first 95 const cached = this.keyCache.get(cacheKey) 96 if (cached) { 97 return cached 98 } 99 100 // Check if request is already in-flight (deduplication) 101 const pending = this.pendingRequests.get(cacheKey) 102 if (pending) { 103 return pending 104 } 105 106 // Start new request 107 const promise = this.fetchGroupKey(groupId, version) 108 this.pendingRequests.set(cacheKey, promise) 109 110 try { 111 const result = await promise 112 113 // Cache the result with appropriate TTL 114 const ttl = result.isActive 115 ? this.keyCache.getRemainingTTL(cacheKey) || this.activeKeyTTL 116 : this.historicalKeyTTL 117 118 this.keyCache.set(cacheKey, result, { ttl }) 119 120 return result 121 } finally { 122 // Clean up pending request 123 this.pendingRequests.delete(cacheKey) 124 } 125 } 126 127 /** 128 * Fetch group key from keyserver with retry logic 129 */ 130 private async fetchGroupKey( 131 groupId: string, 132 version?: number, 133 ): Promise<GroupKeyCacheEntry> { 134 const url = new URL( 135 '/xrpc/dev.atpkeyserver.alpha.group.getKey', 136 this.keyserverUrl, 137 ) 138 url.searchParams.set('group_id', groupId) 139 if (version) { 140 url.searchParams.set('version', version.toString()) 141 } 142 143 const lxm = 'dev.atpkeyserver.alpha.group.getKey' 144 const token = await this.authManager.getToken(this.keyserverDid, lxm) 145 146 const response = await this.fetchWithRetry(url.toString(), { 147 method: 'GET', 148 headers: { 149 Authorization: `Bearer ${token}`, 150 }, 151 }) 152 153 if (!response.ok) { 154 await this.handleErrorResponse(response) 155 } 156 157 const data = (await response.json()) as GroupKey 158 159 // Determine if this is the active version (no version specified = latest = active) 160 const isActive = !version 161 162 return { 163 secretKey: data.secretKey, 164 version: data.version, 165 isActive, 166 } 167 } 168 169 /** 170 * Fetch with retry logic for network errors 171 */ 172 private async fetchWithRetry( 173 url: string, 174 options: RequestInit, 175 ): Promise<Response> { 176 if (!this.retryConfig.enabled) { 177 return fetch(url, options) 178 } 179 180 let lastError: Error | null = null 181 const maxAttempts = this.retryConfig.maxRetries + 1 182 183 for (let attempt = 0; attempt < maxAttempts; attempt++) { 184 try { 185 const response = await fetch(url, options) 186 187 // Only retry 5xx errors 188 if (response.status >= 500 && response.status < 600) { 189 if (attempt < this.retryConfig.maxRetries) { 190 const delay = this.retryConfig.baseDelay * Math.pow(2, attempt) 191 await this.sleep(delay) 192 continue 193 } 194 } 195 196 // Return response for all other status codes (including 4xx) 197 return response 198 } catch (error) { 199 // Network error (timeout, connection refused, etc.) 200 lastError = error as Error 201 202 if (attempt < this.retryConfig.maxRetries) { 203 const delay = this.retryConfig.baseDelay * Math.pow(2, attempt) 204 await this.sleep(delay) 205 continue 206 } 207 } 208 } 209 210 throw new NetworkError( 211 `Network request failed after ${maxAttempts} attempts: ${lastError?.message}`, 212 ) 213 } 214 215 /** 216 * Handle error responses from keyserver 217 */ 218 private async handleErrorResponse(response: Response): Promise<never> { 219 let errorMessage = response.statusText 220 221 try { 222 const text = await response.text() 223 if (text.startsWith('{')) { 224 const data = JSON.parse(text) 225 if (data.error || data.message) { 226 errorMessage = data.error || data.message 227 } 228 } else { 229 errorMessage = text 230 } 231 } catch { 232 // Failed to parse error response, use statusText 233 } 234 235 switch (response.status) { 236 case 401: 237 throw new UnauthorizedError(errorMessage) 238 case 403: 239 throw new ForbiddenError(errorMessage) 240 case 404: 241 throw new NotFoundError(errorMessage) 242 case 500: 243 case 502: 244 case 503: 245 case 504: 246 throw new NetworkError(errorMessage, response.status) 247 default: 248 throw new KeyserverError(errorMessage, response.status) 249 } 250 } 251 252 /** 253 * Sleep for specified milliseconds 254 */ 255 private sleep(ms: number): Promise<void> { 256 return new Promise((resolve) => setTimeout(resolve, ms)) 257 } 258 259 /** 260 * Encrypt a message for a group using the latest key 261 */ 262 async encrypt( 263 messageId: string, 264 groupId: string, 265 plaintext: string, 266 ): Promise<{ ciphertext: string; version: number }> { 267 const keyEntry = await this.getGroupKey(groupId) 268 const ciphertext = encryptMessage(messageId, keyEntry.secretKey, plaintext) 269 return { ciphertext, version: keyEntry.version } 270 } 271 272 /** 273 * Decrypt a message from a group using a specific key version 274 */ 275 async decrypt( 276 messageId: string, 277 groupId: string, 278 ciphertext: string, 279 version: number, 280 ): Promise<string> { 281 try { 282 const keyEntry = await this.getGroupKey(groupId, version) 283 return decryptMessage(messageId, keyEntry.secretKey, ciphertext) 284 } catch (error) { 285 if (error instanceof KeyserverError) { 286 throw error 287 } 288 throw new DecryptionError( 289 `Failed to decrypt message: ${(error as Error).message}`, 290 ) 291 } 292 } 293 294 /** 295 * Clear all cached keys and tokens 296 */ 297 clearCache(): void { 298 this.keyCache.clear() 299 this.authManager.clearCache() 300 } 301 302 /** 303 * Add a member to a group 304 * Requires authorization: Must be group owner OR have delegation with 'add_member' permission 305 */ 306 async addMember(params: { 307 group_id: string 308 member_did: string 309 }): Promise<{ groupId: string; memberDid: string; status: string }> { 310 const url = new URL( 311 '/xrpc/dev.atpkeyserver.alpha.group.addMember', 312 this.keyserverUrl, 313 ) 314 315 const lxm = 'dev.atpkeyserver.alpha.group.addMember' 316 const token = await this.authManager.getToken(this.keyserverDid, lxm) 317 318 const response = await this.fetchWithRetry(url.toString(), { 319 method: 'POST', 320 headers: { 321 'Authorization': `Bearer ${token}`, 322 'Content-Type': 'application/json', 323 }, 324 body: JSON.stringify(params), 325 }) 326 327 if (!response.ok) { 328 await this.handleErrorResponse(response) 329 } 330 331 return await response.json() 332 } 333 334 /** 335 * Remove a member from a group 336 * Requires authorization: Must be group owner OR have delegation with 'remove_member' permission 337 */ 338 async removeMember(params: { 339 group_id: string 340 member_did: string 341 }): Promise<{ groupId: string; memberDid: string; status: string }> { 342 const url = new URL( 343 '/xrpc/dev.atpkeyserver.alpha.group.removeMember', 344 this.keyserverUrl, 345 ) 346 347 const lxm = 'dev.atpkeyserver.alpha.group.removeMember' 348 const token = await this.authManager.getToken(this.keyserverDid, lxm) 349 350 const response = await this.fetchWithRetry(url.toString(), { 351 method: 'POST', 352 headers: { 353 'Authorization': `Bearer ${token}`, 354 'Content-Type': 'application/json', 355 }, 356 body: JSON.stringify(params), 357 }) 358 359 if (!response.ok) { 360 await this.handleErrorResponse(response) 361 } 362 363 return await response.json() 364 } 365 366 /** 367 * Authorize a delegate to manage group membership 368 * Requires authorization: Must be group owner 369 */ 370 async authorizeDelegate(params: { 371 group_id: string 372 delegate_did: string 373 permissions: string[] 374 expires_at?: string 375 }): Promise<{ groupId: string; delegateDid: string; status: string }> { 376 const url = new URL( 377 '/xrpc/dev.atpkeyserver.alpha.group.authorizeDelegate', 378 this.keyserverUrl, 379 ) 380 381 const lxm = 'dev.atpkeyserver.alpha.group.authorizeDelegate' 382 const token = await this.authManager.getToken(this.keyserverDid, lxm) 383 384 const response = await this.fetchWithRetry(url.toString(), { 385 method: 'POST', 386 headers: { 387 'Authorization': `Bearer ${token}`, 388 'Content-Type': 'application/json', 389 }, 390 body: JSON.stringify(params), 391 }) 392 393 if (!response.ok) { 394 await this.handleErrorResponse(response) 395 } 396 397 return await response.json() 398 } 399 400 /** 401 * Revoke a delegate's authorization 402 * Requires authorization: Must be group owner 403 */ 404 async revokeDelegate(params: { 405 group_id: string 406 delegate_did: string 407 }): Promise<{ groupId: string; delegateDid: string; status: string }> { 408 const url = new URL( 409 '/xrpc/dev.atpkeyserver.alpha.group.revokeDelegate', 410 this.keyserverUrl, 411 ) 412 413 const lxm = 'dev.atpkeyserver.alpha.group.revokeDelegate' 414 const token = await this.authManager.getToken(this.keyserverDid, lxm) 415 416 const response = await this.fetchWithRetry(url.toString(), { 417 method: 'POST', 418 headers: { 419 'Authorization': `Bearer ${token}`, 420 'Content-Type': 'application/json', 421 }, 422 body: JSON.stringify(params), 423 }) 424 425 if (!response.ok) { 426 await this.handleErrorResponse(response) 427 } 428 429 return await response.json() 430 } 431 432 /** 433 * List all delegates for a group 434 * Requires authorization: Must be group owner 435 */ 436 async listDelegates(params: { group_id: string }): Promise<{ 437 groupId: string 438 delegates: Array<{ 439 delegateDid: string 440 permissions: string[] 441 grantedAt: string 442 expiresAt?: string 443 }> 444 }> { 445 const url = new URL( 446 '/xrpc/dev.atpkeyserver.alpha.group.listDelegates', 447 this.keyserverUrl, 448 ) 449 url.searchParams.set('group_id', params.group_id) 450 451 const lxm = 'dev.atpkeyserver.alpha.group.listDelegates' 452 const token = await this.authManager.getToken(this.keyserverDid, lxm) 453 454 const response = await this.fetchWithRetry(url.toString(), { 455 method: 'GET', 456 headers: { 457 Authorization: `Bearer ${token}`, 458 }, 459 }) 460 461 if (!response.ok) { 462 await this.handleErrorResponse(response) 463 } 464 465 return await response.json() 466 } 467}