Skip to content

Commit d960dac

Browse files
committed
add SignVSA function and unit tests for VSA signing
Introduce the VSA signing, which signs a Verification Summary Attestation (VSA) JSON file using a cosign-compatible private key. This writes a detached, base64-encoded signature alongside the VSA and returns the absolute path to the signature file. It handles key loading, passphrase support, payload reading, signing, and signature persistence with robust error handling. https://issues.redhat.com/browse/EC-1308
1 parent d8c2e8b commit d960dac

File tree

8 files changed

+771
-130
lines changed

8 files changed

+771
-130
lines changed

cmd/validate/image.go

Lines changed: 29 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -458,11 +458,38 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
458458
}
459459

460460
if data.vsaEnabled {
461+
generator := vsa.NewGenerator(report)
462+
writer := vsa.NewWriter()
461463
for _, comp := range components {
462-
if err := processVSA(cmd.Context(), report, comp); err != nil {
463-
log.Errorf("[VSA] Error processing VSA for image %s: %v", comp.ContainerImage, err)
464+
writtenPath, err := vsa.GenerateAndWriteVSA(cmd.Context(), generator, writer, comp)
465+
if err != nil {
466+
log.Error(err)
464467
continue
465468
}
469+
470+
signer, err := vsa.NewSigner(data.vsaSigningKey, utils.FS(cmd.Context()))
471+
if err != nil {
472+
log.Error(err)
473+
continue
474+
}
475+
476+
// Get the git URL safely, defaulting to empty string if GitSource is nil
477+
var gitURL string
478+
if comp.Source.GitSource != nil {
479+
gitURL = comp.Source.GitSource.URL
480+
}
481+
482+
attestor, err := vsa.NewAttestOptions(writtenPath, gitURL, comp.ContainerImage, signer)
483+
if err != nil {
484+
log.Error(err)
485+
continue
486+
}
487+
envelope, err := vsa.AttestVSA(cmd.Context(), attestor, comp)
488+
if err != nil {
489+
log.Error(err)
490+
continue
491+
}
492+
log.Infof("[VSA] VSA attested and envelope written to %s", envelope)
466493
}
467494
}
468495
if data.strict && !report.Success {
@@ -580,51 +607,3 @@ func containsOutput(data []string, value string) bool {
580607
}
581608
return false
582609
}
583-
584-
// PredicateGenerator defines the interface for generating VSA predicates
585-
type PredicateGenerator interface {
586-
GeneratePredicate(ctx context.Context, report applicationsnapshot.Report, comp applicationsnapshot.Component) (*vsa.Predicate, error)
587-
}
588-
589-
// VSAWriter defines the interface for writing VSA files
590-
type VSAWriter interface {
591-
WriteVSA(predicate *vsa.Predicate) (string, error)
592-
}
593-
594-
// generateAndWriteVSA generates a VSA predicate and writes it to a file
595-
func generateAndWriteVSA(
596-
ctx context.Context,
597-
report applicationsnapshot.Report,
598-
comp applicationsnapshot.Component,
599-
generator PredicateGenerator,
600-
writer VSAWriter,
601-
) (string, error) {
602-
log.Debugf("[VSA] Generating predicate for image: %s", comp.ContainerImage)
603-
pred, err := generator.GeneratePredicate(ctx, report, comp)
604-
if err != nil {
605-
return "", fmt.Errorf("failed to generate predicate for image %s: %w", comp.ContainerImage, err)
606-
}
607-
log.Debugf("[VSA] Predicate generated for image: %s", comp.ContainerImage)
608-
609-
log.Debugf("[VSA] Writing VSA for image: %s", comp.ContainerImage)
610-
writtenPath, err := writer.WriteVSA(pred)
611-
if err != nil {
612-
return "", fmt.Errorf("failed to write VSA for image %s: %w", comp.ContainerImage, err)
613-
}
614-
log.Debugf("[VSA] VSA written to %s", writtenPath)
615-
616-
return writtenPath, nil
617-
}
618-
619-
// processVSA handles the complete VSA generation, signing and upload process for a component
620-
func processVSA(ctx context.Context, report applicationsnapshot.Report, comp applicationsnapshot.Component) error {
621-
generator := vsa.NewGenerator()
622-
writer := vsa.NewWriter()
623-
624-
vsaPath, err := generateAndWriteVSA(ctx, report, comp, generator, writer)
625-
log.Infof("[VSA] VSA written to %s", vsaPath)
626-
if err != nil {
627-
return err
628-
}
629-
return nil
630-
}

internal/validate/vsa/attest.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright The Conforma Contributors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
// SPDX-License-Identifier: Apache-2.0
16+
17+
// attest.go
18+
package vsa
19+
20+
import (
21+
"bytes"
22+
"context"
23+
"encoding/json"
24+
"fmt"
25+
"os"
26+
"path/filepath"
27+
"strings"
28+
"time"
29+
30+
"github.com/sigstore/cosign/v2/pkg/cosign"
31+
att "github.com/sigstore/cosign/v2/pkg/cosign/attestation"
32+
"github.com/sigstore/cosign/v2/pkg/types"
33+
"github.com/sigstore/sigstore/pkg/signature"
34+
"github.com/sigstore/sigstore/pkg/signature/dsse"
35+
sigopts "github.com/sigstore/sigstore/pkg/signature/options"
36+
"github.com/spf13/afero"
37+
)
38+
39+
var loadPrivateKey = cosign.LoadPrivateKey
40+
41+
type AttestOptions struct {
42+
PredicatePath string // path to the raw VSA (predicate) JSON
43+
PredicateType string // e.g. "https://enterprisecontract.dev/attestations/vsa/v1" // TODO: make this configurable
44+
ImageDigest string // sha256:abcd… (as returned by `skopeo inspect --format {{.Digest}}`)
45+
Repo string // "quay.io/acme/widget" (hostname/namespace/repo)
46+
Signer *Signer
47+
}
48+
49+
type Signer struct {
50+
KeyPath string
51+
FS afero.Fs
52+
WrapSigner signature.Signer
53+
}
54+
55+
func NewSigner(keyPath string, fs afero.Fs) (*Signer, error) {
56+
keyBytes, err := afero.ReadFile(fs, keyPath)
57+
if err != nil {
58+
return nil, fmt.Errorf("read key %q: %w", keyPath, err)
59+
}
60+
61+
sv, err := loadPrivateKey(keyBytes, []byte(os.Getenv("COSIGN_PASSWORD")))
62+
if err != nil {
63+
return nil, fmt.Errorf("load private key: %w", err)
64+
}
65+
66+
return &Signer{
67+
KeyPath: keyPath,
68+
FS: fs,
69+
WrapSigner: dsse.WrapSigner(sv, types.IntotoPayloadType),
70+
}, nil
71+
}
72+
73+
// Add a constructor with sensible defaults
74+
func NewAttestOptions(predicatePath, repo, imageDigest string, signer *Signer) (*AttestOptions, error) {
75+
return &AttestOptions{
76+
PredicatePath: predicatePath,
77+
PredicateType: "https://enterprisecontract.dev/attestations/vsa/v1",
78+
ImageDigest: imageDigest,
79+
Repo: repo,
80+
Signer: signer,
81+
}, nil
82+
}
83+
84+
// AttestPredicate builds an in‑toto Statement around the predicate and
85+
// returns the fully‑signed **DSSE envelope** (identical to cosign's
86+
// --no-upload output). Nothing is pushed to a registry or the TLog.
87+
func (a AttestOptions) AttestPredicate(ctx context.Context) ([]byte, error) {
88+
//-------------------------------------------------------------------- 2. read predicate
89+
predFile, err := a.Signer.FS.Open(a.PredicatePath)
90+
if err != nil {
91+
return nil, fmt.Errorf("open predicate: %w", err)
92+
}
93+
defer predFile.Close()
94+
95+
//-------------------------------------------------------------------- 3. make the in‑toto statement
96+
stmt, err := att.GenerateStatement(att.GenerateOpts{
97+
Predicate: predFile,
98+
Type: a.PredicateType,
99+
Digest: strings.TrimPrefix(a.ImageDigest, "sha256:"),
100+
Repo: a.Repo,
101+
Time: time.Now, // keeps tests deterministic
102+
})
103+
if err != nil {
104+
return nil, fmt.Errorf("wrap predicate: %w", err)
105+
}
106+
payload, _ := json.Marshal(stmt) // canonicalised by dsse later
107+
108+
//-------------------------------------------------------------------- 4. sign -> DSSE envelope
109+
env, err := a.Signer.WrapSigner.SignMessage(bytes.NewReader(payload), sigopts.WithContext(ctx))
110+
if err != nil {
111+
return nil, fmt.Errorf("sign statement: %w", err)
112+
}
113+
return env, nil // byte‑slice containing the JSON DSSE envelope
114+
}
115+
116+
// WriteEnvelope is an optional convenience that mirrors cosign's
117+
// --output‑signature flag; it emits <predicate>.intoto.jsonl next to the file.
118+
func (a AttestOptions) WriteEnvelope(data []byte) (string, error) {
119+
out := a.PredicatePath + ".intoto.jsonl"
120+
if err := afero.WriteFile(a.Signer.FS, out, data, 0o644); err != nil {
121+
return "", err
122+
}
123+
abs, err := filepath.Abs(out)
124+
if err != nil {
125+
return "", err
126+
}
127+
return abs, nil
128+
}

0 commit comments

Comments
 (0)