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
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 deprecatedv1beta1.TaskRunAPI and needs migration tov1. - Not available on managed Kubernetes clusters that do not allow DaemonSets or CSI drivers (e.g., some restricted enterprise environments).
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'sboundObjectRef, 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
TokenReviewcall 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).
TokenReviewrequires 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).
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.
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.
| 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) |
| 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. |
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.
Depends on: nothing Blocks: all subsequent PRs
Scope:
- Rename the package directory from
pkg/spire/topkg/signing/. - Rename
ControllerAPIClient→SigningControllerClient,EntrypointerAPIClient→SigningEntrypointerClient(or keep the names with// ControllerAPIClient is an alias for backward compat). - Replace all
v1beta1.TaskRunreferences in the package withv1.TaskRun. Replacev1beta1.ArrayOrStringwithv1.ParamValueingetResultValue. - Move
SpireConfigtopkg/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.mdto note the rename and reflect that multiple backends are planned.
Depends on: PR 1 Blocks: PR 4
Scope:
Implement pkg/signing/oidc/entrypointer.go — a new SigningEntrypointerClient
that:
- Generates an ephemeral ECDSA P-256 key pair at startup.
- Calls the Kubernetes
TokenRequestAPI 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).
- Signs each result and the
RESULT_MANIFESTwith the ephemeral private key (same signing format as the SPIRE backend: SHA-256 + ECDSA, base64-encoded). - Emits
RESULT_MANIFEST, per-result.sigresults, 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 forTokenReview)
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
SpireWorkloadAPIfield toSigningClient signing.EntrypointerClient. - Wire the backend selection based on the
-signing-backendflag (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.
Depends on: PR 2 Blocks: PR 4
Scope:
- Add
EnforceNonfalsifiabilityOIDCSAToken = "oidc-sa-token"constant and extend the validation switch inpkg/apis/config/feature_flags.go. - In
pkg/pod/pod.go: whenenforce-nonfalsifiabilityisoidc-sa-token, pass-signing-backend=oidc-sa-tokento the entrypointer (instead of the SPIRE CSI volume +-enable_spire). No additional volumes needed. - Add
POD_UIDdownward API env var injection (needed by the OIDC client) when this backend is selected. - Validate that the feature flag cannot be set to
spireunless theconfig-spireConfigMap 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.
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):- Extract
SIGNING_TOKEN,SIGNING_PUBKEY,RESULT_MANIFEST, and per-result.sigentries from results. - Call
TokenReviewto validate the token; confirmboundObjectRef.uidmatches the TaskRun's pod UID (fromtr.Status.PodName→ pod lookup). - Parse the public key; verify the manifest signature.
- Verify each result's signature.
- Return error on any failure.
- Extract
-
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: returntruealways (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 spireClient → signingClient 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.gowith a fakeTokenReviewserver. - Integration test in
test/that creates a TaskRun withenforce-nonfalsifiability: oidc-sa-tokenand verifies theSignedResultsVerifiedcondition is set toTrue.
Docs: update docs/result-signing.md with a complete OIDC quick-start
guide; mark the feature as Alpha.
Depends on: PR 4 Blocks: nothing
This PR completes the original TEP-0089 SPIRE implementation.
Scope:
pkg/signing/spire/controller.go:
- Implement
CreateEntriescall site: after the TaskRun pod transitions toRunningphase, register a SPIRE workload entry scoped to the pod's UID and service account. - Implement
DeleteEntrycall site: in the TaskRun finalizer, delete the SPIRE entry. - Implement
VerifyStatusInternalAnnotation+AppendStatusInternalAnnotationcall 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
auditmode) or fail the step (inenforcemode).
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.
Depends on: PR 4 Blocks: nothing
Scope:
- Add
signing-enforcement: audit | enforcefield toconfig-feature-flags(independent of the backend choice). - In
auditmode: verification failure setsSignedResultsVerified = Falsebut the TaskRun continues and succeeds. - In
enforcemode: verification failure setsSignedResultsVerified = Falseand fails the TaskRun with reasonTaskRunResultsVerificationFailed. - Default:
audit(non-breaking).
Tests: unit tests for both modes.
Docs: document the enforcement modes and their trade-offs.
Depends on: PR 4
Scope:
- Add
sigstore-keylessbackend 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.yamlConfigMap with Fulcio URL, Rekor URL, and OIDC issuer fields (reusingconfig-spire.yamlshape).
| 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.md → docs/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 |
The MVP is complete after PR 4. At that point:
enforce-nonfalsifiability: oidc-sa-tokenis 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
SignedResultsVerifiedcondition on every completed TaskRun. - Tekton Chains can consume the
SignedResultsVerifiedcondition 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).
-
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. -
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 aTaskRunannotation on completion. -
v1beta1removal timeline: Ifv1beta1is removed before PR 1 lands, the migration becomes trivially a deletion of the old import. Ifv1beta1removal is still some time away, the migration in PR 1 is worth doing now to keeppkg/signing/on the stable API. -
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
SignedResultsVerifiedcondition is stable.