342 lines
11 KiB
Go
342 lines
11 KiB
Go
/*
|
|
Copyright Docker attest authors
|
|
|
|
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 tuf
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"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 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
|
|
pathPrefix string
|
|
}
|
|
|
|
type TargetFile struct {
|
|
ActualFilePath string
|
|
TargetURI string
|
|
Digest string
|
|
Data []byte
|
|
}
|
|
|
|
// ClientOptions contains the options for creating a new TUF client.
|
|
type ClientOptions struct {
|
|
// InitialRoot is the initial root.json file to use for the TUF client.
|
|
InitialRoot []byte
|
|
// LocalStorageDir is the directory where the TUF client will cache any downloaded metadata and target files.
|
|
LocalStorageDir string
|
|
// MetadataSource is the source of the metadata files.
|
|
MetadataSource string
|
|
// TargetsSource is the source of the target files.
|
|
TargetsSource string
|
|
// VersionChecker checks if the current version of this library meets the constraints from the TUF repo.
|
|
VersionChecker VersionChecker
|
|
// PathPrefix is the prefix to prepend to all target paths before downloading.
|
|
PathPrefix string
|
|
}
|
|
|
|
func NewDockerDefaultClientOptions(tufPath string) *ClientOptions {
|
|
return &ClientOptions{
|
|
InitialRoot: DockerTUFRootDefault.Data,
|
|
LocalStorageDir: tufPath,
|
|
MetadataSource: defaultMetadataSource,
|
|
TargetsSource: defaultTargetsSource,
|
|
VersionChecker: NewDefaultVersionChecker(),
|
|
}
|
|
}
|
|
|
|
var validPathPrefix = regexp.MustCompile("^[a-z0-9_-]*$")
|
|
|
|
// NewClient creates a new TUF client.
|
|
func NewClient(ctx context.Context, opts *ClientOptions) (*Client, error) {
|
|
pathPrefix := opts.PathPrefix
|
|
if !validPathPrefix.MatchString(pathPrefix) {
|
|
return nil, fmt.Errorf("invalid path prefix: %s", pathPrefix)
|
|
}
|
|
|
|
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.LocalStorageDir, 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
|
|
// this is parsed as an HTTP url (which doesn't work for OCI). We're setting this to make TUF happy
|
|
// and overwriding the configuration below
|
|
cfg, err := config.New("", 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.RemoteMetadataURL = opts.MetadataSource
|
|
cfg.RemoteTargetsURL = opts.TargetsSource
|
|
|
|
if tufSource == OCISource {
|
|
cfg.Fetcher, err = NewRegistryFetcher(ctx, cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create registry fetcher: %w", err)
|
|
}
|
|
}
|
|
|
|
// 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{
|
|
pathPrefix: pathPrefix,
|
|
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) {
|
|
// before we do anything, prepend the path prefix to the target
|
|
target = path.Join(t.pathPrefix, target)
|
|
|
|
// 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)
|
|
}
|