From f972050dc92c435c75b6ae7559592bfe28fa3dbd Mon Sep 17 00:00:00 2001 From: Zach Steindler Date: Mon, 1 Jul 2024 11:50:39 -0400 Subject: [PATCH] gh attestation trusted-root subcommand (#9206) Adds `trusted-root` subcommand to `gh attestation`. For use in upcoming docs on how to do offline verification with artifact attestations. --------- Signed-off-by: Zach Steindler Co-authored-by: Fredrik Skogman --- pkg/cmd/attestation/attestation.go | 4 +- .../attestation/test/data/trusted_root.json | 91 +----------- .../attestation/trustedroot/trustedroot.go | 131 ++++++++++++++++++ .../trustedroot_test.go} | 76 +++++----- .../tufrootverify/tufrootverify.go | 87 ------------ pkg/cmd/attestation/verification/sigstore.go | 113 ++++++++++++--- .../verification/sigstore_integration_test.go | 4 +- pkg/cmd/attestation/verify/options.go | 2 +- pkg/cmd/attestation/verify/verify.go | 10 +- pkg/cmd/attestation/verify/verify_test.go | 2 +- 10 files changed, 280 insertions(+), 240 deletions(-) create mode 100644 pkg/cmd/attestation/trustedroot/trustedroot.go rename pkg/cmd/attestation/{tufrootverify/tufrootverify_test.go => trustedroot/trustedroot_test.go} (62%) delete mode 100644 pkg/cmd/attestation/tufrootverify/tufrootverify.go diff --git a/pkg/cmd/attestation/attestation.go b/pkg/cmd/attestation/attestation.go index 121ae3db0..ea1e0c08c 100644 --- a/pkg/cmd/attestation/attestation.go +++ b/pkg/cmd/attestation/attestation.go @@ -4,7 +4,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/pkg/cmd/attestation/download" "github.com/cli/cli/v2/pkg/cmd/attestation/inspect" - "github.com/cli/cli/v2/pkg/cmd/attestation/tufrootverify" + "github.com/cli/cli/v2/pkg/cmd/attestation/trustedroot" "github.com/cli/cli/v2/pkg/cmd/attestation/verify" "github.com/cli/cli/v2/pkg/cmdutil" @@ -24,7 +24,7 @@ func NewCmdAttestation(f *cmdutil.Factory) *cobra.Command { root.AddCommand(download.NewDownloadCmd(f, nil)) root.AddCommand(inspect.NewInspectCmd(f, nil)) root.AddCommand(verify.NewVerifyCmd(f, nil)) - root.AddCommand(tufrootverify.NewTUFRootVerifyCmd(f, nil)) + root.AddCommand(trustedroot.NewTrustedRootCmd(f, nil)) return root } diff --git a/pkg/cmd/attestation/test/data/trusted_root.json b/pkg/cmd/attestation/test/data/trusted_root.json index b8706cb3e..eddf07bbb 100644 --- a/pkg/cmd/attestation/test/data/trusted_root.json +++ b/pkg/cmd/attestation/test/data/trusted_root.json @@ -1,90 +1 @@ -{ - "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1", - "tlogs": [ - { - "baseUrl": "https://rekor.sigstore.dev", - "hashAlgorithm": "SHA2_256", - "publicKey": { - "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", - "keyDetails": "PKIX_ECDSA_P256_SHA_256", - "validFor": { - "start": "2021-01-12T11:53:27.000Z" - } - }, - "logId": { - "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" - } - } - ], - "certificateAuthorities": [ - { - "subject": { - "organization": "sigstore.dev", - "commonName": "sigstore" - }, - "uri": "https://fulcio.sigstore.dev", - "certChain": { - "certificates": [ - { - "rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==" - } - ] - }, - "validFor": { - "start": "2021-03-07T03:20:29.000Z", - "end": "2022-12-31T23:59:59.999Z" - } - }, - { - "subject": { - "organization": "sigstore.dev", - "commonName": "sigstore" - }, - "uri": "https://fulcio.sigstore.dev", - "certChain": { - "certificates": [ - { - "rawBytes": "MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow=" - }, - { - "rawBytes": "MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ" - } - ] - }, - "validFor": { - "start": "2022-04-13T20:06:15.000Z" - } - } - ], - "ctlogs": [ - { - "baseUrl": "https://ctfe.sigstore.dev/test", - "hashAlgorithm": "SHA2_256", - "publicKey": { - "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==", - "keyDetails": "PKIX_ECDSA_P256_SHA_256", - "validFor": { - "start": "2021-03-14T00:00:00.000Z", - "end": "2022-10-31T23:59:59.999Z" - } - }, - "logId": { - "keyId": "CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I=" - } - }, - { - "baseUrl": "https://ctfe.sigstore.dev/2022", - "hashAlgorithm": "SHA2_256", - "publicKey": { - "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==", - "keyDetails": "PKIX_ECDSA_P256_SHA_256", - "validFor": { - "start": "2022-10-20T00:00:00.000Z" - } - }, - "logId": { - "keyId": "3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4=" - } - } - ] -} +{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1","tlogs":[{"baseUrl":"https://rekor.sigstore.dev","hashAlgorithm":"SHA2_256","publicKey":{"rawBytes":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==","keyDetails":"PKIX_ECDSA_P256_SHA_256","validFor":{"start":"2021-01-12T11:53:27.000Z"}},"logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="}}],"certificateAuthorities":[{"subject":{"organization":"sigstore.dev","commonName":"sigstore"},"uri":"https://fulcio.sigstore.dev","certChain":{"certificates":[{"rawBytes":"MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ=="}]},"validFor":{"start":"2021-03-07T03:20:29.000Z","end":"2022-12-31T23:59:59.999Z"}},{"subject":{"organization":"sigstore.dev","commonName":"sigstore"},"uri":"https://fulcio.sigstore.dev","certChain":{"certificates":[{"rawBytes":"MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow="},{"rawBytes":"MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ"}]},"validFor":{"start":"2022-04-13T20:06:15.000Z"}}],"ctlogs":[{"baseUrl":"https://ctfe.sigstore.dev/test","hashAlgorithm":"SHA2_256","publicKey":{"rawBytes":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==","keyDetails":"PKIX_ECDSA_P256_SHA_256","validFor":{"start":"2021-03-14T00:00:00.000Z","end":"2022-10-31T23:59:59.999Z"}},"logId":{"keyId":"CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I="}},{"baseUrl":"https://ctfe.sigstore.dev/2022","hashAlgorithm":"SHA2_256","publicKey":{"rawBytes":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==","keyDetails":"PKIX_ECDSA_P256_SHA_256","validFor":{"start":"2022-10-20T00:00:00.000Z"}},"logId":{"keyId":"3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4="}}]} diff --git a/pkg/cmd/attestation/trustedroot/trustedroot.go b/pkg/cmd/attestation/trustedroot/trustedroot.go new file mode 100644 index 000000000..09ab9a6c9 --- /dev/null +++ b/pkg/cmd/attestation/trustedroot/trustedroot.go @@ -0,0 +1,131 @@ +package trustedroot + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + + "github.com/cli/cli/v2/pkg/cmd/attestation/auth" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/cmdutil" + + "github.com/MakeNowJust/heredoc" + "github.com/sigstore/sigstore-go/pkg/tuf" + "github.com/spf13/cobra" +) + +type Options struct { + TufUrl string + TufRootPath string + VerifyOnly bool +} + +type tufClientInstantiator func(o *tuf.Options) (*tuf.Client, error) + +func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command { + opts := &Options{} + trustedRootCmd := cobra.Command{ + Use: "trusted-root [--tuf-url --tuf-root ] [--verify-only]", + Args: cobra.ExactArgs(0), + Short: "Output trusted_root.jsonl contents, likely for offline verification", + Long: heredoc.Docf(` + ### NOTE: This feature is currently in beta, and subject to change. + + Output contents for a trusted_root.jsonl file, likely for offline verification. + + When using %[1]sgh attestation verify%[1]s, if your machine is on the internet, + this will happen automatically. But to do offline verification, you need to + supply a trusted root file with %[1]s--custom-trusted-root%[1]s; this command + will help you fetch a %[1]strusted_root.jsonl%[1]s file for that purpose. + + You can call this command without any flags to get a trusted root file covering + the Sigstore Public Good Instance as well as GitHub's Sigstore instance. + + Otherwise you can use %[1]s--tuf-url%[1]s to specify the URL of a custom TUF + repository mirror, and %[1]s--tuf-root%[1]s should be the path to the + %[1]sroot.json%[1]s file that you securely obtained out-of-band. + + If you just want to verify the integrity of your local TUF repository, and don't + want the contents of a trusted_root.jsonl file, use %[1]s--verify-only%[1]s. + `, "`"), + Example: heredoc.Doc(` + # Get a trusted_root.jsonl for both Sigstore Public Good and GitHub's instance + gh attestation trusted-root + `), + RunE: func(cmd *cobra.Command, args []string) error { + if err := auth.IsHostSupported(); err != nil { + return err + } + + if runF != nil { + return runF(opts) + } + + if err := getTrustedRoot(tuf.New, opts); err != nil { + return fmt.Errorf("Failed to verify the TUF repository: %w", err) + } + + return nil + }, + } + + trustedRootCmd.Flags().StringVarP(&opts.TufUrl, "tuf-url", "", "", "URL to the TUF repository mirror") + trustedRootCmd.Flags().StringVarP(&opts.TufRootPath, "tuf-root", "", "", "Path to the TUF root.json file on disk") + trustedRootCmd.MarkFlagsRequiredTogether("tuf-url", "tuf-root") + trustedRootCmd.Flags().BoolVarP(&opts.VerifyOnly, "verify-only", "", false, "Don't output trusted_root.jsonl contents") + + return &trustedRootCmd +} + +func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error { + var tufOptions []*tuf.Options + + tufOpt := verification.DefaultOptionsWithCacheSetting() + // Disable local caching, so we get up-to-date response from TUF repository + tufOpt.CacheValidity = 0 + + if opts.TufUrl != "" && opts.TufRootPath != "" { + tufRoot, err := os.ReadFile(opts.TufRootPath) + if err != nil { + return fmt.Errorf("failed to read root file %s: %v", opts.TufRootPath, err) + } + + tufOpt.Root = tufRoot + tufOpt.RepositoryBaseURL = opts.TufUrl + tufOptions = append(tufOptions, tufOpt) + } else { + // Get from both Sigstore public good and GitHub private instance + tufOptions = append(tufOptions, tufOpt) + + tufOpt = verification.GitHubTUFOptions() + tufOpt.CacheValidity = 0 + tufOptions = append(tufOptions, tufOpt) + } + + for _, tufOpt = range tufOptions { + tufClient, err := makeTUF(tufOpt) + if err != nil { + return fmt.Errorf("failed to create TUF client: %v", err) + } + + t, err := tufClient.GetTarget("trusted_root.json") + if err != nil { + return err + } + + output := new(bytes.Buffer) + err = json.Compact(output, t) + if err != nil { + return err + } + + if !opts.VerifyOnly { + fmt.Println(output) + } else { + fmt.Printf("Local TUF repository for %s updated and verified\n", tufOpt.RepositoryBaseURL) + } + } + + return nil +} diff --git a/pkg/cmd/attestation/tufrootverify/tufrootverify_test.go b/pkg/cmd/attestation/trustedroot/trustedroot_test.go similarity index 62% rename from pkg/cmd/attestation/tufrootverify/tufrootverify_test.go rename to pkg/cmd/attestation/trustedroot/trustedroot_test.go index fb4d90cdb..d67075f20 100644 --- a/pkg/cmd/attestation/tufrootverify/tufrootverify_test.go +++ b/pkg/cmd/attestation/trustedroot/trustedroot_test.go @@ -1,4 +1,4 @@ -package tufrootverify +package trustedroot import ( "bytes" @@ -6,16 +6,16 @@ import ( "strings" "testing" + "github.com/sigstore/sigstore-go/pkg/tuf" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/cli/cli/v2/pkg/cmd/attestation/test" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/sigstore/sigstore-go/pkg/tuf" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestNewTUFRootVerifyCmd(t *testing.T) { +func TestNewTrustedRootCmd(t *testing.T) { testIO, _, _, _ := iostreams.Test() f := &cmdutil.Factory{ IOStreams: testIO, @@ -27,29 +27,37 @@ func TestNewTUFRootVerifyCmd(t *testing.T) { wantsErr bool }{ { - name: "Missing mirror flag", - cli: "--root ../verification/embed/tuf-repo.github.com/root.json", - wantsErr: true, - }, - { - name: "Missing root flag", - cli: "--mirror https://tuf-repo.github.com", - wantsErr: true, - }, - { - name: "Has all required flags", - cli: "--mirror https://tuf-repo.github.com --root ../verification/embed/tuf-repo.github.com/root.json", + name: "Happy path", + cli: "", wantsErr: false, }, + { + name: "Happy path", + cli: "--verify-only", + wantsErr: false, + }, + { + name: "Custom TUF happy path", + cli: "--tuf-url https://tuf-repo.github.com --tuf-root ../verification/embed/tuf-repo.github.com/root.json", + wantsErr: false, + }, + { + name: "Missing tuf-root flag", + cli: "--tuf-url https://tuf-repo.github.com", + wantsErr: true, + }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { - cmd := NewTUFRootVerifyCmd(f, func() error { + cmd := NewTrustedRootCmd(f, func(_ *Options) error { return nil }) - argv := strings.Split(tc.cli, " ") + argv := []string{} + if tc.cli != "" { + argv = strings.Split(tc.cli, " ") + } cmd.SetArgs(argv) cmd.SetIn(&bytes.Buffer{}) cmd.SetOut(&bytes.Buffer{}) @@ -64,33 +72,35 @@ func TestNewTUFRootVerifyCmd(t *testing.T) { } } -var newTUFMockClient tufClientInstantiator = func(o *tuf.Options) (*tuf.Client, error) { - return &tuf.Client{}, nil -} - var newTUFErrClient tufClientInstantiator = func(o *tuf.Options) (*tuf.Client, error) { return nil, fmt.Errorf("failed to create TUF client") } -func TestTUFRootVerify(t *testing.T) { +func TestGetTrustedRoot(t *testing.T) { mirror := "https://tuf-repo.github.com" root := test.NormalizeRelativePath("../verification/embed/tuf-repo.github.com/root.json") + opts := &Options{ + TufUrl: mirror, + TufRootPath: root, + } + t.Run("successfully verifies TUF root", func(t *testing.T) { - err := tufRootVerify(newTUFMockClient, mirror, root) + err := getTrustedRoot(tuf.New, opts) require.NoError(t, err) }) + t.Run("failed to create TUF root", func(t *testing.T) { + err := getTrustedRoot(newTUFErrClient, opts) + require.Error(t, err) + require.ErrorContains(t, err, "failed to create TUF client") + }) + t.Run("fails because the root cannot be found", func(t *testing.T) { - notFoundRoot := test.NormalizeRelativePath("./does/not/exist/root.json") - err := tufRootVerify(newTUFMockClient, mirror, notFoundRoot) + opts.TufRootPath = test.NormalizeRelativePath("./does/not/exist/root.json") + err := getTrustedRoot(tuf.New, opts) require.Error(t, err) require.ErrorContains(t, err, "failed to read root file") }) - t.Run("failed to create TUF root", func(t *testing.T) { - err := tufRootVerify(newTUFErrClient, mirror, root) - require.Error(t, err) - require.ErrorContains(t, err, "failed to create TUF client") - }) } diff --git a/pkg/cmd/attestation/tufrootverify/tufrootverify.go b/pkg/cmd/attestation/tufrootverify/tufrootverify.go deleted file mode 100644 index b7fac9efe..000000000 --- a/pkg/cmd/attestation/tufrootverify/tufrootverify.go +++ /dev/null @@ -1,87 +0,0 @@ -package tufrootverify - -import ( - "fmt" - "os" - - "github.com/cli/cli/v2/pkg/cmd/attestation/auth" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - "github.com/cli/cli/v2/pkg/cmdutil" - - "github.com/MakeNowJust/heredoc" - "github.com/sigstore/sigstore-go/pkg/tuf" - "github.com/spf13/cobra" -) - -type tufClientInstantiator func(o *tuf.Options) (*tuf.Client, error) - -func NewTUFRootVerifyCmd(f *cmdutil.Factory, runF func() error) *cobra.Command { - var mirror string - var root string - var cmd = cobra.Command{ - Use: "tuf-root-verify --mirror --root ", - Args: cobra.ExactArgs(0), - Short: "Verify the TUF repository from a provided TUF root", - Hidden: true, - Long: heredoc.Docf(` - ### NOTE: This feature is currently in beta, and subject to change. - - Verify a TUF repository with a local TUF root. - - The command requires you provide the %[1]s--mirror%[1]s flag, which should be the URL - of the TUF repository mirror. - - The command also requires you provide the %[1]s--root%[1]s flag, which should be the - path to the TUF root file. - - GitHub relies on TUF to securely deliver the trust root for our signing authority. - For more information on TUF, see the official documentation: . - `, "`"), - Example: heredoc.Doc(` - # Verify the TUF repository from a provided TUF root - gh attestation tuf-root-verify --mirror https://tuf-repo.github.com --root /path/to/1.root.json - `), - RunE: func(cmd *cobra.Command, args []string) error { - if err := auth.IsHostSupported(); err != nil { - return err - } - - if runF != nil { - return runF() - } - - if err := tufRootVerify(tuf.New, mirror, root); err != nil { - return fmt.Errorf("Failed to verify the TUF repository: %w", err) - } - - io := f.IOStreams - fmt.Sprintln(io.Out, io.ColorScheme().Green("Successfully verified the TUF repository")) - return nil - }, - } - - cmd.Flags().StringVarP(&mirror, "mirror", "m", "", "URL to the TUF repository mirror") - cmd.MarkFlagRequired("mirror") //nolint:errcheck - cmd.Flags().StringVarP(&root, "root", "r", "", "Path to the TUF root file on disk") - cmd.MarkFlagRequired("root") //nolint:errcheck - - return &cmd -} - -func tufRootVerify(makeTUF tufClientInstantiator, mirror, root string) error { - rb, err := os.ReadFile(root) - if err != nil { - return fmt.Errorf("failed to read root file %s: %v", root, err) - } - opts := verification.GitHubTUFOptions() - opts.Root = rb - opts.RepositoryBaseURL = mirror - // The purpose is the verify the TUF root and repository, make - // sure there is no caching enabled - opts.CacheValidity = 0 - if _, err = makeTUF(opts); err != nil { - return fmt.Errorf("failed to create TUF client: %v", err) - } - - return nil -} diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index a55f4510d..b3a4aca50 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -1,7 +1,11 @@ package verification import ( + "bufio" + "bytes" + "crypto/x509" "fmt" + "os" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/io" @@ -29,9 +33,9 @@ type SigstoreResults struct { } type SigstoreConfig struct { - CustomTrustedRoot string - Logger *io.Handler - NoPublicGood bool + TrustedRoot string + Logger *io.Handler + NoPublicGood bool } type SigstoreVerifier interface { @@ -65,13 +69,68 @@ func (v *LiveSigstoreVerifier) chooseVerifier(b *bundle.ProtobufBundle) (*verify } issuer := leafCert.Issuer.Organization[0] - // if user provided a custom trusted root file path, use the custom verifier - if v.config.CustomTrustedRoot != "" { - customVerifier, err := newCustomVerifier(v.config.CustomTrustedRoot) + if v.config.TrustedRoot != "" { + customTrustRoots, err := os.ReadFile(v.config.TrustedRoot) if err != nil { - return nil, "", fmt.Errorf("failed to create custom verifier: %v", err) + return nil, "", fmt.Errorf("unable to read file %s: %v", v.config.TrustedRoot, err) } - return customVerifier, issuer, nil + + reader := bufio.NewReader(bytes.NewReader(customTrustRoots)) + var line []byte + var readError error + line, readError = reader.ReadBytes('\n') + for readError == nil { + // Load each trusted root + trustedRoot, err := root.NewTrustedRootFromJSON(line) + if err != nil { + return nil, "", fmt.Errorf("failed to create custom verifier: %v", err) + } + + // Compare bundle leafCert issuer with trusted root cert authority + certAuthorities := trustedRoot.FulcioCertificateAuthorities() + for _, certAuthority := range certAuthorities { + lowestCert, err := getLowestCertInChain(&certAuthority) + if err != nil { + return nil, "", err + } + + if len(lowestCert.Issuer.Organization) == 0 { + continue + } + + if lowestCert.Issuer.Organization[0] == issuer { + // Determine what policy to use with this trusted root. + // + // Note that we are *only* inferring the policy with the + // issuer. We *must* use the trusted root provided. + if issuer == PublicGoodIssuerOrg { + if v.config.NoPublicGood { + return nil, "", fmt.Errorf("Detected public good instance but requested verification without public good instance") + } + verifier, err := newPublicGoodVerifierWithTrustedRoot(trustedRoot) + if err != nil { + return nil, "", err + } + return verifier, issuer, nil + } else if issuer == GitHubIssuerOrg { + verifier, err := newGitHubVerifierWithTrustedRoot(trustedRoot) + if err != nil { + return nil, "", err + } + return verifier, issuer, nil + } else { + // Make best guess at reasonable policy + customVerifier, err := newCustomVerifier(trustedRoot) + if err != nil { + return nil, "", fmt.Errorf("failed to create custom verifier: %v", err) + } + return customVerifier, issuer, nil + } + } + } + line, readError = reader.ReadBytes('\n') + } + return nil, "", fmt.Errorf("unable to use provided trusted roots") } if leafCert.Issuer.Organization[0] == PublicGoodIssuerOrg && !v.config.NoPublicGood { @@ -93,6 +152,18 @@ func (v *LiveSigstoreVerifier) chooseVerifier(b *bundle.ProtobufBundle) (*verify return nil, "", fmt.Errorf("leaf certificate issuer is not recognized") } +func getLowestCertInChain(ca *root.CertificateAuthority) (*x509.Certificate, error) { + if ca.Leaf != nil { + return ca.Leaf, nil + } else if len(ca.Intermediates) > 0 { + return ca.Intermediates[0], nil + } else if ca.Root != nil { + return ca.Root, nil + } + + return nil, fmt.Errorf("certificate authority had no certificates") +} + func (v *LiveSigstoreVerifier) Verify(attestations []*api.Attestation, policy verify.PolicyBuilder) *SigstoreResults { // initialize the processing results before attempting to verify // with multiple verifiers @@ -143,21 +214,17 @@ func (v *LiveSigstoreVerifier) Verify(attestations []*api.Attestation, policy ve } } -func newCustomVerifier(trustedRootFilePath string) (*verify.SignedEntityVerifier, error) { - trustedRoot, err := root.NewTrustedRootFromPath(trustedRootFilePath) - if err != nil { - return nil, fmt.Errorf("failed to create trusted root from file %s: %v", trustedRootFilePath, err) - } - +func newCustomVerifier(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerifier, error) { + // All we know about this trust root is its configuration so make some + // educated guesses as to what the policy should be. verifierConfig := []verify.VerifierOption{} - verifierConfig = append(verifierConfig, verify.WithSignedCertificateTimestamps(1)) + // This requires some independent corroboration of the signing certificate + // (e.g. from Sigstore Fulcio) time, one of: + // - a signed timestamp from a timestamp authority in the trusted root + // - a transparency log entry (e.g. from Sigstore Rekor) verifierConfig = append(verifierConfig, verify.WithObserverTimestamps(1)) // Infer verification options from contents of trusted root - if len(trustedRoot.TimestampingAuthorities()) > 0 { - verifierConfig = append(verifierConfig, verify.WithSignedTimestamps(1)) - } - if len(trustedRoot.RekorLogs()) > 0 { verifierConfig = append(verifierConfig, verify.WithTransparencyLog(1)) } @@ -180,6 +247,10 @@ func newGitHubVerifier() (*verify.SignedEntityVerifier, error) { if err != nil { return nil, err } + return newGitHubVerifierWithTrustedRoot(trustedRoot) +} + +func newGitHubVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerifier, error) { gv, err := verify.NewSignedEntityVerifier(trustedRoot, verify.WithSignedTimestamps(1)) if err != nil { return nil, fmt.Errorf("failed to create GitHub verifier: %v", err) @@ -199,6 +270,10 @@ func newPublicGoodVerifier() (*verify.SignedEntityVerifier, error) { return nil, fmt.Errorf("failed to get trusted root: %v", err) } + return newPublicGoodVerifierWithTrustedRoot(trustedRoot) +} + +func newPublicGoodVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerifier, error) { sv, err := verify.NewSignedEntityVerifier(trustedRoot, verify.WithSignedCertificateTimestamps(1), verify.WithTransparencyLog(1), verify.WithObserverTimestamps(1)) if err != nil { return nil, fmt.Errorf("failed to create Public Good verifier: %v", err) diff --git a/pkg/cmd/attestation/verification/sigstore_integration_test.go b/pkg/cmd/attestation/verification/sigstore_integration_test.go index aa6dd3e74..2fab9dfac 100644 --- a/pkg/cmd/attestation/verification/sigstore_integration_test.go +++ b/pkg/cmd/attestation/verification/sigstore_integration_test.go @@ -92,8 +92,8 @@ func TestLiveSigstoreVerifier(t *testing.T) { attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") verifier := NewLiveSigstoreVerifier(SigstoreConfig{ - Logger: io.NewTestHandler(), - CustomTrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"), + Logger: io.NewTestHandler(), + TrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"), }) res := verifier.Verify(attestations, publicGoodPolicy(t)) diff --git a/pkg/cmd/attestation/verify/options.go b/pkg/cmd/attestation/verify/options.go index 08bb75072..da2c7bb4e 100644 --- a/pkg/cmd/attestation/verify/options.go +++ b/pkg/cmd/attestation/verify/options.go @@ -18,7 +18,7 @@ type Options struct { ArtifactPath string BundlePath string Config func() (gh.Config, error) - CustomTrustedRoot string + TrustedRoot string DenySelfHostedRunner bool DigestAlgorithm string Limit int diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go index 60196583a..16b9477e8 100644 --- a/pkg/cmd/attestation/verify/verify.go +++ b/pkg/cmd/attestation/verify/verify.go @@ -132,9 +132,9 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command } config := verification.SigstoreConfig{ - CustomTrustedRoot: opts.CustomTrustedRoot, - Logger: opts.Logger, - NoPublicGood: opts.NoPublicGood, + TrustedRoot: opts.TrustedRoot, + Logger: opts.Logger, + NoPublicGood: opts.NoPublicGood, } opts.SigstoreVerifier = verification.NewLiveSigstoreVerifier(config) @@ -156,8 +156,8 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command verifyCmd.MarkFlagsMutuallyExclusive("owner", "repo") verifyCmd.MarkFlagsOneRequired("owner", "repo") verifyCmd.Flags().StringVarP(&opts.PredicateType, "predicate-type", "", "", "Filter attestations by provided predicate type") - verifyCmd.Flags().BoolVarP(&opts.NoPublicGood, "no-public-good", "", false, "Only verify attestations signed with GitHub's Sigstore instance") - verifyCmd.Flags().StringVarP(&opts.CustomTrustedRoot, "custom-trusted-root", "", "", "Path to a custom trustedroot.json file to use for verification") + verifyCmd.Flags().BoolVarP(&opts.NoPublicGood, "no-public-good", "", false, "Do not verify attestations signed with Sigstore public good instance") + verifyCmd.Flags().StringVarP(&opts.TrustedRoot, "custom-trusted-root", "", "", "Path to a trusted_root.jsonl file; likely for offline verification") verifyCmd.Flags().IntVarP(&opts.Limit, "limit", "L", api.DefaultLimit, "Maximum number of attestations to fetch") cmdutil.AddFormatFlags(verifyCmd, &opts.exporter) // policy enforcement flags diff --git a/pkg/cmd/attestation/verify/verify_test.go b/pkg/cmd/attestation/verify/verify_test.go index ef778f454..f0cc21709 100644 --- a/pkg/cmd/attestation/verify/verify_test.go +++ b/pkg/cmd/attestation/verify/verify_test.go @@ -220,7 +220,7 @@ func TestNewVerifyCmd(t *testing.T) { assert.Equal(t, tc.wants.ArtifactPath, opts.ArtifactPath) assert.Equal(t, tc.wants.BundlePath, opts.BundlePath) - assert.Equal(t, tc.wants.CustomTrustedRoot, opts.CustomTrustedRoot) + assert.Equal(t, tc.wants.TrustedRoot, opts.TrustedRoot) assert.Equal(t, tc.wants.DenySelfHostedRunner, opts.DenySelfHostedRunner) assert.Equal(t, tc.wants.DigestAlgorithm, opts.DigestAlgorithm) assert.Equal(t, tc.wants.Limit, opts.Limit)