Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion ociclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ func descriptorFromResponse(resp *http.Response, knownDigest oci.Digest, require
if contentType == "" {
contentType = "application/octet-stream"
}
if contentType == mediaTypeSchema1SignedManifest {
// [Client.read] always strips the signature envelope from signed
// schema1 manifests before returning them, so report the media
// type callers will actually see, even for a HEAD response that
// has no body to strip.
contentType = mediaTypeSchema1Manifest
}
size := int64(0)
if (require & requireSize) != 0 {
if resp.StatusCode == http.StatusPartialContent {
Expand Down Expand Up @@ -264,7 +271,8 @@ var knownManifestMediaTypes = []string{
oci.MediaTypeImageManifest,
oci.MediaTypeImageIndex,
"application/vnd.oci.artifact.manifest.v1+json", // deprecated.
"application/vnd.docker.distribution.manifest.v1+json",
mediaTypeSchema1Manifest,
mediaTypeSchema1SignedManifest,
oci.MediaTypeDockerManifest,
oci.MediaTypeDockerManifestList,
// Technically this wildcard should be sufficient, but it isn't
Expand Down
25 changes: 25 additions & 0 deletions ociclient/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,31 @@ func (c *Client) read(req *http.Request, knownDigest oci.Digest, isManifest bool
if err != nil {
return nil, fmt.Errorf("invalid descriptor in response: %v", err)
}
if resp.Header.Get("Content-Type") == mediaTypeSchema1SignedManifest {
// Legacy signed Docker schema1 manifest: strip the libtrust
// signature envelope here, once, so that the verification
// below (and every caller of this method) only ever sees a
// plain schema1 manifest whose bytes hash to desc.Digest,
// exactly like any other media type. See schema1Unsign.
if desc.Size > maxManifestSize {
return nil, fmt.Errorf("manifest size %d exceeds maximum size %d", desc.Size, maxManifestSize)
}
signed, err := io.ReadAll(io.LimitReader(resp.Body, maxManifestSize+1))
if err != nil {
return nil, fmt.Errorf("failed to read signed schema1 manifest: %v", err)
}
resp.Body.Close()
if int64(len(signed)) != desc.Size {
return nil, fmt.Errorf("signed schema1 manifest size mismatch")
}
unsigned, err := schema1Unsign(signed)
if err != nil {
return nil, fmt.Errorf("cannot unsign schema1 manifest: %w", err)
}
desc.MediaType = mediaTypeSchema1Manifest
desc.Size = int64(len(unsigned))
resp.Body = io.NopCloser(bytes.NewReader(unsigned))
}
if desc.Digest == "" {
// Returning a digest isn't mandatory according to the spec, and
// at least one registry (AWS's ECR) fails to return a digest
Expand Down
98 changes: 98 additions & 0 deletions ociclient/schema1.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright 2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package ociclient

import (
"encoding/base64"
"encoding/json"
"fmt"
)

// Legacy Docker Manifest v2 Schema 1 media types.
//
// Schema1 predates the OCI distribution spec and isn't otherwise supported
// by this module (see [oci.IndexOrManifest.Validate]), but some registries
// (notably Docker Hub) still serve very old images this way, so ociclient
// handles reading them.
//
// A schema1 manifest fetched from a registry always arrives wrapped in a
// legacy libtrust JWS-like signature envelope (mediaTypeSchema1SignedManifest),
// even when the content was never meaningfully "signed" (the signature
// algorithm is commonly "none"). The digest that registries and tags
// actually refer to is the digest of the envelope's content with the
// envelope stripped back off, not the digest of the bytes as delivered on
// the wire. ociclient strips this envelope itself (see schema1Unsign and
// its use in [Client.read]) so that everywhere else in this module - and
// every caller of it - only ever sees a plain, unsigned schema1 manifest
// whose bytes hash to the digest reported for it, exactly like every other
// media type.
//
// See https://github.com/tianon/oci-schema1 and
// https://github.com/moby/moby/commit/011bfd666eeb21a111ca450c42a3893ad03c9324
// for more background.
const (
mediaTypeSchema1Manifest = "application/vnd.docker.distribution.manifest.v1+json"
mediaTypeSchema1SignedManifest = "application/vnd.docker.distribution.manifest.v1+prettyjws"
)

// schema1ProtectedHeader is the JSON shape of the base64url-encoded
// "protected" field of each entry in a signed schema1 manifest's
// "signatures" array. FormatTail is base64-encoded (standard, padded
// encoding, as produced by Go's encoding/json for a []byte field) and,
// appended to the first FormatLength bytes of the signed manifest,
// reconstructs the unsigned manifest that the digest actually refers to.
type schema1ProtectedHeader struct {
FormatLength int `json:"formatLength"`
FormatTail []byte `json:"formatTail"`
}

// schema1Unsign reconstructs the unsigned form of a signed Docker schema1
// manifest (mediaTypeSchema1SignedManifest) from its raw signed bytes. The
// sha256 of the returned bytes is the digest that registries and tags refer
// to for this manifest.
func schema1Unsign(signed []byte) ([]byte, error) {
var m struct {
Signatures []struct {
Protected string `json:"protected"`
} `json:"signatures"`
}
if err := json.Unmarshal(signed, &m); err != nil {
return nil, fmt.Errorf("invalid schema1 manifest: %w", err)
}
if len(m.Signatures) == 0 {
return nil, fmt.Errorf("signed schema1 manifest has no signatures")
}
protected := m.Signatures[0].Protected
for _, s := range m.Signatures[1:] {
if s.Protected != protected {
return nil, fmt.Errorf("signed schema1 manifest has mismatched signatures")
}
}
hdrJSON, err := base64.RawURLEncoding.DecodeString(protected)
if err != nil {
return nil, fmt.Errorf("invalid schema1 signature protected header encoding: %w", err)
}
var hdr schema1ProtectedHeader
if err := json.Unmarshal(hdrJSON, &hdr); err != nil {
return nil, fmt.Errorf("invalid schema1 signature protected header: %w", err)
}
if hdr.FormatLength < 0 || hdr.FormatLength > len(signed) {
return nil, fmt.Errorf("schema1 signature formatLength %d out of range for %d-byte manifest", hdr.FormatLength, len(signed))
}
unsigned := make([]byte, 0, hdr.FormatLength+len(hdr.FormatTail))
unsigned = append(unsigned, signed[:hdr.FormatLength]...)
unsigned = append(unsigned, hdr.FormatTail...)
return unsigned, nil
}
203 changes: 203 additions & 0 deletions ociclient/schema1_reader_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// Copyright 2026 Docker, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package ociclient

import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"os"
"testing"

"github.com/docker/oci/ocidigest"
"github.com/stretchr/testify/require"
)

// schema1TestDigest is the real Docker-Content-Digest Docker Hub returns
// for library/php:5.3, which is the digest of the *unsigned* form of the
// manifest, not of the signed bytes on the wire.
const schema1TestDigest = ocidigest.Digest("sha256:ba952a8970f2fc35e3703b2650495c64d6e015eb52a4ee03f750c69e863b3237")

func readSchema1Fixture(t *testing.T) []byte {
t.Helper()
data, err := os.ReadFile("testdata/schema1-signed-manifest.json")
require.NoError(t, err)
return data
}

func TestGetManifestSchema1Signed(t *testing.T) {
signed := readSchema1Fixture(t)

c, err := New("registry.example", &Options{
Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
require.Equal(t, http.MethodGet, req.Method)
require.Equal(t, "/v2/library/php/manifests/"+schema1TestDigest.String(), req.URL.Path)
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
ContentLength: int64(len(signed)),
Header: http.Header{
"Content-Type": {mediaTypeSchema1SignedManifest},
"Docker-Content-Digest": {schema1TestDigest.String()},
},
Body: io.NopCloser(bytes.NewReader(signed)),
Request: req,
}, nil
}),
})
require.NoError(t, err)

r, err := c.GetManifest(context.Background(), "library/php", schema1TestDigest)
require.NoError(t, err)
defer r.Close()

desc := r.Descriptor()
require.Equal(t, mediaTypeSchema1Manifest, desc.MediaType)
require.Equal(t, schema1TestDigest, desc.Digest)

data, err := io.ReadAll(r)
require.NoError(t, err)

// The bytes handed back must actually hash to the reported digest -
// the entire point of unsigning at read time.
require.Equal(t, schema1TestDigest, ocidigest.FromBytes(data))
require.Equal(t, int64(len(data)), desc.Size)
require.Less(t, len(data), len(signed), "unsigned content should be smaller than the signed wire form")

var m map[string]any
require.NoError(t, json.Unmarshal(data, &m))
require.NotContains(t, m, "signatures")
}

func TestGetTagSchema1Signed(t *testing.T) {
signed := readSchema1Fixture(t)

c, err := New("registry.example", &Options{
Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
require.Equal(t, "/v2/library/php/manifests/5.3", req.URL.Path)
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
ContentLength: int64(len(signed)),
Header: http.Header{
"Content-Type": {mediaTypeSchema1SignedManifest},
"Docker-Content-Digest": {schema1TestDigest.String()},
},
Body: io.NopCloser(bytes.NewReader(signed)),
Request: req,
}, nil
}),
})
require.NoError(t, err)

