Skip to content

[audit-signing] Certifier client interface — default local (env key), SPIFFE Workload API backend (SPIRE/KMS-rooted, K8s) #239

Description

@initializ-mk

Context

Audit-event signing (#220) loads a static Ed25519 key from FORGE_AUDIT_SIGNING_KEY_B64 (env) or a file, in-process. That is the correct baseline: self-rooted, tamper-evident, zero external dependencies.

For fleet-scale enterprise identity + key custody, hand-registering an OIDC app per agent (inbound) and an M2M app per agent (outbound) does not scale, and Okta has no agent-native construct. The answer is SPIFFE/SPIRE: the agent consumes a short-lived SVID from the local Workload API — one credential for inbound and outbound — issued by an Initializ-managed SPIRE plane whose CA is rooted in the customer's KMS (see initializ/console-next#6). The agent never holds an Okta client or a static secret.

This issue is the forge agent-side work: a Certifier abstraction with a local default and a spiffe backend, plus the epoch record and verifier changes. Scope: Kubernetes (k8s_psat node attestation) only for this iteration.

Proposed control

Certifier interface yielding the current signing identity ({key/handle, kid, cert/epoch, notBefore, notAfter}), rotating before expiry. Two backends:

  • local (DEFAULT — unchanged). Loads/generates the key from FORGE_AUDIT_SIGNING_KEY_B64 (env), self-rooted, no network. Stays the default so tamper-evident signing has no external dependency; when unconfigured, signing is off and the wire shape is byte-identical to pre-feat(audit): Ed25519 per-event signing + JWKS endpoint #220.
  • spiffe (managed identity route). Reads the agent's SVID from the SPIFFE Workload API Unix socket (no Okta client, no secret — the local SPIRE Agent workload-attests the process via K8s selectors: ServiceAccount / namespace / pod). The SVID (or an ephemeral key certified by it) becomes the audit-signing epoch key. On each rotation:
    1. sign every event locally with the current epoch key (hot path unchanged — feat(audit): Ed25519 per-event signing + JWKS endpoint #220's SetSigner/Emit),
    2. emit an audit_key_epoch record carrying the SVID/cert + epoch pubkey, so the log is self-verifying offline,
    3. rotate when the SVID rotates.

Node churn: the SPIFFE ID is workload-scoped, not node-scoped — when the agent is rescheduled to a new node, it re-attests and receives a fresh SVID with the same SPIFFE ID, new key. That simply opens a new epoch under the same identity; the hash chain (#212) continues and verification uses point-in-time epoch validity. No identity loss, no manual re-provisioning, no broken audit trail.

VerifyAuditLog gains: resolve each event's kid -> its audit_key_epoch, verify the epoch against the SPIRE trust bundle (rooted in the customer KMS, published by the platform), and check point-in-time validity (event.ts inside the epoch window — expired epochs still verify historical events).

Acceptance criteria

  • Certifier interface with local and spiffe backends; selected via audit.signing.mode (default local).
  • local reproduces current feat(audit): Ed25519 per-event signing + JWKS endpoint #220 behavior exactly (env key, self-rooted, off-by-default, byte-identical unsigned).
  • spiffe reads the SVID from the Workload API socket (no Okta client / no static secret), signs locally, and rotates with the SVID — no per-event network call.
  • Node change produces a new epoch, not a failure: re-scheduling the agent to a new node yields the same SPIFFE ID + a new key; a new audit_key_epoch is emitted and the stream still verifies end-to-end.
  • VerifyAuditLog verifies events against the epoch + SPIRE trust bundle with point-in-time validity (an expired epoch still verifies events signed inside its window).
  • Fail-closed knob: if the Workload API/SVID is unavailable in spiffe mode, behavior is configurable (queue+retry / refuse) rather than silently emitting unsigned.

Conformance test

  • TestAuditCertifier_LocalMatchesStaticSigning
  • TestAuditCertifier_SpiffeEpochRotationVerifiesOffline (SVID rotation -> new epoch -> point-in-time verify across an expired epoch)
  • TestAuditCertifier_NodeChangeContinuesChain (same SPIFFE ID, new key, chain intact)

Out of scope

  • The managed SPIRE issuance plane + KMS UpstreamAuthority + node/workload attestation + registration-policy UX — initializ/console-next#6.
  • Non-Kubernetes node attestation (VMs / bare metal / cloud IID) — future iteration; the spiffe backend consumes the Workload API regardless, so no agent-side change is expected when VM attestors land server-side.
  • An Okta M2M client path — the Workload API replaces it; the agent holds no Okta client.
  • Identity/attestation policy itself — the agent consumes its SVID; it does not evaluate attestation.

References

Builds on #220 (Ed25519 signing) and #212 (hash chain). Platform counterpart: initializ/console-next#6 (managed SPIFFE/SPIRE rooted in customer KMS, K8s).

Metadata

Metadata

Assignees

No one assigned

    Labels

    asi10OWASP ASI10forge-coreAffects the forge-core library (runtime, security, types, llm, mcp, auth)owasp-asiOWASP Top 10 for Agentic Applications 2026 conformanceplatformInitializ Platform (governance control plane) responsibilitysecuritySecurity vulnerability fixes

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions