Skip to content

Instantly share code, notes, and snippets.

@afrittoli
Last active March 17, 2026 11:46
Show Gist options
  • Select an option

  • Save afrittoli/d3bb375727b9c8b54f50ea6d3c4cb9d2 to your computer and use it in GitHub Desktop.

Select an option

Save afrittoli/d3bb375727b9c8b54f50ea6d3c4cb9d2 to your computer and use it in GitHub Desktop.

TEP-0089 Non-Falsifiable Provenance: Implementation Plan

Overview

TEP-0089 adds cryptographic signing and verification of TaskRun results to achieve SLSA Level 3 non-falsifiability guarantees. The original design used SPIFFE/SPIRE as the identity provider. This plan extends the approach to support multiple signing backends via configuration, with a lighter-weight Kubernetes-native OIDC option as the default path to an MVP that requires no additional cluster infrastructure.

TEP: https://github.com/tektoncd/community/blob/main/teps/0089-nonfalsifiable-provenance-support.md


Signing Backends: Comparison

SPIFFE/SPIRE

How it works: A SPIRE server and per-node agent DaemonSet provide workload identity. The Tekton controller registers a SPIFFE ID per TaskRun pod in the SPIRE server. The entrypointer requests an X.509 SVID from the SPIRE agent (via a CSI-mounted socket), uses the SVID private key to sign each result and a result manifest, and emits signatures + certificate as additional TaskRun results. The controller verifies by fetching the SPIRE trust bundle and checking the SVID's URI SAN against the actual TaskRun identity.

Advantages:

  • Strong workload identity: the SVID is per-TaskRun-pod and cannot be reused across TaskRuns.
  • Certificate chain is rooted in an external trust authority (SPIRE CA), not the Kubernetes API server.
  • Supports SLSA L3 non-falsifiability because neither the Kubernetes API server nor the controller itself can forge the SVID.
  • Mature, widely deployed standard (SPIFFE is a CNCF graduated project).

Limitations:

  • Requires additional cluster components: SPIRE server, SPIRE agent DaemonSet, SPIFFE CSI driver.
  • SPIRE entry lifecycle must be managed by the controller (create on pod start, delete on TaskRun completion); a race condition exists if the entry is not registered before the entrypointer requests its SVID.
  • Termination message size pressure: ECDSA signatures + X.509 certificate increase the payload of the termination message, which is capped at 4 KiB by default. TaskRuns with many results may hit the limit.
  • The entire pkg/spire/ package still uses the deprecated v1beta1.TaskRun API and needs migration to v1.
  • Not available on managed Kubernetes clusters that do not allow DaemonSets or CSI drivers (e.g., some restricted enterprise environments).

Kubernetes OIDC / Service Account Token (Recommended MVP Path)

How it works: Every Kubernetes pod receives a projected service account token. The TokenRequest API can issue short-lived, audience-scoped tokens bound to a specific pod UID. The entrypointer uses such a token as an identity claim and signs results using an ephemeral key pair generated at startup; the public key and the SA token (as identity proof) are emitted alongside the signatures. The controller verifies by calling TokenReview to confirm the token's validity and binding, then verifies the signatures.

Alternatively (simpler variant): the entrypointer generates an ephemeral key, includes the public key in a TokenRequest with an audience encoding the public key's digest, and signs with the private key. The controller verifies the token audience against the submitted public key and checks that the token's boundObjectRef matches the actual pod.

Advantages:

  • Zero additional infrastructure: works on any Kubernetes cluster out of the box with no DaemonSets or CSI drivers.
  • Identity is bound to the specific pod UID via TokenRequest's boundObjectRef, preventing reuse across TaskRuns.
  • Short-lived tokens (default 1 hour, configurable down to 10 minutes) minimise the window for credential misuse.
  • Verification is a single TokenReview call to the Kubernetes API server, which is already trusted infrastructure.
  • Naturally integrates with cloud provider OIDC (GKE Workload Identity, EKS IRSA, AKS Workload Identity) for cross-cluster verification.

Limitations:

  • Trust root is the Kubernetes API server. If the API server or its signing key is compromised, signatures can be forged. This satisfies SLSA L2 but not strict SLSA L3 (which requires an external root of trust).
  • TokenReview requires an outbound API server call per verification, adding latency.
  • Verification of archived TaskRuns is not possible after the token expires (mitigated by Tekton Chains re-signing at completion time, before expiry).

Sigstore Keyless Signing (Future Extension)

How it works: The entrypointer requests an OIDC token, exchanges it with a Fulcio instance for a short-lived signing certificate, signs results, and optionally logs to Rekor for transparency.

Advantages:

  • No private key management. Public auditability via Rekor.
  • Interoperable with the broader Sigstore ecosystem and Tekton Chains' existing Sigstore support.
  • Fulcio can be backed by the cluster's own OIDC issuer for a self-contained setup.

Limitations:

  • Requires a Fulcio and (optionally) Rekor deployment, or dependency on the public Sigstore instance (privacy concern for internal results).
  • More complex than OIDC-only: involves certificate issuance round-trip before signing.

Feature Flag Design

The existing enforce-nonfalsifiability feature flag in config-feature-flags is extended to a string enum. Its current values are preserved for compatibility:

Value Meaning
none (default) Signing and verification disabled
spire SPIFFE/SPIRE backend (existing infrastructure, currently wired but not invoked)
oidc-sa-token Kubernetes SA token backend (new, MVP path)

The flag is already parsed as a string in pkg/apis/config/feature_flags.go:setEnforceNonFalsifiability. Adding a new value requires only extending the validation switch and adding the new constant.

The backend is selected in the reconciler and entrypointer based on this flag value. Both backends implement the existing spire.ControllerAPIClient and spire.EntrypointerAPIClient interfaces; the SPIRE package is renamed to signing to reflect the multi-backend reality.


Current State

Fully Implemented (pkg/spire/)

Component Status
SPIRE controller client (controller.go) Complete
SPIRE entrypointer client (entrypointer.go) Complete
Signing logic (sign.go) Complete
Verification logic (verify.go) Complete
Mock client for tests (spire_mock.go) Complete
Config parsing (config/config.go) Complete
Feature flag enforce-nonfalsifiability Complete
config-spire ConfigMap Complete
Pod CSI volume injection (pkg/pod/pod.go) Complete
Entrypointer signing (pkg/entrypoint/entrypointer.go) Complete (build-tag gated)
SignedResultsVerified condition type + reasons in API types Complete (v1beta1)

Missing / Broken

Gap Detail
Reconciler call sites spireClient field exists in taskrun.go:87 but is never called. SPIRE entry creation, result verification, and status signing are all absent from the reconcile loop.
v1beta1 → v1 API migration All of pkg/spire/ uses v1beta1.TaskRun; the reconciler works on v1.TaskRun.
SignedResultsVerified condition writes Condition type defined but never set in production code.
No e2e tests YAML fixtures exist in test/testdata/spire/; no Go integration tests.
Tekton Chains integration Phase 4 (Chains re-verification) not started.
OIDC-SA-token backend Not yet implemented.

Proposed PR Sequence

The goal is a usable MVP with the OIDC-SA-token backend in the fewest PRs, while keeping the SPIRE infrastructure intact and completing it in parallel follow-up PRs.


PR 1 — Rename pkg/spirepkg/signing; migrate interfaces to v1.TaskRun

Depends on: nothing Blocks: all subsequent PRs

