Add a --tls-details option and integration tests

Signed-off-by: Miloslav Trmač <mitr@redhat.com>
This commit is contained in:
Miloslav Trmač
2026-02-05 20:46:00 +01:00
parent ade3298955
commit af1b87a955
10 changed files with 614 additions and 4 deletions
+8
View File
@@ -11,6 +11,7 @@ import (
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
commonFlag "go.podman.io/common/pkg/flag"
"go.podman.io/image/v5/pkg/cli/basetls/tlsdetails"
"go.podman.io/image/v5/signature"
"go.podman.io/image/v5/types"
"go.podman.io/storage/pkg/reexec"
@@ -21,6 +22,7 @@ var defaultUserAgent = "skopeo/" + version.Version
type globalOptions struct {
debug bool // Enable debug output
tlsVerify commonFlag.OptionalBool // Require HTTPS and verify certificates (for docker: and docker-daemon:)
tlsDetailsPath string // Path to a containers-tls-details.yaml(5) file
policyPath string // Path to a signature verification policy file
insecurePolicy bool // Use an "allow everything" signature verification policy
registriesDirPath string // Path to a "registries.d" registry configuration directory
@@ -80,6 +82,7 @@ func createApp() (*cobra.Command, *globalOptions) {
var dummyVersion bool
rootCommand.Flags().BoolVarP(&dummyVersion, "version", "v", false, "Version for Skopeo")
rootCommand.PersistentFlags().BoolVar(&opts.debug, "debug", false, "enable debug output")
rootCommand.PersistentFlags().StringVar(&opts.tlsDetailsPath, "tls-details", "", "path to a containers-tls-details.yaml(5) file")
rootCommand.PersistentFlags().StringVar(&opts.policyPath, "policy", "", "Path to a trust policy file")
rootCommand.PersistentFlags().BoolVar(&opts.insecurePolicy, "insecure-policy", false, "run the tool without any policy check")
rootCommand.PersistentFlags().BoolVar(&opts.requireSigned, "require-signed", false, "require any pulled image to be signed")
@@ -208,6 +211,10 @@ func (opts *globalOptions) newSystemContext() (*types.SystemContext, error) {
if opts.userAgentPrefix != "" {
userAgent = opts.userAgentPrefix + " " + defaultUserAgent
}
baseTLSConfig, err := tlsdetails.BaseTLSFromOptionalFile(opts.tlsDetailsPath)
if err != nil {
return nil, err
}
ctx := &types.SystemContext{
RegistriesDirPath: opts.registriesDirPath,
ArchitectureChoice: opts.overrideArch,
@@ -215,6 +222,7 @@ func (opts *globalOptions) newSystemContext() (*types.SystemContext, error) {
VariantChoice: opts.overrideVariant,
SystemRegistriesConfPath: opts.registriesConfPath,
BigFilesTemporaryDir: opts.tmpDir,
BaseTLSConfig: baseTLSConfig.TLSConfig(),
DockerRegistryUserAgent: userAgent,
}
// DEPRECATED: We support this for backward compatibility, but override it if a per-image flag is provided.
+10 -4
View File
@@ -2,6 +2,7 @@ package main
import (
"bytes"
"crypto/tls"
"testing"
"github.com/stretchr/testify/assert"
@@ -35,6 +36,7 @@ func TestGlobalOptionsNewSystemContext(t *testing.T) {
"--override-arch", "overridden-arch",
"--override-os", "overridden-os",
"--override-variant", "overridden-variant",
"--tls-details", "../../integration/fixtures/tls-details-pqc-only.yaml",
"--tmpdir", "/srv",
"--registries-conf", "/srv/registries.conf",
"--tls-verify=false",
@@ -42,10 +44,14 @@ func TestGlobalOptionsNewSystemContext(t *testing.T) {
res, err = opts.newSystemContext()
require.NoError(t, err)
assert.Equal(t, &types.SystemContext{
RegistriesDirPath: "/srv/registries.d",
ArchitectureChoice: "overridden-arch",
OSChoice: "overridden-os",
VariantChoice: "overridden-variant",
RegistriesDirPath: "/srv/registries.d",
ArchitectureChoice: "overridden-arch",
OSChoice: "overridden-os",
VariantChoice: "overridden-variant",
BaseTLSConfig: &tls.Config{
MinVersion: tls.VersionTLS13,
CurvePreferences: []tls.CurveID{tls.X25519MLKEM768},
},
BigFilesTemporaryDir: "/srv",
SystemRegistriesConfPath: "/srv/registries.conf",
DockerInsecureSkipTLSVerify: types.OptionalBoolTrue,
+9
View File
@@ -96,6 +96,15 @@ Use registry configuration files in _dir_ (e.g. for container signature storage)
Require that any pulled image must be signed regardless of what the default or provided trust policy file says.
**--tls-details** _path_
Path to a containers-tls-details(5) file, affecting TLS behavior throughout the program.
If not set, defaults to a reasonable default that may change over time (depending on systems global policy,
version of the program, version of the Go language, and the like).
Users should generally not use this option unless they have a process to ensure that the configuration will be kept up to date.
**--tmpdir** _dir_
Directory used to store temporary files. Defaults to /var/tmp.
@@ -0,0 +1 @@
minVersion: "1.3"
@@ -0,0 +1 @@
{} # No fields
@@ -0,0 +1,3 @@
minVersion: "1.3"
namedGroups:
- "X25519MLKEM768"
+302
View File
@@ -0,0 +1,302 @@
package main
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/opencontainers/image-spec/specs-go"
imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"go.podman.io/image/v5/oci/layout"
)
func TestTLS(t *testing.T) {
suite.Run(t, &tlsSuite{})
}
type tlsSuite struct {
suite.Suite
defaultServer *tlsConfigServer
tls12Server *tlsConfigServer
nonPQCserver *tlsConfigServer
pqcServer *tlsConfigServer
expected []expectedBehavior
}
var (
_ = suite.SetupAllSuite(&tlsSuite{})
_ = suite.TearDownAllSuite(&tlsSuite{})
)
type expectedBehavior struct {
server *tlsConfigServer
tlsDetails string
expected string
}
func (s *tlsSuite) SetupSuite() {
t := s.T()
s.defaultServer = newServer(t, &tls.Config{})
s.tls12Server = newServer(t, &tls.Config{
MaxVersion: tls.VersionTLS12,
})
s.nonPQCserver = newServer(t, &tls.Config{
MinVersion: tls.VersionTLS13,
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384, tls.CurveP521},
})
s.pqcServer = newServer(t, &tls.Config{
MinVersion: tls.VersionTLS13,
CurvePreferences: []tls.CurveID{tls.X25519MLKEM768},
})
s.expected = []expectedBehavior{
{
server: s.defaultServer,
tlsDetails: "fixtures/tls-details-anything.yaml",
expected: `\b418\b`, // "I'm a teapot"
},
{
server: s.tls12Server,
tlsDetails: "fixtures/tls-details-anything.yaml",
expected: `\b418\b`, // "I'm a teapot"
},
{
server: s.nonPQCserver,
tlsDetails: "fixtures/tls-details-anything.yaml",
expected: `\b418\b`, // "I'm a teapot"
},
{
server: s.pqcServer,
tlsDetails: "fixtures/tls-details-anything.yaml",
expected: `\b418\b`, // "I'm a teapot"
},
{
server: s.defaultServer,
tlsDetails: "fixtures/tls-details-1.3.yaml",
expected: `\b418\b`, // "I'm a teapot"
},
{
server: s.tls12Server,
tlsDetails: "fixtures/tls-details-1.3.yaml",
expected: `protocol version not supported`,
},
{
server: s.nonPQCserver,
tlsDetails: "fixtures/tls-details-1.3.yaml",
expected: `\b418\b`, // "I'm a teapot"
},
{
server: s.pqcServer,
tlsDetails: "fixtures/tls-details-1.3.yaml",
expected: `\b418\b`, // "I'm a teapot"
},
{
server: s.defaultServer,
tlsDetails: "fixtures/tls-details-pqc-only.yaml",
expected: `\b418\b`, // "I'm a teapot"
},
{
server: s.tls12Server,
tlsDetails: "fixtures/tls-details-pqc-only.yaml",
expected: `protocol version not supported`,
},
{
server: s.nonPQCserver,
tlsDetails: "fixtures/tls-details-pqc-only.yaml",
expected: `handshake failure`,
},
{
server: s.pqcServer,
tlsDetails: "fixtures/tls-details-pqc-only.yaml",
expected: `\b418\b`, // "I'm a teapot"
},
}
}
func (s *tlsSuite) TearDownSuite() {
}
func (s *tlsSuite) TestDockerDaemon() {
t := s.T()
// Our server doesnt perform client authentication, but the docker-daemon: option semantics
// requires us to provide a certificate if we want to specify a CA.
dockerCertPath := t.TempDir()
caPath := filepath.Join(dockerCertPath, "ca.pem")
privateKey, err := rsa.GenerateKey(rand.Reader, 4096)
require.NoError(t, err)
publicKey := &privateKey.PublicKey
err = os.WriteFile(filepath.Join(dockerCertPath, "key.pem"), pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
}), 0o644)
require.NoError(t, err)
referenceTime := time.Now()
template := &x509.Certificate{
Subject: pkix.Name{
CommonName: "client",
},
NotBefore: referenceTime.Add(-1 * time.Minute),
NotAfter: referenceTime.Add(1 * time.Hour),
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, publicKey, privateKey)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dockerCertPath, "cert.pem"), pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
}), 0o644)
require.NoError(t, err)
for _, e := range s.expected {
err := os.WriteFile(caPath, e.server.certBytes, 0o644)
require.NoError(t, err)
assertSkopeoFails(t, e.expected, "--tls-details", e.tlsDetails, "inspect", "--daemon-host", e.server.server.URL, "--cert-dir", dockerCertPath, "docker-daemon:repo:tag")
}
}
func (s *tlsSuite) TestRegistry() {
t := s.T()
caDir := t.TempDir()
caPath := filepath.Join(caDir, "ca.crt")
for _, e := range s.expected {
err := os.WriteFile(caPath, e.server.certBytes, 0o644)
require.NoError(t, err)
assertSkopeoFails(t, e.expected, "--tls-details", e.tlsDetails, "inspect", "--cert-dir", caDir, "docker://"+e.server.hostPort+"/repo")
}
}
func (s *tlsSuite) TestOCILayout() {
t := s.T()
caDir := t.TempDir()
caPath := filepath.Join(caDir, "ca.crt")
for _, e := range s.expected {
err := os.WriteFile(caPath, e.server.certBytes, 0o644)
require.NoError(t, err)
ociLayoutDir := t.TempDir()
destRef, err := layout.NewReference(ociLayoutDir, "repo:tag")
require.NoError(t, err)
dest, err := destRef.NewImageDestination(context.Background(), nil)
require.NoError(t, err)
manifestBytes, err := json.Marshal(imgspecv1.Manifest{
Versioned: specs.Versioned{SchemaVersion: 2},
MediaType: imgspecv1.MediaTypeImageManifest,
Config: imgspecv1.Descriptor{
MediaType: imgspecv1.MediaTypeImageConfig,
Digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Size: 42,
URLs: []string{e.server.server.URL + "/config.json"},
},
Layers: []imgspecv1.Descriptor{},
ArtifactType: "",
Subject: &imgspecv1.Descriptor{},
Annotations: map[string]string{},
})
require.NoError(t, err)
err = dest.PutManifest(context.Background(), manifestBytes, nil)
require.NoError(t, err)
err = dest.Commit(context.Background(), nil) // nil is technically invalid, but works here
require.NoError(t, err)
err = dest.Close()
require.NoError(t, err)
// We dont expose types.OCICertPath in the CLI. But if we get far enough to be worrying about certificates,
// we already negotiated the TLS version and named group.
expected := e.expected
if expected == `\b418\b` {
expected = `certificate signed by unknown authority`
}
assertSkopeoFails(t, expected, "--tls-details", e.tlsDetails, "inspect", "oci:"+ociLayoutDir)
}
}
func (s *tlsSuite) TestOpenShift() {
t := s.T()
configDir := t.TempDir()
configPath := filepath.Join(configDir, "kubeconfig")
t.Setenv("KUBECONFIG", configPath)
for _, e := range s.expected {
err := os.WriteFile(configPath, []byte(fmt.Sprintf(
`apiVersion: v1
clusters:
- cluster:
certificate-authority: "%s"
server: "%s"
name: our-cluster
contexts:
- context:
cluster: our-cluster
namespace: default
name: our-context
current-context: our-context
kind: Config
`, e.server.certPath, e.server.server.URL)), 0o644)
require.NoError(t, err)
// The atomic: image access starts with resolving the tag in a k8s API (and that will always fail, one way or another),
// so we never actually contact registry.example.
assertSkopeoFails(t, e.expected, "--tls-details", e.tlsDetails, "inspect", "atomic:registry.example/namespace/repo:tag")
}
}
// tlsConfigServer serves TLS with a specific configuration.
// It returns StatusTeapot on all requests; we use that to detect that the TLS negotiation succeeded,
// without bothering to actually implement any of the protocols.
type tlsConfigServer struct {
server *httptest.Server
hostPort string
certBytes []byte
certPath string
}
func newServer(t *testing.T, config *tls.Config) *tlsConfigServer {
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}))
t.Cleanup(server.Close)
server.TLS = config.Clone()
server.StartTLS()
certBytes := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: server.Certificate().Raw,
})
certDir := t.TempDir()
certPath := filepath.Join(certDir, "cert.pem")
err := os.WriteFile(certPath, certBytes, 0o644)
require.NoError(t, err)
return &tlsConfigServer{
server: server,
hostPort: server.Listener.Addr().String(),
certBytes: certBytes,
certPath: certPath,
}
}
+219
View File
@@ -0,0 +1,219 @@
// Package basetls encapsulates a set of base TLS settings (not keys/certificates)
// configured via containers-tls-details.yaml(5).
//
// CLI integration should generally be done using c/image/pkg/cli/basetls/tlsdetails instead
// of using the TLSDetailsFile directly.
package basetls
import (
"bytes"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"slices"
)
// Config encapsulates users choices about base TLS settings, typically
// configured via containers-tls-details.yaml(5).
//
// Most codebases should pass around the resulting *tls.Config, without depending on this subpackage;
// this primarily exists as a separate type to allow passing the configuration around within (version-matched) RPC systems,
// using the MarshalText/UnmarshalText methods.
type Config struct {
// We keep the text representation because we start with it, and this way we don't have
// to implement formatting back to text. This is an internal detail, so we can change that later.
text TLSDetailsFile
config *tls.Config // Parsed from .text, both match
}
// TLSDetailsFile contains a set of TLS options.
//
// To consume such a file, most callers should use c/image/pkg/cli/basetls/tlsdetails instead
// of dealing with this type explicitly.
//
// This type is exported primarily to allow creating parameter files programmatically
// (and eventually the tlsdetails subpackage should provide an API to convert this type into
// the appropriate file contents, so that callers don't need to do that manually).
type TLSDetailsFile struct {
// Keep this in sync with docs/containers-tls-details.yaml.5.md !
MinVersion string `yaml:"minVersion,omitempty"` // If set, minimum version to use throughout the program.
CipherSuites []string `yaml:"cipherSuites,omitempty"` // If set, allowed TLS cipher suites to use throughout the program.
NamedGroups []string `yaml:"namedGroups,omitempty"` // If set, allowed TLS named groups to use throughout the program.
}
// NewFromTLSDetails creates a Config from a TLSDetailsFile.
func NewFromTLSDetails(details *TLSDetailsFile) (*Config, error) {
res := Config{
text: TLSDetailsFile{},
config: &tls.Config{},
}
configChanged := false
for _, fn := range []func(input *TLSDetailsFile) (bool, error){
res.parseMinVersion,
res.parseCipherSuites,
res.parseNamedGroups,
} {
changed, err := fn(details)
if err != nil {
return nil, err
}
if changed {
configChanged = true
}
}
if !configChanged {
res.config = nil
}
return &res, nil
}
// tlsVersions maps TLS version strings to their crypto/tls constants.
// We could use the `tls.VersionName` names, but those are verbose and contain spaces;
// similarly the OpenShift enum values (“VersionTLS11”) are unergonomic.
var tlsVersions = map[string]uint16{
"1.0": tls.VersionTLS10,
"1.1": tls.VersionTLS11,
"1.2": tls.VersionTLS12,
"1.3": tls.VersionTLS13,
}
func (c *Config) parseMinVersion(input *TLSDetailsFile) (bool, error) {
if input.MinVersion == "" {
return false, nil
}
v, ok := tlsVersions[input.MinVersion]
if !ok {
return false, fmt.Errorf("unrecognized TLS minimum version %q", input.MinVersion)
}
c.text.MinVersion = input.MinVersion
c.config.MinVersion = v
return true, nil
}
// cipherSuitesByName returns a map from cipher suite name to its ID.
func cipherSuitesByName() map[string]uint16 {
// The Go standard library uses IANA names and already contains the mapping (for relevant values)
// sadly we still need to turn it into a lookup map.
suites := make(map[string]uint16)
for _, cs := range tls.CipherSuites() {
suites[cs.Name] = cs.ID
}
for _, cs := range tls.InsecureCipherSuites() {
suites[cs.Name] = cs.ID
}
return suites
}
func (c *Config) parseCipherSuites(input *TLSDetailsFile) (bool, error) {
if input.CipherSuites == nil {
return false, nil
}
suitesByName := cipherSuitesByName()
ids := []uint16{}
for _, name := range input.CipherSuites {
id, ok := suitesByName[name]
if !ok {
return false, fmt.Errorf("unrecognized TLS cipher suite %q", name)
}
ids = append(ids, id)
}
c.text.CipherSuites = slices.Clone(input.CipherSuites)
c.config.CipherSuites = ids
return true, nil
}
// groupsByName maps curve/group names to their tls.CurveID.
// The names match IANA TLS Supported Groups registry.
//
// Yes, the x25519 names differ in capitalization.
// Gos tls.CurveID has a .String() method, but it
// uses the Go names.
var groupsByName = map[string]tls.CurveID{
"secp256r1": tls.CurveP256,
"secp384r1": tls.CurveP384,
"secp521r1": tls.CurveP521,
"x25519": tls.X25519,
"X25519MLKEM768": tls.X25519MLKEM768,
}
func (c *Config) parseNamedGroups(input *TLSDetailsFile) (bool, error) {
if input.NamedGroups == nil {
return false, nil
}
ids := []tls.CurveID{}
for _, name := range input.NamedGroups {
id, ok := groupsByName[name]
if !ok {
return false, fmt.Errorf("unrecognized TLS named group %q", name)
}
ids = append(ids, id)
}
c.text.NamedGroups = slices.Clone(input.NamedGroups)
c.config.CurvePreferences = ids
return true, nil
}
// TLSConfig returns a *tls.Config matching the provided settings.
// If c contains no settings, it returns nil.
// Otherwise, the returned *tls.Config is freshly allocated and the caller can modify it as needed.
func (c *Config) TLSConfig() *tls.Config {
if c.config == nil {
return nil
}
return c.config.Clone()
}
// marshaledSerialization is the data we use in MarshalText/UnmarshalText,
// marshaled using JSON.
//
// Note that the file format is using YAML, but we use JSON, to minimize dependencies
// in backend code where we don't need comments and the brackets are not annoying users.
type marshaledSerialization struct {
Version int
Data TLSDetailsFile
}
const marshaledSerializationVersion1 = 1
// MarshalText serializes c to a text representation.
//
// The representation is intended to be reasonably stable across updates to c/image,
// but the consumer must not be older than the producer.
func (c Config) MarshalText() ([]byte, error) {
data := marshaledSerialization{
Version: marshaledSerializationVersion1,
Data: c.text,
}
return json.Marshal(data)
}
// UnmarshalText parses the output of MarshalText.
//
// The format is otherwise undocumented and we do not promise ongoing compatibility with producers external to this package.
func (c *Config) UnmarshalText(text []byte) error {
var data marshaledSerialization
// In the future, this should be an even stricter parser, e.g. refusing duplicate fields
// and requiring a case-sensitive field name match.
decoder := json.NewDecoder(bytes.NewReader(text))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&data); err != nil {
return err
}
if decoder.More() {
return errors.New("unexpected extra data after a JSON object")
}
if data.Version != marshaledSerializationVersion1 {
return fmt.Errorf("unsupported version %d", data.Version)
}
v, err := NewFromTLSDetails(&data.Data)
if err != nil {
return err
}
*c = *v
return nil
}
+59
View File
@@ -0,0 +1,59 @@
// Package tlsdetails implements the containers-tls-details.yaml(5) file format.
//
// Recommended CLI integration is by a --tls-details flag parsed using BaseTLSFromOptionalFile, with the following documentation:
//
// --tls-details is a path to a containers-tls-details.yaml(5) file, affecting TLS behavior throughout the program.
//
// If not set, defaults to a reasonable default that may change over time (depending on systems global policy,
// version of the program, version of the Go language, and the like).
//
// Users should generally not use this option unless they have a process to ensure that the configuration will be kept up to date.
package tlsdetails
import (
"bytes"
"fmt"
"os"
"go.podman.io/image/v5/pkg/cli/basetls"
"gopkg.in/yaml.v3"
)
// BaseTLSFromOptionalFile returns a basetls.Config matching a containers-tls-details.yaml file at the specified path.
// If path is "", it returns a valid basetls.Config with no settings (where config.TLSConfig() will return nil).
func BaseTLSFromOptionalFile(path string) (*basetls.Config, error) {
if path == "" {
return basetls.NewFromTLSDetails(&basetls.TLSDetailsFile{})
}
return BaseTLSFromFile(path)
}
// BaseTLSFromFile returns a basetls.Config matching a containers-tls-details.yaml file at the specified path.
func BaseTLSFromFile(path string) (*basetls.Config, error) {
details, err := ParseFile(path)
if err != nil {
return nil, err
}
res, err := basetls.NewFromTLSDetails(details)
if err != nil {
return nil, fmt.Errorf("parsing TLS details %q: %w", path, err)
}
return res, nil
}
// ParseFile parses a basetls.TLSDetailsFile at the specified path.
//
// Most consumers of the parameter file should use BaseTLSFromFile or BaseTLSFromOptionalFile instead.
func ParseFile(path string) (*basetls.TLSDetailsFile, error) {
var res basetls.TLSDetailsFile
source, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %q: %w", path, err)
}
dec := yaml.NewDecoder(bytes.NewReader(source))
dec.KnownFields(true)
if err = dec.Decode(&res); err != nil {
return nil, fmt.Errorf("parsing %q: %w", path, err)
}
return &res, nil
}
+2
View File
@@ -410,6 +410,8 @@ go.podman.io/image/v5/pkg/blobinfocache/memory
go.podman.io/image/v5/pkg/blobinfocache/none
go.podman.io/image/v5/pkg/blobinfocache/sqlite
go.podman.io/image/v5/pkg/cli
go.podman.io/image/v5/pkg/cli/basetls
go.podman.io/image/v5/pkg/cli/basetls/tlsdetails
go.podman.io/image/v5/pkg/cli/sigstore
go.podman.io/image/v5/pkg/cli/sigstore/params
go.podman.io/image/v5/pkg/compression