mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-03 00:36:56 +00:00
a7569a7added credential-less pulls of public images. Three things about it were wrong, all of them in how the appview handled the decision that belongs to the hold. **Scope handling was all-or-nothing.** IsPullOnlyScope required every requested action to already be "pull", but clients routinely ask for more than the operation needs — pull,push is common for a plain read, and some ask for pull,push,delete up front. Those were rejected and challenged, leaving a credential-less client no way to pull even a public image, which is the entire feature. NarrowToPullOnly drops the write actions and issues a token carrying "pull" and nothing else. Granting a subset is what the distribution token spec expects. The allowlist property is preserved: "pull" is the only action that survives, and "*" is deliberately not expanded into it, since a wildcard request is not evidence the caller wants a read. **The appview-side read gate was inert.** checkReadAccess passed p.ctx.DID, the DID of the repository *owner*, not the requester. Any non-empty DID satisfies a private hold's check, and the owner's is never empty, so it asked "may the owner read their own hold", answered yes, and admitted everyone. Worse, CheckReadAccessWithCaptain admitted any authenticated DID to a private hold at all, on an explicitly-MVP assumption that holding a DID was close enough to being a sailor. Every doc says otherwise (docs/hold.md:109 "Crew with blob:read", CLAUDE.md:140, docs/BYOS.md:280) and so does the hold (ValidateBlobReadAccess: owner, or crew carrying blob:read/blob:write). It now takes isCrew and requires owner-or-crew, and callers only pay for the crew lookup when it can change the answer — a public hold or an anonymous caller is decided by the captain record alone. Nothing here loosens access; it brings the local gate into agreement with the authority. **Denials could not reach the client.** distribution's blobHandler.GetBlob maps everything except ErrBlobUnknown to ErrorCodeUnknown, so a 401 raised in the blob store left as a 500 — misreporting an auth failure as a server fault, and giving BearerChallenge no 401 to attach WWW-Authenticate to, so Docker was told "server error" instead of being prompted for credentials. Clients that retry 5xx looped: 4.1s per case in the matrix, now 0.01s. The check moves to Repository(), where an errcode.Error is passed through verbatim by the registry app — the same mechanisma7569a7used for NAME_UNKNOWN. It fails open on a lookup error, since the hold is the authority and a transient failure should not break public pulls. Removes auth.allow_anonymous_pull. It could only ever withhold — captain.Public is what grants — so it was a second flag for a decision the hold already owns, and gating it appview-side was never the intent. Layer bytes 307 straight to S3, so the appview is not even in the path whose cost might have justified an operator-side lever. Tests: TestAuthMatrix only ever ran against a public hold, and its pull cases never fetched a layer — crane.Pull is lazy and img.Digest() needs only the manifest, which ATCR serves from the user's PDS where it is world-readable, so no pull row in the matrix touched blob authorization at all. Pulls now materialize layer bytes, and testharness.WithPrivateHold plus TestAuthMatrixPrivateHold cover public:false + allow_all_crew:true — the production shape, where anyone with an account pulls and pushes and anonymous gets nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
178 lines
6.4 KiB
Go
178 lines
6.4 KiB
Go
// Package token provides JWT claims and token handling for registry authentication.
|
|
package token
|
|
|
|
import (
|
|
"slices"
|
|
"time"
|
|
|
|
"atcr.io/pkg/auth"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
// Auth method constants
|
|
const (
|
|
AuthMethodOAuth = "oauth"
|
|
AuthMethodAppPassword = "app_password"
|
|
// AuthMethodAnonymous marks a token issued without credentials for a
|
|
// pull-only scope. Such tokens carry an empty Subject (no puller DID); the
|
|
// destination hold decides whether anonymous reads are allowed (captain.Public).
|
|
AuthMethodAnonymous = "anonymous"
|
|
)
|
|
|
|
// Claims represents the JWT claims for registry authentication
|
|
// This follows the Docker Registry token specification
|
|
type Claims struct {
|
|
jwt.RegisteredClaims
|
|
Access []auth.AccessEntry `json:"access,omitempty"`
|
|
AuthMethod string `json:"auth_method,omitempty"` // "oauth" or "app_password"
|
|
}
|
|
|
|
// NewClaims creates a new Claims structure with standard fields
|
|
func NewClaims(subject, issuer, audience string, expiration time.Duration, access []auth.AccessEntry, authMethod string) *Claims {
|
|
now := time.Now()
|
|
return &Claims{
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Subject: subject, // User's DID
|
|
Issuer: issuer, // "atcr.io"
|
|
Audience: jwt.ClaimStrings{audience}, // Service name
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
NotBefore: jwt.NewNumericDate(now),
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(expiration)),
|
|
},
|
|
Access: access,
|
|
AuthMethod: authMethod, // "oauth" or "app_password"
|
|
}
|
|
}
|
|
|
|
// ExtractAuthMethod parses a JWT token string and extracts the auth_method claim
|
|
// Returns the auth method or empty string if not found or token is invalid
|
|
// This does NOT validate the token - it only parses it to extract the claim
|
|
func ExtractAuthMethod(tokenString string) string {
|
|
// Parse token without validation (we only need the claims, validation is done by distribution library)
|
|
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
|
|
token, _, err := parser.ParseUnverified(tokenString, &Claims{})
|
|
if err != nil {
|
|
return "" // Invalid token format
|
|
}
|
|
|
|
claims, ok := token.Claims.(*Claims)
|
|
if !ok {
|
|
return "" // Wrong claims type
|
|
}
|
|
|
|
return claims.AuthMethod
|
|
}
|
|
|
|
// ExtractAccess parses a JWT token string and extracts the access entries (scopes)
|
|
// Returns nil if not found or token is invalid
|
|
// This does NOT validate the token - it only parses it to extract the claim
|
|
func ExtractAccess(tokenString string) []auth.AccessEntry {
|
|
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
|
|
token, _, err := parser.ParseUnverified(tokenString, &Claims{})
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
claims, ok := token.Claims.(*Claims)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
return claims.Access
|
|
}
|
|
|
|
// HasPushScope checks if any access entry contains a "push" action
|
|
func HasPushScope(access []auth.AccessEntry) bool {
|
|
for _, entry := range access {
|
|
if slices.Contains(entry.Actions, "push") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsPullOnlyScope reports whether the requested access is safe to grant without
|
|
// credentials. This is the gate for anonymous token issuance, so it allowlists:
|
|
// every requested action must be exactly "pull". Empty access (the /v2/ ping)
|
|
// and an entry with no actions are both fine.
|
|
//
|
|
// It must not be written as a denylist of "push"/"delete". Distribution's
|
|
// actionSet.contains returns true for ANY action when the set holds "*"
|
|
// (registry/auth/token/util.go), so a scope of `repository:victim/img:*` names
|
|
// neither string yet authorizes push and delete. Denylisting would hand an
|
|
// unauthenticated caller a token that clears the whole appview authorization
|
|
// layer — the authgate is deliberately skipped for anonymous tokens.
|
|
func IsPullOnlyScope(access []auth.AccessEntry) bool {
|
|
for _, entry := range access {
|
|
for _, action := range entry.Actions {
|
|
if action != "pull" {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// NarrowToPullOnly returns a copy of access holding only the "pull" action, and
|
|
// reports whether the result is worth issuing a token for.
|
|
//
|
|
// Clients routinely request more than they need for the operation in hand —
|
|
// `repository:x:pull,push` for a plain pull is common, and some request
|
|
// pull,push,delete up front — so an all-or-nothing IsPullOnlyScope test rejects
|
|
// the request, challenges, and leaves a credential-less client with no way
|
|
// forward even for a public image. Granting a subset is the behavior the
|
|
// distribution token spec expects: the server issues what it is willing to
|
|
// authorize and the client proceeds with that.
|
|
//
|
|
// The allowlist property from IsPullOnlyScope is preserved exactly: "pull" is
|
|
// the only action that survives, so nothing here can emit a token carrying
|
|
// push, delete, or "*". An entry left with no actions is dropped rather than
|
|
// emitted empty, since distribution treats an empty action set as granting
|
|
// nothing and it only adds noise to the token.
|
|
//
|
|
// "*" is deliberately NOT expanded into "pull". Rewriting it would be safe in
|
|
// the narrow sense (the issued token would name only "pull"), but a wildcard
|
|
// request is not evidence the caller wants a read, and refusing it keeps the
|
|
// anonymous path free of any case where a wildcard turns into a grant.
|
|
func NarrowToPullOnly(access []auth.AccessEntry) ([]auth.AccessEntry, bool) {
|
|
narrowed := make([]auth.AccessEntry, 0, len(access))
|
|
grantable := false
|
|
|
|
for _, entry := range access {
|
|
if len(entry.Actions) == 0 {
|
|
// No actions requested (the /v2/ ping shape). Preserve it as-is:
|
|
// it grants nothing and callers rely on the entry surviving.
|
|
narrowed = append(narrowed, entry)
|
|
continue
|
|
}
|
|
if !slices.Contains(entry.Actions, "pull") {
|
|
continue
|
|
}
|
|
pullOnly := entry
|
|
pullOnly.Actions = []string{"pull"}
|
|
narrowed = append(narrowed, pullOnly)
|
|
grantable = true
|
|
}
|
|
|
|
return narrowed, grantable || len(access) == 0
|
|
}
|
|
|
|
// ExtractSubject parses a JWT token string and extracts the Subject claim (the user's DID)
|
|
// Returns the subject or empty string if not found or token is invalid
|
|
// This does NOT validate the token - it only parses it to extract the claim
|
|
func ExtractSubject(tokenString string) string {
|
|
// Parse token without validation (we only need the claims, validation is done by distribution library)
|
|
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
|
|
token, _, err := parser.ParseUnverified(tokenString, &Claims{})
|
|
if err != nil {
|
|
return "" // Invalid token format
|
|
}
|
|
|
|
claims, ok := token.Claims.(*Claims)
|
|
if !ok {
|
|
return "" // Wrong claims type
|
|
}
|
|
|
|
return claims.Subject
|
|
}
|