Skip to content

Commit 4c578fd

Browse files
committed
cosign: unit-test trusted-root auto-detection
Add table-driven coverage for the capability detection and CheckOpts mutation: keyless and keyed auto-detection paths, Rekor URL extraction with sorting and deduplication, and the online Rekor retry loop that walks each base URL while skipping bundle-format and tlog-ignored verification. Exercise empty, Rekor-only, Fulcio-only, and combined trusted roots. Assisted-by: GitHub Copilot CLI/gpt-5.5 Assisted-by: Kiro/opus-4.8 Signed-off-by: leigh capili <leigh@null.net>
1 parent f585b2b commit 4c578fd

2 files changed

Lines changed: 472 additions & 0 deletions

File tree

Lines changed: 388 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,388 @@
1+
/*
2+
Copyright 2026 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package cosign
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"strings"
23+
"testing"
24+
25+
. "github.com/onsi/gomega"
26+
"github.com/sigstore/cosign/v3/pkg/cosign"
27+
"github.com/sigstore/sigstore-go/pkg/root"
28+
)
29+
30+
// trustedRootHeader is the header for a trusted root JSON document.
31+
const trustedRootHeader = `"mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1"`
32+
33+
// rekorEntryJSON is a minimal but complete transparency log entry. The public
34+
// key is the same test ECDSA P-256 key used elsewhere in this package.
35+
const rekorEntryJSON = `{
36+
"baseUrl": "https://rekor.example.com",
37+
"hashAlgorithm": "SHA2_256",
38+
"publicKey": {
39+
"rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==",
40+
"keyDetails": "PKIX_ECDSA_P256_SHA_256",
41+
"validFor": {"start": "2021-01-12T11:53:27.000Z"}
42+
},
43+
"logId": {"keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="}
44+
}`
45+
46+
// fulcioEntryJSON is a minimal Fulcio CA entry. The certificate is the
47+
// sigstore.dev test CA used in the public TUF repository.
48+
const fulcioEntryJSON = `{
49+
"subject": {"organization": "test", "commonName": "test"},
50+
"uri": "https://fulcio.example.com",
51+
"certChain": {"certificates": [{"rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ=="}]},
52+
"validFor": {"start": "2021-03-07T03:20:29.000Z", "end": "2099-12-31T23:59:59.999Z"}
53+
}`
54+
55+
// ctlogEntryJSON is a minimal CT log entry sharing the same test public key.
56+
const ctlogEntryJSON = `{
57+
"baseUrl": "https://ctfe.example.com",
58+
"hashAlgorithm": "SHA2_256",
59+
"publicKey": {
60+
"rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==",
61+
"keyDetails": "PKIX_ECDSA_P256_SHA_256",
62+
"validFor": {"start": "2021-01-12T11:53:27.000Z"}
63+
},
64+
"logId": {"keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="}
65+
}`
66+
67+
// tsaEntryJSON is a minimal timestamping authority entry. The certificate is
68+
// reused for both the leaf and the chain root since the trusted root format
69+
// only requires a non-empty cert chain to populate TimestampingAuthorities().
70+
const tsaEntryJSON = `{
71+
"subject": {"organization": "test", "commonName": "test-tsa"},
72+
"uri": "https://tsa.example.com",
73+
"certChain": {"certificates": [{"rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ=="}]},
74+
"validFor": {"start": "2021-03-07T03:20:29.000Z"}
75+
}`
76+
77+
// makeTrustedRoot composes a trusted root JSON document with the requested
78+
// component sets.
79+
func makeTrustedRoot(t *testing.T, withFulcio, withRekor, withCTLog, withTSA bool) *root.TrustedRoot {
80+
t.Helper()
81+
parts := []string{trustedRootHeader}
82+
if withFulcio {
83+
parts = append(parts, fmt.Sprintf(`"certificateAuthorities": [%s]`, fulcioEntryJSON))
84+
}
85+
if withRekor {
86+
parts = append(parts, fmt.Sprintf(`"tlogs": [%s]`, rekorEntryJSON))
87+
}
88+
if withCTLog {
89+
parts = append(parts, fmt.Sprintf(`"ctlogs": [%s]`, ctlogEntryJSON))
90+
}
91+
if withTSA {
92+
parts = append(parts, fmt.Sprintf(`"timestampAuthorities": [%s]`, tsaEntryJSON))
93+
}
94+
jsonStr := "{" + strings.Join(parts, ",") + "}"
95+
tr, err := root.NewTrustedRootFromJSON([]byte(jsonStr))
96+
if err != nil {
97+
t.Fatalf("failed to parse composed trusted root: %v\nJSON: %s", err, jsonStr)
98+
}
99+
return tr
100+
}
101+
102+
func TestDetectTrustedRootCapabilities(t *testing.T) {
103+
tests := []struct {
104+
name string
105+
fulcio bool
106+
rekor bool
107+
ctlog bool
108+
tsa bool
109+
wantCap trustedRootCapabilities
110+
}{
111+
{
112+
name: "all components",
113+
fulcio: true, rekor: true, ctlog: true, tsa: true,
114+
wantCap: trustedRootCapabilities{HasFulcio: true, HasRekor: true, HasCTLog: true, HasTSA: true},
115+
},
116+
{
117+
name: "rekor only",
118+
rekor: true,
119+
wantCap: trustedRootCapabilities{HasRekor: true},
120+
},
121+
{
122+
name: "fulcio only",
123+
fulcio: true,
124+
wantCap: trustedRootCapabilities{HasFulcio: true},
125+
},
126+
{
127+
name: "tsa only",
128+
tsa: true,
129+
wantCap: trustedRootCapabilities{HasTSA: true},
130+
},
131+
{
132+
name: "fulcio and rekor (typical keyless)",
133+
fulcio: true, rekor: true,
134+
wantCap: trustedRootCapabilities{HasFulcio: true, HasRekor: true},
135+
},
136+
{
137+
name: "fulcio and ctlog without rekor",
138+
fulcio: true, ctlog: true,
139+
wantCap: trustedRootCapabilities{HasFulcio: true, HasCTLog: true},
140+
},
141+
{
142+
name: "rekor and tsa",
143+
rekor: true, tsa: true,
144+
wantCap: trustedRootCapabilities{HasRekor: true, HasTSA: true},
145+
},
146+
}
147+
148+
for _, tt := range tests {
149+
t.Run(tt.name, func(t *testing.T) {
150+
g := NewWithT(t)
151+
tr := makeTrustedRoot(t, tt.fulcio, tt.rekor, tt.ctlog, tt.tsa)
152+
got := detectTrustedRootCapabilities(tr)
153+
g.Expect(got).To(Equal(tt.wantCap))
154+
})
155+
}
156+
}
157+
158+
func TestApplyTrustedRootAutoDetection_Keyless(t *testing.T) {
159+
tests := []struct {
160+
name string
161+
fulcio bool
162+
rekor bool
163+
ctlog bool
164+
tsa bool
165+
wantIgnoreTlog bool
166+
wantUseSignedTimestamps bool
167+
wantIgnoreSCT bool
168+
}{
169+
{
170+
name: "fulcio + rekor + ctlog (typical public Sigstore)",
171+
fulcio: true, rekor: true, ctlog: true,
172+
wantIgnoreTlog: false,
173+
wantUseSignedTimestamps: false,
174+
wantIgnoreSCT: false,
175+
},
176+
{
177+
name: "fulcio + tsa (no tlog)",
178+
fulcio: true, tsa: true,
179+
wantIgnoreTlog: true,
180+
wantUseSignedTimestamps: true,
181+
wantIgnoreSCT: true,
182+
},
183+
{
184+
name: "rekor only (tlog-only policy)",
185+
rekor: true,
186+
wantIgnoreTlog: false,
187+
wantUseSignedTimestamps: false,
188+
wantIgnoreSCT: true,
189+
},
190+
{
191+
name: "all four components",
192+
fulcio: true, rekor: true, ctlog: true, tsa: true,
193+
wantIgnoreTlog: false,
194+
wantUseSignedTimestamps: true,
195+
wantIgnoreSCT: false,
196+
},
197+
{
198+
// GitHub-style immutable releases: keyless verification anchored
199+
// in TSA timestamps and Fulcio identity rather than a Rekor tlog.
200+
name: "fulcio + ctlog + tsa (GitHub-style, no Rekor)",
201+
fulcio: true, ctlog: true, tsa: true,
202+
wantIgnoreTlog: true,
203+
wantUseSignedTimestamps: true,
204+
wantIgnoreSCT: false,
205+
},
206+
{
207+
name: "tsa only",
208+
tsa: true,
209+
wantIgnoreTlog: true,
210+
wantUseSignedTimestamps: true,
211+
wantIgnoreSCT: true,
212+
},
213+
}
214+
215+
for _, tt := range tests {
216+
t.Run(tt.name, func(t *testing.T) {
217+
g := NewWithT(t)
218+
tr := makeTrustedRoot(t, tt.fulcio, tt.rekor, tt.ctlog, tt.tsa)
219+
caps := detectTrustedRootCapabilities(tr)
220+
co := &cosign.CheckOpts{}
221+
applyTrustedRootAutoDetection(co, tr, caps)
222+
g.Expect(co.TrustedMaterial).NotTo(BeNil())
223+
g.Expect(co.IgnoreTlog).To(Equal(tt.wantIgnoreTlog))
224+
g.Expect(co.UseSignedTimestamps).To(Equal(tt.wantUseSignedTimestamps))
225+
g.Expect(co.IgnoreSCT).To(Equal(tt.wantIgnoreSCT))
226+
g.Expect(co.RekorClient).To(BeNil())
227+
})
228+
}
229+
}
230+
231+
func TestNewCosignVerifier_KeylessAutoDetect(t *testing.T) {
232+
ctx := context.Background()
233+
vf := NewCosignVerifierFactory()
234+
235+
t.Run("rejects empty bundle", func(t *testing.T) {
236+
g := NewWithT(t)
237+
tr := makeTrustedRoot(t, false, false, false, false)
238+
marshaled, err := tr.MarshalJSON()
239+
g.Expect(err).NotTo(HaveOccurred())
240+
_, err = vf.NewCosignVerifier(ctx, WithTrustedRoot(marshaled))
241+
g.Expect(err).To(HaveOccurred())
242+
g.Expect(err.Error()).To(ContainSubstring("must contain Fulcio and at least one of Rekor or TSA"))
243+
})
244+
245+
for _, tt := range []struct {
246+
name string
247+
fulcio bool
248+
rekor bool
249+
ctlog bool
250+
tsa bool
251+
}{
252+
{name: "rejects fulcio only", fulcio: true},
253+
{name: "rejects rekor only", rekor: true},
254+
{name: "rejects tsa only", tsa: true},
255+
{name: "rejects rekor + tsa without fulcio", rekor: true, tsa: true},
256+
{name: "rejects fulcio + ctlog without time source", fulcio: true, ctlog: true},
257+
} {
258+
t.Run(tt.name, func(t *testing.T) {
259+
g := NewWithT(t)
260+
tr := makeTrustedRoot(t, tt.fulcio, tt.rekor, tt.ctlog, tt.tsa)
261+
marshaled, err := tr.MarshalJSON()
262+
g.Expect(err).NotTo(HaveOccurred())
263+
_, err = vf.NewCosignVerifier(ctx, WithTrustedRoot(marshaled))
264+
g.Expect(err).To(HaveOccurred())
265+
g.Expect(err.Error()).To(ContainSubstring("must contain Fulcio and at least one of Rekor or TSA"))
266+
})
267+
}
268+
269+
t.Run("fulcio + rekor matches typical keyless", func(t *testing.T) {
270+
g := NewWithT(t)
271+
tr := makeTrustedRoot(t, true, true, false, false)
272+
marshaled, err := tr.MarshalJSON()
273+
g.Expect(err).NotTo(HaveOccurred())
274+
v, err := vf.NewCosignVerifier(ctx, WithTrustedRoot(marshaled))
275+
g.Expect(err).NotTo(HaveOccurred())
276+
g.Expect(v.opts.TrustedMaterial).NotTo(BeNil())
277+
g.Expect(v.opts.RekorClient).To(BeNil())
278+
g.Expect(v.rekorURLs).To(Equal([]string{"https://rekor.example.com"}))
279+
g.Expect(v.opts.IgnoreTlog).To(BeFalse())
280+
g.Expect(v.opts.UseSignedTimestamps).To(BeFalse())
281+
g.Expect(v.opts.IgnoreSCT).To(BeTrue())
282+
})
283+
284+
t.Run("fulcio + tsa skips tlog and requires signed timestamps", func(t *testing.T) {
285+
g := NewWithT(t)
286+
tr := makeTrustedRoot(t, true, false, false, true)
287+
marshaled, err := tr.MarshalJSON()
288+
g.Expect(err).NotTo(HaveOccurred())
289+
v, err := vf.NewCosignVerifier(ctx, WithTrustedRoot(marshaled))
290+
g.Expect(err).NotTo(HaveOccurred())
291+
g.Expect(v.opts.TrustedMaterial).NotTo(BeNil())
292+
g.Expect(v.opts.RekorClient).To(BeNil())
293+
g.Expect(v.rekorURLs).To(BeEmpty())
294+
g.Expect(v.opts.IgnoreTlog).To(BeTrue())
295+
g.Expect(v.opts.UseSignedTimestamps).To(BeTrue())
296+
})
297+
}
298+
299+
func TestNewCosignVerifier_KeyedAutoDetect(t *testing.T) {
300+
ctx := context.Background()
301+
vf := NewCosignVerifierFactory()
302+
303+
// A throwaway ECDSA P-256 public key in PEM form, generated for tests.
304+
pubKey := []byte(`-----BEGIN PUBLIC KEY-----
305+
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwr
306+
kBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==
307+
-----END PUBLIC KEY-----
308+
`)
309+
310+
t.Run("public key without trusted root keeps legacy offline tlog skip", func(t *testing.T) {
311+
g := NewWithT(t)
312+
v, err := vf.NewCosignVerifier(ctx, WithPublicKey(pubKey))
313+
g.Expect(err).NotTo(HaveOccurred())
314+
g.Expect(v.opts.SigVerifier).NotTo(BeNil())
315+
g.Expect(v.opts.TrustedMaterial).To(BeNil())
316+
g.Expect(v.opts.IgnoreTlog).To(BeTrue())
317+
g.Expect(v.opts.Offline).To(BeTrue())
318+
g.Expect(v.opts.UseSignedTimestamps).To(BeFalse())
319+
})
320+
321+
t.Run("public key + rekor-only trusted root enables tlog verification", func(t *testing.T) {
322+
g := NewWithT(t)
323+
tr := makeTrustedRoot(t, false, true, false, false)
324+
marshaled, err := tr.MarshalJSON()
325+
g.Expect(err).NotTo(HaveOccurred())
326+
v, err := vf.NewCosignVerifier(ctx,
327+
WithPublicKey(pubKey),
328+
WithTrustedRoot(marshaled),
329+
)
330+
g.Expect(err).NotTo(HaveOccurred())
331+
g.Expect(v.opts.SigVerifier).NotTo(BeNil())
332+
g.Expect(v.opts.TrustedMaterial).NotTo(BeNil())
333+
g.Expect(v.opts.RekorClient).To(BeNil())
334+
g.Expect(v.rekorURLs).To(Equal([]string{"https://rekor.example.com"}))
335+
g.Expect(v.opts.IgnoreTlog).To(BeFalse())
336+
g.Expect(v.opts.UseSignedTimestamps).To(BeFalse())
337+
})
338+
339+
t.Run("public key + tsa-only trusted root enables signed timestamps and skips tlog", func(t *testing.T) {
340+
g := NewWithT(t)
341+
tr := makeTrustedRoot(t, false, false, false, true)
342+
marshaled, err := tr.MarshalJSON()
343+
g.Expect(err).NotTo(HaveOccurred())
344+
v, err := vf.NewCosignVerifier(ctx,
345+
WithPublicKey(pubKey),
346+
WithTrustedRoot(marshaled),
347+
)
348+
g.Expect(err).NotTo(HaveOccurred())
349+
g.Expect(v.opts.SigVerifier).NotTo(BeNil())
350+
g.Expect(v.opts.TrustedMaterial).NotTo(BeNil())
351+
g.Expect(v.opts.RekorClient).To(BeNil())
352+
g.Expect(v.rekorURLs).To(BeEmpty())
353+
g.Expect(v.opts.IgnoreTlog).To(BeTrue())
354+
g.Expect(v.opts.UseSignedTimestamps).To(BeTrue())
355+
})
356+
357+
t.Run("public key + rekor + tsa requires both", func(t *testing.T) {
358+
g := NewWithT(t)
359+
tr := makeTrustedRoot(t, false, true, false, true)
360+
marshaled, err := tr.MarshalJSON()
361+
g.Expect(err).NotTo(HaveOccurred())
362+
v, err := vf.NewCosignVerifier(ctx,
363+
WithPublicKey(pubKey),
364+
WithTrustedRoot(marshaled),
365+
)
366+
g.Expect(err).NotTo(HaveOccurred())
367+
g.Expect(v.opts.RekorClient).To(BeNil())
368+
g.Expect(v.rekorURLs).To(Equal([]string{"https://rekor.example.com"}))
369+
g.Expect(v.opts.IgnoreTlog).To(BeFalse())
370+
g.Expect(v.opts.UseSignedTimestamps).To(BeTrue())
371+
})
372+
373+
t.Run("public key + ctlog-only trusted root is allowed for keyed verification", func(t *testing.T) {
374+
g := NewWithT(t)
375+
tr := makeTrustedRoot(t, false, false, true, false)
376+
marshaled, err := tr.MarshalJSON()
377+
g.Expect(err).NotTo(HaveOccurred())
378+
v, err := vf.NewCosignVerifier(ctx,
379+
WithPublicKey(pubKey),
380+
WithTrustedRoot(marshaled),
381+
)
382+
g.Expect(err).NotTo(HaveOccurred())
383+
g.Expect(v.opts.SigVerifier).NotTo(BeNil())
384+
g.Expect(v.opts.TrustedMaterial).NotTo(BeNil())
385+
g.Expect(v.opts.IgnoreTlog).To(BeTrue())
386+
g.Expect(v.opts.IgnoreSCT).To(BeTrue())
387+
})
388+
}

0 commit comments

Comments
 (0)