Testing implementation for private data in ATProto with ATPKeyserver and ATCute tools

allow read keyserver from did doc

+315 -39
+94
packages/client/app/lib/idResolver.server.ts
··· 13 13 type ResourceUri 14 14 } from '@atcute/lexicons/syntax' 15 15 import { getAtprotoHandle } from '@atcute/identity' 16 + import type { DidDocument } from '@atcute/identity' 17 + import { env } from './env.server' 16 18 17 19 export const handleResolver = new CompositeHandleResolver({ 18 20 strategy: 'race', ··· 53 55 const did = await handleResolver.resolve(handle) 54 56 return did 55 57 } 58 + 59 + // Types for keyserver resolution 60 + export interface KeyserverInfo { 61 + keyserverDid: Did 62 + keyserverUrl: string 63 + } 64 + 65 + /** 66 + * Extract keyserver service from DID document 67 + * Looks for service with type "AtprotoKeyserver" 68 + */ 69 + function extractKeyserverFromDidDoc(didDoc: DidDocument, userDid: Did) { 70 + if (!didDoc.service) { 71 + return null 72 + } 73 + 74 + const keyserverService = didDoc.service.find( 75 + (s) => s.type === 'AtprotoKeyserver' 76 + ) 77 + 78 + if (!keyserverService) { 79 + return null 80 + } 81 + 82 + const keyserverUrl = keyserverService.serviceEndpoint 83 + 84 + if (typeof keyserverUrl !== 'string') { 85 + console.warn(`Invalid keyserver URL in DID document for ${userDid}`) 86 + return null 87 + } 88 + 89 + // Extract keyserver DID from service ID 90 + // Service ID format: "did:web:keyserver.example.com#atproto_keyserver" 91 + const keyserverDid = keyserverService.id.split('#')[0] as Did 92 + 93 + return { 94 + keyserverDid, 95 + keyserverUrl 96 + } 97 + } 98 + 99 + /** 100 + * Get keyserver info for a user with fallback 101 + * Reuses DID document resolution that's already cached by didDocResolver 102 + */ 103 + export async function getKeyserverForUser(userDid: Did) { 104 + // Resolve DID document (already cached by didDocResolver) 105 + try { 106 + const didDoc = await didDocResolver.resolve(userDid) 107 + const info = extractKeyserverFromDidDoc(didDoc, userDid) 108 + 109 + if (info) { 110 + return info 111 + } 112 + } catch (error) { 113 + console.warn( 114 + `Failed to resolve DID for keyserver lookup ${userDid}: ${error instanceof Error ? error.message : 'Unknown error'}` 115 + ) 116 + } 117 + 118 + // Fallback to default keyserver 119 + if (env.KEYSERVER_DID) { 120 + const keyserverHost = decodeURIComponent( 121 + env.KEYSERVER_DID.replace('did:web:', '') 122 + ) 123 + const prefix = keyserverHost.startsWith('localhost') 124 + ? 'http://' 125 + : 'https://' 126 + const keyserverUrl = `${prefix}${keyserverHost}` 127 + 128 + return { 129 + keyserverDid: env.KEYSERVER_DID, 130 + keyserverUrl 131 + } 132 + } 133 + 134 + throw new Error( 135 + `No keyserver found in DID document for ${userDid} and no fallback configured` 136 + ) 137 + } 138 + 139 + /** 140 + * Get keyserver for a group (group owner's keyserver) 141 + * Group ID format: "did:plc:abc123#followers" 142 + */ 143 + export async function getKeyserverForGroup(groupId: string) { 144 + const [ownerDid] = groupId.split('#') 145 + if (!ownerDid) { 146 + throw new Error(`Invalid group ID format: ${groupId}`) 147 + } 148 + return getKeyserverForUser(ownerDid as Did) 149 + }
+35 -14
packages/client/app/lib/post.server.ts
··· 1 - import { 2 - is, 3 - parse, 4 - parseCanonicalResourceUri, 5 - type Handle, 6 - type InferXRPCBodyOutput 7 - } from '@atcute/lexicons' 8 1 import { 9 2 createKeyClient, 10 3 getPublicServiceAgent, ··· 12 5 getSessionAgent, 13 6 type HTTPURL 14 7 } from './xrpcClient' 8 + import { ok } from '@atcute/client' 15 9 import { 16 10 AppWafrnContentDefs, 17 11 AppWafrnContentGetFeed, 18 12 AppWafrnContentPrivatePost, 19 13 AppWafrnContentPublicPost 20 14 } from '@watproto/lexicon' 21 - import { ok } from '@atcute/client' 15 + import { 16 + is, 17 + parse, 18 + parseCanonicalResourceUri, 19 + type Handle, 20 + type InferXRPCBodyOutput 21 + } from '@atcute/lexicons' 22 + 22 23 import { env } from './env.server' 23 24 import { getOAuthSession } from './oauth.server' 24 25 import * as TID from '@atcute/tid' 25 26 import type { OAuthSession } from '@atproto/oauth-client-node' 26 27 import type { AtprotoDid as Did, ResourceUri } from '@atcute/lexicons/syntax' 27 - import { atUriToDid, didToHandle, handleToDid } from './idResolver.server' 28 + import { 29 + atUriToDid, 30 + didToHandle, 31 + handleToDid, 32 + getKeyserverForGroup 33 + } from './idResolver.server' 28 34 import asyncWrap from './asyncWrap' 29 35 30 36 type PostPayload = { ··· 81 87 ) 82 88 return recordMeta 83 89 } else { 84 - const keyClient = createKeyClient(session) 90 + const groupId = `${session.did}#${postBody.visibility}` 91 + 92 + // Resolve keyserver for the group (user's own keyserver) 93 + // Reuses DID doc if already cached 94 + const { keyserverDid, keyserverUrl } = await getKeyserverForGroup(groupId) 95 + 96 + // Create key client for user's keyserver 97 + const keyClient = createKeyClient(session, keyserverDid, keyserverUrl) 98 + 85 99 const rkey = TID.now() 86 100 const uri = `at://${session.did}/app.wafrn.content.privatePost/${rkey}` 87 - const group = `${session.did}#${postBody.visibility}` 101 + 88 102 const { ciphertext, version } = await keyClient.encrypt( 89 103 uri, 90 - group, 104 + groupId, 91 105 JSON.stringify({ 92 106 tags: postBody.tags, 93 107 contentWarning: postBody.contentWarning, ··· 147 161 session: OAuthSession, 148 162 feed: ServerFeedResponse 149 163 ) { 150 - const keyClient = createKeyClient(session) 151 164 const posts = [] as FeedItem[] 152 165 for (const record of feed ?? []) { 153 166 const did = atUriToDid(record.uri) ··· 159 172 visibility: 'public' 160 173 }) 161 174 } else if (is(AppWafrnContentDefs.privatePostViewSchema, record)) { 175 + // Resolve keyserver for the group (post author's keyserver) 176 + // Reuses DID doc already fetched for handle 177 + const groupId = `${did}#${record.visibility}` 178 + const { keyserverDid, keyserverUrl } = await getKeyserverForGroup(groupId) 179 + 180 + // Create key client for post author's keyserver 181 + const keyClient = createKeyClient(session, keyserverDid, keyserverUrl) 182 + 162 183 const [plainText, error] = await asyncWrap(() => 163 184 keyClient.decrypt( 164 185 record.uri, 165 - `${did}#${record.visibility}`, 186 + groupId, 166 187 record.encryptedContent, 167 188 record.keyVersion 168 189 )
+29 -9
packages/client/app/lib/xrpcClient.ts
··· 104 104 return jwtClient 105 105 } 106 106 107 - export function createKeyClient(session: OAuthSession) { 107 + export function createKeyClient( 108 + session: OAuthSession, 109 + keyserverDid?: Did, 110 + keyserverUrl?: string 111 + ) { 108 112 const repoClient = new Client({ handler: session.fetchHandler.bind(session) }) 109 - const keyserverHost = decodeURIComponent( 110 - env.KEYSERVER_DID.replace('did:web:', '') 111 - ) 112 - const prefix = keyserverHost.startsWith('localhost') ? 'http://' : 'https://' 113 - const keyserverUrl = `${prefix}${keyserverHost}` 113 + 114 + // Use provided keyserver or fall back to env default 115 + const finalKeyserverDid = keyserverDid || env.KEYSERVER_DID 116 + let finalKeyserverUrl = keyserverUrl 117 + 118 + if (!finalKeyserverUrl && finalKeyserverDid) { 119 + // Derive URL from DID if not provided 120 + const keyserverHost = decodeURIComponent( 121 + finalKeyserverDid.replace('did:web:', '') 122 + ) 123 + const prefix = keyserverHost.startsWith('localhost') 124 + ? 'http://' 125 + : 'https://' 126 + finalKeyserverUrl = `${prefix}${keyserverHost}` 127 + } 128 + 129 + if (!finalKeyserverDid || !finalKeyserverUrl) { 130 + throw new Error( 131 + 'Keyserver DID and URL must be provided or configured in environment' 132 + ) 133 + } 114 134 115 135 return new KeyserverClient({ 116 - keyserverDid: env.KEYSERVER_DID, 117 - keyserverUrl, 136 + keyserverDid: finalKeyserverDid, 137 + keyserverUrl: finalKeyserverUrl, 118 138 getServiceAuthToken: async (aud, lxm) => { 119 139 const data = await ok( 120 140 repoClient.get('com.atproto.server.getServiceAuth', { 121 141 params: { 122 - aud: aud as Did<'web'>, 142 + aud: aud as Did, 123 143 lxm: lxm as Nsid 124 144 } 125 145 })
+11 -2
packages/client/app/routes/settings.delegation.tsx
··· 3 3 import { getOAuthSession } from '@www/lib/oauth.server' 4 4 import { env } from '@www/lib/env.server' 5 5 import { createKeyClient } from '@www/lib/xrpcClient' 6 + import { getKeyserverForGroup } from '@www/lib/idResolver.server' 6 7 import asyncWrap from '@www/lib/asyncWrap' 7 8 8 9 const defaultData = { ··· 15 16 export async function loader({ request }: Route.LoaderArgs) { 16 17 const session = await getOAuthSession(request) 17 18 const groupId = `${session.did}#followers` 18 - const keyClient = createKeyClient(session) 19 + 20 + // Resolve user's own keyserver (reuses DID doc if already cached) 21 + const { keyserverDid, keyserverUrl } = await getKeyserverForGroup(groupId) 22 + 23 + // Create key client for user's keyserver 24 + const keyClient = createKeyClient(session, keyserverDid, keyserverUrl) 19 25 20 26 const [data, error] = await asyncWrap(async () => { 21 27 const result = await keyClient.listDelegates({ group_id: groupId }) ··· 34 40 const formData = await request.formData() 35 41 const action = formData.get('action') 36 42 const groupId = `${session.did}#followers` 37 - const keyClient = createKeyClient(session) 43 + 44 + // Resolve user's own keyserver 45 + const { keyserverDid, keyserverUrl } = await getKeyserverForGroup(groupId) 46 + const keyClient = createKeyClient(session, keyserverDid, keyserverUrl) 38 47 39 48 try { 40 49 if (action === 'authorize') {
+21 -5
packages/server/src/lib/follow.ts
··· 1 1 import { sql } from 'kysely' 2 2 import { db } from '@api/db/db' 3 - import { keyserverClient } from './keyserverClient' 3 + import { createKeyserverClient } from './keyserverClient' 4 + import { getKeyserverForUser } from './idResolver' 5 + import type { Did } from '@atcute/lexicons' 4 6 5 7 /** 6 8 * Create a follow relationship between two accounts. 7 9 * Updates follow_counts for both accounts in a transaction. 8 10 */ 9 11 export async function createFollow( 10 - followerDid: string, 11 - followeeDid: string, 12 + followerDid: Did, 13 + followeeDid: Did, 12 14 uri: string, 13 15 createdAt: number 14 16 ): Promise<void> { ··· 55 57 // After successful database transaction, add follower to keyserver group 56 58 // This allows the follower to decrypt the followee's private posts 57 59 try { 60 + // Resolve the followee's keyserver (reuses DID doc already cached for handle) 61 + const { keyserverDid, keyserverUrl } = 62 + await getKeyserverForUser(followeeDid) 63 + 64 + // Create client for the followee's keyserver 65 + const keyserverClient = createKeyserverClient(keyserverDid, keyserverUrl) 66 + 58 67 await keyserverClient.addMember({ 59 68 group_id: `${followeeDid}#followers`, 60 69 member_did: followerDid ··· 72 81 * Returns the URI of the deleted follow record (for PDS deletion), or null if not found. 73 82 */ 74 83 export async function deleteFollow( 75 - followerDid: string, 76 - followeeDid: string 84 + followerDid: Did, 85 + followeeDid: Did 77 86 ): Promise<string | null> { 78 87 const uri = await db.transaction().execute(async (trx) => { 79 88 // Step 1: Query the follow record to get the URI before deleting ··· 122 131 // This revokes the follower's ability to decrypt the followee's new private posts 123 132 if (uri) { 124 133 try { 134 + // Resolve the followee's keyserver (reuses DID doc already cached for handle) 135 + const { keyserverDid, keyserverUrl } = 136 + await getKeyserverForUser(followeeDid) 137 + 138 + // Create client for the followee's keyserver 139 + const keyserverClient = createKeyserverClient(keyserverDid, keyserverUrl) 140 + 125 141 await keyserverClient.removeMember({ 126 142 group_id: `${followeeDid}#followers`, 127 143 member_did: followerDid
+89
packages/server/src/lib/idResolver.ts
··· 6 6 WebDidDocumentResolver, 7 7 WellKnownHandleResolver 8 8 } from '@atcute/identity-resolver' 9 + import type { DidDocument } from '@atcute/identity' 10 + import type { Did } from '@atcute/lexicons' 11 + import env from './env' 9 12 10 13 export const handleResolver = new CompositeHandleResolver({ 11 14 strategy: 'race', ··· 23 26 web: new WebDidDocumentResolver() 24 27 } 25 28 }) 29 + 30 + // Types for keyserver resolution 31 + export interface KeyserverInfo { 32 + keyserverDid: Did 33 + keyserverUrl: string 34 + } 35 + 36 + /** 37 + * Extract keyserver service from DID document 38 + * Looks for service with type "AtprotoKeyserver" 39 + */ 40 + function extractKeyserverFromDidDoc(didDoc: DidDocument, userDid: Did) { 41 + if (!didDoc.service) { 42 + return null 43 + } 44 + 45 + const keyserverService = didDoc.service.find( 46 + (s) => s.type === 'AtprotoKeyserver' 47 + ) 48 + 49 + if (!keyserverService) { 50 + return null 51 + } 52 + 53 + const keyserverUrl = keyserverService.serviceEndpoint 54 + 55 + if (typeof keyserverUrl !== 'string') { 56 + console.warn(`Invalid keyserver URL in DID document for ${userDid}`) 57 + return null 58 + } 59 + 60 + // Extract keyserver DID from service ID 61 + // Service ID format: "did:web:keyserver.example.com#atproto_keyserver" 62 + const keyserverDid = keyserverService.id.split('#')[0] as Did 63 + 64 + return { 65 + keyserverDid, 66 + keyserverUrl 67 + } 68 + } 69 + 70 + /** 71 + * Get keyserver info for a user with fallback 72 + * Reuses DID document resolution that's already cached by didDocResolver 73 + */ 74 + export async function getKeyserverForUser(userDid: Did) { 75 + // Resolve DID document (this is already cached by didDocResolver) 76 + try { 77 + const didDoc = await didDocResolver.resolve( 78 + userDid as Did<'web'> | Did<'plc'> 79 + ) 80 + const info = extractKeyserverFromDidDoc(didDoc, userDid) 81 + 82 + if (info) { 83 + return info 84 + } 85 + } catch (error) { 86 + console.warn( 87 + `Failed to resolve DID for keyserver lookup ${userDid}: ${error instanceof Error ? error.message : 'Unknown error'}` 88 + ) 89 + } 90 + 91 + // Fallback to default keyserver from environment 92 + if (env.KEYSERVER_DID && env.KEYSERVER_URL) { 93 + return { 94 + keyserverDid: env.KEYSERVER_DID, 95 + keyserverUrl: env.KEYSERVER_URL 96 + } 97 + } 98 + 99 + throw new Error( 100 + `No keyserver found in DID document for ${userDid} and no fallback configured` 101 + ) 102 + } 103 + 104 + /** 105 + * Get keyserver for a group (group owner's keyserver) 106 + * Group ID format: "did:plc:abc123#followers" 107 + */ 108 + export async function getKeyserverForGroup(groupId: string) { 109 + const [ownerDid] = groupId.split('#') 110 + if (!ownerDid) { 111 + throw new Error(`Invalid group ID format: ${groupId}`) 112 + } 113 + return getKeyserverForUser(ownerDid as Did) 114 + }
+36 -9
packages/server/src/lib/keyserverClient.ts
··· 4 4 import type { Did, Nsid } from '@atcute/lexicons' 5 5 6 6 /** 7 - * Keyserver client configured to authenticate as the XRPC server itself. 8 - * This allows the server to manage group membership on behalf of users who 9 - * have authorized delegation. 7 + * Create a keyserver client for a specific keyserver 8 + * Authenticates as the XRPC server to manage group membership on behalf of users 9 + * who have authorized delegation. 10 + * 11 + * @param keyserverDid - DID of the keyserver (optional, uses env default if not provided) 12 + * @param keyserverUrl - URL of the keyserver (optional, derived from DID if not provided) 10 13 */ 11 - export const keyserverClient = new KeyserverClient({ 12 - keyserverDid: env.KEYSERVER_DID, 13 - keyserverUrl: env.KEYSERVER_URL, 14 - getServiceAuthToken: async (aud, lxm) => { 15 - return await generateServiceAuthToken(aud as Did, lxm as Nsid) 14 + export function createKeyserverClient( 15 + keyserverDid?: Did, 16 + keyserverUrl?: string 17 + ): KeyserverClient { 18 + // Use provided keyserver or fall back to env default 19 + const finalKeyserverDid = keyserverDid || env.KEYSERVER_DID 20 + let finalKeyserverUrl = keyserverUrl || env.KEYSERVER_URL 21 + 22 + // Derive URL from DID if not provided 23 + if (!finalKeyserverUrl && finalKeyserverDid) { 24 + if (finalKeyserverDid.startsWith('did:web:')) { 25 + const keyserverHost = decodeURIComponent( 26 + finalKeyserverDid.replace('did:web:', '') 27 + ) 28 + const prefix = keyserverHost.startsWith('localhost') ? 'http://' : 'https://' 29 + finalKeyserverUrl = `${prefix}${keyserverHost}` 30 + } 16 31 } 17 - }) 32 + 33 + if (!finalKeyserverDid || !finalKeyserverUrl) { 34 + throw new Error('Keyserver DID and URL must be provided or configured in environment') 35 + } 36 + 37 + return new KeyserverClient({ 38 + keyserverDid: finalKeyserverDid, 39 + keyserverUrl: finalKeyserverUrl, 40 + getServiceAuthToken: async (aud, lxm) => { 41 + return await generateServiceAuthToken(aud as Did, lxm as Nsid) 42 + } 43 + }) 44 + }