r, err := c.GetTag(context.Background(), "library/php", "5.3")
require.NoError(t, err)
defer r.Close()

data, err := io.ReadAll(r)
require.NoError(t, err)
require.Equal(t, schema1TestDigest, ocidigest.FromBytes(data))
require.Equal(t, mediaTypeSchema1Manifest, r.Descriptor().MediaType)
}

// TestGetTagSchema1SignedNoDigestHeader exercises registries (like AWS ECR,
// per the comment in [Client.read]) that omit Docker-Content-Digest on a
// tag GET: the digest must be derived from the unsigned content, not the
// signed wire bytes.
func TestGetTagSchema1SignedNoDigestHeader(t *testing.T) {
signed := readSchema1Fixture(t)

c, err := New("registry.example", &Options{
Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
ContentLength: int64(len(signed)),
Header: http.Header{
"Content-Type": {mediaTypeSchema1SignedManifest},
},
Body: io.NopCloser(bytes.NewReader(signed)),
Request: req,
}, nil
}),
})
require.NoError(t, err)

r, err := c.GetTag(context.Background(), "library/php", "5.3")
require.NoError(t, err)
defer r.Close()

require.Equal(t, schema1TestDigest, r.Descriptor().Digest)
data, err := io.ReadAll(r)
require.NoError(t, err)
require.Equal(t, schema1TestDigest, ocidigest.FromBytes(data))
}

