diff --git a/README.md b/README.md index 6b471db..f0bbeef 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,10 @@ export ISSUER_URI=https://mydiga.example.com #---- 2. deploy the relying party docker run --rm \ - -v "$(pwd)"/enc_mydiga_example_com_jwks.json:/secrets/enc_jwks.json:ro \ -v "$(pwd)"/sig_mydiga_example_com_jwks.json:/secrets/sig_jwks.json:ro \ -e "EHEALTHID_RP_APP_NAME=Awesome DiGA" \ -e "EHEALTHID_RP_BASE_URI=$ISSUER_URI" \ - -e 'EHEALTHID_RP_FEDERATION_ENC_JWKS_PATH=/secrets/enc_jwks.json' \ - -e 'EHEALTHID_RP_FEDERATION_SIG_JWKS_PATH=/secrets/sig_jwks.json' \ + -e 'EHEALTHID_RP_FEDERATION_ES_JWKS_PATH=/secrets/sig_jwks.json' \ -e 'EHEALTHID_RP_FEDERATION_MASTER=https://app-test.federationmaster.de' \ -e 'EHEALTHID_RP_REDIRECT_URIS=https://sso-mydiga.example.com/auth/callback' \ -e 'EHEALTHID_RP_ES_TTL=PT5M' \ @@ -124,24 +122,23 @@ Use environment variables to configure the relying party server. (*) required configuration -| Name | Description | Example | -|------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------| -| `EHEALTHID_RP_FEDERATION_ENC_JWKS_PATH`* | Path to a JWKS with at least one keypair for encryption of ID tokens. | `./enc_jwks.json` | -| `EHEALTHID_RP_FEDERATION_SIG_JWKS_PATH`* | Path to a JWKS with at least one keypair for signature withing the federation. All these keys __MUST__ be registered with the federation master. | `./sig_jwks.json` | -| `EHEALTHID_RP_REDIRECT_URIS`* | Valid redirection URIs for OpenID connect. | `https://sso-mydiga.example.com/auth/callback` | -| `EHEALTHID_RP_BASE_URI`* | The external base URI of the relying party. This is also the `issuer` towards the OpenID federation. Additional paths are unsupported for now. | `https://mydiga-rp.example.com` | -| `EHEALTHID_RP_IDP_DISCOVERY_URI`* | The URI of the discovery document of your identity provider. Used to fetch public keys for client authentication. | `https://sso-mydiga.example.com/.well-known/openid-configuration` | -| `EHEALTHID_RP_FEDERATION_MASTER`* | The URI of the federation master. | `https://app-test.federationmaster.de` | -| `EHEALTHID_RP_APP_NAME`* | The application name within the federation. | `Awesome DiGA` | -| `EHEALTHID_RP_HOST` | Host to bind to. | `0.0.0.0` | -| `EHEALTHID_RP_PORT` | Port to bind to. | `1234` | -| `EHEALTHID_RP_ES_TTL` | The time to live for the entity statement. In ISO8601 format. | `PT12H` | -| `EHEALTHID_RP_SCOPES` | The comma separated list of scopes requested in the federation. This __MUST__ match what was registered with the federation master. | `openid,urn:telematik:email,urn:telematik:display_name` | -| `EHEALTHID_RP_SESSION_STORE_TTL` | The time to live for sessions. In ISO8601 format. | `PT20M` | -| `EHEALTHID_RP_SESSION_STORE_MAX_ENTRIES` | The maximum number of sessions to store. Keeps memory bounded. | `1000` | -| `EHEALTHID_RP_CODE_STORE_TTL` | The time to live for codes, i.e. successful logins where the code is not redeemed yet. In ISO8601 format. | `PT5M` | -| `EHEALTHID_RP_CODE_STORE_MAX_ENTRIES` | The maximum number of codes to store. Keeps memory bounded. | `1000` | -| `EHEALTHID_RP_LOG_LEVEL` | The log level. | `INFO` | +| Name | Description | Example | +|------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------| +| `EHEALTHID_RP_FEDERATION_ES_JWKS_PATH`* | Path to a JWKS with at least one keypair for signature of the entity statement. All these keys __MUST__ be registered with the federation master. | `./sig_jwks.json` | +| `EHEALTHID_RP_REDIRECT_URIS`* | Valid redirection URIs for OpenID connect. | `https://sso-mydiga.example.com/auth/callback` | +| `EHEALTHID_RP_BASE_URI`* | The external base URI of the relying party. This is also the `issuer` towards the OpenID federation. Additional paths are unsupported for now. | `https://mydiga-rp.example.com` | +| `EHEALTHID_RP_IDP_DISCOVERY_URI`* | The URI of the discovery document of your identity provider. Used to fetch public keys for client authentication. | `https://sso-mydiga.example.com/.well-known/openid-configuration` | +| `EHEALTHID_RP_FEDERATION_MASTER`* | The URI of the federation master. | `https://app-test.federationmaster.de` | +| `EHEALTHID_RP_APP_NAME`* | The application name within the federation. | `Awesome DiGA` | +| `EHEALTHID_RP_HOST` | Host to bind to. | `0.0.0.0` | +| `EHEALTHID_RP_PORT` | Port to bind to. | `1234` | +| `EHEALTHID_RP_ES_TTL` | The time to live for the entity statement. In ISO8601 format. | `PT12H` | +| `EHEALTHID_RP_SCOPES` | The comma separated list of scopes requested in the federation. This __MUST__ match what was registered with the federation master. | `openid,urn:telematik:email,urn:telematik:display_name` | +| `EHEALTHID_RP_SESSION_STORE_TTL` | The time to live for sessions. In ISO8601 format. | `PT20M` | +| `EHEALTHID_RP_SESSION_STORE_MAX_ENTRIES` | The maximum number of sessions to store. Keeps memory bounded. | `1000` | +| `EHEALTHID_RP_CODE_STORE_TTL` | The time to live for codes, i.e. successful logins where the code is not redeemed yet. In ISO8601 format. | `PT5M` | +| `EHEALTHID_RP_CODE_STORE_MAX_ENTRIES` | The maximum number of codes to store. Keeps memory bounded. | `1000` | +| `EHEALTHID_RP_LOG_LEVEL` | The log level. | `INFO` | # Generate Keys & Register for Federation @@ -220,8 +217,6 @@ sequenceDiagram # Open Points - end-to-end tests with Verimi, Gematik, RISE and IBM IDPs, most lack options to test currently -- [A_23183 - Veröffentlichen der TLS Authentisierungsschlüssel](https://gemspec.gematik.de/docs/gemSpec/gemSpec_IDP_FD/gemSpec_IDP_FD_V1.7.0/#A_23183) - - no option to test currently, though implemented # Wishlist diff --git a/ehealthid-cli/src/main/java/com/oviva/ehealthid/cli/KeyGeneratorCommand.java b/ehealthid-cli/src/main/java/com/oviva/ehealthid/cli/KeyGeneratorCommand.java index 1f9a081..22a2bf7 100755 --- a/ehealthid-cli/src/main/java/com/oviva/ehealthid/cli/KeyGeneratorCommand.java +++ b/ehealthid-cli/src/main/java/com/oviva/ehealthid/cli/KeyGeneratorCommand.java @@ -3,21 +3,16 @@ import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.jwk.*; import com.nimbusds.jose.jwk.gen.ECKeyGenerator; -import com.nimbusds.jose.util.Base64; -import com.nimbusds.oauth2.sdk.id.Issuer; -import com.nimbusds.oauth2.sdk.util.X509CertificateUtils; import java.io.IOException; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; -import java.security.cert.CertificateEncodingException; import java.time.Duration; import java.time.Instant; import java.util.Date; import java.util.List; import java.util.concurrent.Callable; -import org.bouncycastle.operator.OperatorCreationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import picocli.CommandLine; @@ -35,7 +30,8 @@ public class KeyGeneratorCommand implements Callable { @CommandLine.Option( names = {"-i", "--iss", "--issuer-uri"}, description = "the issuer uri of the 'Fachdienst' identiy provider", - required = true) + required = true, + defaultValue = "default") private URI issuerUri; private static final Logger logger = LoggerFactory.getLogger(KeyGeneratorCommand.class); @@ -43,16 +39,11 @@ public class KeyGeneratorCommand implements Callable { public Integer call() throws Exception { var sigName = "sig"; - var encName = "enc"; logger.atInfo().log("generating signing keys"); - var federationSigningKeys = generateSigningKey(issuerUri); - - logger.atInfo().log("generating encryption keys"); - var federationIdTokenEncryptionKey = generateEncryptionKey(); + var federationSigningKeys = generateSigningKey(); saveJwks(sigName + "_" + deriveName(issuerUri), new JWKSet(List.of(federationSigningKeys))); - saveJwks(encName + "_" + deriveName(issuerUri), new JWKSet(federationIdTokenEncryptionKey)); return 0; } @@ -66,36 +57,18 @@ private String deriveName(URI issuer) { return s; } - private JWK generateSigningKey(URI issuer) - throws JOSEException, IOException, CertificateEncodingException, OperatorCreationException { - - var key = - new ECKeyGenerator(Curve.P_256) - .keyUse(KeyUse.SIGNATURE) - .keyIDFromThumbprint(true) - .generate(); + private JWK generateSigningKey() throws JOSEException { + // https://gemspec.gematik.de/docs/gemSpec/gemSpec_IDP_FD/gemSpec_IDP_FD_V1.7.2/#A_23185-01 var now = Instant.now(); var nbf = now.minus(Duration.ofHours(24)); - var exp = now.plus(Duration.ofDays(180)); - - var cert = - X509CertificateUtils.generateSelfSigned( - new Issuer(issuer), - Date.from(nbf), - Date.from(exp), - key.toPublicKey(), - key.toPrivateKey()); - - key = new ECKey.Builder(key).x509CertChain(List.of(Base64.encode(cert.getEncoded()))).build(); - - return key; - } + var exp = now.plus(Duration.ofDays(366)); // < 398d - private JWK generateEncryptionKey() throws JOSEException { return new ECKeyGenerator(Curve.P_256) - .keyUse(KeyUse.ENCRYPTION) + .keyUse(KeyUse.SIGNATURE) .keyIDFromThumbprint(true) + .notBeforeTime(Date.from(nbf)) + .expirationTime(Date.from(exp)) .generate(); } diff --git a/ehealthid-rp/pom.xml b/ehealthid-rp/pom.xml index 2b03d70..e6567fa 100644 --- a/ehealthid-rp/pom.xml +++ b/ehealthid-rp/pom.xml @@ -30,14 +30,30 @@ com.github.spotbugs spotbugs-annotations + + com.nimbusds nimbus-jose-jwt + + com.nimbusds + oauth2-oidc-sdk + + + org.bouncycastle + bcprov-jdk18on + + + org.bouncycastle + bcpkix-jdk18on + + com.github.ben-manes.caffeine caffeine + @@ -155,22 +171,6 @@ test - - com.nimbusds - oauth2-oidc-sdk - test - - - org.bouncycastle - bcprov-jdk18on - test - - - org.bouncycastle - bcpkix-jdk18on - test - - diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ConfigReader.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ConfigReader.java index 4b3846d..e0800b0 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ConfigReader.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ConfigReader.java @@ -14,8 +14,8 @@ public class ConfigReader { - public static final String CONFIG_FEDERATION_ENC_JWKS_PATH = "federation_enc_jwks_path"; - public static final String CONFIG_FEDERATION_SIG_JWKS_PATH = "federation_sig_jwks_path"; + public static final String CONFIG_FEDERATION_ENTITY_STATEMENT_JWKS_PATH = + "federation_es_jwks_path"; public static final String CONFIG_BASE_URI = "base_uri"; public static final String CONFIG_HOST = "host"; public static final String CONFIG_PORT = "port"; @@ -42,8 +42,7 @@ public ConfigReader(ConfigProvider configProvider) { public Config read() { - var federationEncJwksPath = loadJwks(CONFIG_FEDERATION_ENC_JWKS_PATH); - var federationSigJwksPath = loadJwks(CONFIG_FEDERATION_SIG_JWKS_PATH); + var federationEntityStatementJwksPath = loadJwks(CONFIG_FEDERATION_ENTITY_STATEMENT_JWKS_PATH); var baseUri = configProvider @@ -84,13 +83,10 @@ public Config read() { .iss(baseUri) .appName(appName) .federationMaster(fedmaster) - .entitySigningKey(federationSigJwksPath.getKeys().get(0).toECKey()) // safety, remove the private key as we don't need it here - .entitySigningKeys(federationSigJwksPath.toPublicJWKSet()) - - // _MUST NOT_ be public. We need it for decryption. - .relyingPartyEncKeys(federationEncJwksPath) + .entitySigningKeys(federationEntityStatementJwksPath.toPublicJWKSet()) + .entitySigningKey(federationEntityStatementJwksPath.getKeys().get(0).toECKey()) .ttl(entityStatementTtl) .scopes(getScopes()) .redirectUris(List.of(baseUri.resolve("/auth/callback").toString())) diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/Main.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/Main.java index e085f26..b4d41a9 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/Main.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/Main.java @@ -28,6 +28,7 @@ import com.oviva.ehealthid.relyingparty.svc.TokenIssuer.Code; import com.oviva.ehealthid.relyingparty.svc.TokenIssuerImpl; import com.oviva.ehealthid.relyingparty.util.DiscoveryJwkSetSource; +import com.oviva.ehealthid.relyingparty.util.KeyGenerator; import com.oviva.ehealthid.relyingparty.util.LoggingHttpClient; import com.oviva.ehealthid.relyingparty.ws.App; import com.oviva.ehealthid.relyingparty.ws.HealthEndpoint; @@ -46,6 +47,7 @@ import java.net.http.HttpClient; import java.time.Clock; import java.time.Duration; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -118,13 +120,17 @@ public void start() throws ExecutionException, InterruptedException { var config = configReader.read(); + // generate fresh keys for the relying-party + config = replaceRelyingPartyKeys(config); + var keyStore = new KeyStore(); var meterRegistry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); var codeRepo = buildCodeRepo(config.codeStoreConfig(), meterRegistry); var tokenIssuer = new TokenIssuerImpl(config.baseUri(), keyStore, codeRepo); var sessionRepo = buildSessionRepo(config.sessionStore(), meterRegistry); - var sslContext = TlsContext.fromClientCertificate(config.federation().entitySigningKey()); + // the relying party signing key is for mTLS + var sslContext = TlsContext.fromClientCertificate(config.federation().relyingPartySigningKey()); var httpClient = HttpClient.newBuilder() @@ -136,7 +142,7 @@ public void start() throws ExecutionException, InterruptedException { buildAuthFlow( config.baseUri(), config.federation().federationMaster(), - config.federation().relyingPartyEncKeys(), + config.federation().relyingPartyKeys(), httpClient); var discoveryHttpClient = @@ -182,6 +188,39 @@ public void start() throws ExecutionException, InterruptedException { logger.atInfo().log("Management Server can be found at port {}", config.managementPort()); } + private ConfigReader.Config replaceRelyingPartyKeys(ConfigReader.Config config) { + + logger.atInfo().log( + "Generating fresh 'openid_relying_party' keys for mTLS and id_token encryption."); + + var signingKey = KeyGenerator.generateSigningKeyWithCertificate(config.federation().sub()); + var encKey = KeyGenerator.generateEncryptionKey(); + + var keys = new JWKSet(List.of(signingKey, encKey)); + + logger.atDebug().log("openid_relying_party signing key, kid={}", signingKey.getKeyID()); + logger.atDebug().log("openid_relying_party encryption key, kid={}", encKey.getKeyID()); + + var fedConfig = + config + .federation() + .builder() + .relyingPartySigningKey(signingKey.toECKey()) + .relyingPartyKeys(keys) + .build(); + + return new ConfigReader.Config( + config.relyingParty(), + fedConfig, + config.host(), + config.port(), + config.managementPort(), + config.baseUri(), + config.idpDiscoveryUri(), + config.sessionStore(), + config.codeStoreConfig()); + } + private com.oviva.ehealthid.fedclient.api.HttpClient instrumentHttpClient( com.oviva.ehealthid.fedclient.api.HttpClient client) { if (logger.isDebugEnabled()) { diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/cfg/EnvConfigProvider.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/cfg/EnvConfigProvider.java index eb31036..ba423b0 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/cfg/EnvConfigProvider.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/cfg/EnvConfigProvider.java @@ -3,13 +3,14 @@ import java.util.Locale; import java.util.Optional; import java.util.function.Function; +import java.util.function.UnaryOperator; public class EnvConfigProvider implements ConfigProvider { private final String prefix; private final Function getenv; - public EnvConfigProvider(String prefix, Function getenv) { + public EnvConfigProvider(String prefix, UnaryOperator getenv) { this.prefix = prefix; this.getenv = getenv; } diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/fed/FederationConfig.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/fed/FederationConfig.java index 0f4f16c..cdca809 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/fed/FederationConfig.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/fed/FederationConfig.java @@ -10,11 +10,16 @@ public record FederationConfig( URI iss, URI sub, URI federationMaster, + + // trusted signing keys in the federation JWKSet entitySigningKeys, // the actual private key used for signing, _MUST_ be part of `entitySigningKeys` ECKey entitySigningKey, - JWKSet relyingPartyEncKeys, + + // keys uses for the relying-party, i.e. mTLS and idToken encryption + JWKSet relyingPartyKeys, + ECKey relyingPartySigningKey, Duration ttl, List redirectUris, List scopes, @@ -24,6 +29,21 @@ public static Builder create() { return new Builder(); } + public Builder builder() { + return new Builder( + iss, + sub, + federationMaster, + entitySigningKeys, + entitySigningKey, + relyingPartyKeys, + relyingPartySigningKey, + ttl, + redirectUris, + scopes, + appName); + } + public static final class Builder { private URI iss; @@ -33,13 +53,39 @@ public static final class Builder { private ECKey entitySigningKey; private JWKSet entitySigningKeys; - private JWKSet relyingPartyEncKeys; + private JWKSet relyingPartyKeys; private Duration ttl; private List redirectUris; private List scopes; private String appName; - - public Builder() {} + private ECKey relyingPartySigningKey; + + private Builder() {} + + private Builder( + URI iss, + URI sub, + URI federationMaster, + JWKSet entitySigningKeys, + ECKey entitySigningKey, + JWKSet relyingPartyKeys, + ECKey relyingPartySigningKey, + Duration ttl, + List redirectUris, + List scopes, + String appName) { + this.iss = iss; + this.sub = sub; + this.federationMaster = federationMaster; + this.entitySigningKey = entitySigningKey; + this.entitySigningKeys = entitySigningKeys; + this.relyingPartyKeys = relyingPartyKeys; + this.ttl = ttl; + this.redirectUris = redirectUris; + this.scopes = scopes; + this.appName = appName; + this.relyingPartySigningKey = relyingPartySigningKey; + } public Builder iss(URI iss) { this.iss = iss; @@ -66,8 +112,13 @@ public Builder entitySigningKeys(JWKSet jwks) { return this; } - public Builder relyingPartyEncKeys(JWKSet jwks) { - this.relyingPartyEncKeys = jwks; + public Builder relyingPartyKeys(JWKSet jwks) { + this.relyingPartyKeys = jwks; + return this; + } + + public Builder relyingPartySigningKey(ECKey signingKey) { + this.relyingPartySigningKey = signingKey; return this; } @@ -98,7 +149,8 @@ public FederationConfig build() { federationMaster, entitySigningKeys, entitySigningKey, - relyingPartyEncKeys, + relyingPartyKeys, + relyingPartySigningKey, ttl, redirectUris, scopes, diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/fed/FederationEndpoint.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/fed/FederationEndpoint.java index f1c2f0a..0ef4abb 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/fed/FederationEndpoint.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/fed/FederationEndpoint.java @@ -28,7 +28,7 @@ public FederationEndpoint(FederationConfig federationConfig) { public Response get() { var federationEntityJwks = federationConfig.entitySigningKeys().toPublicJWKSet(); - var relyingPartyJwks = federationConfig.relyingPartyEncKeys().toPublicJWKSet(); + var relyingPartyJwks = federationConfig.relyingPartyKeys().toPublicJWKSet(); var now = Instant.now(); var exp = now.plus(federationConfig.ttl()); diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/poc/Environment.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/poc/Environment.java index 683fe01..72b2af0 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/poc/Environment.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/poc/Environment.java @@ -11,9 +11,11 @@ public class Environment { private static Logger logger = LoggerFactory.getLogger(Environment.class); + private Environment() {} + public static String gematikAuthHeader() { - // for testing in TU + // for testing in TU & RU var name = "GEMATIK_AUTH_HEADER"; var header = System.getenv(name); if (header != null && !header.isBlank()) { diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/util/KeyGenerator.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/util/KeyGenerator.java new file mode 100644 index 0000000..8352b02 --- /dev/null +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/util/KeyGenerator.java @@ -0,0 +1,73 @@ +package com.oviva.ehealthid.relyingparty.util; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.nimbusds.jose.util.Base64; +import com.nimbusds.oauth2.sdk.id.Issuer; +import com.nimbusds.oauth2.sdk.util.X509CertificateUtils; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.io.IOException; +import java.net.URI; +import java.security.cert.CertificateEncodingException; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import org.bouncycastle.operator.OperatorCreationException; + +public class KeyGenerator { + + private KeyGenerator() {} + + @NonNull + public static JWK generateSigningKeyWithCertificate(@NonNull URI issuer) { + + try { + var key = + new ECKeyGenerator(Curve.P_256) + .keyUse(KeyUse.SIGNATURE) + .keyIDFromThumbprint(true) + .generate(); + + var now = Instant.now(); + var nbf = now.minus(Duration.ofHours(3)); + + // https://gemspec.gematik.de/docs/gemSpec/gemSpec_IDP_FD/gemSpec_IDP_FD_V1.7.2/#A_23185-01 + var exp = now.plus(Duration.ofDays(366)); // < 398d + + var cert = + X509CertificateUtils.generateSelfSigned( + new Issuer(issuer), + Date.from(nbf), + Date.from(exp), + key.toPublicKey(), + key.toPrivateKey()); + + return new ECKey.Builder(key) + .x509CertChain(List.of(Base64.encode(cert.getEncoded()))) + .build(); + } catch (IOException + | OperatorCreationException + | JOSEException + | CertificateEncodingException e) { + throw new IllegalStateException( + "failed to generate signing key for issuer=%s".formatted(issuer), e); + } + } + + @NonNull + public static JWK generateEncryptionKey() { + try { + return new ECKeyGenerator(Curve.P_256) + .keyUse(KeyUse.ENCRYPTION) + .keyIDFromThumbprint(true) + .generate(); + } catch (JOSEException e) { + throw new IllegalStateException("failed to generate encryption key", e); + } + } +} diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/App.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/App.java index 9e0fdb3..3ab84e7 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/App.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/App.java @@ -12,6 +12,8 @@ import com.oviva.ehealthid.util.JoseModule; import jakarta.ws.rs.core.Application; import java.util.Set; +import org.jboss.resteasy.plugins.providers.ByteArrayProvider; +import org.jboss.resteasy.plugins.providers.StringTextStar; public class App extends Application { @@ -48,7 +50,9 @@ public Set getSingletons() { @Override public Set> getClasses() { - return Set.of(ThrowableExceptionMapper.class); + + // https://github.com/resteasy/resteasy/blob/f5fedb83d75ac88cad8fe79c0711b46a9db6a5ed/resteasy-core/src/main/resources/META-INF/services/jakarta.ws.rs.ext.Providers + return Set.of(ThrowableExceptionMapper.class, StringTextStar.class, ByteArrayProvider.class); } private ObjectMapper configureObjectMapper() { diff --git a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapper.java b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapper.java index 47d77d0..04b7423 100644 --- a/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapper.java +++ b/ehealthid-rp/src/main/java/com/oviva/ehealthid/relyingparty/ws/ThrowableExceptionMapper.java @@ -4,6 +4,7 @@ import static com.oviva.ehealthid.relyingparty.util.LocaleUtils.getNegotiatedLocale; import com.fasterxml.jackson.annotation.JsonProperty; +import com.oviva.ehealthid.fedclient.FederationException; import com.oviva.ehealthid.relyingparty.svc.AuthenticationException; import com.oviva.ehealthid.relyingparty.svc.ValidationException; import com.oviva.ehealthid.relyingparty.ws.ui.Pages; @@ -32,6 +33,8 @@ public class ThrowableExceptionMapper implements ExceptionMapper { private static final String SERVER_ERROR_MESSAGE = "error.serverError"; + private static final String FEDERATION_ERROR_MESSAGE = "error.federationError"; + private final Pages pages = new Pages(new TemplateRenderer()); @Context UriInfo uriInfo; @Context Request request; @@ -67,8 +70,14 @@ public Response toResponse(Throwable exception) { return buildContentNegotiatedErrorResponse(ve.localizedMessage(), Status.BAD_REQUEST); } + // the remaining exceptions are unexpected, let's log them log(exception); + if (exception instanceof FederationException fe) { + var errorMessage = new Message(FEDERATION_ERROR_MESSAGE, fe.reason().name()); + return buildContentNegotiatedErrorResponse(errorMessage, Status.INTERNAL_SERVER_ERROR); + } + var status = Status.INTERNAL_SERVER_ERROR; var errorMessage = new Message(SERVER_ERROR_MESSAGE, (String) null); diff --git a/ehealthid-rp/src/main/resources/i18n_de_DE.properties b/ehealthid-rp/src/main/resources/i18n_de_DE.properties index fe0c10f..46df774 100644 --- a/ehealthid-rp/src/main/resources/i18n_de_DE.properties +++ b/ehealthid-rp/src/main/resources/i18n_de_DE.properties @@ -2,6 +2,7 @@ lang=de-DE title=Anmeldung mit GesundheitsID error.login=Einloggen mit GesundheitsID error.serverError=Ohh nein! Unerwarteter Serverfehler. Bitte versuchen Sie es erneut. +error.federationError=Ohh nein! Unerwarteter Fehler der GesundheitsID. Bitte versuchen Sie es erneut. Grund: %s error.noProvider=Kein Identitätsanbieter ausgewählt. Bitte zurückgehen. error.invalidSession=Oops, Sitzung unbekannt oder abgelaufen. Bitte starten Sie erneut. error.insecureRedirect=Unsicherer redirect_uri='%s'. Falsch konfigurierter Server, bitte verwenden Sie 'https'. diff --git a/ehealthid-rp/src/main/resources/i18n_en_US.properties b/ehealthid-rp/src/main/resources/i18n_en_US.properties index 87a8fd0..e32fd78 100644 --- a/ehealthid-rp/src/main/resources/i18n_en_US.properties +++ b/ehealthid-rp/src/main/resources/i18n_en_US.properties @@ -1,6 +1,7 @@ lang=en-US title=Login with GesundheitsID error.serverError=Ohh no! Unexpected server error. Please try again. +error.federationError=Ohh no! Unexpected eHealthID error. Cause: %s error.noProvider =No identity provider selected. Please go back error.invalidSession=Oops, session unknown or expired. Please start again. error.insecureRedirect=Insecure redirect_uri='%s'. Misconfigured server, please use 'https'. diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ConfigReaderTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ConfigReaderTest.java index e979f5a..afc6b7e 100644 --- a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ConfigReaderTest.java +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/ConfigReaderTest.java @@ -21,9 +21,7 @@ void read_defaults() { var idpDiscoveryUri = "https://sso.example.com/.well-known/openid-configuration"; var appName = "Awesome DiGA"; - when(provider.get(ConfigReader.CONFIG_FEDERATION_ENC_JWKS_PATH)) - .thenReturn(Optional.of("./src/test/resources/fixtures/example_enc_jwks.json")); - when(provider.get(ConfigReader.CONFIG_FEDERATION_SIG_JWKS_PATH)) + when(provider.get(ConfigReader.CONFIG_FEDERATION_ENTITY_STATEMENT_JWKS_PATH)) .thenReturn(Optional.of("./src/test/resources/fixtures/example_sig_jwks.json")); when(provider.get(ConfigReader.CONFIG_BASE_URI)).thenReturn(Optional.of(baseUri)); when(provider.get(ConfigReader.CONFIG_APP_NAME)).thenReturn(Optional.of(appName)); @@ -55,7 +53,9 @@ void read_defaults() { assertNotNull(config.federation().entitySigningKey()); assertNotNull(config.federation().entitySigningKeys().getKeyByKeyId("test-sig")); - assertNotNull(config.federation().relyingPartyEncKeys().getKeyByKeyId("test-enc")); + + // these will be generated + assertNull(config.federation().relyingPartyKeys()); } @Test diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/cfg/EnvFederationConfigProviderTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/cfg/EnvFederationConfigProviderTest.java index 41af50c..e12ed75 100644 --- a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/cfg/EnvFederationConfigProviderTest.java +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/cfg/EnvFederationConfigProviderTest.java @@ -3,7 +3,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; -import java.util.function.Function; +import java.util.function.UnaryOperator; import java.util.stream.Stream; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -21,7 +21,7 @@ static Stream mangleTestCases() { @MethodSource("mangleTestCases") void getMangleName(TC t) { - var getenv = (Function) mock(Function.class); + var getenv = (UnaryOperator) mock(UnaryOperator.class); var sut = new EnvConfigProvider(PREFIX, getenv); diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/fed/FederationEndpointTest.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/fed/FederationEndpointTest.java index 7fcda70..94f8ca9 100644 --- a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/fed/FederationEndpointTest.java +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/fed/FederationEndpointTest.java @@ -54,7 +54,7 @@ static void setUp() throws ExecutionException, InterruptedException, JOSEExcepti .appName("My App") .scopes(List.of("openid", "email")) .federationMaster(FEDMASTER) - .relyingPartyEncKeys(new JWKSet(encryptionKey)) + .relyingPartyKeys(new JWKSet(encryptionKey)) .entitySigningKeys(new JWKSet(signatureKey)) .entitySigningKey(signatureKey) .ttl(Duration.ofMinutes(5)) diff --git a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/EmbeddedRelyingParty.java b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/EmbeddedRelyingParty.java index 223f366..839ba2d 100644 --- a/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/EmbeddedRelyingParty.java +++ b/ehealthid-rp/src/test/java/com/oviva/ehealthid/relyingparty/test/EmbeddedRelyingParty.java @@ -32,8 +32,7 @@ public URI start() throws ExecutionException, InterruptedException { var config = StaticConfig.fromRawProperties( """ - federation_enc_jwks_path=src/test/resources/fixtures/example_enc_jwks.json - federation_sig_jwks_path=src/test/resources/fixtures/example_sig_jwks.json + federation_es_jwks_path=src/test/resources/fixtures/example_sig_jwks.json base_uri=%s idp_discovery_uri=%s redirect_uris=%s diff --git a/ehealthid-rp/src/test/resources/fixtures/example_enc_jwks.json b/ehealthid-rp/src/test/resources/fixtures/example_enc_jwks.json deleted file mode 100644 index 04c8916..0000000 --- a/ehealthid-rp/src/test/resources/fixtures/example_enc_jwks.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "keys": [ - { - "kty": "EC", - "d": "WlqzsmBkqfL3HmSHy8MHB-9-pnL9A8pzSofEKOVNmWQ", - "use": "enc", - "crv": "P-256", - "kid": "test-enc", - "x": "00G2e-vi6vG_HiOeSGe2Z8D8ihkOsM-X2MgqNMvo1qg", - "y": "bTV0l6efZEpU1Tw40Ke_MdMXwuKaoKp8sBvpoKULUX4" - } - ] -} \ No newline at end of file diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationException.java b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationException.java index 3a26674..42a7323 100644 --- a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationException.java +++ b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationException.java @@ -2,11 +2,25 @@ public class FederationException extends RuntimeException { - public FederationException(String message) { - super(message); + private final Reason reason; + + public FederationException(String message, Reason reason) { + this(message, null, reason); } - public FederationException(String message, Throwable cause) { + public FederationException(String message, Throwable cause, Reason reason) { super(message, cause); + this.reason = reason; + } + + public Reason reason() { + return reason; + } + + public enum Reason { + UNKNOWN, + UNTRUSTED_IDP, + INVALID_ENTITY_STATEMENT, + BAD_FEDERATION_MASTER } } diff --git a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationExceptions.java b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationExceptions.java index 9d12112..0508468 100644 --- a/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationExceptions.java +++ b/ehealthid/src/main/java/com/oviva/ehealthid/fedclient/FederationExceptions.java @@ -7,56 +7,73 @@ public class FederationExceptions { private FederationExceptions() {} public static FederationException badEntityStatement(Exception cause) { - return new FederationException("failed to parse entity statement", cause); + return new FederationException( + "failed to parse entity statement", + cause, + FederationException.Reason.INVALID_ENTITY_STATEMENT); } public static FederationException badIdpList(Exception cause) { - return new FederationException("failed to parse idp list", cause); + return new FederationException( + "failed to parse idp list", cause, FederationException.Reason.BAD_FEDERATION_MASTER); } public static FederationException notAnIdpList(String actualType) { return new FederationException( - "JWS is not of type idp-list but rather '%s'".formatted(actualType)); + "JWS is not of type idp-list but rather '%s'".formatted(actualType), + FederationException.Reason.BAD_FEDERATION_MASTER); } public static FederationException emptyIdpList(URI master) { - return new FederationException("list of idps empty from '%s'".formatted(master)); + return new FederationException( + "list of idps empty from '%s'".formatted(master), + FederationException.Reason.BAD_FEDERATION_MASTER); } public static FederationException badSignature(Exception cause) { - return new FederationException("bad signature", cause); + return new FederationException( + "bad signature", cause, FederationException.Reason.INVALID_ENTITY_STATEMENT); } public static FederationException notAnEntityStatement(String actualType) { return new FederationException( - "JWS is not of type entity statement but rather '%s'".formatted(actualType)); + "JWS is not of type entity statement but rather '%s'".formatted(actualType), + FederationException.Reason.INVALID_ENTITY_STATEMENT); } public static FederationException entityStatementMissingFederationFetchUrl(String sub) { return new FederationException( - "entity statement of '%s' has no federation fetch url".formatted(sub)); + "entity statement of '%s' has no federation fetch url".formatted(sub), + FederationException.Reason.INVALID_ENTITY_STATEMENT); } public static FederationException entityStatementTimeNotValid(String sub) { return new FederationException( - "entity statement of '%s' expired or not yet valid".formatted(sub)); + "entity statement of '%s' expired or not yet valid".formatted(sub), + FederationException.Reason.UNTRUSTED_IDP); } public static FederationException entityStatementBadSignature(String sub) { - return new FederationException("entity statement of '%s' has a bad signature".formatted(sub)); + return new FederationException( + "entity statement of '%s' has a bad signature".formatted(sub), + FederationException.Reason.UNTRUSTED_IDP); } public static FederationException federationStatementTimeNotValid(String sub) { return new FederationException( - "federation statement of '%s' expired or not yet valid".formatted(sub)); + "federation statement of '%s' expired or not yet valid".formatted(sub), + FederationException.Reason.UNTRUSTED_IDP); } public static FederationException federationStatementBadSignature(String sub) { return new FederationException( - "federation statement of '%s' has a bad signature".formatted(sub)); + "federation statement of '%s' has a bad signature".formatted(sub), + FederationException.Reason.UNTRUSTED_IDP); } public static FederationException untrustedFederationStatement(String sub) { - return new FederationException("federation statement untrusted: sub=%s".formatted(sub)); + return new FederationException( + "federation statement untrusted: sub=%s".formatted(sub), + FederationException.Reason.UNTRUSTED_IDP); } } diff --git a/ehealthid/src/test/java/com/oviva/ehealthid/fedclient/api/OpenIdClientMTlsTest.java b/ehealthid/src/test/java/com/oviva/ehealthid/fedclient/api/OpenIdClientMTlsTest.java index 5c78d4f..04cce2b 100644 --- a/ehealthid/src/test/java/com/oviva/ehealthid/fedclient/api/OpenIdClientMTlsTest.java +++ b/ehealthid/src/test/java/com/oviva/ehealthid/fedclient/api/OpenIdClientMTlsTest.java @@ -90,7 +90,7 @@ void requestPushedUri_mTls() throws Exception { var req = serveEvents.get(0); assertEquals("https", req.getRequest().getScheme()); - assertEquals(res.requestUri(), PAR_REDIRECT); + assertEquals(PAR_REDIRECT, res.requestUri()); } private com.oviva.ehealthid.fedclient.api.HttpClient newHttpClient() @@ -163,7 +163,6 @@ private static Path createTrustManager(JWK key) { for (X509Certificate cert : key.getParsedX509CertChain()) { ks.setCertificateEntry(ISSUER.toString(), cert); - // ks.setCertificateEntry("localhost", cert); } ks.store(fout, password); diff --git a/start.sh b/start.sh index 4fc58cb..a3e30e2 100755 --- a/start.sh +++ b/start.sh @@ -2,11 +2,11 @@ export EHEALTHID_RP_APP_NAME=Awesome DiGA export EHEALTHID_RP_BASE_URI=https://t.oviva.io -export EHEALTHID_RP_FEDERATION_ENC_JWKS_PATH=./enc_t_oviva_io_jwks.json -export EHEALTHID_RP_FEDERATION_SIG_JWKS_PATH=./sig_t_oviva_io_jwks.json +export EHEALTHID_RP_FEDERATION_ES_JWKS_PATH=./sig_t_oviva_io_jwks.json export EHEALTHID_RP_FEDERATION_MASTER=https://app-ref.federationmaster.de export EHEALTHID_RP_REDIRECT_URIS=https://sso-mydiga.example.com/auth/callback export EHEALTHID_RP_ES_TTL=PT5M +export EHEALTHID_RP_LOG_LEVEL=DEBUG export EHEALTHID_RP_IDP_DISCOVERY_URI=https://sso-mydiga.example.com/.well-known/openid-configuration java -jar ehealthid-rp/target/ehealthid-rp-jar-with-dependencies.jar