Skip to content

Commit 178e9e2

Browse files
EPA-265: Also establish trust in the IDP discovery document
1 parent c0ffeee commit 178e9e2

4 files changed

Lines changed: 298 additions & 7 deletions

File tree

epa4all-vau-client/src/main/java/com/oviva/telematik/vau/epa4all/client/authz/internal/OidcClient.java

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import static java.util.function.Predicate.not;
44

55
import com.fasterxml.jackson.annotation.JsonProperty;
6-
import com.nimbusds.jose.JWSObject;
76
import com.nimbusds.jose.jwk.JWK;
7+
import com.nimbusds.jwt.SignedJWT;
88
import com.nimbusds.oauth2.sdk.GeneralException;
99
import com.nimbusds.oauth2.sdk.id.Issuer;
1010
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
@@ -24,9 +24,11 @@
2424
public class OidcClient {
2525

2626
private final HttpClient httpClient;
27+
private final DiscoveryValidator discoveryValidator;
2728

28-
public OidcClient(HttpClient httpClient) {
29+
public OidcClient(HttpClient httpClient, DiscoveryValidator discoveryValidator) {
2930
this.httpClient = httpClient;
31+
this.discoveryValidator = discoveryValidator;
3032
}
3133

3234
public record OidcDiscoveryResponse(
@@ -71,17 +73,19 @@ private OidcDiscoveryResponse parseResponse(HttpResponse<String> response) {
7173
"Missing content-type header in discovery document response"));
7274

7375
if (contentType.equals("application/jwt")) {
74-
return parseFromJwt(response.body());
75-
} else if (contentType.equals(MimeTypes.APPLICATION_JSON)) {
76-
return JsonCodec.readString(response.body(), OidcDiscoveryResponse.class);
76+
return verifyAndParse(response.body());
7777
}
7878
throw new AuthorizationException(
7979
"Unsupported content-type in discovery document response: " + contentType);
8080
}
8181

82-
private OidcDiscoveryResponse parseFromJwt(String jwt) {
82+
private OidcDiscoveryResponse verifyAndParse(String jwt) {
8383
try {
84-
var payload = JWSObject.parse(jwt).getPayload();
84+
85+
// establish trust in the discovery document
86+
discoveryValidator.validate(SignedJWT.parse(jwt));
87+
88+
var payload = SignedJWT.parse(jwt).getPayload();
8589
return JsonCodec.readBytes(payload.toBytes(), OidcDiscoveryResponse.class);
8690
} catch (ParseException e) {
8791
throw new AuthorizationException("Failed to parse JWT", e);
@@ -137,4 +141,8 @@ private <T> void verifyContentType(HttpResponse<T> res, String expectedContentTy
137141
.formatted(res.uri(), expectedContentType, contentType));
138142
}
139143
}
144+
145+
public interface DiscoveryValidator {
146+
void validate(SignedJWT jwsObject);
147+
}
140148
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package com.oviva.telematik.vau.epa4all.client.authz.internal;
2+
3+
import com.nimbusds.jose.JOSEException;
4+
import com.nimbusds.jwt.JWTClaimsSet;
5+
import com.nimbusds.jwt.SignedJWT;
6+
import com.nimbusds.jwt.proc.BadJWTException;
7+
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
8+
import com.oviva.telematik.vau.epa4all.client.authz.AuthorizationException;
9+
import com.oviva.telematik.vau.epa4all.client.authz.internal.jose.BrainpoolJwsVerifier;
10+
import com.oviva.telematik.vau.epa4all.client.authz.internal.jose.CertificateUtil;
11+
import java.security.*;
12+
import java.security.cert.*;
13+
import java.security.interfaces.ECPublicKey;
14+
import java.text.ParseException;
15+
import java.util.Set;
16+
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
17+
import org.bouncycastle.jce.provider.BouncyCastleProvider;
18+
import org.slf4j.Logger;
19+
import org.slf4j.LoggerFactory;
20+
21+
public class OidcDiscoveryValidatorImpl implements OidcClient.DiscoveryValidator {
22+
23+
// https://gemspec.gematik.de/docs/gemSpec/gemSpec_OID/gemSpec_OID_V3.23.0/#GS-A_4446-17
24+
private static final ASN1ObjectIdentifier OID_IDPD =
25+
new ASN1ObjectIdentifier("1.2.276.0.76.4.260");
26+
27+
private static final Logger logger = LoggerFactory.getLogger(OidcDiscoveryValidatorImpl.class);
28+
29+
private final KeyStore trustStore;
30+
31+
public OidcDiscoveryValidatorImpl(KeyStore trustStore) {
32+
this.trustStore = trustStore;
33+
}
34+
35+
@Override
36+
public void validate(SignedJWT jwt) {
37+
38+
verifySignature(jwt);
39+
verifyClaims(jwt);
40+
}
41+
42+
private void verifySignature(SignedJWT jwt) {
43+
44+
var cert = signingCertificcate(jwt);
45+
verifyTrustChainAgainstRoot(cert);
46+
verifyRole(cert);
47+
verifySignature(jwt, cert);
48+
}
49+
50+
private X509Certificate signingCertificcate(SignedJWT jwt) {
51+
52+
var x5cRaw =
53+
jwt.getHeader().getX509CertChain().stream()
54+
// the first one MUST be the one used for signing
55+
// https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.6
56+
.findFirst()
57+
.orElseThrow(
58+
() ->
59+
new AuthorizationException(
60+
"OIDC discovery document 'x5c' header claim is missing"));
61+
try {
62+
return CertificateUtil.parseDer(x5cRaw.decode());
63+
} catch (CertificateException e) {
64+
throw new AuthorizationException(
65+
"Failed to parse OIDC discovery document x509 header claim: " + e.getMessage(), e);
66+
}
67+
}
68+
69+
private void verifyTrustChainAgainstRoot(X509Certificate endUserCertificate) {
70+
71+
try {
72+
73+
var target = new X509CertSelector();
74+
target.setCertificate(endUserCertificate);
75+
76+
var params = new PKIXBuilderParameters(trustStore, target);
77+
78+
// there are no CRLs to be found
79+
params.setRevocationEnabled(false);
80+
81+
var builder = CertPathBuilder.getInstance("PKIX", BouncyCastleProvider.PROVIDER_NAME);
82+
83+
var result = (PKIXCertPathBuilderResult) builder.build(params);
84+
logger.atDebug().log(
85+
"certificate '{}' verified with trust anchor: '{}'",
86+
endUserCertificate.getSubjectX500Principal().getName(),
87+
result.getTrustAnchor().getTrustedCert().getSubjectX500Principal().getName());
88+
89+
} catch (CertPathBuilderException
90+
| NoSuchAlgorithmException
91+
| InvalidAlgorithmParameterException e) {
92+
var name = endUserCertificate.getSubjectX500Principal().getName();
93+
throw new AuthorizationException(
94+
"failed to validate IDP discovery document signing certificate, bad certificate: " + name,
95+
e);
96+
} catch (NoSuchProviderException | KeyStoreException e) {
97+
throw new AuthorizationException("unexpected crypto exception", e);
98+
}
99+
}
100+
101+
private void verifyRole(X509Certificate trustedSigningCertificate) {
102+
var oid =
103+
CertificateUtil.getProfessionOid(trustedSigningCertificate)
104+
.orElseThrow(
105+
() -> new AuthorizationException("missing profession OID for IDP certificate"));
106+
if (!oid.equals(OID_IDPD)) {
107+
throw new AuthorizationException("expected OID %s, got %s".formatted(OID_IDPD, oid));
108+
}
109+
}
110+
111+
private void verifySignature(SignedJWT jwt, X509Certificate trustedSigningCertificate) {
112+
var verifier = new BrainpoolJwsVerifier((ECPublicKey) trustedSigningCertificate.getPublicKey());
113+
try {
114+
if (!verifier.verify(jwt.getHeader(), jwt.getSigningInput(), jwt.getSignature())) {
115+
throw new AuthorizationException("bad signature");
116+
}
117+
} catch (JOSEException e) {
118+
throw new AuthorizationException("failed to verify signature", e);
119+
}
120+
}
121+
122+
private void verifyClaims(SignedJWT jwt) {
123+
124+
var claims = parseClaims(jwt);
125+
var claimsVerifier =
126+
new DefaultJWTClaimsVerifier<>(
127+
null, Set.of("iat", "exp", "issuer", "uri_puk_idp_enc", "uri_puk_idp_sig", "jwks_uri"));
128+
try {
129+
claimsVerifier.verify(claims, null);
130+
} catch (BadJWTException e) {
131+
throw new AuthorizationException("Bad discovery document", e);
132+
}
133+
}
134+
135+
private JWTClaimsSet parseClaims(SignedJWT jwt) {
136+
try {
137+
return jwt.getJWTClaimsSet();
138+
} catch (ParseException e) {
139+
throw new AuthorizationException("Failed to parse JWT claims", e);
140+
}
141+
}
142+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.oviva.telematik.vau.epa4all.client.authz.internal.jose;
2+
3+
import java.io.ByteArrayInputStream;
4+
import java.io.IOException;
5+
import java.security.NoSuchProviderException;
6+
import java.security.cert.CertificateEncodingException;
7+
import java.security.cert.CertificateException;
8+
import java.security.cert.CertificateFactory;
9+
import java.security.cert.X509Certificate;
10+
import java.util.Optional;
11+
import org.bouncycastle.asn1.ASN1Encodable;
12+
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
13+
import org.bouncycastle.asn1.isismtt.ISISMTTObjectIdentifiers;
14+
import org.bouncycastle.asn1.isismtt.x509.AdmissionSyntax;
15+
import org.bouncycastle.cert.X509CertificateHolder;
16+
import org.bouncycastle.jce.provider.BouncyCastleProvider;
17+
18+
public class CertificateUtil {
19+
private CertificateUtil() {}
20+
21+
public static X509Certificate parseDer(byte[] bytes) throws CertificateException {
22+
try (var certInputStream = new ByteArrayInputStream(bytes)) {
23+
// MUST be bouncycastle to deal with the brainpool certificates
24+
var certFactory = CertificateFactory.getInstance("X.509", BouncyCastleProvider.PROVIDER_NAME);
25+
var cert = certFactory.generateCertificate(certInputStream);
26+
if (cert instanceof X509Certificate x509Cert) {
27+
return x509Cert;
28+
}
29+
throw new CertificateEncodingException("not an X.509 certificate");
30+
} catch (IOException | NoSuchProviderException e) {
31+
throw new CertificateException("failed to parse certificate", e);
32+
}
33+
}
34+
35+
public static Optional<ASN1ObjectIdentifier> getProfessionOid(
36+
X509Certificate trustedSigningCertificate) {
37+
38+
ASN1Encodable asn1Admission = null;
39+
try {
40+
asn1Admission =
41+
new X509CertificateHolder(trustedSigningCertificate.getEncoded())
42+
.getExtensions()
43+
.getExtensionParsedValue(ISISMTTObjectIdentifiers.id_isismtt_at_admission);
44+
} catch (IOException | CertificateEncodingException e) {
45+
throw new IllegalArgumentException("bad certificate", e);
46+
}
47+
48+
var admissionInstance = AdmissionSyntax.getInstance(asn1Admission);
49+
50+
var contents = admissionInstance.getContentsOfAdmissions();
51+
if (contents.length != 1) {
52+
return Optional.empty();
53+
}
54+
55+
var content = contents[0];
56+
var profInfos = content.getProfessionInfos();
57+
if (profInfos.length != 1) {
58+
return Optional.empty();
59+
}
60+
61+
var profInfo = profInfos[0];
62+
63+
var oids = profInfo.getProfessionOIDs();
64+
if (oids.length != 1) {
65+
return Optional.empty();
66+
}
67+
68+
return Optional.ofNullable(oids[0]);
69+
}
70+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.oviva.telematik.vau.epa4all.client.authz.internal.jose;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import java.security.Security;
6+
import java.security.cert.CertificateException;
7+
import java.util.Base64;
8+
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
9+
import org.bouncycastle.jce.provider.BouncyCastleProvider;
10+
import org.junit.jupiter.api.BeforeAll;
11+
import org.junit.jupiter.api.Test;
12+
13+
class CertificateUtilTest {
14+
15+
// RISE IDP test certificate (TEST-ONLY, NOT-VALID)
16+
private static final String IDP_CERT_PEM =
17+
"""
18+
MIIC9zCCAp6gAwIBAgIDALXqMAoGCCqGSM49BAMCMIGEMQswCQYDVQQGEwJERTEf\
19+
MB0GA1UECgwWZ2VtYXRpayBHbWJIIE5PVC1WQUxJRDEyMDAGA1UECwwpS29tcG9u\
20+
ZW50ZW4tQ0EgZGVyIFRlbGVtYXRpa2luZnJhc3RydWt0dXIxIDAeBgNVBAMMF0dF\
21+
TS5LT01QLUNBNTYgVEVTVC1PTkxZMB4XDTI2MDEyMTE1MzkzOFoXDTMxMDEyMDE1\
22+
MzkzN1owfTELMAkGA1UEBhMCQVQxKDAmBgNVBAoMH1JJU0UgR21iSCBURVNULU9O\
23+
TFkgLSBOT1QtVkFMSUQxKTAnBgNVBAUTIDM4Nzc4LVYwMUkwMDA0VDIwMjYwMTIx\
24+
MTUyMzI4MjExMRkwFwYDVQQDDBBkaXNjLnJ1LmlkcC5yaXNlMFowFAYHKoZIzj0C\
25+
AQYJKyQDAwIIAQEHA0IABGL+nmChjSvGhVBH/o14iuUsK9CSZBAyO+UCNs6D7nZa\
26+
O5xaTLrVNCdA4Zb+HjjoCucQjahDYZfmvu3CzCf4RAajggECMIH/MB0GA1UdDgQW\
27+
BBSOp8MJLLkrkstNfHHkAKwwUrJUHTAfBgNVHSMEGDAWgBTVuBx5iaOlrcWNtv5b\
28+
/hA3A50DwzBNBggrBgEFBQcBAQRBMD8wPQYIKwYBBQUHMAGGMWh0dHA6Ly9kb3du\
29+
bG9hZC10ZXN0cmVmLmNybC50aS1kaWVuc3RlLmRlL29jc3AvZWMwDgYDVR0PAQH/\
30+
BAQDAgeAMCEGA1UdIAQaMBgwCgYIKoIUAEwEgSMwCgYIKoIUAEwEgUswDAYDVR0T\
31+
AQH/BAIwADAtBgUrJAgDAwQkMCIwIDAeMBwwGjAMDApJRFAtRGllbnN0MAoGCCqC\
32+
FABMBIIEMAoGCCqGSM49BAMCA0cAMEQCIEYDbjgvR6IbcNQxGv1FQKg0qCqHlfBl\
33+
8kbrNXXOF3+aAiBYjajQzxmWpQAewatkepSE8HQtBLaRNAnWGvgmxLRWFQ==""";
34+
35+
// oid_idpd_gematik: IDP-Dienst OID per gematik gemSpec_OID
36+
private static final ASN1ObjectIdentifier IDP_DIENST_OID =
37+
new ASN1ObjectIdentifier("1.2.276.0.76.4.260");
38+
39+
@BeforeAll
40+
static void setUp() {
41+
Security.addProvider(new BouncyCastleProvider());
42+
}
43+
44+
@Test
45+
void parseDer_validCertificate_returnsX509Certificate() throws CertificateException {
46+
var derBytes = Base64.getDecoder().decode(IDP_CERT_PEM);
47+
48+
var cert = CertificateUtil.parseDer(derBytes);
49+
50+
assertNotNull(cert);
51+
assertTrue(cert.getSubjectX500Principal().getName().contains("CN=disc.ru.idp.rise"));
52+
}
53+
54+
@Test
55+
void parseDer_invalidBytes_throwsCertificateException() {
56+
var garbage = new byte[] {0x00, 0x01, 0x02, 0x03};
57+
58+
assertThrows(CertificateException.class, () -> CertificateUtil.parseDer(garbage));
59+
}
60+
61+
@Test
62+
void getProfessionOid_idpCertificate_returnsIdpDienstOid() throws CertificateException {
63+
var derBytes = Base64.getDecoder().decode(IDP_CERT_PEM);
64+
var cert = CertificateUtil.parseDer(derBytes);
65+
66+
var oid = CertificateUtil.getProfessionOid(cert);
67+
68+
assertTrue(oid.isPresent());
69+
assertEquals(IDP_DIENST_OID, oid.get());
70+
}
71+
}

0 commit comments

Comments
 (0)