mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-03 08:46:57 +00:00
An AppView can front several registry domains that all reach the same backend (seamark.dev serving buoy.cr, seamark.cr, and soon atcr.io). Distribution's token access controller holds `service` as a single string and uses it twice: as the value advertised in the WWW-Authenticate challenge, and as the sole accepted JWT audience. So it announced one domain's name on every domain, and honoured one domain's tokens everywhere. A push to seamark.cr was challenged with service="buoy.cr". Both uses sit inside Authorized, which already has the request, but the value is fixed at construction and reachable through no hook — autoredirect only templates the realm. So register an "atcr-token" controller that builds one upstream controller per domain and dispatches on r.Host. Each front door now advertises its own name and demands its own audience. All signature, certificate and claim verification stays in upstream code; this only routes. The token handler stops discarding ?service= and stamps the audience with the front door the client used, allowlist-checked against the configured domains so the value stays server-determined despite arriving from the client. It has to come from the query param because the realm lives on the UI host, where r.Host names no registry domain. This is token hygiene and spec conformance, not a privilege boundary: every domain fronts the same backend, so a client can obtain a token for any of them just by handshaking there. What it buys is a truthful challenge and the decoupling needed to later split a domain onto its own AppView. Also unify the domain list. DomainRoutingMiddleware keyed its map on the raw config while matching a port-stripped host, so a domain configured with a port could never match its own requests. It now shares the normalized cfg.Auth.Services, so routing and authorization agree on one set of names. cfg.Auth.ServiceName was an exact alias for Services[0] and is replaced by PrimaryService(), which also removes an empty-slice index. Rollout: the audience for seamark.cr and bouy.cr changes, so a token minted just before the restart draws one 401 and Docker re-handshakes into a valid one. buoy.cr is unchanged (it stays primary), and atcr.io keeps the service name it already has today. The challenge and the accepted audience come from the same delegate, so the retry converges by construction. Deploy as a single flip, not a canary: an old instance ignores ?service= and would keep minting the primary audience while a new one rejects it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
240 lines
7.4 KiB
Go
240 lines
7.4 KiB
Go
package token
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/big"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"atcr.io/pkg/auth"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
// Issuer handles JWT token creation and signing
|
|
type Issuer struct {
|
|
privateKey *rsa.PrivateKey
|
|
publicKey *rsa.PublicKey
|
|
certificate []byte // DER-encoded certificate
|
|
issuer string
|
|
service string
|
|
expiration time.Duration
|
|
}
|
|
|
|
// NewIssuer creates a new JWT issuer
|
|
func NewIssuer(privateKeyPath, issuer, service string, expiration time.Duration) (*Issuer, error) {
|
|
privateKey, err := loadOrGenerateKey(privateKeyPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load private key: %w", err)
|
|
}
|
|
|
|
// Load the certificate for x5c header
|
|
certPath := strings.TrimSuffix(privateKeyPath, ".pem") + ".crt"
|
|
certPEM, err := os.ReadFile(certPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read certificate: %w", err)
|
|
}
|
|
|
|
// Parse PEM to get DER-encoded certificate
|
|
block, _ := pem.Decode(certPEM)
|
|
if block == nil || block.Type != "CERTIFICATE" {
|
|
return nil, fmt.Errorf("failed to decode certificate PEM")
|
|
}
|
|
|
|
return &Issuer{
|
|
privateKey: privateKey,
|
|
publicKey: &privateKey.PublicKey,
|
|
certificate: block.Bytes, // DER-encoded certificate
|
|
issuer: issuer,
|
|
service: service,
|
|
expiration: expiration,
|
|
}, nil
|
|
}
|
|
|
|
// NewIssuerFromKey creates a JWT issuer from pre-loaded key material.
|
|
// certDER is the DER-encoded X.509 certificate for the x5c JWT header.
|
|
func NewIssuerFromKey(privateKey *rsa.PrivateKey, certDER []byte, issuer, service string, expiration time.Duration) *Issuer {
|
|
return &Issuer{
|
|
privateKey: privateKey,
|
|
publicKey: &privateKey.PublicKey,
|
|
certificate: certDER,
|
|
issuer: issuer,
|
|
service: service,
|
|
expiration: expiration,
|
|
}
|
|
}
|
|
|
|
// Issue creates and signs a new JWT token using the issuer's configured
|
|
// expiration and service.
|
|
func (i *Issuer) Issue(subject string, access []auth.AccessEntry, authMethod string) (string, error) {
|
|
return i.IssueWithExpiration(subject, access, authMethod, i.expiration, i.service)
|
|
}
|
|
|
|
// IssueWithExpiration creates and signs a JWT with a per-call expiration and
|
|
// audience.
|
|
//
|
|
// The expiration is per-call because the JWT's lifetime is bound to a
|
|
// downstream credential that can expire sooner than the issuer's default — the
|
|
// AppView↔hold service-auth, where the cache applies a 10s safety margin
|
|
// against the PDS-granted exp.
|
|
//
|
|
// The audience is per-call because an AppView can front several registry
|
|
// domains (server.registry_domains), and the Docker token spec makes `service`
|
|
// the name of the registry the client is authenticating against. Stamping the
|
|
// front door the client actually used lets the matching access controller
|
|
// (pkg/appview/registryauth) demand its own domain's audience. Note this is
|
|
// scoping, not a privilege boundary: the same client can obtain a token for any
|
|
// configured domain just by handshaking there.
|
|
//
|
|
// An empty service falls back to the issuer's default. The caller is
|
|
// responsible for validating a non-empty service against the configured
|
|
// registry domains, since the value ultimately derives from client input.
|
|
func (i *Issuer) IssueWithExpiration(subject string, access []auth.AccessEntry, authMethod string, expiration time.Duration, service string) (string, error) {
|
|
if service == "" {
|
|
service = i.service
|
|
}
|
|
claims := NewClaims(subject, i.issuer, service, expiration, access, authMethod)
|
|
|
|
slog.Debug("Creating JWT token",
|
|
"issuer", i.issuer,
|
|
"service", service,
|
|
"subject", subject,
|
|
"access", access,
|
|
"expiration", expiration)
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
|
|
|
// Add x5c header - embeds the certificate chain in the JWT
|
|
// This is base64-encoded DER certificate(s)
|
|
certChain := []string{
|
|
base64.StdEncoding.EncodeToString(i.certificate),
|
|
}
|
|
token.Header["x5c"] = certChain
|
|
|
|
signedToken, err := token.SignedString(i.privateKey)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to sign token: %w", err)
|
|
}
|
|
|
|
slog.Debug("Successfully signed token with x5c header")
|
|
|
|
return signedToken, nil
|
|
}
|
|
|
|
// PublicKey returns the public key for token verification
|
|
func (i *Issuer) PublicKey() *rsa.PublicKey {
|
|
return i.publicKey
|
|
}
|
|
|
|
// Expiration returns the token expiration duration
|
|
func (i *Issuer) Expiration() time.Duration {
|
|
return i.expiration
|
|
}
|
|
|
|
// loadOrGenerateKey loads an existing RSA private key or generates a new one
|
|
func loadOrGenerateKey(path string) (*rsa.PrivateKey, error) {
|
|
// Try to load existing key
|
|
if _, err := os.Stat(path); err == nil {
|
|
keyData, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read key file: %w", err)
|
|
}
|
|
|
|
block, _ := pem.Decode(keyData)
|
|
if block == nil || block.Type != "RSA PRIVATE KEY" {
|
|
return nil, fmt.Errorf("failed to decode PEM block containing private key")
|
|
}
|
|
|
|
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
|
}
|
|
|
|
// Ensure certificate exists
|
|
certPath := strings.TrimSuffix(path, ".pem") + ".crt"
|
|
if _, err := os.Stat(certPath); os.IsNotExist(err) {
|
|
// Certificate doesn't exist, generate it
|
|
if err := generateCertificate(privateKey, certPath); err != nil {
|
|
return nil, fmt.Errorf("failed to generate certificate: %w", err)
|
|
}
|
|
}
|
|
|
|
return privateKey, nil
|
|
}
|
|
|
|
// Generate new key
|
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate private key: %w", err)
|
|
}
|
|
|
|
// Ensure directory exists
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return nil, fmt.Errorf("failed to create key directory: %w", err)
|
|
}
|
|
|
|
// Save key to file
|
|
keyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: keyBytes,
|
|
})
|
|
|
|
if err := os.WriteFile(path, keyPEM, 0600); err != nil {
|
|
return nil, fmt.Errorf("failed to write private key: %w", err)
|
|
}
|
|
|
|
// Also generate a self-signed certificate for the public key
|
|
certPath := strings.TrimSuffix(path, ".pem") + ".crt"
|
|
if err := generateCertificate(privateKey, certPath); err != nil {
|
|
return nil, fmt.Errorf("failed to generate certificate: %w", err)
|
|
}
|
|
|
|
return privateKey, nil
|
|
}
|
|
|
|
// generateCertificate creates a self-signed certificate for JWT validation
|
|
func generateCertificate(privateKey *rsa.PrivateKey, certPath string) error {
|
|
// Create certificate template
|
|
template := x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{
|
|
Organization: []string{"ATCR"},
|
|
CommonName: "ATCR Token Signing Certificate",
|
|
},
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), // 10 years
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
BasicConstraintsValid: true,
|
|
}
|
|
|
|
// Create self-signed certificate
|
|
certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create certificate: %w", err)
|
|
}
|
|
|
|
// Encode certificate to PEM
|
|
certPEM := pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: certBytes,
|
|
})
|
|
|
|
// Write certificate to file
|
|
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
|
|
return fmt.Errorf("failed to write certificate: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|