From 3a2f05e9b7663c116fcc67395659e63d0625bd99 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 29 Jul 2026 09:01:06 +0200 Subject: [PATCH] Re-verify stored signatures offline during sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync now re-verifies each managed entry's stored Sigstore bundle against the identity recorded in the lock file — entirely offline, via the embedded trust root — before an entry can count as current (RFC THV-0080). A failed re-verification is drift: check mode reports it (the CI gate covers signatures like it covers content drift), and apply mode reinstalls from the pinned reference, where install-time verification enforces the locked identity and heals the stored state. An OCI entry recording a signer identity without a stored bundle fails closed; git entries store no bundle by design — their signatures are re-verified when content is re-resolved. Adoption back-fills provenance from the stored bundle when one exists; otherwise adopting is the same trust decision as an unsigned install and now requires the explicit --allow-unsigned exception (new flag on sync, threaded through the API), recorded as unsigned in the entry. Part of #5899. Co-Authored-By: Claude Fable 5 --- cmd/thv/app/skill_sync.go | 28 +++--- docs/cli/thv_skill_sync.md | 1 + docs/server/docs.go | 4 + docs/server/swagger.json | 4 + docs/server/swagger.yaml | 5 + pkg/api/v1/skills.go | 11 +- pkg/api/v1/skills_types.go | 3 + pkg/skills/client/client.go | 11 +- pkg/skills/client/client_test.go | 22 ++++ pkg/skills/client/dto.go | 2 + pkg/skills/options.go | 4 + pkg/skills/skillsvc/lock_test.go | 4 + pkg/skills/skillsvc/sync.go | 67 ++++++++++++- pkg/skills/skillsvc/sync_test.go | 16 ++- pkg/skills/skillsvc/sync_verify_test.go | 127 ++++++++++++++++++++++++ 15 files changed, 283 insertions(+), 26 deletions(-) create mode 100644 pkg/skills/skillsvc/sync_verify_test.go diff --git a/cmd/thv/app/skill_sync.go b/cmd/thv/app/skill_sync.go index baac271d5d..1c52f9faf8 100644 --- a/cmd/thv/app/skill_sync.go +++ b/cmd/thv/app/skill_sync.go @@ -13,13 +13,14 @@ import ( ) var ( - skillSyncProjectRoot string - skillSyncClientsRaw string - skillSyncCheck bool - skillSyncAdopt bool - skillSyncPrune bool - skillSyncYes bool - skillSyncFormat string + skillSyncProjectRoot string + skillSyncClientsRaw string + skillSyncCheck bool + skillSyncAdopt bool + skillSyncPrune bool + skillSyncYes bool + skillSyncAllowUnsigned bool + skillSyncFormat string ) var skillSyncCmd = &cobra.Command{ @@ -59,6 +60,8 @@ func init() { "Remove installs no longer present in the lock file") skillSyncCmd.Flags().BoolVar(&skillSyncYes, "yes", false, "Skip the confirmation prompt (required when not running interactively)") + skillSyncCmd.Flags().BoolVar(&skillSyncAllowUnsigned, "allow-unsigned", false, + "Allow adopting skills whose signature state cannot be established (recorded as unsigned)") AddFormatFlag(skillSyncCmd, &skillSyncFormat) } @@ -84,11 +87,12 @@ func skillSyncCmdFunc(cmd *cobra.Command, _ []string) error { c := newSkillClient(cmd.Context()) result, err := c.Sync(cmd.Context(), skills.SyncOptions{ - ProjectRoot: projectRoot, - Clients: parseSkillInstallClients(skillSyncClientsRaw), - Check: skillSyncCheck, - Adopt: skillSyncAdopt, - Prune: skillSyncPrune, + ProjectRoot: projectRoot, + Clients: parseSkillInstallClients(skillSyncClientsRaw), + Check: skillSyncCheck, + Adopt: skillSyncAdopt, + Prune: skillSyncPrune, + AllowUnsigned: skillSyncAllowUnsigned, }) if err != nil { return formatSkillError("sync skills", err) diff --git a/docs/cli/thv_skill_sync.md b/docs/cli/thv_skill_sync.md index f7a78f99cd..8cf0f6beb1 100644 --- a/docs/cli/thv_skill_sync.md +++ b/docs/cli/thv_skill_sync.md @@ -37,6 +37,7 @@ thv skill sync [flags] ``` --adopt Write lock entries for existing unmanaged project-scope installs + --allow-unsigned Allow adopting skills whose signature state cannot be established (recorded as unsigned) --check Report drift without installing, writing, or removing anything --clients string Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client --format string Output format (json, text) (default "text") diff --git a/docs/server/docs.go b/docs/server/docs.go index 7ce641e12d..8fb16d6098 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -3159,6 +3159,10 @@ const docTemplate = `{ "description": "Adopt writes lock entries for existing unmanaged project-scope installs", "type": "boolean" }, + "allow_unsigned": { + "description": "AllowUnsigned permits adopting skills whose signature state cannot be\nestablished, recording them as unsigned", + "type": "boolean" + }, "check": { "description": "Check verifies on-disk content against the lock file without installing or writing anything", "type": "boolean" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 38f0dc82e7..57b7a354df 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -3152,6 +3152,10 @@ "description": "Adopt writes lock entries for existing unmanaged project-scope installs", "type": "boolean" }, + "allow_unsigned": { + "description": "AllowUnsigned permits adopting skills whose signature state cannot be\nestablished, recording them as unsigned", + "type": "boolean" + }, "check": { "description": "Check verifies on-disk content against the lock file without installing or writing anything", "type": "boolean" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 2e90a119c3..79e2d935b3 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -2850,6 +2850,11 @@ components: description: Adopt writes lock entries for existing unmanaged project-scope installs type: boolean + allow_unsigned: + description: |- + AllowUnsigned permits adopting skills whose signature state cannot be + established, recording them as unsigned + type: boolean check: description: Check verifies on-disk content against the lock file without installing or writing anything diff --git a/pkg/api/v1/skills.go b/pkg/api/v1/skills.go index c0db536f4a..6103712958 100644 --- a/pkg/api/v1/skills.go +++ b/pkg/api/v1/skills.go @@ -405,11 +405,12 @@ func (s *SkillsRoutes) syncSkills(w http.ResponseWriter, r *http.Request) error } result, err := s.lockService.Sync(r.Context(), skills.SyncOptions{ - ProjectRoot: req.ProjectRoot, - Clients: req.Clients, - Prune: req.Prune, - Check: req.Check, - Adopt: req.Adopt, + ProjectRoot: req.ProjectRoot, + Clients: req.Clients, + Prune: req.Prune, + Check: req.Check, + Adopt: req.Adopt, + AllowUnsigned: req.AllowUnsigned, }) if err != nil { return err diff --git a/pkg/api/v1/skills_types.go b/pkg/api/v1/skills_types.go index 65c2356c82..06a041fbe2 100644 --- a/pkg/api/v1/skills_types.go +++ b/pkg/api/v1/skills_types.go @@ -88,6 +88,9 @@ type syncSkillsRequest struct { Check bool `json:"check,omitempty"` // Adopt writes lock entries for existing unmanaged project-scope installs Adopt bool `json:"adopt,omitempty"` + // AllowUnsigned permits adopting skills whose signature state cannot be + // established, recording them as unsigned + AllowUnsigned bool `json:"allow_unsigned,omitempty"` } // upgradeSkillsRequest represents the request to upgrade a project's skills. diff --git a/pkg/skills/client/client.go b/pkg/skills/client/client.go index 6b55191a05..88b9ec1452 100644 --- a/pkg/skills/client/client.go +++ b/pkg/skills/client/client.go @@ -272,11 +272,12 @@ func (c *Client) GetContent(ctx context.Context, opts skills.ContentOptions) (*s // Sync restores a project's installed skills to match its lock file. func (c *Client) Sync(ctx context.Context, opts skills.SyncOptions) (*skills.SyncResult, error) { body := syncRequest{ - ProjectRoot: opts.ProjectRoot, - Clients: opts.Clients, - Prune: opts.Prune, - Check: opts.Check, - Adopt: opts.Adopt, + ProjectRoot: opts.ProjectRoot, + Clients: opts.Clients, + Prune: opts.Prune, + Check: opts.Check, + Adopt: opts.Adopt, + AllowUnsigned: opts.AllowUnsigned, } var result skills.SyncResult diff --git a/pkg/skills/client/client_test.go b/pkg/skills/client/client_test.go index 85dcdde295..08349093fc 100644 --- a/pkg/skills/client/client_test.go +++ b/pkg/skills/client/client_test.go @@ -766,3 +766,25 @@ func TestInstallCarriesAllowUnsigned(t *testing.T) { require.NoError(t, err) assert.True(t, got.AllowUnsigned, "allow_unsigned must reach the server") } + +// TestSyncCarriesAllowUnsigned mirrors TestInstallCarriesAllowUnsigned for +// the sync/adopt path. +func TestSyncCarriesAllowUnsigned(t *testing.T) { + t.Parallel() + + var got syncRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewDecoder(r.Body).Decode(&got)) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(skills.SyncResult{}) + })) + t.Cleanup(srv.Close) + + _, err := newTestClient(t, srv).Sync(t.Context(), skills.SyncOptions{ + ProjectRoot: "/tmp/project", + Adopt: true, + AllowUnsigned: true, + }) + require.NoError(t, err) + assert.True(t, got.AllowUnsigned, "allow_unsigned must reach the server") +} diff --git a/pkg/skills/client/dto.go b/pkg/skills/client/dto.go index f880aea099..0e1ed76146 100644 --- a/pkg/skills/client/dto.go +++ b/pkg/skills/client/dto.go @@ -51,6 +51,8 @@ type syncRequest struct { Prune bool `json:"prune,omitempty"` Check bool `json:"check,omitempty"` Adopt bool `json:"adopt,omitempty"` + // AllowUnsigned mirrors skills.SyncOptions.AllowUnsigned for adoption. + AllowUnsigned bool `json:"allow_unsigned,omitempty"` } type upgradeRequest struct { diff --git a/pkg/skills/options.go b/pkg/skills/options.go index c2819bdc17..21b81a3b13 100644 --- a/pkg/skills/options.go +++ b/pkg/skills/options.go @@ -225,6 +225,10 @@ type SyncOptions struct { // Check verifies on-disk content against contentDigest without installing // or writing anything. Check bool `json:"check,omitempty"` + // AllowUnsigned permits adopting a skill whose signature state cannot + // be established (no stored bundle), recording an explicit unsigned + // exception — the same trust decision as an unsigned install. + AllowUnsigned bool `json:"allow_unsigned,omitempty"` // Adopt writes lock entries for existing unmanaged project-scope installs. Adopt bool `json:"adopt,omitempty"` } diff --git a/pkg/skills/skillsvc/lock_test.go b/pkg/skills/skillsvc/lock_test.go index 1ee64abe8c..b859e3fd0c 100644 --- a/pkg/skills/skillsvc/lock_test.go +++ b/pkg/skills/skillsvc/lock_test.go @@ -70,6 +70,10 @@ func newLockTestService(t *testing.T, gr *gitmocks.MockResolver, extra ...Option AnyTimes().Return(signedResult(), nil) mv.EXPECT().VerifyOCI(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). AnyTimes().Return(signedResult(), nil) + mv.EXPECT().VerifyBundleOffline(gomock.Any(), gomock.Any(), gomock.Any()). + AnyTimes().Return(nil) + mv.EXPECT().ResultFromBundle(gomock.Any(), gomock.Any()). + AnyTimes().Return(signedResult(), nil) opts := append([]Option{WithPathResolver(pr), WithGitResolver(gr), WithVerifier(mv)}, extra...) svc := New(store, opts...) diff --git a/pkg/skills/skillsvc/sync.go b/pkg/skills/skillsvc/sync.go index e4c0eb4832..4e0b2ce867 100644 --- a/pkg/skills/skillsvc/sync.go +++ b/pkg/skills/skillsvc/sync.go @@ -7,11 +7,14 @@ import ( "context" "errors" "fmt" + "log/slog" "net/http" + "strings" "github.com/stacklok/toolhive-core/httperr" "github.com/stacklok/toolhive/pkg/skills" "github.com/stacklok/toolhive/pkg/skills/lockfile" + "github.com/stacklok/toolhive/pkg/skills/verifier" "github.com/stacklok/toolhive/pkg/storage" ) @@ -88,7 +91,19 @@ func (s *service) syncLockedEntry( dbOK bool, result *skills.SyncResult, ) { - if dbOK && entryMatchesInstalled(s.pathResolver, entry, sk) { + sigOK := true + if dbOK { + if sigErr := s.verifyStoredSignature(entry, sk); sigErr != nil { + // A failed offline re-verification is treated as drift: check + // mode reports it, apply mode reinstalls from the pinned + // reference — where install-time verification enforces the + // locked identity and, on success, heals the stored bundle. + sigOK = false + slog.Warn("stored signature failed offline re-verification", + "skill", entry.Name, "error", sigErr) + } + } + if dbOK && sigOK && entryMatchesInstalled(s.pathResolver, entry, sk) { result.AlreadyCurrent = append(result.AlreadyCurrent, entry.Name) return } @@ -170,7 +185,7 @@ func (s *service) syncUnlockedInstall( if !sk.Managed { result.NeverManaged = append(result.NeverManaged, sk.Metadata.Name) if opts.Adopt && !opts.Check { - if err := s.adoptSkill(ctx, sk); err != nil { + if err := s.adoptSkill(ctx, opts, sk); err != nil { result.Failed = append(result.Failed, skills.SyncFailure{ Name: sk.Metadata.Name, Reason: classifySyncFailure(err), Error: err.Error(), }) @@ -193,16 +208,60 @@ func (s *service) syncUnlockedInstall( } } +// verifyStoredSignature re-verifies the Sigstore bundle stored with an +// installed skill against the identity its lock entry records — entirely +// offline, via the embedded trust root. Entries recorded unsigned or with +// no provenance have nothing to verify. A recorded identity with no stored +// bundle fails closed for OCI installs (the bundle should exist); git +// installs never store a bundle — their signature lives on the commit and +// is re-verified when content is re-resolved. +func (s *service) verifyStoredSignature(entry lockfile.Entry, sk skills.InstalledSkill) error { + if entry.Unsigned || entry.Provenance == nil { + return nil + } + if len(sk.SigstoreBundle) == 0 { + if !strings.Contains(entry.Digest, ":") { + return nil // git install: no stored bundle by design + } + return fmt.Errorf("%w: lock entry records signer %q but no bundle is stored", + verifier.ErrSignatureInvalid, entry.Provenance.SignerIdentity) + } + return s.artifactVerifier().VerifyBundleOffline(sk.SigstoreBundle, entry.Digest, entry.Provenance) +} + // adoptSkill writes a lock entry for an existing, unmanaged project-scope // install, pinning its current on-disk state. The install's own Reference is // used as Source: an adopted install predates (or never went through) lock // tracking, so the original user-typed request is not recoverable — the // concrete resolved reference is the closest available fact to pin against. -func (s *service) adoptSkill(ctx context.Context, sk skills.InstalledSkill) error { +// +// Trust state is back-filled from the stored Sigstore bundle when one +// exists; otherwise adoption is the same trust decision as an unsigned +// install and requires the explicit AllowUnsigned exception. +func (s *service) adoptSkill(ctx context.Context, opts skills.SyncOptions, sk skills.InstalledSkill) error { contentDigest, err := computeContentDigest(s.pathResolver, sk) if err != nil { return fmt.Errorf("computing content digest: %w", err) } + var provenance *lockfile.Provenance + unsigned := false + if len(sk.SigstoreBundle) > 0 { + result, verifyErr := s.artifactVerifier().ResultFromBundle(sk.SigstoreBundle, sk.Digest) + if verifyErr != nil { + return fmt.Errorf("verifying stored bundle for adoption: %w", verifyErr) + } + provenance = provenanceInfoToLock(provenanceInfoFromResult(result)) + } + if provenance == nil { + if !opts.AllowUnsigned { + return httperr.WithCode( + fmt.Errorf("%w: adopting %q records it as unsigned; pass --allow-unsigned to accept that", + verifier.ErrUnsigned, sk.Metadata.Name), + http.StatusForbidden, + ) + } + unsigned = true + } if err := recordLockEntry(sk.ProjectRoot, lockEntryInput{ Name: sk.Metadata.Name, Version: sk.Metadata.Version, @@ -210,6 +269,8 @@ func (s *service) adoptSkill(ctx context.Context, sk skills.InstalledSkill) erro ResolvedReference: sk.Reference, Digest: sk.Digest, ContentDigest: contentDigest, + Provenance: provenance, + Unsigned: unsigned, }); err != nil { return fmt.Errorf("writing lock entry: %w", errors.Join(errLockWrite, err)) } diff --git a/pkg/skills/skillsvc/sync_test.go b/pkg/skills/skillsvc/sync_test.go index b5f372fc4c..c7261a1679 100644 --- a/pkg/skills/skillsvc/sync_test.go +++ b/pkg/skills/skillsvc/sync_test.go @@ -305,14 +305,26 @@ func TestSync_AdoptsUnmanagedInstall(t *testing.T) { require.NoError(t, err) assert.Equal(t, []string{"unmanaged-skill"}, result.NeverManaged) + // Adoption of an install with no stored bundle is an unsigned trust + // decision: without the explicit exception it fails... result, err = syncer.Sync(t.Context(), skills.SyncOptions{ProjectRoot: projectRoot, Adopt: true}) require.NoError(t, err) + require.Len(t, result.Failed, 1) + assert.Equal(t, skills.FailureReasonUnsignedRejected, result.Failed[0].Reason) + lf := readLockfile(t, projectRoot) + _, ok := lf.Get("unmanaged-skill") + assert.False(t, ok, "adoption without the unsigned exception must not write a lock entry") + + // ...and with it, the entry records the unsigned state. + result, err = syncer.Sync(t.Context(), skills.SyncOptions{ProjectRoot: projectRoot, Adopt: true, AllowUnsigned: true}) + require.NoError(t, err) assert.Equal(t, []string{"unmanaged-skill"}, result.NeverManaged) - lf := readLockfile(t, projectRoot) + lf = readLockfile(t, projectRoot) entry, ok := lf.Get("unmanaged-skill") require.True(t, ok, "adopt must write a lock entry for the unmanaged install") assert.NotEmpty(t, entry.ContentDigest) + assert.True(t, entry.Unsigned, "an adoption without verifiable trust must record unsigned") sk, err := svc.Info(t.Context(), skills.InfoOptions{Name: "unmanaged-skill", Scope: skills.ScopeProject, ProjectRoot: projectRoot}) require.NoError(t, err) @@ -381,6 +393,8 @@ func TestSync_CheckDetectsTamperInAnyClientDir(t *testing.T) { mv := verifiermocks.NewMockVerifier(ctrl) mv.EXPECT().VerifyGit(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). AnyTimes().Return(signedResult(), nil) + mv.EXPECT().VerifyBundleOffline(gomock.Any(), gomock.Any(), gomock.Any()). + AnyTimes().Return(nil) svc := New(store, WithPathResolver(pr), WithGitResolver(gr), WithVerifier(mv)) ref, _ := gitRef("multi-skill") diff --git a/pkg/skills/skillsvc/sync_verify_test.go b/pkg/skills/skillsvc/sync_verify_test.go new file mode 100644 index 0000000000..a7fbe2e699 --- /dev/null +++ b/pkg/skills/skillsvc/sync_verify_test.go @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/stacklok/toolhive/pkg/skills" + "github.com/stacklok/toolhive/pkg/skills/lockfile" + "github.com/stacklok/toolhive/pkg/skills/verifier" + verifiermocks "github.com/stacklok/toolhive/pkg/skills/verifier/mocks" +) + +// TestSync_StoredSignatureFailureIsDriftThenHeals proves the offline +// re-verification path: a stored bundle that no longer verifies reports as +// drift in check mode, and an apply reinstalls from the pinned reference — +// where install-time verification enforces the locked identity and heals +// the stored state. +// +//nolint:paralleltest // uses t.Setenv via newLockTestService, incompatible with t.Parallel +func TestSync_StoredSignatureFailureIsDriftThenHeals(t *testing.T) { + gr, fx := newGitResolverMock(t) + fx.register("sig-drift-skill", gitSkill("sig-drift-skill")) + + mv := verifiermocks.NewMockVerifier(gomock.NewController(t)) + mv.EXPECT().VerifyGit(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + AnyTimes().Return(signedResult(), nil) + // Every offline re-verification of the stored bundle fails. + mv.EXPECT().VerifyBundleOffline(gomock.Any(), gomock.Any(), gomock.Any()). + AnyTimes().Return(verifier.ErrSignatureInvalid) + + svc, projectRoot := newLockTestService(t, gr, WithVerifier(mv)) + ref, _ := gitRef("sig-drift-skill") + _, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: ref, Scope: skills.ScopeProject, ProjectRoot: projectRoot, Clients: []string{"claude-code"}, + }) + require.NoError(t, err) + + syncer := svc.(*service) //nolint:forcetypeassert + + result, err := syncer.Sync(t.Context(), skills.SyncOptions{ProjectRoot: projectRoot, Check: true}) + require.NoError(t, err) + assert.Equal(t, []string{"sig-drift-skill"}, result.Drifted, + "a failed offline re-verification must report as drift in check mode") + assert.Empty(t, result.AlreadyCurrent) + + result, err = syncer.Sync(t.Context(), skills.SyncOptions{ProjectRoot: projectRoot}) + require.NoError(t, err) + assert.Equal(t, []string{"sig-drift-skill"}, result.Installed, + "apply mode must reinstall from the pinned reference, re-verifying the artifact") + assert.Empty(t, result.Failed) +} + +func TestVerifyStoredSignature(t *testing.T) { + t.Parallel() + + provenance := &lockfile.Provenance{ + SignerIdentity: testSignerIdentity, + CertIssuer: testCertIssuer, + } + ociDigest := "sha256:" + strings.Repeat("a", 64) + gitDigest := strings.Repeat("b", 40) + + tests := []struct { + name string + entry lockfile.Entry + bundle []byte + offlineErr error + expectCall bool + wantErr bool + }{ + { + name: "unsigned entry has nothing to verify", + entry: lockfile.Entry{Unsigned: true, Digest: ociDigest}, + }, + { + name: "no provenance has nothing to verify", + entry: lockfile.Entry{Digest: ociDigest}, + }, + { + name: "provenance without stored bundle fails closed for OCI", + entry: lockfile.Entry{Provenance: provenance, Digest: ociDigest}, + wantErr: true, + }, + { + name: "provenance without stored bundle is fine for git", + entry: lockfile.Entry{Provenance: provenance, Digest: gitDigest}, + }, + { + name: "stored bundle delegates to offline verification", + entry: lockfile.Entry{Provenance: provenance, Digest: ociDigest}, + bundle: []byte(`{"bundle":true}`), + expectCall: true, + }, + { + name: "offline verification failure propagates", + entry: lockfile.Entry{Provenance: provenance, Digest: ociDigest}, + bundle: []byte(`{"bundle":true}`), + offlineErr: verifier.ErrSignatureInvalid, + expectCall: true, + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + mv := verifiermocks.NewMockVerifier(gomock.NewController(t)) + if tc.expectCall { + mv.EXPECT().VerifyBundleOffline(tc.bundle, tc.entry.Digest, tc.entry.Provenance). + Return(tc.offlineErr) + } + svc := &service{sigVerifier: mv} + err := svc.verifyStoredSignature(tc.entry, skills.InstalledSkill{SigstoreBundle: tc.bundle}) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +}