Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion charts/certo/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ apiVersion: v2
name: certo
description: Certo - certificate exchange service (CX-0135)
type: application
version: 0.1.0
version: 0.0.4
appVersion: "0.1.0"
46 changes: 44 additions & 2 deletions charts/certo/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ spec:
env:
- name: SPRING_PROFILES_ACTIVE
value: {{ .Values.springProfile | quote }}
{{- if .Values.debug.enabled }}
- name: JAVA_TOOL_OPTIONS
value: "-agentlib:jdwp=transport=dt_socket,server=y,suspend={{ ternary "y" "n" .Values.debug.suspend }},address=*:{{ .Values.debug.port }}"
{{- end }}
- name: CERTO_DB_URL
value: {{ .Values.database.url | quote }}
- name: CERTO_DB_USER
Expand All @@ -84,6 +88,24 @@ spec:
- name: CERTO_SECURITY_SIGLETBASEURL
value: {{ .Values.sigletBaseUrl | quote }}
{{- end }}
{{- if .Values.tokenExchange.enabled }}
- name: CERTO_SECURITY_TOKENEXCHANGE_ENABLED
value: "true"
- name: CERTO_SECURITY_TOKENEXCHANGE_URL
value: {{ .Values.tokenExchange.url | quote }}
- name: CERTO_SECURITY_TOKENEXCHANGE_SCOPE
value: {{ .Values.tokenExchange.scope | quote }}
- name: CERTO_SECURITY_TOKENEXCHANGE_AUDIENCE
value: {{ .Values.tokenExchange.audience | quote }}
- name: CERTO_SECURITY_TOKENEXCHANGE_VERIFYRESOURCE
value: {{ .Values.tokenExchange.verifyResource | quote }}
{{- if .Values.tokenExchange.subjectTokenAudience }}
# Projected ServiceAccount token minted for the broker's expected audience; the default
# auto-mounted token is addressed to the API server and its TokenReview would reject it.
- name: CERTO_SECURITY_TOKENEXCHANGE_SUBJECTTOKENPATH
value: /var/run/secrets/certo/token-exchange/token
{{- end }}
{{- end }}
{{- if .Values.events.nats.enabled }}
- name: CERTO_EVENTS_NATS_ENABLED
value: "true"
Expand Down Expand Up @@ -113,20 +135,40 @@ spec:
readinessProbe:
tcpSocket:
port: http
{{- if and .Values.events.nats.enabled .Values.natsAuth.enabled }}
{{- if or (and .Values.events.nats.enabled .Values.natsAuth.enabled) (and .Values.tokenExchange.enabled .Values.tokenExchange.subjectTokenAudience) }}
volumeMounts:
{{- if and .Values.events.nats.enabled .Values.natsAuth.enabled }}
- name: nats-nkey
mountPath: /vault/secrets
readOnly: true
{{- end }}
{{- if and .Values.tokenExchange.enabled .Values.tokenExchange.subjectTokenAudience }}
- name: token-exchange-sa-token
mountPath: /var/run/secrets/certo/token-exchange
readOnly: true
{{- end }}
{{- end }}
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if and .Values.events.nats.enabled .Values.natsAuth.enabled }}
{{- if or (and .Values.events.nats.enabled .Values.natsAuth.enabled) (and .Values.tokenExchange.enabled .Values.tokenExchange.subjectTokenAudience) }}
volumes:
{{- if and .Values.events.nats.enabled .Values.natsAuth.enabled }}
# Pod-private in-memory volume the NKey seed is delivered on (never hits disk)
- name: nats-nkey
emptyDir:
medium: Memory
{{- end }}
{{- if and .Values.tokenExchange.enabled .Values.tokenExchange.subjectTokenAudience }}
# ServiceAccount token minted for the token-exchange broker's audience, which it checks with a
# TokenReview. The kubelet rotates it in place, so certo re-reads the file on every exchange.
- name: token-exchange-sa-token
projected:
sources:
- serviceAccountToken:
path: token
audience: {{ .Values.tokenExchange.subjectTokenAudience | quote }}
expirationSeconds: 3600
{{- end }}
{{- end }}
41 changes: 41 additions & 0 deletions charts/certo/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ service:
type: ClusterIP
port: 8080

# Remote JVM debugging (JDWP). When enabled the container starts the debug agent (via
# JAVA_TOOL_OPTIONS, honored by any JVM launcher) and exposes the port on the pod only —
# deliberately not on the Service. Attach with
# kubectl port-forward deploy/<fullname> 5005:5005
# and an IDE "Remote JVM Debug" run configuration pointed at localhost:5005.
debug:
enabled: false
port: 5005
# Suspend the JVM until a debugger attaches (to step through startup code). While suspended the
# app cannot answer health probes, so enabling this also disables liveness/readiness probes.
suspend: false


# Gateway API: when `name` is set, the chart renders an HTTPRoute binding the service to
# that Gateway; when empty, no HTTPRoute is created.
gateway:
Expand All @@ -33,6 +46,34 @@ springProfile: prod
# layer; point it at a mock siglet for dev/test.
sigletBaseUrl: ""

# Authentication for the calls certo makes TO siglet — both the inbound POST /tokens/verify and the
# outbound token lookup. OFF by default, leaving those calls unauthenticated.
#
# When enabled, each siglet call first runs an RFC 8693 token exchange at `url`, sending the pod's
# Kubernetes ServiceAccount JWT as subject_token and getting back a short-lived bearer for siglet.
# The exchange's `resource` is the participant context the token is for: the outbound lookup passes
# its own participantContextId, while /tokens/verify is not bound to a tenant and uses
# `verifyResource`.
#
# `url`, `scope`, `audience` and `verifyResource` are all required once enabled — certo fails at
# startup if one is missing.
tokenExchange:
enabled: false
# The broker's RFC 8693 token endpoint, e.g. http://jwtlet:8080/token
url: ""
# Space-separated scopes requested for the exchanged token. Must be a subset of what the broker's
# resource mapping grants this ServiceAccount, or the exchange is rejected.
scope: ""
# The `aud` requested for the exchanged token — the siglet it is presented to.
audience: ""
# The participant context used as `resource` for the tenant-independent /tokens/verify call.
verifyResource: ""
# Audience of the ServiceAccount token sent as subject_token. The broker verifies it with a
# TokenReview against its own configured client audience, so the default auto-mounted token
# (addressed to the API server) is normally rejected. Setting this mounts a projected token minted
# for the given audience and points certo at it; leaving it empty falls back to the default mount.
subjectTokenAudience: ""

# Certificate-exchange status changes (CX-0135 §2.1.3) published as CloudEvents on NATS JetStream,
# on subjects events.certificate.exchange.* — the same shape and stream the platform's EDC runtimes
# publish to, so consumers of `edc-events` see certo's events alongside the connector's.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.metaform.certo.common.security;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

/**
* Configuration for the CCM protocol security-token layer. Security is <b>always on</b> and tokens always
Expand All @@ -13,7 +14,34 @@
* DID), resolved to a participant context after verification.
*
* @param sigletBaseUrl base URL of the siglet STS ({@code /tokens/verify}, {@code /tokens/{pcid}/{flowId}})
* @param tokenExchange how calls to that siglet authenticate themselves; off by default
*/
@ConfigurationProperties(prefix = "certo.security")
public record SecurityProperties(String sigletBaseUrl) {
public record SecurityProperties(String sigletBaseUrl, @DefaultValue TokenExchange tokenExchange) {

/**
* RFC 8693 token exchange against the siglet's token broker. When {@code enabled}, every call this
* runtime makes to siglet first exchanges the pod's Kubernetes ServiceAccount token for a short-lived
* access token and sends it as the bearer; when disabled the siglet calls stay unauthenticated.
*
* <p>The exchange's {@code resource} names the participant context the token is requested for. Outbound
* calls are per-tenant so they pass their own {@code participantContextId}; the inbound verification call
* is not bound to any tenant, so it uses the configured {@code verifyResource}.
*
* @param enabled whether siglet calls authenticate at all
* @param url the broker's token endpoint (e.g. {@code https://jwtlet:8080/token})
* @param scope space-separated scopes requested for the exchanged token
* @param audience the {@code aud} requested for the exchanged token (siglet)
* @param verifyResource the {@code resource} used by the tenant-independent {@code /tokens/verify} call
* @param subjectTokenPath file holding the Kubernetes ServiceAccount JWT sent as {@code subject_token};
* re-read on every exchange because the kubelet rotates a projected token in place
*/
public record TokenExchange(@DefaultValue("false") boolean enabled,
String url,
String scope,
String audience,
String verifyResource,
@DefaultValue("/var/run/secrets/kubernetes.io/serviceaccount/token")
String subjectTokenPath) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package org.metaform.certo.common.security.exchange;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import okhttp3.FormBody;
import okhttp3.Request;
import org.metaform.certo.common.http.RetryingHttpClient;
import org.metaform.certo.common.security.SecurityProperties;
import org.metaform.certo.common.web.ApiException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import tools.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;

/**
* Mints the bearer this runtime sends to siglet, by exchanging the pod's Kubernetes ServiceAccount token at
* the broker's RFC 8693 endpoint ({@code POST /token}, form-encoded, grant type
* {@code urn:ietf:params:oauth:grant-type:token-exchange}). The {@code resource} names the participant
* context the token is requested for — the caller supplies it, because it differs per siglet call site.
*
* <p>Both siglet call sites share this one component. When
* {@code certo.security.token-exchange.enabled} is false every exchange returns {@link Optional#empty()} and
* the siglet calls go out unauthenticated, exactly as before the feature existed.
*
* <p>Nothing is cached: an exchange runs per siglet call, and the subject token is re-read from disk each
* time because the kubelet rotates a projected ServiceAccount token in place.
*/
@Component
public class TokenExchangeClient {

private static final Logger LOG = LoggerFactory.getLogger(TokenExchangeClient.class);

private static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange";
private static final String SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt";

private final RetryingHttpClient http;
private final ObjectMapper mapper;
private final SecurityProperties.TokenExchange config;

public TokenExchangeClient(SecurityProperties properties, RetryingHttpClient http, ObjectMapper mapper) {
this.http = http;
this.mapper = mapper;
this.config = properties.tokenExchange();
if (config.enabled()) {
// Fail fast: a half-configured exchange would otherwise surface as a 502 on the first
// protocol call rather than at startup.
require(config.url(), "url");
require(config.scope(), "scope");
require(config.audience(), "audience");
require(config.verifyResource(), "verify-resource");
require(config.subjectTokenPath(), "subject-token-path");
}
}

private static void require(String value, String key) {
if (value == null || value.isBlank()) {
throw new IllegalStateException(
"certo.security.token-exchange." + key + " must be set when token exchange is enabled");
}
}

/** Whether siglet calls authenticate at all. */
public boolean enabled() {
return config.enabled();
}

/** The {@code resource} the tenant-independent {@code /tokens/verify} call exchanges for. */
public String verifyResource() {
return config.verifyResource();
}

/**
* Exchanges the subject token for an access token scoped to {@code resource}, or empty when token
* exchange is disabled. Throws {@link ApiException} with {@code 502} when the broker cannot be reached or
* rejects the exchange — an infrastructure failure, not a caller error.
*/
public Optional<String> accessTokenFor(String resource) {
if (!config.enabled()) {
return Optional.empty();
}
var form = new FormBody.Builder()
.add("grant_type", GRANT_TYPE)
.add("subject_token", subjectToken())
.add("subject_token_type", SUBJECT_TOKEN_TYPE)
.add("resource", resource)
.add("scope", config.scope())
.add("audience", config.audience())
.build();
var request = new Request.Builder().url(config.url()).post(form).build();
try (var response = http.execute(request)) {
if (!response.isSuccessful()) {
throw new ApiException(HttpStatus.BAD_GATEWAY,
"Token exchange returned HTTP " + response.code() + " for resource " + resource);
}
var body = response.body() == null ? "" : response.body().string();
var parsed = mapper.readValue(body, TokenExchangeResponse.class);
if (parsed.accessToken() == null || parsed.accessToken().isBlank()) {
throw new ApiException(HttpStatus.BAD_GATEWAY,
"Token exchange response for resource " + resource + " is missing an access_token");
}
return Optional.of(parsed.accessToken());
} catch (IOException e) {
// Log the connectivity detail (broker host/port) server-side only, as the siglet clients do.
LOG.warn("Could not reach the token-exchange broker: {}", e.getMessage());
throw new ApiException(HttpStatus.BAD_GATEWAY,
"Could not obtain a token-exchange token for resource " + resource);
}
}

/** Reads the ServiceAccount JWT afresh — a projected token is rotated in place by the kubelet. */
private String subjectToken() {
try {
var token = Files.readString(Path.of(config.subjectTokenPath())).strip();
if (token.isEmpty()) {
throw new ApiException(HttpStatus.BAD_GATEWAY,
"The token-exchange subject token is empty");
}
return token;
} catch (IOException e) {
// The path is deployment topology; keep it out of the response.
LOG.warn("Could not read the token-exchange subject token from {}: {}",
config.subjectTokenPath(), e.getMessage());
throw new ApiException(HttpStatus.BAD_GATEWAY, "Could not read the token-exchange subject token");
}
}

@JsonIgnoreProperties(ignoreUnknown = true)
private record TokenExchangeResponse(@JsonProperty("access_token") String accessToken) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
import okhttp3.MediaType;
import okhttp3.Request;
import okhttp3.RequestBody;
import org.metaform.certo.common.http.OutboundJsonClient;
import org.metaform.certo.common.http.RetryingHttpClient;
import org.metaform.certo.common.pc.store.ParticipantContextStore;
import org.metaform.certo.common.security.exchange.TokenExchangeClient;
import org.metaform.certo.common.web.ApiException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -30,8 +32,13 @@
*
* <p>The endpoint requires an {@code audience} in the request; it is read from the (unverified) token
* locally — the local read only names the tenant DID to check against, while siglet remains the authority on
* the signature. This runtime does not send a caller JWT, matching {@link SigletTokenSource}'s assumption
* that siglet's token-API auth is disabled for this deployment.
* the signature.
*
* <p>When token exchange is enabled the verify call itself authenticates with a bearer from
* {@link TokenExchangeClient}. Unlike {@link SigletTokenSource}, verification is not bound to a participant
* context — the tenant is only known <em>after</em> siglet answers — so the RFC 8693 {@code resource} comes
* from configuration ({@code certo.security.token-exchange.verify-resource}) rather than from the call. With
* exchange disabled no caller JWT is sent, as before.
*/
@Component
public class SigletTokenVerifier implements SecurityTokenVerifier {
Expand All @@ -42,13 +49,15 @@ public class SigletTokenVerifier implements SecurityTokenVerifier {
private final RetryingHttpClient http;
private final ObjectMapper mapper;
private final ParticipantContextStore contexts;
private final TokenExchangeClient exchange;
private final String verifyUrl;

public SigletTokenVerifier(SecurityProperties properties, ParticipantContextStore contexts,
RetryingHttpClient http, ObjectMapper mapper) {
RetryingHttpClient http, ObjectMapper mapper, TokenExchangeClient exchange) {
this.contexts = contexts;
this.http = http;
this.mapper = mapper;
this.exchange = exchange;
var base = properties.sigletBaseUrl();
if (base == null || base.isBlank()) {
throw new IllegalStateException("certo.security.siglet-base-url must be set for the siglet backend");
Expand Down Expand Up @@ -97,8 +106,10 @@ private Map<String, Object> validateWithSiglet(String token, String audience) {
throw new ApiException(HttpStatus.BAD_GATEWAY, "Could not serialize siglet verify request");
}
var request = new Request.Builder().url(verifyUrl)
.post(RequestBody.create(requestBody, JSON)).build();
try (var response = http.execute(request)) {
.post(RequestBody.create(requestBody, JSON));
exchange.accessTokenFor(exchange.verifyResource())
.ifPresent(bearer -> OutboundJsonClient.authorize(request, bearer));
try (var response = http.execute(request.build())) {
if (response.code() == HttpStatus.UNAUTHORIZED.value()) {
throw ApiException.unauthorized("Invalid security token: rejected by siglet (expired, revoked, or bad signature)");
}
Expand Down
Loading
Loading