osbase/oci/registry: add package

This adds the registry package, which contains a client and server
implementation of the OCI Distribution spec.

Change-Id: I080bb1dbc511f8e6466ca370b090d459d2b730e8
Reviewed-on: https://review.monogon.dev/c/monogon/+/4086
Tested-by: Jenkins CI
Reviewed-by: Tim Windelschmidt <tim@monogon.tech>
diff --git a/osbase/oci/registry/BUILD.bazel b/osbase/oci/registry/BUILD.bazel
new file mode 100644
index 0000000..ca2d806
--- /dev/null
+++ b/osbase/oci/registry/BUILD.bazel
@@ -0,0 +1,25 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+    name = "registry",
+    srcs = [
+        "auth.go",
+        "client.go",
+        "headers.go",
+        "server.go",
+    ],
+    importpath = "source.monogon.dev/osbase/oci/registry",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//osbase/oci",
+        "//osbase/structfs",
+        "@com_github_cenkalti_backoff_v4//:backoff",
+        "@com_github_opencontainers_image_spec//specs-go/v1:specs-go",
+    ],
+)
+
+go_test(
+    name = "registry_test",
+    srcs = ["headers_test.go"],
+    embed = [":registry"],
+)
diff --git a/osbase/oci/registry/auth.go b/osbase/oci/registry/auth.go
new file mode 100644
index 0000000..a1e98aa
--- /dev/null
+++ b/osbase/oci/registry/auth.go
@@ -0,0 +1,113 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package registry
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"github.com/cenkalti/backoff/v4"
+)
+
+type tokenBody struct {
+	Token       string `json:"token"`
+	AccessToken string `json:"access_token"`
+}
+
+// handleUnauthorized implements token authentication based on this
+// specification: https://distribution.github.io/distribution/spec/auth/token/
+//
+// The registry will return Unauthorized if a token is required and it is
+// missing or invalid (e.g. expired). We then need to ask the authorization
+// service for a token, and retry the original request with the new token.
+//
+// Some registries (e.g. Docker Hub and ghcr.io) require a token even for public
+// repositories. In this case, the authorization service returns tokens without
+// requiring any credentials.
+func (c *Client) handleUnauthorized(ctx context.Context, resp *http.Response) (retry bool, err error) {
+	// Check if we have a Bearer challenge.
+	challenges := parseAuthenticateHeader(resp.Header.Values("Www-Authenticate"))
+	var params map[string]string
+	for _, c := range challenges {
+		if strings.EqualFold(c.scheme, "bearer") {
+			params = c.params
+			break
+		}
+	}
+	realm := params["realm"]
+	if realm == "" {
+		// There is no challenge, return the original HTTP error.
+		return false, nil
+	}
+
+	// Construct token URL.
+	tokenURL, err := url.Parse(realm)
+	if err != nil {
+		return false, backoff.Permanent(fmt.Errorf("failed to parse realm: %w", err))
+	}
+	query := tokenURL.Query()
+	service := params["service"]
+	if service != "" {
+		query.Set("service", service)
+	}
+	for scope := range strings.SplitSeq(params["scope"], " ") {
+		if scope != "" {
+			query.Add("scope", scope)
+		}
+	}
+	tokenURL.RawQuery = query.Encode()
+
+	// Do token request.
+	req, err := http.NewRequestWithContext(ctx, "GET", tokenURL.String(), nil)
+	if err != nil {
+		return false, err
+	}
+	if c.UserAgent != "" {
+		req.Header.Set("User-Agent", c.UserAgent)
+	}
+	client := http.Client{Transport: c.Transport}
+	tokenResp, err := client.Do(req)
+	if err != nil {
+		return false, redactURLError(err)
+	}
+	if tokenResp.StatusCode != http.StatusOK {
+		return false, readClientError(tokenResp, req)
+	}
+	defer tokenResp.Body.Close()
+
+	// Parse token response.
+	bodyBytes, err := readFullBody(tokenResp, 1024*1024)
+	if err != nil {
+		return false, err
+	}
+	body := tokenBody{}
+	err = json.Unmarshal(bodyBytes, &body)
+	if err != nil {
+		return false, backoff.Permanent(fmt.Errorf("failed to parse token response: %w", err))
+	}
+	token := body.Token
+	if token == "" {
+		token = body.AccessToken
+	}
+	if token == "" {
+		return false, backoff.Permanent(fmt.Errorf("missing token in token response"))
+	}
+
+	c.authMu.Lock()
+	c.bearerToken = token
+	c.authMu.Unlock()
+	return true, nil
+}
+
+func (c *Client) addAuthorization(req *http.Request) {
+	c.authMu.RLock()
+	defer c.authMu.RUnlock()
+	if c.bearerToken != "" {
+		req.Header.Set("Authorization", "Bearer "+c.bearerToken)
+	}
+}
diff --git a/osbase/oci/registry/client.go b/osbase/oci/registry/client.go
new file mode 100644
index 0000000..c414108
--- /dev/null
+++ b/osbase/oci/registry/client.go
@@ -0,0 +1,482 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+// Package registry contains a client and server implementation of the OCI
+// Distribution spec. Both client and server only support pulling. The server is
+// intended for use in tests.
+package registry
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"regexp"
+	"strconv"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/cenkalti/backoff/v4"
+	ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+
+	"source.monogon.dev/osbase/oci"
+)
+
+// Sources for these expressions:
+//
+//   - https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests
+//   - https://github.com/opencontainers/image-spec/blob/main/descriptor.md#digests
+const (
+	repositoryExpr = `[a-z0-9]+(?:(?:\.|_|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:\.|_|__|-+)[a-z0-9]+)*)*`
+	tagExpr        = `[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}`
+	digestExpr     = `[a-z0-9]+(?:[+._-][a-z0-9]+)*:[a-zA-Z0-9=_-]+`
+)
+
+var (
+	repositoryRegexp = regexp.MustCompile(`^` + repositoryExpr + `$`)
+	tagRegexp        = regexp.MustCompile(`^` + tagExpr + `$`)
+	digestRegexp     = regexp.MustCompile(`^` + digestExpr + `$`)
+)
+
+// Client is an OCI registry client.
+type Client struct {
+	// Transport will be used to make requests. For example, this allows
+	// configuring TLS client and CA certificates.
+	// If nil, [http.DefaultTransport] is used.
+	Transport http.RoundTripper
+	// GetBackOff can be set to to make the Client retry HTTP requests.
+	GetBackOff func() backoff.BackOff
+	// RetryNotify receives errors that trigger a retry, e.g. for logging.
+	RetryNotify backoff.Notify
+	// UserAgent is used as the User-Agent HTTP header.
+	UserAgent string
+
+	// Scheme must be either http or https.
+	Scheme string
+	// Host is the host with optional port.
+	Host string
+	// Repository is the name of the repository. It is part of the client because
+	// bearer tokens are usually scoped to a repository.
+	Repository string
+
+	authMu sync.RWMutex
+	// bearerToken is a cached token obtained from an authorization service.
+	bearerToken string
+}
+
+// Read fetches an image manifest from the registry and returns an [oci.Image].
+//
+// The context is used for the manifest request and all blob requests made
+// through the Image.
+//
+// At least one of tag and digest must be set. If only tag is set, then you are
+// trusting the registry to return the right content. Otherwise, the digest is
+// used to verify the manifest. If both tag and digest are set, then the tag is
+// used in the request, and the digest is used to verify the response. The
+// advantage of fetching by tag is that it allows a pull through cache to
+// display tags to a user inspecting the cache contents.
+func (c *Client) Read(ctx context.Context, tag, digest string) (*oci.Image, error) {
+	if !repositoryRegexp.MatchString(c.Repository) {
+		return nil, fmt.Errorf("invalid repository %q", c.Repository)
+	}
+	if tag != "" && !tagRegexp.MatchString(tag) {
+		return nil, fmt.Errorf("invalid tag %q", tag)
+	}
+	if digest != "" {
+		if _, _, err := oci.ParseDigest(digest); err != nil {
+			return nil, err
+		}
+	}
+	var reference string
+	if tag != "" {
+		reference = tag
+	} else if digest != "" {
+		reference = digest
+	} else {
+		return nil, fmt.Errorf("tag and digest cannot both be empty")
+	}
+
+	manifestPath := fmt.Sprintf("/v2/%s/manifests/%s", c.Repository, reference)
+	var imageManifestBytes []byte
+	err := c.retry(ctx, func() error {
+		req, err := c.newGet(manifestPath)
+		if err != nil {
+			return err
+		}
+		req.Header.Set("Accept", ocispecv1.MediaTypeImageManifest)
+		resp, err := c.doGet(ctx, req)
+		if err != nil {
+			return err
+		}
+		if resp.StatusCode != http.StatusOK {
+			return readClientError(resp, req)
+		}
+		defer resp.Body.Close()
+		imageManifestBytes, err = readFullBody(resp, 50*1024*1024)
+		return err
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	blobs := &clientBlobs{
+		ctx:    ctx,
+		client: c,
+	}
+	return oci.NewImage(imageManifestBytes, digest, blobs)
+}
+
+type clientBlobs struct {
+	ctx    context.Context
+	client *Client
+}
+
+func (r *clientBlobs) Blob(descriptor *ocispecv1.Descriptor) (io.ReadCloser, error) {
+	if !digestRegexp.MatchString(string(descriptor.Digest)) {
+		return nil, fmt.Errorf("invalid blob digest %q", descriptor.Digest)
+	}
+	blobPath := fmt.Sprintf("/v2/%s/blobs/%s", r.client.Repository, descriptor.Digest)
+	var resp *http.Response
+	err := r.client.retry(r.ctx, func() error {
+		req, err := r.client.newGet(blobPath)
+		if err != nil {
+			return err
+		}
+		resp, err = r.client.doGet(r.ctx, req)
+		if err != nil {
+			return err
+		}
+		if resp.StatusCode != http.StatusOK {
+			return readClientError(resp, req)
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	if r.client.GetBackOff == nil {
+		return resp.Body, nil
+	}
+	ctx, cancel := context.WithCancelCause(r.ctx)
+	reader := &retryReader{
+		ctx:    ctx,
+		cancel: cancel,
+		client: r.client,
+		path:   blobPath,
+		pos:    0,
+		size:   descriptor.Size,
+	}
+	reader.resp.Store(resp)
+	return reader, nil
+}
+
+type retryReader struct {
+	ctx    context.Context
+	cancel context.CancelCauseFunc
+	client *Client
+	path   string
+	pos    int64
+	size   int64
+	// resp is an atomic pointer because it may be concurrently written by Read()
+	// and read by Close().
+	resp atomic.Pointer[http.Response]
+}
+
+func (r *retryReader) Read(p []byte) (n int, err error) {
+	if r.pos >= r.size {
+		return 0, io.EOF
+	}
+	if len(p) == 0 {
+		return 0, nil
+	}
+	if int64(len(p)) > r.size-r.pos {
+		p = p[:r.size-r.pos]
+	}
+	closed := false
+	err = r.client.retry(r.ctx, func() error {
+		if closed {
+			req, err := r.client.newGet(r.path)
+			if err != nil {
+				return err
+			}
+			if r.pos != 0 {
+				req.Header.Set("Range", fmt.Sprintf("bytes=%d-", r.pos))
+			}
+			resp, err := r.client.doGet(r.ctx, req)
+			if err != nil {
+				return err
+			}
+			r.resp.Store(resp)
+			if err := context.Cause(r.ctx); err != nil {
+				resp.Body.Close()
+				return err
+			}
+			switch resp.StatusCode {
+			case http.StatusOK:
+				_, err := io.CopyN(io.Discard, resp.Body, r.pos)
+				if err != nil {
+					return err
+				}
+			case http.StatusPartialContent:
+				if !strings.HasPrefix(resp.Header.Get("Content-Range"), fmt.Sprintf("bytes %d-", r.pos)) {
+					return backoff.Permanent(errors.New("invalid content range"))
+				}
+			default:
+				return readClientError(resp, req)
+			}
+		}
+		var err error
+		n, err = r.resp.Load().Body.Read(p)
+		if n != 0 {
+			r.pos += int64(n)
+			return nil
+		}
+		if err == nil {
+			err = errors.New("read 0 bytes")
+		}
+		closed = true
+		r.resp.Load().Body.Close()
+		return err
+	})
+	if r.pos >= r.size {
+		err = io.EOF
+	} else if err == io.EOF {
+		err = io.ErrUnexpectedEOF
+	}
+	return
+}
+
+func (r *retryReader) Close() error {
+	r.cancel(errors.New("reader closed"))
+	return r.resp.Load().Body.Close()
+}
+
+func (c *Client) retry(ctx context.Context, o func() error) error {
+	if err := ctx.Err(); err != nil {
+		return err
+	}
+	var b backoff.BackOff
+	for {
+		err := o()
+		if err == nil {
+			return nil
+		}
+		var permanent *backoff.PermanentError
+		if errors.As(err, &permanent) {
+			return err
+		}
+		if ctx.Err() != nil {
+			return err
+		}
+		if b == nil {
+			if c.GetBackOff == nil {
+				return err
+			}
+			b = c.GetBackOff()
+		}
+		next := b.NextBackOff()
+		if next == backoff.Stop {
+			return err
+		}
+		var clientErr *ClientError
+		if errors.As(err, &clientErr) && !clientErr.RetryAfter.IsZero() {
+			next = max(next, time.Until(clientErr.RetryAfter))
+		}
+		deadline, hasDeadline := ctx.Deadline()
+		if hasDeadline && time.Until(deadline) < next {
+			return err
+		}
+
+		if c.RetryNotify != nil {
+			c.RetryNotify(err, next)
+		}
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-time.After(next):
+		}
+	}
+}
+
+func (c *Client) newGet(path string) (*http.Request, error) {
+	u := url.URL{
+		Scheme: c.Scheme,
+		Host:   c.Host,
+		Path:   path,
+	}
+	req, err := http.NewRequest("GET", u.String(), nil)
+	if err != nil {
+		return nil, err
+	}
+	if c.UserAgent != "" {
+		req.Header.Set("User-Agent", c.UserAgent)
+	}
+	return req, nil
+}
+
+func (c *Client) doGet(ctx context.Context, req *http.Request) (*http.Response, error) {
+	req = req.WithContext(ctx)
+	c.addAuthorization(req)
+	client := http.Client{Transport: c.Transport}
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, redactURLError(err)
+	}
+
+	if resp.StatusCode == http.StatusUnauthorized {
+		unauthorizedErr := readClientError(resp, req)
+		retry, err := c.handleUnauthorized(ctx, resp)
+		if err != nil {
+			return nil, err
+		}
+		if !retry {
+			return nil, unauthorizedErr
+		}
+		c.addAuthorization(req)
+		resp, err = client.Do(req)
+		if err != nil {
+			return nil, redactURLError(err)
+		}
+	}
+
+	return resp, nil
+}
+
+func readClientError(resp *http.Response, req *http.Request) error {
+	defer resp.Body.Close()
+	clientErr := &ClientError{
+		StatusCode: resp.StatusCode,
+	}
+	retryAfter := resp.Header.Get("Retry-After")
+	if retryAfter != "" {
+		seconds, err := strconv.ParseInt(retryAfter, 10, 64)
+		if err == nil {
+			clientErr.RetryAfter = time.Now().Add(time.Duration(seconds) * time.Second)
+		} else {
+			clientErr.RetryAfter, _ = http.ParseTime(retryAfter)
+		}
+	}
+	content, err := readFullBody(resp, 2048)
+	if err == nil {
+		clientErr.RawBody = content
+		_ = json.Unmarshal(content, &clientErr.ErrorBody)
+	}
+
+	errReq := resp.Request
+	if errReq == nil {
+		errReq = req
+	}
+	urlErr := &url.Error{
+		Op:  errReq.Method,
+		URL: errReq.URL.Redacted(),
+		Err: clientErr,
+	}
+	err = redactURLError(urlErr)
+
+	// Client errors are usually permanent, and server errors are usually
+	// temporary, but there are some exceptions.
+	isTemporary := 500 <= clientErr.StatusCode && clientErr.StatusCode <= 599
+	switch clientErr.StatusCode {
+	case http.StatusRequestTimeout, http.StatusTooEarly,
+		http.StatusTooManyRequests,
+		499: // nginx-specific, client closed request
+		isTemporary = true
+	case http.StatusNotImplemented, http.StatusHTTPVersionNotSupported,
+		http.StatusNetworkAuthenticationRequired:
+		isTemporary = false
+	}
+	if !isTemporary {
+		return backoff.Permanent(err)
+	}
+	return err
+}
+
+// ClientError is an HTTP error received from a registry or authorization
+// service.
+type ClientError struct {
+	ErrorBody
+	StatusCode int
+	RetryAfter time.Time
+	RawBody    []byte
+}
+
+type ErrorBody struct {
+	Errors []ErrorInfo `json:"errors,omitempty"`
+}
+
+type ErrorInfo struct {
+	Code    string `json:"code"`
+	Message string `json:"message,omitempty"`
+}
+
+func (e *ClientError) Error() string {
+	if len(e.Errors) == 0 {
+		text := fmt.Sprintf("HTTP %d %s", e.StatusCode, http.StatusText(e.StatusCode))
+		if len(e.RawBody) != 0 {
+			text = fmt.Sprintf("%s: %q", text, e.RawBody)
+		}
+		return text
+	}
+	var errorStrs []string
+	for _, ei := range e.Errors {
+		errorStrs = append(errorStrs, fmt.Sprintf("%s: %s", ei.Code, ei.Message))
+	}
+	return fmt.Sprintf("HTTP %d %s", e.StatusCode, strings.Join(errorStrs, "; "))
+}
+
+// redactURLError redacts the URL in an [url.Error]. After redirects, the URL
+// may contain secrets in query parameter values.
+//
+// Logic adapted from:
+// https://github.com/google/go-containerregistry/blob/v0.20.3/internal/redact/redact.go
+func redactURLError(err error) error {
+	var urlErr *url.Error
+	if !errors.As(err, &urlErr) {
+		return err
+	}
+	u, perr := url.Parse(urlErr.URL)
+	if perr != nil {
+		return err
+	}
+	query := u.Query()
+	for name, vals := range query {
+		if name == "scope" || name == "service" {
+			continue
+		}
+		for i := range vals {
+			vals[i] = "REDACTED"
+		}
+	}
+	u.RawQuery = query.Encode()
+	urlErr.URL = u.Redacted()
+	return err
+}
+
+func readFullBody(resp *http.Response, limit int) ([]byte, error) {
+	switch {
+	case resp.ContentLength < 0:
+		lr := io.LimitReader(resp.Body, int64(limit)+1)
+		content, err := io.ReadAll(lr)
+		if err != nil {
+			return nil, err
+		}
+		if len(content) > limit {
+			return nil, backoff.Permanent(fmt.Errorf("HTTP response exceeds limit of %d bytes", limit))
+		}
+		return content, nil
+	case resp.ContentLength <= int64(limit):
+		content := make([]byte, resp.ContentLength)
+		_, err := io.ReadFull(resp.Body, content)
+		if err != nil {
+			return nil, err
+		}
+		return content, nil
+	default:
+		return nil, backoff.Permanent(fmt.Errorf("HTTP response of size %d exceeds limit of %d bytes", resp.ContentLength, limit))
+	}
+}
diff --git a/osbase/oci/registry/headers.go b/osbase/oci/registry/headers.go
new file mode 100644
index 0000000..3d1de2f
--- /dev/null
+++ b/osbase/oci/registry/headers.go
@@ -0,0 +1,179 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package registry
+
+import "strings"
+
+type authenticateChallenge struct {
+	scheme string
+	info   string
+	params map[string]string
+}
+
+// parseAuthenticateHeader parses the values of a WWW-Authenticate HTTP header.
+// parameter names are converted to lower case.
+// If any value fails to parse, it returns nil.
+func parseAuthenticateHeader(authenticate []string) []authenticateChallenge {
+	// From RFC 9110:
+	// WWW-Authenticate = #challenge
+	// challenge = auth-scheme [ 1*SP ( token68 / #auth-param ) ]
+	// auth-scheme = token
+	// token68 = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"="
+	// auth-param = token BWS "=" BWS ( token / quoted-string )
+	// #element => [ element ] *( OWS "," OWS [ element ] )
+	// token = 1*tchar
+	// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
+	//       / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
+	//       / DIGIT / ALPHA
+	// quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
+	// qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text
+	// quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
+	// obs-text = %x80-FF
+	// OWS = *( SP / HTAB )
+	// BWS = OWS
+	// VCHAR = %x21-7E
+
+	var challenges []authenticateChallenge
+	for _, a := range authenticate {
+		for {
+			a = strings.TrimLeft(a, " \t,") // Consume commas and OWS
+			if a == "" {
+				break
+			}
+			var scheme string
+			scheme, a = scanToken(a) // Consume auth-scheme
+			if scheme == "" {
+				return nil
+			}
+			challenge := authenticateChallenge{
+				scheme: scheme,
+			}
+			if !strings.HasPrefix(a, " ") { // Check for 1*SP
+				a = strings.TrimLeft(a, " \t") // Consume OWS
+				if a != "" && a[0] != ',' {    // Check for mandatory comma
+					return nil
+				}
+				challenges = append(challenges, challenge)
+				continue
+			}
+			a = strings.TrimLeft(a, " ") // Consume 1*SP
+
+			// Check for token68
+			i := 0
+			for i < len(a) && charType[a[i]]&charTypeToken68 != 0 {
+				i++
+			}
+			if i != 0 {
+				for i < len(a) && a[i] == '=' { // Consume *"="
+					i++
+				}
+				remain := strings.TrimLeft(a[i:], " \t") // Consume OWS
+				if remain == "" || remain[0] == ',' {    // Check for mandatory comma
+					// Confirmed token68
+					challenge.info = a[:i]
+					challenges = append(challenges, challenge)
+					a = remain
+					continue
+				}
+			}
+
+			challenge.params = make(map[string]string)
+			for {
+				// Check for auth-param
+				remain := strings.TrimLeft(a, " \t,") // Consume commas and OWS
+				var name string
+				name, remain = scanToken(remain) // Consume token
+				if name == "" {
+					break
+				}
+				remain = strings.TrimLeft(remain, " \t") // Consume BWS
+				var ok bool
+				if remain, ok = strings.CutPrefix(remain, "="); !ok { // Consume "="
+					break
+				}
+				remain = strings.TrimLeft(remain, " \t") // Consume BWS
+				var value string
+				if remain, ok = strings.CutPrefix(remain, `"`); ok { // Check for quoted-string
+					i := 0
+					for i < len(remain) {
+						if charType[remain[i]]&charTypeQdtext != 0 {
+							i++
+						} else if remain[i] == '\\' && i+1 < len(remain) && charType[remain[i+1]]&charTypeQuotedPair != 0 {
+							value += remain[:i]
+							remain = remain[i+1:] // Drop the backslash to unescape the string
+							i = 1
+						} else {
+							break
+						}
+					}
+					value += remain[:i]
+					remain = remain[i:]
+					if remain, ok = strings.CutPrefix(remain, `"`); !ok { // Consume quote
+						break
+					}
+				} else {
+					value, remain = scanToken(remain) // Consume token
+					if value == "" {
+						break
+					}
+				}
+				// Confirmed auth-param
+				name = strings.ToLower(name) // name is case-insensitive
+				if _, ok := challenge.params[name]; ok {
+					return nil // each parameter name MUST only occur once
+				}
+				challenge.params[name] = value
+				a = remain
+				a = strings.TrimLeft(a, " \t") // Consume OWS
+				if a != "" && a[0] != ',' {    // Check for mandatory comma
+					return nil
+				}
+			}
+			challenges = append(challenges, challenge)
+			a = strings.TrimLeft(a, " \t") // Consume OWS
+			if a != "" && a[0] != ',' {    // Check for mandatory comma
+				return nil
+			}
+		}
+	}
+	return challenges
+}
+
+var charType [256]uint8
+
+const (
+	charTypeToken = 1 << iota
+	charTypeToken68
+	charTypeQdtext
+	charTypeQuotedPair
+)
+
+func init() {
+	for _, c := range "!#$%&'*+-.^_`|~" {
+		charType[c] |= charTypeToken
+	}
+	for _, c := range "-._~+/" {
+		charType[c] |= charTypeToken68
+	}
+	for c := range 256 {
+		if '0' <= c && c <= '9' || 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' {
+			charType[c] |= charTypeToken | charTypeToken68
+		}
+		if c == '\t' || c == ' ' || 0x21 <= c && c != 0x7f {
+			if c != '"' && c != '\\' {
+				charType[c] |= charTypeQdtext
+			}
+			charType[c] |= charTypeQuotedPair
+		}
+	}
+}
+
+func scanToken(s string) (token string, remain string) {
+	for i := range len(s) {
+		if charType[s[i]]&charTypeToken == 0 {
+			return s[:i], s[i:]
+		}
+	}
+	return s, ""
+}
diff --git a/osbase/oci/registry/headers_test.go b/osbase/oci/registry/headers_test.go
new file mode 100644
index 0000000..f883ea9
--- /dev/null
+++ b/osbase/oci/registry/headers_test.go
@@ -0,0 +1,83 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package registry
+
+import "testing"
+
+func TestParseAuthenticateHeader(t *testing.T) {
+	testCases := []struct {
+		desc   string
+		header []string
+		parsed []authenticateChallenge
+	}{
+		{"absent", nil, nil},
+		{"no params",
+			[]string{"Basic, !#$%&'*+-.^_`|~019abzABZ"},
+			[]authenticateChallenge{{scheme: "Basic"}, {scheme: "!#$%&'*+-.^_`|~019abzABZ"}}},
+		{"token68",
+			[]string{"0 a", "1 abzABZ019-._~+/, 2 abc=, 3   a==="},
+			[]authenticateChallenge{
+				{scheme: "0", info: "a"},
+				{scheme: "1", info: "abzABZ019-._~+/"},
+				{scheme: "2", info: "abc="},
+				{scheme: "3", info: "a==="},
+			}},
+		{"params",
+			[]string{`0 a="=,", empty =  "", escape="\a\\\"", ` + "1 token!#$%&'*+-.^_`|~019abzABZ=!#$%&'*+-.^_`|~019abzABZ"},
+			[]authenticateChallenge{
+				{scheme: "0", params: map[string]string{"a": "=,", "empty": "", "escape": `a\"`}},
+				{scheme: "1", params: map[string]string{"token!#$%&'*+-.^_`|~019abzabz": "!#$%&'*+-.^_`|~019abzABZ"}},
+			}},
+		{"duplicate param", []string{`Basic realm="apps", REALM=other`}, nil},
+		{"empty", []string{"", " ", "\t", ",", " , ,,\t ,", "Basic"}, []authenticateChallenge{{scheme: "Basic"}}},
+		{"RFC example",
+			[]string{`Basic realm="simple", Newauth realm="apps", type=1, title="Login to \"apps\""`},
+			[]authenticateChallenge{
+				{scheme: "Basic", params: map[string]string{"realm": "simple"}},
+				{scheme: "Newauth", params: map[string]string{"realm": "apps", "type": "1", "title": `Login to "apps"`}},
+			}},
+		{"extra commas",
+			[]string{` , , Basic , , realm="simple" , , Newauth ,realm="apps",type=1` + "\t" + `, ,title="Login to \"apps\"" , , `},
+			[]authenticateChallenge{
+				{scheme: "Basic", params: map[string]string{"realm": "simple"}},
+				{scheme: "Newauth", params: map[string]string{"realm": "apps", "type": "1", "title": `Login to "apps"`}},
+			}},
+		{"missing comma between challenges", []string{"Basic\tBearer"}, nil},
+		{"missing comma between challenges 2", []string{"Basic !"}, nil},
+		{"missing comma after token68", []string{"Basic a Bearer"}, nil},
+		{"missing comma between params", []string{`Basic realm="simple" type=1`}, nil},
+		{"missing quote", []string{`Basic realm="simple`}, nil},
+		{"missing value", []string{`Basic !=`}, nil},
+	}
+	for _, tC := range testCases {
+		t.Run(tC.desc, func(t *testing.T) {
+			actual := parseAuthenticateHeader(tC.header)
+			if want, got := len(tC.parsed), len(actual); want != got {
+				t.Fatalf("Expected %d challenges, got %d", want, got)
+			}
+			for i, actualC := range actual {
+				wantC := tC.parsed[i]
+				if want, got := wantC.scheme, actualC.scheme; want != got {
+					t.Errorf("Expected scheme %q, got %q", want, got)
+				}
+				if want, got := wantC.info, actualC.info; want != got {
+					t.Errorf("Expected info %q, got %q", want, got)
+				}
+				for param, want := range wantC.params {
+					got, ok := actualC.params[param]
+					if !ok {
+						t.Errorf("Scheme %s: Missing param %q", wantC.scheme, param)
+					} else if want != got {
+						t.Errorf("Scheme %s: Expected %s=%q, got %q", wantC.scheme, param, want, got)
+					}
+				}
+				for param := range actualC.params {
+					if _, ok := wantC.params[param]; !ok {
+						t.Errorf("Scheme %s: Extra param %q", wantC.scheme, param)
+					}
+				}
+			}
+		})
+	}
+}
diff --git a/osbase/oci/registry/server.go b/osbase/oci/registry/server.go
new file mode 100644
index 0000000..13a9dc2
--- /dev/null
+++ b/osbase/oci/registry/server.go
@@ -0,0 +1,181 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package registry
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"internal/sync"
+	"io"
+	"net/http"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+
+	ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+
+	"source.monogon.dev/osbase/oci"
+	"source.monogon.dev/osbase/structfs"
+)
+
+var (
+	manifestsEp = regexp.MustCompile("^/v2/(" + repositoryExpr + ")/manifests/(" + tagExpr + "|" + digestExpr + ")$")
+	blobsEp     = regexp.MustCompile("^/v2/(" + repositoryExpr + ")/blobs/(" + digestExpr + ")$")
+)
+
+// Server is an OCI registry server.
+type Server struct {
+	mu           sync.Mutex
+	repositories map[string]*serverRepository
+}
+
+type serverRepository struct {
+	tags      map[string]string
+	manifests map[string]serverManifest
+	blobs     map[string]structfs.Blob
+}
+
+type serverManifest struct {
+	contentType string
+	content     []byte
+}
+
+func NewServer() *Server {
+	return &Server{
+		repositories: make(map[string]*serverRepository),
+	}
+}
+
+// AddImage adds an image to the server in the specified repository.
+//
+// If the tag is empty, the image can only be fetched by digest.
+func (s *Server) AddImage(repository string, tag string, image *oci.Image) error {
+	if !repositoryRegexp.MatchString(repository) {
+		return fmt.Errorf("invalid repository %q", repository)
+	}
+	if tag != "" && !tagRegexp.MatchString(tag) {
+		return fmt.Errorf("invalid tag %q", tag)
+	}
+
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	repo := s.repositories[repository]
+	if repo == nil {
+		repo = &serverRepository{
+			tags:      make(map[string]string),
+			manifests: make(map[string]serverManifest),
+			blobs:     make(map[string]structfs.Blob),
+		}
+		s.repositories[repository] = repo
+	}
+	if _, ok := repo.manifests[image.ManifestDigest]; !ok {
+		for descriptor := range image.Descriptors() {
+			repo.blobs[string(descriptor.Digest)] = image.StructfsBlob(descriptor)
+		}
+		repo.manifests[image.ManifestDigest] = serverManifest{
+			contentType: ocispecv1.MediaTypeImageManifest,
+			content:     image.RawManifest,
+		}
+	}
+	if tag != "" {
+		repo.tags[tag] = image.ManifestDigest
+	}
+	return nil
+}
+
+func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+	if req.Method != "GET" && req.Method != "HEAD" {
+		http.Error(w, "Registry is read-only, only GET and HEAD are allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	if req.URL.Path == "/v2/" {
+		w.WriteHeader(http.StatusOK)
+	} else if matches := manifestsEp.FindStringSubmatch(req.URL.Path); len(matches) > 0 {
+		repository := matches[1]
+		reference := matches[2]
+		s.mu.Lock()
+		repo := s.repositories[repository]
+		if repo == nil {
+			s.mu.Unlock()
+			serveError(w, "NAME_UNKNOWN", fmt.Sprintf("Unknown repository: %s", repository), http.StatusNotFound)
+			return
+		}
+		digest := reference
+		if !strings.ContainsRune(reference, ':') {
+			var ok bool
+			digest, ok = repo.tags[reference]
+			if !ok {
+				s.mu.Unlock()
+				serveError(w, "MANIFEST_UNKNOWN", fmt.Sprintf("Unknown tag: %s", reference), http.StatusNotFound)
+				return
+			}
+		}
+		manifest, ok := repo.manifests[digest]
+		s.mu.Unlock()
+		if !ok {
+			serveError(w, "MANIFEST_UNKNOWN", fmt.Sprintf("Unknown manifest: %s", digest), http.StatusNotFound)
+			return
+		}
+
+		w.Header().Set("Docker-Content-Digest", digest)
+		w.Header().Set("Etag", fmt.Sprintf(`"%s"`, digest))
+		w.Header().Set("Content-Type", manifest.contentType)
+		w.Header().Set("X-Content-Type-Options", "nosniff")
+		http.ServeContent(w, req, "", time.Time{}, bytes.NewReader(manifest.content))
+	} else if matches := blobsEp.FindStringSubmatch(req.URL.Path); len(matches) > 0 {
+		repository := matches[1]
+		digest := matches[2]
+		s.mu.Lock()
+		repo := s.repositories[repository]
+		if repo == nil {
+			s.mu.Unlock()
+			serveError(w, "NAME_UNKNOWN", fmt.Sprintf("Unknown repository: %s", repository), http.StatusNotFound)
+			return
+		}
+		blob, ok := repo.blobs[digest]
+		s.mu.Unlock()
+		if !ok {
+			serveError(w, "BLOB_UNKNOWN", fmt.Sprintf("Unknown blob: %s", digest), http.StatusNotFound)
+			return
+		}
+
+		content, err := blob.Open()
+		if err != nil {
+			http.Error(w, "Failed to open blob", http.StatusInternalServerError)
+			return
+		}
+		defer content.Close()
+		w.Header().Set("Docker-Content-Digest", digest)
+		w.Header().Set("Etag", fmt.Sprintf(`"%s"`, digest))
+		w.Header().Set("Content-Type", "application/octet-stream")
+		if contentSeeker, ok := content.(io.ReadSeeker); ok {
+			http.ServeContent(w, req, "", time.Time{}, contentSeeker)
+		} else {
+			// Range requests are not supported.
+			w.Header().Set("Content-Length", strconv.FormatInt(blob.Size(), 10))
+			w.WriteHeader(http.StatusOK)
+			if req.Method != "HEAD" {
+				io.CopyN(w, content, blob.Size())
+			}
+		}
+	} else {
+		w.WriteHeader(http.StatusNotFound)
+	}
+}
+
+func serveError(w http.ResponseWriter, code string, message string, statusCode int) {
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
+	w.Header().Set("X-Content-Type-Options", "nosniff")
+	w.WriteHeader(statusCode)
+	content, err := json.Marshal(&ErrorBody{Errors: []ErrorInfo{{
+		Code:    code,
+		Message: message,
+	}}})
+	if err == nil {
+		w.Write(content)
+	}
+}