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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions pkg/skills/signer/cosign_attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion pkg/skills/signer/signer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
6 changes: 3 additions & 3 deletions pkg/skills/signer/signer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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[:]),
Expand Down Expand Up @@ -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[:]),
Expand Down Expand Up @@ -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
Expand Down
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")
)
116 changes: 116 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.

183 changes: 183 additions & 0 deletions pkg/skills/verifier/oci.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading