diff --git a/pkg/skills/verifier/errors.go b/pkg/skills/verifier/errors.go new file mode 100644 index 0000000000..854991df31 --- /dev/null +++ b/pkg/skills/verifier/errors.go @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package verifier + +import "errors" + +var ( + // ErrUnsigned indicates the artifact carries no Sigstore signature + // material in any supported layout. + ErrUnsigned = errors.New("artifact is not signed") + // ErrSignatureInvalid indicates signature material was found but failed + // cryptographic verification (or a stored bundle is malformed). + ErrSignatureInvalid = errors.New("signature verification failed") + // ErrSignerMismatch indicates the signature verifies, but against an + // identity other than the expected one. + ErrSignerMismatch = errors.New("signer identity mismatch") +) diff --git a/pkg/skills/verifier/mocks/mock_verifier.go b/pkg/skills/verifier/mocks/mock_verifier.go new file mode 100644 index 0000000000..7110a2430b --- /dev/null +++ b/pkg/skills/verifier/mocks/mock_verifier.go @@ -0,0 +1,102 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: verifier.go +// +// Generated by this command: +// +// mockgen -destination=mocks/mock_verifier.go -package=mocks -source=verifier.go Verifier +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + lockfile "github.com/stacklok/toolhive/pkg/skills/lockfile" + verifier "github.com/stacklok/toolhive/pkg/skills/verifier" + gomock "go.uber.org/mock/gomock" +) + +// MockVerifier is a mock of Verifier interface. +type MockVerifier struct { + ctrl *gomock.Controller + recorder *MockVerifierMockRecorder + isgomock struct{} +} + +// MockVerifierMockRecorder is the mock recorder for MockVerifier. +type MockVerifierMockRecorder struct { + mock *MockVerifier +} + +// NewMockVerifier creates a new mock instance. +func NewMockVerifier(ctrl *gomock.Controller) *MockVerifier { + mock := &MockVerifier{ctrl: ctrl} + mock.recorder = &MockVerifierMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockVerifier) EXPECT() *MockVerifierMockRecorder { + return m.recorder +} + +// ResultFromBundle mocks base method. +func (m *MockVerifier) ResultFromBundle(bundle []byte, digest string) (*verifier.Result, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResultFromBundle", bundle, digest) + ret0, _ := ret[0].(*verifier.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResultFromBundle indicates an expected call of ResultFromBundle. +func (mr *MockVerifierMockRecorder) ResultFromBundle(bundle, digest any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResultFromBundle", reflect.TypeOf((*MockVerifier)(nil).ResultFromBundle), bundle, digest) +} + +// VerifyBundleOffline mocks base method. +func (m *MockVerifier) VerifyBundleOffline(bundle []byte, digest string, expected *lockfile.Provenance) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "VerifyBundleOffline", bundle, digest, expected) + ret0, _ := ret[0].(error) + return ret0 +} + +// VerifyBundleOffline indicates an expected call of VerifyBundleOffline. +func (mr *MockVerifierMockRecorder) VerifyBundleOffline(bundle, digest, expected any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyBundleOffline", reflect.TypeOf((*MockVerifier)(nil).VerifyBundleOffline), bundle, digest, expected) +} + +// VerifyOCI mocks base method. +func (m *MockVerifier) VerifyOCI(ctx context.Context, imageRef, digest string, expected *lockfile.Provenance) (*verifier.Result, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "VerifyOCI", ctx, imageRef, digest, expected) + ret0, _ := ret[0].(*verifier.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// VerifyOCI indicates an expected call of VerifyOCI. +func (mr *MockVerifierMockRecorder) VerifyOCI(ctx, imageRef, digest, expected any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyOCI", reflect.TypeOf((*MockVerifier)(nil).VerifyOCI), ctx, imageRef, digest, expected) +} + +// VerifyOCIWithKey mocks base method. +func (m *MockVerifier) VerifyOCIWithKey(ctx context.Context, imageRef, digest string, pubKeyPEM []byte) (*verifier.Result, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "VerifyOCIWithKey", ctx, imageRef, digest, pubKeyPEM) + ret0, _ := ret[0].(*verifier.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// VerifyOCIWithKey indicates an expected call of VerifyOCIWithKey. +func (mr *MockVerifierMockRecorder) VerifyOCIWithKey(ctx, imageRef, digest, pubKeyPEM any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyOCIWithKey", reflect.TypeOf((*MockVerifier)(nil).VerifyOCIWithKey), ctx, imageRef, digest, pubKeyPEM) +} diff --git a/pkg/skills/verifier/oci.go b/pkg/skills/verifier/oci.go new file mode 100644 index 0000000000..e159e0f117 --- /dev/null +++ b/pkg/skills/verifier/oci.go @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package verifier + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/sigstore/sigstore-go/pkg/root" + "github.com/sigstore/sigstore-go/pkg/verify" + + coreverifier "github.com/stacklok/toolhive-core/container/verifier" + "github.com/stacklok/toolhive/pkg/skills/lockfile" +) + +// VerifyOCI discovers and verifies the Sigstore signature for an OCI +// artifact via the keyless (Fulcio) flow. See the interface documentation +// for the expected/TOFU semantics. +func (d *Default) VerifyOCI( + ctx context.Context, + imageRef, digest string, + expected *lockfile.Provenance, +) (*Result, error) { + bundles, err := d.retrieveBundles(ctx, imageRef, digest) + if err != nil { + return nil, err + } + + tm, err := coreverifier.OfflineTrustedMaterial() + if err != nil { + return nil, fmt.Errorf("loading trusted material: %w", err) + } + opts, err := coreverifier.DefaultVerifierOptions() + if err != nil { + return nil, fmt.Errorf("loading verifier options: %w", err) + } + + var lastErr error + for _, b := range bundles { + vr, verifyErr := coreverifier.VerifyBundle(b, tm, expectedIdentity(expected), opts...) + if verifyErr != nil { + lastErr = verifyErr + continue + } + identity, idErr := coreverifier.IdentityFromResult(vr) + if idErr != nil { + lastErr = idErr + continue + } + return resultFromCore(identity, b.Raw), nil + } + return nil, d.classifyVerifyFailure(bundles, tm, opts, expected, lastErr) +} + +// VerifyOCIWithKey discovers and verifies the Sigstore signature for an OCI +// artifact against a PEM public key (the cosign key-pair flow). +func (d *Default) VerifyOCIWithKey( + ctx context.Context, + imageRef, digest string, + pubKeyPEM []byte, +) (*Result, error) { + bundles, err := d.retrieveBundles(ctx, imageRef, digest) + if err != nil { + return nil, err + } + + for _, b := range bundles { + if _, verifyErr := coreverifier.VerifyBundleWithKey(b, pubKeyPEM); verifyErr != nil { + continue + } + return &Result{ + Signed: true, + SigstoreURL: sigstorePublicGoodRekorURL, + Bundle: b.Raw, + }, nil + } + return nil, ErrSignatureInvalid +} + +// retrieveBundles fetches the signature bundles for the artifact pinned to +// the digest, mapping core's unsigned signal to ErrUnsigned. +func (d *Default) retrieveBundles(ctx context.Context, imageRef, digest string) ([]coreverifier.Bundle, error) { + ref := imageRef + if digest != "" && !strings.Contains(ref, "@") { + ref = imageRef + "@" + digest + } + bundles, err := coreverifier.RetrieveBundles(ctx, ref, d.keychain) + if errors.Is(err, coreverifier.ErrNoBundles) { + return nil, fmt.Errorf("%w: %w", ErrUnsigned, err) + } + if err != nil { + return nil, err + } + return bundles, nil +} + +// classifyVerifyFailure distinguishes a signer mismatch from an invalid +// signature. The expected identity is enforced inside the Sigstore policy, +// so a mismatch surfaces as a verification failure; re-verifying without +// the identity constraint tells the two apart: if the chain of trust holds +// without the constraint, the failure was the identity. +func (*Default) classifyVerifyFailure( + bundles []coreverifier.Bundle, + tm root.TrustedMaterial, + opts []verify.VerifierOption, + expected *lockfile.Provenance, + lastErr error, +) error { + if expected != nil { + for _, b := range bundles { + if _, err := coreverifier.VerifyBundle(b, tm, nil, opts...); err == nil { + return fmt.Errorf("%w: artifact is signed by a different identity than %q", + ErrSignerMismatch, expected.SignerIdentity) + } + } + } + if lastErr != nil { + return fmt.Errorf("%w: %w", ErrSignatureInvalid, lastErr) + } + return ErrSignatureInvalid +} diff --git a/pkg/skills/verifier/offline.go b/pkg/skills/verifier/offline.go new file mode 100644 index 0000000000..a52ebaa94a --- /dev/null +++ b/pkg/skills/verifier/offline.go @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package verifier + +import ( + "errors" + "fmt" + + coreverifier "github.com/stacklok/toolhive-core/container/verifier" + "github.com/stacklok/toolhive/pkg/skills/lockfile" +) + +// VerifyBundleOffline re-verifies a stored bundle against the artifact +// digest without network access. A non-nil expected identity is enforced +// inside the Sigstore policy; a mismatch is reported as ErrSignerMismatch, +// any other verification failure as ErrSignatureInvalid. +func (*Default) VerifyBundleOffline(bundleBytes []byte, digest string, expected *lockfile.Provenance) error { + if len(bundleBytes) == 0 { + return fmt.Errorf("%w: no stored bundle", ErrSignatureInvalid) + } + _, err := coreverifier.VerifyBundleOffline(bundleBytes, digest, expectedIdentity(expected)) + if err == nil { + return nil + } + if expected != nil && !errors.Is(err, coreverifier.ErrVerificationFailed) { + // Malformed input never reaches verification; don't reclassify. + return fmt.Errorf("%w: %w", ErrSignatureInvalid, err) + } + if expected != nil { + // The identity is bound into the policy, so a mismatch surfaces as + // a verification failure; re-verifying without the constraint tells + // mismatch apart from a broken signature. + if _, tofuErr := coreverifier.VerifyBundleOffline(bundleBytes, digest, nil); tofuErr == nil { + return fmt.Errorf("%w: bundle is signed by a different identity than %q", + ErrSignerMismatch, expected.SignerIdentity) + } + } + return fmt.Errorf("%w: %w", ErrSignatureInvalid, err) +} + +// ResultFromBundle verifies a stored bundle offline (chain of trust only) +// and returns the observed identity, for back-filling provenance of +// adopted skills. +func (*Default) ResultFromBundle(bundleBytes []byte, digest string) (*Result, error) { + if len(bundleBytes) == 0 { + return nil, fmt.Errorf("%w: no stored bundle", ErrSignatureInvalid) + } + vr, err := coreverifier.VerifyBundleOffline(bundleBytes, digest, nil) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrSignatureInvalid, err) + } + identity, err := coreverifier.IdentityFromResult(vr) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrSignatureInvalid, err) + } + return resultFromCore(identity, bundleBytes), nil +} diff --git a/pkg/skills/verifier/types.go b/pkg/skills/verifier/types.go new file mode 100644 index 0000000000..6008f633b7 --- /dev/null +++ b/pkg/skills/verifier/types.go @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package verifier + +import ( + coreverifier "github.com/stacklok/toolhive-core/container/verifier" + "github.com/stacklok/toolhive/pkg/skills/lockfile" +) + +// sigstorePublicGoodRekorURL identifies the transparency log instance the +// embedded trust root belongs to, recorded in lock provenance for humans +// auditing the file. +const sigstorePublicGoodRekorURL = "https://rekor.sigstore.dev" + +// Result contains the outcome of verifying a signed artifact. +type Result struct { + // Signed is true when a signature was found and verified. + Signed bool + // SignerIdentity is the certificate's subject identity (workflow path + // for GitHub-Actions-issued certificates, SAN otherwise). Empty for + // key-signed artifacts, which carry no certificate. + SignerIdentity string + // CertIssuer is the OIDC issuer that authenticated the signer. Empty + // for key-signed artifacts. + CertIssuer string + // RepositoryURI is the source repository from the certificate, if any. + RepositoryURI string + // SigstoreURL is the transparency log instance used for verification. + SigstoreURL string + // Bundle is the serialized Sigstore bundle for offline re-verification. + Bundle []byte +} + +// ToLockProvenance converts a verification result to a lock file provenance +// block. Key-signed results have no certificate identity and yield nil — +// the lock file records provenance only for identity-bearing signatures. +func (r *Result) ToLockProvenance() *lockfile.Provenance { + if r == nil || !r.Signed || r.SignerIdentity == "" { + return nil + } + return &lockfile.Provenance{ + SignerIdentity: r.SignerIdentity, + CertIssuer: r.CertIssuer, + RepositoryURI: r.RepositoryURI, + SigstoreURL: r.SigstoreURL, + } +} + +// expectedIdentity converts a lock provenance block into the core Identity +// that gets bound into the Sigstore verification policy. A nil provenance +// (trust on first use) yields nil, which core treats as chain-of-trust-only +// verification. +func expectedIdentity(p *lockfile.Provenance) *coreverifier.Identity { + if p == nil { + return nil + } + return &coreverifier.Identity{ + SignerIdentity: p.SignerIdentity, + CertIssuer: p.CertIssuer, + SourceRepositoryURI: p.RepositoryURI, + } +} + +// resultFromCore builds a Result from a core verification outcome. +func resultFromCore(identity coreverifier.Identity, raw []byte) *Result { + return &Result{ + Signed: true, + SignerIdentity: identity.SignerIdentity, + CertIssuer: identity.CertIssuer, + RepositoryURI: identity.SourceRepositoryURI, + SigstoreURL: sigstorePublicGoodRekorURL, + Bundle: raw, + } +} diff --git a/pkg/skills/verifier/verifier.go b/pkg/skills/verifier/verifier.go new file mode 100644 index 0000000000..05299ed180 --- /dev/null +++ b/pkg/skills/verifier/verifier.go @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package verifier verifies Sigstore signatures on skill artifacts. +// +// It is a thin wrapper over toolhive-core's container/verifier exports: all +// cryptographic verification — including binding an expected identity into +// the Sigstore policy — happens in core. This package adds the +// skills-domain vocabulary: lock file provenance conversion, the +// unsigned/invalid/mismatch error taxonomy, and the trust-on-first-use flow +// (nil expected identity verifies the chain of trust only; the caller +// records the observed identity). +// +// Verification uses the trusted root embedded in toolhive-core — hermetic, +// no TUF fetch — so results are reproducible offline at the cost of +// snapshot freshness (see core's OfflineTrustedMaterial). +package verifier + +import ( + "context" + + "github.com/google/go-containerregistry/pkg/authn" + + "github.com/stacklok/toolhive/pkg/skills/lockfile" +) + +//go:generate mockgen -destination=mocks/mock_verifier.go -package=mocks -source=verifier.go Verifier + +// Verifier verifies Sigstore signatures for skill artifacts. +type Verifier interface { + // VerifyOCI discovers the Sigstore signature material attached to the + // OCI artifact and verifies it (keyless/Fulcio flow). A non-nil + // expected identity is enforced inside the Sigstore verification + // policy; nil expected is the trust-on-first-use case and verifies the + // chain of trust only. Returns ErrUnsigned when the artifact carries + // no signature material. + VerifyOCI(ctx context.Context, imageRef, digest string, expected *lockfile.Provenance) (*Result, error) + + // VerifyOCIWithKey discovers the signature material and verifies it + // against the given PEM public key (the cosign key-pair flow). + // Key-signed bundles carry no certificate identity: trust is the key. + VerifyOCIWithKey(ctx context.Context, imageRef, digest string, pubKeyPEM []byte) (*Result, error) + + // VerifyBundleOffline re-verifies a stored bundle against the artifact + // digest ("sha256:") without network access, enforcing expected + // like VerifyOCI. + VerifyBundleOffline(bundle []byte, digest string, expected *lockfile.Provenance) error + + // ResultFromBundle verifies a stored bundle offline (chain of trust + // only) and returns the observed identity — used to back-fill + // provenance for adopted skills. + ResultFromBundle(bundle []byte, digest string) (*Result, error) +} + +// Default implements Verifier on toolhive-core's Sigstore exports. +type Default struct { + keychain authn.Keychain +} + +var _ Verifier = (*Default)(nil) + +// NewDefault creates a verifier using the given registry auth keychain for +// bundle retrieval. A nil keychain falls back to the default keychain. +func NewDefault(keychain authn.Keychain) *Default { + if keychain == nil { + keychain = authn.DefaultKeychain + } + return &Default{keychain: keychain} +} diff --git a/pkg/skills/verifier/verifier_test.go b/pkg/skills/verifier/verifier_test.go new file mode 100644 index 0000000000..e6e771ef55 --- /dev/null +++ b/pkg/skills/verifier/verifier_test.go @@ -0,0 +1,229 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package verifier + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/sigstore/sigstore/pkg/cryptoutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/skills/lockfile" + "github.com/stacklok/toolhive/pkg/skills/signer" +) + +// startTestRegistry runs an in-process OCI registry and returns its host. +func startTestRegistry(t *testing.T) string { + t.Helper() + reg := httptest.NewServer(registry.New()) + t.Cleanup(reg.Close) + return strings.TrimPrefix(reg.URL, "http://") +} + +// pushTestArtifact pushes a random OCI image and returns its ref and digest. +func pushTestArtifact(t *testing.T, host string) (ref string, digest string) { + t.Helper() + img, err := random.Image(256, 1) + require.NoError(t, err) + ref = host + "/test/skill:v1" + parsed, err := name.ParseReference(ref) + require.NoError(t, err) + require.NoError(t, remote.Write(parsed, img)) + d, err := img.Digest() + require.NoError(t, err) + return ref, d.String() +} + +// signArtifact signs the artifact with a fresh cosign key via the signer +// package and returns the public key PEM and the bundle it produced — +// exactly the flow `thv skill push --key` performs. +func signArtifact(t *testing.T, ref, digest string) (pubPEM, bundle []byte) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + privPEM, err := cryptoutils.MarshalPrivateKeyToPEM(priv) + require.NoError(t, err) + keyPath := filepath.Join(t.TempDir(), "cosign.key") + require.NoError(t, os.WriteFile(keyPath, privPEM, 0o600)) + pubPEM, err = cryptoutils.MarshalPublicKeyToPEM(priv.Public()) + require.NoError(t, err) + + raw, err := signer.NewDefault(nil).SignOCI(t.Context(), ref, digest, signer.Options{Key: keyPath}) + require.NoError(t, err) + return pubPEM, raw +} + +func TestVerifyOCIWithKeyRoundTrip(t *testing.T) { + t.Parallel() + host := startTestRegistry(t) + ref, digest := pushTestArtifact(t, host) + pubPEM, _ := signArtifact(t, ref, digest) + + result, err := NewDefault(nil).VerifyOCIWithKey(t.Context(), ref, digest, pubPEM) + require.NoError(t, err, "the artifact signed by the signer package must verify with its key") + assert.True(t, result.Signed) + assert.NotEmpty(t, result.Bundle, "the bundle must be returned for durable storage") + assert.Empty(t, result.SignerIdentity, "key-signed artifacts carry no certificate identity") + assert.Nil(t, result.ToLockProvenance(), "key-signed results must not fabricate lock provenance") +} + +func TestVerifyOCIWithKeyRejectsWrongKey(t *testing.T) { + t.Parallel() + host := startTestRegistry(t) + ref, digest := pushTestArtifact(t, host) + signArtifact(t, ref, digest) + + otherPriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + otherPub, err := cryptoutils.MarshalPublicKeyToPEM(otherPriv.Public()) + require.NoError(t, err) + + _, err = NewDefault(nil).VerifyOCIWithKey(t.Context(), ref, digest, otherPub) + require.ErrorIs(t, err, ErrSignatureInvalid) +} + +func TestVerifyOCIUnsignedArtifact(t *testing.T) { + t.Parallel() + host := startTestRegistry(t) + ref, digest := pushTestArtifact(t, host) + + _, err := NewDefault(nil).VerifyOCI(t.Context(), ref, digest, nil) + require.ErrorIs(t, err, ErrUnsigned) + + _, err = NewDefault(nil).VerifyOCIWithKey(t.Context(), ref, digest, nil) + require.ErrorIs(t, err, ErrUnsigned) +} + +func TestVerifyOCIKeylessRejectsKeySignedArtifact(t *testing.T) { + t.Parallel() + host := startTestRegistry(t) + ref, digest := pushTestArtifact(t, host) + signArtifact(t, ref, digest) + + // The keyless flow requires a certificate chain to Fulcio; a key-signed + // bundle has none, so this must fail as an invalid signature — not as + // unsigned, and never as a panic. + _, err := NewDefault(nil).VerifyOCI(t.Context(), ref, digest, nil) + require.ErrorIs(t, err, ErrSignatureInvalid) +} + +func TestVerifyBundleOffline(t *testing.T) { + t.Parallel() + host := startTestRegistry(t) + ref, digest := pushTestArtifact(t, host) + _, bundle := signArtifact(t, ref, digest) + d := NewDefault(nil) + + t.Run("empty bundle rejected", func(t *testing.T) { + t.Parallel() + err := d.VerifyBundleOffline(nil, digest, nil) + require.ErrorIs(t, err, ErrSignatureInvalid) + }) + + t.Run("malformed bundle rejected", func(t *testing.T) { + t.Parallel() + err := d.VerifyBundleOffline([]byte("not a bundle"), digest, nil) + require.ErrorIs(t, err, ErrSignatureInvalid) + }) + + t.Run("key-signed bundle fails keyless offline verification", func(t *testing.T) { + t.Parallel() + // The stored bundle parses and reaches verification, but carries no + // certificate — the offline keyless path must reject it rather than + // trusting it. + err := d.VerifyBundleOffline(bundle, digest, nil) + require.ErrorIs(t, err, ErrSignatureInvalid) + }) + + t.Run("expected identity against unverifiable bundle stays invalid", func(t *testing.T) { + t.Parallel() + // Both the pinned and the TOFU re-verify fail, so this must NOT be + // misclassified as a signer mismatch. + err := d.VerifyBundleOffline(bundle, digest, &lockfile.Provenance{ + SignerIdentity: "/.github/workflows/release.yml", + CertIssuer: "https://token.actions.githubusercontent.com", + }) + require.ErrorIs(t, err, ErrSignatureInvalid) + require.NotErrorIs(t, err, ErrSignerMismatch) + }) +} + +func TestResultFromBundleMalformed(t *testing.T) { + t.Parallel() + d := NewDefault(nil) + + _, err := d.ResultFromBundle(nil, "sha256:abc") + require.ErrorIs(t, err, ErrSignatureInvalid) + + _, err = d.ResultFromBundle([]byte("junk"), "sha256:abc") + require.ErrorIs(t, err, ErrSignatureInvalid) +} + +func TestToLockProvenance(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + result *Result + want *lockfile.Provenance + }{ + {name: "nil result", result: nil, want: nil}, + {name: "unsigned result", result: &Result{Signed: false}, want: nil}, + { + name: "key-signed result has no identity to record", + result: &Result{Signed: true, SigstoreURL: sigstorePublicGoodRekorURL}, + want: nil, + }, + { + name: "identity-bearing result maps all fields", + result: &Result{ + Signed: true, + SignerIdentity: "/.github/workflows/release.yml", + CertIssuer: "https://token.actions.githubusercontent.com", + RepositoryURI: "https://github.com/org/repo", + SigstoreURL: sigstorePublicGoodRekorURL, + }, + want: &lockfile.Provenance{ + SignerIdentity: "/.github/workflows/release.yml", + CertIssuer: "https://token.actions.githubusercontent.com", + RepositoryURI: "https://github.com/org/repo", + SigstoreURL: sigstorePublicGoodRekorURL, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, tc.result.ToLockProvenance()) + }) + } +} + +func TestExpectedIdentityConversion(t *testing.T) { + t.Parallel() + + assert.Nil(t, expectedIdentity(nil), "TOFU first use must yield a nil core identity") + + got := expectedIdentity(&lockfile.Provenance{ + SignerIdentity: "/.github/workflows/release.yml", + CertIssuer: "https://token.actions.githubusercontent.com", + RepositoryURI: "https://github.com/org/repo", + }) + require.NotNil(t, got) + assert.Equal(t, "/.github/workflows/release.yml", got.SignerIdentity) + assert.Equal(t, "https://token.actions.githubusercontent.com", got.CertIssuer) + assert.Equal(t, "https://github.com/org/repo", got.SourceRepositoryURI) +}