mirror of https://github.com/containers/image.git
490 lines
16 KiB
Go
490 lines
16 KiB
Go
package signature
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto"
|
||
"crypto/ecdsa"
|
||
"crypto/elliptic"
|
||
"crypto/rand"
|
||
"crypto/x509"
|
||
"crypto/x509/pkix"
|
||
"encoding/asn1"
|
||
"encoding/pem"
|
||
"os"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/sigstore/fulcio/pkg/certificate"
|
||
"github.com/sigstore/sigstore/pkg/cryptoutils"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// assert that crypto.PublicKey matches the on in certPEM.
|
||
func assertPublicKeyMatchesCert(t *testing.T, certPEM []byte, pk crypto.PublicKey) {
|
||
pkInterface, ok := pk.(interface {
|
||
Equal(x crypto.PublicKey) bool
|
||
})
|
||
require.True(t, ok)
|
||
certs, err := cryptoutils.UnmarshalCertificatesFromPEM(certPEM)
|
||
require.NoError(t, err)
|
||
require.Len(t, certs, 1)
|
||
equal := pkInterface.Equal(certs[0].PublicKey)
|
||
assert.True(t, equal)
|
||
}
|
||
|
||
func TestFulcioTrustRootValidate(t *testing.T) {
|
||
certs := x509.NewCertPool() // Empty is valid enough for our purposes.
|
||
|
||
for _, tr := range []fulcioTrustRoot{
|
||
{
|
||
caCertificates: certs,
|
||
oidcIssuer: "",
|
||
subjectEmail: "email",
|
||
},
|
||
{
|
||
caCertificates: certs,
|
||
oidcIssuer: "issuer",
|
||
subjectEmail: "",
|
||
},
|
||
} {
|
||
err := tr.validate()
|
||
assert.Error(t, err)
|
||
}
|
||
|
||
tr := fulcioTrustRoot{
|
||
caCertificates: certs,
|
||
oidcIssuer: "issuer",
|
||
subjectEmail: "email",
|
||
}
|
||
err := tr.validate()
|
||
assert.NoError(t, err)
|
||
}
|
||
|
||
// oidIssuerV1Ext creates an certificate.OIDIssuer extension
|
||
func oidIssuerV1Ext(value string) pkix.Extension {
|
||
return pkix.Extension{
|
||
Id: certificate.OIDIssuer, //nolint:staticcheck // This is deprecated, but we must continue to accept it.
|
||
Value: []byte(value),
|
||
}
|
||
}
|
||
|
||
// asn1MarshalTest is asn1.MarshalWithParams that must not fail
|
||
func asn1MarshalTest(t *testing.T, value any, params string) []byte {
|
||
bytes, err := asn1.MarshalWithParams(value, params)
|
||
require.NoError(t, err)
|
||
return bytes
|
||
}
|
||
|
||
// oidIssuerV2Ext creates an certificate.OIDIssuerV2 extension
|
||
func oidIssuerV2Ext(t *testing.T, value string) pkix.Extension {
|
||
return pkix.Extension{
|
||
Id: certificate.OIDIssuerV2,
|
||
Value: asn1MarshalTest(t, value, "utf8"),
|
||
}
|
||
}
|
||
|
||
func TestFulcioIssuerInCertificate(t *testing.T) {
|
||
referenceTime := time.Now()
|
||
fulcioExtensions, err := certificate.Extensions{Issuer: "https://github.com/login/oauth"}.Render()
|
||
require.NoError(t, err)
|
||
for _, c := range []struct {
|
||
name string
|
||
extensions []pkix.Extension
|
||
errorFragment string
|
||
expected string
|
||
}{
|
||
{
|
||
name: "Missing issuer",
|
||
extensions: nil,
|
||
errorFragment: "Fulcio certificate is missing the issuer extension",
|
||
},
|
||
{
|
||
name: "Duplicate issuer v1 extension",
|
||
extensions: []pkix.Extension{
|
||
oidIssuerV1Ext("https://github.com/login/oauth"),
|
||
oidIssuerV1Ext("this does not match"),
|
||
},
|
||
// Match both our message and the Go 1.19 message: "certificate contains duplicate extensions"
|
||
errorFragment: "duplicate",
|
||
},
|
||
{
|
||
name: "Duplicate issuer v2 extension",
|
||
extensions: []pkix.Extension{
|
||
oidIssuerV2Ext(t, "https://github.com/login/oauth"),
|
||
oidIssuerV2Ext(t, "this does not match"),
|
||
},
|
||
// Match both our message and the Go 1.19 message: "certificate contains duplicate extensions"
|
||
errorFragment: "duplicate",
|
||
},
|
||
{
|
||
name: "Completely invalid issuer v2 extension - error parsing",
|
||
extensions: []pkix.Extension{
|
||
{
|
||
Id: certificate.OIDIssuerV2,
|
||
Value: asn1MarshalTest(t, 1, ""), // not a string type
|
||
},
|
||
},
|
||
errorFragment: "invalid ASN.1 in OIDC issuer v2 extension: asn1: structure error",
|
||
},
|
||
{
|
||
name: "Completely invalid issuer v2 extension - trailing data",
|
||
extensions: []pkix.Extension{
|
||
{
|
||
Id: certificate.OIDIssuerV2,
|
||
Value: append(bytes.Clone(asn1MarshalTest(t, "https://", "utf8")), asn1MarshalTest(t, "example.com", "utf8")...),
|
||
},
|
||
},
|
||
errorFragment: "invalid ASN.1 in OIDC issuer v2 extension, trailing data",
|
||
},
|
||
{
|
||
name: "One valid issuer v1",
|
||
extensions: []pkix.Extension{oidIssuerV1Ext("https://github.com/login/oauth")},
|
||
expected: "https://github.com/login/oauth",
|
||
},
|
||
{
|
||
name: "One valid issuer v2",
|
||
extensions: []pkix.Extension{oidIssuerV2Ext(t, "https://github.com/login/oauth")},
|
||
expected: "https://github.com/login/oauth",
|
||
},
|
||
{
|
||
name: "Inconsistent issuer v1 and v2",
|
||
extensions: []pkix.Extension{
|
||
oidIssuerV1Ext("https://github.com/login/oauth"),
|
||
oidIssuerV2Ext(t, "this does not match"),
|
||
},
|
||
errorFragment: "inconsistent OIDC issuer extension values",
|
||
},
|
||
{
|
||
name: "Both issuer v1 and v2",
|
||
extensions: []pkix.Extension{
|
||
oidIssuerV1Ext("https://github.com/login/oauth"),
|
||
oidIssuerV2Ext(t, "https://github.com/login/oauth"),
|
||
},
|
||
expected: "https://github.com/login/oauth",
|
||
},
|
||
{
|
||
name: "Fulcio interoperability",
|
||
extensions: fulcioExtensions,
|
||
expected: "https://github.com/login/oauth",
|
||
},
|
||
} {
|
||
testLeafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||
require.NoError(t, err, c.name)
|
||
testLeafSN, err := cryptoutils.GenerateSerialNumber()
|
||
require.NoError(t, err, c.name)
|
||
testLeafContents := x509.Certificate{
|
||
SerialNumber: testLeafSN,
|
||
Subject: pkix.Name{CommonName: "leaf"},
|
||
NotBefore: referenceTime.Add(-1 * time.Minute),
|
||
NotAfter: referenceTime.Add(1 * time.Hour),
|
||
ExtraExtensions: c.extensions,
|
||
EmailAddresses: []string{"test-user@example.com"},
|
||
}
|
||
// To be fairly representative, we do generate and parse a _real_ certificate, but we just use a self-signed certificate instead
|
||
// of bothering with a CA.
|
||
testLeafCert, err := x509.CreateCertificate(rand.Reader, &testLeafContents, &testLeafContents, testLeafKey.Public(), testLeafKey)
|
||
require.NoError(t, err, c.name)
|
||
testLeafPEM := pem.EncodeToMemory(&pem.Block{
|
||
Type: "CERTIFICATE",
|
||
Bytes: testLeafCert,
|
||
})
|
||
|
||
parsedLeafCerts, err := cryptoutils.UnmarshalCertificatesFromPEM(testLeafPEM)
|
||
if err != nil {
|
||
require.NotEqual(t, "", c.errorFragment)
|
||
assert.ErrorContains(t, err, c.errorFragment, c.name)
|
||
} else {
|
||
require.Len(t, parsedLeafCerts, 1)
|
||
parsedLeafCert := parsedLeafCerts[0]
|
||
|
||
res, err := fulcioIssuerInCertificate(parsedLeafCert)
|
||
if c.errorFragment == "" {
|
||
require.NoError(t, err, c.name)
|
||
assert.Equal(t, c.expected, res)
|
||
} else {
|
||
assert.ErrorContains(t, err, c.errorFragment, c.name)
|
||
assert.Equal(t, "", res)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestParseLeafCertsFromPEM(t *testing.T) {
|
||
fulcioCertBytes, err := os.ReadFile("fixtures/fulcio-cert")
|
||
require.NoError(t, err)
|
||
// Invalid leaf certificate
|
||
for _, c := range [][]byte{
|
||
[]byte("not a certificate"),
|
||
{}, // Empty
|
||
bytes.Repeat(fulcioCertBytes, 2), // More than one certificate
|
||
} {
|
||
pk, err := parseLeafCertFromPEM(c)
|
||
assert.Error(t, err)
|
||
assert.Nil(t, pk)
|
||
}
|
||
// Valid leaf certificate
|
||
cert, err := parseLeafCertFromPEM(fulcioCertBytes)
|
||
require.NoError(t, err)
|
||
assert.NotNil(t, cert)
|
||
}
|
||
|
||
func TestFulcioTrustRootVerifyFulcioCertificateAtTime(t *testing.T) {
|
||
fulcioCACertificates := x509.NewCertPool()
|
||
fulcioCABundlePEM, err := os.ReadFile("fixtures/fulcio_v1.crt.pem")
|
||
require.NoError(t, err)
|
||
ok := fulcioCACertificates.AppendCertsFromPEM(fulcioCABundlePEM)
|
||
require.True(t, ok)
|
||
fulcioCertBytes, err := os.ReadFile("fixtures/fulcio-cert")
|
||
require.NoError(t, err)
|
||
fulcioChainBytes, err := os.ReadFile("fixtures/fulcio-chain")
|
||
require.NoError(t, err)
|
||
|
||
// A successful verification
|
||
tr := fulcioTrustRoot{
|
||
caCertificates: fulcioCACertificates,
|
||
oidcIssuer: "https://github.com/login/oauth",
|
||
subjectEmail: "mitr@redhat.com",
|
||
}
|
||
pk, err := tr.verifyFulcioCertificateAtTime(time.Unix(1670870899, 0), fulcioCertBytes, fulcioChainBytes)
|
||
require.NoError(t, err)
|
||
assertPublicKeyMatchesCert(t, fulcioCertBytes, pk)
|
||
|
||
// Invalid intermediate certificates
|
||
pk, err = tr.verifyFulcioCertificateAtTime(time.Unix(1670870899, 0), fulcioCertBytes, []byte("not a certificate"))
|
||
assert.Error(t, err)
|
||
assert.Nil(t, pk)
|
||
|
||
// Invalid leaf certificate
|
||
pk, err = tr.verifyFulcioCertificateAtTime(time.Unix(1670870899, 0), []byte("not a certificate"), fulcioChainBytes)
|
||
assert.Error(t, err)
|
||
assert.Nil(t, pk)
|
||
|
||
// No intermediate certificates: verification fails as is …
|
||
pk, err = tr.verifyFulcioCertificateAtTime(time.Unix(1670870899, 0), fulcioCertBytes, []byte{})
|
||
assert.Error(t, err)
|
||
assert.Nil(t, pk)
|
||
// … but succeeds if we add the intermediate certificates to the root of trust
|
||
intermediateCertPool := x509.NewCertPool()
|
||
ok = intermediateCertPool.AppendCertsFromPEM(fulcioChainBytes)
|
||
require.True(t, ok)
|
||
trWithIntermediates := fulcioTrustRoot{
|
||
caCertificates: intermediateCertPool,
|
||
oidcIssuer: "https://github.com/login/oauth",
|
||
subjectEmail: "mitr@redhat.com",
|
||
}
|
||
pk, err = trWithIntermediates.verifyFulcioCertificateAtTime(time.Unix(1670870899, 0), fulcioCertBytes, []byte{})
|
||
require.NoError(t, err)
|
||
assertPublicKeyMatchesCert(t, fulcioCertBytes, pk)
|
||
|
||
// Unexpected relevantTime
|
||
for _, tm := range []time.Time{
|
||
time.Date(2022, time.December, 12, 18, 48, 17, 0, time.UTC),
|
||
time.Date(2022, time.December, 12, 18, 58, 19, 0, time.UTC),
|
||
} {
|
||
pk, err := tr.verifyFulcioCertificateAtTime(tm, fulcioCertBytes, fulcioChainBytes)
|
||
assert.Error(t, err)
|
||
assert.Nil(t, pk)
|
||
}
|
||
|
||
referenceTime := time.Now()
|
||
testCAKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||
require.NoError(t, err)
|
||
testCASN, err := cryptoutils.GenerateSerialNumber()
|
||
require.NoError(t, err)
|
||
testCAContents := x509.Certificate{
|
||
SerialNumber: testCASN,
|
||
Subject: pkix.Name{CommonName: "root CA"},
|
||
NotBefore: referenceTime.Add(-1 * time.Minute),
|
||
NotAfter: referenceTime.Add(1 * time.Hour),
|
||
BasicConstraintsValid: true,
|
||
IsCA: true,
|
||
}
|
||
testCACertBytes, err := x509.CreateCertificate(rand.Reader, &testCAContents, &testCAContents,
|
||
testCAKey.Public(), testCAKey)
|
||
require.NoError(t, err)
|
||
testCACert, err := x509.ParseCertificate(testCACertBytes)
|
||
require.NoError(t, err)
|
||
testCACertPool := x509.NewCertPool()
|
||
testCACertPool.AddCert(testCACert)
|
||
|
||
for _, c := range []struct {
|
||
name string
|
||
fn func(cert *x509.Certificate)
|
||
errorFragment string
|
||
}{
|
||
{
|
||
// OtherName SAN element, with none of the Go-parsed SAN elements present,
|
||
// should not be a reason to reject the certificate entirely;
|
||
// but we don’t actually support matching it, so this basically tests that the code
|
||
// gets far enough to do subject matching.
|
||
name: "OtherName in SAN",
|
||
fn: func(cert *x509.Certificate) {
|
||
// Setting SAN in ExtraExtensions causes EmailAddresses to be ignored,
|
||
// so we need to construct the whole SAN manually.
|
||
sansBytes, err := asn1.Marshal([]asn1.RawValue{
|
||
{
|
||
Class: 2,
|
||
Tag: 0,
|
||
IsCompound: false,
|
||
Bytes: []byte("otherName"),
|
||
},
|
||
})
|
||
require.NoError(t, err)
|
||
cert.ExtraExtensions = append(cert.ExtraExtensions, pkix.Extension{
|
||
Id: cryptoutils.SANOID,
|
||
Critical: true,
|
||
Value: sansBytes,
|
||
})
|
||
},
|
||
errorFragment: `Required email "test-user@example.com" not found`,
|
||
},
|
||
{ // Other completely unrecognized critical extensions still cause failures
|
||
name: "Unhandled critical extension",
|
||
fn: func(cert *x509.Certificate) {
|
||
cert.ExtraExtensions = append(cert.ExtraExtensions, pkix.Extension{
|
||
Id: asn1.ObjectIdentifier{2, 99999, 99998, 99997, 99996},
|
||
Critical: true,
|
||
Value: []byte("whatever"),
|
||
})
|
||
},
|
||
errorFragment: "unhandled critical extension",
|
||
},
|
||
{
|
||
name: "Missing issuer",
|
||
fn: func(cert *x509.Certificate) {
|
||
cert.ExtraExtensions = nil // Remove the issuer extension
|
||
},
|
||
errorFragment: "Fulcio certificate is missing the issuer extension",
|
||
},
|
||
{
|
||
name: "Duplicate issuer extension",
|
||
fn: func(cert *x509.Certificate) {
|
||
cert.ExtraExtensions = append([]pkix.Extension{oidIssuerV1Ext("this does not match")}, cert.ExtraExtensions...)
|
||
},
|
||
// Match both our message and the Go 1.19 message: "certificate contains duplicate extensions"
|
||
errorFragment: "duplicate",
|
||
},
|
||
{
|
||
name: "Issuer mismatch",
|
||
fn: func(cert *x509.Certificate) {
|
||
cert.ExtraExtensions = []pkix.Extension{oidIssuerV1Ext("this does not match")}
|
||
},
|
||
errorFragment: "Unexpected Fulcio OIDC issuer",
|
||
},
|
||
{
|
||
name: "Missing subject email",
|
||
fn: func(cert *x509.Certificate) {
|
||
cert.EmailAddresses = nil
|
||
},
|
||
errorFragment: `Required email "test-user@example.com" not found`,
|
||
},
|
||
{
|
||
name: "Multiple emails, one matches",
|
||
fn: func(cert *x509.Certificate) {
|
||
cert.EmailAddresses = []string{"a@example.com", "test-user@example.com", "c@example.com"}
|
||
},
|
||
errorFragment: "",
|
||
},
|
||
{
|
||
name: "Email mismatch",
|
||
fn: func(cert *x509.Certificate) {
|
||
cert.EmailAddresses = []string{"a@example.com"}
|
||
},
|
||
errorFragment: `Required email "test-user@example.com" not found`,
|
||
},
|
||
{
|
||
name: "Multiple emails, no matches",
|
||
fn: func(cert *x509.Certificate) {
|
||
cert.EmailAddresses = []string{"a@example.com", "b@example.com", "c@example.com"}
|
||
},
|
||
errorFragment: `Required email "test-user@example.com" not found`,
|
||
},
|
||
} {
|
||
testLeafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||
require.NoError(t, err, c.name)
|
||
testLeafSN, err := cryptoutils.GenerateSerialNumber()
|
||
require.NoError(t, err, c.name)
|
||
testLeafContents := x509.Certificate{
|
||
SerialNumber: testLeafSN,
|
||
Subject: pkix.Name{CommonName: "leaf"},
|
||
NotBefore: referenceTime.Add(-1 * time.Minute),
|
||
NotAfter: referenceTime.Add(1 * time.Hour),
|
||
ExtraExtensions: []pkix.Extension{oidIssuerV1Ext("https://github.com/login/oauth")},
|
||
EmailAddresses: []string{"test-user@example.com"},
|
||
}
|
||
c.fn(&testLeafContents)
|
||
testLeafCert, err := x509.CreateCertificate(rand.Reader, &testLeafContents, testCACert, testLeafKey.Public(), testCAKey)
|
||
require.NoError(t, err, c.name)
|
||
tr := fulcioTrustRoot{
|
||
caCertificates: testCACertPool,
|
||
oidcIssuer: "https://github.com/login/oauth",
|
||
subjectEmail: "test-user@example.com",
|
||
}
|
||
testLeafPEM := pem.EncodeToMemory(&pem.Block{
|
||
Type: "CERTIFICATE",
|
||
Bytes: testLeafCert,
|
||
})
|
||
pk, err := tr.verifyFulcioCertificateAtTime(referenceTime, testLeafPEM, []byte{})
|
||
if c.errorFragment == "" {
|
||
require.NoError(t, err, c.name)
|
||
assertPublicKeyMatchesCert(t, testLeafPEM, pk)
|
||
} else {
|
||
assert.ErrorContains(t, err, c.errorFragment, c.name)
|
||
assert.Nil(t, pk, c.name)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestVerifyRekorFulcio(t *testing.T) {
|
||
caCertificates := x509.NewCertPool()
|
||
fulcioCABundlePEM, err := os.ReadFile("fixtures/fulcio_v1.crt.pem")
|
||
require.NoError(t, err)
|
||
ok := caCertificates.AppendCertsFromPEM(fulcioCABundlePEM)
|
||
require.True(t, ok)
|
||
certBytes, err := os.ReadFile("fixtures/fulcio-cert")
|
||
require.NoError(t, err)
|
||
chainBytes, err := os.ReadFile("fixtures/fulcio-chain")
|
||
require.NoError(t, err)
|
||
rekorKeyPEM, err := os.ReadFile("fixtures/rekor.pub")
|
||
require.NoError(t, err)
|
||
rekorKey, err := cryptoutils.UnmarshalPEMToPublicKey(rekorKeyPEM)
|
||
require.NoError(t, err)
|
||
rekorKeyECDSA, ok := rekorKey.(*ecdsa.PublicKey)
|
||
require.True(t, ok)
|
||
rekorKeysECDSA := []*ecdsa.PublicKey{rekorKeyECDSA}
|
||
setBytes, err := os.ReadFile("fixtures/rekor-set")
|
||
require.NoError(t, err)
|
||
sigBase64, err := os.ReadFile("fixtures/rekor-sig")
|
||
require.NoError(t, err)
|
||
payloadBytes, err := os.ReadFile("fixtures/rekor-payload")
|
||
require.NoError(t, err)
|
||
|
||
// Success
|
||
pk, err := verifyRekorFulcio(rekorKeysECDSA, &fulcioTrustRoot{
|
||
caCertificates: caCertificates,
|
||
oidcIssuer: "https://github.com/login/oauth",
|
||
subjectEmail: "mitr@redhat.com",
|
||
}, setBytes, certBytes, chainBytes, string(sigBase64), payloadBytes)
|
||
require.NoError(t, err)
|
||
assertPublicKeyMatchesCert(t, certBytes, pk)
|
||
|
||
// Rekor failure
|
||
pk, err = verifyRekorFulcio(rekorKeysECDSA, &fulcioTrustRoot{
|
||
caCertificates: caCertificates,
|
||
oidcIssuer: "https://github.com/login/oauth",
|
||
subjectEmail: "mitr@redhat.com",
|
||
}, setBytes, certBytes, chainBytes, string(sigBase64), []byte("this payload does not match"))
|
||
assert.Error(t, err)
|
||
assert.Nil(t, pk)
|
||
|
||
// Fulcio failure
|
||
pk, err = verifyRekorFulcio(rekorKeysECDSA, &fulcioTrustRoot{
|
||
caCertificates: caCertificates,
|
||
oidcIssuer: "https://github.com/login/oauth",
|
||
subjectEmail: "this-does-not-match@example.com",
|
||
}, setBytes, certBytes, chainBytes, string(sigBase64), payloadBytes)
|
||
assert.Error(t, err)
|
||
assert.Nil(t, pk)
|
||
}
|