You will be provided with a partial code base and an issue statement explaining a problem to resolve.
<issue>
Aggregate File Creation: Skip packages if payload too small
Change the aggregate file creation logic in a way, in which packages are skipped in case the payload (amount of keys) is too small. The threshold should be configurable, and defaulted to 50 for now.

</issue>

<code>
[start of README.md]
1 <!-- markdownlint-disable MD041 -->
2 <h1 align="center">
3     Corona-Warn-App Server
4 </h1>
5 
6 <p align="center">
7     <a href="https://github.com/corona-warn-app/cwa-server/commits/" title="Last Commit"><img src="https://img.shields.io/github/last-commit/corona-warn-app/cwa-server?style=flat"></a>
8     <a href="https://github.com/corona-warn-app/cwa-server/issues" title="Open Issues"><img src="https://img.shields.io/github/issues/corona-warn-app/cwa-server?style=flat"></a>
9     <a href="https://circleci.com/gh/corona-warn-app/cwa-server" title="Build Status"><img src="https://circleci.com/gh/corona-warn-app/cwa-server.svg?style=shield&circle-token=4ab059989d10709df19eb4b98ab7c121a25e981a"></a>
10         <a href="https://sonarcloud.io/dashboard?id=corona-warn-app_cwa-server" title="Quality Gate"><img src="https://sonarcloud.io/api/project_badges/measure?project=corona-warn-app_cwa-server&metric=alert_status"></a>
11         <a href="https://sonarcloud.io/component_measures?id=corona-warn-app_cwa-server&metric=Coverage&view=list" title="Coverage"><img src="https://sonarcloud.io/api/project_badges/measure?project=corona-warn-app_cwa-server&metric=coverage"></a>
12     <a href="https://github.com/corona-warn-app/cwa-server/blob/master/LICENSE" title="License"><img src="https://img.shields.io/badge/License-Apache%202.0-green.svg?style=flat"></a>
13 </p>
14 
15 <p align="center">
16   <a href="#development">Development</a> •
17   <a href="#service-apis">Service APIs</a> •
18   <a href="#documentation">Documentation</a> •
19   <a href="#support-and-feedback">Support</a> •
20   <a href="#how-to-contribute">Contribute</a> •
21   <a href="#contributors">Contributors</a> •
22   <a href="#repositories">Repositories</a> •
23   <a href="#licensing">Licensing</a>
24 </p>
25 
26 The goal of this project is to develop the official Corona-Warn-App for Germany based on the exposure notification API from [Apple](https://www.apple.com/covid19/contacttracing/) and [Google](https://www.google.com/covid19/exposurenotifications/). The apps (for both iOS and Android) use Bluetooth technology to exchange anonymous encrypted data with other mobile phones (on which the app is also installed) in the vicinity of an app user's phone. The data is stored locally on each user's device, preventing authorities or other parties from accessing or controlling the data. This repository contains the **implementation of the server for encryption keys** for the Corona-Warn-App. This implementation is still a **work in progress**, and the code it contains is currently alpha-quality code.
27 
28 In this documentation, Corona-Warn-App services are also referred to as CWA services.
29 
30 ## Architecture Overview
31 
32 You can find the architecture overview [here](/docs/architecture-overview.md), which will give you
33 a good starting point in how the backend services interact with other services, and what purpose
34 they serve.
35 
36 ## Development
37 
38 After you've checked out this repository, you can run the application in one of the following ways:
39 
40 * As a [Docker](https://www.docker.com/)-based deployment on your local machine. You can run either:
41   * Single components using the respective Dockerfile or
42   * The full backend using the Docker Compose (which is considered the most convenient way)
43 * As a [Maven](https://maven.apache.org)-based build on your local machine.
44   If you want to develop something in a single component, this approach is preferable.
45 
46 ### Docker-Based Deployment
47 
48 If you want to use Docker-based deployment, you need to install Docker on your local machine. For more information about downloading and installing Docker, see the [official Docker documentation](https://docs.docker.com/get-docker/).
49 
50 #### Running the Full CWA Backend Using Docker Compose
51 
52 For your convenience, a full setup including the generation of test data has been prepared using [Docker Compose](https://docs.docker.com/compose/reference/overview/). To build the backend services, run ```docker-compose build``` in the repository's root directory. A default configuration file can be found under ```.env```in the root folder of the repository. The default values for the local Postgres and Zenko Cloudserver should be changed in this file before docker-compose is run.
53 
54 Once the services are built, you can start the whole backend using ```docker-compose up```.
55 The distribution service runs once and then finishes. If you want to trigger additional distribution runs, run ```docker-compose run distribution```.
56 
57 The docker-compose contains the following services:
58 
59 Service           | Description | Endpoint and Default Credentials
60 ------------------|-------------|-----------
61 submission        | The Corona-Warn-App submission service                                                      | `http://localhost:8000` <br> `http://localhost:8006` (for actuator endpoint)
62 distribution      | The Corona-Warn-App distribution service                                                    | NO ENDPOINT
63 postgres          | A [postgres] database installation                                                          | `postgres:8001` <br> Username: postgres <br> Password: postgres
64 pgadmin           | A [pgadmin](https://www.pgadmin.org/) installation for the postgres database                | `http://localhost:8002` <br> Username: user@domain.com <br> Password: password
65 cloudserver       | [Zenko CloudServer] is a S3-compliant object store  | `http://localhost:8003/` <br> Access key: accessKey1 <br> Secret key: verySecretKey1
66 verification-fake | A very simple fake implementation for the tan verification.                                 | `http://localhost:8004/version/v1/tan/verify` <br> The only valid tan is "edc07f08-a1aa-11ea-bb37-0242ac130002"
67 
68 ##### Known Limitation
69 
70 In rare cases the docker-compose runs into a timing issue if the distribution service starts before the bucket of the objectstore was created. This is not a big issue as you can simply run ```docker-compose run distribution``` to trigger additional distribution runs after the objectstore was initialized.
71 
72 #### Running Single CWA Services Using Docker
73 
74 If you would like to build and run a single CWA service, it's considered easiest to run them in a Docker environment. You can do this using the script provided in the respective CWA service directory. The Docker script first builds the CWA service and then creates an image for the runtime, which means that there are no additional dependencies for you to install.
75 
76 To build and run the distribution service, run the following command:
77 
78 ```bash
79 ./services/distribution/build_and_run.sh
80 ```
81 
82 To build and run the submission service, run the following command:
83 
84 ```bash
85 ./services/submission/build_and_run.sh
86 ```
87 
88 The submission service is available on [localhost:8080](http://localhost:8080 ).
89 
90 ### Maven-Based Build
91 
92 If you want to actively develop in one of the CWA services, the Maven-based runtime is most suitable.
93 To prepare your machine to run the CWA project locally, we recommend that you first ensure that you've installed the following:
94 
95 * Minimum JDK Version 11: [OpenJDK](https://openjdk.java.net/) / [SapMachine](https://sap.github.io/SapMachine/)
96 * [Maven 3.6](https://maven.apache.org/)
97 * [Postgres]
98 * [Zenko CloudServer]
99 
100 #### Configure
101 
102 After you made sure that the specified dependencies are running, configure them in the respective configuration files.
103 
104 * Configure the Postgres connection in the [submission config](./services/submission/src/main/resources/application.yaml) and in the [distribution config](./services/distribution/src/main/resources/application.yaml)
105 * Configure the S3 compatible object storage in the [distribution config](./services/distribution/src/main/resources/application.yaml)
106 * Configure the private key for the distribution service, the path need to be prefixed with `file:`
107   * `VAULT_FILESIGNING_SECRET` should be the path to the private key, example available in `<repo-root>/docker-compose-test-secrets/private.pem`
108 
109 #### Build
110 
111 After you've checked out the repository, to build the project, run ```mvn install``` in your base directory.
112 
113 #### Run
114 
115 Navigate to the service you want to start and run the spring-boot:run target. The configured Postgres and the configured S3 compliant object storage are used as default. When you start the submission service, the endpoint is available on your local port 8080.
116 
117 If you want to start the submission service, for example, you start it as follows:
118 
119 ```bash
120   cd services/submission/
121   mvn spring-boot:run
122 ```
123 
124 #### Debugging
125 
126 To enable the `DEBUG` log level, you can run the application using the Spring `dev` profile.
127 
128 ```bash
129 mvn spring-boot:run -Dspring-boot.run.profiles=dev
130 ```
131 
132 To be able to set breakpoints (e.g. in IntelliJ), it may be necessary to use the ```-Dspring-boot.run.fork=false``` parameter.
133 
134 ## Service APIs
135 
136 The API that is being exposed by the backend services is documented in an [OpenAPI](https://www.openapis.org/) specification. The specification files are available at the following locations:
137 
138 Service      | OpenAPI Specification
139 -------------|-------------
140 Submission Service        | [services/submission/api_v1.json](https://github.com/corona-warn-app/cwa-server/raw/master/services/submission/api_v1.json)
141 Distribution Service      | [services/distribution/api_v1.json](https://github.com/corona-warn-app/cwa-server/raw/master/services/distribution/api_v1.json)
142 
143 ## Spring Profiles
144 
145 ### Distribution
146 
147 Profile          | Effect
148 -----------------|-------------
149 `dev`            | Turns the log level to `DEBUG`.
150 `cloud`          | Removes default values for the `datasource` and `objectstore` configurations.
151 `demo`           | Includes incomplete days and hours into the distribution run, thus creating aggregates for the current day and the current hour (and including both in the respective indices). When running multiple distributions in one hour with this profile, the date aggregate for today and the hours aggregate for the current hour will be updated and overwritten.
152 `testdata`       | Causes test data to be inserted into the database before each distribution run. By default, around 1000 random diagnosis keys will be generated per hour. If there are no diagnosis keys in the database yet, random keys will be generated for every hour from the beginning of the retention period (14 days ago at 00:00 UTC) until one hour before the present hour. If there are already keys in the database, the random keys will be generated for every hour from the latest diagnosis key in the database (by submission timestamp) until one hour before the present hour (or none at all, if the latest diagnosis key in the database was submitted one hour ago or later).
153 `signature-dev`  | Sets the app package ID in the export packages' signature info to `de.rki.coronawarnapp-dev` so that test certificates (instead of production certificates) will be used for client-side validation.
154 `signature-prod` | Provides production app package IDs for the signature info
155 
156 ### Submission
157 
158 Profile      | Effect
159 -------------|-------------
160 `dev`        | Turns the log level to `DEBUG`.
161 `cloud`      | Removes default values for the `datasource` configuration.
162 
163 ## Documentation
164 
165 The full documentation for the Corona-Warn-App can be found in the [cwa-documentation](https://github.com/corona-warn-app/cwa-documentation) repository. The documentation repository contains technical documents, architecture information, and whitepapers related to this implementation.
166 
167 ## Support and Feedback
168 
169 The following channels are available for discussions, feedback, and support requests:
170 
171 | Type                     | Channel                                                |
172 | ------------------------ | ------------------------------------------------------ |
173 | **General Discussion**   | <a href="https://github.com/corona-warn-app/cwa-documentation/issues/new/choose" title="General Discussion"><img src="https://img.shields.io/github/issues/corona-warn-app/cwa-documentation/question.svg?style=flat-square"></a> </a>   |
174 | **Concept Feedback**    | <a href="https://github.com/corona-warn-app/cwa-documentation/issues/new/choose" title="Open Concept Feedback"><img src="https://img.shields.io/github/issues/corona-warn-app/cwa-documentation/architecture.svg?style=flat-square"></a>  |
175 | **Backend Issue**    | <a href="https://github.com/corona-warn-app/cwa-server/issues/new/choose" title="Open Backend Issue"><img src="https://img.shields.io/github/issues/corona-warn-app/cwa-server?style=flat-square"></a>  |
176 | **Other Requests**    | <a href="mailto:corona-warn-app.opensource@sap.com" title="Email CWA Team"><img src="https://img.shields.io/badge/email-CWA%20team-green?logo=mail.ru&style=flat-square&logoColor=white"></a>   |
177 
178 ## How to Contribute
179 
180 Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our [Contribution Guidelines](./CONTRIBUTING.md). By participating in this project, you agree to abide by its [Code of Conduct](./CODE_OF_CONDUCT.md) at all times.
181 
182 ## Contributors
183 
184 The German government has asked SAP and Deutsche Telekom to develop the Corona-Warn-App for Germany as open source software. Deutsche Telekom is providing the network and mobile technology and will operate and run the backend for the app in a safe, scalable and stable manner. SAP is responsible for the app development, its framework and the underlying platform. Therefore, development teams of SAP and Deutsche Telekom are contributing to this project. At the same time our commitment to open source means that we are enabling -in fact encouraging- all interested parties to contribute and become part of its developer community.
185 
186 ## Repositories
187 
188 The following public repositories are currently available for the Corona-Warn-App:
189 
190 | Repository          | Description                                                           |
191 | ------------------- | --------------------------------------------------------------------- |
192 | [cwa-documentation] | Project overview, general documentation, and white papers            |
193 | [cwa-server]        | Backend implementation for the Apple/Google exposure notification API|
194 | [cwa-verification-server] | Backend implementation of the verification process|
195 
196 [cwa-documentation]: https://github.com/corona-warn-app/cwa-documentation
197 [cwa-server]: https://github.com/corona-warn-app/cwa-server
198 [cwa-verification-server]: https://github.com/corona-warn-app/cwa-verification-server
199 [Postgres]: https://www.postgresql.org/
200 [HSQLDB]: http://hsqldb.org/
201 [Zenko CloudServer]: https://github.com/scality/cloudserver
202 
203 ## Licensing
204 
205 Copyright (c) 2020 SAP SE or an SAP affiliate company.
206 
207 Licensed under the **Apache License, Version 2.0** (the "License"); you may not use this file except in compliance with the License.
208 
209 You may obtain a copy of the License at <https://www.apache.org/licenses/LICENSE-2.0>.
210 
211 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the [LICENSE](./LICENSE) for the specific language governing permissions and limitations under the License.
212 
[end of README.md]
[start of /dev/null]
1 
[end of /dev/null]
[start of README.md]
1 <!-- markdownlint-disable MD041 -->
2 <h1 align="center">
3     Corona-Warn-App Server
4 </h1>
5 
6 <p align="center">
7     <a href="https://github.com/corona-warn-app/cwa-server/commits/" title="Last Commit"><img src="https://img.shields.io/github/last-commit/corona-warn-app/cwa-server?style=flat"></a>
8     <a href="https://github.com/corona-warn-app/cwa-server/issues" title="Open Issues"><img src="https://img.shields.io/github/issues/corona-warn-app/cwa-server?style=flat"></a>
9     <a href="https://circleci.com/gh/corona-warn-app/cwa-server" title="Build Status"><img src="https://circleci.com/gh/corona-warn-app/cwa-server.svg?style=shield&circle-token=4ab059989d10709df19eb4b98ab7c121a25e981a"></a>
10         <a href="https://sonarcloud.io/dashboard?id=corona-warn-app_cwa-server" title="Quality Gate"><img src="https://sonarcloud.io/api/project_badges/measure?project=corona-warn-app_cwa-server&metric=alert_status"></a>
11         <a href="https://sonarcloud.io/component_measures?id=corona-warn-app_cwa-server&metric=Coverage&view=list" title="Coverage"><img src="https://sonarcloud.io/api/project_badges/measure?project=corona-warn-app_cwa-server&metric=coverage"></a>
12     <a href="https://github.com/corona-warn-app/cwa-server/blob/master/LICENSE" title="License"><img src="https://img.shields.io/badge/License-Apache%202.0-green.svg?style=flat"></a>
13 </p>
14 
15 <p align="center">
16   <a href="#development">Development</a> •
17   <a href="#service-apis">Service APIs</a> •
18   <a href="#documentation">Documentation</a> •
19   <a href="#support-and-feedback">Support</a> •
20   <a href="#how-to-contribute">Contribute</a> •
21   <a href="#contributors">Contributors</a> •
22   <a href="#repositories">Repositories</a> •
23   <a href="#licensing">Licensing</a>
24 </p>
25 
26 The goal of this project is to develop the official Corona-Warn-App for Germany based on the exposure notification API from [Apple](https://www.apple.com/covid19/contacttracing/) and [Google](https://www.google.com/covid19/exposurenotifications/). The apps (for both iOS and Android) use Bluetooth technology to exchange anonymous encrypted data with other mobile phones (on which the app is also installed) in the vicinity of an app user's phone. The data is stored locally on each user's device, preventing authorities or other parties from accessing or controlling the data. This repository contains the **implementation of the server for encryption keys** for the Corona-Warn-App. This implementation is still a **work in progress**, and the code it contains is currently alpha-quality code.
27 
28 In this documentation, Corona-Warn-App services are also referred to as CWA services.
29 
30 ## Architecture Overview
31 
32 You can find the architecture overview [here](/docs/architecture-overview.md), which will give you
33 a good starting point in how the backend services interact with other services, and what purpose
34 they serve.
35 
36 ## Development
37 
38 After you've checked out this repository, you can run the application in one of the following ways:
39 
40 * As a [Docker](https://www.docker.com/)-based deployment on your local machine. You can run either:
41   * Single components using the respective Dockerfile or
42   * The full backend using the Docker Compose (which is considered the most convenient way)
43 * As a [Maven](https://maven.apache.org)-based build on your local machine.
44   If you want to develop something in a single component, this approach is preferable.
45 
46 ### Docker-Based Deployment
47 
48 If you want to use Docker-based deployment, you need to install Docker on your local machine. For more information about downloading and installing Docker, see the [official Docker documentation](https://docs.docker.com/get-docker/).
49 
50 #### Running the Full CWA Backend Using Docker Compose
51 
52 For your convenience, a full setup including the generation of test data has been prepared using [Docker Compose](https://docs.docker.com/compose/reference/overview/). To build the backend services, run ```docker-compose build``` in the repository's root directory. A default configuration file can be found under ```.env```in the root folder of the repository. The default values for the local Postgres and Zenko Cloudserver should be changed in this file before docker-compose is run.
53 
54 Once the services are built, you can start the whole backend using ```docker-compose up```.
55 The distribution service runs once and then finishes. If you want to trigger additional distribution runs, run ```docker-compose run distribution```.
56 
57 The docker-compose contains the following services:
58 
59 Service           | Description | Endpoint and Default Credentials
60 ------------------|-------------|-----------
61 submission        | The Corona-Warn-App submission service                                                      | `http://localhost:8000` <br> `http://localhost:8006` (for actuator endpoint)
62 distribution      | The Corona-Warn-App distribution service                                                    | NO ENDPOINT
63 postgres          | A [postgres] database installation                                                          | `postgres:8001` <br> Username: postgres <br> Password: postgres
64 pgadmin           | A [pgadmin](https://www.pgadmin.org/) installation for the postgres database                | `http://localhost:8002` <br> Username: user@domain.com <br> Password: password
65 cloudserver       | [Zenko CloudServer] is a S3-compliant object store  | `http://localhost:8003/` <br> Access key: accessKey1 <br> Secret key: verySecretKey1
66 verification-fake | A very simple fake implementation for the tan verification.                                 | `http://localhost:8004/version/v1/tan/verify` <br> The only valid tan is "edc07f08-a1aa-11ea-bb37-0242ac130002"
67 
68 ##### Known Limitation
69 
70 In rare cases the docker-compose runs into a timing issue if the distribution service starts before the bucket of the objectstore was created. This is not a big issue as you can simply run ```docker-compose run distribution``` to trigger additional distribution runs after the objectstore was initialized.
71 
72 #### Running Single CWA Services Using Docker
73 
74 If you would like to build and run a single CWA service, it's considered easiest to run them in a Docker environment. You can do this using the script provided in the respective CWA service directory. The Docker script first builds the CWA service and then creates an image for the runtime, which means that there are no additional dependencies for you to install.
75 
76 To build and run the distribution service, run the following command:
77 
78 ```bash
79 ./services/distribution/build_and_run.sh
80 ```
81 
82 To build and run the submission service, run the following command:
83 
84 ```bash
85 ./services/submission/build_and_run.sh
86 ```
87 
88 The submission service is available on [localhost:8080](http://localhost:8080 ).
89 
90 ### Maven-Based Build
91 
92 If you want to actively develop in one of the CWA services, the Maven-based runtime is most suitable.
93 To prepare your machine to run the CWA project locally, we recommend that you first ensure that you've installed the following:
94 
95 * Minimum JDK Version 11: [OpenJDK](https://openjdk.java.net/) / [SapMachine](https://sap.github.io/SapMachine/)
96 * [Maven 3.6](https://maven.apache.org/)
97 * [Postgres]
98 * [Zenko CloudServer]
99 
100 #### Configure
101 
102 After you made sure that the specified dependencies are running, configure them in the respective configuration files.
103 
104 * Configure the Postgres connection in the [submission config](./services/submission/src/main/resources/application.yaml) and in the [distribution config](./services/distribution/src/main/resources/application.yaml)
105 * Configure the S3 compatible object storage in the [distribution config](./services/distribution/src/main/resources/application.yaml)
106 * Configure the private key for the distribution service, the path need to be prefixed with `file:`
107   * `VAULT_FILESIGNING_SECRET` should be the path to the private key, example available in `<repo-root>/docker-compose-test-secrets/private.pem`
108 
109 #### Build
110 
111 After you've checked out the repository, to build the project, run ```mvn install``` in your base directory.
112 
113 #### Run
114 
115 Navigate to the service you want to start and run the spring-boot:run target. The configured Postgres and the configured S3 compliant object storage are used as default. When you start the submission service, the endpoint is available on your local port 8080.
116 
117 If you want to start the submission service, for example, you start it as follows:
118 
119 ```bash
120   cd services/submission/
121   mvn spring-boot:run
122 ```
123 
124 #### Debugging
125 
126 To enable the `DEBUG` log level, you can run the application using the Spring `dev` profile.
127 
128 ```bash
129 mvn spring-boot:run -Dspring-boot.run.profiles=dev
130 ```
131 
132 To be able to set breakpoints (e.g. in IntelliJ), it may be necessary to use the ```-Dspring-boot.run.fork=false``` parameter.
133 
134 ## Service APIs
135 
136 The API that is being exposed by the backend services is documented in an [OpenAPI](https://www.openapis.org/) specification. The specification files are available at the following locations:
137 
138 Service      | OpenAPI Specification
139 -------------|-------------
140 Submission Service        | [services/submission/api_v1.json](https://github.com/corona-warn-app/cwa-server/raw/master/services/submission/api_v1.json)
141 Distribution Service      | [services/distribution/api_v1.json](https://github.com/corona-warn-app/cwa-server/raw/master/services/distribution/api_v1.json)
142 
143 ## Spring Profiles
144 
145 ### Distribution
146 
147 Profile          | Effect
148 -----------------|-------------
149 `dev`            | Turns the log level to `DEBUG`.
150 `cloud`          | Removes default values for the `datasource` and `objectstore` configurations.
151 `demo`           | Includes incomplete days and hours into the distribution run, thus creating aggregates for the current day and the current hour (and including both in the respective indices). When running multiple distributions in one hour with this profile, the date aggregate for today and the hours aggregate for the current hour will be updated and overwritten.
152 `testdata`       | Causes test data to be inserted into the database before each distribution run. By default, around 1000 random diagnosis keys will be generated per hour. If there are no diagnosis keys in the database yet, random keys will be generated for every hour from the beginning of the retention period (14 days ago at 00:00 UTC) until one hour before the present hour. If there are already keys in the database, the random keys will be generated for every hour from the latest diagnosis key in the database (by submission timestamp) until one hour before the present hour (or none at all, if the latest diagnosis key in the database was submitted one hour ago or later).
153 `signature-dev`  | Sets the app package ID in the export packages' signature info to `de.rki.coronawarnapp-dev` so that test certificates (instead of production certificates) will be used for client-side validation.
154 `signature-prod` | Provides production app package IDs for the signature info
155 
156 ### Submission
157 
158 Profile      | Effect
159 -------------|-------------
160 `dev`        | Turns the log level to `DEBUG`.
161 `cloud`      | Removes default values for the `datasource` configuration.
162 
163 ## Documentation
164 
165 The full documentation for the Corona-Warn-App can be found in the [cwa-documentation](https://github.com/corona-warn-app/cwa-documentation) repository. The documentation repository contains technical documents, architecture information, and whitepapers related to this implementation.
166 
167 ## Support and Feedback
168 
169 The following channels are available for discussions, feedback, and support requests:
170 
171 | Type                     | Channel                                                |
172 | ------------------------ | ------------------------------------------------------ |
173 | **General Discussion**   | <a href="https://github.com/corona-warn-app/cwa-documentation/issues/new/choose" title="General Discussion"><img src="https://img.shields.io/github/issues/corona-warn-app/cwa-documentation/question.svg?style=flat-square"></a> </a>   |
174 | **Concept Feedback**    | <a href="https://github.com/corona-warn-app/cwa-documentation/issues/new/choose" title="Open Concept Feedback"><img src="https://img.shields.io/github/issues/corona-warn-app/cwa-documentation/architecture.svg?style=flat-square"></a>  |
175 | **Backend Issue**    | <a href="https://github.com/corona-warn-app/cwa-server/issues/new/choose" title="Open Backend Issue"><img src="https://img.shields.io/github/issues/corona-warn-app/cwa-server?style=flat-square"></a>  |
176 | **Other Requests**    | <a href="mailto:corona-warn-app.opensource@sap.com" title="Email CWA Team"><img src="https://img.shields.io/badge/email-CWA%20team-green?logo=mail.ru&style=flat-square&logoColor=white"></a>   |
177 
178 ## How to Contribute
179 
180 Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our [Contribution Guidelines](./CONTRIBUTING.md). By participating in this project, you agree to abide by its [Code of Conduct](./CODE_OF_CONDUCT.md) at all times.
181 
182 ## Contributors
183 
184 The German government has asked SAP and Deutsche Telekom to develop the Corona-Warn-App for Germany as open source software. Deutsche Telekom is providing the network and mobile technology and will operate and run the backend for the app in a safe, scalable and stable manner. SAP is responsible for the app development, its framework and the underlying platform. Therefore, development teams of SAP and Deutsche Telekom are contributing to this project. At the same time our commitment to open source means that we are enabling -in fact encouraging- all interested parties to contribute and become part of its developer community.
185 
186 ## Repositories
187 
188 The following public repositories are currently available for the Corona-Warn-App:
189 
190 | Repository          | Description                                                           |
191 | ------------------- | --------------------------------------------------------------------- |
192 | [cwa-documentation] | Project overview, general documentation, and white papers            |
193 | [cwa-server]        | Backend implementation for the Apple/Google exposure notification API|
194 | [cwa-verification-server] | Backend implementation of the verification process|
195 
196 [cwa-documentation]: https://github.com/corona-warn-app/cwa-documentation
197 [cwa-server]: https://github.com/corona-warn-app/cwa-server
198 [cwa-verification-server]: https://github.com/corona-warn-app/cwa-verification-server
199 [Postgres]: https://www.postgresql.org/
200 [HSQLDB]: http://hsqldb.org/
201 [Zenko CloudServer]: https://github.com/scality/cloudserver
202 
203 ## Licensing
204 
205 Copyright (c) 2020 SAP SE or an SAP affiliate company.
206 
207 Licensed under the **Apache License, Version 2.0** (the "License"); you may not use this file except in compliance with the License.
208 
209 You may obtain a copy of the License at <https://www.apache.org/licenses/LICENSE-2.0>.
210 
211 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the [LICENSE](./LICENSE) for the specific language governing permissions and limitations under the License.
212 
[end of README.md]
[start of services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/component/DiagnosisKeysStructureProvider.java]
1 /*-
2  * ---license-start
3  * Corona-Warn-App
4  * ---
5  * Copyright (C) 2020 SAP SE and all other contributors
6  * ---
7  * Licensed under the Apache License, Version 2.0 (the "License");
8  * you may not use this file except in compliance with the License.
9  * You may obtain a copy of the License at
10  *
11  *      http://www.apache.org/licenses/LICENSE-2.0
12  *
13  * Unless required by applicable law or agreed to in writing, software
14  * distributed under the License is distributed on an "AS IS" BASIS,
15  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16  * See the License for the specific language governing permissions and
17  * limitations under the License.
18  * ---license-end
19  */
20 
21 package app.coronawarn.server.services.distribution.assembly.component;
22 
23 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
24 import app.coronawarn.server.common.persistence.service.DiagnosisKeyService;
25 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory.DiagnosisKeysDirectory;
26 import app.coronawarn.server.services.distribution.assembly.structure.WritableOnDisk;
27 import app.coronawarn.server.services.distribution.assembly.structure.directory.Directory;
28 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
29 import java.util.Collection;
30 import org.slf4j.Logger;
31 import org.slf4j.LoggerFactory;
32 import org.springframework.stereotype.Component;
33 
34 /**
35  * Retrieves stored diagnosis keys and builds a {@link DiagnosisKeysDirectory} with them.
36  */
37 @Component
38 public class DiagnosisKeysStructureProvider {
39 
40   private static final Logger logger = LoggerFactory
41       .getLogger(DiagnosisKeysStructureProvider.class);
42 
43   private final DiagnosisKeyService diagnosisKeyService;
44   private final CryptoProvider cryptoProvider;
45   private final DistributionServiceConfig distributionServiceConfig;
46 
47   /**
48    * Creates a new DiagnosisKeysStructureProvider.
49    */
50   DiagnosisKeysStructureProvider(DiagnosisKeyService diagnosisKeyService, CryptoProvider cryptoProvider,
51       DistributionServiceConfig distributionServiceConfig) {
52     this.diagnosisKeyService = diagnosisKeyService;
53     this.cryptoProvider = cryptoProvider;
54     this.distributionServiceConfig = distributionServiceConfig;
55   }
56 
57   /**
58    * Get directory for diagnosis keys from database.
59    * @return the directory
60    */
61   public Directory<WritableOnDisk> getDiagnosisKeys() {
62     logger.debug("Querying diagnosis keys from the database...");
63     Collection<DiagnosisKey> diagnosisKeys = diagnosisKeyService.getDiagnosisKeys();
64     return new DiagnosisKeysDirectory(diagnosisKeys, cryptoProvider, distributionServiceConfig);
65   }
66 }
67 
[end of services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/component/DiagnosisKeysStructureProvider.java]
[start of services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysCountryDirectory.java]
1 /*-
2  * ---license-start
3  * Corona-Warn-App
4  * ---
5  * Copyright (C) 2020 SAP SE and all other contributors
6  * ---
7  * Licensed under the Apache License, Version 2.0 (the "License");
8  * you may not use this file except in compliance with the License.
9  * You may obtain a copy of the License at
10  *
11  *      http://www.apache.org/licenses/LICENSE-2.0
12  *
13  * Unless required by applicable law or agreed to in writing, software
14  * distributed under the License is distributed on an "AS IS" BASIS,
15  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16  * See the License for the specific language governing permissions and
17  * limitations under the License.
18  * ---license-end
19  */
20 
21 package app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory;
22 
23 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
24 import app.coronawarn.server.services.distribution.assembly.component.CryptoProvider;
25 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory.decorator.DateAggregatingDecorator;
26 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory.decorator.DateIndexingDecorator;
27 import app.coronawarn.server.services.distribution.assembly.structure.WritableOnDisk;
28 import app.coronawarn.server.services.distribution.assembly.structure.directory.IndexDirectory;
29 import app.coronawarn.server.services.distribution.assembly.structure.directory.IndexDirectoryOnDisk;
30 import app.coronawarn.server.services.distribution.assembly.structure.util.ImmutableStack;
31 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
32 import java.time.LocalDate;
33 import java.util.Collection;
34 import java.util.Set;
35 
36 public class DiagnosisKeysCountryDirectory extends IndexDirectoryOnDisk<String> {
37 
38   private final Collection<DiagnosisKey> diagnosisKeys;
39   private final CryptoProvider cryptoProvider;
40   private final DistributionServiceConfig distributionServiceConfig;
41 
42   /**
43    * Constructs a {@link DiagnosisKeysCountryDirectory} instance that represents the {@code .../country/:country/...}
44    * portion of the diagnosis key directory structure.
45    *
46    * @param diagnosisKeys  The diagnosis keys processed in the contained sub directories.
47    * @param cryptoProvider The {@link CryptoProvider} used for payload signing.
48    */
49   public DiagnosisKeysCountryDirectory(Collection<DiagnosisKey> diagnosisKeys,
50       CryptoProvider cryptoProvider, DistributionServiceConfig distributionServiceConfig) {
51     super(distributionServiceConfig.getApi().getCountryPath(), __ ->
52         Set.of(distributionServiceConfig.getApi().getCountryGermany()), Object::toString);
53     this.diagnosisKeys = diagnosisKeys;
54     this.cryptoProvider = cryptoProvider;
55     this.distributionServiceConfig = distributionServiceConfig;
56   }
57 
58   @Override
59   public void prepare(ImmutableStack<Object> indices) {
60     this.addWritableToAll(__ -> {
61       DiagnosisKeysDateDirectory dateDirectory = new DiagnosisKeysDateDirectory(diagnosisKeys, cryptoProvider,
62           distributionServiceConfig);
63       return decorateDateDirectory(dateDirectory);
64     });
65     super.prepare(indices);
66   }
67 
68   private IndexDirectory<LocalDate, WritableOnDisk> decorateDateDirectory(DiagnosisKeysDateDirectory dateDirectory) {
69     return new DateAggregatingDecorator(new DateIndexingDecorator(dateDirectory, distributionServiceConfig),
70         cryptoProvider, distributionServiceConfig);
71   }
72 }
73 
[end of services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysCountryDirectory.java]
[start of services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDateDirectory.java]
1 /*-
2  * ---license-start
3  * Corona-Warn-App
4  * ---
5  * Copyright (C) 2020 SAP SE and all other contributors
6  * ---
7  * Licensed under the Apache License, Version 2.0 (the "License");
8  * you may not use this file except in compliance with the License.
9  * You may obtain a copy of the License at
10  *
11  *      http://www.apache.org/licenses/LICENSE-2.0
12  *
13  * Unless required by applicable law or agreed to in writing, software
14  * distributed under the License is distributed on an "AS IS" BASIS,
15  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16  * See the License for the specific language governing permissions and
17  * limitations under the License.
18  * ---license-end
19  */
20 
21 package app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory;
22 
23 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
24 import app.coronawarn.server.services.distribution.assembly.component.CryptoProvider;
25 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory.decorator.HourIndexingDecorator;
26 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.util.DateTime;
27 import app.coronawarn.server.services.distribution.assembly.structure.WritableOnDisk;
28 import app.coronawarn.server.services.distribution.assembly.structure.directory.Directory;
29 import app.coronawarn.server.services.distribution.assembly.structure.directory.IndexDirectoryOnDisk;
30 import app.coronawarn.server.services.distribution.assembly.structure.util.ImmutableStack;
31 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
32 import java.time.LocalDate;
33 import java.time.format.DateTimeFormatter;
34 import java.util.Collection;
35 
36 public class DiagnosisKeysDateDirectory extends IndexDirectoryOnDisk<LocalDate> {
37 
38   private static final DateTimeFormatter ISO8601 = DateTimeFormatter.ofPattern("yyyy-MM-dd");
39 
40   private final Collection<DiagnosisKey> diagnosisKeys;
41   private final CryptoProvider cryptoProvider;
42   private final DistributionServiceConfig distributionServiceConfig;
43 
44   /**
45    * Constructs a {@link DiagnosisKeysDateDirectory} instance associated with the specified {@link DiagnosisKey}
46    * collection. Payload signing is be performed according to the specified {@link CryptoProvider}.
47    *
48    * @param diagnosisKeys  The diagnosis keys processed in the contained directories.
49    * @param cryptoProvider The {@link CryptoProvider} used for payload signing.
50    */
51   public DiagnosisKeysDateDirectory(Collection<DiagnosisKey> diagnosisKeys,
52       CryptoProvider cryptoProvider, DistributionServiceConfig distributionServiceConfig) {
53     super(distributionServiceConfig.getApi().getDatePath(), __ -> DateTime.getDates(diagnosisKeys), ISO8601::format);
54     this.cryptoProvider = cryptoProvider;
55     this.diagnosisKeys = diagnosisKeys;
56     this.distributionServiceConfig = distributionServiceConfig;
57   }
58 
59   @Override
60   public void prepare(ImmutableStack<Object> indices) {
61     this.addWritableToAll(__ -> {
62       DiagnosisKeysHourDirectory hourDirectory =
63           new DiagnosisKeysHourDirectory(diagnosisKeys, cryptoProvider, distributionServiceConfig);
64       return decorateHourDirectory(hourDirectory);
65     });
66     super.prepare(indices);
67   }
68 
69   private Directory<WritableOnDisk> decorateHourDirectory(DiagnosisKeysHourDirectory hourDirectory) {
70     return new HourIndexingDecorator(hourDirectory, distributionServiceConfig);
71   }
72 }
73 
[end of services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDateDirectory.java]
[start of services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectory.java]
1 /*-
2  * ---license-start
3  * Corona-Warn-App
4  * ---
5  * Copyright (C) 2020 SAP SE and all other contributors
6  * ---
7  * Licensed under the Apache License, Version 2.0 (the "License");
8  * you may not use this file except in compliance with the License.
9  * You may obtain a copy of the License at
10  *
11  *      http://www.apache.org/licenses/LICENSE-2.0
12  *
13  * Unless required by applicable law or agreed to in writing, software
14  * distributed under the License is distributed on an "AS IS" BASIS,
15  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16  * See the License for the specific language governing permissions and
17  * limitations under the License.
18  * ---license-end
19  */
20 
21 package app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory;
22 
23 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
24 import app.coronawarn.server.services.distribution.assembly.component.CryptoProvider;
25 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory.decorator.CountryIndexingDecorator;
26 import app.coronawarn.server.services.distribution.assembly.structure.WritableOnDisk;
27 import app.coronawarn.server.services.distribution.assembly.structure.directory.Directory;
28 import app.coronawarn.server.services.distribution.assembly.structure.directory.DirectoryOnDisk;
29 import app.coronawarn.server.services.distribution.assembly.structure.directory.IndexDirectory;
30 import app.coronawarn.server.services.distribution.assembly.structure.directory.IndexDirectoryOnDisk;
31 import app.coronawarn.server.services.distribution.assembly.structure.directory.decorator.indexing.IndexingDecoratorOnDisk;
32 import app.coronawarn.server.services.distribution.assembly.structure.util.ImmutableStack;
33 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
34 import java.util.Collection;
35 
36 /**
37  * A {@link Directory} containing the file and directory structure that mirrors the API defined in the OpenAPI
38  * definition {@code /services/distribution/api_v1.json}. Available countries (endpoint {@code
39  * /version/v1/diagnosis-keys/country}) are statically set to only {@code "DE"}. The dates and respective hours
40  * (endpoint {@code /version/v1/diagnosis-keys/country/DE/date}) will be created based on the actual {@link DiagnosisKey
41  * DiagnosisKeys} given to the {@link DiagnosisKeysDirectory#DiagnosisKeysDirectory constructor}.
42  */
43 public class DiagnosisKeysDirectory extends DirectoryOnDisk {
44 
45   private final Collection<DiagnosisKey> diagnosisKeys;
46   private final CryptoProvider cryptoProvider;
47   private final DistributionServiceConfig distributionServiceConfig;
48 
49   /**
50    * Constructs a {@link DiagnosisKeysDirectory} based on the specified {@link DiagnosisKey} collection. Cryptographic
51    * signing is performed using the specified {@link CryptoProvider}.
52    *
53    * @param diagnosisKeys  The diagnosis keys processed in the contained sub directories.
54    * @param cryptoProvider The {@link CryptoProvider} used for payload signing.
55    */
56   public DiagnosisKeysDirectory(Collection<DiagnosisKey> diagnosisKeys, CryptoProvider cryptoProvider,
57       DistributionServiceConfig distributionServiceConfig) {
58     super(distributionServiceConfig.getApi().getDiagnosisKeysPath());
59     this.diagnosisKeys = diagnosisKeys;
60     this.cryptoProvider = cryptoProvider;
61     this.distributionServiceConfig = distributionServiceConfig;
62   }
63 
64   @Override
65   public void prepare(ImmutableStack<Object> indices) {
66     this.addWritable(decorateCountryDirectory(
67         new DiagnosisKeysCountryDirectory(diagnosisKeys, cryptoProvider, distributionServiceConfig)));
68     super.prepare(indices);
69   }
70 
71   private IndexDirectory<String, WritableOnDisk> decorateCountryDirectory(
72       IndexDirectoryOnDisk<String> countryDirectory) {
73     return new CountryIndexingDecorator<>(
74         new IndexingDecoratorOnDisk<>(countryDirectory, distributionServiceConfig.getOutputFileName()),
75         distributionServiceConfig);
76   }
77 }
78 
[end of services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectory.java]
[start of services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysHourDirectory.java]
1 /*-
2  * ---license-start
3  * Corona-Warn-App
4  * ---
5  * Copyright (C) 2020 SAP SE and all other contributors
6  * ---
7  * Licensed under the Apache License, Version 2.0 (the "License");
8  * you may not use this file except in compliance with the License.
9  * You may obtain a copy of the License at
10  *
11  *      http://www.apache.org/licenses/LICENSE-2.0
12  *
13  * Unless required by applicable law or agreed to in writing, software
14  * distributed under the License is distributed on an "AS IS" BASIS,
15  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16  * See the License for the specific language governing permissions and
17  * limitations under the License.
18  * ---license-end
19  */
20 
21 package app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory;
22 
23 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
24 import app.coronawarn.server.services.distribution.assembly.component.CryptoProvider;
25 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.archive.decorator.singing.DiagnosisKeySigningDecorator;
26 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.file.TemporaryExposureKeyExportFile;
27 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.util.DateTime;
28 import app.coronawarn.server.services.distribution.assembly.structure.WritableOnDisk;
29 import app.coronawarn.server.services.distribution.assembly.structure.archive.Archive;
30 import app.coronawarn.server.services.distribution.assembly.structure.archive.ArchiveOnDisk;
31 import app.coronawarn.server.services.distribution.assembly.structure.directory.Directory;
32 import app.coronawarn.server.services.distribution.assembly.structure.directory.IndexDirectoryOnDisk;
33 import app.coronawarn.server.services.distribution.assembly.structure.file.File;
34 import app.coronawarn.server.services.distribution.assembly.structure.util.ImmutableStack;
35 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
36 import java.time.LocalDate;
37 import java.time.LocalDateTime;
38 import java.time.ZoneOffset;
39 import java.util.Collection;
40 import java.util.Set;
41 import java.util.stream.Collectors;
42 
43 public class DiagnosisKeysHourDirectory extends IndexDirectoryOnDisk<LocalDateTime> {
44 
45   private final Collection<DiagnosisKey> diagnosisKeys;
46   private final CryptoProvider cryptoProvider;
47   private final DistributionServiceConfig distributionServiceConfig;
48 
49   /**
50    * Constructs a {@link DiagnosisKeysHourDirectory} instance for the specified date.
51    *
52    * @param diagnosisKeys  A collection of diagnosis keys. These will be filtered according to the specified current
53    *                       date.
54    * @param cryptoProvider The {@link CryptoProvider} used for cryptographic signing.
55    */
56   public DiagnosisKeysHourDirectory(Collection<DiagnosisKey> diagnosisKeys, CryptoProvider cryptoProvider,
57       DistributionServiceConfig distributionServiceConfig) {
58     super(distributionServiceConfig.getApi().getHourPath(),
59         indices -> DateTime.getHours(((LocalDate) indices.peek()), diagnosisKeys), LocalDateTime::getHour);
60     this.diagnosisKeys = diagnosisKeys;
61     this.cryptoProvider = cryptoProvider;
62     this.distributionServiceConfig = distributionServiceConfig;
63   }
64 
65   @Override
66   public void prepare(ImmutableStack<Object> indices) {
67     this.addWritableToAll(currentIndices -> {
68       LocalDateTime currentHour = (LocalDateTime) currentIndices.peek();
69       // The LocalDateTime currentHour already contains both the date and the hour information, so
70       // we can throw away the LocalDate that's the second item on the stack from the "/date"
71       // IndexDirectory.
72       String region = (String) currentIndices.pop().pop().peek();
73 
74       Set<DiagnosisKey> diagnosisKeysForCurrentHour = getDiagnosisKeysForHour(currentHour);
75 
76       long startTimestamp = currentHour.toEpochSecond(ZoneOffset.UTC);
77       long endTimestamp = currentHour.plusHours(1).toEpochSecond(ZoneOffset.UTC);
78       File<WritableOnDisk> temporaryExposureKeyExportFile = TemporaryExposureKeyExportFile.fromDiagnosisKeys(
79           diagnosisKeysForCurrentHour, region, startTimestamp, endTimestamp, distributionServiceConfig);
80 
81       Archive<WritableOnDisk> hourArchive = new ArchiveOnDisk(distributionServiceConfig.getOutputFileName());
82       hourArchive.addWritable(temporaryExposureKeyExportFile);
83 
84       return decorateDiagnosisKeyArchive(hourArchive);
85     });
86     super.prepare(indices);
87   }
88 
89   private Set<DiagnosisKey> getDiagnosisKeysForHour(LocalDateTime hour) {
90     return this.diagnosisKeys.stream()
91         .filter(diagnosisKey -> DistributionDateTimeCalculator.getDistributionDateTime(diagnosisKey).equals(hour))
92         .collect(Collectors.toSet());
93   }
94 
95   private Directory<WritableOnDisk> decorateDiagnosisKeyArchive(Archive<WritableOnDisk> archive) {
96     return new DiagnosisKeySigningDecorator(archive, cryptoProvider, distributionServiceConfig);
97   }
98 }
99 
[end of services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysHourDirectory.java]
[start of services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DistributionDateTimeCalculator.java]
1 /*-
2  * ---license-start
3  * Corona-Warn-App
4  * ---
5  * Copyright (C) 2020 SAP SE and all other contributors
6  * ---
7  * Licensed under the Apache License, Version 2.0 (the "License");
8  * you may not use this file except in compliance with the License.
9  * You may obtain a copy of the License at
10  *
11  *      http://www.apache.org/licenses/LICENSE-2.0
12  *
13  * Unless required by applicable law or agreed to in writing, software
14  * distributed under the License is distributed on an "AS IS" BASIS,
15  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16  * See the License for the specific language governing permissions and
17  * limitations under the License.
18  * ---license-end
19  */
20 
21 package app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory;
22 
23 import static app.coronawarn.server.services.distribution.assembly.diagnosiskeys.util.DateTime.ONE_HOUR_INTERVAL_SECONDS;
24 import static app.coronawarn.server.services.distribution.assembly.diagnosiskeys.util.DateTime.TEN_MINUTES_INTERVAL_SECONDS;
25 import static java.time.ZoneOffset.UTC;
26 
27 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
28 import java.time.Duration;
29 import java.time.LocalDateTime;
30 import java.time.temporal.ChronoUnit;
31 
32 public class DistributionDateTimeCalculator {
33 
34   /**
35    * Minimum time in minutes after key expiration after which it can be distributed.
36    */
37   public static final long DISTRIBUTION_PADDING = 120L;
38 
39   private DistributionDateTimeCalculator() {
40   }
41 
42   /**
43    * Calculates the earliest point in time at which the specified {@link DiagnosisKey} can be distributed. Before keys
44    * are allowed to be distributed, they must be expired for a configured amount of time.
45    *
46    * @return {@link LocalDateTime} at which the specified {@link DiagnosisKey} can be distributed.
47    */
48   public static LocalDateTime getDistributionDateTime(DiagnosisKey diagnosisKey) {
49     LocalDateTime submissionDateTime = LocalDateTime
50         .ofEpochSecond(diagnosisKey.getSubmissionTimestamp() * ONE_HOUR_INTERVAL_SECONDS, 0, UTC);
51     LocalDateTime keyExpiryDateTime = LocalDateTime
52         .ofEpochSecond(diagnosisKey.getRollingStartIntervalNumber() * TEN_MINUTES_INTERVAL_SECONDS, 0, UTC)
53         .plusMinutes(diagnosisKey.getRollingPeriod() * 10L);
54 
55     if (Duration.between(keyExpiryDateTime, submissionDateTime).toMinutes() <= DISTRIBUTION_PADDING) {
56       // truncatedTo floors the value, so we need to add an hour to the DISTRIBUTION_PADDING to compensate that.
57       return keyExpiryDateTime.plusMinutes(DISTRIBUTION_PADDING + 60).truncatedTo(ChronoUnit.HOURS);
58     }
59 
60     return submissionDateTime;
61   }
62 }
63 
[end of services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DistributionDateTimeCalculator.java]
[start of services/distribution/src/main/java/app/coronawarn/server/services/distribution/config/DistributionServiceConfig.java]
1 /*-
2  * ---license-start
3  * Corona-Warn-App
4  * ---
5  * Copyright (C) 2020 SAP SE and all other contributors
6  * ---
7  * Licensed under the Apache License, Version 2.0 (the "License");
8  * you may not use this file except in compliance with the License.
9  * You may obtain a copy of the License at
10  *
11  *      http://www.apache.org/licenses/LICENSE-2.0
12  *
13  * Unless required by applicable law or agreed to in writing, software
14  * distributed under the License is distributed on an "AS IS" BASIS,
15  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16  * See the License for the specific language governing permissions and
17  * limitations under the License.
18  * ---license-end
19  */
20 
21 package app.coronawarn.server.services.distribution.config;
22 
23 import app.coronawarn.server.common.protocols.external.exposurenotification.SignatureInfo;
24 import org.springframework.boot.context.properties.ConfigurationProperties;
25 import org.springframework.stereotype.Component;
26 
27 @Component
28 @ConfigurationProperties(prefix = "services.distribution")
29 public class DistributionServiceConfig {
30 
31   private Paths paths;
32   private TestData testData;
33   private Integer retentionDays;
34   private String outputFileName;
35   private Boolean includeIncompleteDays;
36   private Boolean includeIncompleteHours;
37   private TekExport tekExport;
38   private Signature signature;
39   private Api api;
40   private ObjectStore objectStore;
41 
42   public Paths getPaths() {
43     return paths;
44   }
45 
46   public void setPaths(Paths paths) {
47     this.paths = paths;
48   }
49 
50   public TestData getTestData() {
51     return testData;
52   }
53 
54   public void setTestData(TestData testData) {
55     this.testData = testData;
56   }
57 
58   public Integer getRetentionDays() {
59     return retentionDays;
60   }
61 
62   public void setRetentionDays(Integer retentionDays) {
63     this.retentionDays = retentionDays;
64   }
65 
66   public String getOutputFileName() {
67     return outputFileName;
68   }
69 
70   public void setOutputFileName(String outputFileName) {
71     this.outputFileName = outputFileName;
72   }
73 
74   public Boolean getIncludeIncompleteDays() {
75     return includeIncompleteDays;
76   }
77 
78   public void setIncludeIncompleteDays(Boolean includeIncompleteDays) {
79     this.includeIncompleteDays = includeIncompleteDays;
80   }
81 
82   public Boolean getIncludeIncompleteHours() {
83     return includeIncompleteHours;
84   }
85 
86   public void setIncludeIncompleteHours(Boolean includeIncompleteHours) {
87     this.includeIncompleteHours = includeIncompleteHours;
88   }
89 
90   public TekExport getTekExport() {
91     return tekExport;
92   }
93 
94   public void setTekExport(TekExport tekExport) {
95     this.tekExport = tekExport;
96   }
97 
98   public Signature getSignature() {
99     return signature;
100   }
101 
102   public void setSignature(Signature signature) {
103     this.signature = signature;
104   }
105 
106   public Api getApi() {
107     return api;
108   }
109 
110   public void setApi(Api api) {
111     this.api = api;
112   }
113 
114   public ObjectStore getObjectStore() {
115     return objectStore;
116   }
117 
118   public void setObjectStore(
119       ObjectStore objectStore) {
120     this.objectStore = objectStore;
121   }
122 
123   public static class TekExport {
124 
125     private String fileName;
126     private String fileHeader;
127     private Integer fileHeaderWidth;
128 
129     public String getFileName() {
130       return fileName;
131     }
132 
133     public void setFileName(String fileName) {
134       this.fileName = fileName;
135     }
136 
137     public String getFileHeader() {
138       return fileHeader;
139     }
140 
141     public void setFileHeader(String fileHeader) {
142       this.fileHeader = fileHeader;
143     }
144 
145     public Integer getFileHeaderWidth() {
146       return fileHeaderWidth;
147     }
148 
149     public void setFileHeaderWidth(Integer fileHeaderWidth) {
150       this.fileHeaderWidth = fileHeaderWidth;
151     }
152   }
153 
154   public static class TestData {
155 
156     private Integer seed;
157     private Integer exposuresPerHour;
158 
159     public Integer getSeed() {
160       return seed;
161     }
162 
163     public void setSeed(Integer seed) {
164       this.seed = seed;
165     }
166 
167     public Integer getExposuresPerHour() {
168       return exposuresPerHour;
169     }
170 
171     public void setExposuresPerHour(Integer exposuresPerHour) {
172       this.exposuresPerHour = exposuresPerHour;
173     }
174   }
175 
176   public static class Paths {
177 
178     private String privateKey;
179     private String output;
180 
181     public String getPrivateKey() {
182       return privateKey;
183     }
184 
185     public void setPrivateKey(String privateKey) {
186       this.privateKey = privateKey;
187     }
188 
189     public String getOutput() {
190       return output;
191     }
192 
193     public void setOutput(String output) {
194       this.output = output;
195     }
196   }
197 
198   public static class Api {
199 
200     private String versionPath;
201     private String versionV1;
202     private String countryPath;
203     private String countryGermany;
204     private String datePath;
205     private String hourPath;
206     private String diagnosisKeysPath;
207     private String parametersPath;
208     private String appConfigFileName;
209 
210     public String getVersionPath() {
211       return versionPath;
212     }
213 
214     public void setVersionPath(String versionPath) {
215       this.versionPath = versionPath;
216     }
217 
218     public String getVersionV1() {
219       return versionV1;
220     }
221 
222     public void setVersionV1(String versionV1) {
223       this.versionV1 = versionV1;
224     }
225 
226     public String getCountryPath() {
227       return countryPath;
228     }
229 
230     public void setCountryPath(String countryPath) {
231       this.countryPath = countryPath;
232     }
233 
234     public String getCountryGermany() {
235       return countryGermany;
236     }
237 
238     public void setCountryGermany(String countryGermany) {
239       this.countryGermany = countryGermany;
240     }
241 
242     public String getDatePath() {
243       return datePath;
244     }
245 
246     public void setDatePath(String datePath) {
247       this.datePath = datePath;
248     }
249 
250     public String getHourPath() {
251       return hourPath;
252     }
253 
254     public void setHourPath(String hourPath) {
255       this.hourPath = hourPath;
256     }
257 
258     public String getDiagnosisKeysPath() {
259       return diagnosisKeysPath;
260     }
261 
262     public void setDiagnosisKeysPath(String diagnosisKeysPath) {
263       this.diagnosisKeysPath = diagnosisKeysPath;
264     }
265 
266     public String getParametersPath() {
267       return parametersPath;
268     }
269 
270     public void setParametersPath(String parametersPath) {
271       this.parametersPath = parametersPath;
272     }
273 
274     public String getAppConfigFileName() {
275       return appConfigFileName;
276     }
277 
278     public void setAppConfigFileName(String appConfigFileName) {
279       this.appConfigFileName = appConfigFileName;
280     }
281   }
282 
283   public static class Signature {
284 
285     private String appBundleId;
286     private String androidPackage;
287     private String verificationKeyId;
288     private String verificationKeyVersion;
289     private String algorithmOid;
290     private String algorithmName;
291     private String fileName;
292     private String securityProvider;
293 
294     public String getAppBundleId() {
295       return appBundleId;
296     }
297 
298     public void setAppBundleId(String appBundleId) {
299       this.appBundleId = appBundleId;
300     }
301 
302     public String getAndroidPackage() {
303       return androidPackage;
304     }
305 
306     public void setAndroidPackage(String androidPackage) {
307       this.androidPackage = androidPackage;
308     }
309 
310     public String getVerificationKeyId() {
311       return verificationKeyId;
312     }
313 
314     public void setVerificationKeyId(String verificationKeyId) {
315       this.verificationKeyId = verificationKeyId;
316     }
317 
318     public String getVerificationKeyVersion() {
319       return verificationKeyVersion;
320     }
321 
322     public void setVerificationKeyVersion(String verificationKeyVersion) {
323       this.verificationKeyVersion = verificationKeyVersion;
324     }
325 
326     public String getAlgorithmOid() {
327       return algorithmOid;
328     }
329 
330     public void setAlgorithmOid(String algorithmOid) {
331       this.algorithmOid = algorithmOid;
332     }
333 
334     public String getAlgorithmName() {
335       return algorithmName;
336     }
337 
338     public void setAlgorithmName(String algorithmName) {
339       this.algorithmName = algorithmName;
340     }
341 
342     public String getFileName() {
343       return fileName;
344     }
345 
346     public void setFileName(String fileName) {
347       this.fileName = fileName;
348     }
349 
350     public String getSecurityProvider() {
351       return securityProvider;
352     }
353 
354     public void setSecurityProvider(String securityProvider) {
355       this.securityProvider = securityProvider;
356     }
357 
358     /**
359      * Returns the static {@link SignatureInfo} configured in the application properties.
360      */
361     public SignatureInfo getSignatureInfo() {
362       return SignatureInfo.newBuilder()
363           .setAppBundleId(this.getAppBundleId())
364           .setAndroidPackage(this.getAndroidPackage())
365           .setVerificationKeyVersion(this.getVerificationKeyVersion())
366           .setVerificationKeyId(this.getVerificationKeyId())
367           .setSignatureAlgorithm(this.getAlgorithmOid())
368           .build();
369     }
370   }
371 
372   public static class ObjectStore {
373 
374     private String accessKey;
375     private String secretKey;
376     private String endpoint;
377     private Integer port;
378     private String bucket;
379     private Boolean setPublicReadAclOnPutObject;
380 
381     public String getAccessKey() {
382       return accessKey;
383     }
384 
385     public void setAccessKey(String accessKey) {
386       this.accessKey = accessKey;
387     }
388 
389     public String getSecretKey() {
390       return secretKey;
391     }
392 
393     public void setSecretKey(String secretKey) {
394       this.secretKey = secretKey;
395     }
396 
397     public String getEndpoint() {
398       return endpoint;
399     }
400 
401     public void setEndpoint(String endpoint) {
402       this.endpoint = endpoint;
403     }
404 
405     public Integer getPort() {
406       return port;
407     }
408 
409     public void setPort(Integer port) {
410       this.port = port;
411     }
412 
413     public String getBucket() {
414       return bucket;
415     }
416 
417     public void setBucket(String bucket) {
418       this.bucket = bucket;
419     }
420 
421     public Boolean isSetPublicReadAclOnPutObject() {
422       return setPublicReadAclOnPutObject;
423     }
424 
425     public void setSetPublicReadAclOnPutObject(Boolean setPublicReadAclOnPutObject) {
426       this.setPublicReadAclOnPutObject = setPublicReadAclOnPutObject;
427     }
428   }
429 }
430 
[end of services/distribution/src/main/java/app/coronawarn/server/services/distribution/config/DistributionServiceConfig.java]
[start of services/distribution/src/main/resources/application.yaml]
1 ---
2 logging:
3   level:
4     org:
5       springframework:
6         web: INFO
7     app:
8       coronawarn: INFO
9 
10 services:
11   distribution:
12     output-file-name: index
13     retention-days: 14
14     include-incomplete-days: false
15     include-incomplete-hours: false
16     paths:
17       output: out
18       privatekey: ${VAULT_FILESIGNING_SECRET}
19     tek-export:
20       file-name: export.bin
21       file-header: EK Export v1
22       file-header-width: 16
23     api:
24       version-path: version
25       version-v1: v1
26       country-path: country
27       country-germany: DE
28       date-path: date
29       hour-path: hour
30       diagnosis-keys-path: diagnosis-keys
31       parameters-path: configuration
32       app-config-file-name: app_config
33     signature:
34       verification-key-id: 262
35       verification-key-version: v1
36       algorithm-oid: 1.2.840.10045.4.3.2
37       algorithm-name: SHA256withECDSA
38       file-name: export.sig
39       security-provider: BC
40     # Configuration for the S3 compatible object storage hosted by Telekom in Germany
41     objectstore:
42       access-key: ${CWA_OBJECTSTORE_ACCESSKEY:accessKey1}
43       secret-key: ${CWA_OBJECTSTORE_SECRETKEY:verySecretKey1}
44       endpoint: ${CWA_OBJECTSTORE_ENDPOINT:http://localhost}
45       bucket: ${CWA_OBJECTSTORE_BUCKET:cwa}
46       port: ${CWA_OBJECTSTORE_PORT:8003}
47       set-public-read-acl-on-put-object: true
48 
49 spring:
50   main:
51     web-application-type: NONE
52 # Postgres configuration
53   jpa:
54     hibernate:
55       ddl-auto: validate
56   flyway:
57     enabled: true
58     locations: classpath:db/migration/postgres
59 
60   datasource:
61     driver-class-name: org.postgresql.Driver
62     url: jdbc:postgresql://${POSTGRESQL_SERVICE_HOST:localhost}:${POSTGRESQL_SERVICE_PORT:5432}/${POSTGRESQL_DATABASE:cwa}
63     username: ${POSTGRESQL_USER:postgres}
64     password: ${POSTGRESQL_PASSWORD:postgres}
65 
[end of services/distribution/src/main/resources/application.yaml]
</code>

Here is an example of a patch file. It consists of changes to the code base. It specifies the file names, the line numbers of each change, and the removed and added lines. A single patch file can contain changes to multiple files.
<patch>
--- a/file.py
+++ b/file.py
@@ -1,27 +1,35 @@
 def euclidean(a, b):
-    while b:
-        a, b = b, a % b
-    return a
+    if b == 0:
+        return a
+    return euclidean(b, a % b)
 
 
 def bresenham(x0, y0, x1, y1):
     points = []
     dx = abs(x1 - x0)
     dy = abs(y1 - y0)
-    sx = 1 if x0 < x1 else -1
-    sy = 1 if y0 < y1 else -1
-    err = dx - dy
+    x, y = x0, y0
+    sx = -1 if x0 > x1 else 1
+    sy = -1 if y0 > y1 else 1
 
-    while True:
-        points.append((x0, y0))
-        if x0 == x1 and y0 == y1:
-            break
-        e2 = 2 * err
-        if e2 > -dy:
+    if dx > dy:
+        err = dx / 2.0
+        while x != x1:
+            points.append((x, y))
             err -= dy
-            x0 += sx
-        if e2 < dx:
-            err += dx
-            y0 += sy
+            if err < 0:
+                y += sy
+                err += dx
+            x += sx
+    else:
+        err = dy / 2.0
+        while y != y1:
+            points.append((x, y))
+            err -= dx
+            if err < 0:
+                x += sx
+                err += dy
+            y += sy
 
+    points.append((x, y))
     return points
</patch>

I need you to solve the provded issue by generating a single patch file that I can apply directly to this repository using git apply. Please respond with a single patch file in the format shown above.
Respond below:


Aggregate File Creation: Skip packages if payload too small
Change the aggregate file creation logic in a way, in which packages are skipped in case the payload (amount of keys) is too small. The threshold should be configurable, and defaulted to 50 for now.

Maybe combine with #140 
**Alternative suggestion: Pad packages with random dummy keys if too small**

Thinking a bit further into the hoped for future, when new infections are becoming rare, this might lead to unnecessary (possibly quite long) delays in notifications being sent. Could one instead use random dummy keys to pad every package that is too small to maybe 50 or 100 keys (or possibly padding to a randomised amount so an outsider can't even determine whether there any dummies are in the set).

This would impact required data volumes marginally - in the worst case, if 75 dummy keys are added every hour, this would represent only 50 kiB per day (or 3% of your bandwidth example in the architecture document) if I did not miscalculate. Since this would only occur in a low load situation anyway, this should be acceptable.
Threhold is now: 140
I like the padding idea - @MKusber do you think we can do this from a DPP/security perspective?
Hey @MikeJayDee, after some investigation, @michael-burwig and me decided that we will go for the "shifting" approach instead of the "padding" approach for this release. For clarification:

**"Shifting" approach:**
- If a package is too small (number of keys submitted in a timeframe is below a configurable threshold), then it will not be distributed (there will be zero keys in that package)
- The keys that would have been distributed in that run are "shifted" to the next distribution run
- Rinse and repeat until a package can be assembled that contains enough keys (above the configurable threshold)

**"Padding" approach:**
- If a package is too small, then it will be padded with random / fake keys until the threshold is matched (or exceeded)

As you have rightly pointed out, the main disadvantage of the "shifting" approach is that it introduces an artificial delay in the distribution of keys. This means that people will not be warned about a potential COVID exposure as quickly as possible, but that the CWA server will wait with the distribution until enough submissions have been made. Especially as the number of submissions decreases, this delay will become significant.

This disadvantage, of course, doesn't exist with the "padding" approach. However, the purpose of this whole "threshold" feature is to preserve peoples anonymity and to not allow anyone to create movement profiles. In order to achieve that, it must be impossible to match a specific temporary exposure key (TEK) to any particular person. In the "shifting" approach, this is achieved by ensuring that a single person's TEKs are always mashed together with enough other people's real TEKs to get lost in the shuffle. In the "padding" approach, this is achieved by mashing a single person's TEKs together with artificially/randomly generated TEKs. In order to make the "padding" approach equally as secure and privacy preserving, we would need to make real TEKs and artificially/randomly generated TEKs absolutely indistinguishable from one another. We decided for the "shifting" approach for this release because we are not 100% certain that we can achieve that - for the following reasons:

There are 4 fields in a TEK:
- Key data
- Transmission risk level
- Rolling start interval number
- Rolling period

"Key data" is random by nature and can easily be generated - no problem there. "Rolling period" is always 144 - no problem here either. However, in the real world, "transmission risk levels" and "rolling start interval numbers" each follow some statistical distribution. In order to make real and fake TEKs indistinguishable to any reasonably certainty, we would need to make sure, that the fake TEKs follow the same distribution as the real ones. The problem is, that we are not sure what the exact properties of that distribution are (and how they change over time). We can thus not confidently guarantee that one wouldn't be able to distinguish random and real TEKs through statistical analysis.

Since data privacy and protection are the foundation of this whole project, we decided to not take this risk and go with the safer but less accurate solution.

Now, it might be entirely possible that there is a solution to this problem that we just don't see. But since time is of the essence right now (and we can not wait for a scientifically substantiated analysis of the "padding" approach), we decided to go for the "shifting" approach for the initial release.

Anyways. Thank you very much for sharing your idea, @MikeJayDee. We forwarded your idea and the open questions outlined above to our scientific partners and will keep you posted on any updates 👍 
<patch>
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -148,7 +148,7 @@ Profile          | Effect
 -----------------|-------------
 `dev`            | Turns the log level to `DEBUG`.
 `cloud`          | Removes default values for the `datasource` and `objectstore` configurations.
-`demo`           | Includes incomplete days and hours into the distribution run, thus creating aggregates for the current day and the current hour (and including both in the respective indices). When running multiple distributions in one hour with this profile, the date aggregate for today and the hours aggregate for the current hour will be updated and overwritten.
+`demo`           | Includes incomplete days and hours into the distribution run, thus creating aggregates for the current day and the current hour (and including both in the respective indices). When running multiple distributions in one hour with this profile, the date aggregate for today and the hours aggregate for the current hour will be updated and overwritten. This profile also turns off the expiry policy (Keys must be expired for at least 2 hours before distribution) and the shifting policy (there must be at least 140 keys in a distribution).
 `testdata`       | Causes test data to be inserted into the database before each distribution run. By default, around 1000 random diagnosis keys will be generated per hour. If there are no diagnosis keys in the database yet, random keys will be generated for every hour from the beginning of the retention period (14 days ago at 00:00 UTC) until one hour before the present hour. If there are already keys in the database, the random keys will be generated for every hour from the latest diagnosis key in the database (by submission timestamp) until one hour before the present hour (or none at all, if the latest diagnosis key in the database was submitted one hour ago or later).
 `signature-dev`  | Sets the app package ID in the export packages' signature info to `de.rki.coronawarnapp-dev` so that test certificates (instead of production certificates) will be used for client-side validation.
 `signature-prod` | Provides production app package IDs for the signature info
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/component/DiagnosisKeysStructureProvider.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/component/DiagnosisKeysStructureProvider.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/component/DiagnosisKeysStructureProvider.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/component/DiagnosisKeysStructureProvider.java
@@ -22,6 +22,7 @@
 
 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
 import app.coronawarn.server.common.persistence.service.DiagnosisKeyService;
+import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.DiagnosisKeyBundler;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory.DiagnosisKeysDirectory;
 import app.coronawarn.server.services.distribution.assembly.structure.WritableOnDisk;
 import app.coronawarn.server.services.distribution.assembly.structure.directory.Directory;
@@ -29,6 +30,7 @@
 import java.util.Collection;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
 
 /**
@@ -40,6 +42,8 @@ public class DiagnosisKeysStructureProvider {
   private static final Logger logger = LoggerFactory
       .getLogger(DiagnosisKeysStructureProvider.class);
 
+  @Autowired
+  private DiagnosisKeyBundler diagnosisKeyBundler;
   private final DiagnosisKeyService diagnosisKeyService;
   private final CryptoProvider cryptoProvider;
   private final DistributionServiceConfig distributionServiceConfig;
@@ -56,11 +60,13 @@ public class DiagnosisKeysStructureProvider {
 
   /**
    * Get directory for diagnosis keys from database.
+   *
    * @return the directory
    */
   public Directory<WritableOnDisk> getDiagnosisKeys() {
     logger.debug("Querying diagnosis keys from the database...");
     Collection<DiagnosisKey> diagnosisKeys = diagnosisKeyService.getDiagnosisKeys();
-    return new DiagnosisKeysDirectory(diagnosisKeys, cryptoProvider, distributionServiceConfig);
+    diagnosisKeyBundler.setDiagnosisKeys(diagnosisKeys);
+    return new DiagnosisKeysDirectory(diagnosisKeyBundler, cryptoProvider, distributionServiceConfig);
   }
 }
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/DemoDiagnosisKeyBundler.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/DemoDiagnosisKeyBundler.java
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/DemoDiagnosisKeyBundler.java
@@ -0,0 +1,61 @@
+/*-
+ * ---license-start
+ * Corona-Warn-App
+ * ---
+ * Copyright (C) 2020 SAP SE and all other contributors
+ * ---
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ---license-end
+ */
+
+package app.coronawarn.server.services.distribution.assembly.diagnosiskeys;
+
+import static java.util.stream.Collectors.groupingBy;
+
+import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
+import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
+import java.time.LocalDateTime;
+import java.util.Collection;
+import java.util.List;
+import org.springframework.context.annotation.Profile;
+
+/**
+ * An instance of this class contains a collection of {@link DiagnosisKey DiagnosisKeys}, that will be distributed
+ * in the same hour they have been submitted.
+ */
+@Profile("demo")
+public class DemoDiagnosisKeyBundler extends DiagnosisKeyBundler {
+
+  public DemoDiagnosisKeyBundler(DistributionServiceConfig distributionServiceConfig) {
+    super(distributionServiceConfig);
+  }
+
+  /**
+   * Initializes the internal {@code distributableDiagnosisKeys} map, grouping the diagnosis keys by the submission
+   * timestamp, thus ignoring the expiry policy.
+   */
+  @Override
+  protected void createDiagnosisKeyDistributionMap(Collection<DiagnosisKey> diagnosisKeys) {
+    this.distributableDiagnosisKeys.clear();
+    this.distributableDiagnosisKeys.putAll(diagnosisKeys.stream().collect(groupingBy(this::getSubmissionDateTime)));
+  }
+
+  /**
+   * Returns all diagnosis keys that should be distributed in a specific hour, without respecting the shifting and
+   * expiry policies.
+   */
+  @Override
+  public List<DiagnosisKey> getDiagnosisKeysDistributableAt(LocalDateTime hour) {
+    return this.getDiagnosisKeysForHour(hour);
+  }
+}
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/DiagnosisKeyBundler.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/DiagnosisKeyBundler.java
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/DiagnosisKeyBundler.java
@@ -0,0 +1,98 @@
+/*-
+ * ---license-start
+ * Corona-Warn-App
+ * ---
+ * Copyright (C) 2020 SAP SE and all other contributors
+ * ---
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ---license-end
+ */
+
+package app.coronawarn.server.services.distribution.assembly.diagnosiskeys;
+
+import static app.coronawarn.server.services.distribution.assembly.diagnosiskeys.util.DateTime.ONE_HOUR_INTERVAL_SECONDS;
+import static java.time.ZoneOffset.UTC;
+import static java.util.Collections.emptyList;
+
+import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
+import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
+import java.time.LocalDateTime;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.springframework.stereotype.Component;
+
+/**
+ * An instance of this class contains a collection of {@link DiagnosisKey DiagnosisKeys}.
+ */
+@Component
+public abstract class DiagnosisKeyBundler {
+
+  protected final int minNumberOfKeysPerBundle;
+  protected final long expiryPolicyMinutes;
+
+  // A map containing diagnosis keys, grouped by the LocalDateTime on which they may be distributed
+  protected final Map<LocalDateTime, List<DiagnosisKey>> distributableDiagnosisKeys = new HashMap<>();
+
+  public DiagnosisKeyBundler(DistributionServiceConfig distributionServiceConfig) {
+    this.minNumberOfKeysPerBundle = distributionServiceConfig.getShiftingPolicyThreshold();
+    this.expiryPolicyMinutes = distributionServiceConfig.getExpiryPolicyMinutes();
+  }
+
+  /**
+   * Sets the {@link DiagnosisKey DiagnosisKeys} contained by this {@link DiagnosisKeyBundler} and calls {@link
+   * DiagnosisKeyBundler#createDiagnosisKeyDistributionMap}.
+   */
+  public void setDiagnosisKeys(Collection<DiagnosisKey> diagnosisKeys) {
+    createDiagnosisKeyDistributionMap(diagnosisKeys);
+  }
+
+  /**
+   * Returns all {@link DiagnosisKey DiagnosisKeys} contained by this {@link DiagnosisKeyBundler}.
+   */
+  public List<DiagnosisKey> getAllDiagnosisKeys() {
+    return this.distributableDiagnosisKeys.values().stream()
+        .flatMap(List::stream)
+        .collect(Collectors.toList());
+  }
+
+  /**
+   * Initializes the internal {@code distributableDiagnosisKeys} map, which should contain all diagnosis keys, grouped
+   * by the LocalDateTime on which they may be distributed.
+   */
+  protected abstract void createDiagnosisKeyDistributionMap(Collection<DiagnosisKey> diagnosisKeys);
+
+  /**
+   * Returns all diagnosis keys that should be distributed in a specific hour.
+   */
+  public abstract List<DiagnosisKey> getDiagnosisKeysDistributableAt(LocalDateTime hour);
+
+  /**
+   * Returns the submission timestamp of a {@link DiagnosisKey} as a {@link LocalDateTime}.
+   */
+  protected LocalDateTime getSubmissionDateTime(DiagnosisKey diagnosisKey) {
+    return LocalDateTime.ofEpochSecond(diagnosisKey.getSubmissionTimestamp() * ONE_HOUR_INTERVAL_SECONDS, 0, UTC);
+  }
+
+  /**
+   * Returns all diagnosis keys that should be distributed in a specific hour.
+   */
+  protected List<DiagnosisKey> getDiagnosisKeysForHour(LocalDateTime hour) {
+    return Optional
+        .ofNullable(this.distributableDiagnosisKeys.get(hour))
+        .orElse(emptyList());
+  }
+}
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/ProdDiagnosisKeyBundler.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/ProdDiagnosisKeyBundler.java
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/ProdDiagnosisKeyBundler.java
@@ -0,0 +1,135 @@
+/*-
+ * ---license-start
+ * Corona-Warn-App
+ * ---
+ * Copyright (C) 2020 SAP SE and all other contributors
+ * ---
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ---license-end
+ */
+
+package app.coronawarn.server.services.distribution.assembly.diagnosiskeys;
+
+import static app.coronawarn.server.services.distribution.assembly.diagnosiskeys.util.DateTime.TEN_MINUTES_INTERVAL_SECONDS;
+import static java.time.ZoneOffset.UTC;
+import static java.util.Collections.emptyList;
+import static java.util.stream.Collectors.groupingBy;
+
+import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
+import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.springframework.context.annotation.Profile;
+
+/**
+ * An instance of this class contains a collection of {@link DiagnosisKey DiagnosisKeys}, that will be distributed while
+ * respecting expiry policy (keys must be expired for a configurable amount of time before distribution) and shifting
+ * policy (there must be at least a configurable number of keys in a distribution). The policies are configurable
+ * through the properties {@code expiry-policy-minutes} and {@code shifting-policy-threshold}.
+ */
+@Profile("!demo")
+public class ProdDiagnosisKeyBundler extends DiagnosisKeyBundler {
+
+  /**
+   * Creates a new {@link ProdDiagnosisKeyBundler}.
+   */
+  public ProdDiagnosisKeyBundler(DistributionServiceConfig distributionServiceConfig) {
+    super(distributionServiceConfig);
+  }
+
+  /**
+   * Initializes the internal {@code distributableDiagnosisKeys} map, grouping the diagnosis keys by the date on which
+   * they may be distributed, while respecting the expiry policy.
+   */
+  @Override
+  protected void createDiagnosisKeyDistributionMap(Collection<DiagnosisKey> diagnosisKeys) {
+    this.distributableDiagnosisKeys.clear();
+    this.distributableDiagnosisKeys.putAll(diagnosisKeys.stream().collect(groupingBy(this::getDistributionDateTime)));
+  }
+
+  /**
+   * Returns all diagnosis keys that should be distributed in a specific hour, while respecting the shifting and expiry
+   * policies.
+   */
+  @Override
+  public List<DiagnosisKey> getDiagnosisKeysDistributableAt(LocalDateTime hour) {
+    List<DiagnosisKey> keysSinceLastDistribution = getKeysSinceLastDistribution(hour);
+    if (keysSinceLastDistribution.size() >= minNumberOfKeysPerBundle) {
+      return keysSinceLastDistribution;
+    } else {
+      return emptyList();
+    }
+  }
+
+  /**
+   * Returns a all distributable keys between a specific hour and the last distribution (bundle that was above the
+   * shifting threshold) or the earliest distributable key.
+   */
+  private List<DiagnosisKey> getKeysSinceLastDistribution(LocalDateTime hour) {
+    Optional<LocalDateTime> earliestDistributableTimestamp = getEarliestDistributableTimestamp();
+    if (earliestDistributableTimestamp.isEmpty() || hour.isBefore(earliestDistributableTimestamp.get())) {
+      return emptyList();
+    }
+    List<DiagnosisKey> distributableInCurrentHour = getDiagnosisKeysForHour(hour);
+    if (distributableInCurrentHour.size() >= minNumberOfKeysPerBundle) {
+      return distributableInCurrentHour;
+    }
+    LocalDateTime previousHour = hour.minusHours(1);
+    Collection<DiagnosisKey> distributableInPreviousHour = getDiagnosisKeysDistributableAt(previousHour);
+    if (distributableInPreviousHour.size() >= minNumberOfKeysPerBundle) {
+      // Last hour was distributed, so we can not combine the current hour with the last hour
+      return distributableInCurrentHour;
+    } else {
+      // Last hour was not distributed, so we can combine the current hour with the last hour
+      return Stream.concat(distributableInCurrentHour.stream(), getKeysSinceLastDistribution(previousHour).stream())
+          .collect(Collectors.toList());
+    }
+  }
+
+  private Optional<LocalDateTime> getEarliestDistributableTimestamp() {
+    return this.distributableDiagnosisKeys.keySet().stream().min(LocalDateTime::compareTo);
+  }
+
+  /**
+   * Returns the end of the rolling time window that a {@link DiagnosisKey} was active for as a {@link LocalDateTime}.
+   */
+  private LocalDateTime getExpiryDateTime(DiagnosisKey diagnosisKey) {
+    return LocalDateTime
+        .ofEpochSecond(diagnosisKey.getRollingStartIntervalNumber() * TEN_MINUTES_INTERVAL_SECONDS, 0, UTC)
+        .plusMinutes(diagnosisKey.getRollingPeriod() * 10L);
+  }
+
+  /**
+   * Calculates the earliest point in time at which the specified {@link DiagnosisKey} can be distributed. Before keys
+   * are allowed to be distributed, they must be expired for a configured amount of time.
+   *
+   * @return {@link LocalDateTime} at which the specified {@link DiagnosisKey} can be distributed.
+   */
+  private LocalDateTime getDistributionDateTime(DiagnosisKey diagnosisKey) {
+    LocalDateTime submissionDateTime = getSubmissionDateTime(diagnosisKey);
+    LocalDateTime expiryDateTime = getExpiryDateTime(diagnosisKey);
+    long minutesBetweenExpiryAndSubmission = Duration.between(expiryDateTime, submissionDateTime).toMinutes();
+    if (minutesBetweenExpiryAndSubmission <= expiryPolicyMinutes) {
+      // truncatedTo floors the value, so we need to add an hour to the DISTRIBUTION_PADDING to compensate that.
+      return expiryDateTime.plusMinutes(expiryPolicyMinutes + 60).truncatedTo(ChronoUnit.HOURS);
+    } else {
+      return submissionDateTime;
+    }
+  }
+}
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysCountryDirectory.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysCountryDirectory.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysCountryDirectory.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysCountryDirectory.java
@@ -22,6 +22,7 @@
 
 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
 import app.coronawarn.server.services.distribution.assembly.component.CryptoProvider;
+import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.DiagnosisKeyBundler;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory.decorator.DateAggregatingDecorator;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory.decorator.DateIndexingDecorator;
 import app.coronawarn.server.services.distribution.assembly.structure.WritableOnDisk;
@@ -30,12 +31,11 @@
 import app.coronawarn.server.services.distribution.assembly.structure.util.ImmutableStack;
 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
 import java.time.LocalDate;
-import java.util.Collection;
 import java.util.Set;
 
 public class DiagnosisKeysCountryDirectory extends IndexDirectoryOnDisk<String> {
 
-  private final Collection<DiagnosisKey> diagnosisKeys;
+  private final DiagnosisKeyBundler diagnosisKeyBundler;
   private final CryptoProvider cryptoProvider;
   private final DistributionServiceConfig distributionServiceConfig;
 
@@ -43,14 +43,14 @@ public class DiagnosisKeysCountryDirectory extends IndexDirectoryOnDisk<String>
    * Constructs a {@link DiagnosisKeysCountryDirectory} instance that represents the {@code .../country/:country/...}
    * portion of the diagnosis key directory structure.
    *
-   * @param diagnosisKeys  The diagnosis keys processed in the contained sub directories.
-   * @param cryptoProvider The {@link CryptoProvider} used for payload signing.
+   * @param diagnosisKeyBundler A {@link DiagnosisKeyBundler} containing the {@link DiagnosisKey DiagnosisKeys}.
+   * @param cryptoProvider      The {@link CryptoProvider} used for payload signing.
    */
-  public DiagnosisKeysCountryDirectory(Collection<DiagnosisKey> diagnosisKeys,
+  public DiagnosisKeysCountryDirectory(DiagnosisKeyBundler diagnosisKeyBundler,
       CryptoProvider cryptoProvider, DistributionServiceConfig distributionServiceConfig) {
     super(distributionServiceConfig.getApi().getCountryPath(), __ ->
         Set.of(distributionServiceConfig.getApi().getCountryGermany()), Object::toString);
-    this.diagnosisKeys = diagnosisKeys;
+    this.diagnosisKeyBundler = diagnosisKeyBundler;
     this.cryptoProvider = cryptoProvider;
     this.distributionServiceConfig = distributionServiceConfig;
   }
@@ -58,7 +58,7 @@ public DiagnosisKeysCountryDirectory(Collection<DiagnosisKey> diagnosisKeys,
   @Override
   public void prepare(ImmutableStack<Object> indices) {
     this.addWritableToAll(__ -> {
-      DiagnosisKeysDateDirectory dateDirectory = new DiagnosisKeysDateDirectory(diagnosisKeys, cryptoProvider,
+      DiagnosisKeysDateDirectory dateDirectory = new DiagnosisKeysDateDirectory(diagnosisKeyBundler, cryptoProvider,
           distributionServiceConfig);
       return decorateDateDirectory(dateDirectory);
     });
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDateDirectory.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDateDirectory.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDateDirectory.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDateDirectory.java
@@ -22,6 +22,7 @@
 
 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
 import app.coronawarn.server.services.distribution.assembly.component.CryptoProvider;
+import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.DiagnosisKeyBundler;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory.decorator.HourIndexingDecorator;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.util.DateTime;
 import app.coronawarn.server.services.distribution.assembly.structure.WritableOnDisk;
@@ -31,13 +32,12 @@
 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
-import java.util.Collection;
 
 public class DiagnosisKeysDateDirectory extends IndexDirectoryOnDisk<LocalDate> {
 
   private static final DateTimeFormatter ISO8601 = DateTimeFormatter.ofPattern("yyyy-MM-dd");
 
-  private final Collection<DiagnosisKey> diagnosisKeys;
+  private final DiagnosisKeyBundler diagnosisKeyBundler;
   private final CryptoProvider cryptoProvider;
   private final DistributionServiceConfig distributionServiceConfig;
 
@@ -45,14 +45,15 @@ public class DiagnosisKeysDateDirectory extends IndexDirectoryOnDisk<LocalDate>
    * Constructs a {@link DiagnosisKeysDateDirectory} instance associated with the specified {@link DiagnosisKey}
    * collection. Payload signing is be performed according to the specified {@link CryptoProvider}.
    *
-   * @param diagnosisKeys  The diagnosis keys processed in the contained directories.
+   * @param diagnosisKeyBundler A {@link DiagnosisKeyBundler} containing the {@link DiagnosisKey DiagnosisKeys}.
    * @param cryptoProvider The {@link CryptoProvider} used for payload signing.
    */
-  public DiagnosisKeysDateDirectory(Collection<DiagnosisKey> diagnosisKeys,
+  public DiagnosisKeysDateDirectory(DiagnosisKeyBundler diagnosisKeyBundler,
       CryptoProvider cryptoProvider, DistributionServiceConfig distributionServiceConfig) {
-    super(distributionServiceConfig.getApi().getDatePath(), __ -> DateTime.getDates(diagnosisKeys), ISO8601::format);
+    super(distributionServiceConfig.getApi().getDatePath(),
+        __ -> DateTime.getDates(diagnosisKeyBundler.getAllDiagnosisKeys()), ISO8601::format);
     this.cryptoProvider = cryptoProvider;
-    this.diagnosisKeys = diagnosisKeys;
+    this.diagnosisKeyBundler = diagnosisKeyBundler;
     this.distributionServiceConfig = distributionServiceConfig;
   }
 
@@ -60,7 +61,7 @@ public DiagnosisKeysDateDirectory(Collection<DiagnosisKey> diagnosisKeys,
   public void prepare(ImmutableStack<Object> indices) {
     this.addWritableToAll(__ -> {
       DiagnosisKeysHourDirectory hourDirectory =
-          new DiagnosisKeysHourDirectory(diagnosisKeys, cryptoProvider, distributionServiceConfig);
+          new DiagnosisKeysHourDirectory(diagnosisKeyBundler, cryptoProvider, distributionServiceConfig);
       return decorateHourDirectory(hourDirectory);
     });
     super.prepare(indices);
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectory.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectory.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectory.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectory.java
@@ -22,6 +22,7 @@
 
 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
 import app.coronawarn.server.services.distribution.assembly.component.CryptoProvider;
+import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.DiagnosisKeyBundler;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory.decorator.CountryIndexingDecorator;
 import app.coronawarn.server.services.distribution.assembly.structure.WritableOnDisk;
 import app.coronawarn.server.services.distribution.assembly.structure.directory.Directory;
@@ -31,7 +32,6 @@
 import app.coronawarn.server.services.distribution.assembly.structure.directory.decorator.indexing.IndexingDecoratorOnDisk;
 import app.coronawarn.server.services.distribution.assembly.structure.util.ImmutableStack;
 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
-import java.util.Collection;
 
 /**
  * A {@link Directory} containing the file and directory structure that mirrors the API defined in the OpenAPI
@@ -42,7 +42,7 @@
  */
 public class DiagnosisKeysDirectory extends DirectoryOnDisk {
 
-  private final Collection<DiagnosisKey> diagnosisKeys;
+  private final DiagnosisKeyBundler diagnosisKeyBundler;
   private final CryptoProvider cryptoProvider;
   private final DistributionServiceConfig distributionServiceConfig;
 
@@ -50,13 +50,13 @@ public class DiagnosisKeysDirectory extends DirectoryOnDisk {
    * Constructs a {@link DiagnosisKeysDirectory} based on the specified {@link DiagnosisKey} collection. Cryptographic
    * signing is performed using the specified {@link CryptoProvider}.
    *
-   * @param diagnosisKeys  The diagnosis keys processed in the contained sub directories.
-   * @param cryptoProvider The {@link CryptoProvider} used for payload signing.
+   * @param diagnosisKeyBundler A {@link DiagnosisKeyBundler} containing the {@link DiagnosisKey DiagnosisKeys}.
+   * @param cryptoProvider      The {@link CryptoProvider} used for payload signing.
    */
-  public DiagnosisKeysDirectory(Collection<DiagnosisKey> diagnosisKeys, CryptoProvider cryptoProvider,
+  public DiagnosisKeysDirectory(DiagnosisKeyBundler diagnosisKeyBundler, CryptoProvider cryptoProvider,
       DistributionServiceConfig distributionServiceConfig) {
     super(distributionServiceConfig.getApi().getDiagnosisKeysPath());
-    this.diagnosisKeys = diagnosisKeys;
+    this.diagnosisKeyBundler = diagnosisKeyBundler;
     this.cryptoProvider = cryptoProvider;
     this.distributionServiceConfig = distributionServiceConfig;
   }
@@ -64,7 +64,7 @@ public DiagnosisKeysDirectory(Collection<DiagnosisKey> diagnosisKeys, CryptoProv
   @Override
   public void prepare(ImmutableStack<Object> indices) {
     this.addWritable(decorateCountryDirectory(
-        new DiagnosisKeysCountryDirectory(diagnosisKeys, cryptoProvider, distributionServiceConfig)));
+        new DiagnosisKeysCountryDirectory(diagnosisKeyBundler, cryptoProvider, distributionServiceConfig)));
     super.prepare(indices);
   }
 
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysHourDirectory.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysHourDirectory.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysHourDirectory.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysHourDirectory.java
@@ -22,6 +22,7 @@
 
 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
 import app.coronawarn.server.services.distribution.assembly.component.CryptoProvider;
+import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.DiagnosisKeyBundler;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.archive.decorator.singing.DiagnosisKeySigningDecorator;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.file.TemporaryExposureKeyExportFile;
 import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.util.DateTime;
@@ -36,28 +37,25 @@
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.ZoneOffset;
-import java.util.Collection;
-import java.util.Set;
-import java.util.stream.Collectors;
+import java.util.List;
 
 public class DiagnosisKeysHourDirectory extends IndexDirectoryOnDisk<LocalDateTime> {
 
-  private final Collection<DiagnosisKey> diagnosisKeys;
+  private final DiagnosisKeyBundler diagnosisKeyBundler;
   private final CryptoProvider cryptoProvider;
   private final DistributionServiceConfig distributionServiceConfig;
 
   /**
    * Constructs a {@link DiagnosisKeysHourDirectory} instance for the specified date.
    *
-   * @param diagnosisKeys  A collection of diagnosis keys. These will be filtered according to the specified current
-   *                       date.
-   * @param cryptoProvider The {@link CryptoProvider} used for cryptographic signing.
+   * @param diagnosisKeyBundler A {@link DiagnosisKeyBundler} containing the {@link DiagnosisKey DiagnosisKeys}.
+   * @param cryptoProvider      The {@link CryptoProvider} used for cryptographic signing.
    */
-  public DiagnosisKeysHourDirectory(Collection<DiagnosisKey> diagnosisKeys, CryptoProvider cryptoProvider,
+  public DiagnosisKeysHourDirectory(DiagnosisKeyBundler diagnosisKeyBundler, CryptoProvider cryptoProvider,
       DistributionServiceConfig distributionServiceConfig) {
-    super(distributionServiceConfig.getApi().getHourPath(),
-        indices -> DateTime.getHours(((LocalDate) indices.peek()), diagnosisKeys), LocalDateTime::getHour);
-    this.diagnosisKeys = diagnosisKeys;
+    super(distributionServiceConfig.getApi().getHourPath(), indices -> DateTime.getHours(((LocalDate) indices.peek()),
+        diagnosisKeyBundler.getAllDiagnosisKeys()), LocalDateTime::getHour);
+    this.diagnosisKeyBundler = diagnosisKeyBundler;
     this.cryptoProvider = cryptoProvider;
     this.distributionServiceConfig = distributionServiceConfig;
   }
@@ -71,7 +69,8 @@ public void prepare(ImmutableStack<Object> indices) {
       // IndexDirectory.
       String region = (String) currentIndices.pop().pop().peek();
 
-      Set<DiagnosisKey> diagnosisKeysForCurrentHour = getDiagnosisKeysForHour(currentHour);
+      List<DiagnosisKey> diagnosisKeysForCurrentHour =
+          this.diagnosisKeyBundler.getDiagnosisKeysDistributableAt(currentHour);
 
       long startTimestamp = currentHour.toEpochSecond(ZoneOffset.UTC);
       long endTimestamp = currentHour.plusHours(1).toEpochSecond(ZoneOffset.UTC);
@@ -86,12 +85,6 @@ public void prepare(ImmutableStack<Object> indices) {
     super.prepare(indices);
   }
 
-  private Set<DiagnosisKey> getDiagnosisKeysForHour(LocalDateTime hour) {
-    return this.diagnosisKeys.stream()
-        .filter(diagnosisKey -> DistributionDateTimeCalculator.getDistributionDateTime(diagnosisKey).equals(hour))
-        .collect(Collectors.toSet());
-  }
-
   private Directory<WritableOnDisk> decorateDiagnosisKeyArchive(Archive<WritableOnDisk> archive) {
     return new DiagnosisKeySigningDecorator(archive, cryptoProvider, distributionServiceConfig);
   }
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DistributionDateTimeCalculator.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DistributionDateTimeCalculator.java
deleted file mode 100644
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DistributionDateTimeCalculator.java
+++ /dev/null
@@ -1,62 +0,0 @@
-/*-
- * ---license-start
- * Corona-Warn-App
- * ---
- * Copyright (C) 2020 SAP SE and all other contributors
- * ---
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- * ---license-end
- */
-
-package app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory;
-
-import static app.coronawarn.server.services.distribution.assembly.diagnosiskeys.util.DateTime.ONE_HOUR_INTERVAL_SECONDS;
-import static app.coronawarn.server.services.distribution.assembly.diagnosiskeys.util.DateTime.TEN_MINUTES_INTERVAL_SECONDS;
-import static java.time.ZoneOffset.UTC;
-
-import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
-import java.time.Duration;
-import java.time.LocalDateTime;
-import java.time.temporal.ChronoUnit;
-
-public class DistributionDateTimeCalculator {
-
-  /**
-   * Minimum time in minutes after key expiration after which it can be distributed.
-   */
-  public static final long DISTRIBUTION_PADDING = 120L;
-
-  private DistributionDateTimeCalculator() {
-  }
-
-  /**
-   * Calculates the earliest point in time at which the specified {@link DiagnosisKey} can be distributed. Before keys
-   * are allowed to be distributed, they must be expired for a configured amount of time.
-   *
-   * @return {@link LocalDateTime} at which the specified {@link DiagnosisKey} can be distributed.
-   */
-  public static LocalDateTime getDistributionDateTime(DiagnosisKey diagnosisKey) {
-    LocalDateTime submissionDateTime = LocalDateTime
-        .ofEpochSecond(diagnosisKey.getSubmissionTimestamp() * ONE_HOUR_INTERVAL_SECONDS, 0, UTC);
-    LocalDateTime keyExpiryDateTime = LocalDateTime
-        .ofEpochSecond(diagnosisKey.getRollingStartIntervalNumber() * TEN_MINUTES_INTERVAL_SECONDS, 0, UTC)
-        .plusMinutes(diagnosisKey.getRollingPeriod() * 10L);
-
-    if (Duration.between(keyExpiryDateTime, submissionDateTime).toMinutes() <= DISTRIBUTION_PADDING) {
-      // truncatedTo floors the value, so we need to add an hour to the DISTRIBUTION_PADDING to compensate that.
-      return keyExpiryDateTime.plusMinutes(DISTRIBUTION_PADDING + 60).truncatedTo(ChronoUnit.HOURS);
-    }
-
-    return submissionDateTime;
-  }
-}
diff --git a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/config/DistributionServiceConfig.java b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/config/DistributionServiceConfig.java
--- a/services/distribution/src/main/java/app/coronawarn/server/services/distribution/config/DistributionServiceConfig.java
+++ b/services/distribution/src/main/java/app/coronawarn/server/services/distribution/config/DistributionServiceConfig.java
@@ -31,6 +31,8 @@ public class DistributionServiceConfig {
   private Paths paths;
   private TestData testData;
   private Integer retentionDays;
+  private Integer expiryPolicyMinutes;
+  private Integer shiftingPolicyThreshold;
   private String outputFileName;
   private Boolean includeIncompleteDays;
   private Boolean includeIncompleteHours;
@@ -63,6 +65,22 @@ public void setRetentionDays(Integer retentionDays) {
     this.retentionDays = retentionDays;
   }
 
+  public Integer getExpiryPolicyMinutes() {
+    return expiryPolicyMinutes;
+  }
+
+  public void setExpiryPolicyMinutes(Integer expiryPolicyMinutes) {
+    this.expiryPolicyMinutes = expiryPolicyMinutes;
+  }
+
+  public Integer getShiftingPolicyThreshold() {
+    return shiftingPolicyThreshold;
+  }
+
+  public void setShiftingPolicyThreshold(Integer shiftingPolicyThreshold) {
+    this.shiftingPolicyThreshold = shiftingPolicyThreshold;
+  }
+
   public String getOutputFileName() {
     return outputFileName;
   }
diff --git a/services/distribution/src/main/resources/application.yaml b/services/distribution/src/main/resources/application.yaml
--- a/services/distribution/src/main/resources/application.yaml
+++ b/services/distribution/src/main/resources/application.yaml
@@ -11,6 +11,8 @@ services:
   distribution:
     output-file-name: index
     retention-days: 14
+    expiry-policy-minutes: 120
+    shifting-policy-threshold: 140
     include-incomplete-days: false
     include-incomplete-hours: false
     paths:

</patch>
diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/ProdDiagnosisKeyBundlerTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/ProdDiagnosisKeyBundlerTest.java
new file mode 100644
--- /dev/null
+++ b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/ProdDiagnosisKeyBundlerTest.java
@@ -0,0 +1,190 @@
+/*-
+ * ---license-start
+ * Corona-Warn-App
+ * ---
+ * Copyright (C) 2020 SAP SE and all other contributors
+ * ---
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ---license-end
+ */
+
+package app.coronawarn.server.services.distribution.assembly.diagnosiskeys;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
+import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.test.context.ConfigFileApplicationContextInitializer;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+@EnableConfigurationProperties(value = DistributionServiceConfig.class)
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration(classes = {DistributionServiceConfig.class, ProdDiagnosisKeyBundler.class},
+    initializers = ConfigFileApplicationContextInitializer.class)
+class ProdDiagnosisKeyBundlerTest {
+
+  @Autowired
+  DistributionServiceConfig distributionServiceConfig;
+
+  @Autowired
+  DiagnosisKeyBundler bundler;
+
+  @Test
+  void testEmptyListWhenNoDiagnosisKeys() {
+    bundler.setDiagnosisKeys(List.of());
+    assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 1, 0, 0, 0))).hasSize(0);
+  }
+
+  @Test
+  void testEmptyListWhenGettingDistributableKeysBeforeEarliestDiagnosisKey() {
+    List<DiagnosisKey> diagnosisKeys = buildDiagnosisKeys(6, 50L, 5);
+    bundler.setDiagnosisKeys(diagnosisKeys);
+    assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 1, 0, 0, 0))).hasSize(0);
+  }
+
+  @Nested
+  @DisplayName("Expiry policy")
+  class ProdDiagnosisKeyBundlerExpiryPolicyTest {
+
+    @ParameterizedTest
+    @ValueSource(longs = {0L, 24L, 24L + 2L})
+    void testLastPeriodOfHourAndSubmissionLessThanDistributionDateTime(long submissionTimestamp) {
+      List<DiagnosisKey> diagnosisKeys = buildDiagnosisKeys(5, submissionTimestamp, 10);
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 2, 3, 0, 0))).hasSize(10);
+    }
+
+    @Test
+    void testLastPeriodOfHourAndSubmissionEqualsDistributionDateTime() {
+      List<DiagnosisKey> diagnosisKeys = buildDiagnosisKeys(5, 24L + 3L, 10);
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 2, 3, 0, 0))).hasSize(10);
+    }
+
+    @ParameterizedTest
+    @ValueSource(longs = {0L, 24L, 24L + 2L, 24L + 3L})
+    void testFirstPeriodOfHourAndSubmissionLessThanDistributionDateTime(long submissionTimestamp) {
+      List<DiagnosisKey> diagnosisKeys = buildDiagnosisKeys(6, submissionTimestamp, 10);
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 2, 4, 0, 0))).hasSize(10);
+    }
+
+    @Test
+    void testFirstPeriodOfHourAndSubmissionEqualsDistributionDateTime() {
+      List<DiagnosisKey> diagnosisKeys = buildDiagnosisKeys(6, 24L + 4L, 10);
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 2, 4, 0, 0))).hasSize(10);
+    }
+
+    @Test
+    void testLastPeriodOfHourAndSubmissionGreaterDistributionDateTime() {
+      List<DiagnosisKey> diagnosisKeys = buildDiagnosisKeys(5, 24L + 4L, 10);
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 2, 4, 0, 0))).hasSize(10);
+    }
+  }
+
+  @Nested
+  @DisplayName("Shifting policy")
+  class ProdDiagnosisKeyBundlerShiftingPolicyTest {
+
+    @Test
+    void testDoesNotShiftIfPackageSizeGreaterThanThreshold() {
+      List<DiagnosisKey> diagnosisKeys = buildDiagnosisKeys(6, 50L, 6);
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 2, 0, 0))).hasSize(6);
+    }
+
+    @Test
+    void testDoesNotShiftIfPackageSizeEqualsThreshold() {
+      List<DiagnosisKey> diagnosisKeys = buildDiagnosisKeys(6, 50L, 5);
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 2, 0, 0))).hasSize(5);
+    }
+
+    @Test
+    void testShiftsIfPackageSizeLessThanThreshold() {
+      List<DiagnosisKey> diagnosisKeys = Stream
+          .concat(buildDiagnosisKeys(6, 50L, 4).stream(), buildDiagnosisKeys(6, 51L, 1).stream())
+          .collect(Collectors.toList());
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 2, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 3, 0, 0))).hasSize(5);
+    }
+
+    @Test
+    void testShiftsSinceLastDistribution() {
+      List<DiagnosisKey> diagnosisKeys = Stream
+          .of(buildDiagnosisKeys(6, 50L, 5), buildDiagnosisKeys(6, 51L, 2), buildDiagnosisKeys(6, 52L, 4))
+          .flatMap(List::stream)
+          .collect(Collectors.toList());
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 2, 0, 0))).hasSize(5);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 3, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 4, 0, 0))).hasSize(6);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 5, 0, 0))).hasSize(0);
+    }
+
+    @Test
+    void testShiftIncludesPreviouslyUndistributedKeys() {
+      List<DiagnosisKey> diagnosisKeys = Stream
+          .concat(buildDiagnosisKeys(6, 50L, 1).stream(), buildDiagnosisKeys(6, 51L, 5).stream())
+          .collect(Collectors.toList());
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 2, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 3, 0, 0))).hasSize(5);
+    }
+
+    @Test
+    void testShiftsSparseDistributions() {
+      List<DiagnosisKey> diagnosisKeys = Stream
+          .of(buildDiagnosisKeys(6, 50L, 1), buildDiagnosisKeys(6, 51L, 1), buildDiagnosisKeys(6, 52L, 1),
+              buildDiagnosisKeys(6, 53L, 0), buildDiagnosisKeys(6, 54L, 0), buildDiagnosisKeys(6, 55L, 1),
+              buildDiagnosisKeys(6, 56L, 1))
+          .flatMap(List::stream)
+          .collect(Collectors.toList());
+      bundler.setDiagnosisKeys(diagnosisKeys);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 2, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 3, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 4, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 5, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 6, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 7, 0, 0))).hasSize(0);
+      assertThat(bundler.getDiagnosisKeysDistributableAt(LocalDateTime.of(1970, 1, 3, 8, 0, 0))).hasSize(5);
+    }
+  }
+
+  public static List<DiagnosisKey> buildDiagnosisKeys(int startIntervalNumber, long submissionTimestamp, int number) {
+    return IntStream.range(0, number)
+        .mapToObj(__ -> DiagnosisKey.builder()
+            .withKeyData(new byte[16])
+            .withRollingStartIntervalNumber(startIntervalNumber)
+            .withTransmissionRiskLevel(2)
+            .withSubmissionTimestamp(submissionTimestamp).build())
+        .collect(Collectors.toList());
+  }
+}
diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectoryTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectoryTest.java
--- a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectoryTest.java
+++ b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/diagnosiskeys/structure/directory/DiagnosisKeysDirectoryTest.java
@@ -26,6 +26,8 @@
 
 import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
 import app.coronawarn.server.services.distribution.assembly.component.CryptoProvider;
