A community based topic aggregation platform built on atproto
at main 185 lines 7.0 kB view raw
1package communities 2 3import ( 4 "context" 5 "crypto/rand" 6 "encoding/base64" 7 "fmt" 8 "strings" 9 10 "github.com/bluesky-social/indigo/api/atproto" 11 comatproto "github.com/bluesky-social/indigo/api/atproto" 12 "github.com/bluesky-social/indigo/xrpc" 13) 14 15// CommunityPDSAccount represents PDS account credentials for a community 16type CommunityPDSAccount struct { 17 DID string // Community's DID (owns the repository) 18 Handle string // Community's handle (e.g., gaming.community.coves.social) 19 Email string // System email for PDS account 20 Password string // Cleartext password (MUST be encrypted before database storage) 21 AccessToken string // JWT for making API calls as the community 22 RefreshToken string // For refreshing sessions 23 PDSURL string // PDS hosting this community 24 RotationKeyPEM string // PEM-encoded rotation key (for portability) 25 SigningKeyPEM string // PEM-encoded signing key (for atproto operations) 26} 27 28// GetPDSURL implements blobs.BlobOwner interface. 29// Returns the community's PDS URL for blob uploads. 30func (c *CommunityPDSAccount) GetPDSURL() string { 31 return c.PDSURL 32} 33 34// GetPDSAccessToken implements blobs.BlobOwner interface. 35// Returns the community's PDS access token for blob upload authentication. 36func (c *CommunityPDSAccount) GetPDSAccessToken() string { 37 return c.AccessToken 38} 39 40// PDSAccountProvisioner creates PDS accounts for communities with PDS-managed DIDs 41type PDSAccountProvisioner struct { 42 instanceDomain string 43 pdsURL string // URL to call PDS (e.g., http://localhost:3001) 44} 45 46// NewPDSAccountProvisioner creates a new provisioner for V2.0 (PDS-managed keys) 47func NewPDSAccountProvisioner(instanceDomain, pdsURL string) *PDSAccountProvisioner { 48 return &PDSAccountProvisioner{ 49 instanceDomain: instanceDomain, 50 pdsURL: pdsURL, 51 } 52} 53 54// ProvisionCommunityAccount creates a real PDS account for a community with PDS-managed keys 55// 56// V2.0 Architecture (PDS-Managed Keys): 57// 1. Generates community handle and credentials 58// 2. Calls com.atproto.server.createAccount (PDS generates DID and keys) 59// 3. Returns credentials for storage 60// 61// V2.0 Design Philosophy: 62// - PDS manages ALL cryptographic keys (signing + rotation) 63// - Communities can migrate between Coves-controlled PDSs using standard atProto migration 64// - Simpler, faster, ships immediately 65// - Migration uses com.atproto.server.getServiceAuth + standard migration endpoints 66// 67// Future V2.1 (Optional Portability Enhancement): 68// - Add Coves-controlled rotation key alongside PDS rotation key 69// - Enables migration to non-Coves PDSs 70// - Implement when actual external migration is needed 71// 72// SECURITY: The returned credentials MUST be encrypted before database storage 73func (p *PDSAccountProvisioner) ProvisionCommunityAccount( 74 ctx context.Context, 75 communityName string, 76) (*CommunityPDSAccount, error) { 77 if communityName == "" { 78 return nil, fmt.Errorf("community name is required") 79 } 80 81 // 1. Generate unique handle for the community 82 // Format: c-{name}.{instance-domain} 83 // Example: "c-gaming.coves.social" 84 // Uses c- prefix to distinguish from user handles while keeping single-level subdomain 85 handle := fmt.Sprintf("c-%s.%s", strings.ToLower(communityName), p.instanceDomain) 86 87 // 2. Generate system email for PDS account management 88 // This email is used for account operations, not for user communication 89 email := fmt.Sprintf("c-%s@%s", strings.ToLower(communityName), p.instanceDomain) 90 91 // 3. Generate secure random password (32 characters) 92 // This password is never shown to users - it's for Coves to authenticate as the community 93 password, err := generateSecurePassword(32) 94 if err != nil { 95 return nil, fmt.Errorf("failed to generate password: %w", err) 96 } 97 98 // 4. Create PDS account - let PDS generate DID and all keys 99 // The PDS will: 100 // 1. Generate a signing keypair (stored in PDS, never exported) 101 // 2. Generate rotation keys (stored in PDS) 102 // 3. Create a DID (did:plc:xxx) 103 // 4. Register DID with PLC directory 104 // 5. Return credentials (DID, handle, tokens) 105 client := &xrpc.Client{ 106 Host: p.pdsURL, 107 } 108 109 emailStr := email 110 passwordStr := password 111 112 input := &atproto.ServerCreateAccount_Input{ 113 Handle: handle, 114 Email: &emailStr, 115 Password: &passwordStr, 116 // No Did parameter - let PDS generate it 117 // No RecoveryKey - PDS manages rotation keys 118 } 119 120 output, err := atproto.ServerCreateAccount(ctx, client, input) 121 if err != nil { 122 return nil, fmt.Errorf("PDS account creation failed for community %s: %w", communityName, err) 123 } 124 125 // 5. Return account credentials with cleartext password 126 // CRITICAL: The password MUST be encrypted (not hashed) before database storage 127 // We need to recover the plaintext password to call com.atproto.server.createSession 128 // when access/refresh tokens expire (90-day window on refresh tokens) 129 // The repository layer handles encryption using pgp_sym_encrypt() 130 return &CommunityPDSAccount{ 131 DID: output.Did, // The community's DID (PDS-generated) 132 Handle: output.Handle, // e.g., gaming.community.coves.social 133 Email: email, // community-gaming@community.coves.social 134 Password: password, // Cleartext - will be encrypted by repository 135 AccessToken: output.AccessJwt, // JWT for making API calls 136 RefreshToken: output.RefreshJwt, // For refreshing sessions 137 PDSURL: p.pdsURL, // PDS hosting this community 138 RotationKeyPEM: "", // Empty - PDS manages keys (V2.1: add Coves rotation key) 139 SigningKeyPEM: "", // Empty - PDS manages keys 140 }, nil 141} 142 143// generateSecurePassword creates a cryptographically secure random password 144// Uses crypto/rand for security-critical randomness 145func generateSecurePassword(length int) (string, error) { 146 if length < 8 { 147 return "", fmt.Errorf("password length must be at least 8 characters") 148 } 149 150 // Generate random bytes 151 bytes := make([]byte, length) 152 if _, err := rand.Read(bytes); err != nil { 153 return "", fmt.Errorf("failed to generate random bytes: %w", err) 154 } 155 156 // Encode as base64 URL-safe (no special chars that need escaping) 157 password := base64.URLEncoding.EncodeToString(bytes) 158 159 // Trim to exact length 160 if len(password) > length { 161 password = password[:length] 162 } 163 164 return password, nil 165} 166 167// FetchPDSDID queries the PDS to get its DID via com.atproto.server.describeServer 168// This is the proper way to get the PDS DID rather than hardcoding it 169// Works in both development (did:web:localhost) and production (did:web:pds.example.com) 170func FetchPDSDID(ctx context.Context, pdsURL string) (string, error) { 171 client := &xrpc.Client{ 172 Host: pdsURL, 173 } 174 175 resp, err := comatproto.ServerDescribeServer(ctx, client) 176 if err != nil { 177 return "", fmt.Errorf("failed to describe server at %s: %w", pdsURL, err) 178 } 179 180 if resp.Did == "" { 181 return "", fmt.Errorf("PDS at %s did not return a DID", pdsURL) 182 } 183 184 return resp.Did, nil 185}