1 Commits

Author SHA1 Message Date
Evan Jarrett
89f4641a2a trusted platform poc 2025-12-18 23:23:38 -06:00
13 changed files with 964 additions and 172 deletions

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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,

View File

@@ -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

View File

@@ -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)
}

145
pkg/atproto/did/document.go Normal file
View File

@@ -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,
},
}
}

View File

@@ -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

322
pkg/auth/proxy/assertion.go Normal file
View File

@@ -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))
}

View File

@@ -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),
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)
}