Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

The Go the spin-stack repositories build with, and hold their code to: tools a repository runs
from its go.mod (`go tool <name>`), each a command under `cmd/`. Only `versions`,
`versions/upstream`, `trustroot`, `provenance`, `attestgate` and `sbom` are packages another
repository imports; the rest is
`versions/upstream`, `trustroot`, `provenance`, `attestgate`, `sbom` and `fulciotest` (a test
Sigstore whose certificates carry Fulcio's source-repository and build extensions, which a verifier
of a reusable builder pins) are packages another repository imports; the rest is
`internal/`. Nothing here is linked into what any of them ships but versions' reading half, which
spin's binaries use to read the pins they carry, and the trusted root, which what verifies a
release's signatures checks them against.
Expand Down
271 changes: 271 additions & 0 deletions fulciotest/fulciotest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
// Package fulciotest is a Sigstore for tests whose certificates carry what Fulcio writes into a
// GitHub Actions run's: the source repository, its ref, the build's signer and config. sigstore-go's
// VirtualSigstore mints a leaf with the issuer and nothing else, so a verifier that pins the
// source repository - which a reusable builder needs, since any repository may call one and be
// issued its SAN - has no certificate to pass in a test. This one has its own Fulcio CA and keeps
// the VirtualSigstore's Rekor, CT log and timestamp authority.
package fulciotest

import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"math/big"
"net/url"
"time"

"github.com/secure-systems-lab/go-securesystemslib/dsse"
"github.com/sigstore/rekor/pkg/pki"
"github.com/sigstore/rekor/pkg/types"
"github.com/sigstore/rekor/pkg/types/hashedrekord"
_ "github.com/sigstore/rekor/pkg/types/hashedrekord/v0.0.1" // the version types.NewProposedEntry makes
"github.com/sigstore/sigstore-go/pkg/bundle"
"github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
"github.com/sigstore/sigstore-go/pkg/root"
"github.com/sigstore/sigstore-go/pkg/testing/ca"
"github.com/sigstore/sigstore-go/pkg/tlog"
"github.com/sigstore/sigstore-go/pkg/verify"
"github.com/sigstore/sigstore/pkg/cryptoutils"
"github.com/sigstore/sigstore/pkg/signature"
sigdsse "github.com/sigstore/sigstore/pkg/signature/dsse"
)

// Sigstore is trusted material for a verifier, and what signs and attests under it.
type Sigstore struct {
*ca.VirtualSigstore
fulcio *root.FulcioCertificateAuthority
key *ecdsa.PrivateKey
}

// New is a Sigstore whose Fulcio is valid from five hours ago to an hour from now, as the
// VirtualSigstore's is.
func New() (*Sigstore, error) {
v, err := ca.NewVirtualSigstore()
if err != nil {
return nil, fmt.Errorf("fulciotest: %w", err)
}
rootCert, rootKey, err := ca.GenerateRootCa()
if err != nil {
return nil, fmt.Errorf("fulciotest: %w", err)
}
inter, key, err := ca.GenerateFulcioIntermediate(rootCert, rootKey)
if err != nil {
return nil, fmt.Errorf("fulciotest: %w", err)
}
now := time.Now()
return &Sigstore{VirtualSigstore: v, key: key, fulcio: &root.FulcioCertificateAuthority{
Root: rootCert, Intermediates: []*x509.Certificate{inter}, URI: "https://fulciotest.invalid",
ValidityPeriodStart: now.Add(-5 * time.Hour), ValidityPeriodEnd: now.Add(time.Hour), // mutate-exempt: how far back the start is, a test signs within it; only the end is a test's (signed past it)
}}, nil
}

// FulcioCertificateAuthorities is this Sigstore's own Fulcio, the one that knows the extensions.
func (s *Sigstore) FulcioCertificateAuthorities() []root.CertificateAuthority {
return []root.CertificateAuthority{s.fulcio}
}

