Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api/v1/ociverification_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ type OCIRepositoryVerification struct {
// specified matchers match against the identity.
// +optional
MatchOIDCIdentity []OIDCIdentityMatch `json:"matchOIDCIdentity,omitempty"`

// TrustedRootSecretRef specifies the Kubernetes Secret containing a
// Sigstore trusted_root.json file. This enables verification against
// self-hosted Sigstore infrastructure (custom Fulcio CA, self-hosted
// Rekor instance). The Secret must contain a key named "trusted_root.json".
// +optional
TrustedRootSecretRef *meta.LocalObjectReference `json:"trustedRootSecretRef,omitempty"`
}

// OIDCIdentityMatch specifies options for verifying the certificate identity,
Expand Down
13 changes: 13 additions & 0 deletions config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,19 @@ spec:
required:
- name
type: object
trustedRootSecretRef:
description: |-
TrustedRootSecretRef specifies the Kubernetes Secret containing a
Sigstore trusted_root.json file. This enables verification against
self-hosted Sigstore infrastructure (custom Fulcio CA, self-hosted
Rekor instance). The Secret must contain a key named "trusted_root.json".
properties:
name:
description: Name of the referent.
type: string
required:
- name
type: object
required:
- provider
type: object
Expand Down
61 changes: 57 additions & 4 deletions docs/spec/v1/ocirepositories.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,11 +641,64 @@ spec:
subject: "^https://github.com/stefanprodan/podinfo.*$"
```

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

Note that keyless verification is an **experimental feature**, using
custom root CAs or self-hosted Rekor instances are not currently supported.
##### Custom Sigstore infrastructure (self-hosted Rekor / Fulcio)

To verify artifacts signed with a self-hosted Sigstore deployment, provide a
Sigstore `trusted_root.json` via the `.spec.verify.trustedRootSecretRef` field.
The trusted root bundles the Fulcio root CA chain, Rekor public key and URL,
CT log keys, and optionally TSA certificates. The Rekor URL is extracted
automatically from the `baseUrl` field in the transparency log entries.

The `trusted_root.json` file follows the
[Sigstore trusted root format](https://github.com/sigstore/protobuf-specs).

Generate the file using `cosign trusted-root create`:

```sh
cosign trusted-root create \
--fulcio="url=https://fulcio.example.com,certificate-chain=/path/to/fulcio-chain.pem" \
--rekor="url=https://rekor.example.com,public-key=/path/to/rekor.pub,start-time=2024-01-01T00:00:00Z" \
--ctfe="url=https://ctfe.example.com,public-key=/path/to/ctfe.pub,start-time=2024-01-01T00:00:00Z" \
--out trusted_root.json
```

The `--tsa` flag can also be used if a custom timestamp authority is deployed:

```sh
cosign trusted-root create \
--tsa="url=https://tsa.example.com/api/v1/timestamp,certificate-chain=/path/to/tsa-chain.pem" \
...
```

Create the Kubernetes Secret from the generated file:

```sh
kubectl create secret generic sigstore-trusted-root \
--from-file=trusted_root.json=./trusted_root.json \
-n <namespace>
```

Reference it in the OCIRepository:

```yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
name: podinfo
spec:
interval: 5m
url: oci://registry.example.com/manifests/podinfo
verify:
provider: cosign
trustedRootSecretRef:
name: sigstore-trusted-root
matchOIDCIdentity:
- issuer: "^https://oidc-issuer.example.com$"
subject: "^https://ci.example.com/.*$"
```

#### Notation

Expand Down
33 changes: 33 additions & 0 deletions internal/controller/ocirepository_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,16 @@ func (r *OCIRepositoryReconciler) verifySignature(ctx context.Context, obj *sour
scosign.WithRemoteOptions(opt...),
}

// If a trusted root secret is provided, read and pass it to the verifier.
if trustedRootRef := obj.Spec.Verify.TrustedRootSecretRef; trustedRootRef != nil {
data, err := readTrustedRootFromSecret(ctxTimeout, r.Client, obj.Namespace, trustedRootRef)
if err != nil {
return soci.VerificationResultFailed, fmt.Errorf("failed to read trusted root from secret '%s/%s': %w",
obj.Namespace, trustedRootRef.Name, err)
}
defaultCosignOciOpts = append(defaultCosignOciOpts, scosign.WithTrustedRoot(data))
}

// get the public keys from the given secret
if secretRef := obj.Spec.Verify.SecretRef; secretRef != nil {

Expand Down Expand Up @@ -1357,6 +1367,29 @@ func layerSelectorEqual(a, b *sourcev1.OCILayerSelector) bool {
return *a == *b
}

const trustedRootKey = "trusted_root.json"

// readTrustedRootFromSecret reads and returns the trusted_root.json data from
// the Kubernetes Secret referenced by the given LocalObjectReference.
func readTrustedRootFromSecret(ctx context.Context, c client.Reader, namespace string, ref *meta.LocalObjectReference) ([]byte, error) {
secretName := types.NamespacedName{
Namespace: namespace,
Name: ref.Name,
}

var secret corev1.Secret
if err := c.Get(ctx, secretName, &secret); err != nil {
return nil, err
}

data, ok := secret.Data[trustedRootKey]
if !ok {
return nil, fmt.Errorf("'%s' not found in secret '%s'", trustedRootKey, secretName.String())
}

return data, nil
}

func filterTags(filter string) filterFunc {
return func(tags []string) ([]string, error) {
if filter == "" {
Expand Down
103 changes: 103 additions & 0 deletions internal/controller/ocirepository_verify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
Copyright 2026 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package controller

import (
"context"
"testing"

"github.com/fluxcd/pkg/apis/meta"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)

func TestReadTrustedRootFromSecret(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)

tests := []struct {
name string
namespace string
ref *meta.LocalObjectReference
secret *corev1.Secret
wantData []byte
wantErr string
}{
{
name: "reads trusted_root.json from secret",
namespace: "default",
ref: &meta.LocalObjectReference{Name: "sigstore-root"},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "sigstore-root",
Namespace: "default",
},
Data: map[string][]byte{
"trusted_root.json": []byte(`{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1"}`),
},
},
wantData: []byte(`{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1"}`),
},
{
name: "error when secret does not exist",
namespace: "default",
ref: &meta.LocalObjectReference{Name: "missing-secret"},
wantErr: `"missing-secret" not found`,
},
{
name: "error when key is missing from secret",
namespace: "default",
ref: &meta.LocalObjectReference{Name: "no-key-secret"},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "no-key-secret",
Namespace: "default",
},
Data: map[string][]byte{
"other-key": []byte("data"),
},
},
wantErr: "'trusted_root.json' not found in secret",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)

builder := fake.NewClientBuilder().WithScheme(scheme)
if tt.secret != nil {
builder = builder.WithObjects(tt.secret)
}
c := builder.Build()

data, err := readTrustedRootFromSecret(context.Background(), c, tt.namespace, tt.ref)

if tt.wantErr != "" {
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring(tt.wantErr))
return
}

g.Expect(err).NotTo(HaveOccurred())
g.Expect(data).To(Equal(tt.wantData))
})
}
}
Loading
Loading