blob: 459d4bb189bba5bc7d006755668d64b142ea96db [file] [log] [blame]
// Copyright 2024 The Update Framework 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
//
// SPDX-License-Identifier: Apache-2.0
//
package main
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"fmt"
"os"
"path/filepath"
"time"
"github.com/sigstore/sigstore/pkg/signature"
"github.com/theupdateframework/go-tuf/v2/metadata"
"github.com/theupdateframework/go-tuf/v2/metadata/repository"
)
// A TUF repository example using the low-level TUF Metadata API.
// The example code in this file demonstrates how to *manually* create and
// maintain repository metadata using the low-level Metadata API.
// Contents:
// * creation of top-level metadata
// * target file handling
// * consistent snapshots
// * key management
// * top-level delegation and signing thresholds
// * metadata verification
// * target delegation
// * in-band and out-of-band metadata signing
// * writing and reading metadata files
// * root key rotation
// NOTE: Metadata files will be written to a 'tmp*'-directory in CWD.
func main() {
// Create top-level metadata
// =========================
// Every TUF repository has at least four roles, i.e. the top-level roles
// 'targets', 'snapshot', 'timestamp' and 'root'. Below we will discuss their
// purpose, show how to create the corresponding metadata, and how to use them
// to provide integrity, consistency and freshness for the files TUF aims to
// protect, i.e. target files.
// Define containers for metadata objects and cryptographic keys created below. This
// allows us to sign and write metadata in a batch more easily. The repository.New() instance
// doesn't provide anything else yet other than serving as a placeholder for all metadata.
roles := repository.New()
keys := map[string]ed25519.PrivateKey{}
// Targets (integrity)
// -------------------
// The targets role guarantees integrity for the files that TUF aims to protect,
// i.e. target files. It does so by listing the relevant target files, along
// with their hash and length.
targets := metadata.Targets(helperExpireIn(7))
roles.SetTargets("targets", targets)
// For the purpose of this example we use the top-level targets role to protect
// the integrity of this very example script. The metadata entry contains the
// hash and length of this file at the local path. In addition, it specifies the
// 'target path', which a client uses to locate the target file relative to a
// configured mirror base URL.
// |----base URL---||--------target path--------|
// e.g. tuf-examples.org/examples/basic_repository.py
targetPath, localPath := helperGetPathForTarget("basic_repository.go")
targetFileInfo, err := metadata.TargetFile().FromFile(localPath, "sha256")
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "generating target file info failed", err))
}
roles.Targets("targets").Signed.Targets[targetPath] = targetFileInfo
// Snapshot (consistency)
// ----------------------
// The snapshot role guarantees consistency of the entire repository. It does so
// by listing all available targets metadata files at their latest version. This
// becomes relevant, when there are multiple targets metadata files in a
// repository and we want to protect the client against mix-and-match attacks.
snapshot := metadata.Snapshot(helperExpireIn(7))
roles.SetSnapshot(snapshot)
// Timestamp (freshness)
// ---------------------
// The timestamp role guarantees freshness of the repository metadata. It does
// so by listing the latest snapshot (which in turn lists all the latest
// targets) metadata. A short expiration interval requires the repository to
// regularly issue new timestamp metadata and thus protects the client against
// freeze attacks.
// Note that snapshot and timestamp use the same generic wireline metadata
// format.
timestamp := metadata.Timestamp(helperExpireIn(1))
roles.SetTimestamp(timestamp)
// Root (root of trust)
// --------------------
// The root role serves as root of trust for all top-level roles, including
// itself. It does so by mapping cryptographic keys to roles, i.e. the keys that
// are authorized to sign any top-level role metadata, and signing thresholds,
// i.e. how many authorized keys are required for a given role (see 'roles'
// field). This is called top-level delegation.
// In addition, root provides all public keys to verify these signatures (see
// 'keys' field), and a configuration parameter that describes whether a
// repository uses consistent snapshots (see section 'Persist metadata' below
// for more details).
// Create root metadata object
root := metadata.Root(helperExpireIn(365))
roles.SetRoot(root)
// For this example, we generate one private key of type 'ed25519' for each top-level role
for _, name := range []string{"targets", "snapshot", "timestamp", "root"} {
_, private, err := ed25519.GenerateKey(nil)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "key generation failed", err))
}
keys[name] = private
key, err := metadata.KeyFromPublicKey(private.Public())
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "key conversion failed", err))
}
err = roles.Root().Signed.AddKey(key, name)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "adding key to root failed", err))
}
}
// NOTE: We only need the public part to populate root, so it is possible to use
// out-of-band mechanisms to generate key pairs and only expose the public part
// to whoever maintains the root role. As a matter of fact, the very purpose of
// signature thresholds is to avoid having private keys all in one place.
// Signature thresholds
// --------------------
// Given the importance of the root role, it is highly recommended to require a
// threshold of multiple keys to sign root metadata. For this example we
// generate another root key (you can pretend it's out-of-band) and increase the
// required signature threshold.
_, anotherRootKey, err := ed25519.GenerateKey(nil)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "key generation failed", err))
}
anotherKey, err := metadata.KeyFromPublicKey(anotherRootKey.Public())
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "key conversion failed", err))
}
err = roles.Root().Signed.AddKey(anotherKey, "root")
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "adding another key to root failed", err))
}
roles.Root().Signed.Roles["root"].Threshold = 2
// Sign top-level metadata (in-band)
// =================================
// In this example we have access to all top-level signing keys, so we can use
// them to create and add a signature for each role metadata.
for _, name := range []string{"targets", "snapshot", "timestamp", "root"} {
key := keys[name]
signer, err := signature.LoadSigner(key, crypto.Hash(0))
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "loading a signer failed", err))
}
switch name {
case "targets":
_, err = roles.Targets("targets").Sign(signer)
case "snapshot":
_, err = roles.Snapshot().Sign(signer)
case "timestamp":
_, err = roles.Timestamp().Sign(signer)
case "root":
_, err = roles.Root().Sign(signer)
}
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "metadata signing failed", err))
}
}
// Persist metadata (consistent snapshot)
// ======================================
// It is time to publish the first set of metadata for a client to safely
// download the target file that we have registered for this example repository.
// For the purpose of this example we will follow the consistent snapshot naming
// convention for all metadata. This means that each metadata file, must be
// prefixed with its version number, except for timestamp. The naming convention
// also affects the target files, but we don't cover this in the example. See
// the TUF specification for more details:
// https://theupdateframework.github.io/specification/latest/#writing-consistent-snapshots
// Also note that the TUF specification does not mandate a wireline format. In
// this demo we use a non-compact JSON format and store all metadata in
// temporary directory at CWD for review.
cwd, err := os.Getwd()
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "getting cwd failed", err))
}
tmpDir, err := os.MkdirTemp(cwd, "tmp")
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "creating a temporary folder failed", err))
}
for _, name := range []string{"targets", "snapshot", "timestamp", "root"} {
switch name {
case "targets":
filename := fmt.Sprintf("%d.%s.json", roles.Targets("targets").Signed.Version, name)
err = roles.Targets("targets").ToFile(filepath.Join(tmpDir, filename), true)
case "snapshot":
filename := fmt.Sprintf("%d.%s.json", roles.Snapshot().Signed.Version, name)
err = roles.Snapshot().ToFile(filepath.Join(tmpDir, filename), true)
case "timestamp":
filename := fmt.Sprintf("%s.json", name)
err = roles.Timestamp().ToFile(filepath.Join(tmpDir, filename), true)
case "root":
filename := fmt.Sprintf("%d.%s.json", roles.Root().Signed.Version, name)
err = roles.Root().ToFile(filepath.Join(tmpDir, filename), true)
}
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "saving metadata to file failed", err))
}
}
// Threshold signing (out-of-band)
// ===============================
// As mentioned above, using signature thresholds usually entails that not all
// signing keys for a given role are in the same place. Let's briefly pretend
// this is the case for the second root key we registered above, and we are now
// on that key owner's computer. All the owner has to do is read the metadata
// file, sign it, and write it back to the same file, and this can be repeated
// until the threshold is satisfied.
_, err = roles.Root().FromFile(filepath.Join(tmpDir, "1.root.json"))
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "loading root metadata from file failed", err))
}
outofbandSigner, err := signature.LoadSigner(anotherRootKey, crypto.Hash(0))
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "loading a signer failed", err))
}
_, err = roles.Root().Sign(outofbandSigner)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "signing root failed", err))
}
err = roles.Root().ToFile(filepath.Join(tmpDir, "1.root.json"), true)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "saving root metadata to file failed", err))
}
// Verify that metadata is signed correctly
// ====================================
// Verify root
err = roles.Root().VerifyDelegate("root", roles.Root())
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "verifying root metadata failed", err))
}
// Verify targets
err = roles.Root().VerifyDelegate("targets", roles.Targets("targets"))
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "verifying targets metadata failed", err))
}
// Verify snapshot
err = roles.Root().VerifyDelegate("snapshot", roles.Snapshot())
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "verifying snapshot metadata failed", err))
}
// Verify timestamp
err = roles.Root().VerifyDelegate("timestamp", roles.Timestamp())
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "verifying timestamp metadata failed", err))
}
// Targets delegation
// ==================
// Similar to how the root role delegates responsibilities about integrity,
// consistency and freshness to the corresponding top-level roles, a targets
// role may further delegate its responsibility for target files (or a subset
// thereof) to other targets roles. This allows creation of a granular trust
// hierarchy, and further reduces the impact of a single role compromise.
// In this example the top-level targets role trusts a new "go-scripts"
// targets role to provide integrity for any target file that ends with ".go".
delegateeName := "go-scripts"
_, delegateePrivateKey, err := ed25519.GenerateKey(nil)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "key generation failed", err))
}
keys[delegateeName] = delegateePrivateKey
// Delegatee
// ---------
// Create a new targets role, akin to how we created top-level targets above, and
// add target file info from above according to the delegatee's responsibility.
delegatee := metadata.Targets(helperExpireIn(7))
delegatee.Signed.Targets[targetPath] = targetFileInfo
roles.SetTargets(delegateeName, delegatee)
// Delegator
// ---------
// Akin to top-level delegation, the delegator expresses its trust in the
// delegatee by authorizing a threshold of cryptographic keys to provide
// signatures for the delegatee metadata. It also provides the corresponding
// public key store.
// The delegation info defined by the delegator further requires the provision
// of a unique delegatee name and constraints about the target files the
// delegatee is responsible for, e.g. a list of path patterns. For details about
// all configuration parameters see
// https://theupdateframework.github.io/specification/latest/#delegations
delegateeKey, _ := metadata.KeyFromPublicKey(delegateePrivateKey.Public())
roles.Targets("targets").Signed.Delegations = &metadata.Delegations{
Keys: map[string]*metadata.Key{
delegateeKey.ID(): delegateeKey,
},
Roles: []metadata.DelegatedRole{
{
Name: delegateeName,
KeyIDs: []string{delegateeKey.ID()},
Threshold: 1,
Terminating: true,
Paths: []string{"*.go"},
},
},
}
// Remove target file info from top-level targets (delegatee is now responsible)
delete(roles.Targets("targets").Signed.Targets, targetPath)
// Increase expiry (delegators should be less volatile)
roles.Targets("targets").Signed.Expires = helperExpireIn(365)
// Snapshot + Timestamp + Sign + Persist
// -------------------------------------
// In order to publish a new consistent set of metadata, we need to update
// dependent roles (snapshot, timestamp) accordingly, bumping versions of all
// changed metadata.
// Bump targets version
roles.Targets("targets").Signed.Version += 1
// Update snapshot to account for changed and new targets(delegatee) metadata
roles.Snapshot().Signed.Meta["targets.json"] = metadata.MetaFile(roles.Targets("targets").Signed.Version)
roles.Snapshot().Signed.Meta[fmt.Sprintf("%s.json", delegateeName)] = metadata.MetaFile(1)
roles.Snapshot().Signed.Version += 1
// Update timestamp to account for changed snapshot metadata
roles.Timestamp().Signed.Meta["snapshot.json"] = metadata.MetaFile(roles.Snapshot().Signed.Version)
roles.Timestamp().Signed.Version += 1
// Sign and write metadata for all changed roles, i.e. all but root
for _, name := range []string{"targets", "snapshot", "timestamp", delegateeName} {
key := keys[name]
signer, err := signature.LoadSigner(key, crypto.Hash(0))
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "loading a signer failed", err))
}
switch name {
case "targets":
roles.Targets("targets").ClearSignatures()
_, err = roles.Targets("targets").Sign(signer)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "signing metadata failed", err))
}
filename := fmt.Sprintf("%d.%s.json", roles.Targets("targets").Signed.Version, name)
err = roles.Targets("targets").ToFile(filepath.Join(tmpDir, filename), true)
case "snapshot":
roles.Snapshot().ClearSignatures()
_, err = roles.Snapshot().Sign(signer)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "signing metadata failed", err))
}
filename := fmt.Sprintf("%d.%s.json", roles.Snapshot().Signed.Version, name)
err = roles.Snapshot().ToFile(filepath.Join(tmpDir, filename), true)
case "timestamp":
roles.Timestamp().ClearSignatures()
_, err = roles.Timestamp().Sign(signer)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "signing metadata failed", err))
}
filename := fmt.Sprintf("%s.json", name)
err = roles.Timestamp().ToFile(filepath.Join(tmpDir, filename), true)
case delegateeName:
roles.Targets(delegateeName).ClearSignatures()
_, err = roles.Targets(delegateeName).Sign(signer)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "signing metadata failed", err))
}
filename := fmt.Sprintf("%d.%s.json", roles.Targets(delegateeName).Signed.Version, name)
err = roles.Targets(delegateeName).ToFile(filepath.Join(tmpDir, filename), true)
}
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "saving metadata to file failed", err))
}
}
// Root key rotation (recover from a compromise / key loss)
// ========================================================
// TUF makes it easy to recover from a key compromise in-band. Given the trust
// hierarchy through top-level and targets delegation you can easily
// replace compromised or lost keys for any role using the delegating role, even
// for the root role.
// However, since root authorizes its own keys, it always has to be signed with
// both the threshold of keys from the previous version and the threshold of
// keys from the new version. This establishes a trusted line of continuity.
// In this example we will replace a root key, and sign a new version of root
// with the threshold of old and new keys. Since one of the previous root keys
// remains in place, it can be used to count towards the old and new threshold.
_, newRootKey, err := ed25519.GenerateKey(nil)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "key generation failed", err))
}
oldRootKey, err := metadata.KeyFromPublicKey(keys["root"].Public())
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "key conversion failed", err))
}
err = roles.Root().Signed.RevokeKey(oldRootKey.ID(), "root")
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "revoking key failed", err))
}
// Add new key for root
newRootKeyTUF, err := metadata.KeyFromPublicKey(newRootKey.Public())
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "key conversion failed", err))
}
err = roles.Root().Signed.AddKey(newRootKeyTUF, "root")
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "adding key to root failed", err))
}
roles.Root().Signed.Version += 1
roles.Root().ClearSignatures()
// Sign root
for _, k := range []ed25519.PrivateKey{keys["root"], anotherRootKey, newRootKey} {
signer, err := signature.LoadSigner(k, crypto.Hash(0))
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "loading a signer failed", err))
}
_, err = roles.Root().Sign(signer)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "signing root failed", err))
}
}
filename := fmt.Sprintf("%d.%s.json", roles.Root().Signed.Version, "root")
err = roles.Root().ToFile(filepath.Join(tmpDir, filename), true)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "saving root to file failed", err))
}
// Verify again that metadata is signed correctly
// ==============================================
// Verify root
err = roles.Root().VerifyDelegate("root", roles.Root())
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "verifying root metadata failed", err))
}
// Verify targets
err = roles.Root().VerifyDelegate("targets", roles.Targets("targets"))
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "verifying targets metadata failed", err))
}
// Verify snapshot
err = roles.Root().VerifyDelegate("snapshot", roles.Snapshot())
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "verifying snapshot metadata failed", err))
}
// Verify timestamp
err = roles.Root().VerifyDelegate("timestamp", roles.Timestamp())
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "verifying timestamp metadata failed", err))
}
// Verify delegatee
err = roles.Targets("targets").VerifyDelegate(delegateeName, roles.Targets(delegateeName))
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "verifying delegatee metadata failed", err))
}
// Use a mixture of key types
// ==========================
// Create an RSA key
anotherRootKeyRSA, _ := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "RSA key generation failed", err))
}
anotherKeyRSA, err := metadata.KeyFromPublicKey(anotherRootKeyRSA.Public())
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "RSA key conversion failed", err))
}
// Create an ECDSA key
anotherRootKeyECDSA, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "ECDSA key generation failed", err))
}
anotherKeyECDSA, err := metadata.KeyFromPublicKey(anotherRootKeyECDSA.Public())
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "ECDSA key conversion failed", err))
}
// Add the RSA key to root keys
err = roles.Root().Signed.AddKey(anotherKeyRSA, "root")
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "adding RSA key to root failed", err))
}
// Add the ECDSA key to root keys
err = roles.Root().Signed.AddKey(anotherKeyECDSA, "root")
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "adding ECDSA key to root failed", err))
}
// Clear existing signatures, bump version and threshold
roles.Root().Signed.Roles["root"].Threshold = 4
roles.Root().Signed.Version += 1
roles.Root().ClearSignatures()
// Sign root with existing ed25519 keys
for _, k := range []ed25519.PrivateKey{keys["root"], anotherRootKey, newRootKey} {
signer, err := signature.LoadSigner(k, crypto.Hash(0))
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "loading a signer failed", err))
}
_, err = roles.Root().Sign(signer)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "signing root failed", err))
}
}
// Sign root with the new RSA and ECDSA keys
outofbandSignerRSA, err := signature.LoadSigner(anotherRootKeyRSA, crypto.SHA256)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "loading RSA signer failed", err))
}
outofbandSignerECDSA, err := signature.LoadSigner(anotherRootKeyECDSA, crypto.SHA256)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "loading ECDSA signer failed", err))
}
_, err = roles.Root().Sign(outofbandSignerRSA)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "signing root failed", err))
}
_, err = roles.Root().Sign(outofbandSignerECDSA)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "signing root failed", err))
}
// Verify that root is signed correctly
// ====================================
err = roles.Root().VerifyDelegate("root", roles.Root())
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "verifying root metadata failed", err))
}
// Save root to file
filename = fmt.Sprintf("%d.%s.json", roles.Root().Signed.Version, "root")
err = roles.Root().ToFile(filepath.Join(tmpDir, filename), true)
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "saving root to file failed", err))
}
fmt.Println("Done! Metadata files location:", tmpDir)
}
// helperExpireIn returns time offset by days
func helperExpireIn(days int) time.Time {
return time.Now().AddDate(0, 0, days).UTC()
}
// helperGetPathForTarget returns local and target paths for target
func helperGetPathForTarget(name string) (string, string) {
cwd, err := os.Getwd()
if err != nil {
panic(fmt.Sprintln("basic_repository.go:", "getting cwd failed", err))
}
// _, dir := filepath.Split(cwd)
// return filepath.Join(dir, name), filepath.Join(cwd, name)
return name, filepath.Join(cwd, name)
}