Scope:

  • Rename the package directory from pkg/spire/ to pkg/signing/.
  • Rename ControllerAPIClientSigningControllerClient, EntrypointerAPIClientSigningEntrypointerClient (or keep the names with // ControllerAPIClient is an alias for backward compat).
  • Replace all v1beta1.TaskRun references in the package with v1.TaskRun. Replace v1beta1.ArrayOrString with v1.ParamValue in getResultValue.
  • Move SpireConfig to pkg/signing/config/config.go; keep the SPIRE-specific fields scoped to the SPIRE implementation file.
  • Update all import paths throughout the codebase.
  • No behaviour change. The SPIRE backend is still the only implementation; it is still never called from the reconciler.
  • Update existing unit tests to use the new import paths.
  • Docs: update docs/spire.md to note the rename and reflect that multiple backends are planned.

PR 2 — Add oidc-sa-token backend (entrypointer side)

Depends on: PR 1 Blocks: PR 4

Scope: Implement pkg/signing/oidc/entrypointer.go — a new SigningEntrypointerClient that:

  1. Generates an ephemeral ECDSA P-256 key pair at startup.
  2. Calls the Kubernetes TokenRequest API with:
    • audiences: ["tekton-result-signing"] (configurable)
    • expirationSeconds: 600 (10 minutes)
    • boundObjectRef: the pod UID (passed via downward API env var, already injected by the entrypointer infrastructure).
  3. Signs each result and the RESULT_MANIFEST with the ephemeral private key (same signing format as the SPIRE backend: SHA-256 + ECDSA, base64-encoded).
  4. Emits RESULT_MANIFEST, per-result .sig results, and two new results:
    • SIGNING_CERT (absent for OIDC; the public key is used instead)
    • SIGNING_PUBKEY — DER-encoded public key (base64)
    • SIGNING_TOKEN — the SA token (used by controller for TokenReview)

Add cmd/entrypoint/oidc.go (analogous to cmd/entrypoint/spire.go) to wire the new client when -signing-backend=oidc-sa-token is passed.

Extend pkg/entrypoint/entrypointer.go:

  • Change SpireWorkloadAPI field to SigningClient signing.EntrypointerClient.
  • Wire the backend selection based on the -signing-backend flag (set by the pod builder from the feature flag).

Tests: unit tests for oidc/entrypointer.go with a fake TokenRequest server.

No reconciler changes. The controller does not yet verify these results.


PR 3 — Wire backend selection in pkg/pod/pod.go and feature flag validation

Depends on: PR 2 Blocks: PR 4

Scope:

  • Add EnforceNonfalsifiabilityOIDCSAToken = "oidc-sa-token" constant and extend the validation switch in pkg/apis/config/feature_flags.go.
  • In pkg/pod/pod.go: when enforce-nonfalsifiability is oidc-sa-token, pass -signing-backend=oidc-sa-token to the entrypointer (instead of the SPIRE CSI volume + -enable_spire). No additional volumes needed.
  • Add POD_UID downward API env var injection (needed by the OIDC client) when this backend is selected.
  • Validate that the feature flag cannot be set to spire unless the config-spire ConfigMap is present (soft warning, not a hard error).

Tests: extend pkg/pod/pod_test.go to cover the new env var injection and flag passing.

Docs: extend docs/spire.md (or rename to docs/result-signing.md) with an OIDC backend section covering enablement steps.


PR 4 — Add oidc-sa-token backend (controller/verification side) + wire reconciler

Depends on: PRs 2, 3 Blocks: nothing (MVP complete after this PR)

This is the MVP PR. After this PR, enforce-nonfalsifiability: oidc-sa-token is fully functional end-to-end.

Scope:

pkg/signing/oidc/controller.go — implement SigningControllerClient:

  • VerifyTaskRunResults(ctx, results, tr):

    1. Extract SIGNING_TOKEN, SIGNING_PUBKEY, RESULT_MANIFEST, and per-result .sig entries from results.
    2. Call TokenReview to validate the token; confirm boundObjectRef.uid matches the TaskRun's pod UID (from tr.Status.PodName → pod lookup).
    3. Parse the public key; verify the manifest signature.
    4. Verify each result's signature.
    5. Return error on any failure.
  • CreateEntries, DeleteEntry, AppendStatusInternalAnnotation, VerifyStatusInternalAnnotation: no-ops (OIDC backend does not manage SPIRE entries or sign controller status; these methods are required by the interface).

  • CheckSpireVerifiedFlag: return true always (not applicable).

pkg/reconciler/taskrun/taskrun.go — add call sites in reconcileTaskRun:

// When pod transitions to running:
if cfg.FeatureFlags.EnforceNonfalsifiability != config.EnforceNonfalsifiabilityNone {
    if err := r.signingClient.CreateEntries(ctx, tr, pod, ttl); err != nil { ... }
}

// When TaskRun completes (before setting final status):
if cfg.FeatureFlags.EnforceNonfalsifiability != config.EnforceNonfalsifiabilityNone {
    if err := r.signingClient.VerifyTaskRunResults(ctx, results, tr); err != nil {
        // set SignedResultsVerified = False condition
    } else {
        // set SignedResultsVerified = True condition
    }
}

// After status update:
if cfg.FeatureFlags.EnforceNonfalsifiability != config.EnforceNonfalsifiabilityNone {
    if err := r.signingClient.AppendStatusInternalAnnotation(ctx, tr); err != nil { ... }
}

Rename spireClientsigningClient in the Reconciler struct.

pkg/reconciler/taskrun/controller.go — select backend from feature flag:

switch cfg.EnforceNonfalsifiability {
case config.EnforceNonfalsifiabilityWithSpire:
    r.signingClient = spire.GetControllerAPIClient(ctx)
case config.EnforceNonfalsifiabilityOIDCSAToken:
    r.signingClient = oidcsigning.NewControllerClient(kubeClient)
default:
    r.signingClient = signing.NoopControllerClient{}
}

Add signing.NoopControllerClient{} — a zero-value struct that satisfies the interface with all no-ops, used when enforce-nonfalsifiability: none.

pkg/apis/pipeline/v1/taskrun_types.go — migrate SignedResultsVerified condition type and reasons from v1beta1 to v1, and add helper methods AreResultsVerified() / IsResultsVerificationComplete() to v1.TaskRunStatus.

Tests:

  • Unit tests for oidc/controller.go with a fake TokenReview server.
  • Integration test in test/ that creates a TaskRun with enforce-nonfalsifiability: oidc-sa-token and verifies the SignedResultsVerified condition is set to True.

Docs: update docs/result-signing.md with a complete OIDC quick-start guide; mark the feature as Alpha.


PR 5 — Complete SPIRE reconciler integration

Depends on: PR 4 Blocks: nothing

This PR completes the original TEP-0089 SPIRE implementation.

Scope:

pkg/signing/spire/controller.go:

  • Implement CreateEntries call site: after the TaskRun pod transitions to Running phase, register a SPIRE workload entry scoped to the pod's UID and service account.
  • Implement DeleteEntry call site: in the TaskRun finalizer, delete the SPIRE entry.
  • Implement VerifyStatusInternalAnnotation + AppendStatusInternalAnnotation call sites (Phase 2 of the TEP).

Address the entry registration race:

  • Pre-register the entry just before pod creation (not after pod starts running), so the SVID is available when the entrypointer starts.
  • If the SVID request times out in the entrypointer (configurable, default 30s), emit a partial result set without signatures and log a warning (in audit mode) or fail the step (in enforce mode).

Feature flag: add enforce-nonfalsifiability-mode: audit | enforce (or extend the value to spire-audit / spire-enforce) to distinguish between logging-only and hard-fail behaviours.

Tests:

  • End-to-end test using the test SPIRE deployment in test/testdata/spire/.
  • Unit tests for entry lifecycle.

Docs: update docs/result-signing.md SPIRE section; remove the "not yet functional" warning.


PR 6 — Soft/hard enforcement mode for all backends

Depends on: PR 4 Blocks: nothing

Scope:

  • Add signing-enforcement: audit | enforce field to config-feature-flags (independent of the backend choice).
  • In audit mode: verification failure sets SignedResultsVerified = False but the TaskRun continues and succeeds.
  • In enforce mode: verification failure sets SignedResultsVerified = False and fails the TaskRun with reason TaskRunResultsVerificationFailed.
  • Default: audit (non-breaking).

Tests: unit tests for both modes.

Docs: document the enforcement modes and their trade-offs.


PR 7 — Sigstore keyless backend (future, post-MVP)

Depends on: PR 4

Scope:

  • Add sigstore-keyless backend value.
  • Implement pkg/signing/sigstore/entrypointer.go: request OIDC token, exchange with Fulcio for a short-lived certificate, sign results.
  • Implement pkg/signing/sigstore/controller.go: verify Fulcio certificate, check Rekor inclusion proof if configured.
  • Add config-signing.yaml ConfigMap with Fulcio URL, Rekor URL, and OIDC issuer fields (reusing config-spire.yaml shape).

File Map

File Change
pkg/spire/pkg/signing/spire/ Rename + v1beta1→v1 migration (PR 1)
pkg/signing/oidc/entrypointer.go New — OIDC entrypointer client (PR 2)
pkg/signing/oidc/controller.go New — OIDC controller client (PR 4)
pkg/signing/noop.go New — no-op client for enforce-nonfalsifiability: none (PR 4)
pkg/signing/signing.go Renamed from pkg/spire/spire.go; interfaces unchanged (PR 1)
pkg/pod/pod.go Wire OIDC backend flag injection (PR 3)
pkg/entrypoint/entrypointer.go Replace SpireWorkloadAPI with SigningClient (PR 2)
cmd/entrypoint/oidc.go New — OIDC client wiring (PR 2)
pkg/reconciler/taskrun/taskrun.go Add signing call sites; rename field (PR 4)
pkg/reconciler/taskrun/controller.go Backend selection from feature flag (PR 4)
pkg/apis/config/feature_flags.go Add oidc-sa-token value (PR 3)
pkg/apis/pipeline/v1/taskrun_types.go Migrate condition types from v1beta1 (PR 4)
docs/spire.mddocs/result-signing.md Rename + OIDC section (PRs 1, 3, 4, 5)
config/config-spire.yaml Keep as-is; extend with shared signing fields in PR 5

MVP Definition

The MVP is complete after PR 4. At that point:

  • enforce-nonfalsifiability: oidc-sa-token is a fully working option with no additional infrastructure beyond a standard Kubernetes cluster.
  • TaskRun results are signed by the entrypointer using an ephemeral key bound to the pod identity via a TokenRequest.
  • The controller verifies signatures and sets the SignedResultsVerified condition on every completed TaskRun.
  • Tekton Chains can consume the SignedResultsVerified condition as a signal before producing its own attestation.
  • Verification failure is visible (condition = False) but non-blocking (audit mode default).
  • The SPIRE backend remains present and wired but is still not yet invoked from the reconciler (completed in PR 5).

Open Questions

  1. Termination message size: Adding SIGNING_PUBKEY (~100 bytes) + SIGNING_TOKEN (~500 bytes) + per-result signatures to the termination message. For TaskRuns with >5–6 results this may approach the 4 KiB limit. Consider switching to sidecar-log-results extraction when OIDC signing is enabled, or emitting signing material as annotations rather than results.

  2. Token expiry and Chains: The SA token expires. If Chains processing is delayed beyond the token TTL, it cannot re-verify via TokenReview. Chains should re-sign promptly (within the token's 10-minute window), or the token should be persisted (e.g., as an annotation) before it expires. The controller could persist the token and pod-UID binding as a TaskRun annotation on completion.

  3. v1beta1 removal timeline: If v1beta1 is removed before PR 1 lands, the migration becomes trivially a deletion of the old import. If v1beta1 removal is still some time away, the migration in PR 1 is worth doing now to keep pkg/signing/ on the stable API.

  4. Chains integration spec: TEP-0089 Phase 4 (Chains consuming signed results) should be scoped as a separate TEP amendment or a follow-on PR in the Chains repo once the SignedResultsVerified condition is stable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment