Skip to content

Commit c73ce2b

Browse files
committed
Clean version of the first reference implementation of DP3T.
This version implementes the decentralized low-cost design referenced in the DP3T whitepaper.
0 parents  commit c73ce2b

4 files changed

Lines changed: 433 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__pycache__/*

LowCostDP3T.py

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
#!/usr/bin/env python3
2+
__copyright__ = """
3+
Copyright 2020 EPFL
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
17+
"""
18+
__license__ = "Apache 2.0"
19+
20+
from Cryptodome.Util import Counter
21+
from Cryptodome.Cipher import AES
22+
import hashlib
23+
import hmac
24+
import secrets
25+
import random
26+
from datetime import datetime, timezone, timedelta
27+
28+
# Fixed global default broadcast key for ephID generation.
29+
BROADCAST_KEY = "Broadcast key"
30+
31+
# Length of an epoch (in minutes).
32+
EPOCH_LENGTH = 15
33+
34+
# Number of epochs per day.
35+
NUM_EPOCHS_PER_DAY = 24*60//EPOCH_LENGTH
36+
37+
# Duration key and contact history is kept (in days).
38+
RETENTION_PERIOD = 14
39+
40+
# Min number of observation seconds for a contact
41+
CONTACT_THRESHOLD = 120
42+
43+
44+
# Local key management and storage
45+
##################################
46+
class KeyStore:
47+
''' This class handles local key management (SKs and EphIDs).
48+
The set of previous SKs is kept in a local array (up to max length).
49+
The set of EphIDs for the current day is kept in another array.
50+
'''
51+
52+
def __init__(self):
53+
self.SKt = [] # Current set of SKts
54+
self.ephIDs = [] # Daily set of ephIDs
55+
# Initial key is created from true random value
56+
self.SKt.insert(0, secrets.token_bytes(32))
57+
self.rotate_ephIDs()
58+
59+
@staticmethod
60+
def get_SKt1(SK_t0):
61+
''' Updates a given SK_t to SK_t+1.
62+
63+
This method creates the next key in the chain of SK_t's.
64+
This method is called either for the local rotation or when we
65+
recover the different SK_ts from an infected person.
66+
67+
Arguments:
68+
SK(list): current SK_t (b"rand" * 32).
69+
70+
Returns:
71+
b[]: The next SK (SK_t+1).
72+
'''
73+
SK_t1 = hashlib.sha256(SK_t0).digest()
74+
return SK_t1
75+
76+
def rotate_SK(self):
77+
''' Create a new SK_t+1 based on SK_t.
78+
79+
This method updates the current key and moves on to the next day.
80+
This method is called at midnight UTC.
81+
'''
82+
SK_t1 = KeyStore.get_SKt1(self.SKt[0])
83+
self.SKt.insert(0, SK_t1)
84+
# truncate list to max days
85+
while len(self.SKt) > RETENTION_PERIOD:
86+
self.SKt.pop()
87+
88+
@staticmethod
89+
def create_ephIDs(SK):
90+
''' Create the set of beacons for the day based on a new SK_t.
91+
92+
This method created the set of ephemeral IDs given an SK for
93+
day / broadcast_key.
94+
95+
Arguments:
96+
SK(b[]): given SK (b"rand" * 32)
97+
98+
Returns:
99+
[b[]]: Set of ephemeral IDs (ephIDs) for a single day.
100+
'''
101+
# Set up PRF with the SK and broadcast_key
102+
prf = hmac.new(SK, BROADCAST_KEY.encode(), hashlib.sha256).digest()
103+
# Start with a fresh counter each day and initialize AES in CTR mode
104+
prg = AES.new(prf, AES.MODE_CTR, counter = Counter.new(128, initial_value=0))
105+
ephIDs = []
106+
107+
# Create the number of desired ephIDs by encrypting 0 bytes
108+
prg_data = prg.encrypt(b"\0" * 16 * NUM_EPOCHS_PER_DAY)
109+
for i in range(NUM_EPOCHS_PER_DAY):
110+
# split the prg data into individual ephIDs
111+
ephIDs.append(prg_data[i*16:i*16+16])
112+
return ephIDs
113+
114+
def rotate_ephIDs(self):
115+
''' Generate the daily set of EphIDs.
116+
117+
This method creates the set of ephIDs for the app to broadcast.
118+
We shuffle this set before returning it back to the app.
119+
This method updates the local set of ephIDs, epoch indexes into set.
120+
Executed once per day, at 0:00 UTC
121+
'''
122+
ephIDs = self.create_ephIDs(self.SKt[0])
123+
# TODO: random.shuffle is not cryptographically secure!
124+
# The real app will use a cryptographic secure shuffle
125+
random.shuffle(ephIDs)
126+
self.ephIDs = ephIDs
127+
128+
def get_epoch(self, now):
129+
''' Return the current epoch.
130+
now: time mapped to epoch
131+
'''
132+
offset = now.hour*60 + now.minute
133+
delta = 24*60 // NUM_EPOCHS_PER_DAY
134+
return offset//delta
135+
136+
def get_current_ephID(self, now = None):
137+
''' Returns the current ephID
138+
'''
139+
if now == None:
140+
now = datetime.now(timezone.utc)
141+
return self.ephIDs[self.get_epoch(now)]
142+
143+
144+
# Handle and manage contacts
145+
############################
146+
class ContactManager:
147+
''' Keep track of contacts and manage measurements
148+
'''
149+
150+
def __init__(self):
151+
self.observations = {} # Observations of the current epoch
152+
self.contacts = [{}] # Array of daily contact sets
153+
154+
# Remote beacon management
155+
##########################
156+
def receive_scans(self, beacons = [], now = None):
157+
''' Receive a set of new BLE beacons and process them.
158+
159+
Add the current received information to the observations for the
160+
current epoch.
161+
162+
Arguments:
163+
beacons([]): list of received beacons.
164+
now(datetime): current time, override for mock testing.
165+
'''
166+
if now == None:
167+
now = datetime.now(timezone.utc)
168+
169+
timestamp = (now.hour*60 + now.minute)*60 + now.second
170+
for beacon in beacons:
171+
self.add_observation(beacon, timestamp)
172+
173+
174+
# Contact management and proximity logger
175+
#########################################
176+
def add_observation(self, beacon, timestamp):
177+
''' Adds a new contact observation to the current epoch.
178+
179+
Arguments:
180+
beacon(b[]): observed beacon.
181+
timestamp(int): offset to beginning of UTC 0:00 in seconds.
182+
'''
183+
if beacon in self.observations:
184+
self.observations[beacon].append(timestamp)
185+
else:
186+
self.observations[beacon] = [timestamp]
187+
188+
def rotate_contacts(self):
189+
''' Move to the next day for contacts.
190+
191+
Create a new empty set of contacts, update and truncate history.
192+
'''
193+
self.contacts.insert(0, {})
194+
# truncate history
195+
while len(self.contacts) > RETENTION_PERIOD:
196+
self.contacts.pop()
197+
198+
def process_epoch(self):
199+
''' Process observations/epoch, add to contact set if >threshold.
200+
201+
Iterate through all observations and identify which observations
202+
are above a threshold, i.e., turn the set of observations into a
203+
set of minimal contacts. This process aggregates multiple
204+
observations into a contact of a given duration. As a side effect,
205+
this process drops timing information (i.e., when the contact
206+
happened and only stores how long the contact lasted).
207+
'''
208+
for beacon in self.observations:
209+
# TODO: as of now, we subtract the last observation from the first
210+
# and use this as overall timestamp. The real app will use a
211+
# elaborate way to identify a contact, e.g., by averaging and
212+
# adding some statistical modeling across all timestamps.
213+
if len(self.observations[beacon]) >= 2:
214+
duration = self.observations[beacon][-1] - self.observations[beacon][0]
215+
if duration > CONTACT_THRESHOLD:
216+
self.contacts[0][beacon] = duration
217+
self.observations = {}
218+
219+
220+
# Update infected and local risk scoring
221+
########################################
222+
def check_infected(self, inf_SK0, date, now = None):
223+
''' Checks if our database was exposed to an infected SK starting on date.
224+
225+
NOTE: this implementation uses the date of the SK_t to reduce the
226+
number of comparisons. The backend needs to store <SK, date> tuples.
227+
228+
Check if we recorded a contact with a given SK0 across in our
229+
database of contact records. This implementation assumes we are
230+
given a date of infection and checks on a per-day basis.
231+
232+
Arguments
233+
infSK0(b[]): SK_t of infected
234+
date(str): date of SK_t (i.e., the t in the form 2020-04-23).
235+
now(datetime): current date for mock testing.
236+
'''
237+
if now == None:
238+
now = datetime.now(timezone.utc)
239+
infect_date = datetime.strptime(date, "%Y-%m-%d")
240+
days_infected = (now-infect_date).days
241+
inf_SK = inf_SK0
242+
for day in range(days_infected, -1, -1):
243+
# Create infected EphIDs and rotate infected SK
244+
infected_ephIDs = KeyStore.create_ephIDs(inf_SK)
245+
inf_SK = KeyStore.get_SKt1(inf_SK)
246+
247+
# Do we have observations that day?
248+
if len(self.contacts)<=day or len(self.contacts[day]) == 0:
249+
continue
250+
251+
# Go through all infected EphIDs and check if we have a hit
252+
for inf_ephID in infected_ephIDs:
253+
# Hash check of infected beacon in set of daily contacts
254+
if inf_ephID in self.contacts[day]:
255+
duration = self.contacts[day][inf_ephID]
256+
print("At risk, observed {} on day -{} for {}".format(inf_ephID.hex(), day, duration))
257+
258+
259+
# Mock Application that ties contact manager and keystore together
260+
##################################################################
261+
class MockApp:
262+
def __init__(self):
263+
''' Initialize the simple mock app, create an SK/ephIDs.
264+
'''
265+
self.keystore = KeyStore()
266+
self.ctmgr = ContactManager()
267+
268+
def next_day(self):
269+
# Rotate keys daily
270+
# ASSERT(This function is executed at 0:00 UTC)
271+
self.keystore.rotate_SK()
272+
self.keystore.rotate_ephIDs()
273+
self.ctmgr.rotate_contacts()
274+
275+
def next_epoch(self):
276+
self.ctmgr.process_epoch()

README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Mock DP3T: Decentralized Privacy Preserving Proximity Tracing
2+
3+
* Source code license: Apache 2.0
4+
5+
The full set of documents for DP3T is at <https://github.com/DP-3T/documents>.
6+
Please refer to the technical documents and whitepapers for the descriptions
7+
of the implementation.
8+
9+
At its core, DP3T describes a decentralized framework for proximity tracing
10+
where the smart phones store all observed contacts in a local database.
11+
During periodic intervals, the apps poll a database of infected SKt vectors
12+
and check if they have been exposed to any of the beacons sent by an infected
13+
app.
14+
15+
One of the core challenges in designing and implementing such a framework is
16+
the large amount of unknowns. This mock implementation of the framework helps
17+
us identify and measure some of these unknowns so that we can carry out better
18+
decisions and fine tune our technical design. This mock implementation is not
19+
meant to be a real implementation.
20+
21+
The mock implementation focuses on the app side. The backend is extremely
22+
simple and may consist of just a set of flat files of `<SK, day>` tuples. The
23+
app periodically polls and downloads lists of newly infected, checking locally
24+
if they have been exposed.
25+
26+
27+
## Cryptographic primitives: `KeyStore`
28+
29+
The cryptographic primitives are implemented in the class `DP3T.KeyStore`.
30+
The main methods are `rotate_SK` which rotates the private SK to the next day
31+
and `rotate_ephIDs` which creates the set of `ephIDs` for the new day. These
32+
methods are straight forward implementations of the description in the
33+
whitepaper.
34+
35+
36+
## Contact tracing: `ContactManager`
37+
38+
The contact tracing and management is implemented in the class
39+
`DP3T.ContactManager`. Newly received beacons come in through `receive_scans`
40+
where they are added to local observations. Each epoch, these local
41+
observations are evaluated in `process_epoch` and contacts that have been
42+
around long enough (there was sufficient contact) are added to the daily
43+
observed contacts. The `check_infected` method takes an `SK` and date and
44+
checks all local contacts if one of the `EphIDs` of that `SK` were observed
45+
and then issues a warning.
46+
47+
48+
## Example run
49+
50+
The file `example_run.py` show cases the different aspects of proximity
51+
tracing. The three persons Alice, Bob, and Isidor interact in different
52+
settings and record the corresponding ephemeral IDs. At one point, Isidor
53+
is tested positive and agrees to submit his `SK_t`. Next, Alice and Bob test
54+
for this `SK_t` and Bob detects that he was at risk 1 day ago.
55+
56+
You can run the example with `python3 example_run.py`.

0 commit comments

Comments
 (0)