diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..e0543d7 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* thomas.richner@oviva.com michele.albanese@oviva.com \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d8b4af6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Oviva AG + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file 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]); diff --git a/pom.xml b/pom.xml index 0318cc3..b1dc279 100644 --- a/pom.xml +++ b/pom.xml @@ -46,10 +46,18 @@ - Copyright (C) 2024 Oviva AG - All Rights Reserved + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + A business-friendly OSS license + + scm:git:https://github.com/oviva-ag/keycloak-gesundheitsid.git + https://github.com/oviva-ag/keycloak-gesundheitsid + + gesundheitsid reports