diff --git a/pkg/skills/signer/cosign_attach.go b/pkg/skills/signer/cosign_attach.go index fa1d3e97a4..880dc0c44b 100644 --- a/pkg/skills/signer/cosign_attach.go +++ b/pkg/skills/signer/cosign_attach.go @@ -46,12 +46,14 @@ type cosignImage struct { DockerManifestDigest string `json:"docker-manifest-digest"` } -// simpleSigningPayload builds the canonical simple-signing payload for the +// SimpleSigningPayload builds the canonical simple-signing payload for the // artifact at ref pinned to digestStr. This payload — not the manifest // digest — is what gets signed, per the cosign convention: a verifier // recovers the payload from the signature manifest's layer, checks the // signature over it, and reads the bound manifest digest out of it. -func simpleSigningPayload(imageRef, digestStr string) ([]byte, error) { +// Exported because offline re-verification of a stored key-signed bundle +// must reconstruct exactly these bytes to check the signature's binding. +func SimpleSigningPayload(imageRef, digestStr string) ([]byte, error) { ref, err := name.ParseReference(imageRef) if err != nil { return nil, fmt.Errorf("parsing image reference: %w", err) diff --git a/pkg/skills/signer/signer.go b/pkg/skills/signer/signer.go index 59d13efd22..245ff69e38 100644 --- a/pkg/skills/signer/signer.go +++ b/pkg/skills/signer/signer.go @@ -76,7 +76,7 @@ func (d *Default) SignOCI(ctx context.Context, ref, digestStr string, opts Optio return nil, err } - payload, err := simpleSigningPayload(ref, digestStr) + payload, err := SimpleSigningPayload(ref, digestStr) if err != nil { return nil, err } diff --git a/pkg/skills/signer/signer_test.go b/pkg/skills/signer/signer_test.go index 91e2b68b19..7e72332604 100644 --- a/pkg/skills/signer/signer_test.go +++ b/pkg/skills/signer/signer_test.go @@ -108,7 +108,7 @@ func TestSignOCIRoundTrip(t *testing.T) { // The returned bundle verifies against the signing key over the // simple-signing payload digest. - payload, err := simpleSigningPayload(ref, digestStr) + payload, err := SimpleSigningPayload(ref, digestStr) require.NoError(t, err) payloadDigest := sha256.Sum256(payload) require.NoError(t, verifyKeyBundle(t, raw, pubPEM, payloadDigest[:]), @@ -163,7 +163,7 @@ func TestSignOCIRejectsWrongKeyVerification(t *testing.T) { raw, err := NewDefault(nil).SignOCI(t.Context(), ref, digestStr, Options{Key: keyPath}) require.NoError(t, err) - payload, err := simpleSigningPayload(ref, digestStr) + payload, err := SimpleSigningPayload(ref, digestStr) require.NoError(t, err) payloadDigest := sha256.Sum256(payload) require.Error(t, verifyKeyBundle(t, raw, otherPub, payloadDigest[:]), @@ -275,7 +275,7 @@ func TestParseManifestDigestNormalizesBareHex(t *testing.T) { func TestSimpleSigningPayloadBindsDigestAndRepo(t *testing.T) { t.Parallel() digestStr := "sha256:" + strings.Repeat("b", 64) - payload, err := simpleSigningPayload("example.com/org/skill:v1", digestStr) + payload, err := SimpleSigningPayload("example.com/org/skill:v1", digestStr) require.NoError(t, err) var got cosignSimpleSigning 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..3bca66007f --- /dev/null +++ b/pkg/skills/verifier/mocks/mock_verifier.go @@ -0,0 +1,116 @@ +// 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) +} + +// VerifyBundleOfflineWithKey mocks base method. +func (m *MockVerifier) VerifyBundleOfflineWithKey(bundle []byte, imageRef, digest string, pubKeyPEM []byte) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "VerifyBundleOfflineWithKey", bundle, imageRef, digest, pubKeyPEM) + ret0, _ := ret[0].(error) + return ret0 +} + +// VerifyBundleOfflineWithKey indicates an expected call of VerifyBundleOfflineWithKey. +func (mr *MockVerifierMockRecorder) VerifyBundleOfflineWithKey(bundle, imageRef, digest, pubKeyPEM any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyBundleOfflineWithKey", reflect.TypeOf((*MockVerifier)(nil).VerifyBundleOfflineWithKey), bundle, imageRef, digest, pubKeyPEM) +} + +// 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..d2291c5b3a --- /dev/null +++ b/pkg/skills/verifier/oci.go @@ -0,0 +1,183 @@ +// 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) + } + + result, lastErr := verifyKeylessBundles(bundles, tm, opts, expected) + if result != nil { + return result, nil + } + return nil, 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 + } + + var lastErr error + for _, b := range bundles { + if _, verifyErr := coreverifier.VerifyBundleWithKey(b, pubKeyPEM); verifyErr != nil { + lastErr = verifyErr + continue + } + return resultFromKey(b.Raw), nil + } + return nil, wrapInvalid(lastErr) +} + +// verifyKeylessBundles verifies bundles until one passes the keyless policy, +// returning its result, or nil with the last verification error. +func verifyKeylessBundles( + bundles []coreverifier.Bundle, + tm root.TrustedMaterial, + opts []verify.VerifierOption, + expected *lockfile.Provenance, +) (*Result, error) { + 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, lastErr +} + +// retrieveBundles fetches the signature bundles for the artifact pinned to +// the digest, mapping core's unsigned signal to ErrUnsigned. The digest is +// required — verification without a pinned digest would leave tag +// resolution to fetch time — and when imageRef already embeds one, the two +// must agree: verifying the ref's digest while the caller believes the +// parameter's was verified would hide lock corruption. +func (d *Default) retrieveBundles(ctx context.Context, imageRef, digest string) ([]coreverifier.Bundle, error) { + if digest == "" { + return nil, errors.New("artifact digest is required for verification") + } + ref := imageRef + if embedded, ok := splitEmbeddedDigest(imageRef); ok { + if embedded != digest { + return nil, fmt.Errorf("reference %q embeds digest %s but %s was requested — refusing to verify ambiguous input", + imageRef, embedded, digest) + } + } else { + ref = imageRef + "@" + digest + } + bundles, err := coreverifier.RetrieveBundles(ctx, ref, d.keychain) + if errors.Is(err, coreverifier.ErrNoBundles) { + return nil, fmt.Errorf("%w: no signature material found for %s", ErrUnsigned, ref) + } + if err != nil { + return nil, err + } + return bundles, nil +} + +// splitEmbeddedDigest returns the digest embedded in an OCI reference +// ("repo@sha256:..."), if any. +func splitEmbeddedDigest(imageRef string) (string, bool) { + _, embedded, ok := strings.Cut(imageRef, "@") + return embedded, ok +} + +// 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 — and the identity +// that DID verify is reported, so an operator can tell a legitimate +// publisher rotation from an artifact substitution. +func classifyVerifyFailure( + bundles []coreverifier.Bundle, + tm root.TrustedMaterial, + opts []verify.VerifierOption, + expected *lockfile.Provenance, + lastErr error, +) error { + if expected != nil { + for _, b := range bundles { + vr, err := coreverifier.VerifyBundle(b, tm, nil, opts...) + if err != nil { + continue + } + return signerMismatchError(vr, expected) + } + } + return wrapInvalid(lastErr) +} + +// signerMismatchError builds the ErrSignerMismatch error, naming both the +// expected identity tuple and the identity the artifact actually verifies +// with (when extractable). +func signerMismatchError(vr *verify.VerificationResult, expected *lockfile.Provenance) error { + observed, idErr := coreverifier.IdentityFromResult(vr) + if idErr != nil { + return fmt.Errorf("%w: artifact is signed by a different identity than %q (issuer %q)", + ErrSignerMismatch, expected.SignerIdentity, expected.CertIssuer) + } + return fmt.Errorf("%w: locked to %q (issuer %q), but the artifact verifies as %q (issuer %q)", + ErrSignerMismatch, + expected.SignerIdentity, expected.CertIssuer, + observed.SignerIdentity, observed.CertIssuer) +} + +// wrapInvalid wraps a verification cause in ErrSignatureInvalid without +// stuttering: core's own verification-failed sentinel prefix is trimmed +// from the display text (the classification value it carried is replaced by +// our sentinel; this is message cosmetics, not error matching). +func wrapInvalid(cause error) error { + if cause == nil { + return ErrSignatureInvalid + } + text := strings.TrimPrefix(cause.Error(), coreverifier.ErrVerificationFailed.Error()+": ") + return fmt.Errorf("%w: %s", ErrSignatureInvalid, text) +} diff --git a/pkg/skills/verifier/offline.go b/pkg/skills/verifier/offline.go new file mode 100644 index 0000000000..d7dc37f854 --- /dev/null +++ b/pkg/skills/verifier/offline.go @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package verifier + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + + coreverifier "github.com/stacklok/toolhive-core/container/verifier" + "github.com/stacklok/toolhive/pkg/skills/lockfile" + "github.com/stacklok/toolhive/pkg/skills/signer" +) + +// 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 { + // Classified as an invalid signature because a recorded identity + // with nothing backing it cannot be verified; the message leads + // with the actionable fact — the stored bundle is missing, so + // reinstalling (or re-adopting) is the fix. + return fmt.Errorf("%w: no stored bundle to verify — reinstall to restore it", 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: %s", ErrSignatureInvalid, err.Error()) + } + 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 — and yields the identity + // that DID verify, which the error reports. + if vr, tofuErr := coreverifier.VerifyBundleOffline(bundleBytes, digest, nil); tofuErr == nil { + return signerMismatchError(vr, expected) + } + } + return wrapInvalid(err) +} + +// VerifyBundleOfflineWithKey re-verifies a stored key-signed bundle against +// the signer's PEM public key — the offline counterpart of +// VerifyOCIWithKey. Key-signed bundles sign the cosign simple-signing +// payload (which embeds the artifact digest), so the payload is +// reconstructed from imageRef and digest and the signature checked over it +// — that reconstruction IS the digest-binding check. +func (*Default) VerifyBundleOfflineWithKey(bundleBytes []byte, imageRef, digest string, pubKeyPEM []byte) error { + if len(bundleBytes) == 0 { + return fmt.Errorf("%w: no stored bundle to verify — reinstall to restore it", ErrSignatureInvalid) + } + payload, err := signer.SimpleSigningPayload(imageRef, digest) + if err != nil { + return fmt.Errorf("%w: %s", ErrSignatureInvalid, err.Error()) + } + payloadDigest := sha256.Sum256(payload) + digestArg := coreverifier.DigestAlgorithmSHA256 + ":" + hex.EncodeToString(payloadDigest[:]) + if _, err := coreverifier.VerifyBundleOfflineWithKey(bundleBytes, digestArg, pubKeyPEM); err != nil { + return wrapInvalid(err) + } + return nil +} + +// 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 to verify", ErrSignatureInvalid) + } + vr, err := coreverifier.VerifyBundleOffline(bundleBytes, digest, nil) + if err != nil { + return nil, wrapInvalid(err) + } + identity, err := coreverifier.IdentityFromResult(vr) + if err != nil { + return nil, wrapInvalid(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..4c68511b6f --- /dev/null +++ b/pkg/skills/verifier/types.go @@ -0,0 +1,86 @@ +// 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, + } +} + +// resultFromKey builds a Result for a key-signed artifact. It carries no +// certificate identity and no SigstoreURL — the key flow writes no +// transparency-log entry, and recording an instance the signature never +// touched would fabricate provenance. +func resultFromKey(raw []byte) *Result { + return &Result{ + Signed: true, + Bundle: raw, + } +} + +// 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..ad78875d3a --- /dev/null +++ b/pkg/skills/verifier/verifier.go @@ -0,0 +1,75 @@ +// 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 + + // VerifyBundleOfflineWithKey re-verifies a stored key-signed bundle + // against the signer's PEM public key without network access — the + // offline counterpart of VerifyOCIWithKey. imageRef and digest + // reconstruct the signed payload, binding the check to the artifact. + VerifyBundleOfflineWithKey(bundle []byte, imageRef, digest string, pubKeyPEM []byte) 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..9e07cbb90d --- /dev/null +++ b/pkg/skills/verifier/verifier_test.go @@ -0,0 +1,264 @@ +// 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.Empty(t, result.SigstoreURL, + "the key flow writes no transparency-log entry; recording one would fabricate provenance") + assert.Nil(t, result.ToLockProvenance(), "key-signed results must not fabricate lock provenance") + + // The stored bundle re-verifies offline with the key, and rejects a + // different key. + require.NoError(t, NewDefault(nil).VerifyBundleOfflineWithKey(result.Bundle, ref, digest, pubPEM)) + otherPriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + otherPub, err := cryptoutils.MarshalPublicKeyToPEM(otherPriv.Public()) + require.NoError(t, err) + require.ErrorIs(t, NewDefault(nil).VerifyBundleOfflineWithKey(result.Bundle, ref, digest, otherPub), + ErrSignatureInvalid) + require.ErrorIs(t, NewDefault(nil).VerifyBundleOfflineWithKey( + result.Bundle, ref, "sha256:"+strings.Repeat("e", 64), pubPEM), ErrSignatureInvalid, + "a different artifact digest reconstructs a different payload and must not verify") +} + +func TestVerifyOCIDigestGuards(t *testing.T) { + t.Parallel() + host := startTestRegistry(t) + ref, digest := pushTestArtifact(t, host) + d := NewDefault(nil) + + _, err := d.VerifyOCI(t.Context(), ref, "", nil) + require.ErrorContains(t, err, "digest is required", + "an empty digest would leave tag resolution to fetch time") + + otherDigest := "sha256:" + strings.Repeat("f", 64) + _, err = d.VerifyOCI(t.Context(), ref+"@"+digest, otherDigest, nil) + require.ErrorContains(t, err, "refusing to verify ambiguous input", + "a ref-embedded digest disagreeing with the parameter is lock corruption") + + // Agreement between the embedded digest and the parameter is fine. + _, err = d.VerifyOCI(t.Context(), ref+"@"+digest, digest, nil) + require.ErrorIs(t, err, ErrUnsigned, "consistent inputs proceed to retrieval") +} + +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) +}