+import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.DemoDiagnosisKeyBundler;
+import app.coronawarn.server.services.distribution.assembly.diagnosiskeys.DiagnosisKeyBundler;
 import app.coronawarn.server.services.distribution.assembly.structure.WritableOnDisk;
 import app.coronawarn.server.services.distribution.assembly.structure.directory.Directory;
 import app.coronawarn.server.services.distribution.assembly.structure.directory.DirectoryOnDisk;
@@ -33,7 +35,6 @@
 import app.coronawarn.server.services.distribution.config.DistributionServiceConfig;
 import java.io.File;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Objects;
@@ -90,8 +91,8 @@ void setupAll() throws IOException {
 
   @Test
   void checkBuildsTheCorrectDirectoryStructureWhenNoKeys() {
-    diagnosisKeys = new ArrayList<>();
-    Directory<WritableOnDisk> directory = new DiagnosisKeysDirectory(diagnosisKeys, cryptoProvider,
+    DiagnosisKeyBundler bundler = new DemoDiagnosisKeyBundler(distributionServiceConfig);
+    Directory<WritableOnDisk> directory = new DiagnosisKeysDirectory(bundler, cryptoProvider,
         distributionServiceConfig);
     parentDirectory.addWritable(directory);
     directory.prepare(new ImmutableStack<>());
@@ -111,7 +112,9 @@ void checkBuildsTheCorrectDirectoryStructureWhenNoKeys() {
 
   @Test
   void checkBuildsTheCorrectDirectoryStructure() {
-    Directory<WritableOnDisk> directory = new DiagnosisKeysDirectory(diagnosisKeys, cryptoProvider,
+    DiagnosisKeyBundler bundler = new DemoDiagnosisKeyBundler(distributionServiceConfig);
+    bundler.setDiagnosisKeys(diagnosisKeys);
+    Directory<WritableOnDisk> directory = new DiagnosisKeysDirectory(bundler, cryptoProvider,
         distributionServiceConfig);
     parentDirectory.addWritable(directory);
     directory.prepare(new ImmutableStack<>());
diff --git a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/structure/directory/DistributionDateTimeCalculatorTest.java b/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/structure/directory/DistributionDateTimeCalculatorTest.java
deleted file mode 100644
--- a/services/distribution/src/test/java/app/coronawarn/server/services/distribution/assembly/structure/directory/DistributionDateTimeCalculatorTest.java
+++ /dev/null
@@ -1,72 +0,0 @@
-/*-
- * ---license-start
- * Corona-Warn-App
- * ---
- * Copyright (C) 2020 SAP SE and all other contributors
- * ---
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- * ---license-end
- */
-
-package app.coronawarn.server.services.distribution.assembly.structure.directory;
-
-import static app.coronawarn.server.services.distribution.assembly.diagnosiskeys.structure.directory.DistributionDateTimeCalculator.getDistributionDateTime;
-import static org.assertj.core.api.Assertions.assertThat;
-
-import app.coronawarn.server.common.persistence.domain.DiagnosisKey;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.ValueSource;
-
-class DistributionDateTimeCalculatorTest {
-
-  @ParameterizedTest
-  @ValueSource(longs = {0L, 24L, 24L + 2L})
-  void testLastPeriodOfHourAndSubmissionLessThanDistributionDateTime(long submissionTimestamp) {
-    var diagnosisKey = buildDiagnosisKey(5, submissionTimestamp);
-    assertThat(getDistributionDateTime(diagnosisKey)).isEqualTo("1970-01-02T03:00");
-  }
-
-  @Test
-  void testLastPeriodOfHourAndSubmissionEqualsDistributionDateTime() {
-    var diagnosisKey = buildDiagnosisKey(5, 24L + 3L);
-    assertThat(getDistributionDateTime(diagnosisKey)).isEqualTo("1970-01-02T03:00");
-  }
-
-  @ParameterizedTest
-  @ValueSource(longs = {0L, 24L, 24L + 2L, 24L + 3L})
-  void testFirstPeriodOfHourAndSubmissionLessThanDistributionDateTime(long submissionTimestamp) {
-    var diagnosisKey = buildDiagnosisKey(6, submissionTimestamp);
-    assertThat(getDistributionDateTime(diagnosisKey)).isEqualTo("1970-01-02T04:00");
-  }
-
-  @Test
-  void testFirstPeriodOfHourAndSubmissionEqualsDistributionDateTime() {
-    var diagnosisKey = buildDiagnosisKey(6, 24L + 4L);
-    assertThat(getDistributionDateTime(diagnosisKey)).isEqualTo("1970-01-02T04:00");
-  }
-
-  @Test
-  void testLastPeriodOfHourAndSubmissionGreaterDistributionDateTime() {
-    var diagnosisKey = buildDiagnosisKey(5, 24L + 4L);
-    assertThat(getDistributionDateTime(diagnosisKey)).isEqualTo("1970-01-02T04:00");
-  }
-
-  private DiagnosisKey buildDiagnosisKey(int startIntervalNumber, long submissionTimestamp) {
-    return DiagnosisKey.builder()
-        .withKeyData(new byte[16])
-        .withRollingStartIntervalNumber(startIntervalNumber)
-        .withTransmissionRiskLevel(2)
-        .withSubmissionTimestamp(submissionTimestamp).build();
-  }
-}
diff --git a/services/distribution/src/test/resources/application.yaml b/services/distribution/src/test/resources/application.yaml
--- a/services/distribution/src/test/resources/application.yaml
+++ b/services/distribution/src/test/resources/application.yaml
@@ -9,6 +9,8 @@ services:
   distribution:
     output-file-name: index
     retention-days: 14
+    expiry-policy-minutes: 120
+    shifting-policy-threshold: 5
     include-incomplete-days: false
     include-incomplete-hours: false
     paths:
