From 89f4641a2acfae84aab02d72d3ce57649a0f9934 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Thu, 18 Dec 2025 23:23:38 -0600 Subject: [PATCH] trusted platform poc --- cmd/appview/serve.go | 47 +++- pkg/appview/config.go | 5 + pkg/appview/middleware/registry.go | 56 ++++- pkg/appview/storage/context.go | 7 +- pkg/appview/storage/proxy_blob_store.go | 41 ++- pkg/atproto/did/document.go | 145 +++++++++++ pkg/atproto/lexicon.go | 17 +- pkg/auth/proxy/assertion.go | 322 ++++++++++++++++++++++++ pkg/auth/serviceauth/validator.go | 223 ++++++++++++++++ pkg/auth/token/claims.go | 5 +- pkg/auth/token/handler.go | 78 ++++-- pkg/hold/pds/auth.go | 66 +++-- pkg/hold/pds/did.go | 124 +-------- 13 files changed, 964 insertions(+), 172 deletions(-) create mode 100644 pkg/atproto/did/document.go create mode 100644 pkg/auth/proxy/assertion.go create mode 100644 pkg/auth/serviceauth/validator.go diff --git a/cmd/appview/serve.go b/cmd/appview/serve.go index 8f37a41..f880d2b 100644 --- a/cmd/appview/serve.go +++ b/cmd/appview/serve.go @@ -21,6 +21,7 @@ import ( "atcr.io/pkg/appview/middleware" "atcr.io/pkg/appview/storage" "atcr.io/pkg/atproto" + "atcr.io/pkg/atproto/did" "atcr.io/pkg/auth" "atcr.io/pkg/auth/oauth" "atcr.io/pkg/auth/token" @@ -139,6 +140,20 @@ func serveRegistry(cmd *cobra.Command, args []string) error { slog.Info("Invalidated OAuth sessions due to scope changes", "count", invalidatedCount) } + // Load or generate AppView K-256 signing key (for proxy assertions and DID document) + slog.Info("Loading AppView signing key", "path", cfg.Server.ProxyKeyPath) + proxySigningKey, err := oauth.GenerateOrLoadPDSKey(cfg.Server.ProxyKeyPath) + if err != nil { + return fmt.Errorf("failed to load proxy signing key: %w", err) + } + + // Generate AppView DID from base URL + serviceDID := did.GenerateDIDFromURL(baseURL) + slog.Info("AppView DID initialized", "did", serviceDID) + + // Store signing key and DID for use by proxy assertion system + middleware.SetGlobalProxySigningKey(proxySigningKey, serviceDID) + // Create oauth token refresher refresher := oauth.NewRefresher(oauthClientApp) @@ -402,7 +417,30 @@ func serveRegistry(cmd *cobra.Command, args []string) error { } }) - // Note: Indigo handles OAuth state cleanup internally via its store + // Serve DID document for AppView (enables proxy assertion validation) + mainRouter.Get("/.well-known/did.json", func(w http.ResponseWriter, r *http.Request) { + pubKey, err := proxySigningKey.PublicKey() + if err != nil { + slog.Error("Failed to get public key for DID document", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + services := did.DefaultAppViewServices(baseURL) + doc, err := did.GenerateDIDDocument(baseURL, pubKey, services) + if err != nil { + slog.Error("Failed to generate DID document", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + if err := json.NewEncoder(w).Encode(doc); err != nil { + slog.Error("Failed to encode DID document", "error", err) + } + }) + slog.Info("DID document endpoint enabled", "endpoint", "/.well-known/did.json", "did", serviceDID) // Mount auth endpoints if enabled if issuer != nil { @@ -414,6 +452,13 @@ func serveRegistry(cmd *cobra.Command, args []string) error { // Prevents the flood of errors when a stale session is discovered during push tokenHandler.SetOAuthSessionValidator(refresher) + // Enable service token authentication for CI platforms (e.g., Tangled/Spindle) + // Service tokens from getServiceAuth are validated against this service's DID + if serviceDID != "" { + tokenHandler.SetServiceTokenValidator(serviceDID) + slog.Info("Service token authentication enabled", "service_did", serviceDID) + } + // Register token post-auth callback for profile management // This decouples the token package from AppView-specific dependencies tokenHandler.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error { diff --git a/pkg/appview/config.go b/pkg/appview/config.go index e8516d0..b295811 100644 --- a/pkg/appview/config.go +++ b/pkg/appview/config.go @@ -58,6 +58,10 @@ type ServerConfig struct { // ClientName is the OAuth client display name (from env: ATCR_CLIENT_NAME, default: "AT Container Registry") // Shown in OAuth authorization screens ClientName string `yaml:"client_name"` + + // 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") + // Auto-generated on first run. Used to sign proxy assertions for Hold services. + ProxyKeyPath string `yaml:"proxy_key_path"` } // UIConfig defines web UI settings @@ -150,6 +154,7 @@ func LoadConfigFromEnv() (*Config, error) { cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true" cfg.Server.OAuthKeyPath = getEnvOrDefault("ATCR_OAUTH_KEY_PATH", "/var/lib/atcr/oauth/client.key") cfg.Server.ClientName = getEnvOrDefault("ATCR_CLIENT_NAME", "AT Container Registry") + cfg.Server.ProxyKeyPath = getEnvOrDefault("ATCR_PROXY_KEY_PATH", "/var/lib/atcr/auth/proxy-key") // Auto-detect base URL if not explicitly set cfg.Server.BaseURL = os.Getenv("ATCR_BASE_URL") diff --git a/pkg/appview/middleware/registry.go b/pkg/appview/middleware/registry.go index 1e90d22..29820fb 100644 --- a/pkg/appview/middleware/registry.go +++ b/pkg/appview/middleware/registry.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/bluesky-social/indigo/atproto/atcrypto" "github.com/distribution/distribution/v3" "github.com/distribution/distribution/v3/registry/api/errcode" registrymw "github.com/distribution/distribution/v3/registry/middleware/registry" @@ -20,6 +21,7 @@ import ( "atcr.io/pkg/atproto" "atcr.io/pkg/auth" "atcr.io/pkg/auth/oauth" + "atcr.io/pkg/auth/proxy" "atcr.io/pkg/auth/token" ) @@ -170,10 +172,12 @@ func (vc *validationCache) getOrFetch(ctx context.Context, cacheKey string, fetc // These are set by main.go during startup and copied into NamespaceResolver instances. // After initialization, request handling uses the NamespaceResolver's instance fields. var ( - globalRefresher *oauth.Refresher - globalDatabase storage.DatabaseMetrics - globalAuthorizer auth.HoldAuthorizer - globalReadmeCache storage.ReadmeCache + globalRefresher *oauth.Refresher + globalDatabase storage.DatabaseMetrics + globalAuthorizer auth.HoldAuthorizer + globalReadmeCache storage.ReadmeCache + globalProxySigningKey *atcrypto.PrivateKeyK256 + globalServiceDID string ) // SetGlobalRefresher sets the OAuth refresher instance during initialization @@ -200,6 +204,23 @@ func SetGlobalReadmeCache(readmeCache storage.ReadmeCache) { globalReadmeCache = readmeCache } +// SetGlobalProxySigningKey sets the K-256 signing key and DID for proxy assertions +// Must be called before the registry starts serving requests +func SetGlobalProxySigningKey(key *atcrypto.PrivateKeyK256, serviceDID string) { + globalProxySigningKey = key + globalServiceDID = serviceDID +} + +// GetGlobalServiceDID returns the AppView service DID +func GetGlobalServiceDID() string { + return globalServiceDID +} + +// GetGlobalProxySigningKey returns the K-256 signing key for proxy assertions +func GetGlobalProxySigningKey() *atcrypto.PrivateKeyK256 { + return globalProxySigningKey +} + func init() { // Register the name resolution middleware registrymw.Register("atproto-resolver", initATProtoResolver) @@ -455,6 +476,31 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name // 2. OAuth sessions can be refreshed/invalidated between requests // 3. The refresher already caches sessions efficiently (in-memory + DB) // 4. Caching the repository with a stale ATProtoClient causes refresh token errors + // Check if hold trusts AppView for proxy assertions + var proxyAsserter *proxy.Asserter + holdTrusted := false + + if globalProxySigningKey != nil && globalServiceDID != "" && nr.authorizer != nil { + // Create proxy asserter with AppView's signing key + proxyAsserter = proxy.NewAsserter(globalServiceDID, globalProxySigningKey) + + // Check if the hold has AppView in its trustedProxies + captain, err := nr.authorizer.GetCaptainRecord(ctx, holdDID) + if err != nil { + slog.Debug("Could not fetch captain record for proxy trust check", + "hold_did", holdDID, "error", err) + } else if captain != nil { + for _, trusted := range captain.TrustedProxies { + if trusted == globalServiceDID { + holdTrusted = true + slog.Debug("Hold trusts AppView, will use proxy assertions", + "hold_did", holdDID, "appview_did", globalServiceDID) + break + } + } + } + } + registryCtx := &storage.RegistryContext{ DID: did, Handle: handle, @@ -464,6 +510,8 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name ServiceToken: serviceToken, // Cached service token from middleware validation ATProtoClient: atprotoClient, AuthMethod: authMethod, // Auth method from JWT token + ProxyAsserter: proxyAsserter, // Creates proxy assertions signed by AppView + HoldTrusted: holdTrusted, // Whether hold trusts AppView for proxy auth Database: nr.database, Authorizer: nr.authorizer, Refresher: nr.refresher, diff --git a/pkg/appview/storage/context.go b/pkg/appview/storage/context.go index d59d534..2f9041d 100644 --- a/pkg/appview/storage/context.go +++ b/pkg/appview/storage/context.go @@ -6,6 +6,7 @@ import ( "atcr.io/pkg/atproto" "atcr.io/pkg/auth" "atcr.io/pkg/auth/oauth" + "atcr.io/pkg/auth/proxy" ) // DatabaseMetrics interface for tracking pull/push counts and querying hold DIDs @@ -32,7 +33,11 @@ type RegistryContext struct { Repository string // Image repository name (e.g., "debian") ServiceToken string // Service token for hold authentication (cached by middleware) ATProtoClient *atproto.Client // Authenticated ATProto client for this user - AuthMethod string // Auth method used ("oauth" or "app_password") + AuthMethod string // Auth method used ("oauth", "app_password", "service_token") + + // Proxy assertion support (for CI and performance optimization) + ProxyAsserter *proxy.Asserter // Creates proxy assertions (nil if not configured) + HoldTrusted bool // Whether hold trusts AppView (has did:web:atcr.io in trustedProxies) // Shared services (same for all requests) Database DatabaseMetrics // Metrics tracking database diff --git a/pkg/appview/storage/proxy_blob_store.go b/pkg/appview/storage/proxy_blob_store.go index db9304b..6089f25 100644 --- a/pkg/appview/storage/proxy_blob_store.go +++ b/pkg/appview/storage/proxy_blob_store.go @@ -12,6 +12,7 @@ import ( "time" "atcr.io/pkg/atproto" + "atcr.io/pkg/auth/proxy" "github.com/distribution/distribution/v3" "github.com/distribution/distribution/v3/registry/api/errcode" "github.com/opencontainers/go-digest" @@ -60,19 +61,41 @@ func NewProxyBlobStore(ctx *RegistryContext) *ProxyBlobStore { } } -// doAuthenticatedRequest performs an HTTP request with service token authentication -// Uses the service token from middleware to authenticate requests to the hold service +// doAuthenticatedRequest performs an HTTP request with authentication +// Uses proxy assertion if hold trusts AppView, otherwise falls back to service token func (p *ProxyBlobStore) doAuthenticatedRequest(ctx context.Context, req *http.Request) (*http.Response, error) { - // Use service token that middleware already validated and cached - // Middleware fails fast with HTTP 401 if OAuth session is invalid - if p.ctx.ServiceToken == "" { - // Should never happen - middleware validates OAuth before handlers run - slog.Error("No service token in context", "component", "proxy_blob_store", "did", p.ctx.DID) - return nil, fmt.Errorf("no service token available (middleware should have validated)") + var token string + + // Use proxy assertion if hold trusts AppView (faster, no per-request service token validation) + if p.ctx.HoldTrusted && p.ctx.ProxyAsserter != nil { + // Create proxy assertion signed by AppView + proofHash := proxy.HashProofForAudit(p.ctx.ServiceToken) + assertion, err := p.ctx.ProxyAsserter.CreateAssertion(p.ctx.DID, p.ctx.HoldDID, p.ctx.AuthMethod, proofHash) + if err != nil { + slog.Error("Failed to create proxy assertion, falling back to service token", + "component", "proxy_blob_store", "error", err) + // Fall through to service token + } else { + token = assertion + slog.Debug("Using proxy assertion for hold authentication", + "component", "proxy_blob_store", "user_did", p.ctx.DID, "hold_did", p.ctx.HoldDID) + } + } + + // Fall back to service token if proxy assertion not available + if token == "" { + if p.ctx.ServiceToken == "" { + // Should never happen - middleware validates OAuth before handlers run + slog.Error("No service token in context", "component", "proxy_blob_store", "did", p.ctx.DID) + return nil, fmt.Errorf("no service token available (middleware should have validated)") + } + token = p.ctx.ServiceToken + slog.Debug("Using service token for hold authentication", + "component", "proxy_blob_store", "user_did", p.ctx.DID, "hold_did", p.ctx.HoldDID) } // Add Bearer token to Authorization header - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.ctx.ServiceToken)) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) return p.httpClient.Do(req) } diff --git a/pkg/atproto/did/document.go b/pkg/atproto/did/document.go new file mode 100644 index 0000000..455d1c1 --- /dev/null +++ b/pkg/atproto/did/document.go @@ -0,0 +1,145 @@ +// Package did provides shared DID document types and utilities for ATProto services. +// Both AppView and Hold use this package for did:web document generation. +package did + +import ( + "encoding/json" + "fmt" + "net/url" + + "github.com/bluesky-social/indigo/atproto/atcrypto" +) + +// DIDDocument represents a did:web document +type DIDDocument struct { + Context []string `json:"@context"` + ID string `json:"id"` + AlsoKnownAs []string `json:"alsoKnownAs,omitempty"` + VerificationMethod []VerificationMethod `json:"verificationMethod"` + Authentication []string `json:"authentication,omitempty"` + AssertionMethod []string `json:"assertionMethod,omitempty"` + Service []Service `json:"service,omitempty"` +} + +// VerificationMethod represents a public key in a DID document +type VerificationMethod struct { + ID string `json:"id"` + Type string `json:"type"` + Controller string `json:"controller"` + PublicKeyMultibase string `json:"publicKeyMultibase"` +} + +// Service represents a service endpoint in a DID document +type Service struct { + ID string `json:"id"` + Type string `json:"type"` + ServiceEndpoint string `json:"serviceEndpoint"` +} + +// GenerateDIDFromURL creates a did:web identifier from a public URL +// Example: "https://atcr.io" -> "did:web:atcr.io" +// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com:8080" +// Note: Non-standard ports are included in the DID +func GenerateDIDFromURL(publicURL string) string { + u, err := url.Parse(publicURL) + if err != nil { + // Fallback: assume it's just a hostname + return fmt.Sprintf("did:web:%s", publicURL) + } + + hostname := u.Hostname() + if hostname == "" { + hostname = "localhost" + } + + port := u.Port() + + // Include port in DID if it's non-standard (not 80 for http, not 443 for https) + if port != "" && port != "80" && port != "443" { + return fmt.Sprintf("did:web:%s:%s", hostname, port) + } + + return fmt.Sprintf("did:web:%s", hostname) +} + +// GenerateDIDDocument creates a DID document for a did:web identity +// This is a standalone function that can be used by any ATProto service. +// The services parameter allows customizing which service endpoints to include. +func GenerateDIDDocument(publicURL string, publicKey atcrypto.PublicKey, services []Service) (*DIDDocument, error) { + u, err := url.Parse(publicURL) + if err != nil { + return nil, fmt.Errorf("failed to parse public URL: %w", err) + } + + hostname := u.Hostname() + port := u.Port() + + // Build host string (include non-standard ports) + host := hostname + if port != "" && port != "80" && port != "443" { + host = fmt.Sprintf("%s:%s", hostname, port) + } + + did := fmt.Sprintf("did:web:%s", host) + + // Get public key in multibase format + publicKeyMultibase := publicKey.Multibase() + + doc := &DIDDocument{ + Context: []string{ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1", + "https://w3id.org/security/suites/secp256k1-2019/v1", + }, + ID: did, + AlsoKnownAs: []string{ + fmt.Sprintf("at://%s", host), + }, + VerificationMethod: []VerificationMethod{ + { + ID: fmt.Sprintf("%s#atproto", did), + Type: "Multikey", + Controller: did, + PublicKeyMultibase: publicKeyMultibase, + }, + }, + Authentication: []string{ + fmt.Sprintf("%s#atproto", did), + }, + Service: services, + } + + return doc, nil +} + +// MarshalDIDDocument converts a DID document to JSON bytes +func MarshalDIDDocument(doc *DIDDocument) ([]byte, error) { + return json.MarshalIndent(doc, "", " ") +} + +// DefaultHoldServices returns the standard service endpoints for a Hold service +func DefaultHoldServices(publicURL string) []Service { + return []Service{ + { + ID: "#atproto_pds", + Type: "AtprotoPersonalDataServer", + ServiceEndpoint: publicURL, + }, + { + ID: "#atcr_hold", + Type: "AtcrHoldService", + ServiceEndpoint: publicURL, + }, + } +} + +// DefaultAppViewServices returns the standard service endpoints for AppView +func DefaultAppViewServices(publicURL string) []Service { + return []Service{ + { + ID: "#atcr_registry", + Type: "AtcrRegistryService", + ServiceEndpoint: publicURL, + }, + } +} diff --git a/pkg/atproto/lexicon.go b/pkg/atproto/lexicon.go index af4d8a3..c8f9aff 100644 --- a/pkg/atproto/lexicon.go +++ b/pkg/atproto/lexicon.go @@ -539,14 +539,15 @@ func (t *TagRecord) GetManifestDigest() (string, error) { // Stored in the hold's embedded PDS to identify the hold owner and settings // Uses CBOR encoding for efficient storage in hold's carstore type CaptainRecord struct { - Type string `json:"$type" cborgen:"$type"` - Owner string `json:"owner" cborgen:"owner"` // DID of hold owner - Public bool `json:"public" cborgen:"public"` // Public read access - AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew - EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var) - DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp - Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional) - Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional) + Type string `json:"$type" cborgen:"$type"` + Owner string `json:"owner" cborgen:"owner"` // DID of hold owner + Public bool `json:"public" cborgen:"public"` // Public read access + AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew + EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var) + TrustedProxies []string `json:"trustedProxies,omitempty" cborgen:"trustedProxies,omitempty"` // DIDs of trusted proxy services (e.g., ["did:web:atcr.io"]) + DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp + Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional) + Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional) } // CrewRecord represents a crew member in the hold diff --git a/pkg/auth/proxy/assertion.go b/pkg/auth/proxy/assertion.go new file mode 100644 index 0000000..9cf7883 --- /dev/null +++ b/pkg/auth/proxy/assertion.go @@ -0,0 +1,322 @@ +// Package proxy provides proxy assertion creation and validation for trusted proxy authentication. +// Proxy assertions allow AppView to vouch for users when communicating with Hold services, +// eliminating the need for per-request service token validation. +package proxy + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/golang-jwt/jwt/v5" + + "atcr.io/pkg/atproto" +) + +// ProxyAssertionClaims represents the claims in a proxy assertion JWT +type ProxyAssertionClaims struct { + jwt.RegisteredClaims + UserDID string `json:"user_did"` // User being proxied (for clarity, also in sub) + AuthMethod string `json:"auth_method"` // Original auth method: "oauth", "app_password", "service_token" + Proof string `json:"proof"` // Original token (truncated hash for audit, not full token) +} + +// Asserter creates proxy assertions signed by AppView +type Asserter struct { + proxyDID string // AppView's DID (e.g., "did:web:atcr.io") + signingKey *atcrypto.PrivateKeyK256 // AppView's K-256 signing key +} + +// NewAsserter creates a new proxy assertion creator +func NewAsserter(proxyDID string, signingKey *atcrypto.PrivateKeyK256) *Asserter { + return &Asserter{ + proxyDID: proxyDID, + signingKey: signingKey, + } +} + +// CreateAssertion creates a proxy assertion JWT for a user +// userDID: the user being proxied +// holdDID: the target hold service +// authMethod: how the user authenticated ("oauth", "app_password", "service_token") +// proofHash: a hash of the original authentication proof (for audit trail) +func (a *Asserter) CreateAssertion(userDID, holdDID, authMethod, proofHash string) (string, error) { + now := time.Now() + + claims := ProxyAssertionClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: a.proxyDID, + Subject: userDID, + Audience: jwt.ClaimStrings{holdDID}, + ExpiresAt: jwt.NewNumericDate(now.Add(60 * time.Second)), // Short-lived + IssuedAt: jwt.NewNumericDate(now), + }, + UserDID: userDID, + AuthMethod: authMethod, + Proof: proofHash, + } + + // Create JWT header + header := map[string]string{ + "alg": "ES256K", + "typ": "JWT", + } + + // Encode header + headerJSON, err := json.Marshal(header) + if err != nil { + return "", fmt.Errorf("failed to marshal header: %w", err) + } + headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) + + // Encode payload + payloadJSON, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("failed to marshal claims: %w", err) + } + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) + + // Create signing input + signingInput := headerB64 + "." + payloadB64 + + // Sign using K-256 + signature, err := a.signingKey.HashAndSign([]byte(signingInput)) + if err != nil { + return "", fmt.Errorf("failed to sign assertion: %w", err) + } + + // Encode signature + signatureB64 := base64.RawURLEncoding.EncodeToString(signature) + + // Combine into JWT + token := signingInput + "." + signatureB64 + + slog.Debug("Created proxy assertion", + "proxyDID", a.proxyDID, + "userDID", userDID, + "holdDID", holdDID, + "authMethod", authMethod) + + return token, nil +} + +// ValidatedUser represents a validated proxy assertion issuer +type ValidatedUser struct { + DID string // User DID from sub claim + ProxyDID string // Proxy DID from iss claim + AuthMethod string // Original auth method +} + +// Validator validates proxy assertions from trusted proxies +type Validator struct { + trustedProxies []string // List of trusted proxy DIDs + pubKeyCache *publicKeyCache // Cache for proxy public keys +} + +// NewValidator creates a new proxy assertion validator +func NewValidator(trustedProxies []string) *Validator { + return &Validator{ + trustedProxies: trustedProxies, + pubKeyCache: newPublicKeyCache(24 * time.Hour), // Cache public keys for 24 hours + } +} + +// ValidateAssertion validates a proxy assertion JWT +// Returns the validated user info if successful +func (v *Validator) ValidateAssertion(ctx context.Context, tokenString, holdDID string) (*ValidatedUser, error) { + // Parse JWT parts + parts := strings.Split(tokenString, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid JWT format") + } + + // Decode payload + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to decode payload: %w", err) + } + + // Parse claims + var claims ProxyAssertionClaims + if err := json.Unmarshal(payloadBytes, &claims); err != nil { + return nil, fmt.Errorf("failed to unmarshal claims: %w", err) + } + + // Get issuer (proxy DID) + proxyDID := claims.Issuer + if proxyDID == "" { + return nil, fmt.Errorf("missing iss claim") + } + + // Check if issuer is trusted + if !v.isTrustedProxy(proxyDID) { + return nil, fmt.Errorf("proxy %s not in trustedProxies", proxyDID) + } + + // Verify audience matches this hold + audiences, err := claims.GetAudience() + if err != nil { + return nil, fmt.Errorf("failed to get audience: %w", err) + } + if len(audiences) == 0 || audiences[0] != holdDID { + return nil, fmt.Errorf("audience mismatch: expected %s, got %v", holdDID, audiences) + } + + // Verify expiration + exp, err := claims.GetExpirationTime() + if err != nil { + return nil, fmt.Errorf("failed to get expiration: %w", err) + } + if exp != nil && time.Now().After(exp.Time) { + return nil, fmt.Errorf("assertion has expired") + } + + // Fetch proxy's public key (with caching) + publicKey, err := v.getProxyPublicKey(ctx, proxyDID) + if err != nil { + return nil, fmt.Errorf("failed to fetch public key for proxy %s: %w", proxyDID, err) + } + + // Verify signature + signedData := []byte(parts[0] + "." + parts[1]) + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, fmt.Errorf("failed to decode signature: %w", err) + } + + if err := publicKey.HashAndVerify(signedData, signature); err != nil { + return nil, fmt.Errorf("signature verification failed: %w", err) + } + + // Get user DID from sub claim + userDID := claims.Subject + if userDID == "" { + userDID = claims.UserDID // Fallback to explicit field + } + if userDID == "" { + return nil, fmt.Errorf("missing user DID in assertion") + } + + slog.Debug("Validated proxy assertion", + "proxyDID", proxyDID, + "userDID", userDID, + "authMethod", claims.AuthMethod) + + return &ValidatedUser{ + DID: userDID, + ProxyDID: proxyDID, + AuthMethod: claims.AuthMethod, + }, nil +} + +// isTrustedProxy checks if a proxy DID is in the trusted list +func (v *Validator) isTrustedProxy(proxyDID string) bool { + for _, trusted := range v.trustedProxies { + if trusted == proxyDID { + return true + } + } + return false +} + +// getProxyPublicKey fetches and caches a proxy's public key +func (v *Validator) getProxyPublicKey(ctx context.Context, proxyDID string) (atcrypto.PublicKey, error) { + // Check cache first + if key := v.pubKeyCache.get(proxyDID); key != nil { + return key, nil + } + + // Fetch from DID document + key, err := fetchPublicKeyFromDID(ctx, proxyDID) + if err != nil { + return nil, err + } + + // Cache the key + v.pubKeyCache.set(proxyDID, key) + + return key, nil +} + +// publicKeyCache caches public keys for proxy DIDs +type publicKeyCache struct { + mu sync.RWMutex + entries map[string]cacheEntry + ttl time.Duration +} + +type cacheEntry struct { + key atcrypto.PublicKey + expiresAt time.Time +} + +func newPublicKeyCache(ttl time.Duration) *publicKeyCache { + return &publicKeyCache{ + entries: make(map[string]cacheEntry), + ttl: ttl, + } +} + +func (c *publicKeyCache) get(did string) atcrypto.PublicKey { + c.mu.RLock() + defer c.mu.RUnlock() + + entry, ok := c.entries[did] + if !ok || time.Now().After(entry.expiresAt) { + return nil + } + return entry.key +} + +func (c *publicKeyCache) set(did string, key atcrypto.PublicKey) { + c.mu.Lock() + defer c.mu.Unlock() + + c.entries[did] = cacheEntry{ + key: key, + expiresAt: time.Now().Add(c.ttl), + } +} + +// fetchPublicKeyFromDID fetches a public key from a DID document +func fetchPublicKeyFromDID(ctx context.Context, did string) (atcrypto.PublicKey, error) { + directory := atproto.GetDirectory() + atID, err := syntax.ParseAtIdentifier(did) + if err != nil { + return nil, fmt.Errorf("invalid DID format: %w", err) + } + + ident, err := directory.Lookup(ctx, *atID) + if err != nil { + return nil, fmt.Errorf("failed to resolve DID: %w", err) + } + + publicKey, err := ident.PublicKey() + if err != nil { + return nil, fmt.Errorf("failed to get public key from DID: %w", err) + } + + return publicKey, nil +} + +// HashProofForAudit creates a truncated hash of a token for audit purposes +// This allows tracking without storing the full sensitive token +func HashProofForAudit(token string) string { + if token == "" { + return "" + } + // Use first 16 chars of a simple hash (not cryptographic, just for tracking) + // We don't need security here, just a way to correlate requests + hash := 0 + for _, c := range token { + hash = hash*31 + int(c) + } + return fmt.Sprintf("%016x", uint64(hash)) +} diff --git a/pkg/auth/serviceauth/validator.go b/pkg/auth/serviceauth/validator.go new file mode 100644 index 0000000..998a276 --- /dev/null +++ b/pkg/auth/serviceauth/validator.go @@ -0,0 +1,223 @@ +// Package serviceauth provides service token validation for ATProto service authentication. +// Service tokens are JWTs issued by a user's PDS via com.atproto.server.getServiceAuth. +// They allow services to authenticate users on behalf of other services. +package serviceauth + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/golang-jwt/jwt/v5" + + "atcr.io/pkg/atproto" +) + +// ValidatedUser represents a validated user from a service token +type ValidatedUser struct { + DID string // User DID (from iss claim - the user's PDS signed this token for the user) +} + +// ServiceTokenClaims represents the claims in an ATProto service token +type ServiceTokenClaims struct { + jwt.RegisteredClaims + Lxm string `json:"lxm,omitempty"` // Lexicon method identifier (e.g., "io.atcr.registry.push") +} + +// Validator validates ATProto service tokens +type Validator struct { + serviceDID string // This service's DID (expected in aud claim) + pubKeyCache *publicKeyCache // Cache for public keys +} + +// NewValidator creates a new service token validator +// serviceDID is the DID of this service (e.g., "did:web:atcr.io") +// Tokens will be validated to ensure they are intended for this service (aud claim) +func NewValidator(serviceDID string) *Validator { + return &Validator{ + serviceDID: serviceDID, + pubKeyCache: newPublicKeyCache(24 * time.Hour), + } +} + +// Validate validates a service token and returns the authenticated user +// tokenString is the raw JWT token (without "Bearer " prefix) +// Returns the user DID if validation succeeds +func (v *Validator) Validate(ctx context.Context, tokenString string) (*ValidatedUser, error) { + // Parse JWT parts manually (golang-jwt doesn't support ES256K algorithm used by ATProto) + parts := splitJWT(tokenString) + if parts == nil { + return nil, fmt.Errorf("invalid JWT format") + } + + // Decode payload to extract claims + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to decode JWT payload: %w", err) + } + + // Parse claims + var claims ServiceTokenClaims + if err := json.Unmarshal(payloadBytes, &claims); err != nil { + return nil, fmt.Errorf("failed to unmarshal claims: %w", err) + } + + // Get issuer DID (the user's DID - they own the PDS that issued this token) + issuerDID := claims.Issuer + if issuerDID == "" { + return nil, fmt.Errorf("missing iss claim") + } + + // Verify audience matches this service + audiences, err := claims.GetAudience() + if err != nil { + return nil, fmt.Errorf("failed to get audience: %w", err) + } + if len(audiences) == 0 || audiences[0] != v.serviceDID { + return nil, fmt.Errorf("audience mismatch: expected %s, got %v", v.serviceDID, audiences) + } + + // Verify expiration + exp, err := claims.GetExpirationTime() + if err != nil { + return nil, fmt.Errorf("failed to get expiration: %w", err) + } + if exp != nil && time.Now().After(exp.Time) { + return nil, fmt.Errorf("token has expired") + } + + // Fetch public key from issuer's DID document (with caching) + publicKey, err := v.getPublicKey(ctx, issuerDID) + if err != nil { + return nil, fmt.Errorf("failed to fetch public key for issuer %s: %w", issuerDID, err) + } + + // Verify signature using ATProto's secp256k1 crypto + signedData := []byte(parts[0] + "." + parts[1]) + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, fmt.Errorf("failed to decode signature: %w", err) + } + + if err := publicKey.HashAndVerify(signedData, signature); err != nil { + return nil, fmt.Errorf("signature verification failed: %w", err) + } + + slog.Debug("Successfully validated service token", + "userDID", issuerDID, + "serviceDID", v.serviceDID) + + return &ValidatedUser{ + DID: issuerDID, + }, nil +} + +// splitJWT splits a JWT into its three parts +// Returns nil if the format is invalid +func splitJWT(token string) []string { + parts := make([]string, 0, 3) + start := 0 + count := 0 + + for i, c := range token { + if c == '.' { + parts = append(parts, token[start:i]) + start = i + 1 + count++ + } + } + + // Add the final part + parts = append(parts, token[start:]) + + if len(parts) != 3 { + return nil + } + return parts +} + +// getPublicKey fetches and caches a public key for a DID +func (v *Validator) getPublicKey(ctx context.Context, did string) (atcrypto.PublicKey, error) { + // Check cache first + if key := v.pubKeyCache.get(did); key != nil { + return key, nil + } + + // Fetch from DID document + key, err := fetchPublicKeyFromDID(ctx, did) + if err != nil { + return nil, err + } + + // Cache the key + v.pubKeyCache.set(did, key) + + return key, nil +} + +// fetchPublicKeyFromDID fetches the public key from a DID document +func fetchPublicKeyFromDID(ctx context.Context, did string) (atcrypto.PublicKey, error) { + directory := atproto.GetDirectory() + atID, err := syntax.ParseAtIdentifier(did) + if err != nil { + return nil, fmt.Errorf("invalid DID format: %w", err) + } + + ident, err := directory.Lookup(ctx, *atID) + if err != nil { + return nil, fmt.Errorf("failed to resolve DID: %w", err) + } + + publicKey, err := ident.PublicKey() + if err != nil { + return nil, fmt.Errorf("failed to get public key from DID: %w", err) + } + + return publicKey, nil +} + +// publicKeyCache caches public keys for DIDs +type publicKeyCache struct { + mu sync.RWMutex + entries map[string]cacheEntry + ttl time.Duration +} + +type cacheEntry struct { + key atcrypto.PublicKey + expiresAt time.Time +} + +func newPublicKeyCache(ttl time.Duration) *publicKeyCache { + return &publicKeyCache{ + entries: make(map[string]cacheEntry), + ttl: ttl, + } +} + +func (c *publicKeyCache) get(did string) atcrypto.PublicKey { + c.mu.RLock() + defer c.mu.RUnlock() + + entry, ok := c.entries[did] + if !ok || time.Now().After(entry.expiresAt) { + return nil + } + return entry.key +} + +func (c *publicKeyCache) set(did string, key atcrypto.PublicKey) { + c.mu.Lock() + defer c.mu.Unlock() + + c.entries[did] = cacheEntry{ + key: key, + expiresAt: time.Now().Add(c.ttl), + } +} diff --git a/pkg/auth/token/claims.go b/pkg/auth/token/claims.go index 66fdc03..08e21d1 100644 --- a/pkg/auth/token/claims.go +++ b/pkg/auth/token/claims.go @@ -9,8 +9,9 @@ import ( // Auth method constants const ( - AuthMethodOAuth = "oauth" - AuthMethodAppPassword = "app_password" + AuthMethodOAuth = "oauth" + AuthMethodAppPassword = "app_password" + AuthMethodServiceToken = "service_token" ) // Claims represents the JWT claims for registry authentication diff --git a/pkg/auth/token/handler.go b/pkg/auth/token/handler.go index f3d1c75..f803180 100644 --- a/pkg/auth/token/handler.go +++ b/pkg/auth/token/handler.go @@ -12,6 +12,7 @@ import ( "atcr.io/pkg/appview/db" "atcr.io/pkg/atproto" "atcr.io/pkg/auth" + "atcr.io/pkg/auth/serviceauth" ) // PostAuthCallback is called after successful Basic Auth authentication. @@ -31,11 +32,12 @@ type OAuthSessionValidator interface { // Handler handles /auth/token requests type Handler struct { - issuer *Issuer - validator *auth.SessionValidator - deviceStore *db.DeviceStore // For validating device secrets - postAuthCallback PostAuthCallback + issuer *Issuer + validator *auth.SessionValidator + deviceStore *db.DeviceStore // For validating device secrets + postAuthCallback PostAuthCallback oauthSessionValidator OAuthSessionValidator + serviceTokenValidator *serviceauth.Validator // For CI service token authentication } // NewHandler creates a new token handler @@ -60,6 +62,13 @@ func (h *Handler) SetOAuthSessionValidator(validator OAuthSessionValidator) { h.oauthSessionValidator = validator } +// SetServiceTokenValidator sets the service token validator for CI authentication +// When set, the handler will accept Bearer tokens with service tokens from CI platforms +// serviceDID is the DID of this service (e.g., "did:web:atcr.io") +func (h *Handler) SetServiceTokenValidator(serviceDID string) { + h.serviceTokenValidator = serviceauth.NewValidator(serviceDID) +} + // TokenResponse represents the response from /auth/token type TokenResponse struct { Token string `json:"token,omitempty"` // Legacy field @@ -132,16 +141,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Extract Basic auth credentials - username, password, ok := r.BasicAuth() - if !ok { - slog.Debug("No Basic auth credentials provided") - sendAuthError(w, r, "authentication required") - return - } - - slog.Debug("Got Basic auth credentials", "username", username, "passwordLength", len(password)) - // Parse query parameters _ = r.URL.Query().Get("service") // service parameter - validated by issuer scopeParam := r.URL.Query().Get("scope") @@ -163,6 +162,50 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { var accessToken string var authMethod string + // Check for Bearer token authentication (CI service tokens) + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") && h.serviceTokenValidator != nil { + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + + slog.Debug("Processing service token authentication") + + validatedUser, err := h.serviceTokenValidator.Validate(r.Context(), tokenString) + if err != nil { + slog.Debug("Service token validation failed", "error", err) + http.Error(w, fmt.Sprintf("service token authentication failed: %v", err), http.StatusUnauthorized) + return + } + + did = validatedUser.DID + authMethod = AuthMethodServiceToken + + slog.Debug("Service token validated successfully", "did", did) + + // Resolve handle from DID for access validation + resolvedDID, resolvedHandle, _, resolveErr := atproto.ResolveIdentity(r.Context(), did) + if resolveErr != nil { + slog.Warn("Failed to resolve handle for service token user", "did", did, "error", resolveErr) + // Use empty handle - access validation will use DID + } else { + did = resolvedDID // Use canonical DID from resolution + handle = resolvedHandle + } + + // Service token auth - issue token and return + h.issueToken(w, r, did, handle, access, authMethod) + return + } + + // Extract Basic auth credentials + username, password, ok := r.BasicAuth() + if !ok { + slog.Debug("No Basic auth credentials provided") + sendAuthError(w, r, "authentication required") + return + } + + slog.Debug("Got Basic auth credentials", "username", username, "passwordLength", len(password)) + // 1. Check if it's a device secret (starts with "atcr_device_") if strings.HasPrefix(password, "atcr_device_") { device, err := h.deviceStore.ValidateDeviceSecret(password) @@ -227,6 +270,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } + // Issue token using common helper + h.issueToken(w, r, did, handle, access, authMethod) +} + +// issueToken validates access and issues a JWT token +// This is the common code path for all authentication methods +func (h *Handler) issueToken(w http.ResponseWriter, r *http.Request, did, handle string, access []auth.AccessEntry, authMethod string) { // Validate that the user has permission for the requested access // Use the actual handle from the validated credentials, not the Basic Auth username if err := auth.ValidateAccess(did, handle, access); err != nil { diff --git a/pkg/hold/pds/auth.go b/pkg/hold/pds/auth.go index 49551e7..c8d0bc0 100644 --- a/pkg/hold/pds/auth.go +++ b/pkg/hold/pds/auth.go @@ -13,6 +13,7 @@ import ( "time" "atcr.io/pkg/atproto" + "atcr.io/pkg/auth/proxy" "github.com/bluesky-social/indigo/atproto/atcrypto" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/golang-jwt/jwt/v5" @@ -258,33 +259,54 @@ func ValidateOwnerOrCrewAdmin(r *http.Request, pds *HoldPDS, httpClient HTTPClie // 2. DPoP + OAuth tokens - for direct user access // The httpClient parameter is optional and defaults to http.DefaultClient if nil. func ValidateBlobWriteAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) { - // Try service token validation first (for AppView access) - authHeader := r.Header.Get("Authorization") - var user *ValidatedUser - var err error - - if strings.HasPrefix(authHeader, "Bearer ") { - // Service token authentication - user, err = ValidateServiceToken(r, pds.did, httpClient) - if err != nil { - return nil, fmt.Errorf("service token authentication failed: %w", err) - } - } else if strings.HasPrefix(authHeader, "DPoP ") { - // DPoP + OAuth authentication (direct user access) - user, err = ValidateDPoPRequest(r, httpClient) - if err != nil { - return nil, fmt.Errorf("DPoP authentication failed: %w", err) - } - } else { - return nil, fmt.Errorf("missing or invalid Authorization header (expected Bearer or DPoP)") - } - - // Get captain record to check owner and public settings + // Get captain record first - needed for proxy validation and crew check _, captain, err := pds.GetCaptainRecord(r.Context()) if err != nil { return nil, fmt.Errorf("failed to get captain record: %w", err) } + authHeader := r.Header.Get("Authorization") + var user *ValidatedUser + + if strings.HasPrefix(authHeader, "Bearer ") { + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + + // Try proxy assertion first if we have trusted proxies configured + if len(captain.TrustedProxies) > 0 { + validator := proxy.NewValidator(captain.TrustedProxies) + proxyUser, proxyErr := validator.ValidateAssertion(r.Context(), tokenString, pds.did) + if proxyErr == nil { + // Proxy assertion validated successfully + slog.Debug("Validated proxy assertion", "userDID", proxyUser.DID, "proxyDID", proxyUser.ProxyDID) + user = &ValidatedUser{ + DID: proxyUser.DID, + Authorized: true, + } + } else if !strings.Contains(proxyErr.Error(), "not in trustedProxies") { + // Log non-trust errors for debugging + slog.Debug("Proxy assertion validation failed, trying service token", "error", proxyErr) + } + } + + // Fall back to service token if proxy assertion didn't work + if user == nil { + var serviceErr error + user, serviceErr = ValidateServiceToken(r, pds.did, httpClient) + if serviceErr != nil { + return nil, fmt.Errorf("bearer token authentication failed: %w", serviceErr) + } + } + } else if strings.HasPrefix(authHeader, "DPoP ") { + // DPoP + OAuth authentication (direct user access) + var dpopErr error + user, dpopErr = ValidateDPoPRequest(r, httpClient) + if dpopErr != nil { + return nil, fmt.Errorf("DPoP authentication failed: %w", dpopErr) + } + } else { + return nil, fmt.Errorf("missing or invalid Authorization header (expected Bearer or DPoP)") + } + // Check if user is the owner (always has write access) if user.DID == captain.Owner { return user, nil diff --git a/pkg/hold/pds/did.go b/pkg/hold/pds/did.go index ab32edf..2257806 100644 --- a/pkg/hold/pds/did.go +++ b/pkg/hold/pds/did.go @@ -1,99 +1,29 @@ package pds import ( - "encoding/json" "fmt" - "net/url" + + "atcr.io/pkg/atproto/did" ) -// DIDDocument represents a did:web document -type DIDDocument struct { - Context []string `json:"@context"` - ID string `json:"id"` - AlsoKnownAs []string `json:"alsoKnownAs,omitempty"` - VerificationMethod []VerificationMethod `json:"verificationMethod"` - Authentication []string `json:"authentication,omitempty"` - AssertionMethod []string `json:"assertionMethod,omitempty"` - Service []Service `json:"service,omitempty"` -} +// Type aliases for backward compatibility - code using pds.DIDDocument etc. still works +type DIDDocument = did.DIDDocument +type VerificationMethod = did.VerificationMethod +type Service = did.Service -// VerificationMethod represents a public key in a DID document -type VerificationMethod struct { - ID string `json:"id"` - Type string `json:"type"` - Controller string `json:"controller"` - PublicKeyMultibase string `json:"publicKeyMultibase"` -} +// GenerateDIDFromURL creates a did:web identifier from a public URL +// Delegates to shared package +var GenerateDIDFromURL = did.GenerateDIDFromURL -// Service represents a service endpoint in a DID document -type Service struct { - ID string `json:"id"` - Type string `json:"type"` - ServiceEndpoint string `json:"serviceEndpoint"` -} - -// GenerateDIDDocument creates a DID document for a did:web identity +// GenerateDIDDocument creates a DID document for the hold's did:web identity func (p *HoldPDS) GenerateDIDDocument(publicURL string) (*DIDDocument, error) { - // Parse URL to extract host and port - u, err := url.Parse(publicURL) - if err != nil { - return nil, fmt.Errorf("failed to parse public URL: %w", err) - } - - hostname := u.Hostname() - port := u.Port() - - // Build host string (include non-standard ports per did:web spec) - host := hostname - if port != "" && port != "80" && port != "443" { - host = fmt.Sprintf("%s:%s", hostname, port) - } - - did := fmt.Sprintf("did:web:%s", host) - - // Get public key in multibase format using indigo's crypto pubKey, err := p.signingKey.PublicKey() if err != nil { return nil, fmt.Errorf("failed to get public key: %w", err) } - publicKeyMultibase := pubKey.Multibase() - doc := &DIDDocument{ - Context: []string{ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/multikey/v1", - "https://w3id.org/security/suites/secp256k1-2019/v1", - }, - ID: did, - AlsoKnownAs: []string{ - fmt.Sprintf("at://%s", host), - }, - VerificationMethod: []VerificationMethod{ - { - ID: fmt.Sprintf("%s#atproto", did), - Type: "Multikey", - Controller: did, - PublicKeyMultibase: publicKeyMultibase, - }, - }, - Authentication: []string{ - fmt.Sprintf("%s#atproto", did), - }, - Service: []Service{ - { - ID: "#atproto_pds", - Type: "AtprotoPersonalDataServer", - ServiceEndpoint: publicURL, - }, - { - ID: "#atcr_hold", - Type: "AtcrHoldService", - ServiceEndpoint: publicURL, - }, - }, - } - - return doc, nil + services := did.DefaultHoldServices(publicURL) + return did.GenerateDIDDocument(publicURL, pubKey, services) } // MarshalDIDDocument converts a DID document to JSON using the stored public URL @@ -103,33 +33,5 @@ func (p *HoldPDS) MarshalDIDDocument() ([]byte, error) { return nil, err } - return json.MarshalIndent(doc, "", " ") -} - -// GenerateDIDFromURL creates a did:web identifier from a public URL -// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com:8080" -// Note: Per did:web spec, non-standard ports (not 80/443) are included in the DID -func GenerateDIDFromURL(publicURL string) string { - // Parse URL - u, err := url.Parse(publicURL) - if err != nil { - // Fallback: assume it's just a hostname - return fmt.Sprintf("did:web:%s", publicURL) - } - - // Get hostname - hostname := u.Hostname() - if hostname == "" { - hostname = "localhost" - } - - // Get port - port := u.Port() - - // Include port in DID if it's non-standard (not 80 for http, not 443 for https) - if port != "" && port != "80" && port != "443" { - return fmt.Sprintf("did:web:%s:%s", hostname, port) - } - - return fmt.Sprintf("did:web:%s", hostname) + return did.MarshalDIDDocument(doc) }