diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/CertificateOuParser.java b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/CertificateOuParser.java new file mode 100644 index 00000000000..9e9b843ca8e --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/CertificateOuParser.java @@ -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"); + } + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/CfInstanceIdentity.java b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/CfInstanceIdentity.java new file mode 100644 index 00000000000..4dfb2296388 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/CfInstanceIdentity.java @@ -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) { +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/InstanceIdentityVerifier.java b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/InstanceIdentityVerifier.java new file mode 100644 index 00000000000..573c252bebc --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/InstanceIdentityVerifier.java @@ -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); + } + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidController.java b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidController.java new file mode 100644 index 00000000000..27aed663281 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidController.java @@ -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 handleUntrustedCertificate( + InstanceIdentityVerifier.InvalidInstanceCertificateException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage()); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleBadInput(IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + } + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidRequest.java b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidRequest.java new file mode 100644 index 00000000000..0621997e3dc --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidRequest.java @@ -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) { +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidResponse.java b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidResponse.java new file mode 100644 index 00000000000..f1a3f9f2bc5 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidResponse.java @@ -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) { +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidSigner.java b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidSigner.java new file mode 100644 index 00000000000..0afed29efee --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidSigner.java @@ -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 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 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); + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/ProofOfPossessionVerifier.java b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/ProofOfPossessionVerifier.java new file mode 100644 index 00000000000..21f7a3bb1ba --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/ProofOfPossessionVerifier.java @@ -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()); + }; + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeConfiguration.java new file mode 100644 index 00000000000..6f9e34416c7 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeConfiguration.java @@ -0,0 +1,26 @@ +package org.cloudfoundry.identity.uaa.spiffe; + +import org.cloudfoundry.identity.uaa.util.KeyWithCert; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +/** Enables SPIFFE properties and exposes the parsed instance-identity CA certificate. */ +@Configuration +@EnableConfigurationProperties(SpiffeProperties.class) +@ConditionalOnProperty(prefix = "uaa.spiffe", name = "instance-identity-ca") +public class SpiffeConfiguration { + + @Bean("spiffeInstanceIdentityCa") + public X509Certificate spiffeInstanceIdentityCa(SpiffeProperties properties) { + try { + return new KeyWithCert(properties.instanceIdentityCa()).getCertificate(); + } catch (CertificateException e) { + throw new IllegalStateException("Invalid uaa.spiffe.instance_identity_ca", e); + } + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeId.java b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeId.java new file mode 100644 index 00000000000..d719039bdf6 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeId.java @@ -0,0 +1,16 @@ +package org.cloudfoundry.identity.uaa.spiffe; + +/** Pure formatter for CF workload SPIFFE IDs. */ +public final class SpiffeId { + + private SpiffeId() { + } + + public static String format(String trustDomain, CfInstanceIdentity identity, String processType) { + return "spiffe://" + trustDomain + + "/cf/org/" + identity.orgId() + + "/space/" + identity.spaceId() + + "/app/" + identity.appId() + + "/process/" + processType; + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeProperties.java b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeProperties.java new file mode 100644 index 00000000000..9c2afa967a4 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeProperties.java @@ -0,0 +1,28 @@ +package org.cloudfoundry.identity.uaa.spiffe; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Binds {@code uaa.spiffe.*} from uaa.yml. Relaxed binding maps snake_case keys + * (e.g. {@code uaa.spiffe.trust_domain}) to these record components. + */ +@ConfigurationProperties(prefix = "uaa.spiffe") +public record SpiffeProperties( + String trustDomain, + String instanceIdentityCa, + Long jwtSvidTtlSeconds, + Integer popFreshnessSeconds, + Boolean popEnabled +) { + public SpiffeProperties { + if (jwtSvidTtlSeconds == null) { + jwtSvidTtlSeconds = 3600L; + } + if (popFreshnessSeconds == null) { + popFreshnessSeconds = 60; + } + if (popEnabled == null) { + popEnabled = true; + } + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeSecurityConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeSecurityConfiguration.java new file mode 100644 index 00000000000..781d56ba0f4 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeSecurityConfiguration.java @@ -0,0 +1,68 @@ +package org.cloudfoundry.identity.uaa.spiffe; + +import org.cloudfoundry.identity.uaa.authentication.ClientBasicAuthenticationFilter; +import org.cloudfoundry.identity.uaa.oauth.provider.error.OAuth2AccessDeniedHandler; +import org.cloudfoundry.identity.uaa.oauth.provider.error.OAuth2AuthenticationEntryPoint; +import org.cloudfoundry.identity.uaa.web.FilterChainOrder; +import org.cloudfoundry.identity.uaa.web.UaaFilterChain; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AnonymousConfigurer; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; + +/** Protects {@code /jwt-svid/**} with client HTTP-Basic auth requiring the uaa.resource authority. */ +@Configuration +@EnableWebSecurity +@ConditionalOnProperty(prefix = "uaa.spiffe", name = "instance-identity-ca") +public class SpiffeSecurityConfiguration { + + @Autowired + @Qualifier("basicAuthenticationEntryPoint") + OAuth2AuthenticationEntryPoint basicAuthenticationEntryPoint; + + @Autowired + @Qualifier("clientAuthenticationManager") + AuthenticationManager clientAuthenticationManager; + + @Autowired + @Qualifier("oauthAccessDeniedHandler") + OAuth2AccessDeniedHandler oauthAccessDeniedHandler; + + @Autowired + @Qualifier("clientAuthenticationFilter") + FilterRegistrationBean clientAuthenticationFilter; + + @Bean + @Order(FilterChainOrder.RESOURCE) + UaaFilterChain jwtSvidSecurity(HttpSecurity http) throws Exception { + SecurityFilterChain chain = http + .securityMatcher("/jwt-svid/**") + .authorizeHttpRequests(auth -> { + auth.requestMatchers("/**").hasAuthority("uaa.resource"); + auth.anyRequest().denyAll(); + }) + .authenticationManager(clientAuthenticationManager) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterAt(clientAuthenticationFilter.getFilter(), BasicAuthenticationFilter.class) + .anonymous(AnonymousConfigurer::disable) + .csrf(CsrfConfigurer::disable) + .exceptionHandling(exception -> + exception.authenticationEntryPoint(basicAuthenticationEntryPoint) + .accessDeniedHandler(oauthAccessDeniedHandler)) + .securityContext(sc -> sc.requireExplicitSave(false)) + .build(); + + return new UaaFilterChain(chain, "jwtSvidSecurity"); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/CertificateOuParserTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/CertificateOuParserTests.java new file mode 100644 index 00000000000..63600d30690 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/CertificateOuParserTests.java @@ -0,0 +1,35 @@ +package org.cloudfoundry.identity.uaa.spiffe; + +import org.junit.jupiter.api.Test; + +import java.security.cert.X509Certificate; + +import static org.assertj.core.api.Assertions.assertThat; + +class CertificateOuParserTests { + + private final CertificateOuParser parser = new CertificateOuParser(); + + @Test + void extractsOrgSpaceAppFromMultiValuedOuRdn() { + X509Certificate cert = SpiffeTestCerts + .newInstanceCert(SpiffeTestCerts.newCa(), "org-guid", "space-guid", "app-guid") + .certificate(); + + CfInstanceIdentity identity = parser.parse(cert); + + assertThat(identity.orgId()).isEqualTo("org-guid"); + assertThat(identity.spaceId()).isEqualTo("space-guid"); + assertThat(identity.appId()).isEqualTo("app-guid"); + } + + @Test + void throwsWhenRequiredOuMissing() { + X509Certificate ca = SpiffeTestCerts.newCa().certificate(); // CA cert has only CN, no OUs + + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> parser.parse(ca)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("organization"); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/InstanceIdentityVerifierTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/InstanceIdentityVerifierTests.java new file mode 100644 index 00000000000..7146c1a5cb2 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/InstanceIdentityVerifierTests.java @@ -0,0 +1,48 @@ +package org.cloudfoundry.identity.uaa.spiffe; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InstanceIdentityVerifierTests { + + private final SpiffeTestCerts.CertKey ca = SpiffeTestCerts.newCa(); + private final InstanceIdentityVerifier verifier = new InstanceIdentityVerifier(ca.certificate()); + + @Test + void acceptsCertSignedByConfiguredCa() { + SpiffeTestCerts.CertKey leaf = SpiffeTestCerts.newInstanceCert(ca, "o", "s", "a"); + + assertThat(verifier.isValid(leaf.certificate())).isTrue(); + } + + @Test + void rejectsCertSignedByDifferentCa() { + SpiffeTestCerts.CertKey otherCa = SpiffeTestCerts.newCa(); + SpiffeTestCerts.CertKey leaf = SpiffeTestCerts.newInstanceCert(otherCa, "o", "s", "a"); + + assertThatThrownBy(() -> verifier.verify(leaf.certificate())) + .isInstanceOf(InstanceIdentityVerifier.InvalidInstanceCertificateException.class); + assertThat(verifier.isValid(leaf.certificate())).isFalse(); + } + + @Test + void rejectsExpiredCert() { + SpiffeTestCerts.CertKey leaf = SpiffeTestCerts.newInstanceCert(ca, "o", "s", "a", + Instant.now().minus(2, ChronoUnit.HOURS), Instant.now().minus(1, ChronoUnit.HOURS)); + + assertThat(verifier.isValid(leaf.certificate())).isFalse(); + } + + @Test + void rejectsNotYetValidCert() { + SpiffeTestCerts.CertKey leaf = SpiffeTestCerts.newInstanceCert(ca, "o", "s", "a", + Instant.now().plus(1, ChronoUnit.HOURS), Instant.now().plus(2, ChronoUnit.HOURS)); + + assertThat(verifier.isValid(leaf.certificate())).isFalse(); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidControllerTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidControllerTests.java new file mode 100644 index 00000000000..fac17a065f2 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidControllerTests.java @@ -0,0 +1,190 @@ +package org.cloudfoundry.identity.uaa.spiffe; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.security.cert.X509Certificate; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class JwtSvidControllerTests { + + private final CertificateOuParser ouParser = mock(CertificateOuParser.class); + private final InstanceIdentityVerifier identityVerifier = mock(InstanceIdentityVerifier.class); + private final ProofOfPossessionVerifier popVerifier = mock(ProofOfPossessionVerifier.class); + private final JwtSvidSigner signer = mock(JwtSvidSigner.class); + private final SpiffeProperties props = new SpiffeProperties("example.org", "ca", 900L, 60, true); + + private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + // The controller parses the PEM with the real KeyWithCert BEFORE any mocked + // collaborator runs, so this must be a genuinely parseable certificate. The OUs + // are irrelevant here because CertificateOuParser is mocked. + private static final String CERT_PEM = SpiffeTestCerts.certificatePem( + SpiffeTestCerts.newInstanceCert(SpiffeTestCerts.newCa(), "org-1", "space-2", "app-3") + .certificate()); + + @BeforeEach + void setUp() { + JwtSvidController controller = + new JwtSvidController(ouParser, identityVerifier, popVerifier, signer, props); + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new JwtSvidController.ExceptionHandling()) + .build(); + } + + private String body(String popSignature) throws Exception { + return objectMapper.writeValueAsString( + new JwtSvidRequest(CERT_PEM, "web", "https://api.example.com", 1000L, popSignature)); + } + + @Test + void returnsSignedSvidOnHappyPath() throws Exception { + CfInstanceIdentity identity = new CfInstanceIdentity("org-1", "space-2", "app-3"); + String spiffeId = "spiffe://example.org/cf/org/org-1/space/space-2/app/app-3/process/web"; + when(ouParser.parse(any())).thenReturn(identity); + when(popVerifier.isValid(any(), eq(spiffeId), eq("https://api.example.com"), anyLong(), anyString())) + .thenReturn(true); + when(signer.sign(eq(spiffeId), eq(identity), eq("web"), eq("https://api.example.com"))) + .thenReturn(new JwtSvidSigner.JwtSvidResult("header.body.sig", spiffeId, 1900L)); + + mockMvc.perform(post("/jwt-svid/sign") + .contentType(MediaType.APPLICATION_JSON) + .content(body("c2ln"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.svid").value("header.body.sig")) + .andExpect(jsonPath("$.spiffe_id").value(spiffeId)) + .andExpect(jsonPath("$.expires_at").value(1900)); + } + + @Test + void returns401WhenCertificateUntrusted() throws Exception { + when(ouParser.parse(any())).thenReturn(new CfInstanceIdentity("o", "s", "a")); + org.mockito.Mockito.doThrow( + new InstanceIdentityVerifier.InvalidInstanceCertificateException("bad", null)) + .when(identityVerifier).verify(any(X509Certificate.class)); + + mockMvc.perform(post("/jwt-svid/sign") + .contentType(MediaType.APPLICATION_JSON) + .content(body("c2ln"))) + .andExpect(status().isUnauthorized()); + } + + @Test + void returns401WhenProofOfPossessionFails() throws Exception { + when(ouParser.parse(any())).thenReturn(new CfInstanceIdentity("org-1", "space-2", "app-3")); + when(popVerifier.isValid(any(), anyString(), anyString(), anyLong(), anyString())).thenReturn(false); + + mockMvc.perform(post("/jwt-svid/sign") + .contentType(MediaType.APPLICATION_JSON) + .content(body("c2ln"))) + .andExpect(status().isUnauthorized()); + } + + /** + * The controller must reject structurally dangerous inputs BEFORE they reach + * {@link SpiffeId#format} (process_type is concatenated into the SPIFFE ID path) + * or the proof-of-possession message (newlines would make the signed message + * ambiguous). All rejections are 400 Bad Request. + */ + @Nested + class InputValidation { + + @BeforeEach + void stubCollaboratorsSoOnlyValidationCanReject() { + // A valid identity keeps the pre-validation flow deterministic: without the + // new guard the request would reach the (unstubbed -> false) PoP check and + // return 401, so a 400 can only come from input validation. + when(ouParser.parse(any())).thenReturn(new CfInstanceIdentity("org-1", "space-2", "app-3")); + } + + @ParameterizedTest + @ValueSource(strings = {"web/extra", "with space", "dotted.type", "semi;colon", "comma,type", "star*"}) + void rejectsProcessTypeWithIllegalCharacters(String processType) throws Exception { + mockMvc.perform(post("/jwt-svid/sign") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody(processType, "https://api.example.com"))) + .andExpect(status().isBadRequest()); + } + + @Test + void rejectsProcessTypeWithControlCharacter() throws Exception { + mockMvc.perform(post("/jwt-svid/sign") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody("web\ninjected", "https://api.example.com"))) + .andExpect(status().isBadRequest()); + } + + @Test + void rejectsEmptyProcessType() throws Exception { + mockMvc.perform(post("/jwt-svid/sign") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody("", "https://api.example.com"))) + .andExpect(status().isBadRequest()); + } + + @Test + void rejectsOverlongProcessType() throws Exception { + mockMvc.perform(post("/jwt-svid/sign") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody("a".repeat(64), "https://api.example.com"))) + .andExpect(status().isBadRequest()); + } + + @Test + void rejectsBlankAudience() throws Exception { + mockMvc.perform(post("/jwt-svid/sign") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody("web", " "))) + .andExpect(status().isBadRequest()); + } + + @Test + void rejectsAudienceWithControlCharacter() throws Exception { + mockMvc.perform(post("/jwt-svid/sign") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody("web", "https://api.example.com\nHeader: injected"))) + .andExpect(status().isBadRequest()); + } + + @Test + void rejectsOverlongAudience() throws Exception { + mockMvc.perform(post("/jwt-svid/sign") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody("web", "https://" + "a".repeat(512)))) + .andExpect(status().isBadRequest()); + } + + @Test + void rejectsMalformedCertificatePem() throws Exception { + // process_type/audience are valid, so the unreadable PEM is what produces the 400. + String requestWithBadCert = objectMapper.writeValueAsString( + new JwtSvidRequest("not-a-pem", "web", "https://api.example.com", 1000L, "c2ln")); + mockMvc.perform(post("/jwt-svid/sign") + .contentType(MediaType.APPLICATION_JSON) + .content(requestWithBadCert)) + .andExpect(status().isBadRequest()); + } + + private String requestBody(String processType, String audience) throws Exception { + return objectMapper.writeValueAsString( + new JwtSvidRequest(CERT_PEM, processType, audience, 1000L, "c2ln")); + } + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidSignerTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidSignerTests.java new file mode 100644 index 00000000000..0cb7bb8b100 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/JwtSvidSignerTests.java @@ -0,0 +1,81 @@ +package org.cloudfoundry.identity.uaa.spiffe; + +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jwt.SignedJWT; +import org.cloudfoundry.identity.uaa.oauth.KeyInfo; +import org.cloudfoundry.identity.uaa.oauth.KeyInfoService; +import org.cloudfoundry.identity.uaa.oauth.TokenEndpointBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.interfaces.RSAPublicKey; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class JwtSvidSignerTests { + + private KeyPair signingKeyPair; + private JwtSvidSigner signer; + + @BeforeEach + void setUp() { + signingKeyPair = SpiffeTestCerts.newRsaKeyPair(); + String pem = SpiffeTestCerts.pkcs8PrivateKeyPem(signingKeyPair.getPrivate()); + KeyInfo keyInfo = new KeyInfo("svid-key", pem, "https://uaa.example.com"); + + KeyInfoService keyInfoService = mock(KeyInfoService.class); + when(keyInfoService.getActiveKey()).thenReturn(keyInfo); + + TokenEndpointBuilder tokenEndpointBuilder = mock(TokenEndpointBuilder.class); + when(tokenEndpointBuilder.getTokenEndpoint(any())) + .thenReturn("https://uaa.example.com/oauth/token"); + + SpiffeProperties props = new SpiffeProperties("example.org", "ca", 900L, 60, true); + signer = new JwtSvidSigner(keyInfoService, tokenEndpointBuilder, props); + } + + @Test + void signsVerifiableJwtSvidWithExpectedClaims() throws Exception { + CfInstanceIdentity identity = new CfInstanceIdentity("org-1", "space-2", "app-3"); + String spiffeId = "spiffe://example.org/cf/org/org-1/space/space-2/app/app-3/process/web"; + long before = Instant.now().getEpochSecond(); + + JwtSvidSigner.JwtSvidResult result = + signer.sign(spiffeId, identity, "web", "https://api.example.com"); + + SignedJWT jwt = SignedJWT.parse(result.svid()); + + // Header references UAA's key + JWKS URL so relying parties can fetch the key. + assertThat(jwt.getHeader().getKeyID()).isEqualTo("svid-key"); + assertThat(jwt.getHeader().getAlgorithm().getName()).isEqualTo("RS256"); + + // Signature verifies against the signing key's public half. + assertThat(jwt.verify(new RSASSAVerifier((RSAPublicKey) signingKeyPair.getPublic()))).isTrue(); + + var claims = jwt.getJWTClaimsSet(); + assertThat(claims.getIssuer()).isEqualTo("https://uaa.example.com/oauth/token"); + assertThat(claims.getSubject()).isEqualTo(spiffeId); + assertThat(claims.getAudience()).containsExactly("https://api.example.com"); + assertThat(claims.getJWTID()).isNotBlank(); + assertThat(claims.getExpirationTime().toInstant().getEpochSecond()) + .isBetween(before + 899, before + 902); + + @SuppressWarnings("unchecked") + Map cf = (Map) claims.getClaim("cf"); + assertThat(cf).containsEntry("org_id", "org-1") + .containsEntry("space_id", "space-2") + .containsEntry("app_id", "app-3") + .containsEntry("process_type", "web"); + + assertThat(result.spiffeId()).isEqualTo(spiffeId); + assertThat(result.expiresAt()).isEqualTo(claims.getExpirationTime().toInstant().getEpochSecond()); + assertThat((List) claims.getAudience()).hasSize(1); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/ProofOfPossessionVerifierTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/ProofOfPossessionVerifierTests.java new file mode 100644 index 00000000000..eb8b5cbd332 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/ProofOfPossessionVerifierTests.java @@ -0,0 +1,81 @@ +package org.cloudfoundry.identity.uaa.spiffe; + +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.security.Signature; +import java.time.Instant; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; + +class ProofOfPossessionVerifierTests { + + private final SpiffeTestCerts.CertKey leaf = + SpiffeTestCerts.newInstanceCert(SpiffeTestCerts.newCa(), "o", "s", "a"); + private final SpiffeProperties props = new SpiffeProperties("td", "ca", null, 60, true); + private final ProofOfPossessionVerifier verifier = new ProofOfPossessionVerifier(props); + + private static final String SPIFFE_ID = "spiffe://td/cf/org/o/space/s/app/a/process/web"; + private static final String AUDIENCE = "https://api.example.com"; + + private String sign(String spiffeId, String audience, long timestamp) throws Exception { + String message = spiffeId + "\n" + audience + "\n" + timestamp; + Signature sig = Signature.getInstance("SHA256withRSA", BouncyCastleFipsProvider.PROVIDER_NAME); + sig.initSign(leaf.keyPair().getPrivate()); + sig.update(message.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(sig.sign()); + } + + @Test + void acceptsFreshValidSignature() throws Exception { + long now = Instant.now().getEpochSecond(); + String popSignature = sign(SPIFFE_ID, AUDIENCE, now); + + assertThat(verifier.isValid(leaf.certificate(), SPIFFE_ID, AUDIENCE, now, popSignature)).isTrue(); + } + + @Test + void rejectsTamperedMessage() throws Exception { + long now = Instant.now().getEpochSecond(); + String popSignature = sign(SPIFFE_ID, AUDIENCE, now); + + // Verifier recomputes the message with a different audience -> signature will not match. + assertThat(verifier.isValid(leaf.certificate(), SPIFFE_ID, "https://evil.example.com", now, popSignature)) + .isFalse(); + } + + @Test + void rejectsStaleTimestamp() throws Exception { + long stale = Instant.now().getEpochSecond() - 600; + String popSignature = sign(SPIFFE_ID, AUDIENCE, stale); + + assertThat(verifier.isValid(leaf.certificate(), SPIFFE_ID, AUDIENCE, stale, popSignature)).isFalse(); + } + + @Test + void rejectsFutureDatedTimestamp() throws Exception { + // Outside the symmetric freshness window even with an otherwise-valid signature. + long future = Instant.now().getEpochSecond() + 600; + String popSignature = sign(SPIFFE_ID, AUDIENCE, future); + + assertThat(verifier.isValid(leaf.certificate(), SPIFFE_ID, AUDIENCE, future, popSignature)).isFalse(); + } + + @Test + void rejectsMalformedBase64Signature() { + long now = Instant.now().getEpochSecond(); + + assertThat(verifier.isValid(leaf.certificate(), SPIFFE_ID, AUDIENCE, now, "not valid base64 !!!")) + .isFalse(); + } + + @Test + void shortCircuitsWhenPopDisabled() { + SpiffeProperties disabled = new SpiffeProperties("td", "ca", null, 60, false); + ProofOfPossessionVerifier off = new ProofOfPossessionVerifier(disabled); + + assertThat(off.isValid(leaf.certificate(), SPIFFE_ID, AUDIENCE, 0L, "not-a-signature")).isTrue(); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeConfigurationTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeConfigurationTests.java new file mode 100644 index 00000000000..1c8a4d387a1 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeConfigurationTests.java @@ -0,0 +1,33 @@ +package org.cloudfoundry.identity.uaa.spiffe; + +import org.junit.jupiter.api.Test; + +import java.security.cert.X509Certificate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SpiffeConfigurationTests { + + private final SpiffeConfiguration configuration = new SpiffeConfiguration(); + + @Test + void parsesConfiguredCaPemIntoCertificate() throws Exception { + SpiffeTestCerts.CertKey ca = SpiffeTestCerts.newCa(); + String caPem = SpiffeTestCerts.certificatePem(ca.certificate()); + SpiffeProperties props = new SpiffeProperties("example.org", caPem, null, null, null); + + X509Certificate parsed = configuration.spiffeInstanceIdentityCa(props); + + assertThat(parsed.getSubjectX500Principal()) + .isEqualTo(ca.certificate().getSubjectX500Principal()); + } + + @Test + void failsFastOnUnparseableCaPem() { + SpiffeProperties props = new SpiffeProperties("example.org", "not-a-pem", null, null, null); + + assertThatThrownBy(() -> configuration.spiffeInstanceIdentityCa(props)) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeIdTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeIdTests.java new file mode 100644 index 00000000000..6a5613e7b1b --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeIdTests.java @@ -0,0 +1,26 @@ +package org.cloudfoundry.identity.uaa.spiffe; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SpiffeIdTests { + + @Test + void formatsWorkloadSpiffeId() { + CfInstanceIdentity identity = new CfInstanceIdentity("org-1", "space-2", "app-3"); + + String id = SpiffeId.format("example.org", identity, "web"); + + assertThat(id).isEqualTo( + "spiffe://example.org/cf/org/org-1/space/space-2/app/app-3/process/web"); + } + + @Test + void formatsSshProcessType() { + CfInstanceIdentity identity = new CfInstanceIdentity("o", "s", "a"); + + assertThat(SpiffeId.format("td", identity, "ssh")) + .isEqualTo("spiffe://td/cf/org/o/space/s/app/a/process/ssh"); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/SpiffePropertiesTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/SpiffePropertiesTests.java new file mode 100644 index 00000000000..748704018b9 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/SpiffePropertiesTests.java @@ -0,0 +1,28 @@ +package org.cloudfoundry.identity.uaa.spiffe; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SpiffePropertiesTests { + + @Test + void appliesDefaultsWhenOptionalValuesAreNull() { + SpiffeProperties props = new SpiffeProperties("example.org", "PEM", null, null, null); + + assertThat(props.trustDomain()).isEqualTo("example.org"); + assertThat(props.instanceIdentityCa()).isEqualTo("PEM"); + assertThat(props.jwtSvidTtlSeconds()).isEqualTo(3600L); + assertThat(props.popFreshnessSeconds()).isEqualTo(60); + assertThat(props.popEnabled()).isTrue(); + } + + @Test + void retainsExplicitValues() { + SpiffeProperties props = new SpiffeProperties("td", "ca", 120L, 5, false); + + assertThat(props.jwtSvidTtlSeconds()).isEqualTo(120L); + assertThat(props.popFreshnessSeconds()).isEqualTo(5); + assertThat(props.popEnabled()).isFalse(); + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeTestCerts.java b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeTestCerts.java new file mode 100644 index 00000000000..28a485e0dc8 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/spiffe/SpiffeTestCerts.java @@ -0,0 +1,121 @@ +package org.cloudfoundry.identity.uaa.spiffe; + +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.X500NameBuilder; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Date; + +/** Test-only helper that mints a CA and Diego-style instance certificates. */ +public final class SpiffeTestCerts { + + static { + if (Security.getProvider(BouncyCastleFipsProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleFipsProvider()); + } + } + + private SpiffeTestCerts() { + } + + /** A certificate together with the key pair whose public key it certifies. */ + public record CertKey(X509Certificate certificate, KeyPair keyPair) { + } + + public static KeyPair newRsaKeyPair() { + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyCastleFipsProvider.PROVIDER_NAME); + kpg.initialize(2048); + return kpg.generateKeyPair(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + /** Self-signed CA usable as the configured instance-identity CA. */ + public static CertKey newCa() { + KeyPair caKeys = newRsaKeyPair(); + X500Name caName = new X500NameBuilder(BCStyle.INSTANCE) + .addRDN(BCStyle.CN, "instanceIdentityCA") + .build(); + X509Certificate cert = sign(caName, caName, caKeys.getPublic(), caKeys.getPrivate(), + Instant.now().minus(1, ChronoUnit.DAYS), Instant.now().plus(365, ChronoUnit.DAYS)); + return new CertKey(cert, caKeys); + } + + /** Instance cert signed by {@code ca}, with org/space/app OUs in one multi-valued RDN. */ + public static CertKey newInstanceCert(CertKey ca, String orgId, String spaceId, String appId) { + return newInstanceCert(ca, orgId, spaceId, appId, + Instant.now().minus(1, ChronoUnit.HOURS), Instant.now().plus(1, ChronoUnit.HOURS)); + } + + public static CertKey newInstanceCert(CertKey ca, String orgId, String spaceId, String appId, + Instant notBefore, Instant notAfter) { + KeyPair leafKeys = newRsaKeyPair(); + X500Name subject = new X500NameBuilder(BCStyle.INSTANCE) + .addMultiValuedRDN( + new ASN1ObjectIdentifier[]{BCStyle.OU, BCStyle.OU, BCStyle.OU}, + new String[]{"organization:" + orgId, "space:" + spaceId, "app:" + appId}) + .addRDN(BCStyle.CN, "instance-" + appId) + .build(); + X500Name issuer = new X500NameBuilder(BCStyle.INSTANCE) + .addRDN(BCStyle.CN, "instanceIdentityCA") + .build(); + X509Certificate cert = sign(issuer, subject, leafKeys.getPublic(), ca.keyPair().getPrivate(), + notBefore, notAfter); + return new CertKey(cert, leafKeys); + } + + public static String pkcs8PrivateKeyPem(PrivateKey privateKey) { + String body = Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(privateKey.getEncoded()); + return "-----BEGIN PRIVATE KEY-----\n" + body + "\n-----END PRIVATE KEY-----\n"; + } + + public static String certificatePem(X509Certificate certificate) { + try { + String body = Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(certificate.getEncoded()); + return "-----BEGIN CERTIFICATE-----\n" + body + "\n-----END CERTIFICATE-----\n"; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static X509Certificate sign(X500Name issuer, X500Name subject, + java.security.PublicKey subjectPublicKey, PrivateKey issuerPrivateKey, + Instant notBefore, Instant notAfter) { + try { + JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + issuer, + BigInteger.valueOf(System.nanoTime()), + Date.from(notBefore), + Date.from(notAfter), + subject, + subjectPublicKey); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .build(issuerPrivateKey); + X509CertificateHolder holder = builder.build(signer); + return new JcaX509CertificateConverter() + .setProvider(BouncyCastleFipsProvider.PROVIDER_NAME) + .getCertificate(holder); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +}