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 ()
0 commit comments