diff --git a/.golangci.yml b/.golangci.yml index 283b5f645..ce4040838 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -47,6 +47,9 @@ issues: linters: - errcheck - gosec + - path: pkg/tuf + linters: + revive max-issues-per-linter: 0 max-same-issues: 0 run: diff --git a/go.mod b/go.mod index e66d3d512..fb041d3ef 100644 --- a/go.mod +++ b/go.mod @@ -99,6 +99,7 @@ require ( github.com/pierrec/lz4 v2.6.1+incompatible // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect github.com/ysmood/goob v0.4.0 // indirect github.com/ysmood/gson v0.7.1 // indirect diff --git a/go.sum b/go.sum index 181c0f435..7dd6ff9bf 100644 --- a/go.sum +++ b/go.sum @@ -1015,6 +1015,7 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/sylvia7788/contextcheck v1.0.4/go.mod h1:vuPKJMQ7MQ91ZTqfdyreNKwZjyUg6KO+IebVyQDedZQ= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= github.com/tdakkota/asciicheck v0.0.0-20200416200610-e657995f937b/go.mod h1:yHp0ai0Z9gUljN3o0xMhYJnH/IcvkdTBOX2fmJ93JEM= github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= diff --git a/pkg/fulcioroots/fulcioroots.go b/pkg/fulcioroots/fulcioroots.go new file mode 100644 index 000000000..d5419eb49 --- /dev/null +++ b/pkg/fulcioroots/fulcioroots.go @@ -0,0 +1,115 @@ +// +// Copyright 2022 The Sigstore 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 fulcioroots + +import ( + "bytes" + "context" + "crypto/x509" + "errors" + "fmt" + "sync" + + "github.com/sigstore/sigstore/pkg/cryptoutils" + "github.com/sigstore/sigstore/pkg/tuf" +) + +var ( + rootsOnce sync.Once + roots *x509.CertPool + intermediates *x509.CertPool + singletonRootErr error +) + +// This is the root in the fulcio project. +var fulcioTargetStr = `fulcio.crt.pem` + +// This is the v1 migrated root. +var fulcioV1TargetStr = `fulcio_v1.crt.pem` + +// The untrusted intermediate CA certificate, used for chain building +// TODO: Remove once this is bundled in TUF metadata. +var fulcioIntermediateV1 = `-----BEGIN CERTIFICATE----- +MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMw +KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y +MjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3Jl +LmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0C +AQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV7 +7LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS +0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYB +BQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjp +KFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZI +zj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJR +nZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsP +mygUY7Ii2zbdCdliiow= +-----END CERTIFICATE-----` + +// Get returns the Fulcio root certificate. +func Get() (*x509.CertPool, error) { + rootsOnce.Do(func() { + roots, intermediates, singletonRootErr = initRoots() + if singletonRootErr != nil { + return + } + }) + return roots, singletonRootErr +} + +// GetIntermediates returns the Fulcio intermediate certificates. +func GetIntermediates() (*x509.CertPool, error) { + rootsOnce.Do(func() { + roots, intermediates, singletonRootErr = initRoots() + if singletonRootErr != nil { + return + } + }) + return intermediates, singletonRootErr +} + +func initRoots() (*x509.CertPool, *x509.CertPool, error) { + tufClient, err := tuf.NewFromEnv(context.Background()) + if err != nil { + return nil, nil, fmt.Errorf("initializing tuf: %w", err) + } + // Retrieve from the embedded or cached TUF root. If expired, a network + // call is made to update the root. + targets, err := tufClient.GetTargetsByMeta(tuf.Fulcio, []string{fulcioTargetStr, fulcioV1TargetStr}) + if err != nil { + return nil, nil, fmt.Errorf("error getting targets: %w", err) + } + if len(targets) == 0 { + return nil, nil, errors.New("none of the Fulcio roots have been found") + } + rootPool := x509.NewCertPool() + intermediatePool := x509.NewCertPool() + for _, t := range targets { + certs, err := cryptoutils.UnmarshalCertificatesFromPEM(t.Target) + if err != nil { + return nil, nil, fmt.Errorf("error unmarshalling certificates: %w", err) + } + for _, cert := range certs { + // root certificates are self-signed + if bytes.Equal(cert.RawSubject, cert.RawIssuer) { + rootPool.AddCert(cert) + } else { + intermediatePool.AddCert(cert) + } + } + } + intermediatePool.AppendCertsFromPEM([]byte(fulcioIntermediateV1)) + + return rootPool, intermediatePool, nil +} diff --git a/pkg/tuf/client.go b/pkg/tuf/client.go new file mode 100644 index 000000000..cd7498fa0 --- /dev/null +++ b/pkg/tuf/client.go @@ -0,0 +1,696 @@ +// +// Copyright 2022 The Sigstore 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 ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "io/ioutil" + "net/url" + "os" + "path" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "time" + + "github.com/theupdateframework/go-tuf/client" + tuf_leveldbstore "github.com/theupdateframework/go-tuf/client/leveldbstore" + "github.com/theupdateframework/go-tuf/data" + "github.com/theupdateframework/go-tuf/util" +) + +const ( + // DefaultRemoteRoot is the default remote TUF root location. + DefaultRemoteRoot = "https://sigstore-tuf-root.storage.googleapis.com" + + // TufRootEnv is the name of the environment variable that locates an alternate local TUF root location. + TufRootEnv = "TUF_ROOT" + + // SigstoreNoCache is the name of the environment variable that, if set, configures this code to only store root data in memory. + SigstoreNoCache = "SIGSTORE_NO_CACHE" +) + +var ( + // singletonTUF holds a single instance of TUF that will get reused on + // subsequent invocations of initializeTUF + singletonTUF *TUF + singletonTUFOnce = new(sync.Once) + singletonTUFErr error +) + +// getRemoteRoot is a var for testing. +var getRemoteRoot = func() string { return DefaultRemoteRoot } + +type TUF struct { + client *client.Client + targets targetImpl + local client.LocalStore + remote client.RemoteStore + embedded fs.FS + mirror string // location of mirror +} + +// JSON output representing the configured root status +type RootStatus struct { + Local string `json:"local"` + Remote string `json:"remote"` + Metadata map[string]MetadataStatus `json:"metadata"` + Targets []string `json:"targets"` +} + +type MetadataStatus struct { + Version int `json:"version"` + Size int `json:"len"` + Expiration string `json:"expiration"` + Error string `json:"error"` +} + +type TargetFile struct { + Target []byte + Status StatusKind +} + +type customMetadata struct { + Usage UsageKind `json:"usage"` + Status StatusKind `json:"status"` +} + +type sigstoreCustomMetadata struct { + Sigstore customMetadata `json:"sigstore"` +} + +type signedMeta struct { + Type string `json:"_type"` + Expires time.Time `json:"expires"` + Version int64 `json:"version"` +} + +// RemoteCache contains information to cache on the location of the remote +// repository. +type remoteCache struct { + Mirror string `json:"mirror"` +} + +func resetForTests() { + singletonTUFOnce = new(sync.Once) +} + +func getExpiration(metadata []byte) (*time.Time, error) { + s := &data.Signed{} + if err := json.Unmarshal(metadata, s); err != nil { + return nil, err + } + sm := &signedMeta{} + if err := json.Unmarshal(s.Signed, sm); err != nil { + return nil, err + } + return &sm.Expires, nil +} + +func getVersion(metadata []byte) (int64, error) { + s := &data.Signed{} + if err := json.Unmarshal(metadata, s); err != nil { + return 0, err + } + sm := &signedMeta{} + if err := json.Unmarshal(s.Signed, sm); err != nil { + return 0, err + } + return sm.Version, nil +} + +var isExpiredTimestamp = func(metadata []byte) bool { + expiration, err := getExpiration(metadata) + if err != nil { + return true + } + return time.Until(*expiration) <= 0 +} + +func getMetadataStatus(b []byte) (*MetadataStatus, error) { + expires, err := getExpiration(b) + if err != nil { + return nil, err + } + version, err := getVersion(b) + if err != nil { + return nil, err + } + return &MetadataStatus{ + Size: len(b), + Expiration: expires.Format(time.RFC822), + Version: int(version), + }, nil +} + +func (t *TUF) getRootStatus() (*RootStatus, error) { + local := rootCacheDir() + if noCache() { + local = "in-memory" + } + status := &RootStatus{ + Local: local, + Remote: t.mirror, + Metadata: make(map[string]MetadataStatus), + Targets: []string{}, + } + + // Get targets + targets, err := t.client.Targets() + if err != nil { + return nil, err + } + for t := range targets { + status.Targets = append(status.Targets, t) + } + + // Get metadata expiration + trustedMeta, err := t.local.GetMeta() + if err != nil { + return nil, fmt.Errorf("getting trusted meta: %w", err) + } + for role, md := range trustedMeta { + mdStatus, err := getMetadataStatus(md) + if err != nil { + status.Metadata[role] = MetadataStatus{Error: err.Error()} + continue + } + status.Metadata[role] = *mdStatus + } + + return status, nil +} + +func getRoot(meta map[string]json.RawMessage, fallback fs.FS) (json.RawMessage, error) { + if trustedRoot, ok := meta["root.json"]; ok { + return trustedRoot, nil + } + // On first initialize, there will be no root in the TUF DB, so read from embedded. + rd, ok := fallback.(fs.ReadFileFS) + if !ok { + return nil, errors.New("fs.ReadFileFS unimplemented for embedded repo") + } + trustedRoot, err := rd.ReadFile(path.Join("repository", "root.json")) + if err != nil { + return nil, err + } + return trustedRoot, nil +} + +// GetRootStatus gets the current root status for info logging +func GetRootStatus(ctx context.Context) (*RootStatus, error) { + t, err := NewFromEnv(ctx) + if err != nil { + return nil, err + } + return t.getRootStatus() +} + +// initializeTUF creates a TUF client using the following params: +// * embed: indicates using the embedded metadata and in-memory file updates. +// When this is false, this uses a filesystem cache. +// * mirror: provides a reference to a remote GCS or HTTP mirror. +// * root: provides an external initial root.json. When this is not provided, this +// defaults to the embedded root.json. +// * embedded: An embedded filesystem that provides a trusted root and pre-downloaded +// targets in a targets/ subfolder. +// * forceUpdate: indicates checking the remote for an update, even when the local +// timestamp.json is up to date. +func initializeTUF(mirror string, root []byte, embedded fs.FS, forceUpdate bool) (*TUF, error) { + singletonTUFOnce.Do(func() { + t := &TUF{ + mirror: mirror, + embedded: embedded, + } + + t.targets = newFileImpl() + t.local, singletonTUFErr = newLocalStore() + if singletonTUFErr != nil { + return + } + + t.remote, singletonTUFErr = remoteFromMirror(t.mirror) + if singletonTUFErr != nil { + return + } + + t.client = client.NewClient(t.local, t.remote) + + trustedMeta, err := t.local.GetMeta() + if err != nil { + singletonTUFErr = fmt.Errorf("getting trusted meta: %w", err) + return + } + + // If the caller does not supply a root, then either use the root in the local store + // or default to the embedded one. + if root == nil { + root, err = getRoot(trustedMeta, t.embedded) + if err != nil { + singletonTUFErr = fmt.Errorf("getting trusted root: %w", err) + return + } + } + + if err := t.client.InitLocal(root); err != nil { + singletonTUFErr = fmt.Errorf("unable to initialize client, local cache may be corrupt: %w", err) + return + } + + // We may already have an up-to-date local store! Check to see if it needs to be updated. + trustedTimestamp, ok := trustedMeta["timestamp.json"] + if ok && !isExpiredTimestamp(trustedTimestamp) && !forceUpdate { + // We're golden so stash the TUF object for later use + singletonTUF = t + return + } + + // Update if local is not populated or out of date. + if err := t.updateMetadataAndDownloadTargets(); err != nil { + singletonTUFErr = fmt.Errorf("updating local metadata and targets: %w", err) + return + } + + // We're golden so stash the TUF object for later use + singletonTUF = t + }) + return singletonTUF, singletonTUFErr +} + +// TODO: Remove ctx arg. +func NewFromEnv(_ context.Context) (*TUF, error) { + // Check for the current remote mirror. + mirror := getRemoteRoot() + b, err := os.ReadFile(cachedRemote(rootCacheDir())) + if err == nil { + remoteInfo := remoteCache{} + if err := json.Unmarshal(b, &remoteInfo); err == nil { + mirror = remoteInfo.Mirror + } + } + + // Initializes a new TUF object from the local cache or defaults. + return initializeTUF(mirror, nil, getEmbedded(), false) +} + +func Initialize(ctx context.Context, mirror string, root []byte) error { + // Initialize the client. Force an update with remote. + if _, err := initializeTUF(mirror, root, getEmbedded(), true); err != nil { + return err + } + + // Store the remote for later if we are caching. + if !noCache() { + remoteInfo := &remoteCache{Mirror: mirror} + b, err := json.Marshal(remoteInfo) + if err != nil { + return err + } + if err := os.WriteFile(cachedRemote(rootCacheDir()), b, 0600); err != nil { + return fmt.Errorf("storing remote: %w", err) + } + } + return nil +} + +// Checks if the testTarget matches the valid target file metadata. +func isValidTarget(testTarget []byte, validMeta data.TargetFileMeta) bool { + localMeta, err := util.GenerateTargetFileMeta(bytes.NewReader(testTarget)) + if err != nil { + return false + } + if err := util.TargetFileMetaEqual(localMeta, validMeta); err != nil { + return false + } + return true +} + +func (t *TUF) GetTarget(name string) ([]byte, error) { + // Get valid target metadata. Does a local verification. + validMeta, err := t.client.Target(name) + if err != nil { + return nil, fmt.Errorf("error verifying local metadata; local cache may be corrupt: %w", err) + } + targetBytes, err := t.targets.Get(name) + if err != nil { + return nil, err + } + + if !isValidTarget(targetBytes, validMeta) { + return nil, fmt.Errorf("cache contains invalid target; local cache may be corrupt") + } + + return targetBytes, nil +} + +// Get target files by a custom usage metadata tag. If there are no files found, +// use the fallback target names to fetch the targets by name. +func (t *TUF) GetTargetsByMeta(usage UsageKind, fallbacks []string) ([]TargetFile, error) { + targets, err := t.client.Targets() + if err != nil { + return nil, fmt.Errorf("error getting targets: %w", err) + } + var matchedTargets []TargetFile + for name, targetMeta := range targets { + // Skip any targets that do not include custom metadata. + if targetMeta.Custom == nil { + continue + } + var scm sigstoreCustomMetadata + err := json.Unmarshal(*targetMeta.Custom, &scm) + if err != nil { + fmt.Fprintf(os.Stderr, "**Warning** Custom metadata not configured properly for target %s, skipping target\n", name) + continue + } + if scm.Sigstore.Usage == usage { + target, err := t.GetTarget(name) + if err != nil { + return nil, fmt.Errorf("error getting target %s by usage: %w", name, err) + } + matchedTargets = append(matchedTargets, TargetFile{Target: target, Status: scm.Sigstore.Status}) + } + } + if len(matchedTargets) == 0 { + for _, fallback := range fallbacks { + target, err := t.GetTarget(fallback) + if err != nil { + fmt.Fprintf(os.Stderr, "**Warning** Missing fallback target %s, skipping\n", fallback) + continue + } + matchedTargets = append(matchedTargets, TargetFile{Target: target, Status: Active}) + } + } + if len(matchedTargets) == 0 { + return matchedTargets, fmt.Errorf("no matching targets by custom metadata, fallbacks not found: %s", strings.Join(fallbacks, ", ")) + } + return matchedTargets, nil +} + +// updateClient() updates the TUF client and also caches new metadata, if needed. +func (t *TUF) updateClient() (data.TargetFiles, error) { + targets, err := t.client.Update() + if err != nil { + // Get some extra information for debugging. What was the state of the top-level + // metadata on the remote? + status := struct { + Mirror string `json:"mirror"` + Metadata map[string]MetadataStatus `json:"metadata"` + }{ + Mirror: t.mirror, + Metadata: make(map[string]MetadataStatus), + } + for _, md := range []string{"root.json", "targets.json", "snapshot.json", "timestamp.json"} { + r, _, err := t.remote.GetMeta(md) + if err != nil { + // May be missing, or failed download. + continue + } + defer r.Close() + b, err := ioutil.ReadAll(r) + if err != nil { + continue + } + mdStatus, err := getMetadataStatus(b) + if err != nil { + continue + } + status.Metadata[md] = *mdStatus + } + b, innerErr := json.MarshalIndent(status, "", "\t") + if innerErr != nil { + return nil, innerErr + } + return nil, fmt.Errorf("error updating to TUF remote mirror: %w\nremote status:%s", err, string(b)) + } + // Success! Cache new metadata, if needed. + if noCache() { + return targets, nil + } + // Sync the on-disk cache with the metadata from the in-memory store. + tufDB := filepath.FromSlash(filepath.Join(rootCacheDir(), "tuf.db")) + diskLocal, err := tuf_leveldbstore.FileLocalStore(tufDB) + defer func() { + if diskLocal != nil { + diskLocal.Close() + } + }() + if err != nil { + return nil, fmt.Errorf("creating cached local store: %w", err) + } + if err := syncLocalMeta(t.local, diskLocal); err != nil { + return nil, err + } + // Return updated targets. + return targets, nil +} + +func (t *TUF) updateMetadataAndDownloadTargets() error { + // Download updated targets and cache new metadata and targets in ${TUF_ROOT}. + // NOTE: This only returns *updated* targets. + targetFiles, err := t.updateClient() + if err != nil { + return err + } + + // Download **newly** updated targets. + // TODO: Consider lazily downloading these -- be careful with embedded targets if so. + for name, targetMeta := range targetFiles { + if err := maybeDownloadRemoteTarget(name, targetMeta, t); err != nil { + return err + } + } + + return nil +} + +type targetDestination struct { + buf *bytes.Buffer +} + +func (t *targetDestination) Write(b []byte) (int, error) { + return t.buf.Write(b) +} + +func (t *targetDestination) Delete() error { + t.buf = &bytes.Buffer{} + return nil +} + +func maybeDownloadRemoteTarget(name string, meta data.TargetFileMeta, t *TUF) error { + // If we already have the target locally, don't bother downloading from remote storage. + if cachedTarget, err := t.targets.Get(name); err == nil { + // If the target we have stored matches the meta, use that. + if isValidTarget(cachedTarget, meta) { + return nil + } + } + + // Check if we already have the target in the embedded store. + w := bytes.Buffer{} + rd, ok := t.embedded.(fs.ReadFileFS) + if !ok { + return errors.New("fs.ReadFileFS unimplemented for embedded repo") + } + b, err := rd.ReadFile(path.Join("repository", "targets", name)) + + if err == nil { + // Unfortunately go:embed appears to somehow replace our line endings on windows, we need to switch them back. + // It should theoretically be safe to do this everywhere - but the files only seem to get mutated on Windows so + // let's only change them back there. + if runtime.GOOS == "windows" { + b = bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n")) + } + + if isValidTarget(b, meta) { + if _, err := io.Copy(&w, bytes.NewReader(b)); err != nil { + return fmt.Errorf("using embedded target: %w", err) + } + } + } + + // Nope -- no local matching target, go download it. + if w.Len() == 0 { + dest := targetDestination{buf: &w} + if err := t.client.Download(name, &dest); err != nil { + return fmt.Errorf("downloading target: %w", err) + } + } + + // Set the target in the cache. + if err := t.targets.Set(name, w.Bytes()); err != nil { + return err + } + return nil +} + +func rootCacheDir() string { + rootDir := os.Getenv(TufRootEnv) + if rootDir == "" { + home, err := os.UserHomeDir() + if err != nil { + home = "" + } + return filepath.FromSlash(filepath.Join(home, ".sigstore", "root")) + } + return rootDir +} + +func cachedRemote(cacheRoot string) string { + return filepath.FromSlash(filepath.Join(cacheRoot, "remote.json")) +} + +func cachedTargetsDir(cacheRoot string) string { + return filepath.FromSlash(filepath.Join(cacheRoot, "targets")) +} + +func syncLocalMeta(from client.LocalStore, to client.LocalStore) error { + // Copy trusted metadata in the from LocalStore into the to LocalStore. + tufLocalStoreMeta, err := from.GetMeta() + if err != nil { + return fmt.Errorf("getting metadata to sync: %w", err) + } + for k, v := range tufLocalStoreMeta { + if err := to.SetMeta(k, v); err != nil { + return fmt.Errorf("syncing local store for metadata %s", k) + } + } + return nil +} + +// Local store implementations +func newLocalStore() (client.LocalStore, error) { + local := client.MemoryLocalStore() + if noCache() { + return local, nil + } + // Otherwise populate the in-memory local store with data fetched from the cache. + tufDB := filepath.FromSlash(filepath.Join(rootCacheDir(), "tuf.db")) + diskLocal, err := tuf_leveldbstore.FileLocalStore(tufDB) + defer func() { + if diskLocal != nil { + diskLocal.Close() + } + }() + if err != nil { + return nil, fmt.Errorf("creating cached local store: %w", err) + } + // Populate the in-memory local store with data fetched from the cache. + if err := syncLocalMeta(diskLocal, local); err != nil { + return nil, err + } + return local, nil +} + +//go:embed repository +var embeddedRootRepo embed.FS + +// getEmbedded is a var for testing. +var getEmbedded = func() fs.FS { return embeddedRootRepo } + +// Target Implementations +type targetImpl interface { + Set(string, []byte) error + Get(string) ([]byte, error) +} + +func newFileImpl() targetImpl { + memTargets := &memoryCache{} + if noCache() { + return memTargets + } + // Otherwise use a disk-cache with in-memory cached targets. + return &diskCache{ + base: cachedTargetsDir(rootCacheDir()), + memory: memTargets, + } +} + +// In-memory cache for targets +type memoryCache struct { + targets map[string][]byte +} + +func (m *memoryCache) Set(p string, b []byte) error { + if m.targets == nil { + m.targets = map[string][]byte{} + } + m.targets[p] = b + return nil +} + +func (m *memoryCache) Get(p string) ([]byte, error) { + if m.targets == nil { + return nil, fmt.Errorf("no cached targets available, cannot retrieve %s", p) + } + b, ok := m.targets[p] + if !ok { + return nil, fmt.Errorf("missing cached target %s", p) + } + return b, nil +} + +// On-disk cache for targets +type diskCache struct { + // Base directory for accessing targets. + base string + // An in-memory map of targets that are kept in sync. + memory *memoryCache +} + +func (d *diskCache) Get(p string) ([]byte, error) { + // Read from the in-memory cache first. + if b, err := d.memory.Get(p); err == nil { + return b, nil + } + fp := filepath.FromSlash(filepath.Join(d.base, p)) + return os.ReadFile(fp) +} + +func (d *diskCache) Set(p string, b []byte) error { + if err := d.memory.Set(p, b); err != nil { + return err + } + if err := os.MkdirAll(d.base, 0700); err != nil { + return fmt.Errorf("creating targets dir: %w", err) + } + fp := filepath.FromSlash(filepath.Join(d.base, p)) + return os.WriteFile(fp, b, 0600) +} + +func noCache() bool { + b, err := strconv.ParseBool(os.Getenv(SigstoreNoCache)) + if err != nil { + return false + } + return b +} + +func remoteFromMirror(mirror string) (client.RemoteStore, error) { + // This is for compatibility with specifying a GCS bucket remote. + if _, parseErr := url.ParseRequestURI(mirror); parseErr != nil { + mirror = fmt.Sprintf("https://%s.storage.googleapis.com", mirror) + } + return client.HTTPRemoteStore(mirror, nil, nil) +} diff --git a/pkg/tuf/client_test.go b/pkg/tuf/client_test.go new file mode 100644 index 000000000..1555f0870 --- /dev/null +++ b/pkg/tuf/client_test.go @@ -0,0 +1,649 @@ +// +// Copyright 2022 The Sigstore 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 ( + "bytes" + "context" + "encoding/json" + "io/fs" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "path" + "path/filepath" + "reflect" + "sort" + "strings" + "sync" + "testing" + "testing/fstest" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/theupdateframework/go-tuf" + "github.com/theupdateframework/go-tuf/data" + "github.com/theupdateframework/go-tuf/verify" +) + +var targets = []string{ + "artifact.pub", + "fulcio.crt.pem", + "fulcio_v1.crt.pem", + "ctfe.pub", + "rekor.pub", + "rekor.0.pub", +} + +func TestNewFromEnv(t *testing.T) { + td := t.TempDir() + t.Setenv("TUF_ROOT", td) + ctx := context.Background() + + // Make sure nothing is expired + tuf, err := NewFromEnv(ctx) + if err != nil { + t.Fatal(err) + } + + checkTargetsAndMeta(t, tuf) + resetForTests() + + // Now try with expired targets + forceExpiration(t, true) + tuf, err = NewFromEnv(ctx) + if err != nil { + t.Fatal(err) + } + checkTargetsAndMeta(t, tuf) + resetForTests() + + if err := Initialize(ctx, DefaultRemoteRoot, nil); err != nil { + t.Error() + } + if l := dirLen(t, td); l == 0 { + t.Errorf("expected filesystem writes, got %d entries", l) + } + + // And go from there! + tuf, err = NewFromEnv(ctx) + if err != nil { + t.Fatal(err) + } + checkTargetsAndMeta(t, tuf) + resetForTests() +} + +func TestNoCache(t *testing.T) { + ctx := context.Background() + // Once more with NO_CACHE + t.Setenv("SIGSTORE_NO_CACHE", "true") + td := t.TempDir() + t.Setenv("TUF_ROOT", td) + + // First initialization, populate the cache. + tuf, err := NewFromEnv(ctx) + if err != nil { + t.Fatal(err) + } + checkTargetsAndMeta(t, tuf) + resetForTests() + + // Force expiration so we have some content to download + forceExpiration(t, true) + + tuf, err = NewFromEnv(ctx) + if err != nil { + t.Fatal(err) + } + checkTargetsAndMeta(t, tuf) + resetForTests() + + // No filesystem writes when using SIGSTORE_NO_CACHE. + if l := dirLen(t, td); l != 0 { + t.Errorf("expected no filesystem writes, got %d entries", l) + } + resetForTests() +} + +func TestCache(t *testing.T) { + ctx := context.Background() + // Once more with cache. + t.Setenv("SIGSTORE_NO_CACHE", "false") + td := t.TempDir() + t.Setenv("TUF_ROOT", td) + + // Make sure nothing is in that directory to start with + if l := dirLen(t, td); l != 0 { + t.Errorf("expected no filesystem writes, got %d entries", l) + } + + // First initialization, populate the cache. Expect disk writes. + tuf, err := NewFromEnv(ctx) + if err != nil { + t.Fatal(err) + } + checkTargetsAndMeta(t, tuf) + resetForTests() + cachedDirLen := dirLen(t, td) + if cachedDirLen == 0 { + t.Errorf("expected filesystem writes, got %d entries", cachedDirLen) + } + + // Nothing should get downloaded if everything is up to date. + forceExpiration(t, false) + _, err = NewFromEnv(ctx) + if err != nil { + t.Fatal(err) + } + resetForTests() + + if l := dirLen(t, td); cachedDirLen != l { + t.Errorf("expected no filesystem writes, got %d entries", l-cachedDirLen) + } + + // Forcing expiration, but expect no disk writes because all targets up to date. + forceExpiration(t, true) + tuf, err = NewFromEnv(ctx) + if err != nil { + t.Fatal(err) + } + + if l := dirLen(t, td); l != cachedDirLen { + t.Errorf("expected filesystem writes, got %d entries", l) + } + checkTargetsAndMeta(t, tuf) + resetForTests() +} + +func TestCustomRoot(t *testing.T) { + ctx := context.Background() + // Create a remote repository. + td := t.TempDir() + remote, r := newTufRepo(t, td, "foo") + + // Serve remote repository. + s := httptest.NewServer(http.FileServer(http.Dir(filepath.Join(td, "repository")))) + defer s.Close() + + // Initialize with custom root. + tufRoot := t.TempDir() + t.Setenv("TUF_ROOT", tufRoot) + meta, err := remote.GetMeta() + if err != nil { + t.Error(err) + } + rootBytes, ok := meta["root.json"] + if !ok { + t.Error(err) + } + if err := Initialize(ctx, s.URL, rootBytes); err != nil { + t.Error(err) + } + if l := dirLen(t, tufRoot); l == 0 { + t.Errorf("expected filesystem writes, got %d entries", l) + } + + // Successfully get target. + tufObj, err := NewFromEnv(ctx) + if err != nil { + t.Fatal(err) + } + if b, err := tufObj.GetTarget("foo.txt"); err != nil || !bytes.Equal(b, []byte("foo")) { + t.Fatal(err) + } + resetForTests() + + // Force expiration on the first timestamp and internal go-tuf verification. + forceExpirationVersion(t, 1) + oldIsExpired := verify.IsExpired + verify.IsExpired = func(time time.Time) bool { + return true + } + + // This should cause an error that remote metadata is expired. + if _, err = NewFromEnv(ctx); err == nil { + t.Errorf("expected expired timestamp from the remote") + } + + // Let internal TUF verification succeed normally now. + verify.IsExpired = oldIsExpired + + // Update remote targets, issue a timestamp v2. + updateTufRepo(t, td, r, "foo1") + + // Use newTuf and successfully get updated metadata using the cached remote location. + resetForTests() + tufObj, err = NewFromEnv(ctx) + if err != nil { + t.Fatal(err) + } + if b, err := tufObj.GetTarget("foo.txt"); err != nil || !bytes.Equal(b, []byte("foo1")) { + t.Fatal(err) + } + resetForTests() +} + +func TestGetTargetsByMeta(t *testing.T) { + ctx := context.Background() + // Create a remote repository. + td := t.TempDir() + remote, _ := newTufCustomRepo(t, td, "foo") + + // Serve remote repository. + s := httptest.NewServer(http.FileServer(http.Dir(filepath.Join(td, "repository")))) + defer s.Close() + + // Initialize with custom root. + tufRoot := t.TempDir() + t.Setenv("TUF_ROOT", tufRoot) + meta, err := remote.GetMeta() + if err != nil { + t.Error(err) + } + rootBytes, ok := meta["root.json"] + if !ok { + t.Error(err) + } + if err := Initialize(ctx, s.URL, rootBytes); err != nil { + t.Error(err) + } + if l := dirLen(t, tufRoot); l == 0 { + t.Errorf("expected filesystem writes, got %d entries", l) + } + + tufObj, err := NewFromEnv(ctx) + if err != nil { + t.Fatal(err) + } + defer resetForTests() + // Fetch a target with no custom metadata. + targets, err := tufObj.GetTargetsByMeta(UnknownUsage, []string{"fooNoCustom.txt"}) + if err != nil { + t.Fatal(err) + } + if len(targets) != 1 { + t.Fatalf("expected one target without custom metadata, got %d targets", len(targets)) + } + if !bytes.Equal(targets[0].Target, []byte("foo")) { + t.Fatalf("target metadata mismatched, expected: %s, got: %s", "foo", string(targets[0].Target)) + } + if targets[0].Status != Active { + t.Fatalf("target without custom metadata not active, got: %v", targets[0].Status) + } + // Fetch multiple targets with no custom metadata. + targets, err = tufObj.GetTargetsByMeta(UnknownUsage, []string{"fooNoCustom.txt", "fooNoCustomOther.txt"}) + if err != nil { + t.Fatal(err) + } + if len(targets) != 2 { + t.Fatalf("expected two targets without custom metadata, got %d targets", len(targets)) + } + if targets[0].Status != Active || targets[1].Status != Active { + t.Fatalf("target without custom metadata not active, got: %v and %v", targets[0].Status, targets[1].Status) + } + // Specify multiple fallbacks with no custom metadata. + targets, err = tufObj.GetTargetsByMeta(UnknownUsage, []string{"fooNoCustom.txt", "fooNoCustomOtherMissingTarget.txt"}) + if err != nil { + t.Fatal(err) + } + if len(targets) != 1 { + t.Fatalf("expected one targets without custom metadata, got %d targets", len(targets)) + } + if targets[0].Status != Active { + t.Fatalf("target without custom metadata not active, got: %v and %v", targets[0].Status, targets[1].Status) + } + // Fetch targets with custom metadata. + targets, err = tufObj.GetTargetsByMeta(Fulcio, []string{"fooNoCustom.txt"}) + if err != nil { + t.Fatal(err) + } + if len(targets) != 2 { + t.Fatalf("expected two targets without custom metadata, got %d targets", len(targets)) + } + targetBytes := []string{string(targets[0].Target), string(targets[1].Target)} + expectedTB := []string{"foo", "foo"} + if !reflect.DeepEqual(targetBytes, expectedTB) { + t.Fatalf("target metadata mismatched, expected: %v, got: %v", expectedTB, targetBytes) + } + targetStatuses := []StatusKind{targets[0].Status, targets[1].Status} + sort.Slice(targetStatuses, func(i, j int) bool { + return targetStatuses[i] < targetStatuses[j] + }) + expectedTS := []StatusKind{Active, Expired} + if !reflect.DeepEqual(targetStatuses, expectedTS) { + t.Fatalf("unexpected target status with custom metadata, expected %v, got: %v", expectedTS, targetStatuses) + } + // Error when fetching target that does not exist. + _, err = tufObj.GetTargetsByMeta(UsageKind(UnknownStatus), []string{"unknown.txt"}) + expectedErr := "file not found: unknown.txt" + if !strings.Contains(err.Error(), "not found: unknown.txt") { + t.Fatalf("unexpected error fetching missing metadata, expected: %s, got: %s", expectedErr, err.Error()) + } +} + +func makeMapFS(repo string) (fs fstest.MapFS) { + fs = make(fstest.MapFS) + _ = filepath.Walk(repo, + func(fpath string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(repo, fpath) + if info.IsDir() { + fs[path.Join("repository", rel)] = &fstest.MapFile{Mode: os.ModeDir} + } else { + b, _ := os.ReadFile(fpath) + fs[path.Join("repository", rel)] = &fstest.MapFile{Data: b} + } + return nil + }) + return +} + +// Regression test for failure to fetch a target that does not exist in the embedded +// repository on an update. The new target exists on the remote before the TUF object +// is initialized. +func TestUpdatedTargetNamesEmbedded(t *testing.T) { + td := t.TempDir() + // Set the TUF_ROOT so we don't interact with other tests and local TUF roots. + t.Setenv("TUF_ROOT", td) + + origEmbedded := getEmbedded + origDefaultRemote := getRemoteRoot + + // Create an "expired" embedded repository that does not contain newTarget. + ctx := context.Background() + store, r := newTufCustomRepo(t, td, "foo") + repository := filepath.FromSlash(filepath.Join(td, "repository")) + mapfs := makeMapFS(repository) + getEmbedded = func() fs.FS { return mapfs } + + oldIsExpired := isExpiredTimestamp + isExpiredTimestamp = func(metadata []byte) bool { + m, _ := store.GetMeta() + timestampExpires, _ := getExpiration(m["timestamp.json"]) + metadataExpires, _ := getExpiration(metadata) + return metadataExpires.Sub(*timestampExpires) <= 0 + } + defer func() { + getEmbedded = origEmbedded + getRemoteRoot = origDefaultRemote + isExpiredTimestamp = oldIsExpired + }() + + // Assert that the embedded repository does not contain the newTarget. + newTarget := "fooNew.txt" + rd, ok := getEmbedded().(fs.ReadFileFS) + if !ok { + t.Fatal("fs.ReadFileFS unimplemented for embedded repo") + } + if _, err := rd.ReadFile(path.Join("repository", "targets", newTarget)); err == nil { + t.Fatal("embedded repository should not contain new target") + } + + // Serve an updated remote repository with the newTarget. + addNewCustomTarget(t, td, r, map[string]string{newTarget: "newdata"}) + s := httptest.NewServer(http.FileServer(http.Dir(repository))) + defer s.Close() + getRemoteRoot = func() string { return s.URL } + + // Initialize. + tufObj, err := NewFromEnv(ctx) + if err != nil { + t.Fatal(err) + } + defer resetForTests() + + // Try to retrieve the newly added target. + targets, err := tufObj.GetTargetsByMeta(Fulcio, []string{"fooNoCustom.txt"}) + if err != nil { + t.Fatal(err) + } + if len(targets) != 3 { + t.Fatalf("expected three target without custom metadata, got %d targets", len(targets)) + } + targetBytes := []string{string(targets[0].Target), string(targets[1].Target), string(targets[2].Target)} + expectedTB := []string{"foo", "foo", "newdata"} + if !cmp.Equal(targetBytes, expectedTB, + cmpopts.SortSlices(func(a, b string) bool { return a < b })) { + t.Fatalf("target data mismatched, expected: %v, got: %v", expectedTB, targetBytes) + } +} + +func checkTargetsAndMeta(t *testing.T, tuf *TUF) { + // Check the targets + t.Helper() + for _, target := range targets { + if _, err := tuf.GetTarget(target); err != nil { + t.Fatal(err) + } + } + + // An invalid target + if _, err := tuf.GetTarget("invalid"); err == nil { + t.Error("expected error reading target, got nil") + } + + // Check root status matches + status, err := tuf.getRootStatus() + if err != nil { + t.Fatal(err) + } + if !cmp.Equal(targets, status.Targets, + cmpopts.SortSlices(func(a, b string) bool { return a < b })) { + t.Errorf("mismatched targets, expected %s, got %s", targets, status.Targets) + } +} + +func dirLen(t *testing.T, td string) int { + t.Helper() + de, err := os.ReadDir(td) + if err != nil { + t.Fatal(err) + } + return len(de) +} + +func forceExpiration(t *testing.T, expire bool) { + oldIsExpiredTimestamp := isExpiredTimestamp + isExpiredTimestamp = func(_ []byte) bool { + return expire + } + t.Cleanup(func() { + isExpiredTimestamp = oldIsExpiredTimestamp + }) +} + +func forceExpirationVersion(t *testing.T, version int64) { + oldIsExpiredTimestamp := isExpiredTimestamp + isExpiredTimestamp = func(metadata []byte) bool { + s := &data.Signed{} + if err := json.Unmarshal(metadata, s); err != nil { + return true + } + sm := &data.Timestamp{} + if err := json.Unmarshal(s.Signed, sm); err != nil { + return true + } + return sm.Version <= version + } + t.Cleanup(func() { + isExpiredTimestamp = oldIsExpiredTimestamp + }) +} + +// newTufCustomRepo initializes a TUF repository with root, targets, snapshot, and timestamp roles +// 4 targets are created to exercise various code paths, including two targets with no custom metadata, +// one target with custom metadata marked as active, and another with custom metadata marked as expired. +func newTufCustomRepo(t *testing.T, td string, targetData string) (tuf.LocalStore, *tuf.Repo) { + scmActive, err := json.Marshal(&sigstoreCustomMetadata{Sigstore: customMetadata{Usage: Fulcio, Status: Active}}) + if err != nil { + t.Error(err) + } + scmExpired, err := json.Marshal(&sigstoreCustomMetadata{Sigstore: customMetadata{Usage: Fulcio, Status: Expired}}) + if err != nil { + t.Error(err) + } + + remote := tuf.FileSystemStore(td, nil) + r, err := tuf.NewRepo(remote) + if err != nil { + t.Error(err) + } + if err := r.Init(false); err != nil { + t.Error(err) + } + for _, role := range []string{"root", "targets", "snapshot", "timestamp"} { + if _, err := r.GenKey(role); err != nil { + t.Error(err) + } + } + for name, scm := range map[string]json.RawMessage{ + "fooNoCustom.txt": nil, "fooNoCustomOther.txt": nil, + "fooActive.txt": scmActive, "fooExpired.txt": scmExpired} { + targetPath := filepath.FromSlash(filepath.Join(td, "staged", "targets", name)) + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + t.Error(err) + } + if err := ioutil.WriteFile(targetPath, []byte(targetData), 0600); err != nil { + t.Error(err) + } + if err := r.AddTarget(name, scm); err != nil { + t.Error(err) + } + } + if err := r.Snapshot(); err != nil { + t.Error(err) + } + if err := r.Timestamp(); err != nil { + t.Error(err) + } + if err := r.Commit(); err != nil { + t.Error(err) + } + return remote, r +} + +func addNewCustomTarget(t *testing.T, td string, r *tuf.Repo, targetData map[string]string) { + scmActive, err := json.Marshal(&sigstoreCustomMetadata{Sigstore: customMetadata{Usage: Fulcio, Status: Active}}) + if err != nil { + t.Error(err) + } + + for name, data := range targetData { + targetPath := filepath.FromSlash(filepath.Join(td, "staged", "targets", name)) + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + t.Error(err) + } + if err := ioutil.WriteFile(targetPath, []byte(data), 0600); err != nil { + t.Error(err) + } + if err := r.AddTarget(name, scmActive); err != nil { + t.Error(err) + } + } + + if err := r.Snapshot(); err != nil { + t.Error(err) + } + if err := r.Timestamp(); err != nil { + t.Error(err) + } + if err := r.Commit(); err != nil { + t.Error(err) + } +} + +func newTufRepo(t *testing.T, td string, targetData string) (tuf.LocalStore, *tuf.Repo) { + remote := tuf.FileSystemStore(td, nil) + r, err := tuf.NewRepo(remote) + if err != nil { + t.Error(err) + } + if err := r.Init(false); err != nil { + t.Error(err) + } + for _, role := range []string{"root", "targets", "snapshot", "timestamp"} { + if _, err := r.GenKey(role); err != nil { + t.Error(err) + } + } + targetPath := filepath.FromSlash(filepath.Join(td, "staged", "targets", "foo.txt")) + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + t.Error(err) + } + if err := ioutil.WriteFile(targetPath, []byte(targetData), 0600); err != nil { + t.Error(err) + } + if err := r.AddTarget("foo.txt", nil); err != nil { + t.Error(err) + } + if err := r.Snapshot(); err != nil { + t.Error(err) + } + if err := r.Timestamp(); err != nil { + t.Error(err) + } + if err := r.Commit(); err != nil { + t.Error(err) + } + return remote, r +} + +func updateTufRepo(t *testing.T, td string, r *tuf.Repo, targetData string) { + targetPath := filepath.FromSlash(filepath.Join(td, "staged", "targets", "foo.txt")) + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + t.Error(err) + } + if err := ioutil.WriteFile(targetPath, []byte(targetData), 0600); err != nil { + t.Error(err) + } + if err := r.AddTarget("foo.txt", nil); err != nil { + t.Error(err) + } + if err := r.Snapshot(); err != nil { + t.Error(err) + } + if err := r.Timestamp(); err != nil { + t.Error(err) + } + if err := r.Commit(); err != nil { + t.Error(err) + } +} +func TestConcurrentAccess(t *testing.T) { + var wg sync.WaitGroup + + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + tufObj, err := NewFromEnv(context.Background()) + if err != nil { + t.Errorf("Failed to construct NewFromEnv: %s", err) + } + if tufObj == nil { + t.Error("Got back nil tufObj") + } + time.Sleep(1 * time.Second) + }() + } + wg.Wait() + resetForTests() +} diff --git a/pkg/tuf/policy.go b/pkg/tuf/policy.go new file mode 100644 index 000000000..055103aa1 --- /dev/null +++ b/pkg/tuf/policy.go @@ -0,0 +1,204 @@ +// +// Copyright 2022 The Sigstore 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. + +// Contains root policy definitions. +// Eventually, this will move this to go-tuf definitions. + +package tuf + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + cjson "github.com/secure-systems-lab/go-securesystemslib/cjson" +) + +type Signed struct { + Signed json.RawMessage `json:"signed"` + Signatures []Signature `json:"signatures"` +} + +type Signature struct { + KeyID string `json:"keyid"` + Signature string `json:"sig"` + Cert string `json:"cert,omitempty"` +} + +type Key struct { + Type string `json:"keytype"` + Scheme string `json:"scheme"` + Algorithms []string `json:"keyid_hash_algorithms,omitempty"` + Value json.RawMessage `json:"keyval"` + + id string + idOnce sync.Once +} + +func (k *Key) ID() string { + k.idOnce.Do(func() { + data, _ := cjson.EncodeCanonical(k) + digest := sha256.Sum256(data) + k.id = hex.EncodeToString(digest[:]) + }) + return k.id +} + +func (k *Key) ContainsID(id string) bool { + return id == k.ID() +} + +type Root struct { + Type string `json:"_type"` + SpecVersion string `json:"spec_version"` + Version int `json:"version"` + Expires time.Time `json:"expires"` + Keys map[string]*Key `json:"keys"` + Roles map[string]*Role `json:"roles"` + Namespace string `json:"namespace"` + + ConsistentSnapshot bool `json:"consistent_snapshot"` +} + +func NewRoot() *Root { + return &Root{ + Type: "root", + SpecVersion: "1.0", + Version: 1, + // Default expires in 3 months + Expires: time.Now().AddDate(0, 3, 0).UTC().Round(time.Second), + Keys: make(map[string]*Key), + Roles: make(map[string]*Role), + ConsistentSnapshot: true, + } +} + +func (r *Root) AddKey(key *Key) bool { + changed := false + if _, ok := r.Keys[key.ID()]; !ok { + changed = true + r.Keys[key.ID()] = key + } + + return changed +} + +type Role struct { + KeyIDs []string `json:"keyids"` + Threshold int `json:"threshold"` +} + +func (r *Role) AddKeysWithThreshold(keys []*Key, threshold int) bool { + roleIDs := make(map[string]struct{}) + for _, id := range r.KeyIDs { + roleIDs[id] = struct{}{} + } + changed := false + for _, key := range keys { + if _, ok := roleIDs[key.ID()]; !ok { + changed = true + r.KeyIDs = append(r.KeyIDs, key.ID()) + } + } + r.Threshold = threshold + return changed +} + +func (r *Root) Marshal() (*Signed, error) { + // Marshals the Root into a Signed type + b, err := cjson.EncodeCanonical(r) + if err != nil { + return nil, err + } + return &Signed{Signed: b}, nil +} + +func (r *Root) ValidKey(key *Key, role string) (string, error) { + // Checks if id is a valid key for role by matching the identity and issuer if specified. + // Returns the key ID or an error if invalid key. + fulcioKeyVal, err := GetFulcioKeyVal(key) + if err != nil { + return "", fmt.Errorf("error parsing signer key: %w", err) + } + + result := "" + for keyid, rootKey := range r.Keys { + fulcioRootKeyVal, err := GetFulcioKeyVal(rootKey) + if err != nil { + return "", fmt.Errorf("error parsing root key: %w", err) + } + if fulcioKeyVal.Identity == fulcioRootKeyVal.Identity { + if fulcioRootKeyVal.Issuer == "" || fulcioRootKeyVal.Issuer == fulcioKeyVal.Issuer { + result = keyid + break + } + } + } + if result == "" { + return "", errors.New("key not found in root keys") + } + + rootRole, ok := r.Roles[role] + if !ok { + return "", errors.New("invalid role") + } + for _, id := range rootRole.KeyIDs { + if id == result { + return result, nil + } + } + return "", errors.New("key not found in role") +} + +func (s *Signed) JSONMarshal(prefix, indent string) ([]byte, error) { + // Marshals Signed with prefix and indent. + b, err := cjson.EncodeCanonical(s) + if err != nil { + return []byte{}, err + } + + var out bytes.Buffer + if err := json.Indent(&out, b, prefix, indent); err != nil { + return []byte{}, err + } + + return out.Bytes(), nil +} + +func (s *Signed) AddOrUpdateSignature(key *Key, signature Signature) error { + root := &Root{} + if err := json.Unmarshal(s.Signed, root); err != nil { + return fmt.Errorf("unmarshalling root policy: %w", err) + } + var err error + signature.KeyID, err = root.ValidKey(key, "root") + if err != nil { + return errors.New("invalid root key") + } + signatures := []Signature{} + for _, sig := range s.Signatures { + if sig.KeyID != signature.KeyID { + signatures = append(signatures, sig) + } + } + signatures = append(signatures, signature) + s.Signatures = signatures + return nil +} diff --git a/pkg/tuf/policy_test.go b/pkg/tuf/policy_test.go new file mode 100644 index 000000000..0b8c6387e --- /dev/null +++ b/pkg/tuf/policy_test.go @@ -0,0 +1,94 @@ +// +// Copyright 2022 The Sigstore 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. + +// Contains root policy definitions. +// Eventually, this will move this to go-tuf definitions. + +package tuf + +import ( + "encoding/json" + "testing" +) + +func TestAddKey(t *testing.T) { + root := NewRoot() + publicKey := FulcioVerificationKey("test@rekor.dev", "") + if !root.AddKey(publicKey) { + t.Errorf("Adding new key failed") + } + if _, ok := root.Keys[publicKey.ID()]; !ok { + t.Errorf("Error adding public key") + } + // Add duplicate key. + if root.AddKey(publicKey) { + t.Errorf("Duplicate key should not add to dictionary") + } + if len(root.Keys) != 1 { + t.Errorf("Root keys should contain exactly one key.") + } +} + +func TestValidKey(t *testing.T) { + root := NewRoot() + publicKey := FulcioVerificationKey("test@rekor.dev", "https://accounts.google.com") + if !root.AddKey(publicKey) { + t.Errorf("Adding new key failed") + } + role := &Role{KeyIDs: []string{}, Threshold: 1} + role.AddKeysWithThreshold([]*Key{publicKey}, 2) + root.Roles["root"] = role + + if _, ok := root.Keys[publicKey.ID()]; !ok { + t.Errorf("Error adding public key") + } + if _, err := root.ValidKey(publicKey, "root"); err != nil { + t.Errorf("Error checking key validity %s", err) + } + // Now change issuer, and expect error. + publicKey = FulcioVerificationKey("test@rekor.dev", "") + if _, err := root.ValidKey(publicKey, "root"); err == nil { + t.Errorf("Expected invalid key with mismatching issuer") + } +} + +func TestRootRole(t *testing.T) { + root := NewRoot() + publicKey := FulcioVerificationKey("test@rekor.dev", "") + role := &Role{KeyIDs: []string{}, Threshold: 1} + role.AddKeysWithThreshold([]*Key{publicKey}, 2) + root.Roles["root"] = role + policy, err := root.Marshal() + if err != nil { + t.Errorf("Error marshalling root policy") + } + newRoot := Root{} + if err := json.Unmarshal(policy.Signed, &newRoot); err != nil { + t.Errorf("Error marshalling root policy") + } + rootRole, ok := newRoot.Roles["root"] + if !ok { + t.Errorf("Missing root role") + } + if len(rootRole.KeyIDs) != 1 { + t.Errorf("Missing root key ID") + } + if rootRole.KeyIDs[0] != publicKey.ID() { + t.Errorf("Bad root role key ID") + } + if rootRole.Threshold != 2 { + t.Errorf("Threshold incorrect") + } +} diff --git a/pkg/tuf/repository/root.json b/pkg/tuf/repository/root.json new file mode 100644 index 000000000..386ebe62c --- /dev/null +++ b/pkg/tuf/repository/root.json @@ -0,0 +1,144 @@ +{ + "signatures": [ + { + "keyid": "2f64fb5eac0cf94dd39bb45308b98920055e9a0d8e012a7220787834c60aef97", + "sig": "3046022100d3ea59490b253beae0926c6fa63f54336dea1ed700555be9f27ff55cd347639c0221009157d1ba012cead81948a4ab777d355451d57f5c4a2d333fc68d2e3f358093c2" + }, + { + "keyid": "bdde902f5ec668179ff5ca0dabf7657109287d690bf97e230c21d65f99155c62", + "sig": "304502206eaef40564403ce572c6d062e0c9b0aab5e0223576133e081e1b495e8deb9efd02210080fd6f3464d759601b4afec596bbd5952f3a224cd06ed1cdfc3c399118752ba2" + }, + { + "keyid": "eaf22372f417dd618a46f6c627dbc276e9fd30a004fc94f9be946e73f8bd090b", + "sig": "304502207baace02f56d8e6069f10b6ff098a26e7f53a7f9324ad62cffa0557bdeb9036c022100fb3032baaa090d0040c3f2fd872571c84479309b773208601d65948df87a9720" + }, + { + "keyid": "f40f32044071a9365505da3d1e3be6561f6f22d0e60cf51df783999f6c3429cb", + "sig": "304402205180c01905505dd88acd7a2dad979dd75c979b3722513a7bdedac88c6ae8dbeb022056d1ddf7a192f0b1c2c90ff487de2fb3ec9f0c03f66ea937c78d3b6a493504ca" + }, + { + "keyid": "f505595165a177a41750a8e864ed1719b1edfccd5a426fd2c0ffda33ce7ff209", + "sig": "3046022100c8806d4647c514d80fd8f707d3369444c4fd1d0812a2d25f828e564c99790e3f022100bb51f12e862ef17a7d3da2ac103bebc5c7e792237006c4cafacd76267b249c2f" + } + ], + "signed": { + "_type": "root", + "consistent_snapshot": false, + "expires": "2022-05-11T19:09:02.663975009Z", + "keys": { + "2f64fb5eac0cf94dd39bb45308b98920055e9a0d8e012a7220787834c60aef97": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa-sha2-nistp256", + "keyval": { + "public": "04cbc5cab2684160323c25cd06c3307178a6b1d1c9b949328453ae473c5ba7527e35b13f298b41633382241f3fd8526c262d43b45adee5c618fa0642c82b8a9803" + }, + "scheme": "ecdsa-sha2-nistp256" + }, + "b6710623a30c010738e64c5209d367df1c0a18cf90e6ab5292fb01680f83453d": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa-sha2-nistp256", + "keyval": { + "public": "04fa1a3e42f2300cd3c5487a61509348feb1e936920fef2f83b7cd5dbe7ba045f538725ab8f18a666e6233edb7e0db8766c8dc336633449c5e1bbe0c182b02df0b" + }, + "scheme": "ecdsa-sha2-nistp256" + }, + "bdde902f5ec668179ff5ca0dabf7657109287d690bf97e230c21d65f99155c62": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa-sha2-nistp256", + "keyval": { + "public": "04a71aacd835dc170ba6db3fa33a1a33dee751d4f8b0217b805b9bd3242921ee93672fdcfd840576c5bb0dc0ed815edf394c1ee48c2b5e02485e59bfc512f3adc7" + }, + "scheme": "ecdsa-sha2-nistp256" + }, + "eaf22372f417dd618a46f6c627dbc276e9fd30a004fc94f9be946e73f8bd090b": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa-sha2-nistp256", + "keyval": { + "public": "04117b33dd265715bf23315e368faa499728db8d1f0a377070a1c7b1aba2cc21be6ab1628e42f2cdd7a35479f2dce07b303a8ba646c55569a8d2a504ba7e86e447" + }, + "scheme": "ecdsa-sha2-nistp256" + }, + "f40f32044071a9365505da3d1e3be6561f6f22d0e60cf51df783999f6c3429cb": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa-sha2-nistp256", + "keyval": { + "public": "04cc1cd53a61c23e88cc54b488dfae168a257c34fac3e88811c55962b24cffbfecb724447999c54670e365883716302e49da57c79a33cd3e16f81fbc66f0bcdf48" + }, + "scheme": "ecdsa-sha2-nistp256" + }, + "f505595165a177a41750a8e864ed1719b1edfccd5a426fd2c0ffda33ce7ff209": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa-sha2-nistp256", + "keyval": { + "public": "048a78a44ac01099890d787e5e62afc29c8ccb69a70ec6549a6b04033b0a8acbfb42ab1ab9c713d225cdb52b858886cf46c8e90a7f3b9e6371882f370c259e1c5b" + }, + "scheme": "ecdsa-sha2-nistp256" + }, + "fc61191ba8a516fe386c7d6c97d918e1d241e1589729add09b122725b8c32451": { + "keyid_hash_algorithms": [ + "sha256", + "sha512" + ], + "keytype": "ecdsa-sha2-nistp256", + "keyval": { + "public": "044c7793ab74b9ddd713054e587b8d9c75c5f6025633d0fef7ca855ed5b8d5a474b23598fe33eb4a63630d526f74d4bdaec8adcb51993ed65652d651d7c49203eb" + }, + "scheme": "ecdsa-sha2-nistp256" + } + }, + "roles": { + "root": { + "keyids": [ + "2f64fb5eac0cf94dd39bb45308b98920055e9a0d8e012a7220787834c60aef97", + "bdde902f5ec668179ff5ca0dabf7657109287d690bf97e230c21d65f99155c62", + "eaf22372f417dd618a46f6c627dbc276e9fd30a004fc94f9be946e73f8bd090b", + "f40f32044071a9365505da3d1e3be6561f6f22d0e60cf51df783999f6c3429cb", + "f505595165a177a41750a8e864ed1719b1edfccd5a426fd2c0ffda33ce7ff209" + ], + "threshold": 3 + }, + "snapshot": { + "keyids": [ + "fc61191ba8a516fe386c7d6c97d918e1d241e1589729add09b122725b8c32451" + ], + "threshold": 1 + }, + "targets": { + "keyids": [ + "2f64fb5eac0cf94dd39bb45308b98920055e9a0d8e012a7220787834c60aef97", + "bdde902f5ec668179ff5ca0dabf7657109287d690bf97e230c21d65f99155c62", + "eaf22372f417dd618a46f6c627dbc276e9fd30a004fc94f9be946e73f8bd090b", + "f40f32044071a9365505da3d1e3be6561f6f22d0e60cf51df783999f6c3429cb", + "f505595165a177a41750a8e864ed1719b1edfccd5a426fd2c0ffda33ce7ff209" + ], + "threshold": 3 + }, + "timestamp": { + "keyids": [ + "b6710623a30c010738e64c5209d367df1c0a18cf90e6ab5292fb01680f83453d" + ], + "threshold": 1 + } + }, + "spec_version": "1.0", + "version": 2 + } +} \ No newline at end of file diff --git a/pkg/tuf/repository/targets/artifact.pub b/pkg/tuf/repository/targets/artifact.pub new file mode 100644 index 000000000..d6e745bdd --- /dev/null +++ b/pkg/tuf/repository/targets/artifact.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhyQCx0E9wQWSFI9ULGwy3BuRklnt +IqozONbbdbqz11hlRJy9c7SG+hdcFl9jE9uE/dwtuwU2MqU9T/cN0YkWww== +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/pkg/tuf/repository/targets/ctfe.pub b/pkg/tuf/repository/targets/ctfe.pub new file mode 100644 index 000000000..1bb1488c9 --- /dev/null +++ b/pkg/tuf/repository/targets/ctfe.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3Pyu +dDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w== +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/pkg/tuf/repository/targets/fulcio.crt.pem b/pkg/tuf/repository/targets/fulcio.crt.pem new file mode 100644 index 000000000..6a06ff300 --- /dev/null +++ b/pkg/tuf/repository/targets/fulcio.crt.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAq +MRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIx +MDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUu +ZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSy +A7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0Jcas +taRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6Nm +MGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYE +FMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2u +Su1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJx +Ve/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uup +Hr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/pkg/tuf/repository/targets/fulcio_v1.crt.pem b/pkg/tuf/repository/targets/fulcio_v1.crt.pem new file mode 100644 index 000000000..3afc46bb6 --- /dev/null +++ b/pkg/tuf/repository/targets/fulcio_v1.crt.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMw +KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y +MTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3Jl +LmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7 +XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxex +X69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92j +YzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRY +wB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQ +KsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCM +WP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9 +TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ +-----END CERTIFICATE----- \ No newline at end of file diff --git a/pkg/tuf/repository/targets/rekor.0.pub b/pkg/tuf/repository/targets/rekor.0.pub new file mode 100644 index 000000000..050ef6014 --- /dev/null +++ b/pkg/tuf/repository/targets/rekor.0.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwr +kBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw== +-----END PUBLIC KEY----- diff --git a/pkg/tuf/repository/targets/rekor.json b/pkg/tuf/repository/targets/rekor.json new file mode 100644 index 000000000..f86930d53 --- /dev/null +++ b/pkg/tuf/repository/targets/rekor.json @@ -0,0 +1,23 @@ +{ + "signatures": [ + { + "keyid": "ae0c689c6347ada7359df48934991f4e013193d6ddf3482a5ffb293f74f3b217", + "sig": "3045022076eadd73f6664bac5cc91f12d3a7ddcdd53f9bde661f147651196ff66e7235d1022100f7b3143792405f9e8a75331a05d4128bdf083de302801e99c3d027919a4b03da" + } + ], + "signed": { + "_type": "targets", + "expires": "2022-05-11T19:10:11Z", + "spec_version": "1.0", + "targets": { + "rekor.0.pub": { + "hashes": { + "sha256": "dce5ef715502ec9f3cdfd11f8cc384b31a6141023d3e7595e9908a81cb6241bd", + "sha512": "0ae7705e02db33e814329746a4a0e5603c5bdcd91c96d072158d71011a2695788866565a2fec0fe363eb72cbcaeda39e54c5fe8d416daf9f3101fdba4217ef35" + }, + "length": 178 + } + }, + "version": 1 + } +} \ No newline at end of file diff --git a/pkg/tuf/repository/targets/rekor.pub b/pkg/tuf/repository/targets/rekor.pub new file mode 100644 index 000000000..050ef6014 --- /dev/null +++ b/pkg/tuf/repository/targets/rekor.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwr +kBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw== +-----END PUBLIC KEY----- diff --git a/pkg/tuf/signer.go b/pkg/tuf/signer.go new file mode 100644 index 000000000..3ad56874a --- /dev/null +++ b/pkg/tuf/signer.go @@ -0,0 +1,50 @@ +// +// Copyright 2022 The Sigstore 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 ( + "encoding/json" +) + +const ( + KeyTypeFulcio = "sigstore-oidc" + KeySchemeFulcio = "https://fulcio.sigstore.dev" +) + +var ( + KeyAlgorithms = []string{"sha256", "sha512"} +) + +type FulcioKeyVal struct { + Identity string `json:"identity"` + Issuer string `json:"issuer,omitempty"` +} + +func FulcioVerificationKey(email string, issuer string) *Key { + keyValBytes, _ := json.Marshal(FulcioKeyVal{Identity: email, Issuer: issuer}) + return &Key{ + Type: KeyTypeFulcio, + Scheme: KeySchemeFulcio, + Algorithms: KeyAlgorithms, + Value: keyValBytes, + } +} + +func GetFulcioKeyVal(key *Key) (*FulcioKeyVal, error) { + fulcioKeyVal := &FulcioKeyVal{} + err := json.Unmarshal(key.Value, fulcioKeyVal) + return fulcioKeyVal, err +} diff --git a/pkg/tuf/status_type.go b/pkg/tuf/status_type.go new file mode 100644 index 000000000..8a020e5d2 --- /dev/null +++ b/pkg/tuf/status_type.go @@ -0,0 +1,60 @@ +// Copyright 2022 The Sigstore 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 ( + "fmt" + "strings" +) + +type StatusKind int + +const ( + UnknownStatus StatusKind = iota + Active + Expired +) + +var toStatusString = map[StatusKind]string{ + UnknownStatus: "Unknown", + Active: "Active", + Expired: "Expired", +} + +func (s StatusKind) String() string { + return toStatusString[s] +} + +func (s StatusKind) MarshalText() ([]byte, error) { + str := s.String() + if len(str) == 0 { + return nil, fmt.Errorf("error while marshalling, int(StatusKind)=%d not valid", int(s)) + } + return []byte(s.String()), nil +} + +func (s *StatusKind) UnmarshalText(text []byte) error { + switch strings.ToLower(string(text)) { + case "unknown": + *s = UnknownStatus + case "active": + *s = Active + case "expired": + *s = Expired + default: + return fmt.Errorf("error while unmarshalling, StatusKind=%v not valid", string(text)) + } + return nil +} diff --git a/pkg/tuf/status_type_test.go b/pkg/tuf/status_type_test.go new file mode 100644 index 000000000..bc34a3451 --- /dev/null +++ b/pkg/tuf/status_type_test.go @@ -0,0 +1,84 @@ +// Copyright 2022 The Sigstore 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 ( + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" +) + +func TestMarshalStatusType(t *testing.T) { + statuses := []StatusKind{UnknownStatus, Active, Expired} + bytes, err := json.Marshal(statuses) + if err != nil { + t.Fatalf("expected no error marshalling struct, got: %v", err) + } + expected := `["Unknown","Active","Expired"]` + if string(bytes) != expected { + t.Fatalf("error while marshalling, expected: %s, got: %s", expected, bytes) + } +} + +func TestMarshalInvalidStatusType(t *testing.T) { + invalidStatus := 42 + statuses := []StatusKind{StatusKind(invalidStatus)} + bytes, err := json.Marshal(statuses) + if bytes != nil { + t.Fatalf("expected error marshalling struct, got: %v", bytes) + } + expectedErr := fmt.Sprintf("error while marshalling, int(StatusKind)=%d not valid", invalidStatus) + if !strings.Contains(err.Error(), expectedErr) { + t.Fatalf("expected error marshalling struct, expected: %v, got: %v", expectedErr, err) + } +} + +func TestUnmarshalStatusType(t *testing.T) { + var statuses []StatusKind + j := json.RawMessage(`["expired", "active", "unknown"]`) + err := json.Unmarshal(j, &statuses) + if err != nil { + t.Fatalf("expected no error unmarshalling struct, got: %v", err) + } + if !reflect.DeepEqual(statuses, []StatusKind{Expired, Active, UnknownStatus}) { + t.Fatalf("expected [Expired, Active, Unknown], got: %v", statuses) + } +} + +func TestUnmarshalStatusTypeCapitalization(t *testing.T) { + // Any capitalization is allowed. + var statuses []StatusKind + j := json.RawMessage(`["eXpIrEd", "aCtIvE", "uNkNoWn"]`) + err := json.Unmarshal(j, &statuses) + if err != nil { + t.Fatalf("expected no error unmarshalling struct, got: %v", err) + } + if !reflect.DeepEqual(statuses, []StatusKind{Expired, Active, UnknownStatus}) { + t.Fatalf("expected [Expired, Active, Unknown], got: %v", statuses) + } +} + +func TestUnmarshalInvalidStatusType(t *testing.T) { + var statuses []StatusKind + invalidStatus := "invalid" + j := json.RawMessage(fmt.Sprintf(`["%s"]`, invalidStatus)) + err := json.Unmarshal(j, &statuses) + expectedErr := fmt.Sprintf("error while unmarshalling, StatusKind=%s not valid", invalidStatus) + if !strings.Contains(err.Error(), expectedErr) { + t.Fatalf("expected error unmarshalling struct, expected: %v, got: %v", expectedErr, err) + } +} diff --git a/pkg/tuf/testutils.go b/pkg/tuf/testutils.go new file mode 100644 index 000000000..c4efe5f13 --- /dev/null +++ b/pkg/tuf/testutils.go @@ -0,0 +1,130 @@ +// +// Copyright 2022 The Sigstore 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" + "crypto/x509" + "encoding/json" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/sigstore/sigstore/pkg/cryptoutils" + "github.com/sigstore/sigstore/pkg/signature" + "github.com/sigstore/sigstore/pkg/signature/options" + "github.com/theupdateframework/go-tuf" +) + +type TestSigstoreRoot struct { + Rekor signature.Verifier + FulcioCertificate *x509.Certificate + // TODO: Include a CTFE key if/when cosign verifies SCT. +} + +// This creates a new sigstore TUF repo whose signers can be used to create dynamic +// signed Rekor entries. +func NewSigstoreTufRepo(t *testing.T, root TestSigstoreRoot) (tuf.LocalStore, *tuf.Repo) { + td := t.TempDir() + ctx := context.Background() + remote := tuf.FileSystemStore(td, nil) + r, err := tuf.NewRepo(remote) + if err != nil { + t.Error(err) + } + if err := r.Init(false); err != nil { + t.Error(err) + } + + for _, role := range []string{"root", "targets", "snapshot", "timestamp"} { + if _, err := r.GenKey(role); err != nil { + t.Error(err) + } + } + targetsPath := filepath.Join(td, "staged", "targets") + if err := os.MkdirAll(filepath.Dir(targetsPath), 0755); err != nil { + t.Error(err) + } + // Add the rekor key target + pk, err := root.Rekor.PublicKey(options.WithContext(ctx)) + if err != nil { + t.Error(err) + } + b, err := x509.MarshalPKIXPublicKey(pk) + if err != nil { + t.Error(err) + } + rekorPath := "rekor.pub" + rekorData := cryptoutils.PEMEncode(cryptoutils.PublicKeyPEMType, b) + if err := ioutil.WriteFile(filepath.Join(targetsPath, rekorPath), rekorData, 0600); err != nil { + t.Error(err) + } + scmRekor, err := json.Marshal(&sigstoreCustomMetadata{Sigstore: customMetadata{Usage: Rekor, Status: Active}}) + if err != nil { + t.Error(err) + } + if err := r.AddTarget("rekor.pub", scmRekor); err != nil { + t.Error(err) + } + // Add Fulcio Certificate information. + fulcioPath := "fulcio.crt.pem" + fulcioData := cryptoutils.PEMEncode(cryptoutils.CertificatePEMType, root.FulcioCertificate.Raw) + if err := ioutil.WriteFile(filepath.Join(targetsPath, fulcioPath), fulcioData, 0600); err != nil { + t.Error(err) + } + scmFulcio, err := json.Marshal(&sigstoreCustomMetadata{Sigstore: customMetadata{Usage: Fulcio, Status: Active}}) + if err != nil { + t.Error(err) + } + if err := r.AddTarget(fulcioPath, scmFulcio); err != nil { + t.Error(err) + } + if err := r.Snapshot(); err != nil { + t.Error(err) + } + if err := r.Timestamp(); err != nil { + t.Error(err) + } + if err := r.Commit(); err != nil { + t.Error(err) + } + // Serve remote repository. + s := httptest.NewServer(http.FileServer(http.Dir(filepath.Join(td, "repository")))) + defer s.Close() + + // Initialize with custom root. + tufRoot := t.TempDir() + t.Setenv("TUF_ROOT", tufRoot) + meta, err := remote.GetMeta() + if err != nil { + t.Error(err) + } + rootBytes, ok := meta["root.json"] + if !ok { + t.Error(err) + } + resetForTests() + if err := Initialize(ctx, s.URL, rootBytes); err != nil { + t.Error(err) + } + t.Cleanup(func() { + resetForTests() + }) + return remote, r +} diff --git a/pkg/tuf/usage_type.go b/pkg/tuf/usage_type.go new file mode 100644 index 000000000..4ea7ad04f --- /dev/null +++ b/pkg/tuf/usage_type.go @@ -0,0 +1,64 @@ +// Copyright 2022 The Sigstore 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 ( + "fmt" + "strings" +) + +type UsageKind int + +const ( + UnknownUsage UsageKind = iota + Fulcio + Rekor + CTFE +) + +var toUsageString = map[UsageKind]string{ + UnknownUsage: "Unknown", + Fulcio: "Fulcio", + Rekor: "Rekor", + CTFE: "CTFE", +} + +func (u UsageKind) String() string { + return toUsageString[u] +} + +func (u UsageKind) MarshalText() ([]byte, error) { + str := u.String() + if len(str) == 0 { + return nil, fmt.Errorf("error while marshalling, int(UsageKind)=%d not valid", int(u)) + } + return []byte(u.String()), nil +} + +func (u *UsageKind) UnmarshalText(text []byte) error { + switch strings.ToLower(string(text)) { + case "unknown": + *u = UnknownUsage + case "fulcio": + *u = Fulcio + case "rekor": + *u = Rekor + case "ctfe": + *u = CTFE + default: + return fmt.Errorf("error while unmarshalling, UsageKind=%v not valid", string(text)) + } + return nil +} diff --git a/pkg/tuf/usage_type_test.go b/pkg/tuf/usage_type_test.go new file mode 100644 index 000000000..9fca0cf73 --- /dev/null +++ b/pkg/tuf/usage_type_test.go @@ -0,0 +1,84 @@ +// Copyright 2022 The Sigstore 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 ( + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" +) + +func TestMarshalUsageType(t *testing.T) { + usages := []UsageKind{UnknownUsage, Fulcio, Rekor, CTFE} + bytes, err := json.Marshal(usages) + if err != nil { + t.Fatalf("expected no error marshalling struct, got: %v", err) + } + expected := `["Unknown","Fulcio","Rekor","CTFE"]` + if string(bytes) != expected { + t.Fatalf("error while marshalling, expected: %s, got: %s", expected, bytes) + } +} + +func TestMarshalInvalidUsageType(t *testing.T) { + invalidUsage := 42 + usages := []UsageKind{UsageKind(invalidUsage)} + bytes, err := json.Marshal(usages) + if bytes != nil { + t.Fatalf("expected error marshalling struct, got: %v", bytes) + } + expectedErr := fmt.Sprintf("error while marshalling, int(UsageKind)=%d not valid", invalidUsage) + if !strings.Contains(err.Error(), expectedErr) { + t.Fatalf("expected error marshalling struct, expected: %v, got: %v", expectedErr, err) + } +} + +func TestUnmarshalUsageType(t *testing.T) { + var usages []UsageKind + j := json.RawMessage(`["fulcio", "rekor", "ctfe", "unknown"]`) + err := json.Unmarshal(j, &usages) + if err != nil { + t.Fatalf("expected no error unmarshalling struct, got: %v", err) + } + if !reflect.DeepEqual(usages, []UsageKind{Fulcio, Rekor, CTFE, UnknownUsage}) { + t.Fatalf("expected [Fulcio, Rekor, CTFE, UnknownUsage], got: %v", usages) + } +} + +func TestUnmarshalUsageTypeCapitalization(t *testing.T) { + // Any capitalization is allowed. + var usages []UsageKind + j := json.RawMessage(`["fUlCiO", "rEkOr", "cTfE", "uNkNoWn"]`) + err := json.Unmarshal(j, &usages) + if err != nil { + t.Fatalf("expected no error unmarshalling struct, got: %v", err) + } + if !reflect.DeepEqual(usages, []UsageKind{Fulcio, Rekor, CTFE, UnknownUsage}) { + t.Fatalf("expected [Fulcio, Rekor, CTFE, UnknownUsage], got: %v", usages) + } +} + +func TestUnmarshalInvalidUsageType(t *testing.T) { + var usages []UsageKind + invalidUsage := "invalid" + j := json.RawMessage(fmt.Sprintf(`["%s"]`, invalidUsage)) + err := json.Unmarshal(j, &usages) + expectedErr := fmt.Sprintf("error while unmarshalling, UsageKind=%s not valid", invalidUsage) + if !strings.Contains(err.Error(), expectedErr) { + t.Fatalf("expected error unmarshalling struct, expected: %v, got: %v", expectedErr, err) + } +}