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
9 changes: 6 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,13 @@ require go.starlark.net v0.0.0-20260630144053-529d8e869a14
require (
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 // indirect
github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect
github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.3 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/sassoftware/relic v7.2.1+incompatible // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/theupdateframework/go-tuf v0.7.0 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
)

Expand Down Expand Up @@ -246,11 +249,11 @@ require (
github.com/sergi/go-diff v1.4.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shibumi/go-pathspec v1.3.0 // indirect
github.com/sigstore/protobuf-specs v0.5.1 // indirect
github.com/sigstore/protobuf-specs v0.5.1
github.com/sigstore/rekor v1.5.3 // indirect
github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect
github.com/sigstore/sigstore v1.10.8 // indirect
github.com/sigstore/sigstore-go v1.2.2 // indirect
github.com/sigstore/sigstore v1.10.8
github.com/sigstore/sigstore-go v1.2.2
github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
Expand Down
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAg
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-openapi/validate v0.26.0 h1:dxWzQ3F+vb1SajqUxHjwb5T4mTpSHmdrtv5Bi7+ZNhw=
github.com/go-openapi/validate v0.26.0/go.mod h1:b4o00uq7fJeJA+wWhVFCJpKTctzeFwzZImGGmHsl2JA=
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
Expand Down Expand Up @@ -872,6 +874,16 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
Expand Down
134 changes: 134 additions & 0 deletions pkg/skills/signer/cosign_attach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package signer

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"

"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/static"
"github.com/google/go-containerregistry/pkg/v1/types"
"github.com/opencontainers/go-digest"
)

const (
mediaTypeCosignSimpleSigningV1JSON = "application/vnd.dev.cosign.simplesigning.v1+json"
annotationCosignSignature = "dev.cosignproject.cosign/signature"
)

// cosignSimpleSigning is the payload cosign signs: it embeds the artifact's
// manifest digest, binding the signature to the exact artifact content.
type cosignSimpleSigning struct {
Critical cosignCritical `json:"critical"`
}

type cosignCritical struct {
Identity cosignIdentity `json:"identity"`
Image cosignImage `json:"image"`
Type string `json:"type"`
}

type cosignIdentity struct {
DockerReference string `json:"docker-reference"`
}

type cosignImage struct {
DockerManifestDigest string `json:"docker-manifest-digest"`
}

// 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) {
ref, err := name.ParseReference(imageRef)
if err != nil {
return nil, fmt.Errorf("parsing image reference: %w", err)
}
d, err := parseManifestDigest(digestStr)
if err != nil {
return nil, err
}
payload := cosignSimpleSigning{
Critical: cosignCritical{
Identity: cosignIdentity{DockerReference: ref.Context().Name()},
Image: cosignImage{DockerManifestDigest: d.String()},
Type: "cosign container image signature",
},
}
return json.Marshal(payload)
}

// parseManifestDigest validates and normalizes an artifact manifest digest
// string, defaulting a bare hex value to sha256.
func parseManifestDigest(digestStr string) (digest.Digest, error) {
digestStr = strings.TrimSpace(digestStr)
if digestStr == "" {
return "", fmt.Errorf("digest is required for signing")
}
if !strings.Contains(digestStr, ":") {
digestStr = "sha256:" + digestStr
}
d, err := digest.Parse(digestStr)
if err != nil {
return "", fmt.Errorf("parsing digest: %w", err)
}
return d, nil
}

// attachCosignSignature writes the cosign signature manifest for the
// artifact: an OCI image at the "sha256-<hex>.sig" tag whose single layer is
// the simple-signing payload, carrying the signature in the layer's
// annotations. This is the classic cosign layout, chosen deliberately for
// interop — "cosign verify --key" and any Sigstore-aware registry tooling
// can discover and verify it.
func attachCosignSignature(
ctx context.Context,
keychain authn.Keychain,
imageRef, digestStr string,
payload, signatureBytes []byte,
) error {
ref, err := name.ParseReference(imageRef)
if err != nil {
return fmt.Errorf("parsing image reference: %w", err)
}
d, err := parseManifestDigest(digestStr)
if err != nil {
return err
}

layer := static.NewLayer(payload, mediaTypeCosignSimpleSigningV1JSON)
img, err := mutate.Append(empty.Image, mutate.Addendum{
Layer: layer,
Annotations: map[string]string{
annotationCosignSignature: base64.StdEncoding.EncodeToString(signatureBytes),
},
MediaType: mediaTypeCosignSimpleSigningV1JSON,
})
if err != nil {
return fmt.Errorf("building signature manifest: %w", err)
}
img = mutate.MediaType(img, types.OCIManifestSchema1)

h, err := v1.NewHash(d.String())
if err != nil {
return fmt.Errorf("parsing digest hash: %w", err)
}
sigTag := ref.Context().Tag(fmt.Sprint(h.Algorithm, "-", h.Hex, ".sig"))
remoteOpts := []remote.Option{remote.WithAuthFromKeychain(keychain), remote.WithContext(ctx)}
if err := remote.Write(sigTag, img, remoteOpts...); err != nil {
return fmt.Errorf("pushing signature manifest: %w", err)
}
return nil
}
127 changes: 127 additions & 0 deletions pkg/skills/signer/key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package signer