// The extensions a leaf carries, by their OIDs: the issuer twice, as Fulcio writes it - raw in the
// deprecated OID, DER in the current one - and the rest as DER strings.
func render(e certificate.Extensions) ([]pkix.Extension, error) {
out := []pkix.Extension{{Id: certificate.OIDIssuer, Value: []byte(e.Issuer)}} //nolint:staticcheck // Fulcio still writes it, and older verifiers read only it
if e.GithubWorkflowRepository != "" {
// What cosign's --certificate-github-workflow-repository reads, which Fulcio still writes.
out = append(out, pkix.Extension{Id: certificate.OIDGitHubWorkflowRepository, Value: []byte(e.GithubWorkflowRepository)}) //nolint:staticcheck // see above
}
for _, f := range []struct {
id asn1.ObjectIdentifier
val string
}{
{certificate.OIDIssuerV2, e.Issuer},
{certificate.OIDBuildSignerURI, e.BuildSignerURI},
{certificate.OIDBuildSignerDigest, e.BuildSignerDigest},
{certificate.OIDRunnerEnvironment, e.RunnerEnvironment},
{certificate.OIDSourceRepositoryURI, e.SourceRepositoryURI},
{certificate.OIDSourceRepositoryDigest, e.SourceRepositoryDigest},
{certificate.OIDSourceRepositoryRef, e.SourceRepositoryRef},
{certificate.OIDBuildConfigURI, e.BuildConfigURI},
{certificate.OIDBuildTrigger, e.BuildTrigger},
} {
if f.val == "" {
continue
}
der, err := asn1.MarshalWithParams(f.val, "utf8")
if err != nil {
return nil, err
}
out = append(out, pkix.Extension{Id: f.id, Value: der})
}
return out, nil
}

// leaf is a certificate for san - a URI, as Fulcio's for a workflow is - carrying ext, valid from a
// minute before at to ten minutes after it, and its key.
func (s *Sigstore) leaf(san string, ext certificate.Extensions, at time.Time) (*x509.Certificate, *ecdsa.PrivateKey, error) {
u, err := url.Parse(san)
if err != nil || u.Scheme == "" {
return nil, nil, fmt.Errorf("fulciotest: %q is not a URI", san)
}
if ext.Issuer == "" {
return nil, nil, errors.New("fulciotest: a certificate with no issuer")
}
extra, err := render(ext)
if err != nil {
return nil, nil, fmt.Errorf("fulciotest: %w", err)
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("fulciotest: %w", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(at.UnixNano()), URIs: []*url.URL{u},
NotBefore: at.Add(-time.Minute), NotAfter: at.Add(10 * time.Minute),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning},
ExtraExtensions: extra,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, s.fulcio.Intermediates[0], key.Public(), s.key)
if err != nil {
return nil, nil, fmt.Errorf("fulciotest: %w", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, nil, fmt.Errorf("fulciotest: %w", err)
}
return cert, key, nil
}

// AttestAtTime is an in-toto attestation of body, signed by a certificate for san with ext, logged at
// at, with the log's proof of inclusion when proof.
func (s *Sigstore) AttestAtTime(san string, ext certificate.Extensions, body []byte, at time.Time, proof bool) (verify.SignedEntity, error) {
cert, key, err := s.leaf(san, ext, at)
if err != nil {
return nil, err
}
signer, err := signature.LoadECDSASignerVerifier(key, crypto.SHA256)
if err != nil {
return nil, fmt.Errorf("fulciotest: %w", err)
}
envSigner, err := dsse.NewEnvelopeSigner(&sigdsse.SignerAdapter{SignatureSigner: signer, Pub: key.Public()})
if err != nil {
return nil, fmt.Errorf("fulciotest: %w", err)
}
env, err := envSigner.SignPayload(context.Background(), "application/vnd.in-toto+json", body)
if err != nil {
return nil, fmt.Errorf("fulciotest: %w", err)
}
sig, err := base64.StdEncoding.DecodeString(env.Signatures[0].Sig)
if err != nil {
return nil, fmt.Errorf("fulciotest: %w", err)
}
entry, err := s.GenerateTlogEntry(cert, env, sig, at.Unix(), proof)
if err != nil {
return nil, fmt.Errorf("fulciotest: %w", err)
}
return &entity{chain: s.chain(cert), envelope: env, entries: []*tlog.Entry{entry}}, nil
}

// SignAtTime is a signature over artifact by a certificate for san with ext, logged at at, as
// cosign sign-blob's is: a hashedrekord entry with the log's promise of inclusion.
func (s *Sigstore) SignAtTime(san string, ext certificate.Extensions, artifact []byte, at time.Time) (verify.SignedEntity, error) {
cert, key, err := s.leaf(san, ext, at)
if err != nil {
return nil, err
}
digest := sha256.Sum256(artifact)
sig, err := ecdsa.SignASN1(rand.Reader, key, digest[:])
if err != nil {
return nil, fmt.Errorf("fulciotest: %w", err)
}
entry, err := s.hashedRekord(cert, digest[:], sig, at.Unix())
if err != nil {
return nil, err
}
return &entity{chain: s.chain(cert), message: bundle.NewMessageSignature(digest[:], "SHA2_256", sig), entries: []*tlog.Entry{entry}}, nil
}

func (s *Sigstore) chain(leaf *x509.Certificate) []*x509.Certificate {
return []*x509.Certificate{leaf, s.fulcio.Intermediates[0], s.fulcio.Root}
}

// hashedRekord is the log's entry of a signature over digest by cert, as the VirtualSigstore's own
// is: canonical, signed by its log, at index 1000.
func (s *Sigstore) hashedRekord(cert *x509.Certificate, digest, sig []byte, at int64) (*tlog.Entry, error) {
ctx := context.Background()
pem, err := cryptoutils.MarshalCertificateToPEM(cert)
if err != nil {
return nil, fmt.Errorf("fulciotest: %w", err)
}
proposed, err := types.NewProposedEntry(ctx, hashedrekord.KIND, hashedrekord.New().DefaultVersion(), types.ArtifactProperties{
PublicKeyBytes: [][]byte{pem}, PKIFormat: string(pki.X509), ArtifactHash: hex.EncodeToString(digest), SignatureBytes: sig,
})
if err != nil {
return nil, fmt.Errorf("fulciotest: %w", err)
}
impl, err := types.CreateVersionedEntry(proposed)
if err != nil {
return nil, fmt.Errorf("fulciotest: %w", err)
}
body, err := types.CanonicalizeEntry(ctx, impl)
if err != nil {
return nil, fmt.Errorf("fulciotest: %w", err)
}
logID, err := s.RekorLogID()
if err != nil {
return nil, fmt.Errorf("fulciotest: %w", err)
}
const index = 1000 // mutate-exempt: the entry's index in a log with no proof; the promise signs whichever it is
set, err := s.RekorSignPayload(tlog.RekorPayload{LogID: logID, IntegratedTime: at, LogIndex: index, Body: base64.StdEncoding.EncodeToString(body)})
if err != nil {
return nil, fmt.Errorf("fulciotest: %w", err)
}
raw, err := hex.DecodeString(logID)
if err != nil {
return nil, fmt.Errorf("fulciotest: %w", err)
}
return tlog.NewEntry(body, at, index, raw, set, nil) //nolint:staticcheck // the constructor the VirtualSigstore uses for the same entry
}

// entity is a signed thing as a verifier reads it out of a bundle.
type entity struct {
chain []*x509.Certificate
envelope *dsse.Envelope
message *bundle.MessageSignature
entries []*tlog.Entry
}

func (e *entity) VerificationContent() (verify.VerificationContent, error) {
return bundle.NewCertificate(e.chain[0]), nil
}

func (e *entity) SignatureContent() (verify.SignatureContent, error) {
if e.envelope != nil {
return &bundle.Envelope{Envelope: e.envelope}, nil
}
return e.message, nil
}

func (e *entity) HasInclusionPromise() bool { return true }

func (e *entity) HasInclusionProof() bool {
for _, t := range e.entries {
if t.HasInclusionProof() {
return true
}
}
return false
}

func (e *entity) Timestamps() ([][]byte, error) { return nil, nil }

func (e *entity) TlogEntries() ([]*tlog.Entry, error) { return e.entries, nil }

func (e *entity) Version() (string, error) { return "v0.3", nil }
110 changes: 110 additions & 0 deletions fulciotest/fulciotest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package fulciotest_test

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"testing"
"time"

"github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
"github.com/sigstore/sigstore-go/pkg/verify"
"github.com/stretchr/testify/require"

"github.com/spin-stack/go-tools/fulciotest"
)

