From 6c2b8a9d2ecc2ecbe9f44068d0192dc08d7f7649 Mon Sep 17 00:00:00 2001 From: Janno Kusman Date: Tue, 3 Dec 2024 10:34:31 +0200 Subject: [PATCH] RM-3898: authtoken check --- README.md | 15 +- admin-guide.md | 8 + pom.xml | 5 +- server-db/pom.xml | 14 +- .../ee/cyber/cdoc2/server/model}/Crypto.java | 5 +- .../cdoc2/server/model/entity/KeyShareDb.java | 2 +- .../server/model/entity/KeyShareNonceDb.java | 2 +- .../repository/KeyShareNonceRepository.java | 3 + server-openapi/pom.xml | 12 +- shared-crypto/pom.xml | 121 ----- shares-server/pom.xml | 47 +- .../cdoc2/server/api/KeyShareApiImpl.java | 146 ------ .../cdoc2/server/api/KeyShareApiService.java | 495 ++++++++++++++++++ .../server/config/SecurityConfiguration.java | 1 - .../cyber/cdoc2/server/KeyShareApiTests.java | 19 +- .../cdoc2/server/KeyShareIntegrationTest.java | 13 + .../java/ee/cyber/cdoc2/server/TestData.java | 147 ++++++ .../api/KeyShareApiAuthenticationTest.java | 192 +++++++ .../src/test/resources/application.properties | 54 +- shares-server/src/test/resources/logback.xml | 4 +- ...of_EE_Certification_Centre_Root_CA.pem.crt | 24 + .../TEST_of_EID-SK_2016.pem.crt | 40 ++ .../sid-trusted-issuers/sk-ca.localhost.crt | 13 + .../test_sid_trusted_issuers.jks | Bin 0 -> 4006 bytes 24 files changed, 1044 insertions(+), 338 deletions(-) rename {shared-crypto/src/main/java/ee/cyber/cdoc2/shared/crypto => server-db/src/main/java/ee/cyber/cdoc2/server/model}/Crypto.java (80%) delete mode 100644 shared-crypto/pom.xml delete mode 100644 shares-server/src/main/java/ee/cyber/cdoc2/server/api/KeyShareApiImpl.java create mode 100644 shares-server/src/main/java/ee/cyber/cdoc2/server/api/KeyShareApiService.java create mode 100644 shares-server/src/test/java/ee/cyber/cdoc2/server/api/KeyShareApiAuthenticationTest.java create mode 100644 shares-server/src/test/resources/sid-trusted-issuers/TEST_of_EE_Certification_Centre_Root_CA.pem.crt create mode 100644 shares-server/src/test/resources/sid-trusted-issuers/TEST_of_EID-SK_2016.pem.crt create mode 100644 shares-server/src/test/resources/sid-trusted-issuers/sk-ca.localhost.crt create mode 100644 shares-server/src/test/resources/sid-trusted-issuers/test_sid_trusted_issuers.jks diff --git a/README.md b/README.md index a28f1fd..780d35b 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@ CDOC2 Key Shares Server for [CDOC2](https://open-eid.github.io/CDOC2/). Implements `cdoc2-key-shares-openapi` [OpenAPI spec](https://github.com/open-eid/cdoc2-openapi/blob/master/cdoc2-key-shares-openapi.yaml) from [cdoc2-openapi](https://github.com/open-eid/cdoc2-openapi/) for Key Shares upload/download. Used by [cdoc2-java-ref-impl](https://github.com/open-eid/cdoc2-java-ref-impl) -and [DigiDoc4-Client](https://github.com/open-eid/DigiDoc4-Client) for CDOC2 encryption/decryption server scenarios. +and [DigiDoc4-Client](https://github.com/open-eid/DigiDoc4-Client) for CDOC2 encryption/decryption Smart-ID/Mobile-ID scenarios. ## Structure - server - Implements `/key-shares` API-s. - server-db - shared DB code. Liquibase based DB creation - server-openapi - server stub generation from OpenAPI specifications - - cdoc2-shared-crypto - some shared crypto functions + ## Preconditions for building * Java 17 @@ -73,12 +73,7 @@ See [getting-started.md](getting-started.md) and [admin-guide.md](admin-guide.md ### Running pre-built Docker/OCI images -Download `cdoc2-shares-server` image from [open-eid Container registry](https://github.com/orgs/open-eid/packages?ecosystem=container) - -* See [cdoc2-gatling-tests/setup-load-testing](https://github.com/open-eid/cdoc2-gatling-tests/tree/master/setup-load-testing) for `docker run` examples -* See [cdoc2-java-ref-impl/test/config/server/docker-compose.yml](https://github.com/open-eid/cdoc2-java-ref-impl/blob/master/test/config/server/docker-compose.yml) for `docker compose` example - -To create `cdoc2` database required by `server` see [postgres.README.md](postgres.README.md) +TODO: ## Releasing and versioning @@ -90,7 +85,3 @@ See [VERSIONING.md](https://github.com/open-eid/cdoc2-java-ref-impl/blob/master/ It will trigger `maven-release.yml` workflow that will deploy Maven packages to GitHub Maven package repository and build & publish Docker/OCI images. - -## Related projects - -* Gatling tests (load and functional) for cdoc2-shares-server https://github.com/open-eid/cdoc2-gatling-tests diff --git a/admin-guide.md b/admin-guide.md index 1c7881a..8557042 100644 --- a/admin-guide.md +++ b/admin-guide.md @@ -91,6 +91,14 @@ spring.datasource.driver-class-name=org.postgresql.Driver # change to 'debug' if you want to see logs. Run server with -Dlogging.config=target/test-classes/logback.xml logging.level.root=info logging.level.ee.cyber.cdoc2=trace + +# Enable/disable certificate revocation checking for auth ticket certificates, +# experimental feature, default value is "false" +cdoc2.auth-x5c.revocation-checks.enabled=false + +# nonce validity time in seconds, default 300 +cdoc2.nonce.expiration.seconds=300 + ``` #### Running diff --git a/pom.xml b/pom.xml index 9a9bdc9..379e073 100644 --- a/pom.xml +++ b/pom.xml @@ -2,9 +2,9 @@ 4.0.0 - cdoc2-shares-server + cdoc2-shares-server-pom ee.cyber.cdoc2 - 0.1.0-SNAPSHOT + 0.2.0-SNAPSHOT CDOC2 key shares server pom @@ -14,7 +14,6 @@ server-openapi server-db shares-server - shared-crypto diff --git a/server-db/pom.xml b/server-db/pom.xml index d6d327a..da4f176 100644 --- a/server-db/pom.xml +++ b/server-db/pom.xml @@ -4,22 +4,16 @@ ee.cyber.cdoc2 - cdoc2-shares-server - 0.1.0-SNAPSHOT + cdoc2-shares-server-pom + 0.2.0-SNAPSHOT .. - cdoc2-server-db - 0.1.0-SNAPSHOT + cdoc2-css-db + 0.1.1-SNAPSHOT jar - - ee.cyber.cdoc2 - cdoc2-shared-crypto - 0.1.0-SNAPSHOT - - org.springframework.boot spring-boot-starter-data-jpa diff --git a/shared-crypto/src/main/java/ee/cyber/cdoc2/shared/crypto/Crypto.java b/server-db/src/main/java/ee/cyber/cdoc2/server/model/Crypto.java similarity index 80% rename from shared-crypto/src/main/java/ee/cyber/cdoc2/shared/crypto/Crypto.java rename to server-db/src/main/java/ee/cyber/cdoc2/server/model/Crypto.java index 722a24b..a51ad17 100644 --- a/shared-crypto/src/main/java/ee/cyber/cdoc2/shared/crypto/Crypto.java +++ b/server-db/src/main/java/ee/cyber/cdoc2/server/model/Crypto.java @@ -1,4 +1,4 @@ -package ee.cyber.cdoc2.shared.crypto; +package ee.cyber.cdoc2.server.model; import java.security.DrbgParameters; import java.security.NoSuchAlgorithmException; @@ -26,7 +26,8 @@ public static synchronized SecureRandom getSecureRandom() throws NoSuchAlgorithm private static SecureRandom createSecureRandom() throws NoSuchAlgorithmException { log.debug("Initializing SecureRandom"); - SecureRandom sRnd = SecureRandom.getInstance("DRBG", DrbgParameters.instantiation(256, DrbgParameters.Capability.PR_AND_RESEED, "CDOC2".getBytes())); + SecureRandom sRnd = SecureRandom.getInstance("DRBG", + DrbgParameters.instantiation(256, DrbgParameters.Capability.PR_AND_RESEED, "CDOC2".getBytes())); log.info("Initialized SecureRandom."); return sRnd; } diff --git a/server-db/src/main/java/ee/cyber/cdoc2/server/model/entity/KeyShareDb.java b/server-db/src/main/java/ee/cyber/cdoc2/server/model/entity/KeyShareDb.java index 3d034de..4a6fd95 100644 --- a/server-db/src/main/java/ee/cyber/cdoc2/server/model/entity/KeyShareDb.java +++ b/server-db/src/main/java/ee/cyber/cdoc2/server/model/entity/KeyShareDb.java @@ -19,7 +19,7 @@ import org.hibernate.type.SqlTypes; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -import ee.cyber.cdoc2.shared.crypto.Crypto; +import ee.cyber.cdoc2.server.model.Crypto; /** diff --git a/server-db/src/main/java/ee/cyber/cdoc2/server/model/entity/KeyShareNonceDb.java b/server-db/src/main/java/ee/cyber/cdoc2/server/model/entity/KeyShareNonceDb.java index 2cf9dd9..ab5c460 100644 --- a/server-db/src/main/java/ee/cyber/cdoc2/server/model/entity/KeyShareNonceDb.java +++ b/server-db/src/main/java/ee/cyber/cdoc2/server/model/entity/KeyShareNonceDb.java @@ -18,7 +18,7 @@ import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; -import ee.cyber.cdoc2.shared.crypto.Crypto; +import ee.cyber.cdoc2.server.model.Crypto; /** diff --git a/server-db/src/main/java/ee/cyber/cdoc2/server/model/repository/KeyShareNonceRepository.java b/server-db/src/main/java/ee/cyber/cdoc2/server/model/repository/KeyShareNonceRepository.java index 87035f3..35174fd 100644 --- a/server-db/src/main/java/ee/cyber/cdoc2/server/model/repository/KeyShareNonceRepository.java +++ b/server-db/src/main/java/ee/cyber/cdoc2/server/model/repository/KeyShareNonceRepository.java @@ -5,7 +5,10 @@ import ee.cyber.cdoc2.server.model.entity.KeyShareNonceDb; +import java.util.Optional; + public interface KeyShareNonceRepository extends JpaRepository { + Optional findByShareIdAndNonce(String shareId, byte[] nonce); } diff --git a/server-openapi/pom.xml b/server-openapi/pom.xml index 7396ca3..005ec69 100644 --- a/server-openapi/pom.xml +++ b/server-openapi/pom.xml @@ -6,14 +6,14 @@ ee.cyber.cdoc2 - cdoc2-shares-server - 0.1.0-SNAPSHOT + cdoc2-shares-server-pom + 0.2.0-SNAPSHOT .. - cdoc2-server-openapi - 0.1.0-SNAPSHOT - CDOC2 server stub generation from OpenAPI spec + cdoc2-css-openapi + 0.1.1-SNAPSHOT + CDOC2 shares server stub generation from OpenAPI spec 17 @@ -28,7 +28,7 @@ 4.8.3 - 1.0.0-draft + 1.0.1-draft diff --git a/shared-crypto/pom.xml b/shared-crypto/pom.xml deleted file mode 100644 index 28552bb..0000000 --- a/shared-crypto/pom.xml +++ /dev/null @@ -1,121 +0,0 @@ - - - 4.0.0 - - ee.cyber.cdoc2 - cdoc2-shared-crypto - 0.1.0-SNAPSHOT - CDOC2 common crypto functions shared between lib and server - jar - - - 17 - 17 - UTF-8 - 1.78.1 - 1.5.7 - - - - - - github_ci - - - env.GITHUB_ACTIONS - true - - - - - - github - - https://maven.pkg.github.com/${env.GITHUB_REPOSITORY} - - - - - - - gitlab_ci - - - env.GITLAB_CI - true - - - - - - - ${env.CI_SERVER_HOST} - - ${env.CI_SERVER_URL}/api/v4/projects/${env.CI_PROJECT_ID}/packages/maven - - - ${env.CI_SERVER_HOST} - - ${env.CI_SERVER_URL}/api/v4/projects/${env.CI_PROJECT_ID}/packages/maven - - - - - - - - - ch.qos.logback - logback-classic - ${logback.version} - - - - - org.bouncycastle - bcpkix-jdk18on - ${bouncycastle.version} - - - - - - - - - - - org.honton.chas - exists-maven-plugin - 0.13.0 - - - install - - remote - - - - deploy - - - - - false - true - - - - - - org.apache.maven.plugins - maven-deploy-plugin - 3.1.3 - - - - - - \ No newline at end of file diff --git a/shares-server/pom.xml b/shares-server/pom.xml index c73972f..bf8b33a 100644 --- a/shares-server/pom.xml +++ b/shares-server/pom.xml @@ -10,12 +10,16 @@ ee.cyber.cdoc2 - shares-server - 0.1.0-SNAPSHOT + cdoc2-shares-server + 0.2.0-SNAPSHOT jar - shares-server - CDOC2 server for creating and getting key shares capsules + cdoc2-shares-server + CDOC2 server for storing/retrieving key shares. Key shares are used to + split/recreate encryption/decryption key material for auth means (Smart-ID/Mobile-ID). + Implements `/key-shares` OAS https://github.com/open-eid/cdoc2-openapi . Full + auth means schema description https://open-eid.github.io/CDOC2/ + 17 @@ -159,45 +163,38 @@ - - ee.cyber.cdoc2 - cdoc2-shared-crypto - 0.1.0-SNAPSHOT - - ee.cyber.cdoc2 - cdoc2-server-openapi - 0.1.0-SNAPSHOT + ee.cyber.cdoc2.auth + cdoc2-key-shares-auth + 0.1.1-SNAPSHOT ee.cyber.cdoc2 - cdoc2-server-db - 0.1.0-SNAPSHOT + cdoc2-css-openapi + 0.1.1-SNAPSHOT ee.cyber.cdoc2 - cdoc2-lib - SID-2.1.0-SNAPSHOT - test + cdoc2-css-db + 0.1.1-SNAPSHOT ee.cyber.cdoc2 - cdoc2-lib - - SID-2.1.0-SNAPSHOT - - test-jar + cdoc2-client + SID-1.6.1-SNAPSHOT test + - ee.cyber.cdoc2 - cdoc2-client - SID-1.6.0-SNAPSHOT + org.bouncycastle + bcpkix-jdk18on + 1.78 test diff --git a/shares-server/src/main/java/ee/cyber/cdoc2/server/api/KeyShareApiImpl.java b/shares-server/src/main/java/ee/cyber/cdoc2/server/api/KeyShareApiImpl.java deleted file mode 100644 index dca2b2d..0000000 --- a/shares-server/src/main/java/ee/cyber/cdoc2/server/api/KeyShareApiImpl.java +++ /dev/null @@ -1,146 +0,0 @@ -package ee.cyber.cdoc2.server.api; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import java.net.URI; -import java.net.URISyntaxException; -import java.util.Optional; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; -import org.springframework.web.context.request.NativeWebRequest; - -import ee.cyber.cdoc2.server.generated.api.KeySharesApi; -import ee.cyber.cdoc2.server.generated.api.KeySharesApiController; -import ee.cyber.cdoc2.server.generated.api.KeySharesApiDelegate; -import ee.cyber.cdoc2.server.generated.model.KeyShare; -import ee.cyber.cdoc2.server.generated.model.NonceResponse; -import ee.cyber.cdoc2.server.model.entity.KeyShareDb; -import ee.cyber.cdoc2.server.model.entity.KeyShareNonceDb; -import ee.cyber.cdoc2.server.model.repository.KeyShareNonceRepository; -import ee.cyber.cdoc2.server.model.repository.KeyShareRepository; - -import static ee.cyber.cdoc2.server.Utils.getPathAndQueryPart; -import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; -import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; - - -/** - * Implements API for getting and creating CDOC2 key shares {@link KeySharesApi} - */ -@Service -@Slf4j -@RequiredArgsConstructor -public class KeyShareApiImpl implements KeySharesApiDelegate { - - private final NativeWebRequest nativeWebRequest; - - private final KeyShareRepository keyShareRepository; - - private final KeyShareNonceRepository shareNonceRepository; - - @Override - public Optional getRequest() { - return Optional.of(this.nativeWebRequest); - } - - @Override - public ResponseEntity createKeyShare(KeyShare keyShare) { - log.trace("createKeyShare(share={} bytes, recipient={} bytes)", - keyShare.getShare().length, keyShare.getRecipient() - ); - - try { - var saved = this.keyShareRepository.save( - new KeyShareDb() - .setShare(keyShare.getShare()) - .setRecipient(keyShare.getRecipient()) - ); - - log.info("KeyShare(shareId={}) created", saved.getShareId()); - - URI created = getResourceLocation(saved.getShareId()); - - return ResponseEntity.created(created).build(); - } catch (Exception e) { - log.error( - "Failed to save key share(share={} bytes, recipient={})", - keyShare.getShare().length, - keyShare.getRecipient(), - e - ); - return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - @Override - public ResponseEntity createNonce(String shareId, Object body) { - log.trace("createNonce(shareId={}, body={})", shareId, body); - Optional keyShare = this.keyShareRepository.findById(shareId); - if (keyShare.isEmpty()) { - log.error("Key share with shareId {} not found", shareId); - return new ResponseEntity<>(HttpStatus.NOT_FOUND); - } - - try { - var saved = this.shareNonceRepository.save( - new KeyShareNonceDb() - .setShareId(keyShare.get().getShareId()) - ); - - log.info("KeyShareNonce(shareId = {}, nonce = {}) created", shareId, saved.getNonce()); - - return ResponseEntity.ok(createNonceResponse(saved.getNonce())); - } catch (Exception e) { - log.error( - "Failed to create key share nonce for share ID {}", shareId, - e - ); - return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - @Override - public ResponseEntity getKeyShareByShareId(String shareId, byte[] xAuthTicket) { - Optional shareDbOpt = this.keyShareRepository.findById(shareId); - if (shareDbOpt.isEmpty()) { - log.debug("Key share with shareId {} not found", shareId); - return new ResponseEntity<>(HttpStatus.NOT_FOUND); - } - - return ResponseEntity.ok(createKeyShare(shareDbOpt.get())); - } - - private static KeyShare createKeyShare(KeyShareDb share) { - var response = new KeyShare(); - response.setRecipient(share.getRecipient()); - response.setShare(share.getShare()); - - return response; - } - - private static NonceResponse createNonceResponse(byte[] nonce) { - var response = new NonceResponse(); - response.setNonce(nonce); - - return response; - } - - /** - * Get URI for getting Key Share resource (Location). - * @param id Share id example: KC9b7036de0c9fce889850c4bbb1e23482 - * @return URI (path and query) example: /key-shares/KC9b7036de0c9fce889850c4bbb1e23482 - * @throws URISyntaxException in case of URI syntax error - */ - private static URI getResourceLocation(String id) throws URISyntaxException { - return getPathAndQueryPart( - linkTo(methodOn( - KeySharesApiController.class - // ToDo replace xAuthTicket bytes with auth token after implementation - ).getKeyShareByShareId(id, new byte[32])).toUri() - ); - } - -} diff --git a/shares-server/src/main/java/ee/cyber/cdoc2/server/api/KeyShareApiService.java b/shares-server/src/main/java/ee/cyber/cdoc2/server/api/KeyShareApiService.java new file mode 100644 index 0000000..cfc0a85 --- /dev/null +++ b/shares-server/src/main/java/ee/cyber/cdoc2/server/api/KeyShareApiService.java @@ -0,0 +1,495 @@ +package ee.cyber.cdoc2.server.api; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.util.X509CertUtils; +import ee.cyber.cdoc2.auth.AuthTokenVerifier; +import ee.cyber.cdoc2.auth.IllegalCertificateException; +import ee.cyber.cdoc2.auth.SIDCertificateUtil; +import ee.cyber.cdoc2.auth.ShareAccessData; +import ee.cyber.cdoc2.auth.VerificationException; +import jakarta.annotation.Nullable; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.Security; +import java.security.cert.CertPath; +import java.security.cert.CertPathValidator; +import java.security.cert.CertPathValidatorException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.PKIXParameters; +import java.security.cert.X509Certificate; +import java.text.ParseException; +import java.time.Instant; +import java.util.Base64; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.NativeWebRequest; + +import ee.cyber.cdoc2.server.generated.api.KeySharesApi; +import ee.cyber.cdoc2.server.generated.api.KeySharesApiController; +import ee.cyber.cdoc2.server.generated.api.KeySharesApiDelegate; +import ee.cyber.cdoc2.server.generated.model.KeyShare; +import ee.cyber.cdoc2.server.generated.model.NonceResponse; +import ee.cyber.cdoc2.server.model.entity.KeyShareDb; +import ee.cyber.cdoc2.server.model.entity.KeyShareNonceDb; +import ee.cyber.cdoc2.server.model.repository.KeyShareNonceRepository; +import ee.cyber.cdoc2.server.model.repository.KeyShareRepository; +import org.springframework.web.server.ResponseStatusException; + +import static ee.cyber.cdoc2.auth.Constants.NONCE; +import static ee.cyber.cdoc2.auth.Constants.SERVER_BASE_URL; +import static ee.cyber.cdoc2.auth.Constants.SHARE_ACCESS_DATA; +import static ee.cyber.cdoc2.auth.Constants.SHARE_ID; +import static ee.cyber.cdoc2.server.Utils.getPathAndQueryPart; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + + +/** + * Implements API for getting and creating CDOC2 key shares {@link KeySharesApi} + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class KeyShareApiService implements KeySharesApiDelegate { + + private static final String VALUE_IS_MISSING = "value is missing"; + + private final NativeWebRequest nativeWebRequest; + + private final KeyShareRepository keyShareRepository; + + private final KeyShareNonceRepository shareNonceRepository; + + // configure sslBundles in application.properties + // https://docs.spring.io/spring-boot/reference/features/ssl.html#features.ssl.pem + private final SslBundles sslBundles; + + @Value("${cdoc2.auth-x5c.revocation-checks.enabled:false}") + private boolean revocationCheckEnabled; + + @Value("${cdoc2.nonce.expiration.seconds:300}") + private long nonceExpirationSeconds; + + @Override + public Optional getRequest() { + return Optional.of(this.nativeWebRequest); + } + + @Override + public ResponseEntity createKeyShare(KeyShare keyShare) { + log.trace("createKeyShare(share={} bytes, recipient={} bytes)", + keyShare.getShare().length, keyShare.getRecipient() + ); + + try { + var saved = this.keyShareRepository.save( + new KeyShareDb() + .setShare(keyShare.getShare()) + .setRecipient(keyShare.getRecipient()) + ); + + log.info("KeyShare(shareId={}) created", saved.getShareId()); + + URI created = getResourceLocation(saved.getShareId()); + + return ResponseEntity.created(created).build(); + } catch (Exception e) { + log.error( + "Failed to save key share(share={} bytes, recipient={})", + keyShare.getShare().length, + keyShare.getRecipient(), + e + ); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Override + public ResponseEntity createNonce(String shareId, Object body) { + log.trace("createNonce(shareId={}, body={})", shareId, body); + Optional keyShare = this.keyShareRepository.findById(shareId); + if (keyShare.isEmpty()) { + log.error("Key share with shareId {} not found", shareId); + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + + try { + var saved = this.shareNonceRepository.save( + new KeyShareNonceDb().setShareId(keyShare.get().getShareId()) + ); + + log.info("KeyShareNonce(shareId = {}, nonce = {}) created", shareId, saved.getNonce()); + + return ResponseEntity.ok(createNonceResponse(saved.getNonce())); + } catch (Exception e) { + log.error( + "Failed to create key share nonce for share ID {}", shareId, e + ); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Override + public ResponseEntity getKeyShareByShareId( + String shareId, + String xAuthTicket, + String xAuthCert +) { + // check xAuthTicket + String ticketRecipient; // "etsi/PNOEE-30303039914" + try { + ticketRecipient = validateAuthTicket(shareId, xAuthTicket, xAuthCert); + } catch (VerificationException ve) { + + if (log.isDebugEnabled()) { + log.debug("Auth ticket validation failed", ve); + } else { + log.info("Auth ticket validation failed with {}", ve.getMessage()); + } + + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } + + Optional shareDbOpt = this.keyShareRepository.findById(shareId); + if (shareDbOpt.isEmpty()) { + log.debug("Key share with shareId {} not found", shareId); + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + + KeyShareDb shareDb = shareDbOpt.get(); + //check that keyShare can be accessed by auth ticket issuer + if (!ticketRecipient.equals(shareDb.getRecipient())) { + log.warn("Key share with shareId {} and recipient {} doesn't match ticket issuer {}", + shareId, shareDb.getRecipient(), ticketRecipient); + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + + return ResponseEntity.ok(createKeyShare(shareDb)); + } + + private static KeyShare createKeyShare(KeyShareDb share) { + var response = new KeyShare(); + response.setRecipient(share.getRecipient()); + response.setShare(share.getShare()); + + return response; + } + + private static NonceResponse createNonceResponse(byte[] nonce) { + var response = new NonceResponse(); + response.setNonce(base64UrlEnc(nonce)); + + return response; + } + + /** + * Get URI for getting Key Share resource (Location). + * @param id Share id example: KC9b7036de0c9fce889850c4bbb1e23482 + * @return URI (path and query) example: /key-shares/KC9b7036de0c9fce889850c4bbb1e23482 + * @throws URISyntaxException in case of URI syntax error + */ + private static URI getResourceLocation(String id) throws URISyntaxException { + return getPathAndQueryPart( + linkTo(methodOn( + KeySharesApiController.class + // xAuthTicket and xAuthCertificate are not part of url as these are header params + ).getKeyShareByShareId(id, "", "")).toUri() + ); + } + + private static String base64UrlEnc(byte[] src) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(src); + } + + /** + * CSS server receives the compact SD-JWT presentation (~~) and performs following + * authentication and authorization checks: + * + *
    + *
  • Verify that SD-JWT is signed by the key pair, whose public key is included in the certificate, presented + * in the API method "GET /key-shares/{shareId}" parameter "x-cdoc2-auth-x5c". + *
  • Verify that certificate is issued by trustworthy CA. + *
  • Verify that certificate is valid at current point of time and is not revoked. + *
  • Verify that SD-JWT contains claim aud, which is an array, which contains exactly one JSON string. + *
  • Parse the aud value from SD-JWT (something like + * "https://css.example-org1.ee:443/key-shares/9EE90F2D-D946-4D54-9C3D-F4C68F7FFAE3?nonce=59b314d4815f257694b6") + * into components serverBaseURL, key-share and nonce. + *
  • Verify that serverBaseURL is correct for this CSS server (actual requested URL is extracted from low level + * nativeWebRequest). + *
  • Verify that this CSS server has previously generated a nonce for this key-share and nonce is not expired + *
  • Verify that recipient_id (etsi/PNOEE-xyz) from the KeySharesCapsule matches with the subject SERIALNUMBER + * (PNOEE-xyz) from the X.509 certificate. + *
+ * + * If all checks are positive, then the authentication and access control decision is successful and CSS server can + * return the capsule. + * @param shareId requested shareId (will be compared to shareId in authTicket) + * @param xAuthTicket SD-JWT authticket that was generated for requested shareId + * @param x5c xAuthTicket signer certificate in PEM format. Certificate subject/SERIALNUMBER must match + * xAuthTicket header "kid" + * @return "iss" of SD-JWT, represents authToken issuer identify. Example "etsi/PNOEE-30303039914" + * @throws VerificationException if authTicket validation fails + * @throws ResponseStatusException status 404, when shareId or nonce is not found from DB or nonce is expired + */ + protected String validateAuthTicket(String shareId, String xAuthTicket, String x5c) + throws VerificationException { + + Map verifiedClaims; + + // check that SD-JWT is signed with x5c + try { + X509Certificate cert = X509CertUtils.parseWithException(x5c); + // check that x5c is issued by trustworthy CA + checkCertificateIssuer(cert); + + // check that certificate subject.serialnumber matches to sdjwt.header.kid + // signature is valid + // disclose hidden claims + verifiedClaims = AuthTokenVerifier.getVerifiedClaims(xAuthTicket, cert, + SIDCertificateUtil::getSemanticsIdentifier); + log.debug("claims: {}", verifiedClaims); + + // check that "iss" matches subject/serialnumber in cert + String ticketIss = obj2String(verifiedClaims.get("iss")); + String certSubjectSerial = SIDCertificateUtil.getSemanticsIdentifier(cert); + checkIssuerMatchesCertSubject(ticketIss, certSubjectSerial); + } catch (JOSEException | ParseException | CertificateException | IllegalCertificateException e) { + throw new VerificationException("Ticket processing error", e); + } + + // check aud url - this will change as currently it has shareAccessData structure + checkTicketAudience(shareId, verifiedClaims); + + return obj2String(verifiedClaims.get("iss")); + } + + /** + * For SID cert, check that certificate subject.serialnumber matches with authTicket "iss" claim + * @param ticketIssuer "iss" value from authTicket, example "etsi/PNOEE-30303039914" + * @param certSubjectSerial serialnumber from certificate, example "PNOEE-30303039914" + * @throws VerificationException if subject.serialnumber validation has failed + */ + protected void checkIssuerMatchesCertSubject(@Nullable String ticketIssuer, String certSubjectSerial) + throws VerificationException { + + if ((ticketIssuer == null) || !ticketIssuer.startsWith("etsi/")) { + throw new VerificationException("\"iss\" does not start \"etsi/\""); + } + + if (!ticketIssuer.substring("etsi/".length()).equals(certSubjectSerial)) { + throw new VerificationException("subject serial " + + certSubjectSerial + " doesn't match to iss " + ticketIssuer); + } + } + + protected void checkCertificateIssuer(X509Certificate cert) throws VerificationException { + + // enable certpath validation debug logging by setting security property + // -Djava.security.debug=certpath.ocsp,ocsp,verbose + + try { + KeyStore trustStore = sslBundles.getBundle("sid-trusted-issuers").getStores().getTrustStore(); + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + CertPath certPath = cf.generateCertPath(Collections.singletonList(cert)); + + Security.setProperty("com.sun.security.enableCRLDP", "false"); + Security.setProperty("ocsp.enable", "true"); + // Initialize PKIXParameters + PKIXParameters pkixParams = new PKIXParameters(trustStore); + + // SK ocsp demo env is a minefield 💣 + // https://github.com/SK-EID/ocsp/wiki/SK-OCSP-Demo-environment + // experimental, doesn't work for + // TODO: RM-3218: implement properly including client side OCSP stapling support + // enable/disable OCSP checking through application.properties + pkixParams.setRevocationEnabled(revocationCheckEnabled); + + CertPathValidator validator = CertPathValidator.getInstance("PKIX"); + + validator.validate(certPath, pkixParams); // if the CertPath does not validate, + // an CertPathValidatorException will be thrown + Date now = new Date(); + if (now.after(cert.getNotAfter())) { + throw new VerificationException("Certificate expired on " + cert.getNotAfter()); + } + + } catch (NoSuchAlgorithmException | CertificateException | CertPathValidatorException | KeyStoreException + | InvalidAlgorithmParameterException e) { + throw new VerificationException("Certificate validation error", e); + } + } + + /** + * Check that ticket "aud" claim matches url in request + * @param shareId shareId from request + * @param verifiedClaims disclosed claims from auth ticket + * @throws VerificationException if ticket "aud" claim validation has failed + */ + protected void checkTicketAudience(String shareId, Map verifiedClaims) + throws VerificationException { + + Objects.requireNonNull(shareId); + + try { + ShareAccessData shareAccessData = extractShareAccessData(verifiedClaims); + URL reqURL = extractRequestURL(this.nativeWebRequest); + URL ticketBaseURL = new URL(shareAccessData.getServerBaseUrl()); + + // check protocol, host and port + if ((ticketBaseURL.getHost() == null) || !ticketBaseURL.getHost().equals(reqURL.getHost()) + || ticketBaseURL.getProtocol() == null || !ticketBaseURL.getProtocol().equals(reqURL.getProtocol()) + || ticketBaseURL.getPort() != reqURL.getPort() //TODO: handle default ports + ) { + throw new VerificationException("protocol, host or port in ticket and request don't match (" + + shareAccessData.getServerBaseUrl() + "!=" + + reqURL + ")"); + } + + if (!shareId.equals(shareAccessData.getShareId())) { + throw new VerificationException("ticket and request shareId don't match"); + } + + checkNonceFromDB(shareAccessData.getShareId(), shareAccessData.getNonce()); + } catch (MalformedURLException | ParseException ex) { + log.error("Error validating \"aud\" data", ex); + throw new VerificationException("Error validating \"aud\" data", ex); + } + } + + /** + * Checks that ticketNonce exists in DB and is not older than nonceExpirationSeconds + * @param ticketShareId shareId extracted from sd-jwt + * @param ticketNonce nonce extracted from sd-jwt + * @throws ResponseStatusException with status NOT_FOUND, when nonce is not found from DB or is expired + */ + protected void checkNonceFromDB(String ticketShareId, String ticketNonce) { + byte[] nonceBytes = Base64.getUrlDecoder().decode(ticketNonce); + Optional dbNonceOpt = this.shareNonceRepository + .findByShareIdAndNonce(ticketShareId, nonceBytes); + + if (dbNonceOpt.isPresent()) { + KeyShareNonceDb dbNonce = dbNonceOpt.get(); + long nonceAgeSeconds = Instant.now().getEpochSecond() - dbNonce.getCreatedAt().getEpochSecond(); + if (nonceAgeSeconds > this.nonceExpirationSeconds) { + log.debug("nonce {} is expired. now({})-nonce.createdAt({})={} > {}", ticketNonce, Instant.now(), + dbNonce.getCreatedAt(), nonceAgeSeconds, + this.nonceExpirationSeconds); + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + } else { + log.info("nonce {} not found for share {}", ticketNonce, ticketShareId); + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + } + + /** + * jsonMap is following json: + *
+     *         {
+     *          "shareAccessData":[{
+     *            "shareId":"ff0102030405060708090a0b0c0e0dff",
+     *            "serverNonce":"AAECAwQFBgcICQoLDA4N_w",
+     *            "serverBaseURL":"https://localhost:8443"
+     *          }]
+     *         }
+     * 
+ * convert to Map using Jackson ObjectMapper: + * + * Map map = objectMapper.readValue(jsonString, new TypeReference>() {}); + * + * @param jsonMap JSON map + * @return ShareAccessData object + * @throws ParseException if parsing has failed + */ + ShareAccessData extractShareAccessData(Map jsonMap) throws ParseException { + + Object listObj = jsonMap.get(SHARE_ACCESS_DATA); + log.debug("sd-jwt {}={}", SHARE_ACCESS_DATA, listObj); + if (listObj instanceof Collection collection) { + if (collection.size() != 1) { + throw new ParseException("Expected \"" + SHARE_ACCESS_DATA + "\" to be an array", 0); + } + + Object firstObj = collection.toArray()[0]; + if (firstObj instanceof Map map) { + String baseURL = obj2String(map.get(SERVER_BASE_URL)); + if (baseURL == null) { + throw new ParseException("\"" + SERVER_BASE_URL + "\" " + VALUE_IS_MISSING, 0); + } + String shareId = obj2String(map.get(SHARE_ID)); + if (shareId == null) { + throw new ParseException("\"" + SHARE_ID + "\" " + VALUE_IS_MISSING, 0); + } + String nonce = obj2String(map.get(NONCE)); + if (nonce == null) { + throw new ParseException("\"" + NONCE + "\" " + VALUE_IS_MISSING, 0); + } + + return new ShareAccessData(baseURL, shareId, nonce); + } + } + + throw new ParseException("Failed to parse \"" + SHARE_ACCESS_DATA + "\"", 0); + } + + protected static URL extractRequestURL(NativeWebRequest nativeWebRequest) throws MalformedURLException { + if (nativeWebRequest == null) { + throw new IllegalArgumentException("nativeRequest not initialized"); // http 500, should not happen normally + } + + HttpServletRequest req = nativeWebRequest.getNativeRequest(HttpServletRequest.class); + + if (req != null) { + //when running behind proxy, then "X-Forwarded-*" headers should be set + String forwardedScheme = req.getHeader("X-Forwarded-Proto"); + String forwardedHost = req.getHeader("X-Forwarded-Host"); + String forwardedPort = req.getHeader("X-Forwarded-Port"); + + String scheme = (forwardedScheme != null) ? forwardedScheme : req.getScheme(); //https + String hostname = (forwardedHost != null) ? forwardedHost : req.getServerName(); + String port = (forwardedPort != null) ? forwardedPort : String.valueOf(req.getServerPort()); + + String path = req.getRequestURI(); //without query params + //String params = req.getQueryString(); // Query parameters, if any + + String url = scheme + "://" + hostname + + (("80".equals(port) || "443".equals(port)) ? "" : ":" + port) + + path; + //+ ((params == null) ? "": "?" + params) + + log.debug("Request url {}", url); + return new URL(url); + } else { + log.error("Failed to convert nativeWebRequest to HttpServletRequest"); + } + + throw new MalformedURLException("Failed to extract request URL"); + } + + private static String obj2String(Object obj) { + return (obj == null) ? null : obj.toString(); + } + +} diff --git a/shares-server/src/main/java/ee/cyber/cdoc2/server/config/SecurityConfiguration.java b/shares-server/src/main/java/ee/cyber/cdoc2/server/config/SecurityConfiguration.java index c82d3e1..c7cde27 100644 --- a/shares-server/src/main/java/ee/cyber/cdoc2/server/config/SecurityConfiguration.java +++ b/shares-server/src/main/java/ee/cyber/cdoc2/server/config/SecurityConfiguration.java @@ -34,7 +34,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(authorize -> authorize - .requestMatchers(new AntPathRequestMatcher("/key-shares**")).permitAll() .requestMatchers(new AntPathRequestMatcher("/key-shares/**")).permitAll() // authenticated URI must go first .requestMatchers(new AntPathRequestMatcher("/actuator/prometheus")).authenticated() diff --git a/shares-server/src/test/java/ee/cyber/cdoc2/server/KeyShareApiTests.java b/shares-server/src/test/java/ee/cyber/cdoc2/server/KeyShareApiTests.java index b0cc39c..38daf28 100644 --- a/shares-server/src/test/java/ee/cyber/cdoc2/server/KeyShareApiTests.java +++ b/shares-server/src/test/java/ee/cyber/cdoc2/server/KeyShareApiTests.java @@ -24,7 +24,7 @@ @Slf4j -public class KeyShareApiTests extends KeyShareIntegrationTest { +class KeyShareApiTests extends KeyShareIntegrationTest { private static final byte[] SHARE = new byte[128]; private static final String SHARE_RECIPIENT = "Recipient_for_key_share"; @@ -42,11 +42,13 @@ public void setup() throws Exception { @Test void shouldGetKeyShare() throws Exception { KeyShare keyShare = createKeyShare(); + keyShare.setRecipient(TestData.TEST_ETSI_RECIPIENT); String shareId = this.saveKeyShare(keyShare).getShareId(); - byte[] xAuthTicket = new byte[32]; + String nonce = client.createNonce(shareId).getNonce(); + String xAuthTicket = TestData.generateTestAuthTicket(TestData.TEST_IDENTIFIER, baseUrl, shareId, nonce); - Optional response = client.getKeyShare(shareId, xAuthTicket); + Optional response = client.getKeyShare(shareId, xAuthTicket, TestData.TEST_CERT_PEM); assertTrue(response.isPresent()); KeyShare savedKeyShare = response.get(); @@ -57,11 +59,12 @@ void shouldGetKeyShare() throws Exception { @Test void shouldFailToGetKeyShareWithBadRequest() { String shareId = "short"; - byte[] xAuthTicket = new byte[32]; + String xAuthTicket = ""; + String xAuthCert = ""; ApiException ex = assertThrows( ApiException.class, - () -> client.getKeyShare(shareId, xAuthTicket) + () -> client.getKeyShare(shareId, xAuthTicket, xAuthCert) ); assertBadRequest(ex.getCode()); @@ -70,9 +73,11 @@ void shouldFailToGetKeyShareWithBadRequest() { @Test void shouldFailToGetKeyShareWithNotFound() throws ApiException { String shareId = "SHARE_ID_MIN_LENGTH_SHOULD_BE_32"; - byte[] xAuthTicket = new byte[32]; - Optional keyShare = client.getKeyShare(shareId, xAuthTicket); + String nonce = "random"; + String xAuthTicket = TestData.generateTestAuthTicket(TestData.TEST_IDENTIFIER, baseUrl, shareId, nonce); + + Optional keyShare = client.getKeyShare(shareId, xAuthTicket, TestData.TEST_CERT_PEM); assertTrue(keyShare.isEmpty()); } diff --git a/shares-server/src/test/java/ee/cyber/cdoc2/server/KeyShareIntegrationTest.java b/shares-server/src/test/java/ee/cyber/cdoc2/server/KeyShareIntegrationTest.java index 88ea0d8..bf29a9a 100644 --- a/shares-server/src/test/java/ee/cyber/cdoc2/server/KeyShareIntegrationTest.java +++ b/shares-server/src/test/java/ee/cyber/cdoc2/server/KeyShareIntegrationTest.java @@ -2,6 +2,7 @@ import lombok.extern.slf4j.Slf4j; +import java.util.HexFormat; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -9,6 +10,7 @@ import ee.cyber.cdoc2.server.model.entity.KeyShareDb; import ee.cyber.cdoc2.server.model.entity.KeyShareNonceDb; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -78,6 +80,17 @@ void testJpaSaveForKeyShareNonce() { assertEquals(shareId, dbNonceRecord.getShareId()); assertNotNull(dbNonceRecord.getNonce()); log.debug("Retrieved {}", dbNonceRecord); + + // check findByShareIdAndNonce + Optional retrievedOpt = this.shareNonceRepository.findByShareIdAndNonce(shareId, + dbNonceRecord.getNonce()); + + assertTrue(retrievedOpt.isPresent()); + String nonceHex = HexFormat.of().formatHex(dbNonceRecord.getNonce()); + KeyShareNonceDb retrievedNonce = retrievedOpt.get(); + log.debug("findByShareIdAndNonce({},{}): {}", shareId, nonceHex, retrievedNonce); + assertEquals(shareId, retrievedNonce.getShareId()); + assertArrayEquals(dbNonceRecord.getNonce(), retrievedNonce.getNonce()); } } diff --git a/shares-server/src/test/java/ee/cyber/cdoc2/server/TestData.java b/shares-server/src/test/java/ee/cyber/cdoc2/server/TestData.java index 9930ae2..1dfd1ae 100644 --- a/shares-server/src/test/java/ee/cyber/cdoc2/server/TestData.java +++ b/shares-server/src/test/java/ee/cyber/cdoc2/server/TestData.java @@ -6,16 +6,117 @@ import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; +import java.security.cert.X509Certificate; import java.util.Properties; + +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.util.X509CertUtils; +import ee.cyber.cdoc2.auth.AuthTokenCreator; +import ee.cyber.cdoc2.auth.EtsiIdentifier; +import ee.cyber.cdoc2.auth.SIDCertificateUtil; +import ee.cyber.cdoc2.auth.ShareAccessData; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; +import static org.junit.Assert.assertTrue; + /** * Input test data utility class. */ @Slf4j public final class TestData { + + //generated create-rsa-key-csr-crt-keystore.sh + private static final String TEST_RSAKEY = """ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- +"""; + + // generated create-rsa-key-csr-crt-keystore.sh + // signing cert sk-ca.localhost.crt must be in server sid trust store + // mock service certificate + public static final String TEST_CERT_PEM = "-----BEGIN CERTIFICATE-----" + + """ + MIIDuTCCA2CgAwIBAgIUTL1AousETAVENwEl62mocPaUfZQwCgYIKoZIzj0EAwQw + TDELMAkGA1UEBhMCRUUxEDAOBgNVBAcMB1RhbGxpbm4xETAPBgNVBAoMCHNrLWxv + Y2FsMRgwFgYDVQQDDA9zay1jYS5sb2NhbGhvc3QwHhcNMjQxMTI1MjAwMDQ5WhcN + MjUxMTI1MjAwMDQ5WjBjMRowGAYDVQQFExFQTk9FRS0zMDMwMzAzOTkxNDELMAkG + A1UEKgwCT0sxEzARBgNVBAQMClRFU1ROVU1CRVIxFjAUBgNVBAMMDVRFU1ROVU1C + RVIsT0sxCzAJBgNVBAYTAkVFMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKC + AgEA1TyEtd/tUQsDtNfwy7l34SoUvBAnAXU8CFnXtF1e0+2Rb1hHEm/mRiwHvjmd + VH6Gud03RVWi7Xc+pJLddM/EUxKdF5Rpe9exzNN7yTiOOIP3WcLSjwMdEOOUBE1a + ysUePcqQBB1Se/c2yiQuOBOe5OMzCZbvrv8JRW1T+FGmDVAtHTS04Yv6gnudAd/B + pCdbDdCzdaV2BgI5WME+IXnh7Nwg3GQuqNTwsZWNbfG+1gATLrfBaPWE5alQ1s2K + c+dbURJMA8JraKpVYx1P2h4jyCAQOe2Lza8d+/HPez2BiYFPJVO2ogLxLovcdfmh + ztTm2xDZBTzY0/c2XbjMpMERgIFIssH188FS0TXkWmwBG+cVhhl5SPY6YUEaRDa6 + lRe4940NGgZovlVcc0uLb+LVMcA9S6EE4egv50b+hFXQaejfuhnw5cnoCfvzb1dn + Y8dY7zVHMIDUyt0aAoU6OHRlSsBYTdaHYQuW0UxqzXZBzoGG6wZ6Wkycytg4DIJJ + yoI0Ces8MYHX7Kek8kcX0GsXGiO5HnmHGFkRgPwTOOhrPhcLalLOvFL+JtUotaot + 8wlay2JybaHbYtebjio6PkAGHiDOxMaU+8R25S7PhWPr8A9a+rftNXDQDZOSjZxF + TtLjcXsXesZ7GU63arw073K7Kp3NoQ2r9oemq0ZawmDf9vMCAwEAAaM+MDwwHwYD + VR0jBBgwFoAUbs3btcBBYBn+RwvDkSG9Gz2Sxl8wDAYDVR0TAQH/BAIwADALBgNV + HQ8EBAMCBaAwCgYIKoZIzj0EAwQDRwAwRAIgJsR3WD6ZAIS5+K3YZ822QjmZYHOT + oeW6Qz1MZFgQba8CIBCrja2kNYPtyJmJF/sespAVdz7eYHxgNUkM4cqEWFkz + """.replaceAll("\\s", "") + + "-----END CERTIFICATE-----"; //remove all whitespace + + //identifier from above TEST_CERT + public static final String TEST_IDENTIFIER = "30303039914"; + public static final String TEST_ETSI_RECIPIENT = "etsi/PNOEE-" + TEST_IDENTIFIER; + private TestData() { // utility class } @@ -49,4 +150,50 @@ public static KeyStore loadKeyStore(String keyStoreType, Path keyStoreFile, Stri } } + /** + * Generate Auth ticket with TestData.TEST_RSAKEY. + * @param eid example 30303039914 + * @param serverUrl + * @param shareId + * @param nonce + * @return + */ + @SneakyThrows + public static String generateTestAuthTicket(String eid, String serverUrl, String shareId, String nonce) { + + X509Certificate cert = X509CertUtils.parseWithException(TEST_CERT_PEM); + String testSemanticsIdentifier = SIDCertificateUtil.getSemanticsIdentifier(cert); //PNOEE-30303039914 + EtsiIdentifier etsi = new EtsiIdentifier("etsi/" + testSemanticsIdentifier); + + // Only have certificate and RSA private key for single + assertTrue("Only " + testSemanticsIdentifier + " is supported for auth ticket generation", + testSemanticsIdentifier.contains(eid)); + + + JWK jwk = JWK.parseFromPEMEncodedObjects(TEST_RSAKEY); + RSAKey privateKey = jwk.toRSAKey(); +// RSAKey rsaPublicJWK = new RSAKey.Builder(privateKey.toRSAPublicKey()) +// .keyID(etsi.toString()) +// .build(); + + JWSSigner jwsSigner = new RSASSASigner(privateKey); + + AuthTokenCreator token = AuthTokenCreator.builder() + .withEtsiIdentifier(etsi) // "iss" field etsi/PNOEE-30303039914 + .withShareAccessData(new ShareAccessData( + serverUrl, + shareId, + nonce)) +// .withShareAccessData(new ShareAccessData( +// "https://cdoc-ccs.smit.ee:443/key-shares/", +// "5BAE4603-C33C-4425-B301-125F2ACF9B1E", +// "9d23660840b427f405009d970d269770417bc769")) + .build(); + + + token.sign(jwsSigner, testSemanticsIdentifier); // header "kid" PNOEE-30303039914 + + return token.createTicketForShareId(shareId); + } + } diff --git a/shares-server/src/test/java/ee/cyber/cdoc2/server/api/KeyShareApiAuthenticationTest.java b/shares-server/src/test/java/ee/cyber/cdoc2/server/api/KeyShareApiAuthenticationTest.java new file mode 100644 index 0000000..6899827 --- /dev/null +++ b/shares-server/src/test/java/ee/cyber/cdoc2/server/api/KeyShareApiAuthenticationTest.java @@ -0,0 +1,192 @@ +package ee.cyber.cdoc2.server.api; + +import com.nimbusds.jose.util.X509CertUtils; +import ee.cyber.cdoc2.auth.VerificationException; +import ee.cyber.cdoc2.server.model.entity.KeyShareDb; +import ee.cyber.cdoc2.server.model.entity.KeyShareNonceDb; +import ee.cyber.cdoc2.server.model.repository.KeyShareNonceRepository; +import ee.cyber.cdoc2.server.model.repository.KeyShareRepository; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.web.context.request.NativeWebRequest; + +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(SpringExtension.class) +@SpringBootTest +@EnableAutoConfiguration(exclude = { //disable DB related beans, as these will fail to start without DB + JpaRepositoriesAutoConfiguration.class, + HibernateJpaAutoConfiguration.class, + DataSourceAutoConfiguration.class}) +@MockBean(JpaMetamodelMappingContext.class) // although not using JPA, Spring fails to start without 'jpaMappingContext' + // bean for some reason. Define @MockBean +//@ExtendWith(MockitoExtension.class) // MockitoExtension doesn't work with SpringExtension in the same class +class KeyShareApiAuthenticationTest { + + private static final byte[] SHARE = new byte[128]; + private static final String SID_DEMO_IDENTIFIER = "30303039914"; + private static final String ETSI_RECIPIENT = "etsi/PNOEE-" + SID_DEMO_IDENTIFIER; + private static final String SHARE_ID = "ff0102030405060708090a0b0c0e0dff"; + private static final byte[] NONCE_BYTES = HexFormat.of().parseHex("000102030405060708090a0b0c0e0dff"); + + @MockBean + private KeyShareRepository mockShareRep; + + @MockBean + private KeyShareNonceRepository mockNonceRep; + + //@Mock + // since not using MockitoExtension, need to be mocked manually + private NativeWebRequest mockNativeWebRequest; + + //@Mock + // since not using MockitoExtension, need to be mocked manually + HttpServletRequest mockHttpServletRequest; + + @Autowired + private SslBundles sslBundles; // initialized from application.properties + + // initialized in setUp as non-MockBeans parameters must be mocked manually + KeyShareApiService keyShareApiService; + + // pre-generated using cdoc2-java-ref-impl AuthTokenCreatorTest::testCreateAuthToken test + // generated with SID demo env + private static final String AUTH_TICKET = """ + eyJraWQiOiJQTk9FRS0zMDMwMzAzOTkxNCIsInR5cCI6InZuZC5jZG9jMi5DQ1MtYXV0aC10b2tlbi52MStzZC1qd3QiLCJh + bGciOiJSUzI1NiJ9.eyJpc3MiOiJldHNpL1BOT0VFLTMwMzAzMDM5OTE0IiwiX3NkIjpbImdNWlkwTnJvRUdHNjZnQlY2S2N + zU2l6WGJfaU1aNDdDVnpvb3otdHBBMU0iXSwiZXhwIjoxNzMyMDM1MTk0LCJpYXQiOjE3MzIwMzUxMzQsIl9zZF9hbGciOiJ + zaGEtMjU2In0.j0F_VK5_njK3TdhvUIUGi54gQMQRFFfbXZGvCDRjlNVsl_4u2U7qb2SHr8vm3kmMZYZgNGk_moJApMMXX3O + arct4xpXFSWZa0LOZ-oVCew-GE0iwK9te9oPiUwdYKSrCRDUJRvznS7d3LBslMS6_RgXfTm--h29pOTUZ0LsxIz_QRXGtR5r + mhzpq_H8M_2q2EDkSBV9_rRbFZmJdzHCCk7zfrT6bTvi1qaCbkFRpvlQnBmB5IV5MVN-NlWouQiWQolGLYkJQe1C4-7doi9G + xSNLy8e9sHCYxjadYr7tfMalTNFcdnONM6kIl3y_Nk3t9TdnY7RDhBBt5zLpZDbj3xYBIkD_Ae9mepkRi2FkhWf4mHAad69Q + tON6ia8BqlfsZSrtLCZc9ugbvyMRtiyr6OzrZELE0yaWzZNhYG04KOxw-my3CRQdGdUanXw072thCXq316gogYi2W4boFh8C + CljrML4mLPb27LgYI6uht6wxEzDoFQhqNEoKbaiwr_DwF7Bje51wWFWaABIFD3zyYsnmQ6qnCclUrNW5Xd4CWd2HpIJOgaUZ + 0gRDiucsr10bMjprtePd-UG5rDsqqsVsyxjBwkK_c8iRoHk4nBrugHl1NZJZzV3SOz0eyZEedAQESmEG3JcqXq8o7Dz7Sr3I + NKwhYBHjpx4yQfEO3B38C245QT3hvpvaJSYVxk1dIPMabu2e6aduA8SLfnvgrJ0RDhJJEWqKhP4VJLz4O6U5BxADsMm-G6x8 + yFtONTZFbDz8YJMG_qSBab2gXVZKg-DCAo3IpvgS45-mHTkS2hwfKpBKLnnfii-sAjKq2ghnzQp5cWr5mGyDCB0iKAQb8E07 + odChanbEt_WlXVyknpMhOFksubvw8np4cujyrhj6rJgOA6TqzzfSEkfd09xotXqZi1v20Wa9-I2fFwoKsm8r1ydBiG52-j0R + AVESxkrCBtkcFM-HUomvA8GTZKY-efzsENuTSX3TxXqxvOvvUfWQRBANnnPZlopkn0Y3NzgmyCPgA~WyJFVDU3b0Z0MHhoUX + FjZlhSTTNnMW53Iiwic2hhcmVBY2Nlc3NEYXRhIixbeyIuLi4iOiJfQ1hraXVDVHdJZUpEellFbmdQSkhWcnlrYmdfRUtiZH + B0LVRKS3p5bk9rIn0seyIuLi4iOiJxWWVkZExlRzhzSEFUbjBqYm5UdUlXei1FTWgwM29zdlM4QUYtd3pPVG9FIn1dXQ~WyJ + Eck5fTnQxc2k3S1BscHN5eHlMQ0J3Iix7InNoYXJlSWQiOiJmZjAxMDIwMzA0MDUwNjA3MDgwOTBhMGIwYzBlMGRmZiIsInN + lcnZlck5vbmNlIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBNE5fdyIsInNlcnZlckJhc2VVUkwiOiJodHRwczovL2xvY2FsaG9zdDo + 4NDQzIn1d~ + """.replaceAll("\\s", ""); //remove all whitespace + + // SID demo env cert for 30303039914 that automatically authenticates successfully + private final String sidCertStr = """ + -----BEGIN CERTIFICATE----- + MIIIIjCCBgqgAwIBAgIQUJQ/xtShZhZmgogesEbsGzANBgkqhkiG9w0BAQsFADBoMQswCQYDVQQGEwJFRTEiMCAGA1UECgwZ + QVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1czEXMBUGA1UEYQwOTlRSRUUtMTA3NDcwMTMxHDAaBgNVBAMME1RFU1Qgb2YgRUlE + LVNLIDIwMTYwIBcNMjQwNzAxMTA0MjM4WhgPMjAzMDEyMTcyMzU5NTlaMGMxCzAJBgNVBAYTAkVFMRYwFAYDVQQDDA1URVNU + TlVNQkVSLE9LMRMwEQYDVQQEDApURVNUTlVNQkVSMQswCQYDVQQqDAJPSzEaMBgGA1UEBRMRUE5PRUUtMzAzMDMwMzk5MTQw + ggMiMA0GCSqGSIb3DQEBAQUAA4IDDwAwggMKAoIDAQCo+o1jtKxkNWHvVBRA8Bmh08dSJxhL/Kzmn7WS2u6vyozbF6M3f1lp + XZXqXqittSmiz72UVj02jtGeu9Hajt8tzR6B4D+DwWuLCvTawqc+FSjFQiEB+wHIb4DrKF4t42Aazy5mlrEy+yMGBe0ygMLd + 6GJmkFw1pzINq8vu6sEY25u6YCPnBLhRRT3LhGgJCqWQvdsN3XCV8aBwDK6IVox4MhIWgKgDF/dh9XW60MMiW8VYwWC7ONa + 3LTqXJRuUhjFxmD29Qqj81k8ZGWn79QJzTWzlh4NoDQT8w+8ZIOnyNBAxQ+Ay7iFR4SngQYUyHBWQspHKpG0dhKtzh3zELIk + o8sxnBZ9HNkwnIYe/CvJIlqARpSUHY/Cxo8X5upwrfkhBUmPuDDgS14ci4sFBiW2YbzzWWtxbEwiRkdqmA1NxoTJybA9Frj6 + NIjC4Zkk+tL/N8Xdblfn8kBKs+cAjk4ssQPQruSesyvzs4EGNgAk9PX2oeelGTt02AZiVkIpUha8VgDrRUNYyFZc3E3Z3Ph1 + aOCEQMMPDATaRps3iHw/waHIpziHzFAncnUXQDUMLr6tiq+mOlxYCi8+NEzrwT2GOixSIuvZK5HzcJTBYz35+ESLGjxnUjb + ssfra9RAvyaeE1EDfAOrJNtBHPWP4GxcayCcCuVBK2zuzydhY6Kt8ukXh5MIM08GRGHqj8gbBMOW6zEb3OVNSfyi1xF8MYAT + KnM1XjSYN49My0BPkJ01xCwFzC2HGXUTyb8ksmHtrC8+MrGLus3M3mKFvKA9VatSeQZ8ILR6WeA54A+GMQeJuV54ZHZtD208 + 5Vj7R+IjR+3jakXBvZhVoSTLT7TIIa0U6L46jUIHee/mbf5RJxesZzkP5zA81csYyLlzzNzFah1ff7MxDBi0v/UyJ9ngFCeL + t7HewtlC8+HRbgSdk+57KgaFIgVFKhv34Hz1Wfh3ze1Rld3r1Dx6so4h4CZOHnUN+hprosI4t1y8jorCBF2GUDbIqmBCx7Dg + qT6aE5UcMcXd8CAwEAAaOCAckwggHFMAkGA1UdEwQCMAAwDgYDVR0PAQH/BAQDAgSwMHkGA1UdIARyMHAwZAYKKwYBBAHOHw + MRAjBWMFQGCCsGAQUFBwIBFkhodHRwczovL3d3dy5za2lkc29sdXRpb25zLmV1L3Jlc291cmNlcy9jZXJ0aWZpY2F0aW9uLX + ByYWN0aWNlLXN0YXRlbWVudC8wCAYGBACPegECMB0GA1UdDgQWBBQUFyCLUawSl3KCp22kZI88UhtHvTAfBgNVHSMEGDAWgB + SusOrhNvgmq6XMC2ZV/jodAr8StDATBgNVHSUEDDAKBggrBgEFBQcDAjB8BggrBgEFBQcBAQRwMG4wKQYIKwYBBQUHMAGGHW + h0dHA6Ly9haWEuZGVtby5zay5lZS9laWQyMDE2MEEGCCsGAQUFBzAChjVodHRwOi8vc2suZWUvdXBsb2FkL2ZpbGVzL1RFU1 + Rfb2ZfRUlELVNLXzIwMTYuZGVyLmNydDAwBgNVHREEKTAnpCUwIzEhMB8GA1UEAwwYUE5PRUUtMzAzMDMwMzk5MTQtTU9DSy + 1RMCgGA1UdCQQhMB8wHQYIKwYBBQUHCQExERgPMTkwMzAzMDMxMjAwMDBaMA0GCSqGSIb3DQEBCwUAA4ICAQCqlSMpTx+/n + wfI5eEislq9rce9eOY/9uA0b3Pi7cn6h7jdFes1HIlFDSUjA4DxiSWSMD0XX1MXe7J7xx/AlhwFI1WKKq3eLx4wE8sjOaacH + nwV/JSTf6iSYjAB4MRT2iJmvopgpWHS6cAQfbG7qHE19qsTvG7Ndw7pW2uhsqzeV5/hcCf10xxnGOMYYBtU7TheKRQtkeBiP + Jsv4HuIFVV0pGBnrvpqj56Q+TBD9/8bAwtmEMScQUVDduXPc+uIJJoZfLlUdUwIIfhhMEjSRGnaK4H0laaFHa05+KkFtHzc/ + iYEGwJQbiKvUn35/liWbcJ7nr8uCQSuV4PHMjZ2BEVtZ6Qj58L/wSSidb4qNkSb9BtlK+wwNDjbqysJtQCAKP7SSNuYcEAWl + mvtHmpHlS3tVb7xjko/a7zqiakjCXE5gIFUmtZJFbG5dO/0VkT5zdrBZJoq+4DkvYSVGVDE/AtKC86YZ6d1DY2jIT0c9Blb + Fp40A4Xkjjjf5/BsRlWFAs8Ip0Y/evG68gQBATJ2g3vAbPwxvNX2x3tKGNg+aDBYMGM76rRrtLhRqPIE4Ygv8x/s7JoBxy1q + Czuwu/KmB7puXf/y/BBdcwRHIiBq2XQTfEW3ZJJ0J5+Kq48keAT4uOWoJiPLVTHwUP/UBhwOSa4nSOTAfdBXG4NqMknYwvAE + 9g== + -----END CERTIFICATE----- + """; + + @Test + void contextLoads() { + // tests that test is configured properly (no exceptions means success) + // In case configuration errors, spring fails run-time during initialization + Assertions.assertNotNull(sslBundles); // from application.properties + } + + @BeforeEach + public void setUp() { + // Code to be executed before each test method in this class + mockNativeWebRequest = Mockito.mock(NativeWebRequest.class); + mockHttpServletRequest = Mockito.mock(HttpServletRequest.class); + keyShareApiService = new KeyShareApiService(mockNativeWebRequest, mockShareRep, mockNonceRep, sslBundles); + } + + @Test + void shouldAuthenticateAndGetKeyShare() { + + KeyShareDb keyShareDb = new KeyShareDb() + .setShareId(SHARE_ID) + .setShare(SHARE) + .setRecipient(ETSI_RECIPIENT); + + KeyShareNonceDb nonceDb = new KeyShareNonceDb() + .setShareId(SHARE_ID) + .setNonce(NONCE_BYTES) + .setId(1L); + nonceDb.setCreatedAt(Instant.now()); + + when(mockNativeWebRequest.getNativeRequest(HttpServletRequest.class)).thenReturn(mockHttpServletRequest); + when(mockHttpServletRequest.getHeader("X-Forwarded-Proto")).thenReturn("https"); + when(mockHttpServletRequest.getHeader("X-Forwarded-Host")).thenReturn("localhost"); + when(mockHttpServletRequest.getHeader("X-Forwarded-Port")).thenReturn("8443"); + when(mockHttpServletRequest.getRequestURI()).thenReturn("/key-shares/" + SHARE_ID); + when(mockHttpServletRequest.getQueryString()).thenReturn(null); + + when(mockShareRep.findById(eq(SHARE_ID))).thenReturn(Optional.of(keyShareDb)); + when(mockNonceRep.findByShareIdAndNonce(eq(SHARE_ID), any())).thenReturn(Optional.of(nonceDb)); + + String pemCertNoLineBreaks = X509CertUtils.toPEMString(X509CertUtils.parse(sidCertStr), false); + var resp = keyShareApiService.getKeyShareByShareId(SHARE_ID, AUTH_TICKET, pemCertNoLineBreaks); + + assertTrue(resp.getStatusCode().is2xxSuccessful()); + assertTrue(resp.hasBody()); + assertEquals(ETSI_RECIPIENT, resp.getBody().getRecipient()); + assertArrayEquals(SHARE, resp.getBody().getShare()); + } + + @Test + void testCheckCertificateIssuer() throws VerificationException { + + Assertions.assertNotNull(sslBundles); + X509Certificate cert = X509CertUtils.parse(sidCertStr); + keyShareApiService.checkCertificateIssuer(cert); + } + +} diff --git a/shares-server/src/test/resources/application.properties b/shares-server/src/test/resources/application.properties index 66e1afb..76dadca 100644 --- a/shares-server/src/test/resources/application.properties +++ b/shares-server/src/test/resources/application.properties @@ -15,11 +15,63 @@ server.ssl.enabled=true server.ssl.enabled-protocols=TLSv1.3 spring.datasource.driver-class-name=org.postgresql.Driver -#DB is managed by liquibase scripts +# DB is managed by liquibase scripts spring.jpa.hibernate.ddl-auto: none # credentials for /actuator/prometheus api basic authentication management.endpoints.metrics.username=username management.endpoints.metrics.password=password +logging.level.root=info +logging.level.ee.cyber.cdoc2=trace + +# Enable/disable certificate revocation checking for auth ticket certificates, +# experimental feature, default value is "false" +# Disabled for junit tests, as TEST_RSACERT doesn't have AIA extension and +# SK-OCSP-Demo doesn't know anything about TEST_RSACERT anyway +# https://github.com/SK-EID/ocsp/wiki/SK-OCSP-Demo-environment +#cdoc2.auth-x5c.revocation-checks.enabled=false + +# nonce validity time in seconds, default 300 +#cdoc2.nonce.expiration.seconds=300 + +# https://docs.spring.io/spring-boot/reference/features/ssl.html#features.ssl.pem +# Smart-ID certificate trusted issuer +# For demo env, +# authentications certificates are signed by TEST_of_EID-SK_2016 +#spring.ssl.bundle.pem.sid-trusted-issuers.truststore.certificate=classpath:sid_trusted_issuers/TEST_of_EID-SK_2016.pem.crt +#spring.ssl.bundle.jks.sid-trusted-issuers.truststore.location=classpath:sid-trusted-issuers/TEST_SID_trusted_certificates.jks +spring.ssl.bundle.jks.sid-trusted-issuers.truststore.location=classpath:sid-trusted-issuers/test_sid_trusted_issuers.jks +spring.ssl.bundle.jks.sid-trusted-issuers.truststore.password=changeit +spring.ssl.bundle.jks.sid-trusted-issuers.truststore.type=jks +#spring.ssl.bundle.pem.sid-trusted-issuer.truststore.certificate=classpath:sid_trusted_issuers/TEST_of_EE_Certification_Centre_Root_CA.pem.crt +# TEST_of_EE_Certification_Centre_Root_CA.pem +#spring.ssl.bundle.pem.sid-trusted-issuer.truststore.certificate=\ +#-----BEGIN CERTIFICATE-----\n\ +#MIIEEzCCAvugAwIBAgIQc/jtqiMEFERMtVvsSsH7sjANBgkqhkiG9w0BAQUFADB9\n\ +#MQswCQYDVQQGEwJFRTEiMCAGA1UECgwZQVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1\n\ +#czEwMC4GA1UEAwwnVEVTVCBvZiBFRSBDZXJ0aWZpY2F0aW9uIENlbnRyZSBSb290\n\ +#IENBMRgwFgYJKoZIhvcNAQkBFglwa2lAc2suZWUwIhgPMjAxMDEwMDcxMjM0NTZa\n\ +#GA8yMDMwMTIxNzIzNTk1OVowfTELMAkGA1UEBhMCRUUxIjAgBgNVBAoMGUFTIFNl\n\ +#cnRpZml0c2VlcmltaXNrZXNrdXMxMDAuBgNVBAMMJ1RFU1Qgb2YgRUUgQ2VydGlm\n\ +#aWNhdGlvbiBDZW50cmUgUm9vdCBDQTEYMBYGCSqGSIb3DQEJARYJcGtpQHNrLmVl\n\ +#MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1gGpqCtDmNNEHUjC8LXq\n\ +#xRdC1kpjDgkzOTxQynzDxw/xCjy5hhyG3xX4RPrW9Z6k5ZNTNS+xzrZgQ9m5U6uM\n\ +#ywYpx3F3DVgbdQLd8DsLmuVOz02k/TwoRt1uP6xtV9qG0HsGvN81q3HvPR/zKtA7\n\ +#MmNZuwuDFQwsguKgDR2Jfk44eKmLfyzvh+Xe6Cr5+zRnsVYwMA9bgBaOZMv1TwTT\n\ +#VNi9H1ltK32Z+IhUX8W5f2qVP33R1wWCKapK1qTX/baXFsBJj++F8I8R6+gSyC3D\n\ +#kV5N/pOlWPzZYx+kHRkRe/oddURA9InJwojbnsH+zJOa2VrNKakNv2HnuYCIonzu\n\ +#pwIDAQABo4GKMIGHMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G\n\ +#A1UdDgQWBBS1NAqdpS8QxechDr7EsWVHGwN2/jBFBgNVHSUEPjA8BggrBgEFBQcD\n\ +#AgYIKwYBBQUHAwEGCCsGAQUFBwMDBggrBgEFBQcDBAYIKwYBBQUHAwgGCCsGAQUF\n\ +#BwMJMA0GCSqGSIb3DQEBBQUAA4IBAQAj72VtxIw6p5lqeNmWoQ48j8HnUBM+6mI0\n\ +#I+VkQr0EfQhfmQ5KFaZwnIqxWrEPaxRjYwV0xKa1AixVpFOb1j+XuVmgf7khxXTy\n\ +#Bmd8JRLwl7teCkD1SDnU/yHmwY7MV9FbFBd+5XK4teHVvEVRsJ1oFwgcxVhyoviR\n\ +#SnbIPaOvk+0nxKClrlS6NW5TWZ+yG55z8OCESHaL6JcimkLFjRjSsQDWIEtDvP4S\n\ +#tH3vIMUPPiKdiNkGjVLSdChwkW3z+m0EvAjyD9rnGCmjeEm5diLFu7VMNVqupsbZ\n\ +#SfDzzBLc5+6TqgQTOG7GaZk2diMkn03iLdHGFrh8ML+mXG9SjEPI\n\ +#-----END CERTIFICATE----- +asdf=asdf + + diff --git a/shares-server/src/test/resources/logback.xml b/shares-server/src/test/resources/logback.xml index a85f002..d47ec1b 100644 --- a/shares-server/src/test/resources/logback.xml +++ b/shares-server/src/test/resources/logback.xml @@ -30,8 +30,8 @@ - - + + diff --git a/shares-server/src/test/resources/sid-trusted-issuers/TEST_of_EE_Certification_Centre_Root_CA.pem.crt b/shares-server/src/test/resources/sid-trusted-issuers/TEST_of_EE_Certification_Centre_Root_CA.pem.crt new file mode 100644 index 0000000..a752611 --- /dev/null +++ b/shares-server/src/test/resources/sid-trusted-issuers/TEST_of_EE_Certification_Centre_Root_CA.pem.crt @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIEEzCCAvugAwIBAgIQc/jtqiMEFERMtVvsSsH7sjANBgkqhkiG9w0BAQUFADB9 +MQswCQYDVQQGEwJFRTEiMCAGA1UECgwZQVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1 +czEwMC4GA1UEAwwnVEVTVCBvZiBFRSBDZXJ0aWZpY2F0aW9uIENlbnRyZSBSb290 +IENBMRgwFgYJKoZIhvcNAQkBFglwa2lAc2suZWUwIhgPMjAxMDEwMDcxMjM0NTZa +GA8yMDMwMTIxNzIzNTk1OVowfTELMAkGA1UEBhMCRUUxIjAgBgNVBAoMGUFTIFNl +cnRpZml0c2VlcmltaXNrZXNrdXMxMDAuBgNVBAMMJ1RFU1Qgb2YgRUUgQ2VydGlm +aWNhdGlvbiBDZW50cmUgUm9vdCBDQTEYMBYGCSqGSIb3DQEJARYJcGtpQHNrLmVl +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1gGpqCtDmNNEHUjC8LXq +xRdC1kpjDgkzOTxQynzDxw/xCjy5hhyG3xX4RPrW9Z6k5ZNTNS+xzrZgQ9m5U6uM +ywYpx3F3DVgbdQLd8DsLmuVOz02k/TwoRt1uP6xtV9qG0HsGvN81q3HvPR/zKtA7 +MmNZuwuDFQwsguKgDR2Jfk44eKmLfyzvh+Xe6Cr5+zRnsVYwMA9bgBaOZMv1TwTT +VNi9H1ltK32Z+IhUX8W5f2qVP33R1wWCKapK1qTX/baXFsBJj++F8I8R6+gSyC3D +kV5N/pOlWPzZYx+kHRkRe/oddURA9InJwojbnsH+zJOa2VrNKakNv2HnuYCIonzu +pwIDAQABo4GKMIGHMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G +A1UdDgQWBBS1NAqdpS8QxechDr7EsWVHGwN2/jBFBgNVHSUEPjA8BggrBgEFBQcD +AgYIKwYBBQUHAwEGCCsGAQUFBwMDBggrBgEFBQcDBAYIKwYBBQUHAwgGCCsGAQUF +BwMJMA0GCSqGSIb3DQEBBQUAA4IBAQAj72VtxIw6p5lqeNmWoQ48j8HnUBM+6mI0 +I+VkQr0EfQhfmQ5KFaZwnIqxWrEPaxRjYwV0xKa1AixVpFOb1j+XuVmgf7khxXTy +Bmd8JRLwl7teCkD1SDnU/yHmwY7MV9FbFBd+5XK4teHVvEVRsJ1oFwgcxVhyoviR +SnbIPaOvk+0nxKClrlS6NW5TWZ+yG55z8OCESHaL6JcimkLFjRjSsQDWIEtDvP4S +tH3vIMUPPiKdiNkGjVLSdChwkW3z+m0EvAjyD9rnGCmjeEm5diLFu7VMNVqupsbZ +SfDzzBLc5+6TqgQTOG7GaZk2diMkn03iLdHGFrh8ML+mXG9SjEPI +-----END CERTIFICATE----- diff --git a/shares-server/src/test/resources/sid-trusted-issuers/TEST_of_EID-SK_2016.pem.crt b/shares-server/src/test/resources/sid-trusted-issuers/TEST_of_EID-SK_2016.pem.crt new file mode 100644 index 0000000..dec3f5c --- /dev/null +++ b/shares-server/src/test/resources/sid-trusted-issuers/TEST_of_EID-SK_2016.pem.crt @@ -0,0 +1,40 @@ +-----BEGIN CERTIFICATE----- +MIIG+DCCBeCgAwIBAgIQUkCP5k8r59RXxWzfbx+GsjANBgkqhkiG9w0BAQwFADB9 +MQswCQYDVQQGEwJFRTEiMCAGA1UECgwZQVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1 +czEwMC4GA1UEAwwnVEVTVCBvZiBFRSBDZXJ0aWZpY2F0aW9uIENlbnRyZSBSb290 +IENBMRgwFgYJKoZIhvcNAQkBFglwa2lAc2suZWUwIBcNMTYwODMwMTEyNDE1WhgP +MjAzMDEyMTcyMzU5NTlaMGgxCzAJBgNVBAYTAkVFMSIwIAYDVQQKDBlBUyBTZXJ0 +aWZpdHNlZXJpbWlza2Vza3VzMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEcMBoG +A1UEAwwTVEVTVCBvZiBFSUQtU0sgMjAxNjCCAiIwDQYJKoZIhvcNAQEBBQADggIP +ADCCAgoCggIBAOrKOByrJqS1QsKD4tXhqkZafPMd5sfxem6iVbMAAHKpvOs4Ia2o +XdSvJ2FjrMl5szeT4lpHyzfECzO3nx7pvRLKHufi6lMwMGjtSI6DK8BiH9z7Lm+k +NLunNFdIir0hPijjbIkjg9iwfaeST9Fi5502LsK7duhKuCnH7O0uMrS/MynJ4StA +NGY13X2FvPW4qkrtbwsmhdN0Btro72O6/3O+0vbnq/yCWtcQrBGv3+8XEBdCqH5S +/Rt0EugKX4UlVy5l0QUc8IrjGtdMsr9KDtvmVwlefXYKoLqkC7guMGOUNf6Y4AYG +sPqfY4dG3N5YNp5FHDL7IO93h7TpRV3gyR38LiJsPHk5nES5mdPkNuEkCyg0zEKI +7uJ4LUuBbjzZPp2gP7PN8Iqi9GP7V2NCz8vUVN3WpHvctsf0DMvZdV5pxqLY5ojy +fhMsU4aMcGSQA9EK8ES3O1zBK1DW+btjbQjUFW1SIwCkB2yofFxge+vvzZGbvt2U +GOE8oAL8/JzNxi9FbjTAbycrGWgEMQ0sM1fKc+OsvoaSy9m3ZQGph0+dbsouQpl3 +kpJvjDMzxxkrMqxdhlVMreLKGCMMxJMAGQEwVS5P93Nnmz8UbkmeomUJr3NrBo4+ +V9L5S4Kx1vTvD0p72xRYFyfifLOjs8qs7lR3yhkcBPQI78ERqxv31FWDAgMBAAGj +ggKFMIICgTAfBgNVHSMEGDAWgBS1NAqdpS8QxechDr7EsWVHGwN2/jAdBgNVHQ4E +FgQUrrDq4Tb4JqulzAtmVf46HQK/ErQwDgYDVR0PAQH/BAQDAgEGMIHEBgNVHSAE +gbwwgbkwPAYHBACL7EABAjAxMC8GCCsGAQUFBwIBFiNodHRwczovL3d3dy5zay5l +ZS9yZXBvc2l0b29yaXVtL0NQUzA8BgcEAIvsQAEAMDEwLwYIKwYBBQUHAgEWI2h0 +dHBzOi8vd3d3LnNrLmVlL3JlcG9zaXRvb3JpdW0vQ1BTMDsGBgQAj3oBAjAxMC8G +CCsGAQUFBwIBFiNodHRwczovL3d3dy5zay5lZS9yZXBvc2l0b29yaXVtL0NQUzAS +BgNVHRMBAf8ECDAGAQH/AgEAMCcGA1UdJQQgMB4GCCsGAQUFBwMJBggrBgEFBQcD +AgYIKwYBBQUHAwQwfAYIKwYBBQUHAQEEcDBuMCAGCCsGAQUFBzABhhRodHRwOi8v +b2NzcC5zay5lZS9DQTBKBggrBgEFBQcwAoY+aHR0cDovL3d3dy5zay5lZS9jZXJ0 +cy9FRV9DZXJ0aWZpY2F0aW9uX0NlbnRyZV9Sb290X0NBLmRlci5jcnQwQQYDVR0e +BDowOKE2MASCAiIiMAqHCAAAAAAAAAAAMCKHIAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAMCUGCCsGAQUFBwEDBBkwFzAVBggrBgEFBQcLAjAJBgcEAIvs +SQEBMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHBzOi8vd3d3LnNrLmVlL3JlcG9zaXRv +cnkvY3Jscy90ZXN0X2VlY2NyY2EuY3JsMA0GCSqGSIb3DQEBDAUAA4IBAQAiw1VN +xp1Ho7FwcPlFqlLl6zb225IvpNelFX2QMbq1SPe41LuBW7WRZIV4b6bRQug55k8l +Am8eX3zEXL9I+4Bzai/IBlMSTYNpqAQGNVImQVwMa64uN8DWo8LNWSYNYYxQzO7s +TnqsqxLPWeKZRMkREI0RaVNoIPsciJvid9iBKTcGnMVkbrgyLzlXblLMU4I0pL2R +Wlfs2tr+XtCtWAvJPFskM2QZ2NnLjW8WroZr8TooocRA1vl/ruIAPC3FxW7zebKc +A2B66j4tW7uyF2kPx4WWA3xgR5QZnn4ePEAYjJdu1eWd9KbeAbxPCfFOST43t0fm +20HfV2Wp2PMEq4b2 +-----END CERTIFICATE----- diff --git a/shares-server/src/test/resources/sid-trusted-issuers/sk-ca.localhost.crt b/shares-server/src/test/resources/sid-trusted-issuers/sk-ca.localhost.crt new file mode 100644 index 0000000..30951c1 --- /dev/null +++ b/shares-server/src/test/resources/sid-trusted-issuers/sk-ca.localhost.crt @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB7jCCAZOgAwIBAgIUc3AeVxEYSTyVlAXpkFxs/G3OKD4wCgYIKoZIzj0EAwQw +TDELMAkGA1UEBhMCRUUxEDAOBgNVBAcMB1RhbGxpbm4xETAPBgNVBAoMCHNrLWxv +Y2FsMRgwFgYDVQQDDA9zay1jYS5sb2NhbGhvc3QwHhcNMjQxMTA0MTM0MjEwWhcN +MjUxMTA0MTM0MjEwWjBMMQswCQYDVQQGEwJFRTEQMA4GA1UEBwwHVGFsbGlubjER +MA8GA1UECgwIc2stbG9jYWwxGDAWBgNVBAMMD3NrLWNhLmxvY2FsaG9zdDBZMBMG +ByqGSM49AgEGCCqGSM49AwEHA0IABIIMkZNhqQizD1kHUcyFEKGA8b+SXua07Fvl +O67ZyCNq+uQggVh0szDURFDzaNDFQDY0R5ac9mL2+4NxPyCmihijUzBRMB0GA1Ud +DgQWBBRuzdu1wEFgGf5HC8ORIb0bPZLGXzAfBgNVHSMEGDAWgBRuzdu1wEFgGf5H +C8ORIb0bPZLGXzAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMEA0kAMEYCIQD0 +Wb6Dy2G+NPFaKQQ6SpJl5QNMJ0mMagbSz48Sky8DpAIhAOvVu2zPH4aaqiZhZCq7 +6ReVJoevfe0/H0JM7AciG+1P +-----END CERTIFICATE----- diff --git a/shares-server/src/test/resources/sid-trusted-issuers/test_sid_trusted_issuers.jks b/shares-server/src/test/resources/sid-trusted-issuers/test_sid_trusted_issuers.jks new file mode 100644 index 0000000000000000000000000000000000000000..d76657a7f8a36461e90f5c2494ad0d14e95ed434 GIT binary patch literal 4006 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