Skip to content

feature: SPIRE init-container for automated AgentCard signing at deploy time #179

Description

@kevincogan

Feature Description

Currently, AgentCard signing is a manual, pre-deployment step: operators must generate keys, run sign-agent-card.py with --spiffe-id, and bake the signed card JSON into a ConfigMap before deploying. This works, but requires the operator to know the workload's SPIFFE ID upfront and handle key management outside the cluster. The spiffe_id in the JWS protected header is also self-asserted the signer claims an identity with no cryptographic proof.

With SPIRE available in the cluster, we can automate this entirely at deploy time using an init container that:

  1. Fetches the workload's X.509-SVID from the SPIRE Workload API
  2. Uses the SVID private key to sign the AgentCard JSON
  3. Includes the full X.509 certificate chain via the x5c JWS header parameter (RFC 7515), enabling chain-of-trust verification
  4. Writes the signed card to a shared volume for the agent container to serve

The workload's SPIFFE ID is extracted from the leaf certificate's SAN URI during verification it is no longer a self-asserted claim in the JWS protected header. The x5c chain proves the signer's identity was issued by the SPIRE CA.

This removes the manual signing workflow, eliminates self-asserted identity claims, and makes the SPIFFE ID cryptographically verifiable through the certificate chain.

Proposed Solution

Init container

Add a signing init container (Go binary) that runs before the agent container.

Example manifest snippet
initContainers:
  - name: sign-agentcard
    image: kagenti/agentcard-signer:latest  # pin to digest in production
    env:
      - name: SPIFFE_ENDPOINT_SOCKET
        value: unix:///run/spire/sockets/agent.sock
      - name: UNSIGNED_CARD_PATH
        value: /etc/agentcard/agent.json
      - name: AGENT_CARD_PATH
        value: /app/.well-known/agent.json
      - name: SIGN_TIMEOUT
        value: "30s"
    securityContext:
      runAsNonRoot: true
      readOnlyRootFilesystem: true
      allowPrivilegeEscalation: false
      capabilities:
        drop:
          - ALL
      seccompProfile:
        type: RuntimeDefault
    volumeMounts:
      - name: spire-agent-socket
        mountPath: /run/spire/sockets
        readOnly: true
      - name: unsigned-card
        mountPath: /etc/agentcard
        readOnly: true
      - name: signed-card
        mountPath: /app/.well-known
volumes:
  - name: spire-agent-socket
    csi:
      driver: "csi.spiffe.io"
      readOnly: true
  - name: unsigned-card
    configMap:
      name: weather-agent-card-unsigned
  - name: signed-card
    emptyDir:
      medium: Memory  # tmpfs signed card never touches disk
      sizeLimit: 1Mi
Init container responsibilities
  1. Read the unsigned AgentCard JSON from a ConfigMap-mounted volume (unsigned-card)
  2. Connect to the SPIRE Workload API via the Unix domain socket
  3. Fetch the workload's X.509-SVID (private key + certificate chain)
  4. Sign the card in JWS format using the SVID private key, including the full certificate chain in the x5c header (RFC 7515). The spiffe_id is not included as a separate header field it is embedded in the leaf certificate's SAN URI
  5. Write the signed card to the shared emptyDir volume (signed-card) for the agent container to serve
  6. Zero the private key material in memory (best-effort) and exit

Verification side

The operator's verification side changes significantly from Step 1. A new X5CProvider replaces the existing SecretProvider, JWKSProvider, and NoOpProvider:

  1. X5CProvider validates the x5c certificate chain against the SPIRE X.509 trust bundle, then uses the leaf public key for JWS verification
  2. The SPIFFE ID is extracted from the leaf certificate's SAN URI (cryptographically proven by the chain), not from a self-asserted JWS header field
  3. computeBinding() uses trust-domain-only validation: the operator confirms the SPIFFE ID from the cert SAN belongs to the configured trust domain no manual allowedSpiffeIDs allowlist required
  4. Legacy providers removed: SecretProvider, JWKSProvider, and NoOpProvider are all removed. Single verification path via X5CProvider with no bypass option

Key verification checks:

  • ExtKeyUsage set to x509.ExtKeyUsageAny (Go defaults to ServerAuth when nil, which would reject valid SPIRE SVIDs)
  • Leaf certificate must have exactly one SAN URI with spiffe:// scheme
  • Chain depth limited to 3 (leaf + intermediate + root)
  • Algorithm validation rejects alg: none

Trust bundle distribution

The SPIRE X.509 trust bundle (CA certificates, not JWKS) must be available to the operator for chain validation.

Recommended approach:

  • spire-controller-manager native sync (preferred): syncs the X.509 trust bundle to a Kubernetes Secret automatically
  • spiffe-helper (fallback): watches the SPIRE bundle endpoint and syncs to a Secret via kubectl

Note: SPIRE's JWKS endpoint exposes JWT-SVID signing authority keys, not X.509 CA certificates. It cannot be used to verify x5c certificate chains from X.509-SVIDs.

Key rotation

SPIRE rotates SVIDs automatically.

  • The init container re-signs the AgentCard on each pod restart, so rotation is handled naturally by the pod lifecycle
  • The operator refreshes the trust bundle periodically; on refresh failure, it continues using the last successfully-loaded bundle (fails startup if no bundle was ever loaded)

Want to contribute?

  • I would like to work on this issue.

Additional Context

Design Decision

An init container signs the card at pod startup using the workload's own SVID. Key design choices:

  • x5c certificate chain in JWS header: enables chain-of-trust verification against the SPIRE trust bundle. The SPIFFE ID is extracted from the leaf cert SAN (cryptographically proven), not self-asserted in the header
  • Trust-domain-only binding: replaces allowedSpiffeIDs allowlist. SPIRE attestation (namespace + service account selectors) is the authorization layer; the operator only validates trust domain membership as defense-in-depth
  • Single verification path (X5CProvider): all legacy providers removed (SecretProvider, JWKSProvider, NoOpProvider). No configuration path silently disables verification
  • Separate input/output volumes: unsigned card from ConfigMap, signed card to memory-backed emptyDir. Prevents the “empty emptyDir” problem from the original design
  • Explicit securityContext: compatible with OpenShift restricted SCC

Alternatives considered:

  • Sidecar (spiffe-helper)

    • Adds permanent resource overhead
    • Unnecessary since pod restarts naturally re-sign
  • Operator-side signing

    • Breaks the security model (the operator signing doesn't prove card authenticity)
  • CSI driver + agent-native signing

    • Requires agent authors to implement SPIRE/signing logic, violating the principle of keeping agents agnostic
  • JWKS endpoint for verification

    • SPIRE JWKS contains JWT-SVID signing keys, not X.509 leaf keys — cryptographically invalid for verifying X.509-SVID signatures
  • Manual allowedSpiffeIDs allowlist

    • Creates a silent dependency on ClusterSPIFFEID path conventions; SPIRE attestation already provides the authorization guarantee

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions