Secure storage and distribution of cryptographic keys in ATProto applications
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}