├── .gitignore ├── .mvn └── wrapper │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── exposure-notification └── src │ ├── test │ ├── resources │ │ ├── application-test.yml │ │ ├── sample_diagnosis_key_file.zip │ │ └── application-nodb.yml │ └── java │ │ └── fi │ │ └── thl │ │ └── covid19 │ │ └── exposurenotification │ │ ├── batch │ │ ├── ExportIntervalsTest.java │ │ ├── BatchIdTest.java │ │ ├── SigningTest.java │ │ └── BatchFileServiceIT.java │ │ ├── configuration │ │ └── ConfigurationDaoIT.java │ │ ├── efgs │ │ ├── CallbackControllerEnabledTest.java │ │ ├── CallbackControllerDisabledTest.java │ │ └── DsosMapperUtilTest.java │ │ ├── error │ │ └── TestController.java │ │ └── diagnosiskey │ │ ├── TransmissionRiskBucketsTest.java │ │ └── IntervalNumberTest.java │ └── main │ ├── resources │ ├── db │ │ └── migration │ │ │ ├── V03.05__add_symptoms_exists.sql │ │ │ ├── V03.03__add_visited_countries_to_configuration.sql │ │ │ ├── V02.02__alter_statistics_table.sql │ │ │ ├── V02.01__create_statistics_tables.sql │ │ │ ├── V04.02__diagnosis_key_v2.sql │ │ │ ├── V01.02__token_verification.sql │ │ │ ├── V03.04__alter_inbound_operation.sql │ │ │ ├── V04.03__end_of_life_configuration.sql │ │ │ ├── R__0_diagnosis_key_interval_index.sql │ │ │ ├── V01.01__diagnosis_key.sql │ │ │ ├── V02.03__alter_configuration_table.sql │ │ │ ├── V03.02__alter_diagnosis_key.sql │ │ │ ├── V01.03__exposure_configuration.sql │ │ │ ├── R__2_create_efgs_indexes.sql │ │ │ ├── V03.01__create_efgs_operation_tables.sql │ │ │ ├── V04.01__exposure_configuration_v2.sql │ │ │ ├── R__1_default_exposure_configuration.sql │ │ │ └── R__3_default_v2_exposure_configuration.sql │ ├── logback-dev.xml │ ├── logback.xml │ ├── application-dev.yml │ └── application.yml │ ├── java │ └── fi │ │ └── thl │ │ └── covid19 │ │ └── exposurenotification │ │ ├── tokenverification │ │ ├── PublishTokenVerificationService.java │ │ ├── PublishTokenVerification.java │ │ └── PublishTokenVerificationServiceRest.java │ │ ├── error │ │ ├── InputValidationException.java │ │ ├── TokenValidationException.java │ │ ├── BatchNotFoundException.java │ │ ├── RestTemplateErrorHandler.java │ │ ├── ApiError.java │ │ ├── CorrelationIdInterceptor.java │ │ └── DbSchemaCheckHealthIndicator.java │ │ ├── efgs │ │ ├── util │ │ │ ├── CommonConst.java │ │ │ └── DummyKeyGeneratorUtil.java │ │ ├── signing │ │ │ ├── FederationGatewaySigning.java │ │ │ ├── SigningUtil.java │ │ │ └── FederationGatewaySigningImpl.java │ │ ├── entity │ │ │ ├── Callback.java │ │ │ ├── UploadResponseEntity.java │ │ │ ├── DownloadData.java │ │ │ ├── OutboundOperation.java │ │ │ ├── InboundOperation.java │ │ │ └── AuditEntry.java │ │ ├── IntegrationErrorMeterConfiguration.java │ │ ├── CallbackController.java │ │ └── scheduled │ │ │ └── FederationGatewaySyncProcessor.java │ │ ├── diagnosiskey │ │ ├── v1 │ │ │ ├── CurrentBatch.java │ │ │ ├── BatchList.java │ │ │ ├── Status.java │ │ │ ├── DiagnosisPublishRequest.java │ │ │ └── TemporaryExposureKeyRequest.java │ │ ├── TransmissionRiskBuckets.java │ │ └── IntervalNumber.java │ │ ├── configuration │ │ ├── v2 │ │ │ ├── EndOfLifeStatistic.java │ │ │ ├── AppConfiguration.java │ │ │ ├── ExposureConfigurationV2Controller.java │ │ │ └── ExposureConfigurationV2.java │ │ ├── ConfigurationService.java │ │ └── v1 │ │ │ ├── AppConfiguration.java │ │ │ └── ExposureConfigurationController.java │ │ ├── WebMvcConfiguration.java │ │ ├── batch │ │ ├── SignatureConfig.java │ │ ├── BatchFile.java │ │ ├── BatchMetadata.java │ │ ├── BatchIntervals.java │ │ ├── Signing.java │ │ └── BatchId.java │ │ ├── ExposureNotificationApplication.java │ │ ├── FederationGatewayRestClientProperties.java │ │ ├── MaintenanceService.java │ │ └── FederationGatewayRestClientConfiguration.java │ └── proto │ ├── exposure-sig.proto │ ├── exposure-efgs.proto │ └── exposure-bin.proto ├── documentation ├── generated_images │ ├── efgs_inbound.png │ ├── efgs_outbound.png │ └── publish_diagnosis_keys.png └── plantuml │ ├── generate_plantuml.sh │ ├── efgs_outbound.pu │ ├── efgs_inbound.pu │ └── publish_diagnosis_keys.pu ├── .github ├── docker-slack │ ├── Dockerfile │ ├── action.yml │ └── entrypoint.sh ├── docker-notify-email │ ├── Dockerfile │ ├── action.yml │ └── entrypoint.sh ├── ISSUE_TEMPLATE │ ├── bug-or-problem-report.md │ ├── security_issue.md │ └── feature_request.md └── workflows │ ├── dependency-scan.yml │ ├── release-dependency-scan.yml │ ├── maven.yml │ └── codeql-analysis.yml ├── publish-token └── src │ ├── main │ ├── resources │ │ ├── db │ │ │ └── migration │ │ │ │ ├── V02.02__add_symptoms_exists.sql │ │ │ │ ├── R__0_publish_token_origin_index.sql │ │ │ │ ├── V02.01__create_statistics_tables.sql │ │ │ │ └── V01.01__publish_token.sql │ │ ├── application-nodb.yml │ │ ├── logback-dev.xml │ │ ├── application-dev.yml │ │ ├── logback.xml │ │ └── application.yml │ └── java │ │ └── fi │ │ └── thl │ │ └── covid19 │ │ └── publishtoken │ │ ├── error │ │ ├── InputValidationException.java │ │ ├── SmsGatewayException.java │ │ ├── InputValidationValidateOnlyException.java │ │ ├── ApiError.java │ │ ├── CorrelationIdInterceptor.java │ │ └── DbSchemaCheckHealthIndicator.java │ │ ├── generation │ │ └── v1 │ │ │ ├── PublishTokenList.java │ │ │ ├── PublishToken.java │ │ │ ├── PublishTokenGenerationRequest.java │ │ │ └── PublishTokenGenerationController.java │ │ ├── sms │ │ ├── SmsPayload.java │ │ ├── SmsConfig.java │ │ └── SmsService.java │ │ ├── verification │ │ └── v1 │ │ │ ├── PublishTokenVerification.java │ │ │ └── PublishTokenVerificationController.java │ │ ├── PublishTokenApplication.java │ │ ├── WebMvcConfiguration.java │ │ ├── ApplicationConfiguration.java │ │ ├── MaintenanceService.java │ │ ├── Validation.java │ │ └── PublishTokenService.java │ └── test │ └── java │ └── fi │ └── thl │ └── covid19 │ └── publishtoken │ ├── error │ └── TestController.java │ ├── PublishTokenDaoIT.java │ └── SMSServiceTestIT.java ├── CHANGELOG.md ├── AUTHORS.md ├── pom.xml ├── pull_request_template.md ├── SECURITY.md ├── make_release.sh ├── owasp_suppressions.xml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | !**/src/main/** 3 | !**/src/test/** 4 | *.iml 5 | .idea 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/THLfi/koronavilkku-backend/HEAD/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /exposure-notification/src/test/resources/application-test.yml: -------------------------------------------------------------------------------- 1 | covid19: 2 | diagnosis: 3 | data-cache: 4 | enabled: false 5 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/V03.05__add_symptoms_exists.sql: -------------------------------------------------------------------------------- 1 | alter table en.diagnosis_key add symptoms_exist boolean; 2 | -------------------------------------------------------------------------------- /documentation/generated_images/efgs_inbound.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/THLfi/koronavilkku-backend/HEAD/documentation/generated_images/efgs_inbound.png -------------------------------------------------------------------------------- /documentation/generated_images/efgs_outbound.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/THLfi/koronavilkku-backend/HEAD/documentation/generated_images/efgs_outbound.png -------------------------------------------------------------------------------- /documentation/generated_images/publish_diagnosis_keys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/THLfi/koronavilkku-backend/HEAD/documentation/generated_images/publish_diagnosis_keys.png -------------------------------------------------------------------------------- /.github/docker-slack/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | 3 | RUN apt update && apt install -y curl jq 4 | 5 | COPY entrypoint.sh /entrypoint.sh 6 | ENTRYPOINT ["/entrypoint.sh"] 7 | -------------------------------------------------------------------------------- /.github/docker-notify-email/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | 3 | RUN apt update && apt install -y curl jq 4 | 5 | COPY entrypoint.sh /entrypoint.sh 6 | ENTRYPOINT ["/entrypoint.sh"] 7 | -------------------------------------------------------------------------------- /exposure-notification/src/test/resources/sample_diagnosis_key_file.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/THLfi/koronavilkku-backend/HEAD/exposure-notification/src/test/resources/sample_diagnosis_key_file.zip -------------------------------------------------------------------------------- /publish-token/src/main/resources/db/migration/V02.02__add_symptoms_exists.sql: -------------------------------------------------------------------------------- 1 | alter table pt.publish_token add symptoms_exist boolean; 2 | alter table pt.stats_token_create add symptoms_exist boolean; 3 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/V03.03__add_visited_countries_to_configuration.sql: -------------------------------------------------------------------------------- 1 | alter table en.exposure_configuration add participating_countries varchar(2)[] not null default '{}'; 2 | -------------------------------------------------------------------------------- /publish-token/src/main/resources/application-nodb.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | autoconfigure: 3 | exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration 4 | flyway: 5 | enabled: false 6 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/V02.02__alter_statistics_table.sql: -------------------------------------------------------------------------------- 1 | alter table en.stats_report_keys add total_key_count bigint; 2 | alter table en.stats_report_keys add exported_key_count bigint; 3 | -------------------------------------------------------------------------------- /exposure-notification/src/test/resources/application-nodb.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | autoconfigure: 3 | exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration 4 | flyway: 5 | enabled: false 6 | -------------------------------------------------------------------------------- /publish-token/src/main/resources/db/migration/R__0_publish_token_origin_index.sql: -------------------------------------------------------------------------------- 1 | drop index if exists pt.publish_token_origin; 2 | create index publish_token_origin on pt.publish_token(origin_service, origin_user); 3 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/V02.01__create_statistics_tables.sql: -------------------------------------------------------------------------------- 1 | create table en.stats_report_keys ( 2 | id bigint primary key generated always as identity, 3 | reported_at timestamptz not null 4 | ); 5 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/V04.02__diagnosis_key_v2.sql: -------------------------------------------------------------------------------- 1 | alter table en.diagnosis_key add submission_interval_v2 int not null default 0; 2 | alter table en.diagnosis_key alter column submission_interval_v2 drop default; 3 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.3/apache-maven-3.6.3-bin.zip 2 | wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar 3 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/V01.02__token_verification.sql: -------------------------------------------------------------------------------- 1 | create table en.token_verification ( 2 | verification_id int primary key, 3 | request_checksum varchar(32), 4 | verification_time timestamptz not null default now() 5 | ); 6 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/V03.04__alter_inbound_operation.sql: -------------------------------------------------------------------------------- 1 | alter table en.efgs_inbound_operation add keys_count_success int not null default 0; 2 | alter table en.efgs_inbound_operation add keys_count_not_valid int not null default 0; 3 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/V04.03__end_of_life_configuration.sql: -------------------------------------------------------------------------------- 1 | alter table en.exposure_configuration_v2 add end_of_life_reached boolean not null default 'false'; 2 | alter table en.exposure_configuration_v2 add end_of_life_statistics jsonb not null default '[]'; 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## Unreleased 8 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/R__0_diagnosis_key_interval_index.sql: -------------------------------------------------------------------------------- 1 | drop index if exists en.diagnosis_key_interval; 2 | create index diagnosis_key_interval on en.diagnosis_key(submission_interval); 3 | create index diagnosis_key_interval_v2 on en.diagnosis_key(submission_interval_v2); 4 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/tokenverification/PublishTokenVerificationService.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.tokenverification; 2 | 3 | public interface PublishTokenVerificationService { 4 | PublishTokenVerification getVerification(String token); 5 | } 6 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/error/InputValidationException.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken.error; 2 | 3 | public class InputValidationException extends RuntimeException { 4 | 5 | public InputValidationException(String message) { 6 | super(message); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/error/SmsGatewayException.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken.error; 2 | 3 | public class SmsGatewayException extends RuntimeException { 4 | public SmsGatewayException() { 5 | super("SMS gateway error. Please try again later."); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/error/InputValidationException.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.error; 2 | 3 | public class InputValidationException extends RuntimeException { 4 | public InputValidationException(String message) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/V01.01__diagnosis_key.sql: -------------------------------------------------------------------------------- 1 | create table en.diagnosis_key ( 2 | key_data varchar(30) primary key, 3 | rolling_period int not null, 4 | rolling_start_interval_number int not null, 5 | transmission_risk_level int not null, 6 | submission_interval int not null 7 | ); 8 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/error/TokenValidationException.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.error; 2 | 3 | public class TokenValidationException extends RuntimeException { 4 | public TokenValidationException() { 5 | super("Publish token not accepted"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.github/docker-slack/action.yml: -------------------------------------------------------------------------------- 1 | name: 'thl-slack-notification' 2 | description: 'Simple slack notification action' 3 | inputs: 4 | text: 5 | description: 'Text content to send' 6 | required: true 7 | status: 8 | description: 'Status as text' 9 | required: true 10 | runs: 11 | using: 'docker' 12 | image: 'Dockerfile' 13 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/error/InputValidationValidateOnlyException.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken.error; 2 | 3 | @Deprecated 4 | public class InputValidationValidateOnlyException extends RuntimeException { 5 | public InputValidationValidateOnlyException(String msg) { 6 | super(msg); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /documentation/plantuml/generate_plantuml.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # This script generates images from plantuml diagram definitions. 5 | # 6 | 7 | cd "$(dirname "$0")" || exit 8 | 9 | for pu_file in ./*.pu 10 | do 11 | echo "Generating image from $pu_file" 12 | java -Djava.awt.headless=true -jar /opt/plantuml.jar -o ../generated_images $pu_file 13 | done 14 | -------------------------------------------------------------------------------- /publish-token/src/main/resources/db/migration/V02.01__create_statistics_tables.sql: -------------------------------------------------------------------------------- 1 | create table pt.stats_token_create ( 2 | id bigint primary key generated always as identity, 3 | created_at timestamptz not null 4 | ); 5 | 6 | create table pt.stats_sms_send ( 7 | id bigint primary key generated always as identity, 8 | sent_at timestamptz not null 9 | ); 10 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/efgs/util/CommonConst.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.efgs.util; 2 | 3 | public class CommonConst { 4 | public enum EfgsOperationState {STARTED, FINISHED, ERROR} 5 | 6 | public static final long STALLED_MIN_AGE_IN_MINUTES = 10; 7 | public static final int MAX_RETRY_COUNT = 3; 8 | } 9 | -------------------------------------------------------------------------------- /publish-token/src/main/resources/logback-dev.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.github/docker-notify-email/action.yml: -------------------------------------------------------------------------------- 1 | name: 'thl-email-notification' 2 | description: 'Simple email notification action' 3 | inputs: 4 | status: 5 | description: 'Status as text' 6 | required: true 7 | text: 8 | description: 'Custom text which will be appended to email template' 9 | required: false 10 | default: '' 11 | runs: 12 | using: 'docker' 13 | image: 'Dockerfile' 14 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/logback-dev.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/diagnosiskey/v1/CurrentBatch.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.diagnosiskey.v1; 2 | 3 | import fi.thl.covid19.exposurenotification.batch.BatchId; 4 | 5 | public class CurrentBatch { 6 | public final String current; 7 | 8 | public CurrentBatch(BatchId id) { 9 | this.current = id.toString(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /publish-token/src/main/resources/db/migration/V01.01__publish_token.sql: -------------------------------------------------------------------------------- 1 | create table pt.publish_token ( 2 | id int primary key generated always as identity, 3 | token varchar(20) not null unique, 4 | created_at timestamptz not null, 5 | valid_through timestamptz not null, 6 | symptoms_onset date not null, 7 | origin_service varchar(100) not null, 8 | origin_user varchar(50) not null 9 | ); 10 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/error/BatchNotFoundException.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.error; 2 | 3 | import fi.thl.covid19.exposurenotification.batch.BatchId; 4 | 5 | public class BatchNotFoundException extends RuntimeException { 6 | public BatchNotFoundException(BatchId batchId) { 7 | super("Batch not available: id=" + batchId); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/efgs/signing/FederationGatewaySigning.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.efgs.signing; 2 | 3 | import fi.thl.covid19.proto.EfgsProto; 4 | 5 | import java.security.cert.X509Certificate; 6 | 7 | public interface FederationGatewaySigning { 8 | 9 | String sign(final EfgsProto.DiagnosisKeyBatch data); 10 | X509Certificate getTrustAnchor(); 11 | 12 | } 13 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/error/RestTemplateErrorHandler.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.error; 2 | 3 | import org.springframework.stereotype.Component; 4 | import org.springframework.web.client.DefaultResponseErrorHandler; 5 | 6 | @Component 7 | public class RestTemplateErrorHandler extends DefaultResponseErrorHandler { 8 | public RestTemplateErrorHandler() { 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/generation/v1/PublishTokenList.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken.generation.v1; 2 | 3 | import java.util.List; 4 | 5 | import static java.util.Objects.requireNonNull; 6 | 7 | public class PublishTokenList { 8 | public final List publishTokens; 9 | 10 | public PublishTokenList(List publishTokens) { 11 | this.publishTokens = requireNonNull(publishTokens); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-or-problem-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug or problem report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug or problem** 11 | A clear and concise description of what the bug or problem is. 12 | 13 | **Expected behavior** 14 | A clear and concise description of what you expected to happen. 15 | 16 | **Additional context** 17 | Add any other context about the problem here. 18 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/V02.03__alter_configuration_table.sql: -------------------------------------------------------------------------------- 1 | alter table en.exposure_configuration add duration_at_attenuation_weights decimal(3,2) array[3] not null default '{ 1.0, 0.5, 0.0 }'; 2 | alter table en.exposure_configuration alter column duration_at_attenuation_weights drop default; 3 | alter table en.exposure_configuration add exposure_risk_duration int not null default 15; 4 | alter table en.exposure_configuration alter column exposure_risk_duration drop default; 5 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/efgs/entity/Callback.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.efgs.entity; 2 | 3 | import static java.util.Objects.requireNonNull; 4 | 5 | public class Callback { 6 | 7 | public final String callbackId; 8 | public final String url; 9 | 10 | public Callback(String callbackId, String url) { 11 | this.callbackId = requireNonNull(callbackId); 12 | this.url = requireNonNull(url); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /documentation/plantuml/efgs_outbound.pu: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | state OUTBOUND { 4 | [*] --> RECEIVED 5 | RECEIVED : Stored in db 6 | RECEIVED : No operation created yet 7 | RECEIVED --> STARTED 8 | STARTED --> FINISHED 9 | STARTED --> ERROR 10 | STARTED : Efgs sync timestamp updated in db 11 | ERROR --> FINISHED 12 | ERROR --> ERROR 13 | ERROR : Retry until max retries reached 14 | FINISHED : Number of keys sent stored to db 15 | FINISHED --> [*] 16 | ERROR --> [*] 17 | } 18 | @enduml 19 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/V03.02__alter_diagnosis_key.sql: -------------------------------------------------------------------------------- 1 | alter table en.diagnosis_key add origin varchar(2) not null default 'FI'; 2 | alter table en.diagnosis_key add visited_countries varchar(2)[] not null default '{}'; 3 | alter table en.diagnosis_key add days_since_onset_of_symptoms int; 4 | alter table en.diagnosis_key add consent_to_share boolean not null default 'false'; 5 | alter table en.diagnosis_key add efgs_sync timestamptz; 6 | alter table en.diagnosis_key add retry_count int not null default 0; 7 | 8 | -------------------------------------------------------------------------------- /publish-token/src/main/resources/application-dev.yml: -------------------------------------------------------------------------------- 1 | logging: 2 | config: src/main/resources/logback-dev.xml 3 | 4 | server: 5 | port: ${PT_SERVER_PORT:8081} 6 | 7 | management: 8 | server: 9 | port: ${PT_MANAGEMENT_SERVER_PORT:9081} 10 | 11 | spring: 12 | datasource: 13 | url: "${PT_DATABASE_URL:jdbc:postgresql://localhost:5433/exposure-notification}" 14 | username: "${PT_DATABASE_USERNAME:devserver}" 15 | password: "${PT_DATABASE_PASSWORD:devserver-password}" 16 | flyway: 17 | clean-on-validation-error: true 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/security_issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Security issue 3 | about: Report a security issue 4 | title: '' 5 | labels: security 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Reporting a Vulnerability 11 | 12 | Please **DO NOT** file a public issue, instead send your report privately to 13 | koronavilkku-security@solita.fi 14 | 15 | Security reports are greatly appreciated and we will publicly thank you for it, 16 | although we keep your name confidential if you request it. 17 | 18 | Reported vulnerabilities will be analyzed swiftly. 19 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/V01.03__exposure_configuration.sql: -------------------------------------------------------------------------------- 1 | create table en.exposure_configuration ( 2 | version int primary key generated always as identity, 3 | minimum_risk_score int not null, 4 | attenuation_scores int array[8] not null, 5 | days_since_last_exposure_scores int array[8] not null, 6 | duration_scores int array[8] not null, 7 | transmission_risk_scores int array[8] not null, 8 | duration_at_attenuation_thresholds int array[2] not null, 9 | change_time timestamptz not null default now() 10 | ); 11 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/R__2_create_efgs_indexes.sql: -------------------------------------------------------------------------------- 1 | drop index if exists en.diagnosis_key_efgs_sync; 2 | create index diagnosis_key_efgs_sync on en.diagnosis_key(efgs_sync, retry_count, consent_to_share); 3 | 4 | drop index if exists en.inbound_operation_updated_at_state; 5 | create index inbound_operation_updated_at_state on en.efgs_inbound_operation(updated_at, state); 6 | 7 | drop index if exists en.outbound_operation_updated_at_state; 8 | create index outbound_operation_updated_at_state on en.efgs_outbound_operation(updated_at, state); 9 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/sms/SmsPayload.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken.sms; 2 | 3 | import java.io.Serializable; 4 | import java.util.Set; 5 | 6 | public class SmsPayload implements Serializable { 7 | 8 | public final String sender; 9 | public final String text; 10 | public final Set destination; 11 | 12 | public SmsPayload(String sender, String text, Set destination) { 13 | this.sender = sender; 14 | this.text = text; 15 | this.destination = destination; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/error/ApiError.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken.error; 2 | 3 | 4 | import java.util.Optional; 5 | 6 | import static java.util.Objects.requireNonNull; 7 | 8 | public class ApiError { 9 | 10 | public final String errorId; 11 | public final int code; 12 | public final Optional message; 13 | 14 | public ApiError(String errorId, int code, Optional message) { 15 | this.errorId = requireNonNull(errorId); 16 | this.code = code; 17 | this.message = requireNonNull(message); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/configuration/v2/EndOfLifeStatistic.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.configuration.v2; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | 5 | import java.util.Map; 6 | 7 | public class EndOfLifeStatistic { 8 | 9 | public final Map value; 10 | public final Map label; 11 | 12 | @JsonCreator 13 | public EndOfLifeStatistic(Map value, Map label) { 14 | this.value = value; 15 | this.label = label; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/error/ApiError.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.error; 2 | 3 | 4 | import java.util.Optional; 5 | 6 | import static java.util.Objects.requireNonNull; 7 | 8 | public class ApiError { 9 | 10 | public final String errorId; 11 | public final int code; 12 | public final Optional message; 13 | 14 | public ApiError(String errorId, int code, Optional message) { 15 | this.errorId = requireNonNull(errorId); 16 | this.code = code; 17 | this.message = requireNonNull(message); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/docker-slack/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/dash 2 | 3 | set -e 4 | 5 | JOB_STATUS=$(echo "$INPUT_STATUS" | tr '[:lower:]' '[:upper:]') 6 | WORKFLOW_RUN_PATH="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" 7 | 8 | PAYLOAD=$(jq -n \ 9 | --arg text "*$GITHUB_REPOSITORY: $INPUT_TEXT $JOB_STATUS*." \ 10 | --arg wr_path "$WORKFLOW_RUN_PATH" \ 11 | '{blocks:[{type:"section",text:{type:"mrkdwn",text: $text}},{type: "divider"},{type:"actions",elements:[{type:"button",text:{type:"plain_text",text:"View run",emoji:true},url:$wr_path}]}]}') 12 | 13 | sh -c "curl -X POST -H 'Content-type: application/json' --data '$PAYLOAD' $SLACK_WEBHOOK_URL" 14 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/diagnosiskey/v1/BatchList.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.diagnosiskey.v1; 2 | 3 | import fi.thl.covid19.exposurenotification.batch.BatchId; 4 | 5 | import java.util.List; 6 | import java.util.stream.Collectors; 7 | 8 | import static java.util.Objects.requireNonNull; 9 | 10 | public class BatchList { 11 | public final List batches; 12 | 13 | public BatchList(List batches) { 14 | this.batches = requireNonNull(batches).stream() 15 | .map(BatchId::toString) 16 | .collect(Collectors.toList()); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/efgs/entity/UploadResponseEntity.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.efgs.entity; 2 | 3 | import org.springframework.http.HttpStatus; 4 | 5 | import java.util.List; 6 | import java.util.Map; 7 | import java.util.Optional; 8 | 9 | public class UploadResponseEntity { 10 | 11 | public final HttpStatus httpStatus; 12 | public final Optional>> multiStatuses; 13 | 14 | public UploadResponseEntity(HttpStatus httpStatus, Optional>> multiStatuses) { 15 | this.httpStatus = httpStatus; 16 | this.multiStatuses = multiStatuses; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /documentation/plantuml/efgs_inbound.pu: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | state INBOUND { 4 | [*] --> STARTED 5 | STARTED : Inbound triggered 6 | STARTED --> VERIFY 7 | VERIFY : Verify batch signature 8 | VERIFY --> VALIDATE 9 | VALIDATE : Validate batch data 10 | VALIDATE --> STORE 11 | STORE : Store keys to db 12 | STORE --> FINISHED 13 | STARTED --> ERROR 14 | VERIFY --> FAIL 15 | VALIDATE --> FAIL 16 | STORE --> ERROR 17 | ERROR --> STARTED 18 | ERROR : Retry until max retries reached 19 | FINISHED : Key counts stored in db 20 | FAIL : Keys not stored, no retries 21 | FINISHED --> [*] 22 | ERROR --> [*] 23 | FAIL --> [*] 24 | } 25 | @enduml 26 | -------------------------------------------------------------------------------- /exposure-notification/src/main/proto/exposure-sig.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto2"; 2 | 3 | package exposure; 4 | option java_package = "fi.thl.covid19.proto"; 5 | option java_multiple_files = true; 6 | import "exposure-bin.proto"; 7 | 8 | message TEKSignatureList { 9 | repeated TEKSignature signatures = 1; 10 | } 11 | message TEKSignature { 12 | // Info about the signing key, version, algorithm, and so on. 13 | optional SignatureInfo signature_info = 1; 14 | // For example, file 2 in batch size of 10. Ordinal, 1-based numbering. 15 | optional int32 batch_num = 2; 16 | optional int32 batch_size = 3; 17 | // Signature in X9.62 format (ASN.1 SEQUENCE of two INTEGER fields) 18 | optional bytes signature = 4; 19 | } 20 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/verification/v1/PublishTokenVerification.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken.verification.v1; 2 | 3 | import java.time.LocalDate; 4 | import java.util.Optional; 5 | 6 | import static java.util.Objects.requireNonNull; 7 | 8 | public class PublishTokenVerification { 9 | public final int id; 10 | public final LocalDate symptomsOnset; 11 | public final Optional symptomsExist; 12 | 13 | public PublishTokenVerification(int id, LocalDate symptomsOnset, Optional symptomsExist) { 14 | this.id = id; 15 | this.symptomsOnset = requireNonNull(symptomsOnset); 16 | this.symptomsExist = requireNonNull(symptomsExist); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/PublishTokenApplication.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken; 2 | 3 | import fi.thl.covid19.publishtoken.sms.SmsConfig; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.boot.context.properties.EnableConfigurationProperties; 7 | import org.springframework.scheduling.annotation.EnableScheduling; 8 | 9 | @EnableScheduling 10 | @EnableConfigurationProperties({ SmsConfig.class }) 11 | @SpringBootApplication 12 | public class PublishTokenApplication { 13 | 14 | public static void main(String[] args) { 15 | SpringApplication.run(PublishTokenApplication.class, args); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Authors 2 | 3 | Authors will be listed in alphabetical order. 4 | 5 | ## Koronavilkku-tiimi 6 | 7 | We want to credit the whole core team first. All authors of this repository 8 | can be found at the bottom of this file [Authors](#authors). 9 | 10 | The team members are: 11 | - Tuomo Ala-Vannesluoma 12 | - Tuomas Granlund 13 | - Jani Grönman 14 | - Jaakko Hannikainen 15 | - Seppo Heikkinen 16 | - Jussi Heikkola 17 | - Jyrki Jakobsson 18 | - Henry Jalonen 19 | - Risto Kaikkonen 20 | - Lauri Kieksi 21 | - Jari Kinnunen 22 | - Mika Kulmala 23 | - Sami Köykkä 24 | - Tomi Lahtinen 25 | - Niklas Murtola 26 | - Esko Oramaa 27 | - Pekko Tiitto 28 | - Alpertti Tirronen 29 | 30 | ## Authors 31 | - Tuomo Ala-Vannesluoma 32 | - Jyrki Jakobsson 33 | - Alpertti Tirronen 34 | 35 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 4.0.0 5 | fi.thl.covid19.backend 6 | parent 7 | pom 8 | 3.2 9 | 10 | parent 11 | Parent project for COVID app backend 12 | 13 | 14 | exposure-notification 15 | publish-token 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## We welcome contributions from the public. However there are some guidelines to follow: 2 | 3 | 1. Prior to committing to work, **create an issue** describing a problem, bug or enhancement you would to like to work on 4 | 2. **Wait for discussion** on the issue and how it should be solved 5 | 3. **Wait for main contributors** of the repository to handle the issue and clear it for implementation 6 | 4. Embrace the feedback and be patient. We are working as fast as we can to improve Koronavilkku and community's help is much appreciated 7 | 8 | Please **always** add reference to related issue. You can bring up drop-down of suggested issues by typing #. 9 | 10 | This pull request resolves # . 11 | 12 | Also, remember to update CHANGELOG.md before merging your changes. 13 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | We take security seriously and welcome all reports. 4 | 5 | ## Reporting a Vulnerability 6 | 7 | Please **DO NOT** file a public issue, instead send your report privately to 8 | koronavilkku-security@solita.fi 9 | 10 | Security reports are greatly appreciated and we will publicly thank you for it, 11 | although we keep your name confidential if you request it. 12 | 13 | Reported vulnerabilities will be analyzed swiftly. 14 | 15 | ## OWASP dependency checks 16 | 17 | Dependencies of this project are checked against known vulnerabilities with the *OWASP dependency-check* -tool. 18 | Dependency checks are run periodically once per day and on every push to the repository. 19 | 20 | More info about the tool can be found here: https://jeremylong.github.io/DependencyCheck/index.html 21 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/efgs/entity/DownloadData.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.efgs.entity; 2 | 3 | import fi.thl.covid19.proto.EfgsProto; 4 | 5 | import java.util.Optional; 6 | 7 | 8 | public class DownloadData { 9 | public final Optional batch; 10 | public final String batchTag; 11 | public final Optional nextBatchTag; 12 | 13 | public DownloadData(Optional batch, String batchTag, Optional nextBatchTag) { 14 | this.batch = batch; 15 | this.batchTag = batchTag; 16 | this.nextBatchTag = nextBatchTag; 17 | } 18 | 19 | public int keysCount() { 20 | return batch.map(EfgsProto.DiagnosisKeyBatch::getKeysCount).orElse(0); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/efgs/entity/OutboundOperation.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.efgs.entity; 2 | 3 | import fi.thl.covid19.exposurenotification.diagnosiskey.TemporaryExposureKey; 4 | 5 | import java.time.LocalDate; 6 | import java.time.ZoneOffset; 7 | import java.util.List; 8 | 9 | import static fi.thl.covid19.exposurenotification.efgs.util.BatchUtil.getBatchTag; 10 | 11 | public class OutboundOperation { 12 | public final List keys; 13 | public final long operationId; 14 | public final String batchTag; 15 | 16 | public OutboundOperation(List keys, long operationId) { 17 | this.keys = keys; 18 | this.operationId = operationId; 19 | this.batchTag = getBatchTag(LocalDate.now(ZoneOffset.UTC), "fi-" + operationId); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /exposure-notification/src/main/proto/exposure-efgs.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package exposure; 3 | option java_package = "fi.thl.covid19.proto"; 4 | option java_outer_classname = "EfgsProto"; 5 | 6 | message DiagnosisKeyBatch { 7 | repeated DiagnosisKey keys = 1; 8 | } 9 | 10 | message DiagnosisKey { 11 | bytes keyData = 1; // key 12 | uint32 rollingStartIntervalNumber = 2; 13 | uint32 rollingPeriod = 3; // number of 10-minute windows between key-rolling 14 | int32 transmissionRiskLevel = 4; // risk of transmission 15 | repeated string visitedCountries = 5; 16 | string origin = 6; // country of origin 17 | ReportType reportType = 7; // set by backend 18 | sint32 days_since_onset_of_symptoms = 8; 19 | } 20 | 21 | enum ReportType { 22 | UNKNOWN = 0; 23 | CONFIRMED_TEST = 1; 24 | CONFIRMED_CLINICAL_DIAGNOSIS = 2; 25 | SELF_REPORT = 3; 26 | RECURSIVE = 4; 27 | REVOKED = 5; 28 | } 29 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/WebMvcConfiguration.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken; 2 | 3 | import fi.thl.covid19.publishtoken.error.CorrelationIdInterceptor; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.web.servlet.config.annotation.InterceptorRegistry; 6 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 7 | 8 | @Configuration 9 | public class WebMvcConfiguration implements WebMvcConfigurer { 10 | 11 | final CorrelationIdInterceptor correlationIdInterceptor; 12 | 13 | public WebMvcConfiguration(CorrelationIdInterceptor correlationIdInterceptor) { 14 | this.correlationIdInterceptor = correlationIdInterceptor; 15 | } 16 | 17 | @Override 18 | public void addInterceptors(InterceptorRegistry registry) { 19 | registry.addInterceptor(correlationIdInterceptor); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/WebMvcConfiguration.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification; 2 | 3 | import fi.thl.covid19.exposurenotification.error.CorrelationIdInterceptor; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.web.servlet.config.annotation.InterceptorRegistry; 6 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 7 | 8 | @Configuration 9 | public class WebMvcConfiguration implements WebMvcConfigurer { 10 | 11 | final 12 | CorrelationIdInterceptor correlationIdInterceptor; 13 | 14 | public WebMvcConfiguration(CorrelationIdInterceptor correlationIdInterceptor) { 15 | this.correlationIdInterceptor = correlationIdInterceptor; 16 | } 17 | 18 | @Override 19 | public void addInterceptors(InterceptorRegistry registry) { 20 | registry.addInterceptor(correlationIdInterceptor); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/dependency-scan.yml: -------------------------------------------------------------------------------- 1 | name: Check dependencies 2 | 3 | on: 4 | push: 5 | branches: '*' 6 | pull_request: 7 | branches: 'trunk' 8 | 9 | jobs: 10 | dependency-scan: 11 | name: Owasp Dependency Scanning 12 | runs-on: ubuntu-latest 13 | container: maven:3.6-openjdk-11 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Cache Maven Repository 17 | uses: actions/cache@v2 18 | with: 19 | path: /root/.m2/repository 20 | key: ${{ runner.os }}-m2-dependency-scan-${{ hashFiles('**/pom.xml') }} 21 | restore-keys: ${{ runner.os }}-m2-dependency-scan 22 | - name: OWASP dependency check 23 | run: mvn package -B -Powasp-dependency-check --file pom.xml -DskipTests 24 | - uses: actions/upload-artifact@v2 25 | if: ${{ success() || failure() }} 26 | with: 27 | name: dependency-check-result 28 | path: ./**/target/dependency-check-report.html 29 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/V03.01__create_efgs_operation_tables.sql: -------------------------------------------------------------------------------- 1 | create type en.state_t as enum('STARTED', 'FINISHED', 'ERROR'); 2 | 3 | create table en.efgs_outbound_operation ( 4 | id bigint primary key generated always as identity, 5 | state en.state_t not null, 6 | keys_count_total int not null default 0, 7 | keys_count_201 int not null default 0, 8 | keys_count_409 int not null default 0, 9 | keys_count_500 int not null default 0, 10 | batch_tag varchar(100), 11 | updated_at timestamptz not null 12 | ); 13 | 14 | create table en.efgs_inbound_operation ( 15 | id bigint primary key generated always as identity, 16 | state en.state_t not null, 17 | keys_count_total int not null default 0, 18 | invalid_signature_count int not null default 0, 19 | batch_tag varchar(100), 20 | retry_count int not null default 0, 21 | updated_at timestamptz not null, 22 | batch_date timestamptz not null 23 | ); 24 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/batch/SignatureConfig.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.batch; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | import org.springframework.boot.context.properties.ConstructorBinding; 5 | 6 | import static java.util.Objects.requireNonNull; 7 | 8 | @ConstructorBinding 9 | @ConfigurationProperties(prefix = "covid19.diagnosis.signature") 10 | public class SignatureConfig { 11 | public final String keyVersion; 12 | public final String keyId; 13 | public final String algorithmOid; 14 | public final String algorithmName; 15 | 16 | public SignatureConfig(String keyVersion, String keyId, String algorithmOid, String algorithmName) { 17 | this.keyVersion = requireNonNull(keyVersion); 18 | this.keyId = requireNonNull(keyId); 19 | this.algorithmOid = requireNonNull(algorithmOid); 20 | this.algorithmName = requireNonNull(algorithmName); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/V04.01__exposure_configuration_v2.sql: -------------------------------------------------------------------------------- 1 | create extension if not exists hstore schema public; 2 | create table en.exposure_configuration_v2 ( 3 | version int primary key generated always as identity, 4 | report_type_weight_confirmed_test numeric not null, 5 | report_type_weight_confirmed_clinical_diagnosis numeric not null, 6 | report_type_weight_self_report numeric not null, 7 | report_type_weight_recursive numeric not null, 8 | infectiousness_weight_standard numeric not null, 9 | infectiousness_weight_high numeric not null, 10 | attenuation_bucket_threshold_db numeric array[3] not null, 11 | attenuation_bucket_weights numeric array[4] not null, 12 | days_since_exposure_threshold int not null, 13 | minimum_window_score numeric not null, 14 | minimum_daily_score int not null, 15 | days_since_onset_to_infectiousness hstore not null, 16 | infectiousness_when_dsos_missing varchar(20) not null, 17 | available_countries varchar(2)[] not null 18 | ); 19 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/ApplicationConfiguration.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken; 2 | 3 | import org.springframework.boot.web.client.RestTemplateBuilder; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.http.converter.StringHttpMessageConverter; 7 | import org.springframework.web.client.RestTemplate; 8 | 9 | import java.time.Duration; 10 | 11 | import static java.time.temporal.ChronoUnit.SECONDS; 12 | 13 | @Configuration 14 | public class ApplicationConfiguration { 15 | 16 | private static final Duration TIMEOUT = Duration.of(10, SECONDS); 17 | 18 | @Bean 19 | public RestTemplate restTemplate(RestTemplateBuilder builder) { 20 | return builder 21 | .additionalMessageConverters(new StringHttpMessageConverter()) 22 | .setConnectTimeout(TIMEOUT) 23 | .setReadTimeout(TIMEOUT) 24 | .build(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /publish-token/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/tokenverification/PublishTokenVerification.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.tokenverification; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | 6 | import java.time.LocalDate; 7 | import java.util.Optional; 8 | 9 | import static java.util.Objects.requireNonNull; 10 | 11 | public class PublishTokenVerification { 12 | public final int id; 13 | public final LocalDate symptomsOnset; 14 | public final Optional symptomsExist; 15 | 16 | @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) 17 | public PublishTokenVerification(@JsonProperty("id") int id, 18 | @JsonProperty("symptomsOnset") LocalDate symptomsOnset, 19 | @JsonProperty("symptomsExist") Optional symptomsExist 20 | ) { 21 | this.id = id; 22 | this.symptomsOnset = requireNonNull(symptomsOnset); 23 | this.symptomsExist = requireNonNull(symptomsExist); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /documentation/plantuml/publish_diagnosis_keys.pu: -------------------------------------------------------------------------------- 1 | @startuml 2 | skinparam defaultTextAlignment center 3 | skinparam noteTextAlignment left 4 | start 5 | :**Filter keys** 6 | 7 | Keys older than 14 days and in future will be filtered; 8 | :**Verify publish token** 9 | 10 | Creates call to publish-token api; 11 | :**Adjust risk buckets**; 12 | note right 13 | - There is predefined array of day limits for every bucket: 14, 10, 8, 6, 4, 2, -3. 14 | - Day value which is compared towards the array is days between symptomsOnSet and encounter day (the TEK date) 15 | - Negative value means the encounter has been before the symptoms 16 | - Value for SymptomsOnSet has been set by a health care professional when creating the publish token 17 | - Single bucket will be chosen if day value is greater than value in bucket and less than value on the left hand 18 | - E.g. if value is 11 then bucket with index 1 will be chosen (14 > 11 > 10) 19 | - On later stage this index value will be used to get value from transmission_risk_scores array defined in configuration 20 | end note 21 | :**Store diagnosis keys**; 22 | stop 23 | @enduml 24 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/diagnosiskey/TransmissionRiskBuckets.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.diagnosiskey; 2 | 3 | import java.time.LocalDate; 4 | import java.time.temporal.ChronoUnit; 5 | import java.util.List; 6 | 7 | public final class TransmissionRiskBuckets { 8 | private TransmissionRiskBuckets() {} 9 | 10 | public static final int DEFAULT_RISK_BUCKET = 4; 11 | // Risk buckets for an encounter are defined through days since the onset of symptoms. 12 | private static final List INTERVAL_BUCKETS = List.of(14, 10, 8, 6, 4, 2, -3); 13 | 14 | public static int getRiskBucket(LocalDate symptomsOnset, LocalDate encounter) { 15 | return getRiskBucket(ChronoUnit.DAYS.between(symptomsOnset, encounter)); 16 | } 17 | 18 | public static int getRiskBucket(long daysBetween) { 19 | for (int i = 0; i < INTERVAL_BUCKETS.size(); i++) { 20 | if (daysBetween > INTERVAL_BUCKETS.get(i)) { 21 | return i; 22 | } 23 | } 24 | return INTERVAL_BUCKETS.size(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /exposure-notification/src/test/java/fi/thl/covid19/exposurenotification/batch/ExportIntervalsTest.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.batch; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import static org.junit.jupiter.api.Assertions.assertFalse; 6 | import static org.junit.jupiter.api.Assertions.assertTrue; 7 | 8 | public class ExportIntervalsTest { 9 | 10 | @Test 11 | public void distributionIsRecognizedCorrectly() { 12 | int current = 5; 13 | int keep = 2; 14 | BatchIntervals intervals = new BatchIntervals(current, keep, false); 15 | assertFalse(intervals.isDistributed(0)); 16 | assertFalse(intervals.isDistributed(2)); 17 | assertFalse(intervals.isDistributed(5)); 18 | assertFalse(intervals.isDistributed(6)); 19 | assertTrue(intervals.isDistributed(3)); 20 | assertTrue(intervals.isDistributed(4)); 21 | } 22 | 23 | @Test 24 | public void onlyDemoModeDistributesCurrent() { 25 | assertFalse(new BatchIntervals(123, 2, false).isDistributed(123)); 26 | assertTrue(new BatchIntervals(123, 2, true).isDistributed(123)); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Before you make feature request, please consider following instructions.** 11 | 12 | 1. Prior to committing to work, **create an issue** describing a problem, bug or enhancement you would to like to work on 13 | 2. **Wait for discussion** on the issue and how it should be solved 14 | 3. **Wait for main contributors** of the repository to handle the issue and clear it for implementation 15 | 4. Embrace the feedback and be patient. We are working as fast as we can to improve Koronavilkku and community's help is much appreciated 16 | 17 | **Is your feature request related to a problem? Please describe.** 18 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 19 | 20 | **Describe the solution you'd like** 21 | A clear and concise description of what you want to happen. 22 | 23 | **Describe alternatives you've considered** 24 | A clear and concise description of any alternative solutions or features you've considered. 25 | 26 | **Additional context** 27 | Add any other context about the feature request here. 28 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/generation/v1/PublishToken.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken.generation.v1; 2 | 3 | import java.time.Instant; 4 | import java.util.Objects; 5 | 6 | import static java.util.Objects.requireNonNull; 7 | 8 | public class PublishToken { 9 | public final String token; 10 | public final Instant createTime; 11 | public final Instant validThroughTime; 12 | 13 | public PublishToken(String token, Instant createTime, Instant validThroughTime) { 14 | this.token = requireNonNull(token); 15 | this.createTime = requireNonNull(createTime); 16 | this.validThroughTime = requireNonNull(validThroughTime); 17 | } 18 | 19 | @Override 20 | public boolean equals(Object o) { 21 | if (this == o) return true; 22 | if (o == null || getClass() != o.getClass()) return false; 23 | PublishToken that = (PublishToken) o; 24 | return token.equals(that.token) && 25 | createTime.equals(that.createTime) && 26 | validThroughTime.equals(that.validThroughTime); 27 | } 28 | 29 | @Override 30 | public int hashCode() { 31 | return Objects.hash(token, createTime, validThroughTime); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/application-dev.yml: -------------------------------------------------------------------------------- 1 | logging: 2 | config: src/main/resources/logback-dev.xml 3 | 4 | spring: 5 | datasource: 6 | url: "${EN_DATABASE_URL:jdbc:postgresql://localhost:5433/exposure-notification}" 7 | username: "${EN_DATABASE_USERNAME:devserver}" 8 | password: "${EN_DATABASE_PASSWORD:devserver-password}" 9 | flyway: 10 | clean-on-validation-error: true 11 | 12 | covid19: 13 | diagnosis: 14 | signature: 15 | randomize-key: true 16 | publish-token: 17 | url: "${EN_PT_URL:http://localhost:8081}" 18 | federation-gateway: 19 | enabled: false 20 | base-url: http://localhost:8080/diagnosiskeys 21 | dev-client: true 22 | callback-initializer-enabled: false 23 | client-sha256: "${EN_DEV_EFGS_SHA256:}" 24 | client-dn: C=FI,CN=koronavilkku.dev,O=koronavilkku dev 25 | rest-client: 26 | trust-store: 27 | path: 28 | password: 3fgs-p4ssw0rd 29 | client-key-store: 30 | path: 31 | password: devdev 32 | alias: efgs 33 | signing-key-store: 34 | implementation: dev 35 | path: 36 | password: devdev 37 | key-alias: signing 38 | trust-anchor-alias: efgs-trust-anchor 39 | call-back: 40 | enabled: false 41 | local-url: 42 | -------------------------------------------------------------------------------- /.github/docker-notify-email/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/dash 2 | 3 | set -e 4 | 5 | JOB_STATUS=$(echo "$INPUT_STATUS" | tr '[:lower:]' '[:upper:]') 6 | WORKFLOW_RUN_PATH="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" 7 | 8 | SUBJECT="$GITHUB_REPOSITORY: OWASP Dependency Check ran with status: $JOB_STATUS" 9 | BODY="