const (
issuer = "https://token.actions.githubusercontent.com"
builder = "https://github.com/spin-stack/spin-machine/.github/workflows/build-release.yml@refs/heads/main"
)

var own = certificate.Extensions{
Issuer: issuer, SourceRepositoryURI: "https://github.com/spin-stack/spin-machine", SourceRepositoryRef: "refs/heads/main",
BuildSignerURI: builder, BuildConfigURI: "https://github.com/spin-stack/spin-machine/.github/workflows/release.yml@refs/heads/main",
GithubWorkflowRepository: "spin-stack/spin-machine",
}

// statement is an in-toto statement over artifact.
func statement(t *testing.T, artifact []byte) []byte {
t.Helper()
sum := sha256.Sum256(artifact)
b, err := json.Marshal(map[string]any{
"_type": "https://in-toto.io/Statement/v1",
"subject": []map[string]any{{"name": "f", "digest": map[string]string{"sha256": hexOf(sum[:])}}},
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": map[string]any{},
})
require.NoError(t, err)
return b
}

func hexOf(b []byte) string { return hex.EncodeToString(b) }

// What a verifier pinning the source repository and the build's config takes: a signature and an
// attestation whose certificate says so. One from another repository with the builder's SAN - what
// any repository calling a public reusable builder is issued - is refused, and so is one that says
// nothing of its source.
func TestACertificateCarriesWhatFulcioWrites(t *testing.T) {
s, err := fulciotest.New()
require.NoError(t, err)
artifact := []byte("the machine")
sum := sha256.Sum256(artifact)
now := time.Now()
pinned, err := verify.NewShortCertificateIdentity(issuer, "", builder, "")
require.NoError(t, err)
pinned.SourceRepositoryURI = own.SourceRepositoryURI
pinned.BuildConfigURI = own.BuildConfigURI
verifier, err := verify.NewVerifier(s, verify.WithTransparencyLog(1), verify.WithObserverTimestamps(1))
require.NoError(t, err)
check := func(e verify.SignedEntity) error {
result, err := verifier.Verify(e, verify.NewPolicy(verify.WithArtifactDigest("sha256", sum[:]), verify.WithCertificateIdentity(pinned)))
if err == nil {
// What cosign's --certificate-github-workflow-repository reads, beside what the policy did.
require.Equal(t, own.GithubWorkflowRepository, result.Signature.Certificate.GithubWorkflowRepository)
require.Equal(t, own.BuildSignerURI, result.Signature.Certificate.BuildSignerURI)
}
return err
}

other := own
other.SourceRepositoryURI, other.GithubWorkflowRepository = "https://github.com/someone/fork", "someone/fork"
none := certificate.Extensions{Issuer: issuer}
for name, tc := range map[string]struct {
ext certificate.Extensions
ok bool
}{
"its own repository": {ext: own, ok: true},
"another repository": {ext: other},
"nothing of a source": {ext: none},
} {
t.Run(name, func(t *testing.T) {
signed, err := s.SignAtTime(builder, tc.ext, artifact, now)
require.NoError(t, err)
attested, err := s.AttestAtTime(builder, tc.ext, statement(t, artifact), now, true)
require.NoError(t, err)
require.True(t, attested.HasInclusionProof())
require.False(t, signed.HasInclusionProof())
for what, e := range map[string]verify.SignedEntity{"signature": signed, "attestation": attested} {
if tc.ok {
require.NoError(t, check(e), what)
} else {
require.Error(t, check(e), what)
}
}
})
}

// The SAN is what it was asked for; a certificate needs an issuer and a URI to be for.
signed, err := s.SignAtTime("https://github.com/spin-stack/spin-machine/.github/workflows/release.yml@refs/heads/main", own, artifact, now)
require.NoError(t, err)
require.Error(t, check(signed))
_, err = s.SignAtTime("not a uri", own, artifact, now)
require.Error(t, err)
_, err = s.AttestAtTime(builder, certificate.Extensions{}, statement(t, artifact), now, true)
require.Error(t, err)
// Signed at a time its certificate was not yet valid, or its Fulcio was not: refused.
late, err := s.SignAtTime(builder, own, artifact, now.Add(3*time.Hour))
require.NoError(t, err)
require.Error(t, check(late))
}
Loading