Skip to content

Commit 379cedd

Browse files
committed
Verifying VSA signature
The validate vsa subcommand can verify a signature from a DSSE from Rekor and the filesystem. https://issues.redhat.com/browse/EC-1521
1 parent c0b3710 commit 379cedd

File tree

15 files changed

+3494
-732
lines changed

15 files changed

+3494
-732
lines changed

.cursor/rules/vsa_functionality.mdc

Lines changed: 542 additions & 0 deletions
Large diffs are not rendered by default.

cmd/validate/vsa.go

Lines changed: 78 additions & 556 deletions
Large diffs are not rendered by default.

cmd/validate/vsa_test.go

Lines changed: 578 additions & 123 deletions
Large diffs are not rendered by default.

docs/modules/ROOT/pages/ec_validate_vsa.adoc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ Validate VSA (Verification Summary Attestation)
66

77
Validate VSA by comparing the embedded policy against a supplied policy configuration.
88

9+
By default, VSA signature verification is enabled and requires a public key.
10+
Use --ignore-signature-verification to disable signature verification.
11+
912
Supports validation of:
1013
- Single VSA by identifier (image digest, file path)
1114
- Multiple VSAs from application snapshot
@@ -24,11 +27,13 @@ ec validate vsa <vsa-identifier> [flags]
2427
--color:: Enable color when using text output even when the current terminal does not support it (Default: false)
2528
--effective-time:: Effective time for comparison (Default: now)
2629
-h, --help:: help for vsa (Default: false)
30+
--ignore-signature-verification:: Ignore VSA signature verification (signature verification is enabled by default) (Default: false)
2731
--images:: Application snapshot file
2832
--no-color:: Disable color when using text output even when the current terminal supports it (Default: false)
2933
--output:: Output formats (Default: [])
3034
-o, --output-file:: Output file
3135
-p, --policy:: Policy configuration
36+
--public-key:: Path to public key for signature verification (required by default)
3237
--strict:: Exit with non-zero code if validation fails (Default: true)
3338
-v, --vsa:: VSA identifier (image digest, file path)
3439
--vsa-expiration:: VSA expiration threshold (e.g., 24h, 7d, 1w, 1m) (Default: 168h)

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ replace github.com/google/go-containerregistry => github.com/conforma/go-contain
6565
require (
6666
github.com/go-openapi/runtime v0.28.0
6767
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
68-
gopkg.in/yaml.v2 v2.4.0
68+
gopkg.in/yaml.v3 v3.0.1
6969
k8s.io/api v0.32.3
7070
)
7171

