Skip to content

Commit f62787e

Browse files
committed
Add VSA signing and refactor attestation flow
Introduced support for signing Verification Summary Attestations (VSAs) using a new Signer and Attestor abstraction. The VSA predicate is generated and written to disk, then signed to produce a DSSE envelope. Key changes: - Replaced processVSA and related helpers with explicit use of vsa.NewGenerator, vsa.NewWriter, and vsa.NewSigner - Introduced vsa.NewAttestor to encapsulate VSA signing logic - Signed DSSE envelope is written per component - Output path of the envelope is logged for downstream use These changes lay the foundation for secure VSA publishing by ensuring attestations are signed at generation time. Co-authored-by: Claude Sonnet 4 https://issues.redhat.com/browse/EC-1308
1 parent d8c2e8b commit f62787e

File tree

8 files changed

+775
-134
lines changed

8 files changed

+775
-134
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.NewAttestor(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 Attestor 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+
signerVerifier, 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(signerVerifier, types.IntotoPayloadType),
70+
}, nil
71+
}
72+
73+
// Add a constructor with sensible defaults
74+
func NewAttestor(predicatePath, repo, imageDigest string, signer *Signer) (*Attestor, error) {
75+
return &Attestor{
76+
PredicatePath: predicatePath,
77+
PredicateType: "https://conforma.dev/verification_summary/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 Attestor) 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 Attestor) 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)