$SUBJECT.

$INPUT_TEXT

View run

" 10 | 11 | RECIPIENTS_JSON="" 12 | 13 | generate_recipients() { 14 | RECIPIENTS_ARR=$(awk -v r="$RECIPIENTS" 'BEGIN { split( r, array, ","); for (i=1;i<=length(array);i++) { print array[i]; } }') 15 | 16 | for I in $RECIPIENTS_ARR; do 17 | RECIPIENT=$(jq -n --arg r "$I" '{email: $r}') 18 | RECIPIENTS_JSON="$RECIPIENTS_JSON$RECIPIENT," 19 | done 20 | 21 | RECIPIENTS_JSON="${RECIPIENTS_JSON%?}" 22 | } 23 | 24 | generate_recipients 25 | 26 | PAYLOAD=$(jq -n \ 27 | --argjson rec "[$RECIPIENTS_JSON]" \ 28 | --arg from "$FROM" \ 29 | --arg subject "$SUBJECT" \ 30 | --arg body "$BODY" \ 31 | '{personalizations: [{to: $rec}], from: {email: $from},subject: $subject,content: [{type: "text/html", value: $body}]}') 32 | 33 | sh -c "curl --request POST --url $SEND_URL --header 'Authorization: Bearer $API_KEY' --header 'Content-Type: application/json' --data '$PAYLOAD'" 34 | -------------------------------------------------------------------------------- /.github/workflows/release-dependency-scan.yml: -------------------------------------------------------------------------------- 1 | name: Check latest release dependencies 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - '**' 7 | 8 | jobs: 9 | release-dependency-scan: 10 | name: Owasp Release Dependency Scanning 11 | runs-on: ubuntu-latest 12 | container: maven:3.6-openjdk-11 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | fetch-depth: '0' 17 | - name: Store latest actions 18 | run: | 19 | cp -R .github .github_latest 20 | - name: Checkout latest release 21 | run: | 22 | git fetch --all --tags --prune 23 | git checkout tags/prod -b latest_release 24 | - name: Cache Maven Repository 25 | uses: actions/cache@v2 26 | with: 27 | path: /root/.m2/repository 28 | key: ${{ runner.os }}-m2-release-dependency-scan-${{ hashFiles('**/pom.xml') }} 29 | restore-keys: ${{ runner.os }}-m2-release-dependency-scan 30 | - name: OWASP dependency check 31 | run: mvn package -B -Powasp-dependency-check --file pom.xml -DskipTests 32 | - uses: actions/upload-artifact@v2 33 | if: ${{ success() || failure() }} 34 | with: 35 | name: dependency-check-result 36 | path: ./**/target/dependency-check-report.html 37 | -------------------------------------------------------------------------------- /exposure-notification/src/test/java/fi/thl/covid19/exposurenotification/configuration/ConfigurationDaoIT.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.configuration; 2 | 3 | import fi.thl.covid19.exposurenotification.configuration.v1.ExposureConfiguration; 4 | import fi.thl.covid19.exposurenotification.configuration.v2.ExposureConfigurationV2; 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 8 | import org.springframework.boot.test.context.SpringBootTest; 9 | import org.springframework.test.context.ActiveProfiles; 10 | 11 | import static org.junit.jupiter.api.Assertions.assertTrue; 12 | 13 | @SpringBootTest 14 | @ActiveProfiles({"dev","test"}) 15 | @AutoConfigureMockMvc 16 | public class ConfigurationDaoIT { 17 | 18 | @Autowired 19 | private ConfigurationDao dao; 20 | 21 | @Test 22 | public void exposureConfigIsReturned() { 23 | ExposureConfiguration config = dao.getLatestExposureConfiguration(); 24 | assertTrue(config.version > 0); 25 | } 26 | 27 | @Test 28 | public void exposureConfigV2IsReturned() { 29 | ExposureConfigurationV2 config = dao.getLatestV2ExposureConfiguration(); 30 | assertTrue(config.version > 0); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/configuration/ConfigurationService.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.configuration; 2 | 3 | import fi.thl.covid19.exposurenotification.configuration.v1.AppConfiguration; 4 | import fi.thl.covid19.exposurenotification.configuration.v1.ExposureConfiguration; 5 | import fi.thl.covid19.exposurenotification.configuration.v2.ExposureConfigurationV2; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | import org.springframework.stereotype.Service; 9 | 10 | import static java.util.Objects.requireNonNull; 11 | 12 | @Service 13 | public class ConfigurationService { 14 | 15 | private static final Logger LOG = LoggerFactory.getLogger(ConfigurationService.class); 16 | 17 | private final ConfigurationDao dao; 18 | 19 | public ConfigurationService(ConfigurationDao dao) { 20 | this.dao = requireNonNull(dao); 21 | LOG.info("Initialized"); 22 | } 23 | 24 | public ExposureConfiguration getLatestExposureConfig() { 25 | return dao.getLatestExposureConfiguration(); 26 | } 27 | 28 | public ExposureConfigurationV2 getLatestV2ExposureConfig() { 29 | return dao.getLatestV2ExposureConfiguration(); 30 | } 31 | 32 | public AppConfiguration getLatestAppConfig() { 33 | return AppConfiguration.DEFAULT; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/R__1_default_exposure_configuration.sql: -------------------------------------------------------------------------------- 1 | -- As a repeatable migration, this will be re-run whenever the file changes 2 | insert into en.exposure_configuration 3 | (minimum_risk_score, attenuation_scores, days_since_last_exposure_scores, duration_scores, transmission_risk_scores, duration_at_attenuation_thresholds, duration_at_attenuation_weights, exposure_risk_duration, participating_countries) 4 | select * from ( 5 | values 6 | (72::int, 7 | '{ 1, 2, 3, 4, 5, 6, 7, 8 }'::int array[8], 8 | '{ 0, 0, 0, 1, 1, 1, 1, 1 }'::int array[8], 9 | '{ 1, 2 ,2, 4, 5, 5, 7, 8 }'::int array[8], 10 | '{ 0, 0, 4, 6, 6, 7, 8, 0 }'::int array[8], 11 | '{ 50, 70 }'::int array[2], 12 | '{ 1.0, 0.5, 0.0 }'::decimal(3,2) array[3], 13 | 15::int, 14 | '{ BE, BG, CZ, DK, DE, EE, IE, GR, ES, FR, HR, IT, CY, LV, LT, LU, HU, MT, NL, AT, PL, PT, RO, SI, SK, SE, IS, NO, LI, CH, GB }'::varchar(2)[]) 15 | ) as default_values 16 | -- Don't insert a new version if the latest one is identical 17 | except ( 18 | select 19 | minimum_risk_score, attenuation_scores, days_since_last_exposure_scores, duration_scores, transmission_risk_scores, duration_at_attenuation_thresholds, duration_at_attenuation_weights, exposure_risk_duration, participating_countries 20 | from en.exposure_configuration 21 | order by version desc limit 1 22 | ); 23 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/efgs/IntegrationErrorMeterConfiguration.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.efgs; 2 | 3 | import fi.thl.covid19.exposurenotification.efgs.dao.InboundOperationDao; 4 | import fi.thl.covid19.exposurenotification.efgs.dao.OutboundOperationDao; 5 | import io.micrometer.core.instrument.Gauge; 6 | import io.micrometer.core.instrument.binder.MeterBinder; 7 | import org.springframework.context.annotation.Bean; 8 | import org.springframework.context.annotation.Configuration; 9 | 10 | 11 | @Configuration 12 | public class IntegrationErrorMeterConfiguration { 13 | 14 | @Bean 15 | MeterBinder inboundErrorsLastDay(InboundOperationDao inboundOperationDao) { 16 | return (registry) -> Gauge.builder("efgs_inbound_errors_last_24h", inboundOperationDao::getNumberOfErrorsForDay).register(registry); 17 | } 18 | 19 | @Bean 20 | MeterBinder outboundErrorsLastDay(OutboundOperationDao outboundOperationDao) { 21 | return (registry) -> Gauge.builder("efgs_outbound_errors_last_24h", outboundOperationDao::getNumberOfErrorsForDay).register(registry); 22 | } 23 | 24 | @Bean 25 | MeterBinder invalidSignatureCountForDay(InboundOperationDao inboundOperationDao) { 26 | return (registry) -> Gauge.builder("efgs_invalid_signature_count_last_24h", inboundOperationDao::getInvalidSignatureCountForDay).register(registry); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | name: Backend services CI 2 | 3 | on: 4 | push: 5 | branches: '*' 6 | pull_request: 7 | branches: '*' 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | container: maven:3.6-openjdk-11 13 | services: 14 | postgres: 15 | image: postgres:12 16 | env: 17 | POSTGRES_USER: devserver 18 | POSTGRES_PASSWORD: devserver-password 19 | POSTGRES_DB: exposure-notification 20 | options: >- 21 | --health-cmd pg_isready 22 | --health-interval 10s 23 | --health-timeout 5s 24 | --health-retries 5 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Cache Maven Repository 28 | uses: actions/cache@v2 29 | with: 30 | path: /root/.m2/repository 31 | key: ${{ runner.os }}-m2-build-${{ hashFiles('**/pom.xml') }} 32 | restore-keys: ${{ runner.os }}-m2-build 33 | - name: Build with Maven 34 | run: mvn -B package verify -Dlogging.config=src/main/resources/logback-dev.xml 35 | env: 36 | EN_DATABASE_URL: jdbc:postgresql://postgres:5432/exposure-notification 37 | EN_DATABASE_USERNAME: devserver 38 | EN_DATABASE_PASSWORD: devserver-password 39 | PT_DATABASE_URL: jdbc:postgresql://postgres:5432/exposure-notification 40 | PT_DATABASE_USERNAME: devserver 41 | PT_DATABASE_PASSWORD: devserver-password 42 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/diagnosiskey/v1/Status.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.diagnosiskey.v1; 2 | 3 | import fi.thl.covid19.exposurenotification.batch.BatchId; 4 | import fi.thl.covid19.exposurenotification.configuration.v1.AppConfiguration; 5 | import fi.thl.covid19.exposurenotification.configuration.v1.ExposureConfiguration; 6 | import fi.thl.covid19.exposurenotification.configuration.v2.ExposureConfigurationV2; 7 | 8 | import java.util.List; 9 | import java.util.Optional; 10 | import java.util.stream.Collectors; 11 | 12 | import static java.util.Objects.requireNonNull; 13 | 14 | public class Status { 15 | 16 | public final List batches; 17 | public final Optional appConfig; 18 | public final Optional exposureConfig; 19 | public final Optional exposureConfigV2; 20 | 21 | public Status(List batches, 22 | Optional appConfig, 23 | Optional exposureConfig, 24 | Optional exposureConfigV2) { 25 | this.batches = requireNonNull(batches).stream().map(BatchId::toString).collect(Collectors.toList()); 26 | this.appConfig = requireNonNull(appConfig); 27 | this.exposureConfig = requireNonNull(exposureConfig); 28 | this.exposureConfigV2 = requireNonNull(exposureConfigV2); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/batch/BatchFile.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.batch; 2 | 3 | import static java.util.Objects.requireNonNull; 4 | 5 | public class BatchFile { 6 | 7 | public static final String FILE_PREFIX = "batch_"; 8 | public static final String ZIP_POSTFIX = ".zip"; 9 | 10 | public final BatchId id; 11 | public final byte[] data; 12 | 13 | public BatchFile(BatchId id, byte[] data) { 14 | this.id = requireNonNull(id); 15 | this.data = requireNonNull(data); 16 | } 17 | 18 | public String getName() { 19 | return batchFileName(id); 20 | } 21 | 22 | public static String batchFileName(BatchId batchId) { 23 | return FILE_PREFIX + batchId + ZIP_POSTFIX; 24 | } 25 | 26 | public static boolean isBatchFileName(String name) { 27 | return name.startsWith(FILE_PREFIX) && 28 | name.endsWith(ZIP_POSTFIX) && 29 | name.length() > FILE_PREFIX.length() + ZIP_POSTFIX.length(); 30 | } 31 | 32 | public static BatchId toBatchId(String name) { 33 | if (isBatchFileName(name)) { 34 | int startIdx = FILE_PREFIX.length(); 35 | int endIdx = name.length() - ZIP_POSTFIX.length(); 36 | String idStr = name.substring(startIdx, endIdx); 37 | return new BatchId(idStr); 38 | } else { 39 | throw new IllegalStateException("Not a diagnosis batch file: " + name); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/configuration/v1/AppConfiguration.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.configuration.v1; 2 | 3 | public class AppConfiguration { 4 | public final int version; 5 | public final int tokenLength; 6 | public final int diagnosisKeysPerSubmit; 7 | public final int pollingIntervalMinutes; 8 | public final int municipalityFetchIntervalHours; 9 | public final int lowRiskLimit; 10 | public final int highRiskLimit; 11 | 12 | public AppConfiguration(int version, 13 | int tokenLength, 14 | int diagnosisKeysPerSubmit, 15 | int pollingIntervalMinutes, 16 | int municipalityFetchIntervalHours, 17 | int lowRiskLimit, 18 | int highRiskLimit) { 19 | this.version = version; 20 | this.tokenLength = tokenLength; 21 | this.diagnosisKeysPerSubmit = diagnosisKeysPerSubmit; 22 | this.pollingIntervalMinutes = pollingIntervalMinutes; 23 | this.municipalityFetchIntervalHours = municipalityFetchIntervalHours; 24 | this.lowRiskLimit = lowRiskLimit; 25 | this.highRiskLimit = highRiskLimit; 26 | } 27 | 28 | public static final AppConfiguration DEFAULT = new AppConfiguration( 29 | 1, 30 | 12, 31 | 14, 32 | 60*4, 33 | 48, 34 | 96, 35 | 336); 36 | } 37 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/configuration/v2/AppConfiguration.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.configuration.v2; 2 | 3 | public class AppConfiguration { 4 | public final int version; 5 | public final int tokenLength; 6 | public final int diagnosisKeysPerSubmit; 7 | public final int pollingIntervalMinutes; 8 | public final int municipalityFetchIntervalHours; 9 | public final int lowRiskLimit; 10 | public final int highRiskLimit; 11 | 12 | public AppConfiguration(int version, 13 | int tokenLength, 14 | int diagnosisKeysPerSubmit, 15 | int pollingIntervalMinutes, 16 | int municipalityFetchIntervalHours, 17 | int lowRiskLimit, 18 | int highRiskLimit) { 19 | this.version = version; 20 | this.tokenLength = tokenLength; 21 | this.diagnosisKeysPerSubmit = diagnosisKeysPerSubmit; 22 | this.pollingIntervalMinutes = pollingIntervalMinutes; 23 | this.municipalityFetchIntervalHours = municipalityFetchIntervalHours; 24 | this.lowRiskLimit = lowRiskLimit; 25 | this.highRiskLimit = highRiskLimit; 26 | } 27 | 28 | public static final AppConfiguration DEFAULT = new AppConfiguration( 29 | 1, 30 | 12, 31 | 14, 32 | 60*4, 33 | 48, 34 | 96, 35 | 336); 36 | } 37 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/ExposureNotificationApplication.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification; 2 | 3 | import fi.thl.covid19.exposurenotification.batch.SignatureConfig; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.boot.context.properties.EnableConfigurationProperties; 7 | import org.springframework.context.annotation.Bean; 8 | import org.springframework.scheduling.annotation.EnableAsync; 9 | import org.springframework.scheduling.annotation.EnableScheduling; 10 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; 11 | 12 | import java.util.concurrent.Executor; 13 | 14 | @EnableScheduling 15 | @EnableAsync 16 | @EnableConfigurationProperties({SignatureConfig.class, FederationGatewayRestClientProperties.class}) 17 | @SpringBootApplication 18 | public class ExposureNotificationApplication { 19 | 20 | public static void main(String[] args) { 21 | SpringApplication.run(ExposureNotificationApplication.class, args); 22 | } 23 | 24 | @Bean(name = "callbackAsyncExecutor") 25 | public Executor taskExecutor() { 26 | ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); 27 | executor.setCorePoolSize(1); 28 | executor.setMaxPoolSize(1); 29 | executor.setQueueCapacity(1000); 30 | executor.setThreadNamePrefix("callback-processor-"); 31 | executor.initialize(); 32 | return executor; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/batch/BatchMetadata.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.batch; 2 | 3 | import static fi.thl.covid19.exposurenotification.diagnosiskey.IntervalNumber.startSecondOf24HourInterval; 4 | import static fi.thl.covid19.exposurenotification.diagnosiskey.IntervalNumber.startSecondOfV2Interval; 5 | import static java.util.Objects.requireNonNull; 6 | 7 | public class BatchMetadata { 8 | // Start timestamp inclusive, end timestamp exclusive 9 | public final long startTimestampUtcSec; 10 | public final long endTimestampUtcSec; 11 | public final String region; 12 | 13 | public BatchMetadata(long startTimestampUtcSec, 14 | long endTimestampUtcSec, 15 | String region) { 16 | this.startTimestampUtcSec = startTimestampUtcSec; 17 | this.endTimestampUtcSec = endTimestampUtcSec; 18 | this.region = requireNonNull(region); 19 | } 20 | 21 | public static BatchMetadata of(int intervalNumber, String region) { 22 | return new BatchMetadata( 23 | startSecondOf24HourInterval(intervalNumber), 24 | startSecondOf24HourInterval(intervalNumber+1), 25 | region); 26 | } 27 | 28 | public static BatchMetadata ofV2(int intervalNumberV2, String region) { 29 | return new BatchMetadata( 30 | startSecondOfV2Interval(intervalNumberV2), 31 | startSecondOfV2Interval(intervalNumberV2+1), 32 | region); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /exposure-notification/src/test/java/fi/thl/covid19/exposurenotification/efgs/CallbackControllerEnabledTest.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.efgs; 2 | 3 | import fi.thl.covid19.exposurenotification.ExposureNotificationApplication; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 7 | import org.springframework.boot.test.mock.mockito.MockBean; 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.test.context.ActiveProfiles; 10 | import org.springframework.test.context.ContextConfiguration; 11 | import org.springframework.test.web.servlet.MockMvc; 12 | 13 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 14 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 15 | 16 | @ActiveProfiles({"dev", "test"}) 17 | @WebMvcTest(value = CallbackController.class, properties = {"covid19.federation-gateway.call-back.enabled=true"}) 18 | @ContextConfiguration(classes = ExposureNotificationApplication.class) 19 | public class CallbackControllerEnabledTest { 20 | 21 | @Autowired 22 | private MockMvc mvc; 23 | 24 | @MockBean 25 | private InboundService inboundService; 26 | 27 | @Test 28 | public void callBackReturns202() throws Exception { 29 | mvc.perform(get("/efgs/callback") 30 | .param("batchTag", "tag-1") 31 | .param("date", "2020-11-13")) 32 | .andExpect(status().is(HttpStatus.ACCEPTED.value())); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/MaintenanceService.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import org.springframework.beans.factory.annotation.Value; 6 | import org.springframework.scheduling.annotation.Scheduled; 7 | import org.springframework.stereotype.Service; 8 | 9 | import java.time.Duration; 10 | import java.time.Instant; 11 | 12 | import static java.util.Objects.requireNonNull; 13 | import static net.logstash.logback.argument.StructuredArguments.keyValue; 14 | 15 | @Service 16 | public class MaintenanceService { 17 | private static final Logger LOG = LoggerFactory.getLogger(MaintenanceService.class); 18 | 19 | private final PublishTokenDao dao; 20 | private final Duration expiredTokenLifetime; 21 | 22 | public MaintenanceService(PublishTokenDao dao, 23 | @Value("${covid19.maintenance.expired-token-lifetime}") Duration expiredTokenLifetime) { 24 | this.dao = requireNonNull(dao); 25 | this.expiredTokenLifetime = requireNonNull(expiredTokenLifetime); 26 | LOG.info("Initialized: {}", keyValue("expiredTokenLifetime", expiredTokenLifetime)); 27 | } 28 | 29 | @Scheduled(initialDelayString = "${covid19.maintenance.interval}", 30 | fixedRateString = "${covid19.maintenance.interval}") 31 | public void deleteExpiredTokens() { 32 | Instant limit = Instant.now().minus(expiredTokenLifetime); 33 | LOG.info("Deleting expired tokens: {}", keyValue("limit", limit.toString())); 34 | dao.deleteTokensExpiredBefore(limit); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /make_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | # 6 | # This script creates release branch and rolls version in trunk. 7 | # 8 | 9 | PROGRAM_NAME=$0 10 | 11 | usage() { 12 | printf "usage: %s --new-dev-version=[version]\n" "$PROGRAM_NAME" 13 | printf "new-dev-version: Next version which will be updated to poms in trunk after release branch is created\n" 14 | exit 1 15 | } 16 | 17 | check_parameters() { 18 | if [ -z ${NEW_VERSION+x} ]; then usage; fi 19 | } 20 | 21 | for i in "$@" 22 | do 23 | case $i in 24 | --new-dev-version=*) 25 | NEW_VERSION="${i#*=}" 26 | shift 27 | ;; 28 | *) 29 | ;; 30 | esac 31 | done 32 | 33 | check_parameters 34 | 35 | git fetch --all --tags --prune 36 | git checkout trunk 37 | git pull --rebase 38 | 39 | CURRENT_VERSION=$(grep -oPm1 "(?<=)[^<]+" pom.xml) 40 | 41 | git checkout -b release/$CURRENT_VERSION 42 | 43 | while true; do 44 | read -rp "Do you want to push new release branch(release/$CURRENT_VERSION) to remote?" yn 45 | case $yn in 46 | [Yy]* ) git push --set-upstream origin "release/$CURRENT_VERSION"; break;; 47 | [Nn]* ) break;; 48 | * ) echo "Please answer yes or no.";; 49 | esac 50 | done 51 | 52 | git checkout trunk 53 | 54 | find . -name "pom.xml" -exec sed -i "s|$CURRENT_VERSION|$NEW_VERSION|" {} \; 55 | 56 | git commit -a -m "Rolled version for new development version" 57 | 58 | while true; do 59 | read -p "Do you want to push new version($NEW_VERSION) to remote?" yn 60 | case $yn in 61 | [Yy]* ) git push; break;; 62 | [Nn]* ) break;; 63 | * ) echo "Please answer yes or no.";; 64 | esac 65 | done 66 | -------------------------------------------------------------------------------- /exposure-notification/src/test/java/fi/thl/covid19/exposurenotification/error/TestController.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.error; 2 | 3 | 4 | import org.springframework.web.bind.annotation.*; 5 | 6 | import java.sql.SQLException; 7 | 8 | @RestController 9 | @RequestMapping("/test") 10 | public class TestController { 11 | 12 | public static final String FAILURE_STRING = "TEST_FAILURE_STRING"; 13 | 14 | @GetMapping("/input-validation-failure") 15 | public void getInputValidationException() { 16 | throw new InputValidationException(FAILURE_STRING); 17 | } 18 | 19 | @GetMapping("/token-verification-failure") 20 | public void getTokenValidationException() { 21 | throw new TokenValidationException(); 22 | } 23 | 24 | @GetMapping("/illegal-state") 25 | public void getIllegalStateException() { 26 | throw new IllegalStateException(FAILURE_STRING); 27 | } 28 | 29 | @GetMapping("/illegal-argument") 30 | public void getIllegalArgumentException() { 31 | throw new IllegalArgumentException(FAILURE_STRING); 32 | } 33 | 34 | @GetMapping("/sql-exception") 35 | public void getSqlException() throws SQLException { 36 | throw new SQLException(FAILURE_STRING); 37 | } 38 | 39 | @GetMapping("/should-be-int-path/{int_value}") 40 | public void getIntPath(@PathVariable(value = "int_value") Integer integer) { 41 | if (integer == null) throw new NullPointerException("Should get parameter"); 42 | } 43 | 44 | @GetMapping("/should-be-int-param") 45 | public void getIntParam(@RequestParam(value = "param") Integer integer) { 46 | if (integer == null) throw new NullPointerException("Should get parameter"); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /publish-token/src/test/java/fi/thl/covid19/publishtoken/error/TestController.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken.error; 2 | 3 | 4 | import org.springframework.web.bind.annotation.*; 5 | 6 | import java.sql.SQLException; 7 | 8 | @RestController 9 | @RequestMapping("/test") 10 | public class TestController { 11 | 12 | public static final String FAILURE_STRING = "TEST_FAILURE_STRING"; 13 | 14 | @GetMapping("/input-validation-failure") 15 | public void getInputValidationException() { 16 | throw new InputValidationException(FAILURE_STRING); 17 | } 18 | 19 | @GetMapping("/input-validation-failure-validate-only") 20 | public void getInputValidationExceptionWithValidateOnly() { 21 | throw new InputValidationValidateOnlyException(FAILURE_STRING); 22 | } 23 | 24 | @GetMapping("/illegal-state") 25 | public void getIllegalStateException() { 26 | throw new IllegalStateException(FAILURE_STRING); 27 | } 28 | 29 | @GetMapping("/illegal-argument") 30 | public void getIllegalArgumentException() { 31 | throw new IllegalArgumentException(FAILURE_STRING); 32 | } 33 | 34 | @GetMapping("/sql-exception") 35 | public void getSqlException() throws SQLException { 36 | throw new SQLException(FAILURE_STRING); 37 | } 38 | 39 | @GetMapping("/should-be-int-path/{int_value}") 40 | public void getIntPath(@PathVariable(value = "int_value") Integer integer) { 41 | if (integer == null) throw new NullPointerException("Should get parameter"); 42 | } 43 | 44 | @GetMapping("/should-be-int-param") 45 | public void getIntParam(@RequestParam(value = "param") Integer integer) { 46 | if (integer == null) throw new NullPointerException("Should get parameter"); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/error/CorrelationIdInterceptor.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken.error; 2 | 3 | import org.slf4j.MDC; 4 | import org.springframework.stereotype.Component; 5 | import org.springframework.web.servlet.HandlerInterceptor; 6 | import org.springframework.web.servlet.ModelAndView; 7 | 8 | import javax.servlet.http.HttpServletRequest; 9 | import javax.servlet.http.HttpServletResponse; 10 | import java.util.Random; 11 | 12 | @Component 13 | public class CorrelationIdInterceptor implements HandlerInterceptor { 14 | 15 | private static final String CORRELATION_ID_KEY_NAME = "correlationId"; 16 | 17 | private static final Random RAND = new Random(); 18 | 19 | public static String getOrCreateCorrelationId() { 20 | String correlationId = MDC.get(CORRELATION_ID_KEY_NAME); 21 | 22 | if (correlationId == null || correlationId.isEmpty()) { 23 | correlationId = Integer.toString(RAND.nextInt(Integer.MAX_VALUE)); 24 | MDC.put(CORRELATION_ID_KEY_NAME, correlationId); 25 | } 26 | 27 | return correlationId; 28 | } 29 | 30 | public static void clearCorrelationID() { 31 | MDC.remove(CORRELATION_ID_KEY_NAME); 32 | } 33 | 34 | @Override 35 | public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { 36 | getOrCreateCorrelationId(); 37 | return true; 38 | } 39 | 40 | @Override 41 | public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) { 42 | } 43 | 44 | @Override 45 | public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { 46 | clearCorrelationID(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/error/CorrelationIdInterceptor.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.error; 2 | 3 | import org.slf4j.MDC; 4 | import org.springframework.stereotype.Component; 5 | import org.springframework.web.servlet.HandlerInterceptor; 6 | import org.springframework.web.servlet.ModelAndView; 7 | 8 | import javax.servlet.http.HttpServletRequest; 9 | import javax.servlet.http.HttpServletResponse; 10 | import java.util.Random; 11 | 12 | @Component 13 | public class CorrelationIdInterceptor implements HandlerInterceptor { 14 | 15 | private static final String CORRELATION_ID_KEY_NAME = "correlationId"; 16 | 17 | private static final Random RAND = new Random(); 18 | 19 | public static String getOrCreateCorrelationId() { 20 | String correlationId = MDC.get(CORRELATION_ID_KEY_NAME); 21 | 22 | if (correlationId == null || correlationId.isEmpty()) { 23 | correlationId = Integer.toString(RAND.nextInt(Integer.MAX_VALUE)); 24 | MDC.put(CORRELATION_ID_KEY_NAME, correlationId); 25 | } 26 | 27 | return correlationId; 28 | } 29 | 30 | public static void clearCorrelationID() { 31 | MDC.remove(CORRELATION_ID_KEY_NAME); 32 | } 33 | 34 | @Override 35 | public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { 36 | getOrCreateCorrelationId(); 37 | return true; 38 | } 39 | 40 | @Override 41 | public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) { 42 | } 43 | 44 | @Override 45 | public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { 46 | clearCorrelationID(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /exposure-notification/src/test/java/fi/thl/covid19/exposurenotification/efgs/CallbackControllerDisabledTest.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.efgs; 2 | 3 | import fi.thl.covid19.exposurenotification.ExposureNotificationApplication; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 7 | import org.springframework.boot.test.mock.mockito.MockBean; 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.test.context.ActiveProfiles; 10 | import org.springframework.test.context.ContextConfiguration; 11 | import org.springframework.test.web.servlet.MockMvc; 12 | 13 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 14 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 15 | 16 | @ActiveProfiles({"dev", "test"}) 17 | @WebMvcTest(CallbackController.class) 18 | @ContextConfiguration(classes = ExposureNotificationApplication.class) 19 | public class CallbackControllerDisabledTest { 20 | 21 | @Autowired 22 | private MockMvc mvc; 23 | 24 | @MockBean 25 | private InboundService inboundService; 26 | 27 | @Test 28 | public void callBackReturns503() throws Exception { 29 | mvc.perform(get("/efgs/callback") 30 | .param("batchTag", "tag-1") 31 | .param("date", "2020-11-13")) 32 | .andExpect(status().is(HttpStatus.SERVICE_UNAVAILABLE.value())); 33 | } 34 | 35 | @Test 36 | public void callBackInvalidDateReturnsClientError() throws Exception { 37 | mvc.perform(get("/efgs/callback") 38 | .param("batchTag", "tag-1") 39 | .param("date", "2020-11-13455647")) 40 | .andExpect(status().is4xxClientError()); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/error/DbSchemaCheckHealthIndicator.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken.error; 2 | 3 | import org.springframework.boot.actuate.health.Health; 4 | import org.springframework.boot.actuate.health.HealthIndicator; 5 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 6 | import org.springframework.dao.DataAccessException; 7 | import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; 8 | import org.springframework.stereotype.Repository; 9 | 10 | import java.util.Map; 11 | 12 | import static java.util.Objects.requireNonNull; 13 | 14 | @Repository 15 | @ConditionalOnProperty( 16 | prefix = "covid19.db-schema-check", value = "enabled" 17 | ) 18 | public class DbSchemaCheckHealthIndicator implements HealthIndicator { 19 | 20 | private final NamedParameterJdbcTemplate jdbcTemplate; 21 | 22 | public DbSchemaCheckHealthIndicator(NamedParameterJdbcTemplate jdbcTemplate) { 23 | this.jdbcTemplate = requireNonNull(jdbcTemplate); 24 | } 25 | 26 | @Override 27 | public Health health() { 28 | if (checkEnSchemaExists()) { 29 | return Health.up().build(); 30 | } else { 31 | return Health.down().withDetail("Db schema en does not exists or unspecified db query error", "FAILED").build(); 32 | } 33 | } 34 | 35 | private boolean checkEnSchemaExists() { 36 | String sql = "select count(*) from information_schema.schemata where schema_name = :schema_name"; 37 | try { 38 | return jdbcTemplate.query(sql, Map.of("schema_name", "pt"), 39 | (rs, i) -> rs.getInt(1)).stream().findFirst().orElseThrow(() -> new IllegalStateException("Count returned nothing.")) > 0; 40 | } catch (DataAccessException | IllegalStateException e) { 41 | return false; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/error/DbSchemaCheckHealthIndicator.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.error; 2 | 3 | import org.springframework.boot.actuate.health.Health; 4 | import org.springframework.boot.actuate.health.HealthIndicator; 5 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 6 | import org.springframework.dao.DataAccessException; 7 | import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; 8 | import org.springframework.stereotype.Repository; 9 | 10 | import java.util.Map; 11 | 12 | import static java.util.Objects.requireNonNull; 13 | 14 | @Repository 15 | @ConditionalOnProperty( 16 | prefix = "covid19.db-schema-check", value = "enabled" 17 | ) 18 | public class DbSchemaCheckHealthIndicator implements HealthIndicator { 19 | 20 | private final NamedParameterJdbcTemplate jdbcTemplate; 21 | 22 | public DbSchemaCheckHealthIndicator(NamedParameterJdbcTemplate jdbcTemplate) { 23 | this.jdbcTemplate = requireNonNull(jdbcTemplate); 24 | } 25 | 26 | @Override 27 | public Health health() { 28 | if (checkEnSchemaExists()) { 29 | return Health.up().build(); 30 | } else { 31 | return Health.down().withDetail("Db schema en does not exists or unspecified db query error", "FAILED").build(); 32 | } 33 | } 34 | 35 | private boolean checkEnSchemaExists() { 36 | String sql = "select count(*) from information_schema.schemata where schema_name = :schema_name"; 37 | try { 38 | return jdbcTemplate.query(sql, Map.of("schema_name", "en"), 39 | (rs, i) -> rs.getInt(1)).stream().findFirst().orElseThrow(() -> new IllegalStateException("Count returned nothing.")) > 0; 40 | } catch (DataAccessException | IllegalStateException e) { 41 | return false; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/verification/v1/PublishTokenVerificationController.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken.verification.v1; 2 | 3 | import fi.thl.covid19.publishtoken.PublishTokenService; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.springframework.http.ResponseEntity; 7 | import org.springframework.web.bind.annotation.GetMapping; 8 | import org.springframework.web.bind.annotation.RequestHeader; 9 | import org.springframework.web.bind.annotation.RequestMapping; 10 | import org.springframework.web.bind.annotation.RestController; 11 | 12 | import java.util.Optional; 13 | 14 | import static fi.thl.covid19.publishtoken.Validation.validatePublishToken; 15 | import static java.util.Objects.requireNonNull; 16 | import static net.logstash.logback.argument.StructuredArguments.keyValue; 17 | 18 | @RestController 19 | @RequestMapping("/verification/v1") 20 | public class PublishTokenVerificationController { 21 | 22 | private static final Logger LOG = LoggerFactory.getLogger(PublishTokenVerificationController.class); 23 | 24 | public static final String TOKEN_HEADER = "KV-Publish-Token"; 25 | 26 | private final PublishTokenService publishTokenService; 27 | 28 | public PublishTokenVerificationController(PublishTokenService publishTokenService) { 29 | this.publishTokenService = requireNonNull(publishTokenService); 30 | } 31 | 32 | @GetMapping 33 | public ResponseEntity getVerification(@RequestHeader(TOKEN_HEADER) String token) { 34 | String validated = validatePublishToken(requireNonNull(token)); 35 | Optional result = publishTokenService.getVerification(validated); 36 | LOG.info("Verifying token: {}", keyValue("accepted", result.isPresent())); 37 | return result.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.noContent().build()); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/diagnosiskey/v1/DiagnosisPublishRequest.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.diagnosiskey.v1; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import fi.thl.covid19.exposurenotification.diagnosiskey.Validation; 5 | import fi.thl.covid19.exposurenotification.error.InputValidationException; 6 | 7 | import java.util.List; 8 | import java.util.Map; 9 | import java.util.Optional; 10 | import java.util.Set; 11 | 12 | import static java.util.Objects.requireNonNull; 13 | 14 | public class DiagnosisPublishRequest { 15 | 16 | private static final int KEYS_PER_REQUEST = 14; 17 | 18 | public final List keys; 19 | /** 20 | * Set of visited countries in ISO-3166 alpha-2 format 21 | **/ 22 | public final Map visitedCountries; 23 | 24 | /** 25 | * Set of visited countries in ISO-3166 alpha-2 format 26 | **/ 27 | public final Set visitedCountriesSet; 28 | 29 | /** 30 | * Consent to share data with efgs 31 | **/ 32 | public final boolean consentToShareWithEfgs; 33 | 34 | @JsonCreator 35 | public DiagnosisPublishRequest( 36 | List keys, 37 | Optional> visitedCountries, 38 | Optional consentToShareWithEfgs 39 | ) { 40 | if (keys.size() != KEYS_PER_REQUEST) { 41 | throw new InputValidationException("The request should contain exactly " + KEYS_PER_REQUEST + " keys"); 42 | } 43 | this.keys = requireNonNull(keys); 44 | this.visitedCountries = requireNonNull(visitedCountries).orElse(Map.of()); 45 | this.visitedCountriesSet = Validation.validateISOCountryCodesWithoutFI(this.visitedCountries); 46 | this.consentToShareWithEfgs = requireNonNull(consentToShareWithEfgs).orElse(false); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/FederationGatewayRestClientProperties.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | import org.springframework.boot.context.properties.ConstructorBinding; 5 | 6 | import static java.util.Objects.requireNonNull; 7 | 8 | @ConfigurationProperties(prefix = "covid19.federation-gateway.rest-client") 9 | @ConstructorBinding 10 | public class FederationGatewayRestClientProperties { 11 | 12 | public final TrustStore trustStore; 13 | public final ClientKeyStore clientKeyStore; 14 | 15 | public FederationGatewayRestClientProperties(TrustStore trustStore, ClientKeyStore clientKeyStore) { 16 | this.trustStore = requireNonNull(trustStore); 17 | this.clientKeyStore = requireNonNull(clientKeyStore); 18 | } 19 | 20 | public boolean isMandatoryPropertiesAvailable() { 21 | return !this.trustStore.path.isBlank() && 22 | this.trustStore.password.length > 0 && 23 | !this.clientKeyStore.path.isBlank() && 24 | this.clientKeyStore.password.length > 0; 25 | } 26 | 27 | public static class TrustStore { 28 | public final String path; 29 | public final char[] password; 30 | 31 | public TrustStore(String path, char[] password) { 32 | this.path = requireNonNull(path); 33 | this.password = requireNonNull(password); 34 | } 35 | } 36 | 37 | public static class ClientKeyStore { 38 | public final String path; 39 | public final char[] password; 40 | public final String alias; 41 | 42 | public ClientKeyStore(String path, char[] password, String alias) { 43 | this.path = requireNonNull(path); 44 | this.password = requireNonNull(password); 45 | this.alias = requireNonNull(alias); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/sms/SmsConfig.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken.sms; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | import org.springframework.boot.context.properties.ConstructorBinding; 5 | import org.springframework.util.StringUtils; 6 | 7 | import java.util.Optional; 8 | 9 | import static java.util.Objects.requireNonNull; 10 | 11 | @ConstructorBinding 12 | @ConfigurationProperties(prefix = "covid19.publish-token.sms") 13 | public class SmsConfig { 14 | 15 | private static final int MAX_SMS_LENGTH = 500; 16 | private static final String SMS_CODE_PART = ""; 17 | private static final int TOKEN_LENGTH = 12; 18 | 19 | public final Optional gateway; 20 | public final String senderName; 21 | public final String content; 22 | public final String senderApiKey; 23 | 24 | public SmsConfig(Optional gateway, String senderName, String senderApiKey, String content) { 25 | this.gateway = requireNonNull(gateway).map(String::trim).filter(s -> !s.isEmpty()); 26 | this.senderName = requireNonNull(senderName); 27 | this.content = requireNonNull(content); 28 | this.senderApiKey = requireNonNull(senderApiKey); 29 | if (content.trim().isEmpty()) { 30 | throw new IllegalStateException("Invalid SMS config: empty content"); 31 | } 32 | if (calculateContentLengthAfterTokenReplace(content) > MAX_SMS_LENGTH) { 33 | throw new IllegalStateException("Invalid SMS config: content length " + calculateContentLengthAfterTokenReplace(content) + ">" + MAX_SMS_LENGTH); 34 | } 35 | if (!content.contains(SMS_CODE_PART)) { 36 | throw new IllegalStateException("Invalid SMS config: content doesn't contain " + SMS_CODE_PART + " segment for the token."); 37 | } 38 | } 39 | 40 | private int calculateContentLengthAfterTokenReplace(String content) { 41 | return (content.length() + StringUtils.countOccurrencesOf(content, SMS_CODE_PART) * (TOKEN_LENGTH - SMS_CODE_PART.length())); 42 | } 43 | 44 | public String formatContent(String token) { 45 | return content.replace(SMS_CODE_PART, token); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/Validation.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken; 2 | 3 | import fi.thl.covid19.publishtoken.error.InputValidationException; 4 | 5 | public final class Validation { 6 | private Validation() { 7 | } 8 | 9 | private static final String TOKEN_REGEX = "[0-9]{12}"; 10 | 11 | private static final String PHONE_NUMBER_REGEX = "(\\+[0-9]{3})?[0-9]{6,15}"; 12 | private static final String PHONE_NUMBER_FILLER_REGEX = "[- ()]"; 13 | 14 | public static final int USER_NAME_MAX_LENGTH = 50; 15 | public static final int SERVICE_NAME_MAX_LENGTH = 100; 16 | private static final String NAME_REGEX = "([A-Za-z0-9\\-_.]+)"; 17 | 18 | public static String validatePublishToken(String publishToken) { 19 | if (!publishToken.matches(TOKEN_REGEX)) { 20 | throw new InputValidationException("Invalid publish token."); 21 | } 22 | return publishToken; 23 | } 24 | 25 | public static String normalizeAndValidatePhoneNumber(String phoneNumber) { 26 | // Remove dashes, paranthesis and spaces. We don't care to validate these 27 | String normalized = phoneNumber.replaceAll(PHONE_NUMBER_FILLER_REGEX, ""); 28 | if (!normalized.matches(PHONE_NUMBER_REGEX)) { 29 | throw new InputValidationException("Invalid phone number"); 30 | } 31 | return normalized; 32 | } 33 | 34 | public static String validateUserName(String userName) { 35 | return validateNameString("user name", userName, USER_NAME_MAX_LENGTH); 36 | } 37 | 38 | public static String validateServiceName(String serviceName) { 39 | return validateNameString("service name", serviceName, SERVICE_NAME_MAX_LENGTH); 40 | } 41 | 42 | private static String validateNameString(String description, String nameString, int maxLength) { 43 | if (nameString.length() > maxLength) { 44 | throw new InputValidationException("Too long " + description + " { " + nameString + " }"); 45 | } 46 | if (!nameString.matches(NAME_REGEX)) { 47 | throw new InputValidationException("Invalid " + description + " { " + nameString + " } format"); 48 | } 49 | return nameString; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /exposure-notification/src/test/java/fi/thl/covid19/exposurenotification/diagnosiskey/TransmissionRiskBucketsTest.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.diagnosiskey; 2 | 3 | import org.junit.jupiter.api.Assertions; 4 | import org.junit.jupiter.api.Test; 5 | 6 | import java.time.LocalDate; 7 | 8 | import static java.time.temporal.ChronoUnit.DAYS; 9 | 10 | public class TransmissionRiskBucketsTest { 11 | 12 | @Test 13 | public void bucketsAreCalculatedCorrectly() { 14 | LocalDate now = LocalDate.now(); 15 | Assertions.assertEquals(0, TransmissionRiskBuckets.getRiskBucket(LocalDate.MIN, LocalDate.MAX)); 16 | Assertions.assertEquals(0, TransmissionRiskBuckets.getRiskBucket(now.minus(15, DAYS), now)); 17 | Assertions.assertEquals(1, TransmissionRiskBuckets.getRiskBucket(now.minus(14, DAYS), now)); 18 | Assertions.assertEquals(1, TransmissionRiskBuckets.getRiskBucket(now.minus(11, DAYS), now)); 19 | Assertions.assertEquals(2, TransmissionRiskBuckets.getRiskBucket(now.minus(10, DAYS), now)); 20 | Assertions.assertEquals(2, TransmissionRiskBuckets.getRiskBucket(now.minus(9, DAYS), now)); 21 | Assertions.assertEquals(3, TransmissionRiskBuckets.getRiskBucket(now.minus(8, DAYS), now)); 22 | Assertions.assertEquals(3, TransmissionRiskBuckets.getRiskBucket(now.minus(7, DAYS), now)); 23 | Assertions.assertEquals(4, TransmissionRiskBuckets.getRiskBucket(now.minus(6, DAYS), now)); 24 | Assertions.assertEquals(4, TransmissionRiskBuckets.getRiskBucket(now.minus(5, DAYS), now)); 25 | Assertions.assertEquals(5, TransmissionRiskBuckets.getRiskBucket(now.minus(4, DAYS), now)); 26 | Assertions.assertEquals(5, TransmissionRiskBuckets.getRiskBucket(now.minus(3, DAYS), now)); 27 | Assertions.assertEquals(6, TransmissionRiskBuckets.getRiskBucket(now.minus(2, DAYS), now)); 28 | Assertions.assertEquals(6, TransmissionRiskBuckets.getRiskBucket(now, now)); 29 | Assertions.assertEquals(6, TransmissionRiskBuckets.getRiskBucket(now.plus(2, DAYS), now)); 30 | Assertions.assertEquals(7, TransmissionRiskBuckets.getRiskBucket(now.plus(3, DAYS), now)); 31 | Assertions.assertEquals(7, TransmissionRiskBuckets.getRiskBucket(LocalDate.MAX, LocalDate.MIN)); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /publish-token/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: ${PT_SERVER_PORT:8080} 3 | error: 4 | whitelabel: 5 | enabled: false 6 | 7 | management: 8 | server: 9 | port: ${PT_MANAGEMENT_SERVER_PORT:9080} 10 | endpoints: 11 | web: 12 | exposure: 13 | include: health,prometheus 14 | metrics: 15 | tags: 16 | application: Publish-Token-API 17 | endpoint: 18 | health: 19 | group: 20 | metrics: 21 | include: '*' 22 | show-details: always 23 | readiness: 24 | include: '*' 25 | show-details: never 26 | liveness: 27 | include: ping 28 | show-details: never 29 | 30 | spring: 31 | application: 32 | name: publish-token 33 | mvc: 34 | throw-exception-if-no-handler-found: true 35 | resources: 36 | add-mappings: false 37 | datasource: 38 | type: "com.zaxxer.hikari.HikariDataSource" 39 | driver-class-name: "org.postgresql.Driver" 40 | url: "${PT_DATABASE_URL}" 41 | username: "${PT_DATABASE_USERNAME}" 42 | password: "${PT_DATABASE_PASSWORD}" 43 | hikari: 44 | auto-commit: true 45 | maximum-pool-size: 2 46 | connection-timeout: 20000 47 | leak-detection-threshold: 60000 48 | validation-timeout: 5000 49 | flyway: 50 | url: "${spring.datasource.url}" 51 | user: "${spring.datasource.username}" 52 | password: "${spring.datasource.password}" 53 | schemas: pt 54 | 55 | covid19: 56 | maintenance: 57 | # How often is maintenance-check done 58 | interval: PT1H 59 | # Expired tokens are kept in the DB for this duration. 60 | # The same random token cannot be re-generated before removing the previous instance. 61 | expired-token-lifetime: P14D 62 | publish-token: 63 | validity-duration: PT12H 64 | sms: 65 | gateway: "${PT_SMS_GATEWAY_URL:}" 66 | sender-name: "THL" 67 | sender-api-key: "" 68 | content: "Koronavilkku-avauskoodisi: . Koodi on voimassa 12 tuntia. Ilmoita nimettömästi tartunnastasi: https://koronavilkku.fi/i?\n\nDin startkod för Coronablinkern: . Koden är giltig i 12 timmar. Meddela om din smitta anonymt: https://koronavilkku.fi/i?\n\nYour Koronavilkku key code: . The code is valid for 12 hours. Report your infection anonymously: https://koronavilkku.fi/i?" 69 | db-schema-check: 70 | enabled: true 71 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/generation/v1/PublishTokenGenerationRequest.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken.generation.v1; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import fi.thl.covid19.publishtoken.Validation; 5 | import fi.thl.covid19.publishtoken.error.InputValidationException; 6 | import fi.thl.covid19.publishtoken.error.InputValidationValidateOnlyException; 7 | 8 | import java.time.LocalDate; 9 | import java.time.ZoneOffset; 10 | import java.time.temporal.ChronoUnit; 11 | import java.util.Optional; 12 | 13 | import static fi.thl.covid19.publishtoken.Validation.validateUserName; 14 | import static java.util.Objects.requireNonNull; 15 | 16 | public class PublishTokenGenerationRequest { 17 | public final String requestUser; 18 | public final LocalDate symptomsOnset; 19 | public final Optional patientSmsNumber; 20 | @Deprecated 21 | public final boolean validateOnly; 22 | public final Optional symptomsExist; 23 | 24 | @JsonCreator 25 | public PublishTokenGenerationRequest(String requestUser, 26 | LocalDate symptomsOnset, 27 | Optional patientSmsNumber, 28 | Optional validateOnly, 29 | Optional symptomsExist) { 30 | 31 | this.validateOnly = validateOnly.orElse(false); 32 | 33 | try { 34 | this.requestUser = validateUserName(requireNonNull(requestUser, "User required")); 35 | this.symptomsOnset = requireNonNull(symptomsOnset, "Symptoms onset date required"); 36 | this.patientSmsNumber = requireNonNull(patientSmsNumber).map(Validation::normalizeAndValidatePhoneNumber); 37 | this.symptomsExist = requireNonNull(symptomsExist); 38 | if (symptomsOnset.isAfter(LocalDate.now(ZoneOffset.UTC).plus(1, ChronoUnit.DAYS))) { 39 | throw new InputValidationException("Symptoms onset time in the future: date=" + symptomsOnset); 40 | } 41 | } catch (InputValidationException | NullPointerException e) { 42 | if (this.validateOnly) { 43 | throw new InputValidationValidateOnlyException("ValidateOnly: " + e.getMessage()); 44 | } else { 45 | throw e; 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /exposure-notification/src/test/java/fi/thl/covid19/exposurenotification/batch/BatchIdTest.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.batch; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import java.util.Optional; 6 | 7 | import static org.junit.jupiter.api.Assertions.*; 8 | 9 | public class BatchIdTest { 10 | 11 | @Test 12 | public void negativeBatchIdIsNotAllowed() { 13 | assertThrows(IllegalArgumentException.class, () -> new BatchId(-1)); 14 | } 15 | 16 | @Test 17 | public void negativeDemoIdIsNotAllowed() { 18 | assertThrows(IllegalArgumentException.class, () -> new BatchId(1, Optional.of(-1))); 19 | } 20 | 21 | @Test 22 | public void demoAndV2BatchIdStringFormatIsCorrect() { 23 | BatchId id = new BatchId(123, Optional.of(45)); 24 | String stringId = "123_45"; 25 | assertEquals(stringId, id.toString()); 26 | assertEquals(id, new BatchId(stringId)); 27 | } 28 | 29 | @Test 30 | public void batchIdStringFormatIsCorrect() { 31 | BatchId id = new BatchId(123); 32 | String stringId = "123"; 33 | assertEquals(stringId, id.toString()); 34 | assertEquals(id, new BatchId(stringId)); 35 | } 36 | 37 | @Test 38 | public void demoAndV2BatchIsRecognized() { 39 | assertTrue(new BatchId(23, Optional.of(67)).isDemoOrV2Batch()); 40 | assertTrue(new BatchId("345_21").isDemoOrV2Batch()); 41 | 42 | assertFalse(new BatchId(23).isDemoOrV2Batch()); 43 | assertFalse(new BatchId(23, Optional.empty()).isDemoOrV2Batch()); 44 | assertFalse(new BatchId("345").isDemoOrV2Batch()); 45 | } 46 | 47 | @Test 48 | public void batchOrderingIsCorrect() { 49 | assertEquals(-1, new BatchId(1).compareTo(new BatchId(2))); 50 | assertEquals(0, new BatchId(2).compareTo(new BatchId(2))); 51 | assertEquals(1, new BatchId(3).compareTo(new BatchId(2))); 52 | 53 | assertEquals(-1, new BatchId(1, Optional.of(1)).compareTo(new BatchId(1, Optional.of(2)))); 54 | assertEquals(0, new BatchId(1, Optional.of(2)).compareTo(new BatchId(1, Optional.of(2)))); 55 | assertEquals(1, new BatchId(1, Optional.of(3)).compareTo(new BatchId(1, Optional.of(2)))); 56 | 57 | assertEquals(1, new BatchId(1).compareTo(new BatchId(1, Optional.of(1)))); 58 | assertEquals(-1, new BatchId(1, Optional.of(1)).compareTo(new BatchId(1))); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/efgs/entity/InboundOperation.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.efgs.entity; 2 | 3 | import fi.thl.covid19.exposurenotification.efgs.util.CommonConst; 4 | 5 | import java.time.Instant; 6 | import java.time.LocalDate; 7 | import java.util.Optional; 8 | 9 | public class InboundOperation { 10 | public final long id; 11 | public final CommonConst.EfgsOperationState state; 12 | public final int keysCountTotal; 13 | public final int keysCountSuccess; 14 | public final int keysCountValidationFailed; 15 | public final int invalidSignatureCount; 16 | public final Optional batchTag; 17 | public final int retryCount; 18 | public final Instant updatedAt; 19 | public final LocalDate batchDate; 20 | 21 | public InboundOperation(long id, 22 | CommonConst.EfgsOperationState state, 23 | int keysCountTotal, 24 | int keysCountSuccess, 25 | int keysCountValidationFailed, 26 | int invalidSignatureCount, 27 | Optional batchTag, 28 | int retryCount, 29 | Instant updatedAt, 30 | LocalDate batchDate 31 | ) { 32 | this.id = id; 33 | this.state = state; 34 | this.keysCountTotal = keysCountTotal; 35 | this.keysCountSuccess = keysCountSuccess; 36 | this.keysCountValidationFailed = keysCountValidationFailed; 37 | this.invalidSignatureCount = invalidSignatureCount; 38 | this.batchTag = batchTag; 39 | this.retryCount = retryCount; 40 | this.updatedAt = updatedAt; 41 | this.batchDate = batchDate; 42 | } 43 | 44 | @Override 45 | public String toString() { 46 | return "InboundOperation{" + 47 | "id=" + id + 48 | ", state=" + state + 49 | ", keysCountTotal=" + keysCountTotal + 50 | ", keysCountSuccess=" + keysCountSuccess + 51 | ", keysCountValidationFailed=" + keysCountValidationFailed + 52 | ", invalidSignatureCount=" + invalidSignatureCount + 53 | ", batchTag='" + batchTag + '\'' + 54 | ", retryCount=" + retryCount + 55 | ", updatedAt=" + updatedAt + 56 | ", batchDate=" + batchDate + 57 | '}'; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/configuration/v1/ExposureConfigurationController.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.configuration.v1; 2 | 3 | import fi.thl.covid19.exposurenotification.configuration.ConfigurationService; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.http.CacheControl; 8 | import org.springframework.http.ResponseEntity; 9 | import org.springframework.web.bind.annotation.GetMapping; 10 | import org.springframework.web.bind.annotation.RequestMapping; 11 | import org.springframework.web.bind.annotation.RequestParam; 12 | import org.springframework.web.bind.annotation.RestController; 13 | 14 | import java.time.Duration; 15 | import java.util.Optional; 16 | 17 | import static java.util.Objects.requireNonNull; 18 | import static net.logstash.logback.argument.StructuredArguments.keyValue; 19 | 20 | @RestController 21 | @RequestMapping("/exposure/configuration/v1") 22 | public class ExposureConfigurationController { 23 | 24 | private static final Logger LOG = LoggerFactory.getLogger(ExposureConfigurationController.class); 25 | 26 | private final ConfigurationService configurationService; 27 | private final Duration cacheDuration; 28 | 29 | public ExposureConfigurationController(ConfigurationService configurationService, 30 | @Value("${covid19.diagnosis.response-cache.config-duration}") Duration cacheDuration) { 31 | this.configurationService = requireNonNull(configurationService); 32 | this.cacheDuration = requireNonNull(cacheDuration); 33 | } 34 | 35 | @GetMapping 36 | public ResponseEntity getConfiguration(@RequestParam("previous") Optional version) { 37 | LOG.info("Fetching exposure configuration: {}", keyValue("previous", version)); 38 | ExposureConfiguration latest = configurationService.getLatestExposureConfig(); 39 | return version.isEmpty() || latest.version > version.get() 40 | ? ResponseEntity.ok().cacheControl(cacheControl(version, latest.version)).body(latest) 41 | : ResponseEntity.noContent().cacheControl(CacheControl.noCache()).build(); 42 | } 43 | 44 | private CacheControl cacheControl(Optional previous, int current) { 45 | return previous.isEmpty() || previous.get() == current || previous.get() == current-1 46 | ? CacheControl.maxAge(cacheDuration).cachePublic() 47 | : CacheControl.noCache(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/configuration/v2/ExposureConfigurationV2Controller.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.configuration.v2; 2 | 3 | import fi.thl.covid19.exposurenotification.configuration.ConfigurationService; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.http.CacheControl; 8 | import org.springframework.http.ResponseEntity; 9 | import org.springframework.web.bind.annotation.GetMapping; 10 | import org.springframework.web.bind.annotation.RequestMapping; 11 | import org.springframework.web.bind.annotation.RequestParam; 12 | import org.springframework.web.bind.annotation.RestController; 13 | 14 | import java.time.Duration; 15 | import java.util.Optional; 16 | 17 | import static java.util.Objects.requireNonNull; 18 | import static net.logstash.logback.argument.StructuredArguments.keyValue; 19 | 20 | @RestController 21 | @RequestMapping("/exposure/configuration/v2") 22 | public class ExposureConfigurationV2Controller { 23 | 24 | private static final Logger LOG = LoggerFactory.getLogger(ExposureConfigurationV2Controller.class); 25 | 26 | private final ConfigurationService configurationService; 27 | private final Duration cacheDuration; 28 | 29 | public ExposureConfigurationV2Controller(ConfigurationService configurationService, 30 | @Value("${covid19.diagnosis.response-cache.config-duration}") Duration cacheDuration) { 31 | this.configurationService = requireNonNull(configurationService); 32 | this.cacheDuration = requireNonNull(cacheDuration); 33 | } 34 | 35 | @GetMapping 36 | public ResponseEntity getConfiguration(@RequestParam("previous") Optional version) { 37 | LOG.info("Fetching exposure configuration: {}", keyValue("previous", version)); 38 | ExposureConfigurationV2 latest = configurationService.getLatestV2ExposureConfig(); 39 | return version.isEmpty() || latest.version > version.get() 40 | ? ResponseEntity.ok().cacheControl(cacheControl(version, latest.version)).body(latest) 41 | : ResponseEntity.noContent().cacheControl(CacheControl.noCache()).build(); 42 | } 43 | 44 | private CacheControl cacheControl(Optional previous, int current) { 45 | return previous.isEmpty() || previous.get() == current || previous.get() == current-1 46 | ? CacheControl.maxAge(cacheDuration).cachePublic() 47 | : CacheControl.noCache(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/batch/BatchIntervals.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.batch; 2 | 3 | import java.time.Duration; 4 | import java.time.Instant; 5 | 6 | import static fi.thl.covid19.exposurenotification.diagnosiskey.IntervalNumber.to24HourInterval; 7 | import static fi.thl.covid19.exposurenotification.diagnosiskey.IntervalNumber.toV2Interval; 8 | 9 | public class BatchIntervals { 10 | 11 | // Slight delay for batch generation to ensure that we don't generate batch while it still has data incoming 12 | private static final Duration GENERATION_DELAY = Duration.ofMinutes(15); 13 | // Larger delay for distributing the batch files, so we know it's already generated before we give it out 14 | private static final Duration DISTRIBUTION_DELAY = Duration.ofHours(1); 15 | 16 | private static final int DAYS_TO_DISTRIBUTE_BATCHES = 14; 17 | public static final int DAYS_TO_KEEP_BATCHES = DAYS_TO_DISTRIBUTE_BATCHES + 1; 18 | public static final int DAILY_BATCHES_COUNT = 6; 19 | private static final int V2_INTERVALS_TO_DISTRIBUTE_BATCHES = DAYS_TO_DISTRIBUTE_BATCHES * DAILY_BATCHES_COUNT; 20 | public static final int V2_INTERVALS_TO_KEEP_BATCHES = DAYS_TO_KEEP_BATCHES * DAILY_BATCHES_COUNT; 21 | 22 | public final int current; 23 | public final int first; 24 | public final int last; 25 | 26 | public BatchIntervals(int current, int intervalsToKeep, boolean demoMode) { 27 | this.current = current; 28 | this.first = current - intervalsToKeep; 29 | this.last = demoMode ? current : current - 1; 30 | } 31 | 32 | public static BatchIntervals forExport(boolean demoMode) { 33 | Instant now = demoMode ? Instant.now() : Instant.now().minus(DISTRIBUTION_DELAY); 34 | return new BatchIntervals(to24HourInterval(now), DAYS_TO_DISTRIBUTE_BATCHES, demoMode); 35 | } 36 | 37 | public static BatchIntervals forExportV2(boolean demoMode) { 38 | Instant now = demoMode ? Instant.now() : Instant.now().minus(DISTRIBUTION_DELAY); 39 | return new BatchIntervals(toV2Interval(now), V2_INTERVALS_TO_DISTRIBUTE_BATCHES, demoMode); 40 | } 41 | 42 | public static BatchIntervals forGeneration() { 43 | return new BatchIntervals(to24HourInterval(Instant.now().minus(GENERATION_DELAY)), DAYS_TO_KEEP_BATCHES, false); 44 | } 45 | 46 | public static BatchIntervals forGenerationV2() { 47 | return new BatchIntervals(toV2Interval(Instant.now().minus(GENERATION_DELAY)), V2_INTERVALS_TO_KEEP_BATCHES, false); 48 | } 49 | 50 | public boolean isDistributed(int interval) { 51 | return interval >= first && interval <= last; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /exposure-notification/src/test/java/fi/thl/covid19/exposurenotification/batch/SigningTest.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.batch; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import java.security.GeneralSecurityException; 6 | import java.security.PrivateKey; 7 | import java.security.PublicKey; 8 | 9 | import static java.nio.charset.StandardCharsets.UTF_8; 10 | import static org.junit.jupiter.api.Assertions.assertFalse; 11 | import static org.junit.jupiter.api.Assertions.assertTrue; 12 | 13 | public class SigningTest { 14 | private static final String ALGORITHM = "SHA256withECDSA"; 15 | private static final String TEST_PRIVATE_BASE64 = 16 | "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgj3XOZ/EVd5rcmSycACJ52NUfDxiwpwI/waoIh57GstahRANCAAQOh/vrTDcEd71nXBfUx59rPeXjIbM2nkitgL2AnzJ/guswJqXnD64LRiiU2zajVI+QQxPRHESOXCsy9Z1S3VdR"; 17 | private static final String TEST_PUBLIC_BASE64 = 18 | "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEDof760w3BHe9Z1wX1Mefaz3l4yGzNp5IrYC9gJ8yf4LrMCal5w+uC0YolNs2o1SPkEMT0RxEjlwrMvWdUt1XUQ=="; 19 | 20 | @Test 21 | public void correctSignatureIsVerified() throws GeneralSecurityException { 22 | PrivateKey privateKey = Signing.privateKey(TEST_PRIVATE_BASE64); 23 | PublicKey publicKey = Signing.publicKey(TEST_PUBLIC_BASE64); 24 | byte[] payload = "Testing signature creation by signing this string.".getBytes(UTF_8); 25 | byte[] signature = Signing.sign(ALGORITHM, privateKey, payload); 26 | assertTrue(Signing.singatureMatches(ALGORITHM, publicKey, signature, payload)); 27 | } 28 | 29 | @Test 30 | public void signatureWithIncorrectDataIsNotVerified() throws GeneralSecurityException { 31 | PrivateKey privateKey = Signing.privateKey(TEST_PRIVATE_BASE64); 32 | PublicKey publicKey = Signing.publicKey(TEST_PUBLIC_BASE64); 33 | byte[] payload1 = "Testing signature creation by signing this string.".getBytes(UTF_8); 34 | byte[] payload2 = "Testing signature creation by signing some string.".getBytes(UTF_8); 35 | byte[] signature = Signing.sign(ALGORITHM, privateKey, payload1); 36 | assertFalse(Signing.singatureMatches(ALGORITHM, publicKey, signature, payload2)); 37 | } 38 | 39 | @Test 40 | public void signatureWithWrongKeyIsNotVerified() throws GeneralSecurityException { 41 | PrivateKey privateKey = Signing.privateKey(TEST_PRIVATE_BASE64); 42 | byte[] payload = "Testing signature creation by signing this string.".getBytes(UTF_8); 43 | byte[] signature = Signing.sign(ALGORITHM, privateKey, payload); 44 | assertFalse(Signing.singatureMatches(ALGORITHM, Signing.randomKeyPair().getPublic(), signature, payload)); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/efgs/entity/AuditEntry.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.efgs.entity; 2 | 3 | import java.time.ZonedDateTime; 4 | 5 | import static java.util.Objects.requireNonNull; 6 | 7 | public class AuditEntry { 8 | 9 | public final String country; 10 | public final ZonedDateTime uploadedTime; 11 | public final String uploaderThumbprint; 12 | public final String uploaderSigningThumbprint; 13 | public final String uploaderCertificate; 14 | public final long amount; 15 | public final String batchSignature; 16 | public final String uploaderOperatorSignature; 17 | public final String signingCertificateOperatorSignature; 18 | public final String signingCertificate; 19 | 20 | public AuditEntry(String country, ZonedDateTime uploadedTime, String uploaderThumbprint, 21 | String uploaderCertificate, String uploaderSigningThumbprint, 22 | long amount, String batchSignature, String uploaderOperatorSignature, 23 | String signingCertificateOperatorSignature, String signingCertificate) { 24 | this.country = requireNonNull(country); 25 | this.uploadedTime = requireNonNull(uploadedTime); 26 | this.uploaderThumbprint = requireNonNull(uploaderThumbprint); 27 | this.uploaderSigningThumbprint = requireNonNull(uploaderSigningThumbprint); 28 | this.uploaderCertificate = requireNonNull(uploaderCertificate); 29 | this.amount = amount; 30 | this.batchSignature = requireNonNull(batchSignature); 31 | this.uploaderOperatorSignature = requireNonNull(uploaderOperatorSignature); 32 | this.signingCertificateOperatorSignature = requireNonNull(signingCertificateOperatorSignature); 33 | this.signingCertificate = requireNonNull(signingCertificate); 34 | } 35 | 36 | @Override 37 | public String toString() { 38 | return "AuditEntry{" + 39 | "country='" + country + '\'' + 40 | ", uploadedTime=" + uploadedTime + 41 | ", uploaderThumbprint='" + uploaderThumbprint + '\'' + 42 | ", uploaderSigningThumbprint='" + uploaderSigningThumbprint + '\'' + 43 | ", uploaderCertificate='" + uploaderCertificate + '\'' + 44 | ", amount=" + amount + 45 | ", batchSignature='" + batchSignature + '\'' + 46 | ", uploaderOperatorSignature='" + uploaderOperatorSignature + '\'' + 47 | ", signingCertificateOperatorSignature='" + signingCertificateOperatorSignature + '\'' + 48 | ", signingCertificate='" + signingCertificate + '\'' + 49 | '}'; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /exposure-notification/src/main/proto/exposure-bin.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto2"; 2 | 3 | package exposure; 4 | option java_package = "fi.thl.covid19.proto"; 5 | option java_multiple_files = true; 6 | 7 | message TemporaryExposureKeyExport { 8 | // Time window of keys in this batch based on arrival to server, in UTC seconds. 9 | optional fixed64 start_timestamp = 1; 10 | optional fixed64 end_timestamp = 2; 11 | // Region for which these keys came from, such as country. 12 | optional string region = 3; 13 | // For example, file 2 in batch size of 10. Ordinal, 1-based numbering. 14 | // Note: Not yet supported on iOS. 15 | optional int32 batch_num = 4; 16 | optional int32 batch_size = 5; 17 | // Information about associated signatures 18 | repeated SignatureInfo signature_infos = 6; 19 | // The TemporaryExposureKeys themselves 20 | repeated TemporaryExposureKey keys = 7; 21 | } 22 | message SignatureInfo { 23 | // The first two fields have been deprecated 24 | reserved 1, 2; 25 | reserved "app_bundle_id", "android_package"; 26 | // Key version for rollovers 27 | // Must be in character class [a-zA-Z0-9_]. For example, 'v1' 28 | optional string verification_key_version = 3; 29 | // Alias with which to identify public key to be used for verification 30 | // Must be in character class [a-zA-Z0-9_.] 31 | // For example, the domain of your server: gov.health.foo 32 | optional string verification_key_id = 4; 33 | // ASN.1 OID for Algorithm Identifier. For example, `1.2.840.10045.4.3.2' 34 | optional string signature_algorithm = 5; 35 | } 36 | message TemporaryExposureKey { 37 | // Key of infected user 38 | optional bytes key_data = 1; 39 | // Varying risk associated with a key depending on diagnosis method 40 | optional int32 transmission_risk_level = 2 [deprecated = true]; 41 | // The interval number since epoch for which a key starts 42 | optional int32 rolling_start_interval_number = 3; 43 | // Increments of 10 minutes describing how long a key is valid 44 | optional int32 rolling_period = 4 45 | [default = 144]; // defaults to 24 hours 46 | // Data type representing why this key was published. 47 | enum ReportType { 48 | UNKNOWN = 0; // Never returned by the client API. 49 | CONFIRMED_TEST = 1; 50 | CONFIRMED_CLINICAL_DIAGNOSIS = 2; 51 | SELF_REPORT = 3; 52 | RECURSIVE = 4; // Reserved for future use. 53 | REVOKED = 5; // Used to revoke a key, never returned by client API. 54 | } 55 | // Type of diagnosis associated with a key. 56 | optional ReportType report_type = 5; 57 | // Number of days elapsed between symptom onset and the TEK being used. 58 | // E.g. 2 means TEK is 2 days after onset of symptoms. 59 | optional sint32 days_since_onset_of_symptoms = 6; 60 | } 61 | -------------------------------------------------------------------------------- /exposure-notification/src/test/java/fi/thl/covid19/exposurenotification/efgs/DsosMapperUtilTest.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.efgs; 2 | 3 | import fi.thl.covid19.exposurenotification.diagnosiskey.TemporaryExposureKey; 4 | import fi.thl.covid19.exposurenotification.diagnosiskey.TestKeyGenerator; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import java.time.Instant; 8 | import java.util.Optional; 9 | 10 | import static fi.thl.covid19.exposurenotification.diagnosiskey.IntervalNumber.to24HourInterval; 11 | import static fi.thl.covid19.exposurenotification.diagnosiskey.IntervalNumber.toV2Interval; 12 | import static fi.thl.covid19.exposurenotification.efgs.util.DsosMapperUtil.DsosInterpretationMapper.*; 13 | import static org.junit.jupiter.api.Assertions.assertEquals; 14 | 15 | public class DsosMapperUtilTest { 16 | 17 | private final TestKeyGenerator keyGenerator = new TestKeyGenerator(123); 18 | 19 | @Test 20 | public void mapToEfgsWorks() { 21 | Instant now = Instant.now(); 22 | int currentInterval = to24HourInterval(now); 23 | int currentIntervalV2 = toV2Interval(now); 24 | TemporaryExposureKey key1 = keyGenerator.someKey(1, 0x7FFFFFFF, true, 3, Optional.of(true), currentInterval, currentIntervalV2); 25 | TemporaryExposureKey key2 = keyGenerator.someKey(2, 0x7FFFFFFF, true, 3, Optional.of(false), currentInterval, currentIntervalV2); 26 | TemporaryExposureKey key3 = keyGenerator.someKey(3, 0x7FFFFFFF, true, 3, Optional.empty(), currentInterval, currentIntervalV2); 27 | TemporaryExposureKey key5 = keyGenerator.someKey(50, 0x7FFFFFFF, true, 3, Optional.empty(), currentInterval, currentIntervalV2); 28 | 29 | assertEquals(3, mapToEfgs(key1)); 30 | assertEquals(2998, mapToEfgs(key2)); 31 | assertEquals(3997, mapToEfgs(key3)); 32 | assertEquals(4000, mapToEfgs(key5)); 33 | } 34 | 35 | @Test 36 | public void symptomsExistWorks() { 37 | assertEquals(Optional.empty(), symptomsExist(3986)); 38 | assertEquals(Optional.of(true), symptomsExist(1)); 39 | assertEquals(Optional.empty(), symptomsExist(50)); 40 | assertEquals(Optional.of(true), symptomsExist(2001)); 41 | assertEquals(Optional.of(false), symptomsExist(2999)); 42 | assertEquals(Optional.of(true), symptomsExist(200)); 43 | } 44 | 45 | @Test 46 | public void mapFromWorks() { 47 | assertEquals(Optional.empty(), mapFrom(3986)); 48 | assertEquals(Optional.of(-5), mapFrom(-5)); 49 | assertEquals(Optional.empty(), mapFrom(50)); 50 | assertEquals(Optional.empty(), mapFrom(2001)); 51 | assertEquals(Optional.empty(), mapFrom(2999)); 52 | assertEquals(Optional.empty(), mapFrom(200)); 53 | assertEquals(Optional.empty(), mapFrom(50000)); 54 | assertEquals(Optional.empty(), mapFrom(-15)); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/efgs/signing/SigningUtil.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.efgs.signing; 2 | 3 | import fi.thl.covid19.proto.EfgsProto; 4 | import org.bouncycastle.cert.X509CertificateHolder; 5 | import org.bouncycastle.cms.CMSProcessableByteArray; 6 | import org.bouncycastle.cms.CMSSignedData; 7 | import org.bouncycastle.cms.CMSSignedDataGenerator; 8 | import org.bouncycastle.cms.SignerInfoGenerator; 9 | import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder; 10 | import org.bouncycastle.operator.ContentSigner; 11 | import org.bouncycastle.operator.DigestCalculatorProvider; 12 | import org.bouncycastle.operator.OperatorCreationException; 13 | import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; 14 | import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; 15 | 16 | import java.io.IOException; 17 | import java.security.PrivateKey; 18 | import java.security.cert.CertificateEncodingException; 19 | import java.security.cert.X509Certificate; 20 | import java.util.Base64; 21 | 22 | import static fi.thl.covid19.exposurenotification.efgs.util.SignatureHelperUtil.generateBytesForSignature; 23 | 24 | public class SigningUtil { 25 | 26 | private static final String DIGEST_ALGORITHM = "SHA256with"; 27 | 28 | public static String signBatch(EfgsProto.DiagnosisKeyBatch data, PrivateKey key, X509Certificate cert) 29 | throws Exception { 30 | CMSSignedDataGenerator signedDataGenerator = new CMSSignedDataGenerator(); 31 | signedDataGenerator.addSignerInfoGenerator(createSignerInfo(cert, key)); 32 | signedDataGenerator.addCertificate(createCertificateHolder(cert)); 33 | CMSSignedData singedData = signedDataGenerator.generate(new CMSProcessableByteArray(generateBytesForSignature(data.getKeysList())), false); 34 | return Base64.getEncoder().encodeToString(singedData.getEncoded()); 35 | } 36 | 37 | private static SignerInfoGenerator createSignerInfo(X509Certificate cert, PrivateKey key) throws OperatorCreationException, 38 | CertificateEncodingException { 39 | return new JcaSignerInfoGeneratorBuilder(createDigestBuilder()).build(createContentSigner(key), cert); 40 | } 41 | 42 | private static X509CertificateHolder createCertificateHolder(X509Certificate cert) throws CertificateEncodingException, 43 | IOException { 44 | return new X509CertificateHolder(cert.getEncoded()); 45 | } 46 | 47 | private static DigestCalculatorProvider createDigestBuilder() throws OperatorCreationException { 48 | return new JcaDigestCalculatorProviderBuilder().build(); 49 | } 50 | 51 | private static ContentSigner createContentSigner(PrivateKey privateKey) throws OperatorCreationException { 52 | return new JcaContentSignerBuilder(DIGEST_ALGORITHM + privateKey.getAlgorithm()).build(privateKey); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/diagnosiskey/v1/TemporaryExposureKeyRequest.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.diagnosiskey.v1; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | 5 | import java.util.Objects; 6 | 7 | import static fi.thl.covid19.exposurenotification.diagnosiskey.Validation.*; 8 | import static java.util.Objects.requireNonNull; 9 | 10 | /** 11 | * Posted key data, adjusted from the definition in 12 | * https://developers.google.com/android/exposure-notifications/verification-system#metadata 13 | */ 14 | public final class TemporaryExposureKeyRequest { 15 | /** 16 | * Key of infected user: the byte-array, Base64-encoded 17 | **/ 18 | public final String keyData; 19 | /** 20 | * Varying risk associated with a key depending on diagnosis method 21 | **/ 22 | public final int transmissionRiskLevel; 23 | /** 24 | * The interval number since epoch for which a key starts 25 | **/ 26 | public final int rollingStartIntervalNumber; 27 | /** 28 | * Increments of 10 minutes describing how long a key is valid 29 | **/ 30 | public final int rollingPeriod; 31 | 32 | @JsonCreator 33 | public TemporaryExposureKeyRequest(String keyData, 34 | int transmissionRiskLevel, 35 | int rollingStartIntervalNumber, 36 | int rollingPeriod) { 37 | this.keyData = validateKeyData(requireNonNull(keyData)); 38 | this.transmissionRiskLevel = validateTransmissionRiskLevel(transmissionRiskLevel); 39 | this.rollingStartIntervalNumber = validateRollingStartIntervalNumber(rollingStartIntervalNumber); 40 | this.rollingPeriod = validateRollingPeriod(rollingPeriod); 41 | } 42 | 43 | @Override 44 | public boolean equals(Object o) { 45 | if (this == o) return true; 46 | if (o == null || getClass() != o.getClass()) return false; 47 | TemporaryExposureKeyRequest that = (TemporaryExposureKeyRequest) o; 48 | return transmissionRiskLevel == that.transmissionRiskLevel && 49 | rollingStartIntervalNumber == that.rollingStartIntervalNumber && 50 | rollingPeriod == that.rollingPeriod && 51 | keyData.equals(that.keyData); 52 | } 53 | 54 | @Override 55 | public int hashCode() { 56 | return Objects.hash(keyData, transmissionRiskLevel, rollingStartIntervalNumber, rollingPeriod); 57 | } 58 | 59 | @Override 60 | public String toString() { 61 | return "TemporaryExposureKey{" + 62 | "keyData='" + keyData + '\'' + 63 | ", transmissionRiskLevel=" + transmissionRiskLevel + 64 | ", rollingStartIntervalNumber=" + rollingStartIntervalNumber + 65 | ", rollingPeriod=" + rollingPeriod + '}'; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/efgs/CallbackController.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.efgs; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import org.springframework.beans.factory.annotation.Value; 6 | import org.springframework.format.annotation.DateTimeFormat; 7 | import org.springframework.http.HttpStatus; 8 | import org.springframework.web.bind.annotation.GetMapping; 9 | import org.springframework.web.bind.annotation.RequestMapping; 10 | import org.springframework.web.bind.annotation.RequestParam; 11 | import org.springframework.web.bind.annotation.RestController; 12 | 13 | import javax.servlet.http.HttpServletResponse; 14 | import java.time.LocalDate; 15 | 16 | import static java.util.Objects.requireNonNull; 17 | import static net.logstash.logback.argument.StructuredArguments.keyValue; 18 | 19 | @RestController 20 | @RequestMapping("/efgs") 21 | public class CallbackController { 22 | 23 | private static final Logger LOG = LoggerFactory.getLogger(CallbackController.class); 24 | 25 | private final InboundService inboundService; 26 | private final boolean callbackEnabled; 27 | 28 | public CallbackController( 29 | InboundService inboundService, 30 | @Value("${covid19.federation-gateway.call-back.enabled}") boolean callbackEnabled 31 | ) { 32 | this.inboundService = requireNonNull(inboundService); 33 | this.callbackEnabled = callbackEnabled; 34 | } 35 | 36 | @GetMapping("/callback") 37 | public String triggerCallback(@RequestParam("batchTag") String batchTag, 38 | @RequestParam("date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, 39 | HttpServletResponse response 40 | ) { 41 | if (!validBatchTag(batchTag)) { 42 | LOG.error("Callback with invalid batchTag received {} {}.", keyValue("batchTag", batchTag), keyValue("date", date.toString())); 43 | response.setStatus(HttpStatus.BAD_REQUEST.value()); 44 | return "Batch tag is not valid."; 45 | } else if (callbackEnabled) { 46 | LOG.info("Import from efgs triggered by callback {} {}.", keyValue("batchTag", batchTag), keyValue("date", date.toString())); 47 | inboundService.startInboundAsync(date, batchTag); 48 | response.setStatus(HttpStatus.ACCEPTED.value()); 49 | return "Request added to queue."; 50 | } else { 51 | LOG.warn("Callback request received, but callback is inactive. {} {}.", keyValue("batchTag", batchTag), keyValue("date", date.toString())); 52 | response.setStatus(HttpStatus.SERVICE_UNAVAILABLE.value()); 53 | return "Service is currently disabled."; 54 | } 55 | } 56 | 57 | private boolean validBatchTag(String batchTag) { 58 | return !requireNonNull(batchTag).isEmpty() && batchTag.length() <= 100; 59 | } 60 | } 61 | 62 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/PublishTokenService.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken; 2 | 3 | import fi.thl.covid19.publishtoken.generation.v1.PublishToken; 4 | import fi.thl.covid19.publishtoken.verification.v1.PublishTokenVerification; 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | import org.springframework.beans.factory.annotation.Value; 8 | import org.springframework.stereotype.Service; 9 | 10 | import java.security.SecureRandom; 11 | import java.time.Duration; 12 | import java.time.Instant; 13 | import java.time.LocalDate; 14 | import java.util.List; 15 | import java.util.Optional; 16 | 17 | import static java.time.temporal.ChronoUnit.SECONDS; 18 | import static java.util.Objects.requireNonNull; 19 | import static net.logstash.logback.argument.StructuredArguments.keyValue; 20 | 21 | @Service 22 | public class PublishTokenService { 23 | 24 | private static final Logger LOG = LoggerFactory.getLogger(PublishTokenService.class); 25 | 26 | private static final int HALF_TOKEN_RANGE = 1000000; 27 | 28 | private final SecureRandom random = new SecureRandom(); 29 | 30 | private final PublishTokenDao dao; 31 | private final Duration tokenValidityDuration; 32 | 33 | public PublishTokenService( 34 | PublishTokenDao dao, 35 | @Value("${covid19.publish-token.validity-duration}") Duration tokenValidityDuration) { 36 | this.dao = requireNonNull(dao); 37 | this.tokenValidityDuration = requireNonNull(tokenValidityDuration); 38 | LOG.info("Initialized: {}", keyValue("tokenValidityDuration", tokenValidityDuration)); 39 | } 40 | 41 | public PublishToken generateAndStore(LocalDate symptomsOnset, String requestService, String requestUser, Optional symptomsExist) { 42 | int retryCount = 0; 43 | PublishToken token = generate(); 44 | while (!dao.storeToken(token, symptomsOnset, requestService, requestUser, symptomsExist)) { 45 | if (retryCount++ >= 5) throw new IllegalStateException("Unable to generate unique token!"); 46 | token = generate(); 47 | } 48 | return token; 49 | } 50 | 51 | public void invalidateToken(PublishToken token) { 52 | dao.invalidateToken(token); 53 | } 54 | 55 | public PublishToken generate() { 56 | Instant now = Instant.now().truncatedTo(SECONDS); 57 | // Java Random does not generate longs at a desired range, so we generate 12-number token in 2 parts 58 | String token = String.format("%06d%06d", random.nextInt(HALF_TOKEN_RANGE), random.nextInt(HALF_TOKEN_RANGE)); 59 | return new PublishToken(token, now, now.plus(tokenValidityDuration)); 60 | } 61 | 62 | public List getTokensBy(String originService, String originUser) { 63 | return dao.getTokens(originService, originUser); 64 | } 65 | 66 | public Optional getVerification(String token) { 67 | return dao.getVerification(token); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: 11 | - '*' 12 | pull_request: 13 | branches: 14 | - '*' 15 | 16 | jobs: 17 | analyze: 18 | name: Analyze 19 | runs-on: ubuntu-latest 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | # Override automatic language detection by changing the below list 25 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 26 | language: ['java'] 27 | # Learn more... 28 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 29 | 30 | steps: 31 | - name: Setup Java JDK 32 | uses: actions/setup-java@v1 33 | with: 34 | java-version: 11 35 | - name: Checkout repository 36 | uses: actions/checkout@v2 37 | with: 38 | # We must fetch at least the immediate parents so that if this is 39 | # a pull request then we can checkout the head. 40 | fetch-depth: 2 41 | 42 | # If this run was triggered by a pull request event, then checkout 43 | # the head of the pull request instead of the merge commit. 44 | - run: git checkout HEAD^2 45 | if: ${{ github.event_name == 'pull_request' }} 46 | 47 | # Initializes the CodeQL tools for scanning. 48 | - name: Initialize CodeQL 49 | uses: github/codeql-action/init@v1 50 | with: 51 | languages: ${{ matrix.language }} 52 | # If you wish to specify custom queries, you can do so here or in a config file. 53 | # By default, queries listed here will override any specified in a config file. 54 | # Prefix the list here with "+" to use these queries and those in the config file. 55 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 56 | 57 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 58 | # If this step fails, then you should remove it and run the build manually (see below) 59 | - name: Autobuild 60 | uses: github/codeql-action/autobuild@v1 61 | 62 | # ℹ️ Command-line programs to run using the OS shell. 63 | # 📚 https://git.io/JvXDl 64 | 65 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 66 | # and modify them (or add more) to build your code if your project 67 | # uses a compiled language 68 | 69 | #- run: | 70 | # make bootstrap 71 | # make release 72 | 73 | - name: Perform CodeQL Analysis 74 | uses: github/codeql-action/analyze@v1 75 | - uses: actions/upload-artifact@v2 76 | with: 77 | name: codeql-scan-result 78 | path: /home/runner/work/koronavilkku-backend/results/java-builtin.sarif 79 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/batch/Signing.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.batch; 2 | 3 | import java.security.*; 4 | import java.security.spec.ECGenParameterSpec; 5 | import java.security.spec.InvalidKeySpecException; 6 | import java.security.spec.PKCS8EncodedKeySpec; 7 | import java.security.spec.X509EncodedKeySpec; 8 | import java.util.Base64; 9 | 10 | public final class Signing { 11 | private Signing() {} 12 | 13 | private static final String KEY_ALGORITHM = "EC"; 14 | 15 | public static PrivateKey privateKey(String privateKeyBase64) { 16 | return privateKey(Base64.getDecoder().decode(privateKeyBase64)); 17 | } 18 | 19 | public static PrivateKey privateKey(byte[] privateKeyBytes) { 20 | try { 21 | KeyFactory kf = KeyFactory.getInstance(KEY_ALGORITHM); 22 | return kf.generatePrivate(new PKCS8EncodedKeySpec(privateKeyBytes)); 23 | } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { 24 | throw new IllegalStateException("Could not generate private key", e); 25 | } 26 | } 27 | 28 | public static PublicKey publicKey(String publicKeyBase64) { 29 | return publicKey(Base64.getDecoder().decode(publicKeyBase64)); 30 | } 31 | 32 | public static PublicKey publicKey(byte[] publicKeyBytes) { 33 | try { 34 | KeyFactory kf = KeyFactory.getInstance(KEY_ALGORITHM); 35 | return kf.generatePublic(new X509EncodedKeySpec(publicKeyBytes)); 36 | } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { 37 | throw new IllegalStateException("Could not generate public key", e); 38 | } 39 | } 40 | 41 | public static KeyPair randomKeyPair() { 42 | try { 43 | KeyPairGenerator g = KeyPairGenerator.getInstance(KEY_ALGORITHM); 44 | ECGenParameterSpec ecSpec = new ECGenParameterSpec("secp256k1"); 45 | g.initialize(ecSpec, new SecureRandom()); 46 | return g.generateKeyPair(); 47 | } catch (InvalidAlgorithmParameterException | NoSuchAlgorithmException e) { 48 | throw new IllegalStateException("Could not generate random keypair", e); 49 | } 50 | } 51 | 52 | public static byte[] sign(String algorithmName, PrivateKey key, byte[] payload) 53 | throws NoSuchAlgorithmException, InvalidKeyException, SignatureException { 54 | java.security.Signature ecdsaSign = java.security.Signature.getInstance(algorithmName); 55 | ecdsaSign.initSign(key); 56 | ecdsaSign.update(payload); 57 | return ecdsaSign.sign(); 58 | } 59 | 60 | public static boolean singatureMatches(String algorithmName, PublicKey key, byte[] signature, byte[] payload) 61 | throws NoSuchAlgorithmException, InvalidKeyException, SignatureException { 62 | java.security.Signature ecdsaVerify = java.security.Signature.getInstance(algorithmName); 63 | ecdsaVerify.initVerify(key); 64 | ecdsaVerify.update(payload); 65 | return ecdsaVerify.verify(signature); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /owasp_suppressions.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | ^pkg:maven/org\.springframework/spring\-core@.*$ 8 | CVE-2016-1000027 9 | 10 | 11 | 14 | ^pkg:maven/org\.springframework/spring\-tx@.*$ 15 | CVE-2016-1000027 16 | 17 | 18 | 21 | ^pkg:maven/org\.springframework/spring\-aop@.*$ 22 | CVE-2016-1000027 23 | 24 | 25 | 28 | ^pkg:maven/org\.springframework/spring\-jcl@.*$ 29 | CVE-2016-1000027 30 | 31 | 32 | 35 | ^pkg:maven/org\.springframework/spring\-web@.*$ 36 | CVE-2016-1000027 37 | 38 | 39 | 42 | ^pkg:maven/org\.springframework/spring\-jdbc@.*$ 43 | CVE-2016-1000027 44 | 45 | 46 | 49 | ^pkg:maven/org\.springframework/spring\-beans@.*$ 50 | CVE-2016-1000027 51 | 52 | 53 | 56 | ^pkg:maven/org\.springframework/spring\-webmvc@.*$ 57 | CVE-2016-1000027 58 | 59 | 60 | 63 | ^pkg:maven/org\.springframework/spring\-context@.*$ 64 | CVE-2016-1000027 65 | 66 | 67 | 70 | ^pkg:maven/org\.springframework/spring\-expression@.*$ 71 | CVE-2016-1000027 72 | 73 | 74 | 77 | ^pkg:maven/org\.springframework/spring\-context\-support@.*$ 78 | CVE-2016-1000027 79 | 80 | 81 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/diagnosiskey/IntervalNumber.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.diagnosiskey; 2 | 3 | import java.time.Instant; 4 | import java.time.LocalDate; 5 | import java.time.ZoneOffset; 6 | 7 | import static fi.thl.covid19.exposurenotification.batch.BatchIntervals.DAILY_BATCHES_COUNT; 8 | 9 | public final class IntervalNumber { 10 | 11 | private IntervalNumber() { 12 | } 13 | 14 | public static final int SECONDS_PER_24H = 60 * 60 * 24; 15 | public static final int SECONDS_PER_V2_INTERVAL = 60 * 60 * (24 / DAILY_BATCHES_COUNT); 16 | public static final int SECONDS_PER_10MIN = 60 * 10; 17 | public static final int INTERVALS_10MIN_PER_24H = 6 * 24; 18 | 19 | public static int to24HourInterval(Instant time) { 20 | long value = time.getEpochSecond() / SECONDS_PER_24H; 21 | if (value > Integer.MAX_VALUE) { 22 | throw new IllegalStateException("Cannot represent time as 24h interval: " + time); 23 | } 24 | return (int) value; 25 | } 26 | 27 | public static int toV2Interval(Instant time) { 28 | long value = time.getEpochSecond() / SECONDS_PER_V2_INTERVAL; 29 | if (value > Integer.MAX_VALUE) { 30 | throw new IllegalStateException("Cannot represent time as 24h interval: " + time); 31 | } 32 | return (int) value; 33 | } 34 | 35 | public static int to10MinInterval(Instant time) { 36 | long value = time.getEpochSecond() / SECONDS_PER_10MIN; 37 | if (value > Integer.MAX_VALUE) { 38 | throw new IllegalStateException("Cannot represent time as 10min interval: " + time); 39 | } 40 | return (int) value; 41 | } 42 | 43 | public static int dayFirst10MinInterval(Instant time) { 44 | int dayNumber = to24HourInterval(time); 45 | return dayNumber * INTERVALS_10MIN_PER_24H; 46 | } 47 | 48 | public static int dayLast10MinInterval(Instant time) { 49 | int nextDayNumber = to24HourInterval(time) + 1; 50 | return nextDayNumber * INTERVALS_10MIN_PER_24H - 1; 51 | } 52 | 53 | public static long startSecondOf24HourInterval(int interval24h) { 54 | return (long) interval24h * SECONDS_PER_24H; 55 | } 56 | 57 | public static LocalDate utcDateOf24HourInterval(int interval24h) { 58 | return Instant.ofEpochSecond(startSecondOf24HourInterval(interval24h)).atOffset(ZoneOffset.UTC).toLocalDate(); 59 | } 60 | 61 | public static long startSecondOfV2Interval(int intervalV2) { 62 | return (long) intervalV2 * SECONDS_PER_V2_INTERVAL; 63 | } 64 | 65 | public static LocalDate utcDateOf10MinInterval(int interval) { 66 | Instant startMoment = Instant.ofEpochSecond((long) interval * SECONDS_PER_10MIN); 67 | return startMoment.atOffset(ZoneOffset.UTC).toLocalDate(); 68 | } 69 | 70 | public static int from24hourToV2Interval(int interval24hour) { 71 | return interval24hour * DAILY_BATCHES_COUNT; 72 | } 73 | 74 | public static int fromV2to24hourInterval(int intervalV2) { 75 | return intervalV2 / DAILY_BATCHES_COUNT; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /exposure-notification/src/test/java/fi/thl/covid19/exposurenotification/batch/BatchFileServiceIT.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.batch; 2 | 3 | import fi.thl.covid19.exposurenotification.diagnosiskey.DiagnosisKeyDao; 4 | import fi.thl.covid19.exposurenotification.diagnosiskey.TestKeyGenerator; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Test; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 9 | import org.springframework.boot.test.context.SpringBootTest; 10 | import org.springframework.test.context.ActiveProfiles; 11 | 12 | import java.time.Instant; 13 | import java.util.Optional; 14 | 15 | import static fi.thl.covid19.exposurenotification.diagnosiskey.IntervalNumber.from24hourToV2Interval; 16 | import static fi.thl.covid19.exposurenotification.diagnosiskey.IntervalNumber.fromV2to24hourInterval; 17 | import static java.time.temporal.ChronoUnit.HOURS; 18 | import static org.junit.jupiter.api.Assertions.assertFalse; 19 | import static org.junit.jupiter.api.Assertions.assertTrue; 20 | 21 | /** 22 | * NOTE: These tests require the DB to be available and configured through ENV. 23 | */ 24 | @SpringBootTest 25 | @ActiveProfiles({"dev", "test"}) 26 | @AutoConfigureMockMvc 27 | public class BatchFileServiceIT { 28 | 29 | private static final BatchIntervals INTERVALS = BatchIntervals.forExport(false); 30 | private static final BatchIntervals INTERVALS_V2 = BatchIntervals.forExportV2(false); 31 | 32 | @Autowired 33 | private BatchFileService fileService; 34 | 35 | @Autowired 36 | private BatchFileStorage fileStorage; 37 | 38 | @Autowired 39 | private DiagnosisKeyDao dao; 40 | 41 | private TestKeyGenerator keyGenerator; 42 | 43 | @BeforeEach 44 | public void setUp() { 45 | keyGenerator = new TestKeyGenerator(123); 46 | dao.deleteKeysBefore(Integer.MAX_VALUE); 47 | dao.deleteVerificationsBefore(Instant.now().plus(24, HOURS)); 48 | } 49 | 50 | @Test 51 | public void generateBatchesWorks() { 52 | for (int next = INTERVALS.first; next <= INTERVALS.last; next++) { 53 | dao.addKeys(next, "TEST" + next, next, from24hourToV2Interval(next), keyGenerator.someKeys(5, next, from24hourToV2Interval(next)), 5); 54 | assertFalse(fileStorage.fileExists(new BatchId(next))); 55 | fileService.cacheMissingBatchesBetween(INTERVALS.first, INTERVALS.last); 56 | assertTrue(fileStorage.fileExists(new BatchId(next))); 57 | } 58 | } 59 | 60 | @Test 61 | public void generateBatchesWorksV2() { 62 | for (int next = INTERVALS_V2.first; next <= INTERVALS_V2.last; next++) { 63 | dao.addKeys(next, "TEST" + next, fromV2to24hourInterval(next), next, keyGenerator.someKeys(5, fromV2to24hourInterval(next), next), 5); 64 | assertFalse(fileStorage.fileExists(new BatchId(fromV2to24hourInterval(next), Optional.of(next)))); 65 | fileService.cacheMissingBatchesBetweenV2(INTERVALS_V2.first, INTERVALS_V2.last); 66 | assertTrue(fileStorage.fileExists(new BatchId(fromV2to24hourInterval(next), Optional.of(next)))); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: ${EN_SERVER_PORT:8080} 3 | error: 4 | whitelabel: 5 | enabled: false 6 | 7 | management: 8 | server: 9 | port: ${EN_MANAGEMENT_SERVER_PORT:9080} 10 | endpoints: 11 | web: 12 | exposure: 13 | include: health,prometheus 14 | metrics: 15 | tags: 16 | application: Exposure-Notification-API 17 | endpoint: 18 | health: 19 | group: 20 | metrics: 21 | include: '*' 22 | show-details: always 23 | readiness: 24 | include: '*' 25 | show-details: never 26 | liveness: 27 | include: ping 28 | show-details: never 29 | 30 | spring: 31 | application: 32 | name: exposure-notification 33 | mvc: 34 | throw-exception-if-no-handler-found: true 35 | resources: 36 | add-mappings: false 37 | datasource: 38 | type: com.zaxxer.hikari.HikariDataSource 39 | driver-class-name: org.postgresql.Driver 40 | url: "${EN_DATABASE_URL}" 41 | username: "${EN_DATABASE_USERNAME}" 42 | password: "${EN_DATABASE_PASSWORD}" 43 | hikari: 44 | auto-commit: true 45 | maximum-pool-size: 10 46 | connection-timeout: 60000 47 | leak-detection-threshold: 60000 48 | validation-timeout: 5000 49 | servlet: 50 | multipart: 51 | max-file-size: 10MB 52 | max-request-size: 10MB 53 | flyway: 54 | url: "${spring.datasource.url}" 55 | user: "${spring.datasource.username}" 56 | password: "${spring.datasource.password}" 57 | schemas: en 58 | 59 | covid19: 60 | demo-mode: "${EN_DEMO_MODE:false}" 61 | region: FI 62 | diagnosis: 63 | # Use to set a fixed batch-file directory that lives beyond the application 64 | file-storage: 65 | directory: "${EN_FILES:}" 66 | signature: 67 | key-version: "${EN_SIGNING_VERSION:v1}" 68 | key-id: "244" 69 | algorithm-oid: 1.2.840.10045.4.3.2 70 | algorithm-name: SHA256withECDSA 71 | response-cache: 72 | config-duration: PT1H 73 | status-duration: PT15M 74 | batch-duration: PT12H 75 | data-cache: 76 | enabled: true 77 | status-duration: PT5M 78 | file-duration: PT10S 79 | maintenance: 80 | # How often is maintenance-check done 81 | interval: PT15M 82 | # How long to keep the token verification row. 83 | # Must be longer than maximum token lifetime to prevent reusing a single token. 84 | token-verification-lifetime: P14D 85 | publish-token: 86 | url: "${EN_PT_URL:}" 87 | federation-gateway: 88 | enabled: "${EN_EFGS_SYNC_ENABLED:false}" 89 | scheduled-inbound-enabled: "${EN_EFGS_SCHEDULED_INBOUND_ENABLED:false}" 90 | rest-client: 91 | client-key-store: 92 | alias: efgs 93 | signing-key-store: 94 | key-alias: efgs-signing 95 | trust-anchor-alias: efgs-trust-anchor 96 | base-url: "${EN_EFGS_URL:}" 97 | upload-interval: "${EN_EFGS_UPLOAD_INTERVAL:PT4H}" 98 | download-interval: PT10M 99 | error-handling-interval: PT30M 100 | call-back: 101 | enabled: "${EN_EFGS_CALLBACK_ENABLED:false}" 102 | local-url: "${EN_EFGS_CALLBACK_URL:}" 103 | db-schema-check: 104 | enabled: true 105 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/efgs/util/DummyKeyGeneratorUtil.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.efgs.util; 2 | 3 | import fi.thl.covid19.exposurenotification.diagnosiskey.TemporaryExposureKey; 4 | 5 | import java.security.SecureRandom; 6 | import java.time.Instant; 7 | import java.time.LocalDate; 8 | import java.time.ZoneOffset; 9 | import java.util.*; 10 | import java.util.stream.Collectors; 11 | import java.util.stream.Stream; 12 | 13 | import static fi.thl.covid19.exposurenotification.diagnosiskey.DiagnosisKeyService.DEFAULT_ORIGIN_COUNTRY; 14 | import static fi.thl.covid19.exposurenotification.diagnosiskey.IntervalNumber.*; 15 | import static fi.thl.covid19.exposurenotification.diagnosiskey.TransmissionRiskBuckets.getRiskBucket; 16 | import static fi.thl.covid19.exposurenotification.efgs.util.DsosMapperUtil.DsosInterpretationMapper.calculateDsos; 17 | import static java.time.temporal.ChronoUnit.DAYS; 18 | import static java.util.Comparator.comparing; 19 | 20 | public class DummyKeyGeneratorUtil { 21 | 22 | public static final int BATCH_MIN_SIZE = 200; 23 | private static final int MIN_DAYS = 2; 24 | private static final int MAX_DAYS = 10; 25 | 26 | private static final SecureRandom SECURE_RANDOM = new SecureRandom(); 27 | 28 | public static List concatDummyKeys(List actualKeys, List dummyKeys) { 29 | return Stream.concat( 30 | actualKeys.stream(), 31 | dummyKeys.stream() 32 | ).sorted(comparing(TemporaryExposureKey::getKeyData)).collect(Collectors.toList()); 33 | } 34 | 35 | public static List generateDummyKeys(int totalCount, boolean consentToShare, int intervalV2, Instant now) { 36 | List dummyKeys = new ArrayList<>(); 37 | while (dummyKeys.size() < totalCount) { 38 | LocalDate symptomsOnset = now.atOffset(ZoneOffset.UTC).toLocalDate().minusDays(SECURE_RANDOM.nextInt(MAX_DAYS - MIN_DAYS + 1) + MIN_DAYS); 39 | for (int dummySetCount = 0; dummySetCount < 14; dummySetCount++) { 40 | dummyKeys.add(generateDummyKey(dummySetCount, symptomsOnset, now, consentToShare, intervalV2)); 41 | } 42 | } 43 | 44 | return dummyKeys; 45 | } 46 | 47 | private static TemporaryExposureKey generateDummyKey(int rollingStartIntervalOffset, LocalDate symptomsOnset, Instant now, boolean consentToShare, int intervalV2) { 48 | byte[] keyData = new byte[16]; 49 | SECURE_RANDOM.nextBytes(keyData); 50 | 51 | int rollingStartInterval = dayFirst10MinInterval(now.minus(rollingStartIntervalOffset, DAYS)); 52 | 53 | return new TemporaryExposureKey( 54 | Base64.getEncoder().encodeToString(keyData), 55 | getRiskBucket(symptomsOnset, utcDateOf10MinInterval(rollingStartInterval)), 56 | rollingStartInterval, 57 | 144, 58 | Set.of(), 59 | calculateDsos(symptomsOnset, rollingStartInterval), 60 | DEFAULT_ORIGIN_COUNTRY, 61 | consentToShare, 62 | Optional.empty(), 63 | fromV2to24hourInterval(intervalV2), 64 | intervalV2 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/tokenverification/PublishTokenVerificationServiceRest.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.tokenverification; 2 | 3 | import fi.thl.covid19.exposurenotification.error.TokenValidationException; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.springframework.beans.factory.annotation.Qualifier; 7 | import org.springframework.beans.factory.annotation.Value; 8 | import org.springframework.http.*; 9 | import org.springframework.stereotype.Service; 10 | import org.springframework.web.client.HttpClientErrorException; 11 | import org.springframework.web.client.HttpServerErrorException; 12 | import org.springframework.web.client.RestClientException; 13 | import org.springframework.web.client.RestTemplate; 14 | 15 | import java.util.List; 16 | 17 | import static java.util.Objects.requireNonNull; 18 | import static net.logstash.logback.argument.StructuredArguments.keyValue; 19 | 20 | @Service 21 | public class PublishTokenVerificationServiceRest implements PublishTokenVerificationService { 22 | 23 | private static final Logger LOG = LoggerFactory.getLogger(PublishTokenVerificationServiceRest.class); 24 | 25 | public static final String TOKEN_VERIFICATION_PATH = "/verification/v1"; 26 | public static final String PUBLISH_TOKEN_HEADER = "KV-Publish-Token"; 27 | 28 | private final RestTemplate restTemplate; 29 | private final String publishTokenUrl; 30 | 31 | public PublishTokenVerificationServiceRest( 32 | @Qualifier("default") RestTemplate restTemplate, 33 | @Value("${covid19.publish-token.url}") String publishTokenUrl) { 34 | this.restTemplate = requireNonNull(restTemplate, "RestTemplate required"); 35 | this.publishTokenUrl = requireNonNull(publishTokenUrl, "Publish Token URL required"); 36 | } 37 | 38 | @Override 39 | public PublishTokenVerification getVerification(String token) { 40 | String url = publishTokenUrl + TOKEN_VERIFICATION_PATH; 41 | try { 42 | HttpHeaders headers = new HttpHeaders(); 43 | headers.setAccept(List.of(MediaType.APPLICATION_JSON)); 44 | headers.add(PUBLISH_TOKEN_HEADER, token); 45 | HttpEntity entity = new HttpEntity<>(null, headers); 46 | ResponseEntity reply = restTemplate.exchange(url, HttpMethod.GET, entity, PublishTokenVerification.class); 47 | if (reply.getStatusCode().is2xxSuccessful() && reply.hasBody()) { 48 | return reply.getBody(); 49 | } else { 50 | LOG.warn("Server didn't verify publish token: {}", keyValue("status", reply.getStatusCode())); 51 | throw new TokenValidationException(); 52 | } 53 | } catch (HttpClientErrorException e) { 54 | LOG.warn("Publish token not verified: {}", keyValue("result", e.getStatusCode()), e); 55 | throw new TokenValidationException(); 56 | } catch (HttpServerErrorException e) { 57 | LOG.error("Error in token service: {}", keyValue("url", url), e); 58 | } catch (RestClientException e) { 59 | LOG.error("Error verifying token request: {}", keyValue("url", url), e); 60 | } 61 | throw new IllegalStateException("Could not handle token verification"); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/batch/BatchId.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.batch; 2 | 3 | import java.util.Objects; 4 | import java.util.Optional; 5 | 6 | import static java.util.Objects.requireNonNull; 7 | 8 | public class BatchId implements Comparable { 9 | 10 | private static final String SEPARATOR = "_"; 11 | public static final BatchId DEFAULT = new BatchId(0); 12 | 13 | public final int intervalNumber; 14 | public final Optional intervalNumberV2; 15 | 16 | public BatchId(int intervalNumber) { 17 | this(intervalNumber, Optional.empty()); 18 | } 19 | public BatchId(int intervalNumber, Optional intervalNumberV2) { 20 | if (intervalNumber < 0 || (intervalNumberV2.isPresent() && intervalNumberV2.get() < 0)) { 21 | String formatted = intervalNumber + intervalNumberV2.map(n -> "_" + n).orElse(""); 22 | throw new IllegalArgumentException("Batch ID out of range: " + formatted); 23 | } 24 | this.intervalNumber = intervalNumber; 25 | this.intervalNumberV2 = requireNonNull(intervalNumberV2); 26 | } 27 | 28 | public BatchId(String idString) { 29 | String cleaned = requireNonNull(idString, "The batch ID cannot be null.").trim(); 30 | if (cleaned.contains(SEPARATOR) && cleaned.length() <= 30) { 31 | String[] pieces = cleaned.split(SEPARATOR); 32 | if (pieces.length != 2) throw new IllegalArgumentException("Invalid Demo or V2 Batch ID: parts=" + pieces.length); 33 | this.intervalNumber = Integer.parseInt(pieces[0]); 34 | this.intervalNumberV2 = Optional.of(Integer.parseInt(pieces[1])); 35 | } else if (cleaned.length() > 0 && cleaned.length() <= 20) { 36 | this.intervalNumber = Integer.parseInt(cleaned); 37 | this.intervalNumberV2 = Optional.empty(); 38 | } else { 39 | throw new IllegalArgumentException("Invalid Batch ID: length=" + cleaned.length()); 40 | } 41 | } 42 | 43 | public boolean isDemoOrV2Batch() { 44 | return intervalNumberV2.isPresent(); 45 | } 46 | 47 | public boolean isBefore(BatchId other) { 48 | return compareTo(other) < 0; 49 | } 50 | 51 | public boolean isAfter(BatchId other) { 52 | return compareTo(other) > 0; 53 | } 54 | 55 | @Override 56 | public String toString() { 57 | return this.intervalNumber + intervalNumberV2.map(n -> SEPARATOR + n).orElse(""); 58 | } 59 | 60 | @Override 61 | public int compareTo(BatchId o) { 62 | int main = Long.compare(intervalNumber, o.intervalNumber); 63 | if (main != 0) { 64 | return main; 65 | } else { 66 | return intervalNumberV2.orElse(Integer.MAX_VALUE).compareTo(o.intervalNumberV2.orElse(Integer.MAX_VALUE)); 67 | } 68 | } 69 | 70 | @Override 71 | public boolean equals(Object o) { 72 | if (this == o) return true; 73 | if (o == null || getClass() != o.getClass()) return false; 74 | BatchId batchId = (BatchId) o; 75 | return intervalNumber == batchId.intervalNumber && 76 | intervalNumberV2.equals(batchId.intervalNumberV2); 77 | } 78 | 79 | @Override 80 | public int hashCode() { 81 | return Objects.hash(intervalNumber, intervalNumberV2); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /publish-token/src/test/java/fi/thl/covid19/publishtoken/PublishTokenDaoIT.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken; 2 | 3 | import fi.thl.covid19.publishtoken.generation.v1.PublishToken; 4 | import org.junit.jupiter.api.BeforeEach; 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 8 | import org.springframework.boot.test.context.SpringBootTest; 9 | import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; 10 | import org.springframework.test.context.ActiveProfiles; 11 | 12 | import java.time.Instant; 13 | import java.time.LocalDate; 14 | import java.util.Collections; 15 | import java.util.Optional; 16 | 17 | import static fi.thl.covid19.publishtoken.Validation.SERVICE_NAME_MAX_LENGTH; 18 | import static fi.thl.covid19.publishtoken.Validation.USER_NAME_MAX_LENGTH; 19 | import static java.time.temporal.ChronoUnit.HOURS; 20 | import static org.apache.commons.lang3.StringUtils.repeat; 21 | import static org.junit.jupiter.api.Assertions.*; 22 | 23 | @SpringBootTest 24 | @ActiveProfiles({"dev"}) 25 | @AutoConfigureMockMvc 26 | public class PublishTokenDaoIT { 27 | 28 | private static final String STATS_TOKEN_CREATE = "stats_token_create"; 29 | private static final String STATS_SMS_SEND = "stats_sms_send"; 30 | 31 | @Autowired 32 | private PublishTokenDao dao; 33 | 34 | @Autowired 35 | NamedParameterJdbcTemplate jdbcTemplate; 36 | 37 | @BeforeEach 38 | public void setUp() { 39 | dao.deleteTokensExpiredBefore(Instant.now().plus(48, HOURS)); 40 | deleteStatsRows(); 41 | } 42 | 43 | @Test 44 | public void doubleInsertSameTokenFails() { 45 | PublishToken token = new PublishToken("testtoken", Instant.now(), Instant.now().plus(1, HOURS)); 46 | assertTrue(dao.storeToken(token, LocalDate.now(), "testservice", "testuser", Optional.of(true))); 47 | assertFalse(dao.storeToken(token, LocalDate.now(), "testservice", "testuser", Optional.of(true))); 48 | assertStatRowAdded(STATS_TOKEN_CREATE); 49 | } 50 | 51 | @Test 52 | public void maximumLengthFieldsAreWrittenOk() { 53 | PublishToken token = new PublishToken("123456789012", Instant.now(), Instant.now().plus(1, HOURS)); 54 | dao.storeToken(token, LocalDate.now(), repeat("a", SERVICE_NAME_MAX_LENGTH), repeat("a", USER_NAME_MAX_LENGTH), Optional.of(false)); 55 | } 56 | 57 | @Test 58 | public void minimumLengthFieldsAreWrittenOk() { 59 | PublishToken token = new PublishToken("123456789012", Instant.now(), Instant.now().plus(1, HOURS)); 60 | dao.storeToken(token, LocalDate.now(), "a", "a", Optional.empty()); 61 | } 62 | 63 | @Test 64 | public void testStatRowsAddedOk() { 65 | dao.addSmsStatsRow(Instant.now()); 66 | dao.addTokenCreateStatsRow(Instant.now(), Optional.empty()); 67 | assertStatRowAdded(STATS_TOKEN_CREATE); 68 | assertStatRowAdded(STATS_SMS_SEND); 69 | } 70 | 71 | private void assertStatRowAdded(String tableName) { 72 | String sql = "select count(*) from pt." + tableName; 73 | Integer rows = jdbcTemplate.queryForObject(sql, Collections.emptyMap(), Integer.class); 74 | assertEquals(1, rows); 75 | } 76 | 77 | private void deleteStatsRows() { 78 | jdbcTemplate.update("delete from pt." + STATS_TOKEN_CREATE, Collections.emptyMap()); 79 | jdbcTemplate.update("delete from pt." + STATS_SMS_SEND, Collections.emptyMap()); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## :warning: This project has been terminated and is no longer actively maintained: project has been archived and any usage should be done with caution! :warning: 2 | 3 | # Finnish COVID-19 Application Backend 4 | 5 | ## Contents 6 | - exposure-notification: The exposure notification service for receiving and distributing infection keys and parameters 7 | - publish-token: The publish token generation and verification service for ensuring that only verified infections get reported 8 | 9 | ## Building 10 | The project uses maven wrapper (mvnw). 11 | 12 | This root package contains the others as modules, so you can build it all from here: 13 | ``` 14 | ./mvnw clean package 15 | ``` 16 | ... or on windows 17 | ``` 18 | mvnw.cmd clean package 19 | ``` 20 | 21 | Since the wrapper is in the root directory, you need to refer to it appropriately if going into the subprojects to build them individually: 22 | ``` 23 | cd exposure-notification 24 | ../mvnw clean package 25 | ``` 26 | 27 | ## Database Migrations 28 | Each service handles its own schema (en for exposure-notification and pt for publish-token), migrating them via Flyway as needed. 29 | They don't cross-use each other's data so service update/startup order should not matter. If desired, the services can be configured not to even see each other's schemas. 30 | 31 | In development environment, you may want to use the property `-Dspring.flyway.clean-on-validation-error=true`. 32 | This allows you to edit the schema definitions freely and have flyway simply reset the database when you make an incompatible change. 33 | Obviously, you never want to deploy that setting into production environments though, as you will lose all data. 34 | 35 | ## Running Locally 36 | 37 | ### Requirements 38 | - JDK 11 39 | - PostgreSQL 12 (either directly, or docker for running in a container) 40 | 41 | ### Quick Startup 42 | - The easiest way to get postgresql for development is via docker, for instance (using [the official image in docker hub](https://hub.docker.com/_/postgres)): 43 | ``` 44 | docker run \ 45 | --name covid19-db \ 46 | -p 127.0.0.1:5433:5432 \ 47 | -e POSTGRES_DB=exposure-notification \ 48 | -e POSTGRES_USER=devserver \ 49 | -e POSTGRES_PASSWORD=devserver-password \ 50 | postgres:12 51 | ``` 52 | - You can set the database to store the files in a location of your choosing by adding the parameter 53 | ``` 54 | -v my-local-db-dir:/var/lib/postgresql/data 55 | ``` 56 | - If you vary the parameters (username/password/port), just update them for each service `src/resources/application-dev.yml` 57 | - Build both applications: `./mvnw clean package` 58 | - Start exposure-notification service 59 | ``` 60 | cd exposure-notification 61 | java \ 62 | -Dspring.profiles.active=dev \ 63 | -jar target/exposure-notification-1.0.0-SNAPSHOT.jar \ 64 | fi.thl.covid19.exposurenotification.ExposureNotificationApplication 65 | ``` 66 | - Start publish-token service 67 | ``` 68 | cd publish-token 69 | java \ 70 | -Dspring.profiles.active=dev \ 71 | -jar target/publish-token-1.0.0-SNAPSHOT.jar \ 72 | fi.thl.covid19.publishtoken.PublishTokenApplication 73 | ``` 74 | 75 | ## Contributing 76 | 77 | We are grateful for all the people who have contributed so far. Due to tight schedule of Koronavilkku release we had no time to hone the open source contribution process to the very last detail. This has caused for some contributors to do work we cannot accept due to legal details or design choices that have been made during development. For this we are sorry. 78 | 79 | **IMPORTANT** See further details from [CONTRIBUTING.md](CONTRIBUTING.md) 80 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/MaintenanceService.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification; 2 | 3 | import fi.thl.covid19.exposurenotification.batch.BatchFileService; 4 | import fi.thl.covid19.exposurenotification.batch.BatchFileStorage; 5 | import fi.thl.covid19.exposurenotification.batch.BatchIntervals; 6 | import fi.thl.covid19.exposurenotification.diagnosiskey.DiagnosisKeyDao; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.beans.factory.annotation.Value; 10 | import org.springframework.scheduling.annotation.Scheduled; 11 | import org.springframework.stereotype.Service; 12 | 13 | import java.time.Duration; 14 | import java.time.Instant; 15 | 16 | import static java.util.Objects.requireNonNull; 17 | import static net.logstash.logback.argument.StructuredArguments.keyValue; 18 | 19 | @Service 20 | public class MaintenanceService { 21 | 22 | private static final Logger LOG = LoggerFactory.getLogger(MaintenanceService.class); 23 | 24 | private final DiagnosisKeyDao dao; 25 | private final BatchFileStorage batchFileStorage; 26 | private final BatchFileService batchFileService; 27 | 28 | private final Duration tokenVerificationLifetime; 29 | 30 | public MaintenanceService(DiagnosisKeyDao dao, 31 | BatchFileService batchFileService, 32 | BatchFileStorage batchFileStorage, 33 | @Value("${covid19.maintenance.token-verification-lifetime}") Duration tokenVerificationLifetime) { 34 | this.dao = requireNonNull(dao); 35 | this.batchFileStorage = requireNonNull(batchFileStorage); 36 | this.batchFileService = requireNonNull(batchFileService); 37 | this.tokenVerificationLifetime = requireNonNull(tokenVerificationLifetime); 38 | LOG.info("Initialized: {}", keyValue("tokenVerificationLifetime", tokenVerificationLifetime)); 39 | } 40 | 41 | @Scheduled(initialDelayString = "${covid19.maintenance.interval}", 42 | fixedRateString = "${covid19.maintenance.interval}") 43 | public void runMaintenance() { 44 | BatchIntervals intervals = BatchIntervals.forGeneration(); 45 | BatchIntervals intervalsV2 = BatchIntervals.forGenerationV2(); 46 | 47 | LOG.info("Cleaning keys and updating batch files: {} {} {}", 48 | keyValue("currentInterval", intervals.current), 49 | keyValue("firstFile", intervals.first), 50 | keyValue("lastFile", intervals.last)); 51 | 52 | LOG.info("Cleaning keys and updating batch files for V2: {} {} {}", 53 | keyValue("currentInterval", intervalsV2.current), 54 | keyValue("firstFile", intervalsV2.first), 55 | keyValue("lastFile", intervalsV2.last)); 56 | 57 | int removedKeys = dao.deleteKeysBefore(intervals.first); 58 | int removedVerifications = dao.deleteVerificationsBefore(Instant.now().minus(tokenVerificationLifetime)); 59 | int removedBatches = batchFileStorage.deleteKeyBatchesBefore(intervals.first); 60 | int addedBatches = batchFileService.cacheMissingBatchesBetween(intervals.first, intervals.last); 61 | int addedBatchesV2 = batchFileService.cacheMissingBatchesBetweenV2(intervalsV2.first, intervalsV2.last); 62 | 63 | LOG.info("Batches updated: {} {} {} {} {}", 64 | keyValue("removedKeys", removedBatches), 65 | keyValue("removedVerifications", removedVerifications), 66 | keyValue("removedBatches", removedKeys), 67 | keyValue("addedBatches", addedBatches), 68 | keyValue("addedBatchesV2", addedBatchesV2)); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/generation/v1/PublishTokenGenerationController.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken.generation.v1; 2 | 3 | import fi.thl.covid19.publishtoken.PublishTokenService; 4 | import fi.thl.covid19.publishtoken.Validation; 5 | import fi.thl.covid19.publishtoken.error.SmsGatewayException; 6 | import fi.thl.covid19.publishtoken.sms.SmsService; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.web.bind.annotation.*; 10 | 11 | import static fi.thl.covid19.publishtoken.Validation.validateUserName; 12 | import static java.util.Objects.requireNonNull; 13 | import static net.logstash.logback.argument.StructuredArguments.keyValue; 14 | 15 | @RestController 16 | @RequestMapping("/publish-token/v1") 17 | public class PublishTokenGenerationController { 18 | 19 | private static final Logger LOG = LoggerFactory.getLogger(PublishTokenGenerationController.class); 20 | 21 | public static final String SERVICE_NAME_HEADER = "KV-Request-Service"; 22 | public static final String VALIDATE_ONLY_HEADER = "KV-Validate-Only"; 23 | 24 | private final PublishTokenService publishTokenService; 25 | private final SmsService smsService; 26 | 27 | public PublishTokenGenerationController(PublishTokenService publishTokenService, SmsService smsService) { 28 | this.publishTokenService = requireNonNull(publishTokenService); 29 | this.smsService = requireNonNull(smsService); 30 | } 31 | 32 | @PostMapping 33 | public PublishToken generateToken(@RequestHeader(name = SERVICE_NAME_HEADER) String rawRequestService, 34 | @RequestHeader(name = VALIDATE_ONLY_HEADER, required = false) boolean validateOnly, 35 | @RequestBody PublishTokenGenerationRequest request) { 36 | String requestService = Validation.validateServiceName(rawRequestService); 37 | 38 | if (validateOnly || request.validateOnly) { 39 | LOG.debug("API Validation Test: Generate new publish token: {} {}", 40 | keyValue("service", requestService), keyValue("user", request.requestUser)); 41 | return publishTokenService.generate(); 42 | } else { 43 | LOG.info("Generating new publish token: {} {} {}", 44 | keyValue("service", requestService), 45 | keyValue("user", request.requestUser), 46 | keyValue("smsUsed", request.patientSmsNumber.isPresent())); 47 | PublishToken token = publishTokenService.generateAndStore( 48 | request.symptomsOnset, 49 | requestService, 50 | request.requestUser, 51 | request.symptomsExist); 52 | request.patientSmsNumber.ifPresent(number -> { 53 | if (!smsService.send(number, token)) { 54 | publishTokenService.invalidateToken(token); 55 | throw new SmsGatewayException(); 56 | } 57 | } 58 | ); 59 | return token; 60 | } 61 | } 62 | 63 | @GetMapping("/{user}") 64 | public PublishTokenList getTokensBy(@RequestHeader(name = SERVICE_NAME_HEADER) String rawRequestService, @PathVariable(value = "user") String user) { 65 | String validatedService = Validation.validateServiceName(rawRequestService); 66 | String validatedUser = validateUserName(requireNonNull(user)); 67 | LOG.info("Fetching tokens: {} {}", keyValue("service", validatedService), keyValue("user", user)); 68 | return new PublishTokenList(publishTokenService.getTokensBy(validatedService, validatedUser)); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/efgs/scheduled/FederationGatewaySyncProcessor.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.efgs.scheduled; 2 | 3 | import fi.thl.covid19.exposurenotification.efgs.InboundService; 4 | import fi.thl.covid19.exposurenotification.efgs.OutboundService; 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | import org.slf4j.MDC; 8 | import org.springframework.beans.factory.annotation.Value; 9 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 10 | import org.springframework.scheduling.annotation.Scheduled; 11 | import org.springframework.stereotype.Component; 12 | 13 | import java.time.LocalDate; 14 | import java.time.ZoneOffset; 15 | import java.util.Optional; 16 | import java.util.Set; 17 | 18 | import static java.time.temporal.ChronoUnit.DAYS; 19 | import static java.util.Objects.requireNonNull; 20 | import static net.logstash.logback.argument.StructuredArguments.keyValue; 21 | 22 | @Component 23 | @ConditionalOnProperty( 24 | prefix = "covid19.federation-gateway", value = "enabled", 25 | havingValue = "true" 26 | ) 27 | public class FederationGatewaySyncProcessor { 28 | 29 | private static final Logger LOG = LoggerFactory.getLogger(FederationGatewaySyncProcessor.class); 30 | private final boolean importEnabled; 31 | private volatile LocalDate lastInboundSyncFromEfgs; 32 | 33 | private final OutboundService outboundService; 34 | private final InboundService inboundService; 35 | 36 | public FederationGatewaySyncProcessor( 37 | OutboundService outboundService, 38 | InboundService inboundService, 39 | @Value("${covid19.federation-gateway.scheduled-inbound-enabled}") boolean importEnabled 40 | ) { 41 | this.outboundService = requireNonNull(outboundService); 42 | this.inboundService = requireNonNull(inboundService); 43 | this.lastInboundSyncFromEfgs = LocalDate.now(ZoneOffset.UTC).minus(1, DAYS); 44 | this.importEnabled = importEnabled; 45 | } 46 | 47 | @Scheduled(initialDelayString = "${covid19.federation-gateway.upload-interval}", 48 | fixedDelayString = "${covid19.federation-gateway.upload-interval}") 49 | private void runExportToEfgs() { 50 | MDC.clear(); 51 | LOG.info("Starting scheduled export to efgs."); 52 | Set operationIds = outboundService.startOutbound(false); 53 | LOG.info("Scheduled export to efgs finished. {}", keyValue("operationId", operationIds)); 54 | } 55 | 56 | @Scheduled(initialDelayString = "${covid19.federation-gateway.download-interval}", 57 | fixedDelayString = "${covid19.federation-gateway.download-interval}") 58 | private void runImportFromEfgs() { 59 | MDC.clear(); 60 | LocalDate today = LocalDate.now(ZoneOffset.UTC); 61 | if (importEnabled && today.isAfter(lastInboundSyncFromEfgs)) { 62 | LOG.info("Starting scheduled import from efgs."); 63 | inboundService.startInbound(lastInboundSyncFromEfgs, Optional.empty()); 64 | lastInboundSyncFromEfgs = today; 65 | LOG.info("Scheduled import from efgs finished."); 66 | } 67 | } 68 | 69 | @Scheduled(initialDelayString = "${covid19.federation-gateway.error-handling-interval}", 70 | fixedDelayString = "${covid19.federation-gateway.error-handling-interval}") 71 | private void runErrorHandling() { 72 | MDC.clear(); 73 | LOG.info("Starting scheduled efgs error handling."); 74 | outboundService.resolveCrash(); 75 | outboundService.startOutbound(true); 76 | inboundService.resolveCrash(); 77 | inboundService.startInboundRetry(LocalDate.now(ZoneOffset.UTC).minus(1, DAYS)); 78 | LOG.info("Scheduled efgs error handling finished."); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /exposure-notification/src/test/java/fi/thl/covid19/exposurenotification/diagnosiskey/IntervalNumberTest.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.diagnosiskey; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import java.time.Instant; 6 | 7 | import static fi.thl.covid19.exposurenotification.batch.BatchIntervals.DAILY_BATCHES_COUNT; 8 | import static fi.thl.covid19.exposurenotification.diagnosiskey.IntervalNumber.*; 9 | import static java.time.ZoneOffset.UTC; 10 | import static org.junit.jupiter.api.Assertions.assertEquals; 11 | 12 | public class IntervalNumberTest { 13 | 14 | // Max moment representable via 10 min integer interval number (as is done by GAEN API) 15 | private static final Instant MAX_SUPPORTED_TIME = Instant.ofEpochSecond(60L * 10L * Integer.MAX_VALUE); 16 | 17 | @Test 18 | public void getting24hIntervalWorks() { 19 | assertEquals(0, to24HourInterval(Instant.EPOCH)); 20 | assertEquals(20, to24HourInterval(Instant.parse("1970-01-21T12:00:00Z"))); 21 | assertEquals(Integer.MAX_VALUE / (6 * 24), to24HourInterval(MAX_SUPPORTED_TIME)); 22 | } 23 | 24 | @Test 25 | public void gettingV2IntervalWorks() { 26 | assertEquals(0, toV2Interval(Instant.EPOCH)); 27 | assertEquals(123, toV2Interval(Instant.parse("1970-01-21T12:00:00Z"))); 28 | assertEquals(Integer.MAX_VALUE / (6 * (24 / DAILY_BATCHES_COUNT)), toV2Interval(MAX_SUPPORTED_TIME)); 29 | } 30 | 31 | @Test 32 | public void getting10MinIntervalWorks() { 33 | assertEquals(0, to10MinInterval(Instant.EPOCH)); 34 | assertEquals(2943, to10MinInterval(Instant.parse("1970-01-21T10:30:00Z"))); 35 | assertEquals(Integer.MAX_VALUE, to10MinInterval(MAX_SUPPORTED_TIME)); 36 | } 37 | 38 | @Test 39 | public void dayFirstIntervalWorks() { 40 | assertEquals(to10MinInterval(Instant.parse("2020-10-01T00:00:00Z")), 41 | dayFirst10MinInterval(Instant.parse("2020-10-01T12:00:00Z"))); 42 | } 43 | 44 | @Test 45 | public void dayLastIntervalWorks() { 46 | assertEquals(to10MinInterval(Instant.parse("2020-10-01T23:55:00Z")), 47 | dayLast10MinInterval(Instant.parse("2020-10-01T12:00:00Z"))); 48 | } 49 | 50 | @Test 51 | public void utcLocalDateWorks() { 52 | Instant now = Instant.now(); 53 | assertEquals(now.atZone(UTC).toLocalDate(), utcDateOf10MinInterval(to10MinInterval(now))); 54 | assertEquals(Instant.EPOCH.atZone(UTC).toLocalDate(), utcDateOf10MinInterval(to10MinInterval(Instant.EPOCH))); 55 | assertEquals(MAX_SUPPORTED_TIME.atZone(UTC).toLocalDate(), utcDateOf10MinInterval(to10MinInterval(MAX_SUPPORTED_TIME))); 56 | } 57 | 58 | @Test 59 | public void secondsAreCorrectlyCalculatedFor24hInterval() { 60 | assertEquals(0, startSecondOf24HourInterval(to24HourInterval(Instant.EPOCH))); 61 | assertEquals( 62 | MAX_SUPPORTED_TIME.getEpochSecond() - MAX_SUPPORTED_TIME.getEpochSecond() % (24 * 60 * 60), 63 | startSecondOf24HourInterval(to24HourInterval(MAX_SUPPORTED_TIME))); 64 | } 65 | 66 | @Test 67 | public void secondsAreCorrectlyCalculatedForV2Interval() { 68 | assertEquals(0, startSecondOfV2Interval(toV2Interval(Instant.EPOCH))); 69 | assertEquals( 70 | MAX_SUPPORTED_TIME.getEpochSecond() - MAX_SUPPORTED_TIME.getEpochSecond() % (60 * 60 * (24 / DAILY_BATCHES_COUNT)), 71 | startSecondOfV2Interval(toV2Interval(MAX_SUPPORTED_TIME))); 72 | } 73 | 74 | @Test 75 | public void fromV2To24hourWorks() { 76 | assertEquals(1666, fromV2to24hourInterval(9996)); 77 | assertEquals(9996, from24hourToV2Interval(1666)); 78 | } 79 | 80 | @Test 81 | public void from24hourToV2Works() { 82 | assertEquals(60000, from24hourToV2Interval(10000)); 83 | assertEquals(10000, fromV2to24hourInterval(60000)); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /publish-token/src/test/java/fi/thl/covid19/publishtoken/SMSServiceTestIT.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken; 2 | 3 | import fi.thl.covid19.publishtoken.generation.v1.PublishToken; 4 | import fi.thl.covid19.publishtoken.sms.SmsPayload; 5 | import fi.thl.covid19.publishtoken.sms.SmsService; 6 | import org.junit.jupiter.api.AfterEach; 7 | import org.junit.jupiter.api.BeforeEach; 8 | import org.junit.jupiter.api.Test; 9 | import org.mockito.ArgumentCaptor; 10 | import org.mockito.Captor; 11 | import org.mockito.Mockito; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 14 | import org.springframework.boot.test.context.SpringBootTest; 15 | import org.springframework.boot.test.mock.mockito.MockBean; 16 | import org.springframework.http.HttpEntity; 17 | import org.springframework.http.MediaType; 18 | import org.springframework.http.ResponseEntity; 19 | import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; 20 | import org.springframework.test.context.ActiveProfiles; 21 | import org.springframework.web.client.RestTemplate; 22 | 23 | import java.time.Instant; 24 | import java.util.Collections; 25 | import java.util.List; 26 | 27 | import static org.junit.jupiter.api.Assertions.*; 28 | import static org.mockito.ArgumentMatchers.any; 29 | import static org.mockito.ArgumentMatchers.eq; 30 | import static org.mockito.BDDMockito.given; 31 | import static org.mockito.Mockito.verify; 32 | 33 | @ActiveProfiles({"dev"}) 34 | @SpringBootTest(properties = {"covid19.publish-token.sms.gateway=http://testaddress"}) 35 | @AutoConfigureMockMvc 36 | public class SMSServiceTestIT { 37 | 38 | @Autowired 39 | private SmsService smsService; 40 | 41 | @MockBean 42 | private RestTemplate rest; 43 | 44 | @Autowired 45 | NamedParameterJdbcTemplate jdbcTemplate; 46 | 47 | @Captor 48 | private ArgumentCaptor> reqCaptor; 49 | 50 | @BeforeEach 51 | public void setUp() { 52 | deleteStatsRows(); 53 | } 54 | 55 | @AfterEach 56 | public void end() { 57 | Mockito.verifyNoMoreInteractions(rest); 58 | } 59 | 60 | @Test 61 | public void tokenSmsSendingViaGateway() { 62 | String number = "321654987"; 63 | String token = "123456789012"; 64 | 65 | given(rest.postForEntity(eq("http://testaddress"), reqCaptor.capture(), eq(String.class))) 66 | .willReturn(ResponseEntity.ok("test")); 67 | assertTrue(smsService.send(number, new PublishToken(token, Instant.now(), Instant.now()))); 68 | assertSmsStatRowAdded(); 69 | HttpEntity request = reqCaptor.getValue(); 70 | assertNotNull(request); 71 | verify(rest).postForEntity(eq("http://testaddress"), any(), eq(String.class)); 72 | 73 | assertNotNull(request.getHeaders()); 74 | assertEquals(MediaType.APPLICATION_JSON, request.getHeaders().getContentType()); 75 | assertEquals(List.of(MediaType.APPLICATION_JSON), request.getHeaders().getAccept()); 76 | 77 | assertNotNull(request.getBody()); 78 | String message = request.getBody().text; 79 | String recipient = request.getBody().destination.toArray()[0].toString(); 80 | assertNotNull(message); 81 | // This is not probably max limit, but let's keep this in here because if we grow our message it should be tested first 82 | assertTrue(message.length() <= 500); 83 | assertTrue(message.contains(token)); 84 | assertEquals(number, recipient); 85 | } 86 | 87 | private void assertSmsStatRowAdded() { 88 | String sql = "select count(*) from pt.stats_sms_send"; 89 | Integer rows = jdbcTemplate.queryForObject(sql, Collections.emptyMap(), Integer.class); 90 | assertEquals(1, rows); 91 | } 92 | 93 | private void deleteStatsRows() { 94 | jdbcTemplate.update("delete from pt.stats_sms_send", Collections.emptyMap()); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /exposure-notification/src/main/resources/db/migration/R__3_default_v2_exposure_configuration.sql: -------------------------------------------------------------------------------- 1 | -- As a repeatable migration, this will be re-run whenever the file changes 2 | insert into en.exposure_configuration_v2 3 | (report_type_weight_confirmed_test, report_type_weight_confirmed_clinical_diagnosis, report_type_weight_self_report, report_type_weight_recursive, infectiousness_weight_standard, infectiousness_weight_high, attenuation_bucket_threshold_db, attenuation_bucket_weights, days_since_exposure_threshold, minimum_window_score, minimum_daily_score, days_since_onset_to_infectiousness, infectiousness_when_dsos_missing, available_countries, end_of_life_reached, end_of_life_statistics) 4 | select * from ( 5 | values( 6 | 1.0::numeric, 7 | 0.0::numeric, 8 | 0.0::numeric, 9 | 0.0::numeric, 10 | 0.625::numeric, 11 | 1.0::numeric, 12 | '{ 51, 63, 70 }'::numeric array[3], 13 | '{ 1.25, 1.0, 0.5, 0.0 }'::numeric array[4], 14 | 10::int, 15 | 1.0::numeric, 16 | 900::int, 17 | '-14 => NONE, 18 | -13 => NONE, 19 | -12 => NONE, 20 | -11 => NONE, 21 | -10 => NONE, 22 | -9 => NONE, 23 | -8 => NONE, 24 | -7 => NONE, 25 | -6 => NONE, 26 | -5 => NONE, 27 | -4 => NONE, 28 | -3 => NONE, 29 | -2 => HIGH, 30 | -1 => HIGH, 31 | 0 => HIGH, 32 | 1 => HIGH, 33 | 2 => HIGH, 34 | 3 => HIGH, 35 | 4 => HIGH, 36 | 5 => HIGH, 37 | 6 => HIGH, 38 | 7 => HIGH, 39 | 8 => STANDARD, 40 | 9 => STANDARD, 41 | 10 => STANDARD, 42 | 11 => NONE, 43 | 12 => NONE, 44 | 13 => NONE, 45 | 14 => NONE 46 | '::hstore, 47 | 'HIGH', 48 | '{ BE, BG, CZ, DK, DE, EE, IE, GR, ES, FR, HR, IT, CY, LV, LT, LU, HU, MT, NL, AT, PL, PT, RO, SI, SK, SE, IS, NO, LI, CH, GB }'::varchar(2)[], 49 | true::boolean, 50 | '[ 51 | { 52 | "value": { 53 | "fi": "2,5 miljoonaa", 54 | "sv": "2,5 miljoner", 55 | "en": "2.5 million" 56 | }, 57 | "label": { 58 | "fi": "suomalaista käytti Koronavilkkua.", 59 | "sv": "finländare använde Coronablinkern.", 60 | "en": "people in Finland used Koronavilkku." 61 | } 62 | }, 63 | { 64 | "value": { 65 | "fi": "64\u00a0000", 66 | "sv": "64\u00a0000", 67 | "en": "64,000" 68 | }, 69 | "label": { 70 | "fi": "käyttäjää ilmoitti tartunnastaan Koronavilkun kautta.", 71 | "sv": "användare meddelade om sin smitta via Coronablinkern.", 72 | "en": "users reported their infection with Koronavilkku." 73 | } 74 | }, 75 | { 76 | "value": { 77 | "fi": "23\u00a0%", 78 | "sv": "23\u00a0%", 79 | "en": "23\u00a0%" 80 | }, 81 | "label": { 82 | "fi": "käyttäjistä kertoi saaneensa altistumisilmoituksen.", 83 | "sv": "av användarna uppgav att de hade fått exponeringsmeddelandet.", 84 | "en": "of users reported having received an exposure notification." 85 | } 86 | } 87 | ]'::jsonb) 88 | ) as default_values 89 | -- Don't insert a new version if the latest one is identical 90 | except ( 91 | select 92 | report_type_weight_confirmed_test, report_type_weight_confirmed_clinical_diagnosis, report_type_weight_self_report, report_type_weight_recursive, infectiousness_weight_standard, infectiousness_weight_high, attenuation_bucket_threshold_db, attenuation_bucket_weights, days_since_exposure_threshold, minimum_window_score, minimum_daily_score, days_since_onset_to_infectiousness, infectiousness_when_dsos_missing, available_countries, end_of_life_reached, end_of_life_statistics 93 | from en.exposure_configuration_v2 94 | order by version desc limit 1 95 | ); 96 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/FederationGatewayRestClientConfiguration.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification; 2 | 3 | import fi.thl.covid19.exposurenotification.error.RestTemplateErrorHandler; 4 | import org.apache.http.conn.ssl.SSLConnectionSocketFactory; 5 | import org.apache.http.impl.client.CloseableHttpClient; 6 | import org.apache.http.impl.client.HttpClients; 7 | import org.apache.http.ssl.PrivateKeyStrategy; 8 | import org.apache.http.ssl.SSLContextBuilder; 9 | import org.springframework.boot.web.client.RestTemplateBuilder; 10 | import org.springframework.context.annotation.Bean; 11 | import org.springframework.context.annotation.Configuration; 12 | import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; 13 | import org.springframework.util.ResourceUtils; 14 | import org.springframework.web.client.RestTemplate; 15 | 16 | import javax.net.ssl.SSLContext; 17 | import java.io.File; 18 | import java.io.FileInputStream; 19 | import java.security.KeyStore; 20 | import java.time.Duration; 21 | 22 | @Configuration 23 | public class FederationGatewayRestClientConfiguration { 24 | 25 | private final Duration connectTimeout = Duration.ofSeconds(60); 26 | private final Duration readTimeout = Duration.ofSeconds(60); 27 | 28 | private final FederationGatewayRestClientProperties properties; 29 | 30 | public FederationGatewayRestClientConfiguration( 31 | FederationGatewayRestClientProperties properties 32 | ) { 33 | this.properties = properties; 34 | } 35 | 36 | @Bean("federationGatewayRestTemplate") 37 | public RestTemplate customRestTemplate(RestTemplateBuilder builder) { 38 | return builder 39 | .setConnectTimeout(connectTimeout) 40 | .setReadTimeout(readTimeout) 41 | .requestFactory(this::requestFactory) 42 | .errorHandler(new RestTemplateErrorHandler()) 43 | .build(); 44 | } 45 | 46 | private HttpComponentsClientHttpRequestFactory requestFactory() { 47 | try { 48 | SSLContext context = properties.isMandatoryPropertiesAvailable() ? 49 | createSSLContextWithKey() : SSLContextBuilder.create().build(); 50 | 51 | SSLConnectionSocketFactory socketFactory = new SSLConnectionSocketFactory(context); 52 | CloseableHttpClient client = HttpClients.custom() 53 | .setSSLSocketFactory(socketFactory) 54 | .build(); 55 | return new HttpComponentsClientHttpRequestFactory(client); 56 | } catch (Exception e) { 57 | throw new RuntimeException(e); 58 | } 59 | } 60 | 61 | private SSLContext createSSLContextWithKey() throws Exception { 62 | PrivateKeyStrategy privateKeyStrategy = (v1, v2) -> properties.clientKeyStore.alias; 63 | return SSLContextBuilder.create() 64 | .loadKeyMaterial( 65 | keyStore( 66 | properties.clientKeyStore.path, 67 | properties.clientKeyStore.password 68 | ), properties.clientKeyStore.password, privateKeyStrategy) 69 | .loadTrustMaterial( 70 | new File(properties.trustStore.path), 71 | properties.trustStore.password) 72 | .build(); 73 | } 74 | 75 | private KeyStore keyStore(String keyStoreFile, char[] password) throws Exception { 76 | File file = ResourceUtils.getFile(keyStoreFile); 77 | return loadKeyStore(file, password); 78 | } 79 | 80 | private KeyStore loadKeyStore(File file, char[] password) throws Exception { 81 | try (FileInputStream fileInputStream = new FileInputStream(file)) { 82 | KeyStore keyStore; 83 | keyStore = KeyStore.getInstance("PKCS12"); 84 | keyStore.load(fileInputStream, password); 85 | return keyStore; 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/efgs/signing/FederationGatewaySigningImpl.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.efgs.signing; 2 | 3 | import fi.thl.covid19.proto.EfgsProto; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 8 | import org.springframework.stereotype.Component; 9 | import org.springframework.util.ResourceUtils; 10 | 11 | import java.io.FileInputStream; 12 | import java.io.IOException; 13 | import java.security.*; 14 | import java.security.cert.*; 15 | 16 | import static fi.thl.covid19.exposurenotification.efgs.signing.SigningUtil.signBatch; 17 | import static java.util.Objects.requireNonNull; 18 | import static net.logstash.logback.argument.StructuredArguments.keyValue; 19 | 20 | 21 | @Component 22 | @ConditionalOnProperty( 23 | prefix = "covid19.federation-gateway.signing-key-store", value = "implementation", 24 | havingValue = "default", matchIfMissing = true 25 | ) 26 | public class FederationGatewaySigningImpl implements FederationGatewaySigning { 27 | 28 | private static final Logger LOG = LoggerFactory.getLogger(FederationGatewaySigningImpl.class); 29 | 30 | private final String keyStorePath; 31 | private final char[] keyStorePassword; 32 | private final String keyStoreKeyAlias; 33 | private final String trustAnchorAlias; 34 | private final KeyStore keyStore; 35 | 36 | public FederationGatewaySigningImpl( 37 | @Value("${covid19.federation-gateway.signing-key-store.path}") String keyStorePath, 38 | @Value("${covid19.federation-gateway.signing-key-store.password}") String keyStorePassword, 39 | @Value("${covid19.federation-gateway.signing-key-store.key-alias}") String keyStoreKeyAlias, 40 | @Value("${covid19.federation-gateway.signing-key-store.trust-anchor-alias}") String trustAnchorAlias 41 | ) { 42 | this.keyStorePath = requireNonNull(keyStorePath); 43 | this.keyStorePassword = requireNonNull(keyStorePassword.toCharArray()); 44 | this.keyStoreKeyAlias = requireNonNull(keyStoreKeyAlias); 45 | this.trustAnchorAlias = requireNonNull(trustAnchorAlias); 46 | this.keyStore = initKeystore(); 47 | } 48 | 49 | public String sign(EfgsProto.DiagnosisKeyBatch data) { 50 | try { 51 | PrivateKey key = (PrivateKey) keyStore.getKey(keyStoreKeyAlias, keyStorePassword); 52 | X509Certificate cert = (X509Certificate) keyStore.getCertificate(keyStoreKeyAlias); 53 | return signBatch(data, key, cert); 54 | } catch (Exception e) { 55 | throw new IllegalStateException("EFGS batch signing failed.", e); 56 | } 57 | } 58 | 59 | public X509Certificate getTrustAnchor() { 60 | try { 61 | return requireNonNull((X509Certificate) keyStore.getCertificate(trustAnchorAlias)); 62 | } catch (KeyStoreException e) { 63 | throw new RuntimeException(e.getMessage(), e.getCause()); 64 | } 65 | } 66 | 67 | private KeyStore initKeystore() { 68 | return loadKeyStore(); 69 | } 70 | 71 | private KeyStore loadKeyStore() { 72 | try (FileInputStream fileInputStream = new FileInputStream(ResourceUtils.getFile(keyStorePath))) { 73 | Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider()); 74 | KeyStore keyStore = KeyStore.getInstance("PKCS12", "BC"); 75 | keyStore.load(fileInputStream, keyStorePassword); 76 | keyStore.aliases().asIterator().forEachRemaining(alias -> LOG.debug("signing-keystore: {}", keyValue("alias", alias))); 77 | return keyStore; 78 | } catch (KeyStoreException | NoSuchAlgorithmException | IOException | CertificateException | NoSuchProviderException e) { 79 | throw new IllegalStateException("EFGS signing certificate load error.", e); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /exposure-notification/src/main/java/fi/thl/covid19/exposurenotification/configuration/v2/ExposureConfigurationV2.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.exposurenotification.configuration.v2; 2 | 3 | import java.math.BigDecimal; 4 | import java.util.List; 5 | import java.util.Map; 6 | import java.util.Set; 7 | 8 | // Configuration supporting exposure windows (or V2) api of exposure notifications 9 | // See more from https://developers.google.com/android/exposure-notifications/exposure-notifications-api 10 | public class ExposureConfigurationV2 { 11 | 12 | public final int version; 13 | 14 | // DailySummariesConfig 15 | public final BigDecimal reportTypeWeightConfirmedTest; 16 | public final BigDecimal reportTypeWeightConfirmedClinicalDiagnosis; 17 | public final BigDecimal reportTypeWeightSelfReport; 18 | public final BigDecimal reportTypeWeightRecursive; 19 | public final BigDecimal infectiousnessWeightStandard; 20 | public final BigDecimal infectiousnessWeightHigh; 21 | public final List attenuationBucketThresholdDb; 22 | public final List attenuationBucketWeights; 23 | public final int daysSinceExposureThreshold; 24 | public final double minimumWindowScore; 25 | public final int minimumDailyScore; 26 | 27 | // DiagnosisKeysDataMapping 28 | public final Map daysSinceOnsetToInfectiousness; 29 | public final String infectiousnessWhenDaysSinceOnsetMissing; 30 | 31 | // End of life configurations 32 | public final boolean endOfLifeReached; 33 | public final List endOfLifeStatistics; 34 | 35 | /* 36 | * Set of available countries in ISO-3166 alpha-2 format 37 | * Source: https://ec.europa.eu/info/live-work-travel-eu/health/coronavirus-response/travel-during-coronavirus-pandemic/mobile-contact-tracing-apps-eu-member-states_en 38 | */ 39 | public final Set availableCountries; 40 | 41 | public ExposureConfigurationV2( 42 | int version, 43 | BigDecimal reportTypeWeightConfirmedTest, 44 | BigDecimal reportTypeWeightConfirmedClinicalDiagnosis, 45 | BigDecimal reportTypeWeightSelfReport, 46 | BigDecimal reportTypeWeightRecursive, 47 | BigDecimal infectiousnessWeightStandard, 48 | BigDecimal infectiousnessWeightHigh, 49 | List attenuationBucketThresholdDb, 50 | List attenuationBucketWeights, 51 | int daysSinceExposureThreshold, 52 | double minimumWindowScore, 53 | int minimumDailyScore, 54 | Map daysSinceOnsetToInfectiousness, 55 | String infectiousnessWhenDaysSinceOnsetMissing, 56 | Set availableCountries, 57 | boolean endOfLifeReached, 58 | List endOfLifeStatistics 59 | ) { 60 | this.version = version; 61 | this.reportTypeWeightConfirmedTest = reportTypeWeightConfirmedTest; 62 | this.reportTypeWeightConfirmedClinicalDiagnosis = reportTypeWeightConfirmedClinicalDiagnosis; 63 | this.reportTypeWeightSelfReport = reportTypeWeightSelfReport; 64 | this.reportTypeWeightRecursive = reportTypeWeightRecursive; 65 | this.infectiousnessWeightStandard = infectiousnessWeightStandard; 66 | this.infectiousnessWeightHigh = infectiousnessWeightHigh; 67 | this.attenuationBucketThresholdDb = attenuationBucketThresholdDb; 68 | this.attenuationBucketWeights = attenuationBucketWeights; 69 | this.daysSinceExposureThreshold = daysSinceExposureThreshold; 70 | this.minimumWindowScore = minimumWindowScore; 71 | this.minimumDailyScore = minimumDailyScore; 72 | this.daysSinceOnsetToInfectiousness = daysSinceOnsetToInfectiousness; 73 | this.infectiousnessWhenDaysSinceOnsetMissing = infectiousnessWhenDaysSinceOnsetMissing; 74 | this.availableCountries = availableCountries; 75 | this.endOfLifeReached = endOfLifeReached; 76 | this.endOfLifeStatistics = endOfLifeStatistics; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /publish-token/src/main/java/fi/thl/covid19/publishtoken/sms/SmsService.java: -------------------------------------------------------------------------------- 1 | package fi.thl.covid19.publishtoken.sms; 2 | 3 | import fi.thl.covid19.publishtoken.PublishTokenDao; 4 | import fi.thl.covid19.publishtoken.generation.v1.PublishToken; 5 | import io.micrometer.core.instrument.MeterRegistry; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | import org.springframework.http.HttpEntity; 9 | import org.springframework.http.HttpHeaders; 10 | import org.springframework.http.MediaType; 11 | import org.springframework.http.ResponseEntity; 12 | import org.springframework.stereotype.Service; 13 | import org.springframework.web.client.RestClientException; 14 | import org.springframework.web.client.RestTemplate; 15 | 16 | import java.time.Instant; 17 | import java.util.List; 18 | import java.util.Set; 19 | 20 | import static java.util.Objects.requireNonNull; 21 | import static net.logstash.logback.argument.StructuredArguments.keyValue; 22 | 23 | @Service 24 | public class SmsService { 25 | 26 | private static final Logger LOG = LoggerFactory.getLogger(SmsService.class); 27 | 28 | private final RestTemplate restTemplate; 29 | 30 | private final SmsConfig config; 31 | 32 | private final PublishTokenDao dao; 33 | 34 | private final MeterRegistry meterRegistry; 35 | 36 | private final String smsErrorRequestsTotalCount = "sms_error_requests_total_count"; 37 | 38 | public SmsService(RestTemplate restTemplate, SmsConfig config, PublishTokenDao dao, MeterRegistry meterRegistry) { 39 | this.restTemplate = requireNonNull(restTemplate); 40 | this.config = requireNonNull(config); 41 | this.dao = requireNonNull(dao); 42 | this.meterRegistry = requireNonNull(meterRegistry); 43 | initCounters(); 44 | LOG.info("SMS Service initialized: {} {} {}", 45 | keyValue("active", config.gateway.isPresent()), 46 | keyValue("senderName", config.senderName), 47 | keyValue("gateway", config.gateway.orElse(""))); 48 | } 49 | 50 | public boolean send(String number, PublishToken token) { 51 | if (config.gateway.isPresent() && send(config.gateway.get(), number, config.formatContent(token.token))) { 52 | dao.addSmsStatsRow(Instant.now()); 53 | return true; 54 | } else { 55 | meterRegistry.counter(smsErrorRequestsTotalCount).increment(1.0); 56 | LOG.warn("Requested to send SMS, but no gateway configured or sending failed!"); 57 | return false; 58 | } 59 | } 60 | 61 | private boolean send(String gateway, String number, String content) { 62 | 63 | LOG.info("Sending token via SMS: {} {}", 64 | keyValue("gateway", gateway), 65 | keyValue("length", content.length())); 66 | 67 | try { 68 | HttpEntity request = generateRequest(number, content); 69 | ResponseEntity result = restTemplate.postForEntity(gateway, request, String.class); 70 | if (result.getStatusCode().is2xxSuccessful()) { 71 | LOG.info("SMS sent: {}", keyValue("status", result.getStatusCode())); 72 | return true; 73 | } else { 74 | LOG.error("Failed to send SMS: {}", keyValue("status", result.getStatusCode())); 75 | return false; 76 | } 77 | } catch (RestClientException e) { 78 | LOG.error("Failed to send SMS.", e); 79 | return false; 80 | } 81 | } 82 | 83 | private HttpEntity generateRequest(String number, String content) { 84 | HttpHeaders headers = new HttpHeaders(); 85 | headers.setContentType(MediaType.APPLICATION_JSON); 86 | headers.setAccept(List.of(MediaType.APPLICATION_JSON)); 87 | headers.add("Authorization", "apikey " + config.senderApiKey); 88 | SmsPayload sms = new SmsPayload(config.senderName, content, Set.of(number)); 89 | 90 | return new HttpEntity<>(sms, headers); 91 | } 92 | 93 | private void initCounters() { 94 | meterRegistry.counter(smsErrorRequestsTotalCount); 95 | } 96 | } 97 | --------------------------------------------------------------------------------