Skip to content
Open
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
18 changes: 18 additions & 0 deletions pkg/skills/verifier/errors.go
Original file line number Diff line number Diff line change
@@ -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")
)
102 changes: 102 additions & 0 deletions pkg/skills/verifier/mocks/mock_verifier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

124 changes: 124 additions & 0 deletions pkg/skills/verifier/oci.go
Original file line number Diff line number Diff line change
@@ -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
}
58 changes: 58 additions & 0 deletions pkg/skills/verifier/offline.go
Original file line number Diff line number Diff line change
@@ -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
}
75 changes: 75 additions & 0 deletions pkg/skills/verifier/types.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
Loading
Loading