diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/FederationExceptions.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/FederationExceptions.java index 8933e83..5a4846b 100644 --- a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/FederationExceptions.java +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/FederationExceptions.java @@ -50,6 +50,15 @@ public static RuntimeException entityStatementBadSignature(String sub) { return new RuntimeException("entity statement of '%s' has a bad signature".formatted(sub)); } + public static RuntimeException federationStatementTimeNotValid(String sub) { + return new RuntimeException( + "federation statement of '%s' expired or not yet valid".formatted(sub)); + } + + public static RuntimeException federationStatementBadSignature(String sub) { + return new RuntimeException("federation statement of '%s' has a bad signature".formatted(sub)); + } + public static RuntimeException untrustedFederationStatement(String sub) { return new RuntimeException("federation statement untrusted: sub=%s".formatted(sub)); } diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/FederationMasterClientImpl.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/FederationMasterClientImpl.java index baca162..c58b7e6 100644 --- a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/FederationMasterClientImpl.java +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/FederationMasterClientImpl.java @@ -1,9 +1,11 @@ package com.oviva.gesundheitsid.fedclient; +import com.nimbusds.jose.jwk.JWKSet; import com.oviva.gesundheitsid.fedclient.api.EntityStatement; import com.oviva.gesundheitsid.fedclient.api.EntityStatementJWS; import com.oviva.gesundheitsid.fedclient.api.FederationApiClient; import com.oviva.gesundheitsid.fedclient.api.IdpList.IdpEntity; +import edu.umd.cs.findbugs.annotations.NonNull; import java.net.URI; import java.time.Clock; import java.util.List; @@ -36,28 +38,31 @@ public EntityStatementJWS establishIdpTrust(URI issuer) { var trustedFederationStatement = fetchTrustedFederationStatement(issuer); - if (!trustedFederationStatement.isValidAt(clock.instant())) { - throw FederationExceptions.entityStatementTimeNotValid( - trustedFederationStatement.body().sub()); - } - // the federation statement from the master will establish trust in the JWKS and the issuer URL // of the idp, // we still need to fetch the entity configuration directly afterward to get the full // entity statement - var idpEntitytStatement = apiClient.fetchEntityConfiguration(issuer); - if (!idpEntitytStatement.verifySignature(trustedFederationStatement.body().jwks())) { - throw FederationExceptions.untrustedFederationStatement( - trustedFederationStatement.body().sub()); + return fetchTrustedEntityConfiguration(issuer, trustedFederationStatement.body().jwks()); + } + + private EntityStatementJWS fetchTrustedEntityConfiguration(@NonNull URI sub, JWKSet trustStore) { + + var trustedEntityConfiguration = apiClient.fetchEntityConfiguration(sub); + if (!trustedEntityConfiguration.isValidAt(clock.instant())) { + throw FederationExceptions.entityStatementTimeNotValid(sub.toString()); + } + + if (!trustedEntityConfiguration.verifySignature(trustStore)) { + throw FederationExceptions.untrustedFederationStatement(sub.toString()); } - if (!idpEntitytStatement.isValidAt(clock.instant())) { - throw FederationExceptions.entityStatementTimeNotValid( - trustedFederationStatement.body().sub()); + if (!trustStore.equals(trustedEntityConfiguration.body().jwks()) + && !trustedEntityConfiguration.verifySelfSigned()) { + throw FederationExceptions.entityStatementBadSignature(sub.toString()); } - return idpEntitytStatement; + return trustedEntityConfiguration; } private EntityStatementJWS fetchTrustedFederationStatement(URI issuer) { @@ -66,12 +71,23 @@ private EntityStatementJWS fetchTrustedFederationStatement(URI issuer) { var federationFetchEndpoint = getFederationFetchEndpoint(masterEntityConfiguration.body()); + return fetchTrustedFederationStatement( + federationFetchEndpoint, masterEntityConfiguration.body().jwks(), issuer); + } + + private EntityStatementJWS fetchTrustedFederationStatement( + URI federationFetchEndpoint, JWKSet fedmasterTrustStore, URI issuer) { + var federationStatement = apiClient.fetchFederationStatement( federationFetchEndpoint, fedMasterUri.toString(), issuer.toString()); if (!federationStatement.isValidAt(clock.instant())) { - throw FederationExceptions.entityStatementTimeNotValid(federationStatement.body().sub()); + throw FederationExceptions.federationStatementTimeNotValid(federationStatement.body().sub()); + } + + if (!federationStatement.verifySignature(fedmasterTrustStore)) { + throw FederationExceptions.federationStatementBadSignature(issuer.toString()); } return federationStatement; diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/CachedFederationApiClient.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/CachedFederationApiClient.java index d5321f8..5d83755 100644 --- a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/CachedFederationApiClient.java +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/CachedFederationApiClient.java @@ -1,5 +1,6 @@ package com.oviva.gesundheitsid.fedclient.api; +import edu.umd.cs.findbugs.annotations.NonNull; import java.net.URI; /** very primitive cached client, there is no cache eviction here */ @@ -24,6 +25,7 @@ public CachedFederationApiClient( this.idpListCache = idpListCache; } + @NonNull @Override public EntityStatementJWS fetchFederationStatement( URI federationFetchUrl, String issuer, String subject) { @@ -32,6 +34,7 @@ public EntityStatementJWS fetchFederationStatement( key, k -> delegate.fetchFederationStatement(federationFetchUrl, issuer, subject)); } + @NonNull @Override public IdpListJWS fetchIdpList(URI idpListUrl) { return idpListCache.computeIfAbsent( @@ -39,7 +42,7 @@ public IdpListJWS fetchIdpList(URI idpListUrl) { } @Override - public EntityStatementJWS fetchEntityConfiguration(URI entityUrl) { + public @NonNull EntityStatementJWS fetchEntityConfiguration(URI entityUrl) { return entityStatementCache.computeIfAbsent( entityUrl.toString(), k -> delegate.fetchEntityConfiguration(entityUrl)); } diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/EntityStatement.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/EntityStatement.java index f81e1b3..8394ca4 100644 --- a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/EntityStatement.java +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/EntityStatement.java @@ -113,6 +113,10 @@ public static final class Builder { private String contacts; private String homepageUri; + private String federationFetchEndpoint; + private String federationListEndpoint; + private String idpListEndpoint; + private Builder() {} public Builder name(String name) { @@ -130,8 +134,29 @@ public Builder homepageUri(String homepageUri) { return this; } + public Builder federationFetchEndpoint(String federationFetchEndpoint) { + this.federationFetchEndpoint = federationFetchEndpoint; + return this; + } + + public Builder federationListEndpoint(String federationListEndpoint) { + this.federationListEndpoint = federationListEndpoint; + return this; + } + + public Builder idpListEndpoint(String idpListEndpoint) { + this.idpListEndpoint = idpListEndpoint; + return this; + } + public FederationEntity build() { - return new FederationEntity(name, contacts, homepageUri, null, null, null); + return new FederationEntity( + name, + contacts, + homepageUri, + federationFetchEndpoint, + federationListEndpoint, + idpListEndpoint); } } } diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/FederationApiClient.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/FederationApiClient.java index 180317c..fda3818 100644 --- a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/FederationApiClient.java +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/FederationApiClient.java @@ -1,13 +1,17 @@ package com.oviva.gesundheitsid.fedclient.api; +import edu.umd.cs.findbugs.annotations.NonNull; import java.net.URI; public interface FederationApiClient { + @NonNull EntityStatementJWS fetchFederationStatement( URI federationFetchUrl, String issuer, String subject); + @NonNull IdpListJWS fetchIdpList(URI idpListUrl); + @NonNull EntityStatementJWS fetchEntityConfiguration(URI entityUrl); } diff --git a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/FederationApiClientImpl.java b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/FederationApiClientImpl.java index 499e8e2..d0aeeca 100644 --- a/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/FederationApiClientImpl.java +++ b/gesundheitsid/src/main/java/com/oviva/gesundheitsid/fedclient/api/FederationApiClientImpl.java @@ -2,6 +2,7 @@ import com.oviva.gesundheitsid.fedclient.api.HttpClient.Header; import com.oviva.gesundheitsid.fedclient.api.HttpClient.Request; +import edu.umd.cs.findbugs.annotations.NonNull; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.UriBuilder; @@ -22,6 +23,7 @@ public FederationApiClientImpl(HttpClient client) { this.httpClient = client; } + @NonNull @Override public EntityStatementJWS fetchFederationStatement( URI federationFetchUrl, String issuer, String subject) { @@ -32,6 +34,7 @@ public EntityStatementJWS fetchFederationStatement( return EntityStatementJWS.parse(body); } + @NonNull @Override public IdpListJWS fetchIdpList(URI idpListUrl) { @@ -40,7 +43,7 @@ public IdpListJWS fetchIdpList(URI idpListUrl) { } @Override - public EntityStatementJWS fetchEntityConfiguration(URI entityUrl) { + public @NonNull EntityStatementJWS fetchEntityConfiguration(URI entityUrl) { var uri = UriBuilder.fromUri(entityUrl) diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/crypto/JwsVerifierTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/crypto/JwsVerifierTest.java index f6c8f07..fc852e8 100644 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/crypto/JwsVerifierTest.java +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/crypto/JwsVerifierTest.java @@ -1,5 +1,7 @@ package com.oviva.gesundheitsid.crypto; +import static com.oviva.gesundheitsid.test.JwksUtils.toJwks; +import static com.oviva.gesundheitsid.test.JwsUtils.*; import static com.oviva.gesundheitsid.test.JwsUtils.garbageSignature; import static com.oviva.gesundheitsid.test.JwsUtils.tamperSignature; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -7,20 +9,14 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.JWSHeader; import com.nimbusds.jose.JWSObject; import com.nimbusds.jose.Payload; -import com.nimbusds.jose.crypto.ECDSASigner; -import com.nimbusds.jose.jwk.Curve; -import com.nimbusds.jose.jwk.ECKey; import com.nimbusds.jose.jwk.JWKSet; import com.oviva.gesundheitsid.test.ECKeyPairGenerator; import com.oviva.gesundheitsid.test.ECKeyPairGenerator.ECKeyPair; -import java.io.IOException; import java.text.ParseException; -import java.util.List; import org.junit.jupiter.api.Test; class JwsVerifierTest { @@ -41,11 +37,11 @@ void verifyNoJwks() { } @Test - void verify() throws IOException, JOSEException, ParseException { + void verify() throws ParseException { var jwks = toJwks(ECKEY); - var jws = toJws(jwks, "hello world?"); + var jws = toJws(jwks, "hello world?").serialize(); var in = JWSObject.parse(jws); @@ -53,11 +49,11 @@ void verify() throws IOException, JOSEException, ParseException { } @Test - void verifyBadSignature() throws JOSEException, ParseException { + void verifyBadSignature() throws ParseException { var jwks = toJwks(ECKEY); - var jws = toJws(jwks, "test"); + var jws = toJws(jwks, "test").serialize(); jws = tamperSignature(jws); @@ -68,13 +64,13 @@ void verifyBadSignature() throws JOSEException, ParseException { } @Test - void verifyUnknownKey() throws JOSEException, ParseException { + void verifyUnknownKey() throws ParseException { var trustedJwks = toJwks(ECKEY); var signerJwks = toJwks(ECKeyPairGenerator.generate()); - var jws = toJws(signerJwks, "test"); + var jws = toJws(signerJwks, "test").serialize(); jws = tamperSignature(jws); @@ -85,10 +81,10 @@ void verifyUnknownKey() throws JOSEException, ParseException { } @Test - void verifyGarbageSignature() throws JOSEException, ParseException { + void verifyGarbageSignature() throws ParseException { var jwks = toJwks(ECKEY); - var jws = toJws(jwks, "test"); + var jws = toJws(jwks, "test").serialize(); jws = garbageSignature(jws); var in = JWSObject.parse(jws); @@ -97,32 +93,8 @@ void verifyGarbageSignature() throws JOSEException, ParseException { assertFalse(JwsVerifier.verify(jwks, in)); } - private String toJws(JWKSet jwks, String payload) throws JOSEException { - var key = jwks.getKeys().get(0); - var signer = new ECDSASigner(key.toECKey()); - - var h = new JWSHeader.Builder(JWSAlgorithm.ES256).keyID(key.getKeyID()).build(); - - var jwsObject = new JWSObject(h, new Payload(payload)); - jwsObject.sign(signer); - - return jwsObject.serialize(); - } - - private JWKSet toJwks(ECKeyPair pair) throws JOSEException { - - var jwk = - new ECKey.Builder(Curve.P_256, pair.pub()) - .privateKey(pair.priv()) - .keyIDFromThumbprint() - .build(); - - // JWK with extra steps, otherwise Keycloak can't deal with the parsed key - return new JWKSet(List.of(jwk)); - } - @Test - void verify_badAlg() throws JOSEException { + void verify_badAlg() { var jwks = toJwks(ECKEY); diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/FederationMasterClientImplTest.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/FederationMasterClientImplTest.java index 6c9a276..bba1742 100644 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/FederationMasterClientImplTest.java +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/fedclient/FederationMasterClientImplTest.java @@ -1,6 +1,7 @@ package com.oviva.gesundheitsid.fedclient; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; import com.oviva.gesundheitsid.fedclient.api.EntityStatement; @@ -11,8 +12,15 @@ import com.oviva.gesundheitsid.fedclient.api.IdpList; import com.oviva.gesundheitsid.fedclient.api.IdpList.IdpEntity; import com.oviva.gesundheitsid.fedclient.api.IdpListJWS; +import com.oviva.gesundheitsid.test.ECKeyPairGenerator; +import com.oviva.gesundheitsid.test.ECKeyPairGenerator.ECKeyPair; +import com.oviva.gesundheitsid.test.JwksUtils; +import com.oviva.gesundheitsid.test.JwsUtils; +import com.oviva.gesundheitsid.util.JsonCodec; import java.net.URI; import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -23,29 +31,25 @@ class FederationMasterClientImplTest { private static final URI FEDERATION_MASTER = URI.create("https://fedmaster.example.com"); - + private final Instant NOW = Instant.parse("2024-01-01T00:12:33.000Z"); + private final Clock clock = Clock.fixed(NOW, ZoneId.of("UTC")); @Mock FederationApiClient federationApiClient; - @Mock Clock clock; - @Test void getList() { var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); var idpListEndpoint = FEDERATION_MASTER.resolve("/idplist"); var es = - new EntityStatement( - null, - null, - 0, - 0, - 0, - null, - null, - new Metadata( - null, - null, - new FederationEntity(null, null, null, null, null, idpListEndpoint.toString()))); + EntityStatement.create() + .metadata( + Metadata.create() + .federationEntity( + FederationEntity.create() + .idpListEndpoint(idpListEndpoint.toString()) + .build()) + .build()) + .build(); var jws = new EntityStatementJWS(null, es); when(federationApiClient.fetchEntityConfiguration(FEDERATION_MASTER)).thenReturn(jws); @@ -74,4 +78,424 @@ void getList() { assertEquals(idp1Name, got.get(0).name()); assertEquals(idp2Name, got.get(1).name()); } + + @Test + void establishTrust_expiredFedmasterConfig() { + + var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); + + var issuer = URI.create("https://idp-tk.example.com"); + + var fedmasterKeypair = ECKeyPairGenerator.example(); + + var fedmasterEntityConfigurationJws = expiredFedmasterConfiguration(fedmasterKeypair); + + when(federationApiClient.fetchEntityConfiguration(FEDERATION_MASTER)) + .thenReturn(fedmasterEntityConfigurationJws); + + // when + var e = assertThrows(RuntimeException.class, () -> client.establishIdpTrust(issuer)); + + // then + assertEquals( + "entity statement of 'https://fedmaster.example.com' expired or not yet valid", + e.getMessage()); + } + + @Test + void establishTrust_badFedmasterConfigSignature() { + + var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); + + var issuer = URI.create("https://idp-tk.example.com"); + + var fedmasterKeypair = ECKeyPairGenerator.example(); + var unrelatedKeypair = ECKeyPairGenerator.generate(); + + var fedmasterEntityConfigurationJws = + badSignatureFedmasterConfiguration(fedmasterKeypair, unrelatedKeypair); + + when(federationApiClient.fetchEntityConfiguration(FEDERATION_MASTER)) + .thenReturn(fedmasterEntityConfigurationJws); + + // when + var e = assertThrows(RuntimeException.class, () -> client.establishIdpTrust(issuer)); + + // then + assertEquals( + "entity statement of 'https://fedmaster.example.com' has a bad signature", e.getMessage()); + } + + @Test + void establishTrust_configurationWithUnknownSignature() { + + var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); + + var issuer = URI.create("https://idp-tk.example.com"); + var federationFetchUrl = FEDERATION_MASTER.resolve("/fetch"); + + var fedmasterKeypair = ECKeyPairGenerator.example(); + + var fedmasterEntityConfigurationJws = + federationFetchFedmasterConfiguration(federationFetchUrl, fedmasterKeypair); + + var trustedSectoralIdpKeypair = ECKeyPairGenerator.generate(); + var trustedFederationStatement = + trustedFederationStatement(issuer, trustedSectoralIdpKeypair, fedmasterKeypair); + + var untrustedSectoralIdpKeypair = ECKeyPairGenerator.generate(); + var sectoralEntityConfiguration = + sectoralIdpEntityConfiguration(issuer, untrustedSectoralIdpKeypair); + + when(federationApiClient.fetchEntityConfiguration(FEDERATION_MASTER)) + .thenReturn(fedmasterEntityConfigurationJws); + + when(federationApiClient.fetchFederationStatement( + federationFetchUrl, FEDERATION_MASTER.toString(), issuer.toString())) + .thenReturn(trustedFederationStatement); + + when(federationApiClient.fetchEntityConfiguration(issuer)) + .thenReturn(sectoralEntityConfiguration); + + // when + var e = assertThrows(RuntimeException.class, () -> client.establishIdpTrust(issuer)); + + // then + assertEquals("federation statement untrusted: sub=https://idp-tk.example.com", e.getMessage()); + } + + @Test + void establishTrust_configurationWithBadJwks() { + + var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); + + var issuer = URI.create("https://idp-tk.example.com"); + var federationFetchUrl = FEDERATION_MASTER.resolve("/fetch"); + + var fedmasterKeypair = ECKeyPairGenerator.example(); + + var fedmasterEntityConfigurationJws = + federationFetchFedmasterConfiguration(federationFetchUrl, fedmasterKeypair); + + var trustedSectoralIdpKeypair = ECKeyPairGenerator.generate(); + var trustedFederationStatement = + trustedFederationStatement(issuer, trustedSectoralIdpKeypair, fedmasterKeypair); + + var untrustedSectoralIdpKeypair = ECKeyPairGenerator.generate(); + var sectoralEntityConfiguration = + badSignedSectoralIdpEntityConfiguration( + issuer, trustedSectoralIdpKeypair, untrustedSectoralIdpKeypair); + + when(federationApiClient.fetchEntityConfiguration(FEDERATION_MASTER)) + .thenReturn(fedmasterEntityConfigurationJws); + + when(federationApiClient.fetchFederationStatement( + federationFetchUrl, FEDERATION_MASTER.toString(), issuer.toString())) + .thenReturn(trustedFederationStatement); + + when(federationApiClient.fetchEntityConfiguration(issuer)) + .thenReturn(sectoralEntityConfiguration); + + // when + var e = assertThrows(RuntimeException.class, () -> client.establishIdpTrust(issuer)); + + // then + assertEquals( + "entity statement of 'https://idp-tk.example.com' has a bad signature", e.getMessage()); + } + + @Test + void establishTrust_configurationExpired() { + + var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); + + var issuer = URI.create("https://idp-tk.example.com"); + var federationFetchUrl = FEDERATION_MASTER.resolve("/fetch"); + + var fedmasterKeypair = ECKeyPairGenerator.example(); + + var fedmasterEntityConfigurationJws = + federationFetchFedmasterConfiguration(federationFetchUrl, fedmasterKeypair); + + var trustedSectoralIdpKeypair = ECKeyPairGenerator.generate(); + var trustedFederationStatement = + trustedFederationStatement(issuer, trustedSectoralIdpKeypair, fedmasterKeypair); + + var sectoralEntityConfiguration = + expiredIdpEntityConfiguration(issuer, trustedSectoralIdpKeypair); + + when(federationApiClient.fetchEntityConfiguration(FEDERATION_MASTER)) + .thenReturn(fedmasterEntityConfigurationJws); + + when(federationApiClient.fetchFederationStatement( + federationFetchUrl, FEDERATION_MASTER.toString(), issuer.toString())) + .thenReturn(trustedFederationStatement); + + when(federationApiClient.fetchEntityConfiguration(issuer)) + .thenReturn(sectoralEntityConfiguration); + + // when + var e = assertThrows(RuntimeException.class, () -> client.establishIdpTrust(issuer)); + + // then + assertEquals( + "entity statement of 'https://idp-tk.example.com' expired or not yet valid", + e.getMessage()); + } + + @Test + void establishTrust_expiredFederationStatement() { + + var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); + + var issuer = URI.create("https://idp-tk.example.com"); + var federationFetchUrl = FEDERATION_MASTER.resolve("/fetch"); + + var fedmasterKeypair = ECKeyPairGenerator.example(); + + var fedmasterEntityConfigurationJws = + federationFetchFedmasterConfiguration(federationFetchUrl, fedmasterKeypair); + + var trustedSectoralIdpKeypair = ECKeyPairGenerator.generate(); + var trustedFederationStatement = + expiredFederationStatement(issuer, trustedSectoralIdpKeypair, fedmasterKeypair); + + when(federationApiClient.fetchEntityConfiguration(FEDERATION_MASTER)) + .thenReturn(fedmasterEntityConfigurationJws); + + when(federationApiClient.fetchFederationStatement( + federationFetchUrl, FEDERATION_MASTER.toString(), issuer.toString())) + .thenReturn(trustedFederationStatement); + + // when + var e = assertThrows(RuntimeException.class, () -> client.establishIdpTrust(issuer)); + + // then + assertEquals( + "federation statement of 'https://idp-tk.example.com' expired or not yet valid", + e.getMessage()); + } + + @Test + void establishTrust_badSignatureFederationStatement() { + + var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); + + var issuer = URI.create("https://idp-tk.example.com"); + var federationFetchUrl = FEDERATION_MASTER.resolve("/fetch"); + + var fedmasterKeypair = ECKeyPairGenerator.example(); + + var fedmasterEntityConfigurationJws = + federationFetchFedmasterConfiguration(federationFetchUrl, fedmasterKeypair); + + var trustedSectoralIdpKeypair = ECKeyPairGenerator.generate(); + + var badKeypair = ECKeyPairGenerator.generate(); + var trustedFederationStatement = + trustedFederationStatement(issuer, trustedSectoralIdpKeypair, badKeypair); + + when(federationApiClient.fetchEntityConfiguration(FEDERATION_MASTER)) + .thenReturn(fedmasterEntityConfigurationJws); + + when(federationApiClient.fetchFederationStatement( + federationFetchUrl, FEDERATION_MASTER.toString(), issuer.toString())) + .thenReturn(trustedFederationStatement); + + // when + var e = assertThrows(RuntimeException.class, () -> client.establishIdpTrust(issuer)); + + // then + assertEquals( + "federation statement of 'https://idp-tk.example.com' has a bad signature", e.getMessage()); + } + + @Test + void establishTrust() { + + var client = new FederationMasterClientImpl(FEDERATION_MASTER, federationApiClient, clock); + + var issuer = URI.create("https://idp-tk.example.com"); + var federationFetchUrl = FEDERATION_MASTER.resolve("/fetch"); + + var fedmasterKeypair = ECKeyPairGenerator.example(); + + var fedmasterEntityConfigurationJws = + federationFetchFedmasterConfiguration(federationFetchUrl, fedmasterKeypair); + + var sectoralIdpKeypair = ECKeyPairGenerator.generate(); + var trustedFederationStatement = + trustedFederationStatement(issuer, sectoralIdpKeypair, fedmasterKeypair); + var sectoralEntityConfiguration = sectoralIdpEntityConfiguration(issuer, sectoralIdpKeypair); + + when(federationApiClient.fetchEntityConfiguration(FEDERATION_MASTER)) + .thenReturn(fedmasterEntityConfigurationJws); + + when(federationApiClient.fetchFederationStatement( + federationFetchUrl, FEDERATION_MASTER.toString(), issuer.toString())) + .thenReturn(trustedFederationStatement); + + when(federationApiClient.fetchEntityConfiguration(issuer)) + .thenReturn(sectoralEntityConfiguration); + + // when + var entityStatementJWS = client.establishIdpTrust(issuer); + + // then + assertEquals(entityStatementJWS.body().sub(), issuer.toString()); + } + + private EntityStatementJWS badSignedSectoralIdpEntityConfiguration( + URI sub, ECKeyPair sectoralIdpKeyPair, ECKeyPair actualJwksKeys) { + + var publicJwks = JwksUtils.toPublicJwks(actualJwksKeys); + + var body = + EntityStatement.create() + .iss(sub.toString()) + .sub(sub.toString()) + .exp(NOW.plusSeconds(60)) + .jwks(publicJwks) + .build(); + + var signed = + JwsUtils.toJws(JwksUtils.toJwks(sectoralIdpKeyPair), JsonCodec.writeValueAsString(body)); + + return new EntityStatementJWS(signed, body); + } + + private EntityStatementJWS expiredIdpEntityConfiguration(URI sub, ECKeyPair sectoralIdpKeyPair) { + + var publicJwks = JwksUtils.toPublicJwks(sectoralIdpKeyPair); + + var body = + EntityStatement.create() + .iss(sub.toString()) + .sub(sub.toString()) + .exp(NOW.minusSeconds(60)) + .jwks(publicJwks) + .build(); + + var signed = + JwsUtils.toJws(JwksUtils.toJwks(sectoralIdpKeyPair), JsonCodec.writeValueAsString(body)); + + return new EntityStatementJWS(signed, body); + } + + private EntityStatementJWS sectoralIdpEntityConfiguration(URI sub, ECKeyPair sectoralIdpKeyPair) { + + var publicJwks = JwksUtils.toPublicJwks(sectoralIdpKeyPair); + + var body = + EntityStatement.create() + .iss(sub.toString()) + .sub(sub.toString()) + .exp(NOW.plusSeconds(60)) + .jwks(publicJwks) + .build(); + + var signed = + JwsUtils.toJws(JwksUtils.toJwks(sectoralIdpKeyPair), JsonCodec.writeValueAsString(body)); + + return new EntityStatementJWS(signed, body); + } + + private EntityStatementJWS expiredFederationStatement( + URI sub, ECKeyPair sectoralIdpKeyPair, ECKeyPair fedmasterKeyPair) { + + var publicJwks = JwksUtils.toPublicJwks(sectoralIdpKeyPair); + + var body = + EntityStatement.create() + .iss(FEDERATION_MASTER.toString()) + .sub(sub.toString()) + .exp(NOW.minusSeconds(60)) + .jwks(publicJwks) + .build(); + + var signed = + JwsUtils.toJws(JwksUtils.toJwks(fedmasterKeyPair), JsonCodec.writeValueAsString(body)); + + return new EntityStatementJWS(signed, body); + } + + private EntityStatementJWS trustedFederationStatement( + URI sub, ECKeyPair sectoralIdpKeyPair, ECKeyPair fedmasterKeyPair) { + + var publicJwks = JwksUtils.toPublicJwks(sectoralIdpKeyPair); + + var body = + EntityStatement.create() + .iss(FEDERATION_MASTER.toString()) + .sub(sub.toString()) + .exp(NOW.plusSeconds(60)) + .jwks(publicJwks) + .build(); + + var signed = + JwsUtils.toJws(JwksUtils.toJwks(fedmasterKeyPair), JsonCodec.writeValueAsString(body)); + + return new EntityStatementJWS(signed, body); + } + + private EntityStatementJWS federationFetchFedmasterConfiguration( + URI fetchUrl, ECKeyPair keyPair) { + + var publicJwks = JwksUtils.toPublicJwks(keyPair); + + var body = + EntityStatement.create() + .sub(FEDERATION_MASTER.toString()) + .iss(FEDERATION_MASTER.toString()) + .exp(NOW.plusSeconds(60)) + .jwks(publicJwks) + .metadata( + Metadata.create() + .federationEntity( + FederationEntity.create() + .federationFetchEndpoint(fetchUrl.toString()) + .build()) + .build()) + .build(); + + var signed = JwsUtils.toJws(JwksUtils.toJwks(keyPair), JsonCodec.writeValueAsString(body)); + + return new EntityStatementJWS(signed, body); + } + + private EntityStatementJWS expiredFedmasterConfiguration(ECKeyPair keyPair) { + + var publicJwks = JwksUtils.toPublicJwks(keyPair); + + var body = + EntityStatement.create() + .sub(FEDERATION_MASTER.toString()) + .iss(FEDERATION_MASTER.toString()) + .exp(NOW.minusSeconds(60)) + .jwks(publicJwks) + .build(); + + var signed = JwsUtils.toJws(JwksUtils.toJwks(keyPair), JsonCodec.writeValueAsString(body)); + + return new EntityStatementJWS(signed, body); + } + + private EntityStatementJWS badSignatureFedmasterConfiguration( + ECKeyPair keyPair, ECKeyPair unrelatedKeyPair) { + + var publicJwks = JwksUtils.toPublicJwks(keyPair); + + var body = + EntityStatement.create() + .sub(FEDERATION_MASTER.toString()) + .iss(FEDERATION_MASTER.toString()) + .exp(NOW.plusSeconds(60)) + .jwks(publicJwks) + .build(); + + var signed = + JwsUtils.toJws(JwksUtils.toJwks(unrelatedKeyPair), JsonCodec.writeValueAsString(body)); + + return new EntityStatementJWS(signed, body); + } } diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwksUtils.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwksUtils.java new file mode 100644 index 0000000..3072c59 --- /dev/null +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwksUtils.java @@ -0,0 +1,38 @@ +package com.oviva.gesundheitsid.test; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWKSet; +import com.oviva.gesundheitsid.test.ECKeyPairGenerator.ECKeyPair; +import java.util.List; + +public class JwksUtils { + + private JwksUtils() {} + + public static JWKSet toJwks(ECKeyPair pair) { + + try { + var jwk = + new ECKey.Builder(Curve.P_256, pair.pub()) + .privateKey(pair.priv()) + .keyIDFromThumbprint() + .build(); + + return new JWKSet(List.of(jwk)); + } catch (JOSEException e) { + throw new IllegalArgumentException("bad key", e); + } + } + + public static JWKSet toPublicJwks(ECKeyPair pair) { + try { + var jwk = new ECKey.Builder(Curve.P_256, pair.pub()).keyIDFromThumbprint().build(); + + return new JWKSet(List.of(jwk)); + } catch (JOSEException e) { + throw new IllegalArgumentException("bad key", e); + } + } +} diff --git a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwsUtils.java b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwsUtils.java index 6beff16..e765980 100644 --- a/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwsUtils.java +++ b/gesundheitsid/src/test/java/com/oviva/gesundheitsid/test/JwsUtils.java @@ -1,9 +1,33 @@ package com.oviva.gesundheitsid.test; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSObject; +import com.nimbusds.jose.Payload; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jose.jwk.JWKSet; + public class JwsUtils { private JwsUtils() {} + public static JWSObject toJws(JWKSet jwks, String payload) { + try { + var key = jwks.getKeys().get(0); + var signer = new ECDSASigner(key.toECKey()); + + var h = new JWSHeader.Builder(JWSAlgorithm.ES256).keyID(key.getKeyID()).build(); + + var jwsObject = new JWSObject(h, new Payload(payload)); + jwsObject.sign(signer); + + return jwsObject; + } catch (JOSEException e) { + throw new IllegalArgumentException("failed to sign payload", e); + } + } + public static String tamperSignature(String jws) { var raw = jws.toCharArray(); raw[raw.length - 3] = flipSecondBit(raw[raw.length - 3]);