Skip to content

feat(spiffe): GET /v1/spiffe-bundle (Trust Domain Format)#47

Merged
kanywst merged 2 commits into
mainfrom
feat/spiffe-bundle-tdf
May 11, 2026
Merged

feat(spiffe): GET /v1/spiffe-bundle (Trust Domain Format)#47
kanywst merged 2 commits into
mainfrom
feat/spiffe-bundle-tdf

Conversation

@kanywst
Copy link
Copy Markdown
Member

@kanywst kanywst commented May 11, 2026

Summary

Adds the SPIFFE Trust Domain Format (TDF) bundle endpoint defined in SPIFFE Trust Domain and Bundle 1.0 §4. One JSON document carries both the X.509-SVID trust anchors (use: x509-svid, full DER in x5c) and the JWT-SVID public keys (use: jwt-svid) in a single JWK set, plus the spiffe_sequence and spiffe_refresh_hint envelope fields. SPIFFE-native consumers replace the pair of /v1/bundle (PEM) + /v1/jwt/bundle (JWKS) with one fetch.

The legacy endpoints stay so the existing federation pump and go-spiffe consumers do not need to change.

Example response on a fresh server:

{
  "keys": [
    {"crv":"P-256","kty":"EC","use":"x509-svid","x":"...","x5c":["MIIBdT..."],"y":"..."},
    {"alg":"ES256","crv":"P-256","kid":"BA0vwLDU7Bs","kty":"EC","use":"jwt-svid","x":"...","y":"..."}
  ],
  "spiffe_refresh_hint": 300,
  "spiffe_sequence": 1
}

Scope layer

Core. SPIFFE spec endpoint, no new dependencies. Assembly lives in identity.BuildSPIFFEBundle, a free function that reads the existing Authority.BundlePEM() + Authority.JWTBundle() accessors so the Vault PKI backend and any future Authority backend (step-ca, AWS PCA, GCP CAS) work without code changes.

Behaviour notes

  • spiffe_sequence is fixed at 1 until runtime key rotation lands. omega does not rotate roots without a restart today, so the bundle is monotonic-at-one for the lifetime of a server process. The conformance row tracks this (§6 deferred → partial).
  • spiffe_refresh_hint is configurable via the new --spiffe-bundle-refresh-hint flag (default 5m).
  • Both EC (P-256/P-384/P-521) and RSA trust anchors are supported on the X.509 side. Unsupported key types surface a clear error rather than being silently dropped, so peers either get a full picture or a clear failure.

Test plan

  • go test -race -count=1 ./internal/server/identity/ ./internal/server/api/ — passes
  • New tests: TestBuildSPIFFEBundleEmitsTDFShape, TestBuildSPIFFEBundleSurfacesMalformedTrustAnchor, TestBuildSPIFFEBundleRefreshHintClampsNegative, TestSPIFFEBundleReturnsTDFDocument
  • go vet ./... clean
  • redocly lint api/openapi.yaml0 error(s)
  • markdownlint-cli2 CHANGELOG.md docs/conformance-spiffe.md0 error(s)
  • Local smoke test: omega servercurl /v1/spiffe-bundle returns the document above

Conformance moves

  • docs/conformance-spiffe.md §4.1: partial → implemented
  • docs/conformance-spiffe.md §6: deferred → partial (sequence becomes "implemented" once key rotation lands)

Follow-ups

  • Monotonic spiffe_sequence lands with runtime key rotation.
  • Federation pump could switch from /v1/bundle PEM polling to /v1/spiffe-bundle to honour spiffe_refresh_hint natively (out of scope here).

Adds the SPIFFE Trust Domain Format (TDF) bundle endpoint defined in
SPIFFE Trust Domain and Bundle 1.0 §4. The document carries both the
X.509-SVID trust anchors and the JWT-SVID public keys in one JWK set,
each tagged with `use` so a single fetch replaces the pair of
`/v1/bundle` (PEM) + `/v1/jwt/bundle` (JWKS) for SPIFFE-native
consumers. The legacy endpoints stay for backwards compatibility with
the existing federation pump and `go-spiffe` consumers.

Assembly lives in `identity.BuildSPIFFEBundle`, a free function that
reads the existing `BundlePEM()` + `JWTBundle()` accessors. Adding a
new CA backend (Vault PKI today; step-ca / AWS PCA / GCP CAS later)
does not need to touch this code path.

`spiffe_sequence` is fixed at 1 until runtime key rotation lands -
omega does not rotate roots without a restart today, so the bundle is
monotonic-at-one for the lifetime of a server process. The
`spiffe_refresh_hint` is configurable via the new
`--spiffe-bundle-refresh-hint` flag (default `5m`).

Updates `docs/conformance-spiffe.md`: §4.1 (partial → implemented) and
§6 (deferred → partial). OpenAPI gets the route and schema.
@coderabbitai

This comment was marked as resolved.

gemini-code-assist[bot]

This comment was marked as resolved.

gemini flagged Params().Name string comparison as a soft footgun:
elliptic.P256/P384/P521 return process-wide singletons, so identity
comparison is both faster and harder to spoof than checking the
human-readable curve name. Switch to the interface-equality pattern,
keep Params().Name only for the error message where a unique name for
the unsupported curve is the whole point.
@kanywst kanywst merged commit 031615e into main May 11, 2026
24 checks passed
@kanywst kanywst deleted the feat/spiffe-bundle-tdf branch May 11, 2026 17:30
kanywst added a commit that referenced this pull request May 12, 2026
Federation pump now prefers the SPIFFE Trust Domain Format endpoint
shipped on the producer side in the spiffe-bundle PR (#47), with a
PEM fallback so a freshly-built omega still federates with peers
older than that endpoint.

  fetchPeer
    ├── try GET /v1/spiffe-bundle  →  parse via go-spiffe v2's
    │                                  spiffebundle.Read (same code
    │                                  path a SPIRE agent walks),
    │                                  extract X.509 anchors +
    │                                  refresh_hint
    └── on 404                     →  fall back to GET /v1/bundle
                                       (PEM), and now parse every
                                       CERTIFICATE block as
                                       defence-in-depth so a
                                       malformed peer is rejected

The peer-supplied `spiffe_refresh_hint` drives the effective
per-round poll interval as
  effective = min(operator-configured-refresh, min-peer-hint)
clamped to [10s, 1h]. The Run loop swaps `time.NewTicker` for a
`time.After(EffectiveRefresh())` loop so the cadence can be
re-derived after every round without Ticker.Reset gymnastics.

Tests cover: TDF round-trip (with the test fixture built via
spiffebundle.FromX509Authorities + Marshal so the on-the-wire bytes
match what the SDK accepts on consume), PEM fallback when TDF
returns 404, unreachable peer is omitted from the merged map, and
malformed PEM is rejected by the new defence-in-depth parse.

Moves conformance-spiffe.md §6 (sequence / refresh hints): still
`partial` because spiffe_sequence stays at 1 until key rotation
lands, but the note now reflects that consumption of the hint is in
place too.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant