Skip to content
Draft
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package org.cloudfoundry.identity.uaa.spiffe;

import org.bouncycastle.asn1.x500.AttributeTypeAndValue;
import org.bouncycastle.asn1.x500.RDN;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x500.style.BCStyle;
import org.bouncycastle.asn1.x500.style.IETFUtils;
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;

import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;

/** Extracts CF org/space/app GUIDs from the OU attributes of an instance cert subject. */
@Component
@ConditionalOnProperty(prefix = "uaa.spiffe", name = "instance-identity-ca")
public class CertificateOuParser {

private static final String ORG_PREFIX = "organization:";
private static final String SPACE_PREFIX = "space:";
private static final String APP_PREFIX = "app:";

public CfInstanceIdentity parse(X509Certificate certificate) {
String org = null;
String space = null;
String app = null;

X500Name subject;
try {
subject = new JcaX509CertificateHolder(certificate).getSubject();
} catch (CertificateEncodingException e) {
throw new IllegalArgumentException("Unable to read certificate subject", e);
}

for (RDN rdn : subject.getRDNs()) {
for (AttributeTypeAndValue typeAndValue : rdn.getTypesAndValues()) {
if (!BCStyle.OU.equals(typeAndValue.getType())) {
continue;
}
String ou = IETFUtils.valueToString(typeAndValue.getValue());
if (ou.startsWith(ORG_PREFIX)) {
org = ou.substring(ORG_PREFIX.length());
} else if (ou.startsWith(SPACE_PREFIX)) {
space = ou.substring(SPACE_PREFIX.length());
} else if (ou.startsWith(APP_PREFIX)) {
app = ou.substring(APP_PREFIX.length());
}
}
}

require(org, "organization");
require(space, "space");
require(app, "app");
return new CfInstanceIdentity(org, space, app);
}

private static void require(String value, String name) {
if (value == null || value.isEmpty()) {
throw new IllegalArgumentException("Certificate is missing the '" + name + "' OU attribute");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.cloudfoundry.identity.uaa.spiffe;

/** Org/space/app GUIDs extracted from a Diego instance-identity certificate. */
public record CfInstanceIdentity(String orgId, String spaceId, String appId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package org.cloudfoundry.identity.uaa.spiffe;

import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;

import java.security.cert.X509Certificate;

/** Verifies an instance cert chains to the configured CA and is currently time-valid. */
@Component
@ConditionalOnProperty(prefix = "uaa.spiffe", name = "instance-identity-ca")
public class InstanceIdentityVerifier {

private final X509Certificate caCertificate;

public InstanceIdentityVerifier(@Qualifier("spiffeInstanceIdentityCa") X509Certificate caCertificate) {
this.caCertificate = caCertificate;
}

/** @throws InvalidInstanceCertificateException if the cert is untrusted or not time-valid. */
public void verify(X509Certificate certificate) {
try {
certificate.checkValidity();
certificate.verify(caCertificate.getPublicKey(), BouncyCastleFipsProvider.PROVIDER_NAME);
} catch (Exception e) {
throw new InvalidInstanceCertificateException(e.getMessage(), e);
}
}

public boolean isValid(X509Certificate certificate) {
try {
verify(certificate);
return true;
} catch (InvalidInstanceCertificateException e) {
return false;
}
}

public static class InvalidInstanceCertificateException extends RuntimeException {
public InvalidInstanceCertificateException(String message, Throwable cause) {
super(message, cause);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package org.cloudfoundry.identity.uaa.spiffe;

import org.cloudfoundry.identity.uaa.util.KeyWithCert;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.regex.Pattern;

/** Issues UAA-signed JWT-SVIDs to authenticated Diego workload attestors. */
@RestController
@ConditionalOnProperty(prefix = "uaa.spiffe", name = "instance-identity-ca")
public class JwtSvidController {

private static final Pattern PROCESS_TYPE_PATTERN = Pattern.compile("[a-zA-Z0-9_-]{1,63}");
private static final int MAX_AUDIENCE_LENGTH = 512;

private final CertificateOuParser ouParser;
private final InstanceIdentityVerifier identityVerifier;
private final ProofOfPossessionVerifier popVerifier;
private final JwtSvidSigner signer;
private final SpiffeProperties properties;

public JwtSvidController(CertificateOuParser ouParser,
InstanceIdentityVerifier identityVerifier,
ProofOfPossessionVerifier popVerifier,
JwtSvidSigner signer,
SpiffeProperties properties) {
this.ouParser = ouParser;
this.identityVerifier = identityVerifier;
this.popVerifier = popVerifier;
this.signer = signer;
this.properties = properties;
}

@PostMapping(value = "/jwt-svid/sign", consumes = "application/json", produces = "application/json")
public JwtSvidResponse sign(@RequestBody JwtSvidRequest request) {
validateRequest(request);
X509Certificate certificate = parseCertificate(request.instanceCertificate());
identityVerifier.verify(certificate);

CfInstanceIdentity identity = ouParser.parse(certificate);
String spiffeId = SpiffeId.format(properties.trustDomain(), identity, request.processType());

if (!popVerifier.isValid(certificate, spiffeId, request.audience(),
request.timestamp(), request.popSignature())) {
throw new UnauthorizedRequestException("Proof-of-possession verification failed");
}

JwtSvidSigner.JwtSvidResult result =
signer.sign(spiffeId, identity, request.processType(), request.audience());
return new JwtSvidResponse(result.svid(), result.spiffeId(), result.expiresAt());
}

private static X509Certificate parseCertificate(String pem) {
try {
return new KeyWithCert(pem).getCertificate();
} catch (CertificateException e) {
throw new BadSvidRequestException("Unable to read instance certificate");
}
}

/**
* Rejects structurally dangerous input before it is concatenated into the SPIFFE ID
* path ({@code process_type}) or the proof-of-possession message ({@code audience}).
* Constraining both fields to printable, newline-free values keeps the signed PoP
* message ({@code spiffeId \n audience \n timestamp}) unambiguous.
*/
private static void validateRequest(JwtSvidRequest request) {
String processType = request.processType();
if (processType == null || !PROCESS_TYPE_PATTERN.matcher(processType).matches()) {
throw new BadSvidRequestException("process_type must match [A-Za-z0-9_-]{1,63}");
}
String audience = request.audience();
if (audience == null || audience.isBlank()
|| audience.length() > MAX_AUDIENCE_LENGTH
|| containsControlCharacter(audience)) {
throw new BadSvidRequestException(
"audience must be non-blank, at most " + MAX_AUDIENCE_LENGTH
+ " characters, and free of control characters");
}
}

private static boolean containsControlCharacter(String value) {
return value.chars().anyMatch(Character::isISOControl);
}

@ResponseStatus(HttpStatus.UNAUTHORIZED)
public static class UnauthorizedRequestException extends RuntimeException {
public UnauthorizedRequestException(String message) {
super(message);
}
}

@ResponseStatus(HttpStatus.BAD_REQUEST)
public static class BadSvidRequestException extends RuntimeException {
public BadSvidRequestException(String message) {
super(message);
}
}

/** Maps verifier/parser failures to HTTP status codes. */
@ControllerAdvice
public static class ExceptionHandling {

@ExceptionHandler(InstanceIdentityVerifier.InvalidInstanceCertificateException.class)
public ResponseEntity<String> handleUntrustedCertificate(
InstanceIdentityVerifier.InvalidInstanceCertificateException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<String> handleBadInput(IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.cloudfoundry.identity.uaa.spiffe;

import com.fasterxml.jackson.annotation.JsonProperty;

/** Request body for {@code POST /jwt-svid/sign}. */
public record JwtSvidRequest(
@JsonProperty("instance_certificate") String instanceCertificate,
@JsonProperty("process_type") String processType,
@JsonProperty("audience") String audience,
@JsonProperty("timestamp") long timestamp,
@JsonProperty("pop_signature") String popSignature) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.cloudfoundry.identity.uaa.spiffe;

import com.fasterxml.jackson.annotation.JsonProperty;

/** Response body for {@code POST /jwt-svid/sign}. */
public record JwtSvidResponse(
@JsonProperty("svid") String svid,
@JsonProperty("spiffe_id") String spiffeId,
@JsonProperty("expires_at") long expiresAt) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package org.cloudfoundry.identity.uaa.spiffe;

import org.cloudfoundry.identity.uaa.oauth.KeyInfo;
import org.cloudfoundry.identity.uaa.oauth.KeyInfoService;
import org.cloudfoundry.identity.uaa.oauth.TokenEndpointBuilder;
import org.cloudfoundry.identity.uaa.oauth.jwt.JwtHelper;
import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;

import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import static org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants.AUD;
import static org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants.EXPIRY_IN_SECONDS;
import static org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants.IAT;
import static org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants.ISS;
import static org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants.JTI;
import static org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants.SUB;

/** Builds and signs a JWT-SVID with UAA's active token-signing key. */
@Component
@ConditionalOnProperty(prefix = "uaa.spiffe", name = "instance-identity-ca")
public class JwtSvidSigner {

private final KeyInfoService keyInfoService;
private final TokenEndpointBuilder tokenEndpointBuilder;
private final SpiffeProperties properties;

public JwtSvidSigner(KeyInfoService keyInfoService,
TokenEndpointBuilder tokenEndpointBuilder,
SpiffeProperties properties) {
this.keyInfoService = keyInfoService;
this.tokenEndpointBuilder = tokenEndpointBuilder;
this.properties = properties;
}

public record JwtSvidResult(String svid, String spiffeId, long expiresAt) {
}

public JwtSvidResult sign(String spiffeId, CfInstanceIdentity identity, String processType, String audience) {
long iat = Instant.now().getEpochSecond();
long exp = iat + properties.jwtSvidTtlSeconds();

Map<String, Object> cf = new LinkedHashMap<>();
cf.put("org_id", identity.orgId());
cf.put("space_id", identity.spaceId());
cf.put("app_id", identity.appId());
cf.put("process_type", processType);

Map<String, Object> claims = new LinkedHashMap<>();
claims.put(ISS, tokenEndpointBuilder.getTokenEndpoint(IdentityZoneHolder.get()));
claims.put(SUB, spiffeId);
claims.put(AUD, List.of(audience));
claims.put(IAT, iat);
claims.put(EXPIRY_IN_SECONDS, exp);
claims.put(JTI, UUID.randomUUID().toString());
claims.put("cf", cf);

KeyInfo activeKey = keyInfoService.getActiveKey();
String svid = JwtHelper.encode(claims, activeKey).getEncoded();
return new JwtSvidResult(svid, spiffeId, exp);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package org.cloudfoundry.identity.uaa.spiffe;

import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;

import java.nio.charset.StandardCharsets;
import java.security.PublicKey;
import java.security.Signature;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.Base64;

/** Verifies the workload's proof-of-possession of the instance private key. */
@Component
@ConditionalOnProperty(prefix = "uaa.spiffe", name = "instance-identity-ca")
public class ProofOfPossessionVerifier {

private final SpiffeProperties properties;

public ProofOfPossessionVerifier(SpiffeProperties properties) {
this.properties = properties;
}

public boolean isValid(X509Certificate certificate, String spiffeId, String audience,
long timestamp, String base64Signature) {
if (!properties.popEnabled()) {
return true;
}
if (Math.abs(Instant.now().getEpochSecond() - timestamp) > properties.popFreshnessSeconds()) {
return false;
}
String message = spiffeId + "\n" + audience + "\n" + timestamp;
try {
Signature signature = Signature.getInstance(
algorithmFor(certificate.getPublicKey()), BouncyCastleFipsProvider.PROVIDER_NAME);
signature.initVerify(certificate.getPublicKey());
signature.update(message.getBytes(StandardCharsets.UTF_8));
return signature.verify(Base64.getDecoder().decode(base64Signature));
} catch (Exception e) {
return false;
}
}

private static String algorithmFor(PublicKey publicKey) {
return switch (publicKey.getAlgorithm()) {
case "EC" -> "SHA256withECDSA";
case "RSA" -> "SHA256withRSA";
default -> throw new IllegalArgumentException("Unsupported key type: " + publicKey.getAlgorithm());
};
}
}
Loading
Loading