Skip to content

Commit 1ee7959

Browse files
Pierre-Gilles Mialonpmialon
authored andcommitted
Add custom Sigstore trusted root support for OCIRepository
Enable signature verification of OCI artifacts against self-hosted Sigstore infrastructure (custom Fulcio CA, self-hosted Rekor instance) by introducing a trustedRootSecretRef field on the verify spec. When set, the controller reads a trusted_root.json from the referenced Secret, extracts the Rekor URL from the transparency log entries, and creates a verifier using the custom trusted material instead of the public Sigstore TUF root. Signed-off-by: Pierre-Gilles Mialon <pierre-gilles.mialon@qube-rt.com>
1 parent 7a113ec commit 1ee7959

File tree

9 files changed

+490
-52
lines changed

9 files changed

+490
-52
lines changed

api/v1/ociverification_types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ type OCIRepositoryVerification struct {
3838
// specified matchers match against the identity.
3939
// +optional
4040
MatchOIDCIdentity []OIDCIdentityMatch `json:"matchOIDCIdentity,omitempty"`
41+
42+
// TrustedRootSecretRef specifies the Kubernetes Secret containing a
43+
// Sigstore trusted_root.json file. This enables verification against
44+
// self-hosted Sigstore infrastructure (custom Fulcio CA, self-hosted
45+
// Rekor instance). The Secret must contain a key named "trusted_root.json".
46+
// +optional
47+
TrustedRootSecretRef *meta.LocalObjectReference `json:"trustedRootSecretRef,omitempty"`
4148
}
4249

4350
// OIDCIdentityMatch specifies options for verifying the certificate identity,

config/crd/bases/source.toolkit.fluxcd.io_helmcharts.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,19 @@ spec:
184184
required:
185185
- name
186186
type: object
187+
trustedRootSecretRef:
188+
description: |-
189+
TrustedRootSecretRef specifies the Kubernetes Secret containing a
190+
Sigstore trusted_root.json file. This enables verification against
191+
self-hosted Sigstore infrastructure (custom Fulcio CA, self-hosted
192+
Rekor instance). The Secret must contain a key named "trusted_root.json".
193+
properties:
194+
name:
195+
description: Name of the referent.
196+
type: string
197+
required:
198+
- name
199+
type: object
187200
required:
188201
- provider
189202
type: object

config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,19 @@ spec:
246246
required:
247247
- name
248248
type: object
249+
trustedRootSecretRef:
250+
description: |-
251+
TrustedRootSecretRef specifies the Kubernetes Secret containing a
252+
Sigstore trusted_root.json file. This enables verification against
253+
self-hosted Sigstore infrastructure (custom Fulcio CA, self-hosted
254+
Rekor instance). The Secret must contain a key named "trusted_root.json".
255+
properties:
256+
name:
257+
description: Name of the referent.
258+
type: string
259+
required:
260+
- name
261+
type: object
249262
required:
250263
- provider
251264
type: object

docs/api/v1/source.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3661,6 +3661,23 @@ signing. The artifact&rsquo;s identity is deemed to be verified if any of the
36613661
specified matchers match against the identity.</p>
36623662
</td>
36633663
</tr>
3664+
<tr>
3665+
<td>
3666+
<code>trustedRootSecretRef</code><br>
3667+
<em>
3668+
<a href="https://pkg.go.dev/github.com/fluxcd/pkg/apis/meta#LocalObjectReference">
3669+
github.com/fluxcd/pkg/apis/meta.LocalObjectReference
3670+
</a>
3671+
</em>
3672+
</td>
3673+
<td>
3674+
<em>(Optional)</em>
3675+
<p>TrustedRootSecretRef specifies the Kubernetes Secret containing a
3676+
Sigstore trusted_root.json file. This enables verification against
3677+
self-hosted Sigstore infrastructure (custom Fulcio CA, self-hosted
3678+
Rekor instance). The Secret must contain a key named &ldquo;trusted_root.json&rdquo;.</p>
3679+
</td>
3680+
</tr>
36643681
</tbody>
36653682
</table>
36663683
</div>