@@ -386,7 +386,7 @@ require (
386386
gopkg.in/inf.v0 v0.9.1 // indirect
387387
gopkg.in/ini.v1 v1.67.0 // indirect
388388
gopkg.in/warnings.v0 v0.1.2 // indirect
389-
gopkg.in/yaml.v3 v3.0.1 // indirect
389+
gopkg.in/yaml.v2 v2.4.0 // indirect
390390
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
391391
knative.dev/pkg v0.0.0-20240815051656-89743d9bbf7c // indirect
392392
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect

internal/validate/vsa/policy.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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+
package vsa
18+
19+
import (
20+
"encoding/json"
21+
"fmt"
22+
23+
ecapi "github.com/conforma/crds/api/v1alpha1"
24+
"gopkg.in/yaml.v3"
25+
)
26+
27+
// ParsePolicySpec parses a policy configuration string to extract the EnterpriseContractPolicySpec
28+
func ParsePolicySpec(policyConfig string) (ecapi.EnterpriseContractPolicySpec, error) {
29+
content := []byte(policyConfig)
30+
31+
// Convert YAML to JSON first to handle ruleData field mapping correctly
32+
var yamlData map[string]interface{}
33+
if err := yaml.Unmarshal(content, &yamlData); err != nil {
34+
return ecapi.EnterpriseContractPolicySpec{}, fmt.Errorf("failed to parse YAML: %w", err)
35+
}
36+
37+
// Convert interface{} types to proper types for JSON marshaling
38+
jsonData := ConvertYAMLToJSON(yamlData)
39+
40+
// Convert to JSON bytes
41+
jsonBytes, err := json.Marshal(jsonData)
42+
if err != nil {
43+
return ecapi.EnterpriseContractPolicySpec{}, fmt.Errorf("failed to convert YAML to JSON: %w", err)
44+
}
45+
46+
// Now parse as JSON which should handle ruleData field mapping correctly
47+
var ecp ecapi.EnterpriseContractPolicy
48+
if err := json.Unmarshal(jsonBytes, &ecp); err == nil && ecp.APIVersion != "" {
49+
// Check if this is actually a valid CRD (has required fields)
50+
if ecp.APIVersion == "" || ecp.Kind == "" {
51+
// This is not a valid CRD, try parsing as EnterpriseContractPolicySpec
52+
var spec ecapi.EnterpriseContractPolicySpec
53+
if err := json.Unmarshal(jsonBytes, &spec); err != nil {
54+
return ecapi.EnterpriseContractPolicySpec{}, fmt.Errorf("unable to parse EnterpriseContractPolicySpec: %w", err)
55+
}
56+
return spec, nil
57+
}
58+
return ecp.Spec, nil
59+
}
60+
61+
// If parsing as EnterpriseContractPolicy fails, try as EnterpriseContractPolicySpec
62+
var spec ecapi.EnterpriseContractPolicySpec
63+
if err := json.Unmarshal(jsonBytes, &spec); err != nil {
64+
return ecapi.EnterpriseContractPolicySpec{}, fmt.Errorf("unable to parse EnterpriseContractPolicySpec: %w", err)
65+
}
66+
return spec, nil
67+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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+
package vsa
18+
19+
import (
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
24+
)
25+
26+
// TestParsePolicySpec tests the ParsePolicySpec function
27+
func TestParsePolicySpec(t *testing.T) {
28+
tests := []struct {
29+
name string
30+
policyConfig string
31+
expectError bool
32+
checkResult func(t *testing.T, result interface{})
33+
}{
34+
{
35+
name: "valid YAML policy spec",
36+
policyConfig: `
37+
sources:
38+
- name: policy
39+
`,
40+
expectError: false,
41+
checkResult: func(t *testing.T, result interface{}) {
42+
// Just verify it parsed successfully
43+
assert.NotNil(t, result)
44+
},
45+
},
46+
{
47+
name: "valid JSON policy spec",
48+
policyConfig: `{
49+
"sources": [
50+
{
51+
"name": "policy"
52+
}
53+
]
54+
}`,
55+
expectError: false,
56+
checkResult: func(t *testing.T, result interface{}) {
57+
assert.NotNil(t, result)
58+
},
59+
},
60+
{
61+
name: "valid YAML with CRD wrapper",
62+
policyConfig: `
63+
apiVersion: appstudio.redhat.com/v1alpha1
64+
kind: EnterpriseContractPolicy
65+
metadata:
66+
name: test-policy
67+
spec:
68+
sources:
69+
- name: policy
70+
`,
71+
expectError: false,
72+
checkResult: func(t *testing.T, result interface{}) {
73+
assert.NotNil(t, result)
74+
},
75+
},
76+
{
77+
name: "invalid YAML",
78+
policyConfig: `
79+
invalid: yaml: content: [unclosed
80+
`,
81+
expectError: true,
82+
},
83+
{
84+
name: "invalid JSON",
85+
policyConfig: `{
86+
"sources": [
87+
{
88+
"name": "policy"
89+
}
90+
]
91+
// missing closing brace
92+
`,
93+
expectError: true,
94+
},
95+
}
96+
97+
for _, tt := range tests {
98+
t.Run(tt.name, func(t *testing.T) {
99+
result, err := ParsePolicySpec(tt.policyConfig)
100+
if tt.expectError {
101+
require.Error(t, err)
102+
} else {
103+
require.NoError(t, err)
104+
if tt.checkResult != nil {
105+
tt.checkResult(t, result)
106+
}
107+
}
108+
})
109+
}
110+
}

internal/validate/vsa/rekor_retriever.go

Lines changed: 86 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -366,17 +366,54 @@ func (r *RekorVSARetriever) buildDSSEEnvelopeFromIntotoV002(entry models.LogEntr
366366
return nil, fmt.Errorf("envelope does not contain payloadType")
367367
}
368368

369-
// Prefer payload from content.envelope.payload when present; fallback to Attestation.Data
369+
// Prefer Attestation.Data (needs base64-encoding); fallback to content.envelope.payload
370370
var payloadB64 string
371371

372-
// First, try to get payload from content.envelope.payload
373-
if payload, ok := envelopeData["payload"].(string); ok && payload != "" {
374-
payloadB64 = payload
375-
} else if entry.Attestation != nil && entry.Attestation.Data != nil {
376-
// Fallback to Attestation.Data (already base64-encoded)
377-
payloadB64 = string(entry.Attestation.Data)
372+
// First, try to get payload from Attestation.Data (needs to be base64-encoded)
373+
if entry.Attestation != nil && entry.Attestation.Data != nil {
374+
log.Debugf("Using payload from Attestation.Data (length: %d)", len(entry.Attestation.Data))
375+
// Attestation.Data contains raw JSON, need to base64-encode it
376+
payloadB64 = base64.StdEncoding.EncodeToString(entry.Attestation.Data)
377+
log.Debugf("Base64-encoded payload length: %d", len(payloadB64))
378+
} else if payload, ok := envelopeData["payload"].(string); ok && payload != "" {
379+
// Fallback to content.envelope.payload
380+
log.Debugf("Using payload from envelope.payload (length: %d)", len(payload))
381+
// Check if the payload is already base64-encoded
382+
if _, err := base64.StdEncoding.DecodeString(payload); err == nil {
383+
// Already base64-encoded
384+
payloadB64 = payload
385+
} else {
386+
// Not base64-encoded, encode it
387+
payloadB64 = base64.StdEncoding.EncodeToString([]byte(payload))
388+
}
378389
} else {
379-
return nil, fmt.Errorf("no payload found in envelope or attestation data")
390+
return nil, fmt.Errorf("no payload found in attestation data or envelope")
391+
}
392+
393+
// Debug: Try to decode the payload to see if it's valid base64
394+
if _, err := base64.StdEncoding.DecodeString(payloadB64); err != nil {
395+
log.Debugf("Payload is not valid base64: %v", err)
396+
previewLen := 100
397+
if len(payloadB64) < previewLen {
398+
previewLen = len(payloadB64)
399+
}
400+
log.Debugf("Payload preview (first %d chars): %s", previewLen, payloadB64[:previewLen])
401+
402+
// Try URL encoding as well
403+
if _, err := base64.URLEncoding.DecodeString(payloadB64); err != nil {
404+
log.Debugf("Payload is also not valid URL base64: %v", err)
405+
} else {
406+
log.Debugf("Payload is valid URL base64")
407+
}
408+
} else {
409+
log.Debugf("Payload is valid base64")
410+
// Decode and preview the decoded content
411+
decoded, _ := base64.StdEncoding.DecodeString(payloadB64)
412+
previewLen := 100
413+
if len(decoded) < previewLen {
414+
previewLen = len(decoded)
415+
}
416+
log.Debugf("Decoded payload preview (first %d chars): %s", previewLen, string(decoded[:previewLen]))
380417
}
381418

382419
// Extract and convert signatures
@@ -400,14 +437,48 @@ func (r *RekorVSARetriever) buildDSSEEnvelopeFromIntotoV002(entry models.LogEntr
400437

401438
// Extract sig field (required) - only support standard field
402439
if sigHex, ok := sigMap["sig"].(string); ok {
403-
sig.Sig = sigHex
440+
// Handle both single and double encoding to be robust
441+
// Try single decode first
442+
firstDecode, err := base64.StdEncoding.DecodeString(sigHex)
443+
if err != nil {
444+
return nil, fmt.Errorf("failed to decode signature %d: %w", i, err)
445+
}
446+
447+
// TODO: This is a hack to get the signature from the in-toto entry
448+
// Check if the result is still base64-encoded (indicating double encoding)
449+
// This is usually the case when the signature is stored in the in-toto entry
450+
// For some reason it's double encoded and it doesn't work to not encode when storing.
451+
// So, we need to decode it twice to get the actual ASN.1 DER signature
452+
// Then re-encode it once for the DSSE library
453+
decodedString := string(firstDecode)
454+
if isBase64String(decodedString) {
455+
// Double-encoded: decode again
456+
paddingNeeded := (4 - len(decodedString)%4) % 4
457+
paddedString := decodedString
458+
for j := 0; j < paddingNeeded; j++ {
459+
paddedString += "="
460+
}
461+
462+
actualSignature, err := base64.StdEncoding.DecodeString(paddedString)
463+
if err != nil {
464+
return nil, fmt.Errorf("failed to double-decode signature %d: %w", i, err)
465+
}
466+
sig.Sig = base64.StdEncoding.EncodeToString(actualSignature)
467+
} else {
468+
// Single-encoded: use as-is
469+
sig.Sig = base64.StdEncoding.EncodeToString(firstDecode)
470+
}
404471
} else {
405472
return nil, fmt.Errorf("signature %d missing required 'sig' field", i)
406473
}
407474

408475
// Extract keyid field (optional)
409476
if keyid, ok := sigMap["keyid"].(string); ok {
410477
sig.KeyID = keyid
478+
} else {
479+
// If no KeyID is provided, set a default one to help with verification
480+
// This might help the DSSE library match the signature to the public key
481+
sig.KeyID = "default"
411482
}
412483

413484
signatures = append(signatures, sig)
@@ -676,3 +747,9 @@ func (rc *rekorClient) GetLogEntryByUUID(ctx context.Context, uuid string) (*mod
676747

677748
return nil, fmt.Errorf("log entry not found for UUID: %s", uuid)
678749
}
750+
751+
// isBase64String checks if a string is valid base64
752+
func isBase64String(s string) bool {
753+
_, err := base64.StdEncoding.DecodeString(s)
754+
return err == nil
755+
}

internal/validate/vsa/rekor_retriever_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,12 +258,14 @@ func TestRekorVSARetriever_RetrieveVSA(t *testing.T) {
258258
vsaStatement := `{"_type":"https://in-toto.io/Statement/v0.1","subject":[{"name":"test-image","digest":{"sha256":"abc123def456"}}],"predicateType":"https://conforma.dev/verification_summary/v1","predicate":{"test":"data"}}`
259259

260260
// Create in-toto 0.0.2 entry body
261+
// The signature needs to be double-base64 encoded for the implementation
262+
doubleEncodedSig := base64.StdEncoding.EncodeToString([]byte(base64.StdEncoding.EncodeToString([]byte("test"))))
261263
intotoV002Body := `{
262264
"spec": {
263265
"content": {
264266
"envelope": {
265267
"payloadType": "application/vnd.in-toto+json",
266-
"signatures": [{"sig": "dGVzdA==", "keyid": "test-key-id"}]
268+
"signatures": [{"sig": "` + doubleEncodedSig + `", "keyid": "test-key-id"}]
267269
}
268270
}
269271
}
@@ -276,7 +278,7 @@ func TestRekorVSARetriever_RetrieveVSA(t *testing.T) {
276278
LogID: &[]string{"intoto-v002-uuid"}[0],
277279
Body: base64.StdEncoding.EncodeToString([]byte(intotoV002Body)),
278280
Attestation: &models.LogEntryAnonAttestation{
279-
Data: strfmt.Base64(base64.StdEncoding.EncodeToString([]byte(vsaStatement))),
281+
Data: strfmt.Base64([]byte(vsaStatement)), // Raw JSON, not base64 encoded
280282
},
281283
},
282284
},

0 commit comments

Comments
 (0)