A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

trusted platform poc

evan.jarrett.net 89f4641a e872b71d

verified
+957 -165
+46 -1
cmd/appview/serve.go
··· 21 21 "atcr.io/pkg/appview/middleware" 22 22 "atcr.io/pkg/appview/storage" 23 23 "atcr.io/pkg/atproto" 24 + "atcr.io/pkg/atproto/did" 24 25 "atcr.io/pkg/auth" 25 26 "atcr.io/pkg/auth/oauth" 26 27 "atcr.io/pkg/auth/token" ··· 138 139 } else if invalidatedCount > 0 { 139 140 slog.Info("Invalidated OAuth sessions due to scope changes", "count", invalidatedCount) 140 141 } 142 + 143 + // Load or generate AppView K-256 signing key (for proxy assertions and DID document) 144 + slog.Info("Loading AppView signing key", "path", cfg.Server.ProxyKeyPath) 145 + proxySigningKey, err := oauth.GenerateOrLoadPDSKey(cfg.Server.ProxyKeyPath) 146 + if err != nil { 147 + return fmt.Errorf("failed to load proxy signing key: %w", err) 148 + } 149 + 150 + // Generate AppView DID from base URL 151 + serviceDID := did.GenerateDIDFromURL(baseURL) 152 + slog.Info("AppView DID initialized", "did", serviceDID) 153 + 154 + // Store signing key and DID for use by proxy assertion system 155 + middleware.SetGlobalProxySigningKey(proxySigningKey, serviceDID) 141 156 142 157 // Create oauth token refresher 143 158 refresher := oauth.NewRefresher(oauthClientApp) ··· 402 417 } 403 418 }) 404 419 405 - // Note: Indigo handles OAuth state cleanup internally via its store 420 + // Serve DID document for AppView (enables proxy assertion validation) 421 + mainRouter.Get("/.well-known/did.json", func(w http.ResponseWriter, r *http.Request) { 422 + pubKey, err := proxySigningKey.PublicKey() 423 + if err != nil { 424 + slog.Error("Failed to get public key for DID document", "error", err) 425 + http.Error(w, "internal error", http.StatusInternalServerError) 426 + return 427 + } 428 + 429 + services := did.DefaultAppViewServices(baseURL) 430 + doc, err := did.GenerateDIDDocument(baseURL, pubKey, services) 431 + if err != nil { 432 + slog.Error("Failed to generate DID document", "error", err) 433 + http.Error(w, "internal error", http.StatusInternalServerError) 434 + return 435 + } 436 + 437 + w.Header().Set("Content-Type", "application/json") 438 + w.Header().Set("Access-Control-Allow-Origin", "*") 439 + if err := json.NewEncoder(w).Encode(doc); err != nil { 440 + slog.Error("Failed to encode DID document", "error", err) 441 + } 442 + }) 443 + slog.Info("DID document endpoint enabled", "endpoint", "/.well-known/did.json", "did", serviceDID) 406 444 407 445 // Mount auth endpoints if enabled 408 446 if issuer != nil { ··· 413 451 // This validates OAuth sessions are usable (not just exist) before issuing tokens 414 452 // Prevents the flood of errors when a stale session is discovered during push 415 453 tokenHandler.SetOAuthSessionValidator(refresher) 454 + 455 + // Enable service token authentication for CI platforms (e.g., Tangled/Spindle) 456 + // Service tokens from getServiceAuth are validated against this service's DID 457 + if serviceDID != "" { 458 + tokenHandler.SetServiceTokenValidator(serviceDID) 459 + slog.Info("Service token authentication enabled", "service_did", serviceDID) 460 + } 416 461 417 462 // Register token post-auth callback for profile management 418 463 // This decouples the token package from AppView-specific dependencies
+5
pkg/appview/config.go
··· 58 58 // ClientName is the OAuth client display name (from env: ATCR_CLIENT_NAME, default: "AT Container Registry") 59 59 // Shown in OAuth authorization screens 60 60 ClientName string `yaml:"client_name"` 61 + 62 + // ProxyKeyPath is the path to the K-256 signing key for proxy assertions (from env: ATCR_PROXY_KEY_PATH, default: "/var/lib/atcr/auth/proxy-key") 63 + // Auto-generated on first run. Used to sign proxy assertions for Hold services. 64 + ProxyKeyPath string `yaml:"proxy_key_path"` 61 65 } 62 66 63 67 // UIConfig defines web UI settings ··· 150 154 cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true" 151 155 cfg.Server.OAuthKeyPath = getEnvOrDefault("ATCR_OAUTH_KEY_PATH", "/var/lib/atcr/oauth/client.key") 152 156 cfg.Server.ClientName = getEnvOrDefault("ATCR_CLIENT_NAME", "AT Container Registry") 157 + cfg.Server.ProxyKeyPath = getEnvOrDefault("ATCR_PROXY_KEY_PATH", "/var/lib/atcr/auth/proxy-key") 153 158 154 159 // Auto-detect base URL if not explicitly set 155 160 cfg.Server.BaseURL = os.Getenv("ATCR_BASE_URL")
+52 -4
pkg/appview/middleware/registry.go
··· 10 10 "sync" 11 11 "time" 12 12 13 + "github.com/bluesky-social/indigo/atproto/atcrypto" 13 14 "github.com/distribution/distribution/v3" 14 15 "github.com/distribution/distribution/v3/registry/api/errcode" 15 16 registrymw "github.com/distribution/distribution/v3/registry/middleware/registry" ··· 20 21 "atcr.io/pkg/atproto" 21 22 "atcr.io/pkg/auth" 22 23 "atcr.io/pkg/auth/oauth" 24 + "atcr.io/pkg/auth/proxy" 23 25 "atcr.io/pkg/auth/token" 24 26 ) 25 27 ··· 170 172 // These are set by main.go during startup and copied into NamespaceResolver instances. 171 173 // After initialization, request handling uses the NamespaceResolver's instance fields. 172 174 var ( 173 - globalRefresher *oauth.Refresher 174 - globalDatabase storage.DatabaseMetrics 175 - globalAuthorizer auth.HoldAuthorizer 176 - globalReadmeCache storage.ReadmeCache 175 + globalRefresher *oauth.Refresher 176 + globalDatabase storage.DatabaseMetrics 177 + globalAuthorizer auth.HoldAuthorizer 178 + globalReadmeCache storage.ReadmeCache 179 + globalProxySigningKey *atcrypto.PrivateKeyK256 180 + globalServiceDID string 177 181 ) 178 182 179 183 // SetGlobalRefresher sets the OAuth refresher instance during initialization ··· 198 202 // Must be called before the registry starts serving requests 199 203 func SetGlobalReadmeCache(readmeCache storage.ReadmeCache) { 200 204 globalReadmeCache = readmeCache 205 + } 206 + 207 + // SetGlobalProxySigningKey sets the K-256 signing key and DID for proxy assertions 208 + // Must be called before the registry starts serving requests 209 + func SetGlobalProxySigningKey(key *atcrypto.PrivateKeyK256, serviceDID string) { 210 + globalProxySigningKey = key 211 + globalServiceDID = serviceDID 212 + } 213 + 214 + // GetGlobalServiceDID returns the AppView service DID 215 + func GetGlobalServiceDID() string { 216 + return globalServiceDID 217 + } 218 + 219 + // GetGlobalProxySigningKey returns the K-256 signing key for proxy assertions 220 + func GetGlobalProxySigningKey() *atcrypto.PrivateKeyK256 { 221 + return globalProxySigningKey 201 222 } 202 223 203 224 func init() { ··· 455 476 // 2. OAuth sessions can be refreshed/invalidated between requests 456 477 // 3. The refresher already caches sessions efficiently (in-memory + DB) 457 478 // 4. Caching the repository with a stale ATProtoClient causes refresh token errors 479 + // Check if hold trusts AppView for proxy assertions 480 + var proxyAsserter *proxy.Asserter 481 + holdTrusted := false 482 + 483 + if globalProxySigningKey != nil && globalServiceDID != "" && nr.authorizer != nil { 484 + // Create proxy asserter with AppView's signing key 485 + proxyAsserter = proxy.NewAsserter(globalServiceDID, globalProxySigningKey) 486 + 487 + // Check if the hold has AppView in its trustedProxies 488 + captain, err := nr.authorizer.GetCaptainRecord(ctx, holdDID) 489 + if err != nil { 490 + slog.Debug("Could not fetch captain record for proxy trust check", 491 + "hold_did", holdDID, "error", err) 492 + } else if captain != nil { 493 + for _, trusted := range captain.TrustedProxies { 494 + if trusted == globalServiceDID { 495 + holdTrusted = true 496 + slog.Debug("Hold trusts AppView, will use proxy assertions", 497 + "hold_did", holdDID, "appview_did", globalServiceDID) 498 + break 499 + } 500 + } 501 + } 502 + } 503 + 458 504 registryCtx := &storage.RegistryContext{ 459 505 DID: did, 460 506 Handle: handle, ··· 464 510 ServiceToken: serviceToken, // Cached service token from middleware validation 465 511 ATProtoClient: atprotoClient, 466 512 AuthMethod: authMethod, // Auth method from JWT token 513 + ProxyAsserter: proxyAsserter, // Creates proxy assertions signed by AppView 514 + HoldTrusted: holdTrusted, // Whether hold trusts AppView for proxy auth 467 515 Database: nr.database, 468 516 Authorizer: nr.authorizer, 469 517 Refresher: nr.refresher,
+6 -1
pkg/appview/storage/context.go
··· 6 6 "atcr.io/pkg/atproto" 7 7 "atcr.io/pkg/auth" 8 8 "atcr.io/pkg/auth/oauth" 9 + "atcr.io/pkg/auth/proxy" 9 10 ) 10 11 11 12 // DatabaseMetrics interface for tracking pull/push counts and querying hold DIDs ··· 32 33 Repository string // Image repository name (e.g., "debian") 33 34 ServiceToken string // Service token for hold authentication (cached by middleware) 34 35 ATProtoClient *atproto.Client // Authenticated ATProto client for this user 35 - AuthMethod string // Auth method used ("oauth" or "app_password") 36 + AuthMethod string // Auth method used ("oauth", "app_password", "service_token") 37 + 38 + // Proxy assertion support (for CI and performance optimization) 39 + ProxyAsserter *proxy.Asserter // Creates proxy assertions (nil if not configured) 40 + HoldTrusted bool // Whether hold trusts AppView (has did:web:atcr.io in trustedProxies) 36 41 37 42 // Shared services (same for all requests) 38 43 Database DatabaseMetrics // Metrics tracking database
+32 -9
pkg/appview/storage/proxy_blob_store.go
··· 12 12 "time" 13 13 14 14 "atcr.io/pkg/atproto" 15 + "atcr.io/pkg/auth/proxy" 15 16 "github.com/distribution/distribution/v3" 16 17 "github.com/distribution/distribution/v3/registry/api/errcode" 17 18 "github.com/opencontainers/go-digest" ··· 60 61 } 61 62 } 62 63 63 - // doAuthenticatedRequest performs an HTTP request with service token authentication 64 - // Uses the service token from middleware to authenticate requests to the hold service 64 + // doAuthenticatedRequest performs an HTTP request with authentication 65 + // Uses proxy assertion if hold trusts AppView, otherwise falls back to service token 65 66 func (p *ProxyBlobStore) doAuthenticatedRequest(ctx context.Context, req *http.Request) (*http.Response, error) { 66 - // Use service token that middleware already validated and cached 67 - // Middleware fails fast with HTTP 401 if OAuth session is invalid 68 - if p.ctx.ServiceToken == "" { 69 - // Should never happen - middleware validates OAuth before handlers run 70 - slog.Error("No service token in context", "component", "proxy_blob_store", "did", p.ctx.DID) 71 - return nil, fmt.Errorf("no service token available (middleware should have validated)") 67 + var token string 68 + 69 + // Use proxy assertion if hold trusts AppView (faster, no per-request service token validation) 70 + if p.ctx.HoldTrusted && p.ctx.ProxyAsserter != nil { 71 + // Create proxy assertion signed by AppView 72 + proofHash := proxy.HashProofForAudit(p.ctx.ServiceToken) 73 + assertion, err := p.ctx.ProxyAsserter.CreateAssertion(p.ctx.DID, p.ctx.HoldDID, p.ctx.AuthMethod, proofHash) 74 + if err != nil { 75 + slog.Error("Failed to create proxy assertion, falling back to service token", 76 + "component", "proxy_blob_store", "error", err) 77 + // Fall through to service token 78 + } else { 79 + token = assertion 80 + slog.Debug("Using proxy assertion for hold authentication", 81 + "component", "proxy_blob_store", "user_did", p.ctx.DID, "hold_did", p.ctx.HoldDID) 82 + } 83 + } 84 + 85 + // Fall back to service token if proxy assertion not available 86 + if token == "" { 87 + if p.ctx.ServiceToken == "" { 88 + // Should never happen - middleware validates OAuth before handlers run 89 + slog.Error("No service token in context", "component", "proxy_blob_store", "did", p.ctx.DID) 90 + return nil, fmt.Errorf("no service token available (middleware should have validated)") 91 + } 92 + token = p.ctx.ServiceToken 93 + slog.Debug("Using service token for hold authentication", 94 + "component", "proxy_blob_store", "user_did", p.ctx.DID, "hold_did", p.ctx.HoldDID) 72 95 } 73 96 74 97 // Add Bearer token to Authorization header 75 - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.ctx.ServiceToken)) 98 + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) 76 99 77 100 return p.httpClient.Do(req) 78 101 }
+145
pkg/atproto/did/document.go
··· 1 + // Package did provides shared DID document types and utilities for ATProto services. 2 + // Both AppView and Hold use this package for did:web document generation. 3 + package did 4 + 5 + import ( 6 + "encoding/json" 7 + "fmt" 8 + "net/url" 9 + 10 + "github.com/bluesky-social/indigo/atproto/atcrypto" 11 + ) 12 + 13 + // DIDDocument represents a did:web document 14 + type DIDDocument struct { 15 + Context []string `json:"@context"` 16 + ID string `json:"id"` 17 + AlsoKnownAs []string `json:"alsoKnownAs,omitempty"` 18 + VerificationMethod []VerificationMethod `json:"verificationMethod"` 19 + Authentication []string `json:"authentication,omitempty"` 20 + AssertionMethod []string `json:"assertionMethod,omitempty"` 21 + Service []Service `json:"service,omitempty"` 22 + } 23 + 24 + // VerificationMethod represents a public key in a DID document 25 + type VerificationMethod struct { 26 + ID string `json:"id"` 27 + Type string `json:"type"` 28 + Controller string `json:"controller"` 29 + PublicKeyMultibase string `json:"publicKeyMultibase"` 30 + } 31 + 32 + // Service represents a service endpoint in a DID document 33 + type Service struct { 34 + ID string `json:"id"` 35 + Type string `json:"type"` 36 + ServiceEndpoint string `json:"serviceEndpoint"` 37 + } 38 + 39 + // GenerateDIDFromURL creates a did:web identifier from a public URL 40 + // Example: "https://atcr.io" -> "did:web:atcr.io" 41 + // Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com:8080" 42 + // Note: Non-standard ports are included in the DID 43 + func GenerateDIDFromURL(publicURL string) string { 44 + u, err := url.Parse(publicURL) 45 + if err != nil { 46 + // Fallback: assume it's just a hostname 47 + return fmt.Sprintf("did:web:%s", publicURL) 48 + } 49 + 50 + hostname := u.Hostname() 51 + if hostname == "" { 52 + hostname = "localhost" 53 + } 54 + 55 + port := u.Port() 56 + 57 + // Include port in DID if it's non-standard (not 80 for http, not 443 for https) 58 + if port != "" && port != "80" && port != "443" { 59 + return fmt.Sprintf("did:web:%s:%s", hostname, port) 60 + } 61 + 62 + return fmt.Sprintf("did:web:%s", hostname) 63 + } 64 + 65 + // GenerateDIDDocument creates a DID document for a did:web identity 66 + // This is a standalone function that can be used by any ATProto service. 67 + // The services parameter allows customizing which service endpoints to include. 68 + func GenerateDIDDocument(publicURL string, publicKey atcrypto.PublicKey, services []Service) (*DIDDocument, error) { 69 + u, err := url.Parse(publicURL) 70 + if err != nil { 71 + return nil, fmt.Errorf("failed to parse public URL: %w", err) 72 + } 73 + 74 + hostname := u.Hostname() 75 + port := u.Port() 76 + 77 + // Build host string (include non-standard ports) 78 + host := hostname 79 + if port != "" && port != "80" && port != "443" { 80 + host = fmt.Sprintf("%s:%s", hostname, port) 81 + } 82 + 83 + did := fmt.Sprintf("did:web:%s", host) 84 + 85 + // Get public key in multibase format 86 + publicKeyMultibase := publicKey.Multibase() 87 + 88 + doc := &DIDDocument{ 89 + Context: []string{ 90 + "https://www.w3.org/ns/did/v1", 91 + "https://w3id.org/security/multikey/v1", 92 + "https://w3id.org/security/suites/secp256k1-2019/v1", 93 + }, 94 + ID: did, 95 + AlsoKnownAs: []string{ 96 + fmt.Sprintf("at://%s", host), 97 + }, 98 + VerificationMethod: []VerificationMethod{ 99 + { 100 + ID: fmt.Sprintf("%s#atproto", did), 101 + Type: "Multikey", 102 + Controller: did, 103 + PublicKeyMultibase: publicKeyMultibase, 104 + }, 105 + }, 106 + Authentication: []string{ 107 + fmt.Sprintf("%s#atproto", did), 108 + }, 109 + Service: services, 110 + } 111 + 112 + return doc, nil 113 + } 114 + 115 + // MarshalDIDDocument converts a DID document to JSON bytes 116 + func MarshalDIDDocument(doc *DIDDocument) ([]byte, error) { 117 + return json.MarshalIndent(doc, "", " ") 118 + } 119 + 120 + // DefaultHoldServices returns the standard service endpoints for a Hold service 121 + func DefaultHoldServices(publicURL string) []Service { 122 + return []Service{ 123 + { 124 + ID: "#atproto_pds", 125 + Type: "AtprotoPersonalDataServer", 126 + ServiceEndpoint: publicURL, 127 + }, 128 + { 129 + ID: "#atcr_hold", 130 + Type: "AtcrHoldService", 131 + ServiceEndpoint: publicURL, 132 + }, 133 + } 134 + } 135 + 136 + // DefaultAppViewServices returns the standard service endpoints for AppView 137 + func DefaultAppViewServices(publicURL string) []Service { 138 + return []Service{ 139 + { 140 + ID: "#atcr_registry", 141 + Type: "AtcrRegistryService", 142 + ServiceEndpoint: publicURL, 143 + }, 144 + } 145 + }
+9 -8
pkg/atproto/lexicon.go
··· 539 539 // Stored in the hold's embedded PDS to identify the hold owner and settings 540 540 // Uses CBOR encoding for efficient storage in hold's carstore 541 541 type CaptainRecord struct { 542 - Type string `json:"$type" cborgen:"$type"` 543 - Owner string `json:"owner" cborgen:"owner"` // DID of hold owner 544 - Public bool `json:"public" cborgen:"public"` // Public read access 545 - AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew 546 - EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var) 547 - DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp 548 - Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional) 549 - Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional) 542 + Type string `json:"$type" cborgen:"$type"` 543 + Owner string `json:"owner" cborgen:"owner"` // DID of hold owner 544 + Public bool `json:"public" cborgen:"public"` // Public read access 545 + AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew 546 + EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var) 547 + TrustedProxies []string `json:"trustedProxies,omitempty" cborgen:"trustedProxies,omitempty"` // DIDs of trusted proxy services (e.g., ["did:web:atcr.io"]) 548 + DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp 549 + Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional) 550 + Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional) 550 551 } 551 552 552 553 // CrewRecord represents a crew member in the hold
+322
pkg/auth/proxy/assertion.go
··· 1 + // Package proxy provides proxy assertion creation and validation for trusted proxy authentication. 2 + // Proxy assertions allow AppView to vouch for users when communicating with Hold services, 3 + // eliminating the need for per-request service token validation. 4 + package proxy 5 + 6 + import ( 7 + "context" 8 + "encoding/base64" 9 + "encoding/json" 10 + "fmt" 11 + "log/slog" 12 + "strings" 13 + "sync" 14 + "time" 15 + 16 + "github.com/bluesky-social/indigo/atproto/atcrypto" 17 + "github.com/bluesky-social/indigo/atproto/syntax" 18 + "github.com/golang-jwt/jwt/v5" 19 + 20 + "atcr.io/pkg/atproto" 21 + ) 22 + 23 + // ProxyAssertionClaims represents the claims in a proxy assertion JWT 24 + type ProxyAssertionClaims struct { 25 + jwt.RegisteredClaims 26 + UserDID string `json:"user_did"` // User being proxied (for clarity, also in sub) 27 + AuthMethod string `json:"auth_method"` // Original auth method: "oauth", "app_password", "service_token" 28 + Proof string `json:"proof"` // Original token (truncated hash for audit, not full token) 29 + } 30 + 31 + // Asserter creates proxy assertions signed by AppView 32 + type Asserter struct { 33 + proxyDID string // AppView's DID (e.g., "did:web:atcr.io") 34 + signingKey *atcrypto.PrivateKeyK256 // AppView's K-256 signing key 35 + } 36 + 37 + // NewAsserter creates a new proxy assertion creator 38 + func NewAsserter(proxyDID string, signingKey *atcrypto.PrivateKeyK256) *Asserter { 39 + return &Asserter{ 40 + proxyDID: proxyDID, 41 + signingKey: signingKey, 42 + } 43 + } 44 + 45 + // CreateAssertion creates a proxy assertion JWT for a user 46 + // userDID: the user being proxied 47 + // holdDID: the target hold service 48 + // authMethod: how the user authenticated ("oauth", "app_password", "service_token") 49 + // proofHash: a hash of the original authentication proof (for audit trail) 50 + func (a *Asserter) CreateAssertion(userDID, holdDID, authMethod, proofHash string) (string, error) { 51 + now := time.Now() 52 + 53 + claims := ProxyAssertionClaims{ 54 + RegisteredClaims: jwt.RegisteredClaims{ 55 + Issuer: a.proxyDID, 56 + Subject: userDID, 57 + Audience: jwt.ClaimStrings{holdDID}, 58 + ExpiresAt: jwt.NewNumericDate(now.Add(60 * time.Second)), // Short-lived 59 + IssuedAt: jwt.NewNumericDate(now), 60 + }, 61 + UserDID: userDID, 62 + AuthMethod: authMethod, 63 + Proof: proofHash, 64 + } 65 + 66 + // Create JWT header 67 + header := map[string]string{ 68 + "alg": "ES256K", 69 + "typ": "JWT", 70 + } 71 + 72 + // Encode header 73 + headerJSON, err := json.Marshal(header) 74 + if err != nil { 75 + return "", fmt.Errorf("failed to marshal header: %w", err) 76 + } 77 + headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) 78 + 79 + // Encode payload 80 + payloadJSON, err := json.Marshal(claims) 81 + if err != nil { 82 + return "", fmt.Errorf("failed to marshal claims: %w", err) 83 + } 84 + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) 85 + 86 + // Create signing input 87 + signingInput := headerB64 + "." + payloadB64 88 + 89 + // Sign using K-256 90 + signature, err := a.signingKey.HashAndSign([]byte(signingInput)) 91 + if err != nil { 92 + return "", fmt.Errorf("failed to sign assertion: %w", err) 93 + } 94 + 95 + // Encode signature 96 + signatureB64 := base64.RawURLEncoding.EncodeToString(signature) 97 + 98 + // Combine into JWT 99 + token := signingInput + "." + signatureB64 100 + 101 + slog.Debug("Created proxy assertion", 102 + "proxyDID", a.proxyDID, 103 + "userDID", userDID, 104 + "holdDID", holdDID, 105 + "authMethod", authMethod) 106 + 107 + return token, nil 108 + } 109 + 110 + // ValidatedUser represents a validated proxy assertion issuer 111 + type ValidatedUser struct { 112 + DID string // User DID from sub claim 113 + ProxyDID string // Proxy DID from iss claim 114 + AuthMethod string // Original auth method 115 + } 116 + 117 + // Validator validates proxy assertions from trusted proxies 118 + type Validator struct { 119 + trustedProxies []string // List of trusted proxy DIDs 120 + pubKeyCache *publicKeyCache // Cache for proxy public keys 121 + } 122 + 123 + // NewValidator creates a new proxy assertion validator 124 + func NewValidator(trustedProxies []string) *Validator { 125 + return &Validator{ 126 + trustedProxies: trustedProxies, 127 + pubKeyCache: newPublicKeyCache(24 * time.Hour), // Cache public keys for 24 hours 128 + } 129 + } 130 + 131 + // ValidateAssertion validates a proxy assertion JWT 132 + // Returns the validated user info if successful 133 + func (v *Validator) ValidateAssertion(ctx context.Context, tokenString, holdDID string) (*ValidatedUser, error) { 134 + // Parse JWT parts 135 + parts := strings.Split(tokenString, ".") 136 + if len(parts) != 3 { 137 + return nil, fmt.Errorf("invalid JWT format") 138 + } 139 + 140 + // Decode payload 141 + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) 142 + if err != nil { 143 + return nil, fmt.Errorf("failed to decode payload: %w", err) 144 + } 145 + 146 + // Parse claims 147 + var claims ProxyAssertionClaims 148 + if err := json.Unmarshal(payloadBytes, &claims); err != nil { 149 + return nil, fmt.Errorf("failed to unmarshal claims: %w", err) 150 + } 151 + 152 + // Get issuer (proxy DID) 153 + proxyDID := claims.Issuer 154 + if proxyDID == "" { 155 + return nil, fmt.Errorf("missing iss claim") 156 + } 157 + 158 + // Check if issuer is trusted 159 + if !v.isTrustedProxy(proxyDID) { 160 + return nil, fmt.Errorf("proxy %s not in trustedProxies", proxyDID) 161 + } 162 + 163 + // Verify audience matches this hold 164 + audiences, err := claims.GetAudience() 165 + if err != nil { 166 + return nil, fmt.Errorf("failed to get audience: %w", err) 167 + } 168 + if len(audiences) == 0 || audiences[0] != holdDID { 169 + return nil, fmt.Errorf("audience mismatch: expected %s, got %v", holdDID, audiences) 170 + } 171 + 172 + // Verify expiration 173 + exp, err := claims.GetExpirationTime() 174 + if err != nil { 175 + return nil, fmt.Errorf("failed to get expiration: %w", err) 176 + } 177 + if exp != nil && time.Now().After(exp.Time) { 178 + return nil, fmt.Errorf("assertion has expired") 179 + } 180 + 181 + // Fetch proxy's public key (with caching) 182 + publicKey, err := v.getProxyPublicKey(ctx, proxyDID) 183 + if err != nil { 184 + return nil, fmt.Errorf("failed to fetch public key for proxy %s: %w", proxyDID, err) 185 + } 186 + 187 + // Verify signature 188 + signedData := []byte(parts[0] + "." + parts[1]) 189 + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) 190 + if err != nil { 191 + return nil, fmt.Errorf("failed to decode signature: %w", err) 192 + } 193 + 194 + if err := publicKey.HashAndVerify(signedData, signature); err != nil { 195 + return nil, fmt.Errorf("signature verification failed: %w", err) 196 + } 197 + 198 + // Get user DID from sub claim 199 + userDID := claims.Subject 200 + if userDID == "" { 201 + userDID = claims.UserDID // Fallback to explicit field 202 + } 203 + if userDID == "" { 204 + return nil, fmt.Errorf("missing user DID in assertion") 205 + } 206 + 207 + slog.Debug("Validated proxy assertion", 208 + "proxyDID", proxyDID, 209 + "userDID", userDID, 210 + "authMethod", claims.AuthMethod) 211 + 212 + return &ValidatedUser{ 213 + DID: userDID, 214 + ProxyDID: proxyDID, 215 + AuthMethod: claims.AuthMethod, 216 + }, nil 217 + } 218 + 219 + // isTrustedProxy checks if a proxy DID is in the trusted list 220 + func (v *Validator) isTrustedProxy(proxyDID string) bool { 221 + for _, trusted := range v.trustedProxies { 222 + if trusted == proxyDID { 223 + return true 224 + } 225 + } 226 + return false 227 + } 228 + 229 + // getProxyPublicKey fetches and caches a proxy's public key 230 + func (v *Validator) getProxyPublicKey(ctx context.Context, proxyDID string) (atcrypto.PublicKey, error) { 231 + // Check cache first 232 + if key := v.pubKeyCache.get(proxyDID); key != nil { 233 + return key, nil 234 + } 235 + 236 + // Fetch from DID document 237 + key, err := fetchPublicKeyFromDID(ctx, proxyDID) 238 + if err != nil { 239 + return nil, err 240 + } 241 + 242 + // Cache the key 243 + v.pubKeyCache.set(proxyDID, key) 244 + 245 + return key, nil 246 + } 247 + 248 + // publicKeyCache caches public keys for proxy DIDs 249 + type publicKeyCache struct { 250 + mu sync.RWMutex 251 + entries map[string]cacheEntry 252 + ttl time.Duration 253 + } 254 + 255 + type cacheEntry struct { 256 + key atcrypto.PublicKey 257 + expiresAt time.Time 258 + } 259 + 260 + func newPublicKeyCache(ttl time.Duration) *publicKeyCache { 261 + return &publicKeyCache{ 262 + entries: make(map[string]cacheEntry), 263 + ttl: ttl, 264 + } 265 + } 266 + 267 + func (c *publicKeyCache) get(did string) atcrypto.PublicKey { 268 + c.mu.RLock() 269 + defer c.mu.RUnlock() 270 + 271 + entry, ok := c.entries[did] 272 + if !ok || time.Now().After(entry.expiresAt) { 273 + return nil 274 + } 275 + return entry.key 276 + } 277 + 278 + func (c *publicKeyCache) set(did string, key atcrypto.PublicKey) { 279 + c.mu.Lock() 280 + defer c.mu.Unlock() 281 + 282 + c.entries[did] = cacheEntry{ 283 + key: key, 284 + expiresAt: time.Now().Add(c.ttl), 285 + } 286 + } 287 + 288 + // fetchPublicKeyFromDID fetches a public key from a DID document 289 + func fetchPublicKeyFromDID(ctx context.Context, did string) (atcrypto.PublicKey, error) { 290 + directory := atproto.GetDirectory() 291 + atID, err := syntax.ParseAtIdentifier(did) 292 + if err != nil { 293 + return nil, fmt.Errorf("invalid DID format: %w", err) 294 + } 295 + 296 + ident, err := directory.Lookup(ctx, *atID) 297 + if err != nil { 298 + return nil, fmt.Errorf("failed to resolve DID: %w", err) 299 + } 300 + 301 + publicKey, err := ident.PublicKey() 302 + if err != nil { 303 + return nil, fmt.Errorf("failed to get public key from DID: %w", err) 304 + } 305 + 306 + return publicKey, nil 307 + } 308 + 309 + // HashProofForAudit creates a truncated hash of a token for audit purposes 310 + // This allows tracking without storing the full sensitive token 311 + func HashProofForAudit(token string) string { 312 + if token == "" { 313 + return "" 314 + } 315 + // Use first 16 chars of a simple hash (not cryptographic, just for tracking) 316 + // We don't need security here, just a way to correlate requests 317 + hash := 0 318 + for _, c := range token { 319 + hash = hash*31 + int(c) 320 + } 321 + return fmt.Sprintf("%016x", uint64(hash)) 322 + }
+223
pkg/auth/serviceauth/validator.go
··· 1 + // Package serviceauth provides service token validation for ATProto service authentication. 2 + // Service tokens are JWTs issued by a user's PDS via com.atproto.server.getServiceAuth. 3 + // They allow services to authenticate users on behalf of other services. 4 + package serviceauth 5 + 6 + import ( 7 + "context" 8 + "encoding/base64" 9 + "encoding/json" 10 + "fmt" 11 + "log/slog" 12 + "sync" 13 + "time" 14 + 15 + "github.com/bluesky-social/indigo/atproto/atcrypto" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + "github.com/golang-jwt/jwt/v5" 18 + 19 + "atcr.io/pkg/atproto" 20 + ) 21 + 22 + // ValidatedUser represents a validated user from a service token 23 + type ValidatedUser struct { 24 + DID string // User DID (from iss claim - the user's PDS signed this token for the user) 25 + } 26 + 27 + // ServiceTokenClaims represents the claims in an ATProto service token 28 + type ServiceTokenClaims struct { 29 + jwt.RegisteredClaims 30 + Lxm string `json:"lxm,omitempty"` // Lexicon method identifier (e.g., "io.atcr.registry.push") 31 + } 32 + 33 + // Validator validates ATProto service tokens 34 + type Validator struct { 35 + serviceDID string // This service's DID (expected in aud claim) 36 + pubKeyCache *publicKeyCache // Cache for public keys 37 + } 38 + 39 + // NewValidator creates a new service token validator 40 + // serviceDID is the DID of this service (e.g., "did:web:atcr.io") 41 + // Tokens will be validated to ensure they are intended for this service (aud claim) 42 + func NewValidator(serviceDID string) *Validator { 43 + return &Validator{ 44 + serviceDID: serviceDID, 45 + pubKeyCache: newPublicKeyCache(24 * time.Hour), 46 + } 47 + } 48 + 49 + // Validate validates a service token and returns the authenticated user 50 + // tokenString is the raw JWT token (without "Bearer " prefix) 51 + // Returns the user DID if validation succeeds 52 + func (v *Validator) Validate(ctx context.Context, tokenString string) (*ValidatedUser, error) { 53 + // Parse JWT parts manually (golang-jwt doesn't support ES256K algorithm used by ATProto) 54 + parts := splitJWT(tokenString) 55 + if parts == nil { 56 + return nil, fmt.Errorf("invalid JWT format") 57 + } 58 + 59 + // Decode payload to extract claims 60 + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) 61 + if err != nil { 62 + return nil, fmt.Errorf("failed to decode JWT payload: %w", err) 63 + } 64 + 65 + // Parse claims 66 + var claims ServiceTokenClaims 67 + if err := json.Unmarshal(payloadBytes, &claims); err != nil { 68 + return nil, fmt.Errorf("failed to unmarshal claims: %w", err) 69 + } 70 + 71 + // Get issuer DID (the user's DID - they own the PDS that issued this token) 72 + issuerDID := claims.Issuer 73 + if issuerDID == "" { 74 + return nil, fmt.Errorf("missing iss claim") 75 + } 76 + 77 + // Verify audience matches this service 78 + audiences, err := claims.GetAudience() 79 + if err != nil { 80 + return nil, fmt.Errorf("failed to get audience: %w", err) 81 + } 82 + if len(audiences) == 0 || audiences[0] != v.serviceDID { 83 + return nil, fmt.Errorf("audience mismatch: expected %s, got %v", v.serviceDID, audiences) 84 + } 85 + 86 + // Verify expiration 87 + exp, err := claims.GetExpirationTime() 88 + if err != nil { 89 + return nil, fmt.Errorf("failed to get expiration: %w", err) 90 + } 91 + if exp != nil && time.Now().After(exp.Time) { 92 + return nil, fmt.Errorf("token has expired") 93 + } 94 + 95 + // Fetch public key from issuer's DID document (with caching) 96 + publicKey, err := v.getPublicKey(ctx, issuerDID) 97 + if err != nil { 98 + return nil, fmt.Errorf("failed to fetch public key for issuer %s: %w", issuerDID, err) 99 + } 100 + 101 + // Verify signature using ATProto's secp256k1 crypto 102 + signedData := []byte(parts[0] + "." + parts[1]) 103 + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) 104 + if err != nil { 105 + return nil, fmt.Errorf("failed to decode signature: %w", err) 106 + } 107 + 108 + if err := publicKey.HashAndVerify(signedData, signature); err != nil { 109 + return nil, fmt.Errorf("signature verification failed: %w", err) 110 + } 111 + 112 + slog.Debug("Successfully validated service token", 113 + "userDID", issuerDID, 114 + "serviceDID", v.serviceDID) 115 + 116 + return &ValidatedUser{ 117 + DID: issuerDID, 118 + }, nil 119 + } 120 + 121 + // splitJWT splits a JWT into its three parts 122 + // Returns nil if the format is invalid 123 + func splitJWT(token string) []string { 124 + parts := make([]string, 0, 3) 125 + start := 0 126 + count := 0 127 + 128 + for i, c := range token { 129 + if c == '.' { 130 + parts = append(parts, token[start:i]) 131 + start = i + 1 132 + count++ 133 + } 134 + } 135 + 136 + // Add the final part 137 + parts = append(parts, token[start:]) 138 + 139 + if len(parts) != 3 { 140 + return nil 141 + } 142 + return parts 143 + } 144 + 145 + // getPublicKey fetches and caches a public key for a DID 146 + func (v *Validator) getPublicKey(ctx context.Context, did string) (atcrypto.PublicKey, error) { 147 + // Check cache first 148 + if key := v.pubKeyCache.get(did); key != nil { 149 + return key, nil 150 + } 151 + 152 + // Fetch from DID document 153 + key, err := fetchPublicKeyFromDID(ctx, did) 154 + if err != nil { 155 + return nil, err 156 + } 157 + 158 + // Cache the key 159 + v.pubKeyCache.set(did, key) 160 + 161 + return key, nil 162 + } 163 + 164 + // fetchPublicKeyFromDID fetches the public key from a DID document 165 + func fetchPublicKeyFromDID(ctx context.Context, did string) (atcrypto.PublicKey, error) { 166 + directory := atproto.GetDirectory() 167 + atID, err := syntax.ParseAtIdentifier(did) 168 + if err != nil { 169 + return nil, fmt.Errorf("invalid DID format: %w", err) 170 + } 171 + 172 + ident, err := directory.Lookup(ctx, *atID) 173 + if err != nil { 174 + return nil, fmt.Errorf("failed to resolve DID: %w", err) 175 + } 176 + 177 + publicKey, err := ident.PublicKey() 178 + if err != nil { 179 + return nil, fmt.Errorf("failed to get public key from DID: %w", err) 180 + } 181 + 182 + return publicKey, nil 183 + } 184 + 185 + // publicKeyCache caches public keys for DIDs 186 + type publicKeyCache struct { 187 + mu sync.RWMutex 188 + entries map[string]cacheEntry 189 + ttl time.Duration 190 + } 191 + 192 + type cacheEntry struct { 193 + key atcrypto.PublicKey 194 + expiresAt time.Time 195 + } 196 + 197 + func newPublicKeyCache(ttl time.Duration) *publicKeyCache { 198 + return &publicKeyCache{ 199 + entries: make(map[string]cacheEntry), 200 + ttl: ttl, 201 + } 202 + } 203 + 204 + func (c *publicKeyCache) get(did string) atcrypto.PublicKey { 205 + c.mu.RLock() 206 + defer c.mu.RUnlock() 207 + 208 + entry, ok := c.entries[did] 209 + if !ok || time.Now().After(entry.expiresAt) { 210 + return nil 211 + } 212 + return entry.key 213 + } 214 + 215 + func (c *publicKeyCache) set(did string, key atcrypto.PublicKey) { 216 + c.mu.Lock() 217 + defer c.mu.Unlock() 218 + 219 + c.entries[did] = cacheEntry{ 220 + key: key, 221 + expiresAt: time.Now().Add(c.ttl), 222 + } 223 + }
+3 -2
pkg/auth/token/claims.go
··· 9 9 10 10 // Auth method constants 11 11 const ( 12 - AuthMethodOAuth = "oauth" 13 - AuthMethodAppPassword = "app_password" 12 + AuthMethodOAuth = "oauth" 13 + AuthMethodAppPassword = "app_password" 14 + AuthMethodServiceToken = "service_token" 14 15 ) 15 16 16 17 // Claims represents the JWT claims for registry authentication
+64 -14
pkg/auth/token/handler.go
··· 12 12 "atcr.io/pkg/appview/db" 13 13 "atcr.io/pkg/atproto" 14 14 "atcr.io/pkg/auth" 15 + "atcr.io/pkg/auth/serviceauth" 15 16 ) 16 17 17 18 // PostAuthCallback is called after successful Basic Auth authentication. ··· 31 32 32 33 // Handler handles /auth/token requests 33 34 type Handler struct { 34 - issuer *Issuer 35 - validator *auth.SessionValidator 36 - deviceStore *db.DeviceStore // For validating device secrets 37 - postAuthCallback PostAuthCallback 35 + issuer *Issuer 36 + validator *auth.SessionValidator 37 + deviceStore *db.DeviceStore // For validating device secrets 38 + postAuthCallback PostAuthCallback 38 39 oauthSessionValidator OAuthSessionValidator 40 + serviceTokenValidator *serviceauth.Validator // For CI service token authentication 39 41 } 40 42 41 43 // NewHandler creates a new token handler ··· 58 60 // This prevents the flood of errors that occurs when a stale session is discovered during push 59 61 func (h *Handler) SetOAuthSessionValidator(validator OAuthSessionValidator) { 60 62 h.oauthSessionValidator = validator 63 + } 64 + 65 + // SetServiceTokenValidator sets the service token validator for CI authentication 66 + // When set, the handler will accept Bearer tokens with service tokens from CI platforms 67 + // serviceDID is the DID of this service (e.g., "did:web:atcr.io") 68 + func (h *Handler) SetServiceTokenValidator(serviceDID string) { 69 + h.serviceTokenValidator = serviceauth.NewValidator(serviceDID) 61 70 } 62 71 63 72 // TokenResponse represents the response from /auth/token ··· 132 141 return 133 142 } 134 143 135 - // Extract Basic auth credentials 136 - username, password, ok := r.BasicAuth() 137 - if !ok { 138 - slog.Debug("No Basic auth credentials provided") 139 - sendAuthError(w, r, "authentication required") 140 - return 141 - } 142 - 143 - slog.Debug("Got Basic auth credentials", "username", username, "passwordLength", len(password)) 144 - 145 144 // Parse query parameters 146 145 _ = r.URL.Query().Get("service") // service parameter - validated by issuer 147 146 scopeParam := r.URL.Query().Get("scope") ··· 163 162 var accessToken string 164 163 var authMethod string 165 164 165 + // Check for Bearer token authentication (CI service tokens) 166 + authHeader := r.Header.Get("Authorization") 167 + if strings.HasPrefix(authHeader, "Bearer ") && h.serviceTokenValidator != nil { 168 + tokenString := strings.TrimPrefix(authHeader, "Bearer ") 169 + 170 + slog.Debug("Processing service token authentication") 171 + 172 + validatedUser, err := h.serviceTokenValidator.Validate(r.Context(), tokenString) 173 + if err != nil { 174 + slog.Debug("Service token validation failed", "error", err) 175 + http.Error(w, fmt.Sprintf("service token authentication failed: %v", err), http.StatusUnauthorized) 176 + return 177 + } 178 + 179 + did = validatedUser.DID 180 + authMethod = AuthMethodServiceToken 181 + 182 + slog.Debug("Service token validated successfully", "did", did) 183 + 184 + // Resolve handle from DID for access validation 185 + resolvedDID, resolvedHandle, _, resolveErr := atproto.ResolveIdentity(r.Context(), did) 186 + if resolveErr != nil { 187 + slog.Warn("Failed to resolve handle for service token user", "did", did, "error", resolveErr) 188 + // Use empty handle - access validation will use DID 189 + } else { 190 + did = resolvedDID // Use canonical DID from resolution 191 + handle = resolvedHandle 192 + } 193 + 194 + // Service token auth - issue token and return 195 + h.issueToken(w, r, did, handle, access, authMethod) 196 + return 197 + } 198 + 199 + // Extract Basic auth credentials 200 + username, password, ok := r.BasicAuth() 201 + if !ok { 202 + slog.Debug("No Basic auth credentials provided") 203 + sendAuthError(w, r, "authentication required") 204 + return 205 + } 206 + 207 + slog.Debug("Got Basic auth credentials", "username", username, "passwordLength", len(password)) 208 + 166 209 // 1. Check if it's a device secret (starts with "atcr_device_") 167 210 if strings.HasPrefix(password, "atcr_device_") { 168 211 device, err := h.deviceStore.ValidateDeviceSecret(password) ··· 227 270 } 228 271 } 229 272 273 + // Issue token using common helper 274 + h.issueToken(w, r, did, handle, access, authMethod) 275 + } 276 + 277 + // issueToken validates access and issues a JWT token 278 + // This is the common code path for all authentication methods 279 + func (h *Handler) issueToken(w http.ResponseWriter, r *http.Request, did, handle string, access []auth.AccessEntry, authMethod string) { 230 280 // Validate that the user has permission for the requested access 231 281 // Use the actual handle from the validated credentials, not the Basic Auth username 232 282 if err := auth.ValidateAccess(did, handle, access); err != nil {
+37 -15
pkg/hold/pds/auth.go
··· 13 13 "time" 14 14 15 15 "atcr.io/pkg/atproto" 16 + "atcr.io/pkg/auth/proxy" 16 17 "github.com/bluesky-social/indigo/atproto/atcrypto" 17 18 "github.com/bluesky-social/indigo/atproto/syntax" 18 19 "github.com/golang-jwt/jwt/v5" ··· 258 259 // 2. DPoP + OAuth tokens - for direct user access 259 260 // The httpClient parameter is optional and defaults to http.DefaultClient if nil. 260 261 func ValidateBlobWriteAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) { 261 - // Try service token validation first (for AppView access) 262 + // Get captain record first - needed for proxy validation and crew check 263 + _, captain, err := pds.GetCaptainRecord(r.Context()) 264 + if err != nil { 265 + return nil, fmt.Errorf("failed to get captain record: %w", err) 266 + } 267 + 262 268 authHeader := r.Header.Get("Authorization") 263 269 var user *ValidatedUser 264 - var err error 265 270 266 271 if strings.HasPrefix(authHeader, "Bearer ") { 267 - // Service token authentication 268 - user, err = ValidateServiceToken(r, pds.did, httpClient) 269 - if err != nil { 270 - return nil, fmt.Errorf("service token authentication failed: %w", err) 272 + tokenString := strings.TrimPrefix(authHeader, "Bearer ") 273 + 274 + // Try proxy assertion first if we have trusted proxies configured 275 + if len(captain.TrustedProxies) > 0 { 276 + validator := proxy.NewValidator(captain.TrustedProxies) 277 + proxyUser, proxyErr := validator.ValidateAssertion(r.Context(), tokenString, pds.did) 278 + if proxyErr == nil { 279 + // Proxy assertion validated successfully 280 + slog.Debug("Validated proxy assertion", "userDID", proxyUser.DID, "proxyDID", proxyUser.ProxyDID) 281 + user = &ValidatedUser{ 282 + DID: proxyUser.DID, 283 + Authorized: true, 284 + } 285 + } else if !strings.Contains(proxyErr.Error(), "not in trustedProxies") { 286 + // Log non-trust errors for debugging 287 + slog.Debug("Proxy assertion validation failed, trying service token", "error", proxyErr) 288 + } 289 + } 290 + 291 + // Fall back to service token if proxy assertion didn't work 292 + if user == nil { 293 + var serviceErr error 294 + user, serviceErr = ValidateServiceToken(r, pds.did, httpClient) 295 + if serviceErr != nil { 296 + return nil, fmt.Errorf("bearer token authentication failed: %w", serviceErr) 297 + } 271 298 } 272 299 } else if strings.HasPrefix(authHeader, "DPoP ") { 273 300 // DPoP + OAuth authentication (direct user access) 274 - user, err = ValidateDPoPRequest(r, httpClient) 275 - if err != nil { 276 - return nil, fmt.Errorf("DPoP authentication failed: %w", err) 301 + var dpopErr error 302 + user, dpopErr = ValidateDPoPRequest(r, httpClient) 303 + if dpopErr != nil { 304 + return nil, fmt.Errorf("DPoP authentication failed: %w", dpopErr) 277 305 } 278 306 } else { 279 307 return nil, fmt.Errorf("missing or invalid Authorization header (expected Bearer or DPoP)") 280 - } 281 - 282 - // Get captain record to check owner and public settings 283 - _, captain, err := pds.GetCaptainRecord(r.Context()) 284 - if err != nil { 285 - return nil, fmt.Errorf("failed to get captain record: %w", err) 286 308 } 287 309 288 310 // Check if user is the owner (always has write access)
+13 -111
pkg/hold/pds/did.go
··· 1 1 package pds 2 2 3 3 import ( 4 - "encoding/json" 5 4 "fmt" 6 - "net/url" 5 + 6 + "atcr.io/pkg/atproto/did" 7 7 ) 8 8 9 - // DIDDocument represents a did:web document 10 - type DIDDocument struct { 11 - Context []string `json:"@context"` 12 - ID string `json:"id"` 13 - AlsoKnownAs []string `json:"alsoKnownAs,omitempty"` 14 - VerificationMethod []VerificationMethod `json:"verificationMethod"` 15 - Authentication []string `json:"authentication,omitempty"` 16 - AssertionMethod []string `json:"assertionMethod,omitempty"` 17 - Service []Service `json:"service,omitempty"` 18 - } 9 + // Type aliases for backward compatibility - code using pds.DIDDocument etc. still works 10 + type DIDDocument = did.DIDDocument 11 + type VerificationMethod = did.VerificationMethod 12 + type Service = did.Service 19 13 20 - // VerificationMethod represents a public key in a DID document 21 - type VerificationMethod struct { 22 - ID string `json:"id"` 23 - Type string `json:"type"` 24 - Controller string `json:"controller"` 25 - PublicKeyMultibase string `json:"publicKeyMultibase"` 26 - } 27 - 28 - // Service represents a service endpoint in a DID document 29 - type Service struct { 30 - ID string `json:"id"` 31 - Type string `json:"type"` 32 - ServiceEndpoint string `json:"serviceEndpoint"` 33 - } 14 + // GenerateDIDFromURL creates a did:web identifier from a public URL 15 + // Delegates to shared package 16 + var GenerateDIDFromURL = did.GenerateDIDFromURL 34 17 35 - // GenerateDIDDocument creates a DID document for a did:web identity 18 + // GenerateDIDDocument creates a DID document for the hold's did:web identity 36 19 func (p *HoldPDS) GenerateDIDDocument(publicURL string) (*DIDDocument, error) { 37 - // Parse URL to extract host and port 38 - u, err := url.Parse(publicURL) 39 - if err != nil { 40 - return nil, fmt.Errorf("failed to parse public URL: %w", err) 41 - } 42 - 43 - hostname := u.Hostname() 44 - port := u.Port() 45 - 46 - // Build host string (include non-standard ports per did:web spec) 47 - host := hostname 48 - if port != "" && port != "80" && port != "443" { 49 - host = fmt.Sprintf("%s:%s", hostname, port) 50 - } 51 - 52 - did := fmt.Sprintf("did:web:%s", host) 53 - 54 - // Get public key in multibase format using indigo's crypto 55 20 pubKey, err := p.signingKey.PublicKey() 56 21 if err != nil { 57 22 return nil, fmt.Errorf("failed to get public key: %w", err) 58 23 } 59 - publicKeyMultibase := pubKey.Multibase() 60 24 61 - doc := &DIDDocument{ 62 - Context: []string{ 63 - "https://www.w3.org/ns/did/v1", 64 - "https://w3id.org/security/multikey/v1", 65 - "https://w3id.org/security/suites/secp256k1-2019/v1", 66 - }, 67 - ID: did, 68 - AlsoKnownAs: []string{ 69 - fmt.Sprintf("at://%s", host), 70 - }, 71 - VerificationMethod: []VerificationMethod{ 72 - { 73 - ID: fmt.Sprintf("%s#atproto", did), 74 - Type: "Multikey", 75 - Controller: did, 76 - PublicKeyMultibase: publicKeyMultibase, 77 - }, 78 - }, 79 - Authentication: []string{ 80 - fmt.Sprintf("%s#atproto", did), 81 - }, 82 - Service: []Service{ 83 - { 84 - ID: "#atproto_pds", 85 - Type: "AtprotoPersonalDataServer", 86 - ServiceEndpoint: publicURL, 87 - }, 88 - { 89 - ID: "#atcr_hold", 90 - Type: "AtcrHoldService", 91 - ServiceEndpoint: publicURL, 92 - }, 93 - }, 94 - } 95 - 96 - return doc, nil 25 + services := did.DefaultHoldServices(publicURL) 26 + return did.GenerateDIDDocument(publicURL, pubKey, services) 97 27 } 98 28 99 29 // MarshalDIDDocument converts a DID document to JSON using the stored public URL ··· 103 33 return nil, err 104 34 } 105 35 106 - return json.MarshalIndent(doc, "", " ") 107 - } 108 - 109 - // GenerateDIDFromURL creates a did:web identifier from a public URL 110 - // Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com:8080" 111 - // Note: Per did:web spec, non-standard ports (not 80/443) are included in the DID 112 - func GenerateDIDFromURL(publicURL string) string { 113 - // Parse URL 114 - u, err := url.Parse(publicURL) 115 - if err != nil { 116 - // Fallback: assume it's just a hostname 117 - return fmt.Sprintf("did:web:%s", publicURL) 118 - } 119 - 120 - // Get hostname 121 - hostname := u.Hostname() 122 - if hostname == "" { 123 - hostname = "localhost" 124 - } 125 - 126 - // Get port 127 - port := u.Port() 128 - 129 - // Include port in DID if it's non-standard (not 80 for http, not 443 for https) 130 - if port != "" && port != "80" && port != "443" { 131 - return fmt.Sprintf("did:web:%s:%s", hostname, port) 132 - } 133 - 134 - return fmt.Sprintf("did:web:%s", hostname) 36 + return did.MarshalDIDDocument(doc) 135 37 }