func TestGetManifestSchema1SignedDigestMismatch(t *testing.T) {
signed := readSchema1Fixture(t)
const wrongDigest = ocidigest.Digest("sha256:0000000000000000000000000000000000000000000000000000000000000000")

c, err := New("registry.example", &Options{
Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
ContentLength: int64(len(signed)),
Header: http.Header{
"Content-Type": {mediaTypeSchema1SignedManifest},
"Docker-Content-Digest": {wrongDigest.String()},
},
Body: io.NopCloser(bytes.NewReader(signed)),
Request: req,
}, nil
}),
})
require.NoError(t, err)

r, err := c.GetManifest(context.Background(), "library/php", wrongDigest)
require.NoError(t, err)
_, err = io.ReadAll(r)
require.ErrorContains(t, err, "digest mismatch")
}

func TestResolveManifestSchema1SignedNormalizesMediaType(t *testing.T) {
c, err := New("registry.example", &Options{
Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
require.Equal(t, http.MethodHead, req.Method)
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
ContentLength: 20841,
Header: http.Header{
"Content-Type": {mediaTypeSchema1SignedManifest},
"Docker-Content-Digest": {schema1TestDigest.String()},
},
Body: http.NoBody,
Request: req,
}, nil
}),
})
require.NoError(t, err)

desc, err := c.ResolveManifest(context.Background(), "library/php", schema1TestDigest)
require.NoError(t, err)
// HEAD has no body to unsign, but the reported media type is
// normalized to match what a subsequent GetManifest will return,
// so Resolve and Get stay consistent for callers.
require.Equal(t, mediaTypeSchema1Manifest, desc.MediaType)
require.Equal(t, schema1TestDigest, desc.Digest)
}
Loading
Loading