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

Configure Feed

Select the types of activity you want to include in your feed.

trusted platform poc

+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 }