import (
"context"
"crypto"
"crypto/rand"
"fmt"
"os"
"path/filepath"

protocommon "github.com/sigstore/protobuf-specs/gen/pb-go/common/v1"
"github.com/sigstore/sigstore-go/pkg/sign"
"github.com/sigstore/sigstore/pkg/cryptoutils"
)

// resolveKeyPath canonicalizes and vets a user-supplied signing key path:
// absolute, cleaned, symlinks resolved, and a regular file. The value comes
// straight from a --key flag, so this is the recognized sanitization
// barrier before the path is opened — no string-inspection shortcuts.
func resolveKeyPath(path string) (string, error) {
if path == "" {
return "", ErrKeyRequired
}
abs, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("resolving signing key path: %w", err)
}
resolved, err := filepath.EvalSymlinks(filepath.Clean(abs))
if err != nil {
return "", fmt.Errorf("resolving signing key path: %w", err)
}
info, err := os.Stat(resolved)
if err != nil {
return "", fmt.Errorf("reading signing key: %w", err)
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("signing key %q is not a regular file", resolved)
}
return resolved, nil
}

// loadKeypair reads a cosign PEM private key from path. Encrypted keys are
// decrypted with the COSIGN_PASSWORD environment variable, matching the
// cosign CLI's behavior.
func loadKeypair(path string) (sign.Keypair, error) {
resolved, err := resolveKeyPath(path)
if err != nil {
return nil, err
}
pemBytes, err := os.ReadFile(resolved) //nolint:gosec // resolved by resolveKeyPath from an explicit --key flag
if err != nil {
return nil, fmt.Errorf("reading signing key: %w", err)
}
priv, err := cryptoutils.UnmarshalPEMToPrivateKey(pemBytes, cosignPassFunc())
if err != nil {
return nil, fmt.Errorf("decoding signing key: %w", err)
}
signerKey, ok := priv.(crypto.Signer)
if !ok {
return nil, fmt.Errorf("signing key type %T cannot sign", priv)
}
return &fileKeypair{priv: signerKey}, nil
}

func cosignPassFunc() cryptoutils.PassFunc {
if pw := os.Getenv("COSIGN_PASSWORD"); pw != "" {
return cryptoutils.StaticPasswordFunc([]byte(pw))
}
return func(_ bool) ([]byte, error) { return nil, nil }
}

// fileKeypair adapts a file-loaded private key to sigstore-go's signing
// Keypair interface (ECDSA P-256 / SHA-256, the cosign default).
type fileKeypair struct {
priv crypto.Signer
}

func (*fileKeypair) GetHashAlgorithm() protocommon.HashAlgorithm {
return protocommon.HashAlgorithm_SHA2_256
}

func (*fileKeypair) GetSigningAlgorithm() protocommon.PublicKeyDetails {
return protocommon.PublicKeyDetails_PKIX_ECDSA_P256_SHA_256
}

func (k *fileKeypair) GetHint() []byte {
pubKeyBytes, err := cryptoutils.MarshalPublicKeyToPEM(k.priv.Public())
if err != nil {
return nil
}
return pubKeyBytes
}

func (*fileKeypair) GetKeyAlgorithm() string {
return "ecdsa"
}

func (k *fileKeypair) GetPublicKey() crypto.PublicKey {
return k.priv.Public()
}

func (k *fileKeypair) GetPublicKeyPem() (string, error) {
pemBytes, err := cryptoutils.MarshalPublicKeyToPEM(k.priv.Public())
if err != nil {
return "", err
}
return string(pemBytes), nil
}

// SignData hashes data with SHA-256 and signs the digest, returning
// (signature, digest) — the order sigstore-go's Keypair contract requires
// (see sign.EphemeralKeypair for the reference implementation).
func (k *fileKeypair) SignData(_ context.Context, data []byte) ([]byte, []byte, error) {
hash := crypto.SHA256.New()
if _, err := hash.Write(data); err != nil {
return nil, nil, err
}
digest := hash.Sum(nil)
sig, err := k.priv.Sign(rand.Reader, digest, crypto.SHA256)
if err != nil {
return nil, nil, err
}
return sig, digest, nil
}
Loading
Loading