From 5afeace847a3eeb2ef729c2b34f476d3bc6b6309 Mon Sep 17 00:00:00 2001 From: Bretton Date: Thu, 9 Oct 2025 00:32:58 -0700 Subject: [PATCH] feat(atproto): Add DID generator utility for Communities - Implements did:plc DID generation for community entities - Supports dev mode (mock) and production (PLC directory registration) - Follows atProto DID specification - Required for Communities feature implementation --- internal/atproto/did/generator.go | 79 +++++++++++++++ internal/atproto/did/generator_test.go | 128 +++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 internal/atproto/did/generator.go create mode 100644 internal/atproto/did/generator_test.go diff --git a/internal/atproto/did/generator.go b/internal/atproto/did/generator.go new file mode 100644 index 0000000..bf9180e --- /dev/null +++ b/internal/atproto/did/generator.go @@ -0,0 +1,79 @@ +package did + +import ( + "crypto/rand" + "encoding/base32" + "fmt" + "strings" +) + +// Generator creates DIDs for Coves entities +type Generator struct { + isDevEnv bool // true = generate without registering, false = register with PLC + plcDirectoryURL string // PLC directory URL (only used when isDevEnv=false) +} + +// NewGenerator creates a new DID generator +// isDevEnv: true for local development (no PLC registration), false for production (register with PLC) +// plcDirectoryURL: URL for PLC directory (e.g., "https://plc.directory") +func NewGenerator(isDevEnv bool, plcDirectoryURL string) *Generator { + return &Generator{ + isDevEnv: isDevEnv, + plcDirectoryURL: plcDirectoryURL, + } +} + +// GenerateCommunityDID creates a new random DID for a community +// Format: did:plc:{base32-random} +// +// Dev mode (isDevEnv=true): Generates did:plc:xxx without registering to PLC +// Prod mode (isDevEnv=false): Generates did:plc:xxx AND registers with PLC directory +// +// See: https://github.com/bluesky-social/did-method-plc +func (g *Generator) GenerateCommunityDID() (string, error) { + // Generate 16 random bytes for the DID identifier + randomBytes := make([]byte, 16) + if _, err := rand.Read(randomBytes); err != nil { + return "", fmt.Errorf("failed to generate random DID: %w", err) + } + + // Encode as base32 (lowercase, no padding) - matches PLC format + encoded := base32.StdEncoding.EncodeToString(randomBytes) + encoded = strings.ToLower(strings.TrimRight(encoded, "=")) + + did := fmt.Sprintf("did:plc:%s", encoded) + + // TODO: In production (isDevEnv=false), register this DID with PLC directory + // This would involve: + // 1. Generate signing keypair for the DID + // 2. Create DID document with service endpoints + // 3. POST to plcDirectoryURL to register + // 4. Store keypair securely for future DID updates + // + // For now, we just generate the identifier (works fine for local dev) + if !g.isDevEnv { + // Future: implement PLC registration here + // return "", fmt.Errorf("PLC registration not yet implemented") + } + + return did, nil +} + +// ValidateDID checks if a DID string is properly formatted +// Supports did:plc, did:web (for instances) +func ValidateDID(did string) bool { + if !strings.HasPrefix(did, "did:") { + return false + } + + parts := strings.Split(did, ":") + if len(parts) < 3 { + return false + } + + method := parts[1] + identifier := parts[2] + + // Basic validation: method and identifier must not be empty + return method != "" && identifier != "" +} diff --git a/internal/atproto/did/generator_test.go b/internal/atproto/did/generator_test.go new file mode 100644 index 0000000..1f569ea --- /dev/null +++ b/internal/atproto/did/generator_test.go @@ -0,0 +1,128 @@ +package did + +import ( + "strings" + "testing" +) + +func TestGenerateCommunityDID(t *testing.T) { + tests := []struct { + name string + isDevEnv bool + plcDirectoryURL string + want string // prefix we expect + }{ + { + name: "generates did:plc in dev mode", + isDevEnv: true, + plcDirectoryURL: "https://plc.directory", + want: "did:plc:", + }, + { + name: "generates did:plc in prod mode", + isDevEnv: false, + plcDirectoryURL: "https://plc.directory", + want: "did:plc:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewGenerator(tt.isDevEnv, tt.plcDirectoryURL) + did, err := g.GenerateCommunityDID() + + if err != nil { + t.Fatalf("GenerateCommunityDID() error = %v", err) + } + + if !strings.HasPrefix(did, tt.want) { + t.Errorf("GenerateCommunityDID() = %v, want prefix %v", did, tt.want) + } + + // Verify it's a valid DID + if !ValidateDID(did) { + t.Errorf("Generated DID failed validation: %v", did) + } + }) + } +} + +func TestGenerateCommunityDID_Uniqueness(t *testing.T) { + g := NewGenerator(true, "https://plc.directory") + + // Generate 100 DIDs and ensure they're all unique + dids := make(map[string]bool) + for i := 0; i < 100; i++ { + did, err := g.GenerateCommunityDID() + if err != nil { + t.Fatalf("GenerateCommunityDID() error = %v", err) + } + + if dids[did] { + t.Errorf("Duplicate DID generated: %v", did) + } + dids[did] = true + } +} + +func TestValidateDID(t *testing.T) { + tests := []struct { + name string + did string + want bool + }{ + { + name: "valid did:plc", + did: "did:plc:z72i7hdynmk6r22z27h6tvur", + want: true, + }, + { + name: "valid did:plc with base32", + did: "did:plc:abc123xyz", + want: true, + }, + { + name: "valid did:web", + did: "did:web:coves.social", + want: true, + }, + { + name: "valid did:web with path", + did: "did:web:coves.social:community:gaming", + want: true, + }, + { + name: "invalid: missing prefix", + did: "plc:abc123", + want: false, + }, + { + name: "invalid: missing method", + did: "did::abc123", + want: false, + }, + { + name: "invalid: missing identifier", + did: "did:plc:", + want: false, + }, + { + name: "invalid: only did", + did: "did:", + want: false, + }, + { + name: "invalid: empty string", + did: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ValidateDID(tt.did); got != tt.want { + t.Errorf("ValidateDID(%v) = %v, want %v", tt.did, got, tt.want) + } + }) + } +} -- 2.51.2