docs/spec/v1/ocirepositories.md

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -641,11 +641,64 @@ spec:
641641
subject: "^https://github.com/stefanprodan/podinfo.*$"
642642
```
643643

644-
The controller verifies the signatures using the Fulcio root CA and the Rekor
645-
instance hosted at [rekor.sigstore.dev](https://rekor.sigstore.dev/).
644+
By default, the controller verifies the signatures using the Fulcio root CA and
645+
the Rekor instance hosted at [rekor.sigstore.dev](https://rekor.sigstore.dev/).
646646

647-
Note that keyless verification is an **experimental feature**, using
648-
custom root CAs or self-hosted Rekor instances are not currently supported.
647+
##### Custom Sigstore infrastructure (self-hosted Rekor / Fulcio)
648+
649+
To verify artifacts signed with a self-hosted Sigstore deployment, provide a
650+
Sigstore `trusted_root.json` via the `.spec.verify.trustedRootSecretRef` field.
651+
The trusted root bundles the Fulcio root CA chain, Rekor public key and URL,
652+
CT log keys, and optionally TSA certificates. The Rekor URL is extracted
653+
automatically from the `baseUrl` field in the transparency log entries.
654+
655+
The `trusted_root.json` file follows the
656+
[Sigstore trusted root format](https://github.com/sigstore/protobuf-specs).
657+
658+
Generate the file using `cosign trusted-root create`:
659+
660+
```sh
661+
cosign trusted-root create \
662+
--fulcio="url=https://fulcio.example.com,certificate-chain=/path/to/fulcio-chain.pem" \
663+
--rekor="url=https://rekor.example.com,public-key=/path/to/rekor.pub,start-time=2024-01-01T00:00:00Z" \
664+
--ctfe="url=https://ctfe.example.com,public-key=/path/to/ctfe.pub,start-time=2024-01-01T00:00:00Z" \
665+
--out trusted_root.json
666+
```
667+
668+
The `--tsa` flag can also be used if a custom timestamp authority is deployed:
669+
670+
```sh
671+
cosign trusted-root create \
672+
--tsa="url=https://tsa.example.com/api/v1/timestamp,certificate-chain=/path/to/tsa-chain.pem" \
673+
...
674+
```
675+
676+
Create the Kubernetes Secret from the generated file:
677+
678+
```sh
679+
kubectl create secret generic sigstore-trusted-root \
680+
--from-file=trusted_root.json=./trusted_root.json \
681+
-n <namespace>
682+
```
683+
684+
Reference it in the OCIRepository:
685+
686+
```yaml
687+
apiVersion: source.toolkit.fluxcd.io/v1
688+
kind: OCIRepository
689+
metadata:
690+
name: podinfo
691+
spec:
692+
interval: 5m
693+
url: oci://registry.example.com/manifests/podinfo
694+
verify:
695+
provider: cosign
696+
trustedRootSecretRef:
697+
name: sigstore-trusted-root
698+
matchOIDCIdentity:
699+
- issuer: "^https://oidc-issuer.example.com$"
700+
subject: "^https://ci.example.com/.*$"
701+
```
649702

650703
#### Notation
651704

internal/controller/ocirepository_controller.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,16 @@ func (r *OCIRepositoryReconciler) verifySignature(ctx context.Context, obj *sour
680680
scosign.WithRemoteOptions(opt...),
681681
}
682682

683+
// If a trusted root secret is provided, read and pass it to the verifier.
684+
if trustedRootRef := obj.Spec.Verify.TrustedRootSecretRef; trustedRootRef != nil {
685+
data, err := readTrustedRootFromSecret(ctxTimeout, r.Client, obj.Namespace, trustedRootRef)
686+
if err != nil {
687+
return soci.VerificationResultFailed, fmt.Errorf("failed to read trusted root from secret '%s/%s': %w",
688+
obj.Namespace, trustedRootRef.Name, err)
689+
}
690+
defaultCosignOciOpts = append(defaultCosignOciOpts, scosign.WithTrustedRoot(data))
691+
}
692+
683693
// get the public keys from the given secret
684694
if secretRef := obj.Spec.Verify.SecretRef; secretRef != nil {
685695

@@ -1357,6 +1367,29 @@ func layerSelectorEqual(a, b *sourcev1.OCILayerSelector) bool {
13571367
return *a == *b
13581368
}
13591369

1370+
const trustedRootKey = "trusted_root.json"
1371+
1372+
// readTrustedRootFromSecret reads and returns the trusted_root.json data from
1373+
// the Kubernetes Secret referenced by the given LocalObjectReference.
1374+
func readTrustedRootFromSecret(ctx context.Context, c client.Reader, namespace string, ref *meta.LocalObjectReference) ([]byte, error) {
1375+
secretName := types.NamespacedName{
1376+
Namespace: namespace,
1377+
Name: ref.Name,
1378+
}
1379+
1380+
var secret corev1.Secret
1381+
if err := c.Get(ctx, secretName, &secret); err != nil {
1382+
return nil, err
1383+
}
1384+
1385+
data, ok := secret.Data[trustedRootKey]
1386+
if !ok {
1387+
return nil, fmt.Errorf("'%s' not found in secret '%s'", trustedRootKey, secretName.String())
1388+
}
1389+
1390+
return data, nil
1391+
}
1392+
13601393
func filterTags(filter string) filterFunc {
13611394
return func(tags []string) ([]string, error) {
13621395
if filter == "" {
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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 controller
18+
19+
import (
20+
"context"
21+
"testing"
22+
23+
"github.com/fluxcd/pkg/apis/meta"
24+
. "github.com/onsi/gomega"
25+
corev1 "k8s.io/api/core/v1"
26+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
27+
"k8s.io/apimachinery/pkg/runtime"
28+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
29+
)
30+
31+
func TestReadTrustedRootFromSecret(t *testing.T) {
32+
scheme := runtime.NewScheme()
33+
_ = corev1.AddToScheme(scheme)
34+
35+
tests := []struct {
36+
name string
37+
namespace string
38+
ref *meta.LocalObjectReference
39+
secret *corev1.Secret
40+
wantData []byte
41+
wantErr string
42+
}{
43+
{
44+
name: "reads trusted_root.json from secret",
45+
namespace: "default",
46+
ref: &meta.LocalObjectReference{Name: "sigstore-root"},
47+
secret: &corev1.Secret{
48+
ObjectMeta: metav1.ObjectMeta{
49+
Name: "sigstore-root",
50+
Namespace: "default",
51+
},
52+
Data: map[string][]byte{
53+
"trusted_root.json": []byte(`{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1"}`),
54+
},
55+
},
56+
wantData: []byte(`{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1"}`),
57+
},
58+
{
59+
name: "error when secret does not exist",
60+
namespace: "default",
61+
ref: &meta.LocalObjectReference{Name: "missing-secret"},
62+
wantErr: `"missing-secret" not found`,
63+
},
64+
{
65+
name: "error when key is missing from secret",
66+
namespace: "default",
67+
ref: &meta.LocalObjectReference{Name: "no-key-secret"},
68+
secret: &corev1.Secret{
69+
ObjectMeta: metav1.ObjectMeta{
70+
Name: "no-key-secret",
71+
Namespace: "default",
72+
},
73+
Data: map[string][]byte{
74+
"other-key": []byte("data"),
75+
},
76+
},
77+
wantErr: "'trusted_root.json' not found in secret",
78+
},
79+
}
80+
81+
for _, tt := range tests {
82+
t.Run(tt.name, func(t *testing.T) {
83+
g := NewWithT(t)
84+
85+
builder := fake.NewClientBuilder().WithScheme(scheme)
86+
if tt.secret != nil {
87+
builder = builder.WithObjects(tt.secret)
88+
}
89+
c := builder.Build()
90+
91+
data, err := readTrustedRootFromSecret(context.Background(), c, tt.namespace, tt.ref)
92+
93+
if tt.wantErr != "" {
94+
g.Expect(err).To(HaveOccurred())
95+
g.Expect(err.Error()).To(ContainSubstring(tt.wantErr))
96+
return
97+
}
98+
99+
g.Expect(err).NotTo(HaveOccurred())
100+
g.Expect(data).To(Equal(tt.wantData))
101+
})
102+
}
103+
}

0 commit comments

Comments
 (0)