`policy.Options` now contains the arguments to `tuf.Client`'s constructor rather than an actual Client. If these arguments are not provided, defaults pointing at Docker's TUF repo will be used. An actual TUF client can be passed in on the context (which is useful for testing). If this is not provided `attest.Verify` will create a TUF client using the options on `policy.Options`. --------- Co-authored-by: Joel Kamp <joel.kamp@docker.com>
321 lines
9.9 KiB
Go
321 lines
9.9 KiB
Go
package tuf
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/docker/attest/internal/embed"
|
|
"github.com/docker/attest/internal/util"
|
|
"github.com/theupdateframework/go-tuf/v2/metadata"
|
|
"github.com/theupdateframework/go-tuf/v2/metadata/config"
|
|
"github.com/theupdateframework/go-tuf/v2/metadata/fetcher"
|
|
"github.com/theupdateframework/go-tuf/v2/metadata/trustedmetadata"
|
|
"github.com/theupdateframework/go-tuf/v2/metadata/updater"
|
|
)
|
|
|
|
type tufCtxKeyType struct{}
|
|
|
|
var DownloaderCtxKey tufCtxKeyType
|
|
|
|
// WithDownloader sets Downloader in context.
|
|
func WithDownloader(ctx context.Context, downloader Downloader) context.Context {
|
|
return context.WithValue(ctx, DownloaderCtxKey, downloader)
|
|
}
|
|
|
|
// GetDownloader returns the Downloader from context and `true` if it exists, otherwise `nil` and `false`.
|
|
func GetDownloader(ctx context.Context) (Downloader, bool) {
|
|
t, ok := ctx.Value(DownloaderCtxKey).(Downloader)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
return t, true
|
|
}
|
|
|
|
type Source string
|
|
|
|
const (
|
|
HTTPSource Source = "http"
|
|
OCISource Source = "oci"
|
|
LatestTag string = "latest"
|
|
)
|
|
|
|
var (
|
|
DockerTUFRootProd = embed.RootProd
|
|
DockerTUFRootStaging = embed.RootStaging
|
|
DockerTUFRootDev = embed.RootDev
|
|
DockerTUFRootDefault = embed.RootDefault
|
|
)
|
|
|
|
const (
|
|
defaultMetadataSource = "docker/tuf-metadata:latest"
|
|
defaultTargetsSource = "docker/tuf-targets"
|
|
)
|
|
|
|
type Downloader interface {
|
|
DownloadTarget(target, filePath string) (file *TargetFile, err error)
|
|
}
|
|
|
|
type Client struct {
|
|
updater *updater.Updater
|
|
cfg *config.UpdaterConfig
|
|
}
|
|
|
|
type TargetFile struct {
|
|
ActualFilePath string
|
|
TargetURI string
|
|
Digest string
|
|
Data []byte
|
|
}
|
|
|
|
type ClientOptions struct {
|
|
InitialRoot []byte
|
|
Path string
|
|
MetadataSource string
|
|
TargetsSource string
|
|
VersionChecker VersionChecker
|
|
}
|
|
|
|
func NewDockerDefaultClientOptions(tufPath string) *ClientOptions {
|
|
return &ClientOptions{
|
|
InitialRoot: DockerTUFRootDefault.Data,
|
|
Path: tufPath,
|
|
MetadataSource: defaultMetadataSource,
|
|
TargetsSource: defaultTargetsSource,
|
|
VersionChecker: NewDefaultVersionChecker(),
|
|
}
|
|
}
|
|
|
|
// NewClient creates a new TUF client.
|
|
func NewClient(opts *ClientOptions) (*Client, error) {
|
|
var tufSource Source
|
|
if strings.HasPrefix(opts.MetadataSource, "https://") || strings.HasPrefix(opts.MetadataSource, "http://") {
|
|
tufSource = HTTPSource
|
|
} else {
|
|
tufSource = OCISource
|
|
}
|
|
|
|
tufRootDigest := util.SHA256Hex(opts.InitialRoot)
|
|
|
|
// create a directory for each initial root.json
|
|
metadataPath := filepath.Join(opts.Path, tufRootDigest)
|
|
err := os.MkdirAll(metadataPath, os.ModePerm)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create directory '%s': %w", metadataPath, err)
|
|
}
|
|
rootFile := filepath.Join(metadataPath, "root.json")
|
|
var rootBytes []byte
|
|
rootBytes, err = os.ReadFile(rootFile)
|
|
if err != nil {
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
return nil, fmt.Errorf("failed to read root.json: %w", err)
|
|
}
|
|
// write the root.json file to the metadata directory
|
|
err = os.WriteFile(rootFile, opts.InitialRoot, 0o666) // #nosec G306
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to write root.json %w", err)
|
|
}
|
|
rootBytes = opts.InitialRoot
|
|
}
|
|
|
|
// create updater configuration
|
|
cfg, err := config.New(opts.MetadataSource, rootBytes) // default config
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create TUF updater configuration: %w", err)
|
|
}
|
|
cfg.LocalMetadataDir = metadataPath
|
|
cfg.LocalTargetsDir = filepath.Join(metadataPath, "download")
|
|
cfg.RemoteTargetsURL = opts.TargetsSource
|
|
|
|
if tufSource == OCISource {
|
|
metadataRepo, metadataTag, found := strings.Cut(opts.MetadataSource, ":")
|
|
if !found {
|
|
fmt.Printf("metadata tag not found in URL, using latest\n")
|
|
metadataTag = LatestTag
|
|
}
|
|
cfg.Fetcher = NewRegistryFetcher(metadataRepo, metadataTag, opts.TargetsSource)
|
|
}
|
|
|
|
// create a new Updater instance
|
|
up, err := updater.New(cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create TUF updater instance: %w", err)
|
|
}
|
|
|
|
// try to build the top-level metadata
|
|
err = up.Refresh()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to refresh trusted metadata: %w", err)
|
|
}
|
|
|
|
client := &Client{
|
|
updater: up,
|
|
cfg: cfg,
|
|
}
|
|
|
|
err = opts.VersionChecker.CheckVersion(client)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return client, nil
|
|
}
|
|
|
|
func (t *Client) generateTargetURI(target *metadata.TargetFiles, digest string) (string, error) {
|
|
switch fetcher := t.cfg.Fetcher.(type) {
|
|
case *RegistryFetcher:
|
|
return fmt.Sprintf("%s@sha256:%s", t.cfg.RemoteTargetsURL, digest), nil
|
|
case *fetcher.DefaultFetcher:
|
|
targetBaseURL := ensureTrailingSlash(t.cfg.RemoteTargetsURL)
|
|
targetRemotePath := target.Path
|
|
// if PrefixTargetsWithHash is set, we need to prefix the target name with the hash and handle subdirectories
|
|
// similar logic to https://github.com/theupdateframework/go-tuf/blob/f95222bdd22d2ac4e5b8ed6fe912b645e213c3b5/metadata/updater/updater.go#L227-L247
|
|
if t.cfg.PrefixTargetsWithHash {
|
|
baseName := filepath.Base(targetRemotePath)
|
|
dirName, ok := strings.CutSuffix(targetRemotePath, "/"+baseName)
|
|
if !ok {
|
|
// <hash>.<target-name>
|
|
targetRemotePath = fmt.Sprintf("%s.%s", digest, baseName)
|
|
} else {
|
|
// <dir-prefix>/<hash>.<target-name>
|
|
targetRemotePath = fmt.Sprintf("%s/%s.%s", dirName, digest, baseName)
|
|
}
|
|
}
|
|
return fmt.Sprintf("%s%s", targetBaseURL, targetRemotePath), nil
|
|
default:
|
|
return "", fmt.Errorf("unsupported fetcher type: %T", fetcher)
|
|
}
|
|
}
|
|
|
|
// DownloadTarget downloads the target file using Updater. The Updater gets the target
|
|
// information, verifies if the target is already cached, and if it is not cached,
|
|
// downloads the target file.
|
|
func (t *Client) DownloadTarget(target string, filePath string) (file *TargetFile, err error) {
|
|
// search if the desired target is available
|
|
targetInfo, err := t.updater.GetTargetInfo(target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// check if filePath exists and create the directory if it doesn't
|
|
if _, err := os.Stat(filepath.Dir(filePath)); os.IsNotExist(err) {
|
|
err = os.MkdirAll(filepath.Dir(filePath), os.ModePerm)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create target download directory '%s': %w", filepath.Dir(filePath), err)
|
|
}
|
|
}
|
|
|
|
// target is available, so let's see if the target is already present locally
|
|
actualFilePath, data, err := t.updater.FindCachedTarget(targetInfo, filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed while finding a cached target: %w", err)
|
|
}
|
|
if data != nil {
|
|
digest := util.SHA256Hex(data)
|
|
uri, err := t.generateTargetURI(targetInfo, digest)
|
|
return &TargetFile{ActualFilePath: actualFilePath, TargetURI: uri, Data: data, Digest: digest}, err
|
|
}
|
|
|
|
// target is not present locally, so let's try to download it
|
|
actualFilePath, data, err = t.updater.DownloadTarget(targetInfo, filePath, "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to download target file %s - %w", target, err)
|
|
}
|
|
digest := util.SHA256Hex(data)
|
|
uri, err := t.generateTargetURI(targetInfo, digest)
|
|
return &TargetFile{ActualFilePath: actualFilePath, TargetURI: uri, Data: data, Digest: digest}, err
|
|
}
|
|
|
|
func (t *Client) GetMetadata() trustedmetadata.TrustedMetadata {
|
|
return t.updater.GetTrustedMetadataSet()
|
|
}
|
|
|
|
func (t *Client) MaxRootLength() int64 {
|
|
return t.cfg.RootMaxLength
|
|
}
|
|
|
|
func (t *Client) GetPriorRoots(metadataURL string) (map[string][]byte, error) {
|
|
rootMetadata := map[string][]byte{}
|
|
trustedMetadata := t.GetMetadata()
|
|
client := fetcher.DefaultFetcher{}
|
|
for i := 1; i < int(trustedMetadata.Root.Signed.Version); i++ {
|
|
meta, err := client.DownloadFile(metadataURL+fmt.Sprintf("/%d.root.json", i), t.MaxRootLength(), time.Second*15)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to download root metadata: %w", err)
|
|
}
|
|
rootMetadata[fmt.Sprintf("%d.root.json", i)] = meta
|
|
}
|
|
return rootMetadata, nil
|
|
}
|
|
|
|
func (t *Client) SetRemoteTargetsURL(url string) {
|
|
t.cfg.RemoteTargetsURL = url
|
|
}
|
|
|
|
// Derived from updater.loadTargets() in theupdateframework/go-tuf.
|
|
func (t *Client) LoadDelegatedTargets(roleName, parentName string) (*metadata.Metadata[metadata.TargetsType], error) {
|
|
// extract the targets meta from the trusted snapshot metadata
|
|
meta := t.updater.GetTrustedMetadataSet()
|
|
metaInfo := meta.Snapshot.Signed.Meta[fmt.Sprintf("%s.json", roleName)]
|
|
// extract the length of the target metadata to be downloaded
|
|
length := metaInfo.Length
|
|
if length == 0 {
|
|
length = t.cfg.TargetsMaxLength
|
|
}
|
|
// extract which target metadata version should be downloaded in case of consistent snapshots
|
|
version := ""
|
|
if meta.Root.Signed.ConsistentSnapshot {
|
|
version = strconv.FormatInt(metaInfo.Version, 10)
|
|
}
|
|
// download targets metadata
|
|
data, err := t.downloadMetadata(roleName, length, version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// verify and load the new target metadata
|
|
delegatedTargets, err := meta.UpdateDelegatedTargets(data, roleName, parentName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return delegatedTargets, nil
|
|
}
|
|
|
|
// downloadMetadata download a metadata file and return it as bytes.
|
|
func (t *Client) downloadMetadata(roleName string, length int64, version string) ([]byte, error) {
|
|
urlPath := ensureTrailingSlash(t.cfg.RemoteMetadataURL)
|
|
// build urlPath
|
|
if version == "" {
|
|
urlPath = fmt.Sprintf("%s%s.json", urlPath, url.QueryEscape(roleName))
|
|
} else {
|
|
urlPath = fmt.Sprintf("%s%s.%s.json", urlPath, version, url.QueryEscape(roleName))
|
|
}
|
|
return t.cfg.Fetcher.DownloadFile(urlPath, length, time.Second*15)
|
|
}
|
|
|
|
// ensureTrailingSlash ensures url ends with a slash.
|
|
func ensureTrailingSlash(url string) string {
|
|
if updater.IsWindowsPath(url) {
|
|
slash := string(filepath.Separator)
|
|
if strings.HasSuffix(url, slash) {
|
|
return url
|
|
}
|
|
return url + slash
|
|
}
|
|
if strings.HasSuffix(url, "/") {
|
|
return url
|
|
}
|
|
return url + "/"
|
|
}
|
|
|
|
// GetEmbeddedRoot returns the embedded TUF root based on the given root name.
|
|
func GetEmbeddedRoot(root string) (*embed.EmbeddedRoot, error) {
|
|
return embed.GetRootFromName(root)
|
|
}
|