Static Analysis of TraceTogether for Android
1 Introduction
TraceTogether is a COVID-19 contact tracing mobile application developed by Singapore's Government Technology Agency (GovTech). Along with the closed-source TraceTogether app, GovTech has also released an open-source version known as OpenTrace, and a white paper describing the protocol used by TraceTogether, which they call BlueTrace.
In this report, we conduct a detailed static analysis of TraceTogether on Android. Our research seeks to answer 3 main questions:
- Is the closed-source TraceTogether app identical or largely similar to the open source OpenTrace app?
- How does using TraceTogether impact a person's privacy?
- Does TraceTogether contain any security vulnerabilities?
To answer these questions, we reverse engineered the TraceTogether app to uncover its architecture and inner workings. We focused on the backend components of TraceTogether, and ignored most of the UI-related code.
This report assumes the reader is already familiar with the general concepts surrounding TraceTogether, and has read the BlueTrace protocol white paper.
Internally, TraceTogether goes by the name BlueTrace, as can be seen by the Android application ID (sg.gov.tech.bluetrace). We will refer to the TraceTogether app as BlueTrace henceforth. Note that the BlueTrace white paper refers to "BlueTrace" as a protocol; in this report we will explicitly refer to the protocol as the "BlueTrace protocol".
We conducted our analysis on BlueTrace version 2.0.15, downloaded from the Google Play Store. This is identical to the version that a regular user would install and use on their phone.
A newer version, 2.1.4, was released after we had completed most of our analysis. We performed a differential analysis of the latest version and present our findings in Section 8.
2 Summary of Findings
We present a summary of our key findings here for ease of reference.
2.1 BlueTrace and OpenTrace
BlueTrace and OpenTrace are largely similar and appear to be descended from the same code base. BlueTrace adds a few new features: pausing, SwiftMED detection, a metrics component, and some new remote command features that we describe shortly.
2.2 Privacy
BlueTrace generally respects privacy, and keeps to its word on what data it collects. It does not track a user's physical location via Android's location system. Personal identification information that is stored locally is encrypted, and is never transmitted except during the initial registration phase.
2.2.1 TempIDs
BlueTrace uses TempIDs as a means of preserving privacy while exchanging contact tracing records. However, we found that TempIDs still pose a privacy risk: because they are publicly broadcasted, it is important that a particular TempID is never associated with some other personal information. For example, if a string containing a TempID is transmitted in clear along with the user's IP address, then a link would be established between that TempID and the IP address.
We found that BlueTrace did not expose TempIDs to external parties. Only the BlueTrace app and the central server know what TempIDs were issued to a user. However, BlueTrace maintains a local store of TempIDs. This store is in clear, and an attacker with privileged access to BlueTrace would be able to read it. Such an attacker would thus be able to associate the user to the TempIDs. However, since this attack would require some form of compromise of the BlueTrace client, its impact is low. Please see Section 5.2.6 for more information.
2.2.2 Metrics
BlueTrace contains a component that collects statistics and periodically sends them to a central server. This component was not present in OpenTrace. It collects the following data:
- some information about system state
- the number of contact tracing records collected over the period day
- the timestamp of the latest record collected
An analytics component also exists, which is identical to the one in OpenTrace; it mostly sends error messages to a central server, with no sensitive information.
Based on our analysis, the metrics and analytics components do not pose much of a privacy risk. It is possible for the metrics information to be used to infer some information about a user's location: for example, a relatively larger collection count would indicate more movement, or being in an area with more people.
2.2.3 Pause Feature
BlueTrace has a pause feature that allows a user to manually pause contact tracing. We verified that this feature works as expected; when turned on, no contact tracing records are exchanged. Nevertheless, we recommend turning Bluetooth off as well should a user be concerned about their privacy.
2.2.4 Remote Command Activation
BlueTrace's FCMService component allows the central server to remotely activate certain BlueTrace features. This component is also present in OpenTrace. The main features that can be activated remotely are scanning and advertising, which already run periodically. FCMService can also trigger BlueTrace to upload metrics to the server. We also verified that FCMService does not allow the central server to remotely unpause a paused BlueTrace app.
2.3 Security
BlueTrace appears to be written securely. We traced the flow of data from untrusted sources (remote BLE devices), and did not find any vulnerable uses of the data.
BlueTrace uses the Gson library to JSON-deserialize untrusted data. This is done in a secure fashion, but any vulnerabilities in Gson would be of concern. BlueTrace also eventually writes the untrusted data into an SQLite database, but fortunately this is done using parameterized queries, and so SQL injection attacks are not possible.
Some minor bugs were found as well, but they were assessed to have no security impact.
2.4 BlueTrace version 2.1.4
We performed a quick analysis of the latest version of BlueTrace, 2.1.4. We did not find any serious issues with this version, and our conclusions remain the same.
BlueTrace 2.1.4 does include a new feature that allows it to manage SafeEntry check-ins. Part of this feature logs down check-ins in the database and stores it for 25 days. This has a minor privacy impact as an attacker with privileged access to BlueTrace would be able to see the check-ins. However, non-BlueTrace users are likely to have equivalent information stored in the history of the web browser they use to access SafeEntry.
2.5 Conclusion
BlueTrace is generally respectful of a user's privacy, with only one minor privacy issue relating to TempID storage. BlueTrace also does not appear to have any significant security flaws, and only a few minor bugs.
3 Research Setup
We downloaded BlueTrace version 2.0.15 from Google Play Store 1 using
an Android mobile phone (Samsung Note 5, SM-N920I) rooted via Magisk. The
APK files residing in /data/app/sg.gov.tech.bluetrace
were extracted for
analysis.
BlueTrace consists of multiple APK files. The main code resides in
base.apk
, while the other APK files are for resources, and a native
library. On our ARM64 test device, the native library is
libsqlcipher.so
, which is the SQLCipher encrypted SQLite extension. We
did not find any code in BlueTrace that actually uses this library.
We performed a static analysis by decompiling the base.apk
file using
PNF Software's JEB decompiler (version 3.x). We also took a cursory look
at the libsqlcipher.so
native library in IDA Pro (version 7.5), and did
not notice anything unusual.
Earlier versions of BlueTrace (e.g. 1.6.1) used an obfuscator to protect the APK files, likely DexGuard. Obfuscation made the APK more difficult to decompile, and required more effort to debofuscate important parts of the code. Fortunately, in the version we looked at (2.0.15), obfuscation was fully removed, and symbols were also preserved. This made analysis far easier, and allowed us to be more thorough.
Due to time and resource limitations, we only conducted a static analysis of BlueTrace. For a complete audit, we recommend conducting a dynamic analysis as well; in particular, the Bluetooth code should be tested against malformed inputs.
We compared BlueTrace against the latest version of OpenTrace available on the OpenTrace GitHub repository (commit 7e0cdd8).
4 Architectural Overview
The main purpose of BlueTrace is to disseminate and collect contact tracing records, which are to be stored for a specified duration and provided to a health authority should the user of BlueTrace test positive for COVID-19. As such, BlueTrace needs to provide the following services:
- Scanning: periodically scan for other BlueTrace devices in the vicinity, and exchange contact tracing records with all devices found.
- Advertising: advertise that this device is a BlueTrace device, and exchange contact tracing records with any device that connects to it.
- Temporary ID management: maintain a collection of usable BlueTrace Temporary IDs (TempIDs), retrieving them from the server when necessary.
- Uploading: Send contact tracing records to the health authority.
- Purging: Removal of expired contact tracing records.
BlueTrace also provides a few other miscellaneous services:
- Metrics: Collect metrics about the app and upload to a server
- Health Check: Periodically perform sanity checks on BlueTrace's state, and fix any issues found.
- Pausing: Temporarily pause BlueTrace collection.
- SwiftMED Detection: Detect if the SwiftMED app is installed, and disable BlueTrace if so.
The implementation of the above services are described in detail in Section 7, except for the uploading service, which we did not analyze for this report.
BlueTrace is written in Kotlin, as is OpenTrace. To reverse engineer BlueTrace, we used JEB, which decompiles APK files into Java. Based on our analysis, BlueTrace does not appear to use any native code. 2
The Java namespace used by BlueTrace is sg.gov.tech.bluetrace; unless otherwise specified, all package and class names mentioned in this report are relative to this namespace.
4.1 Bluetooth Terminology
BlueTrace devices communicate via Bluetooth Low Energy (BLE). In BLE terminology, in any connection, one device is known as the Central device, and the other is known as the Peripheral device. In general, Central devices can scan for nearby Peripheral devices, and Peripheral devices allow Central devices to find them by advertising their presence. BlueTrace acts as both a Central and Peripheral device at the same time.
The Generic Attribute Profile (GATT) is the standard BLE profile used by most BLE devices, including BlueTrace. In Peripheral mode, BlueTrace exposes a service which contains characteristics, that are "variables" which can be read and written by a Central device. Reading and writing to characteristics allows BlueTrace devices to send and receive information between each other.
4.2 User Identifiers
As described in the white paper, a BlueTrace device sends temporary identifiers (TempIDs) to other devices for contact tracing purposes. These TempIDs are ephemeral and encrypted such that only a health authority possessing the secret key is able to decrypt them to obtain a permanent identifier. It is assumed this permanent identifier can be used to look up a user's personal identifying information from the database held by the health authority, e.g. their contact information.
We also noticed the existence of another identifier, the TTID, which is stored on the device and used when performing cloud actions such as making Firebase function calls. We do not know if the TTID is equivalent to the permanent identifier that can be obtained when decrypting the TempID. However, because it does not change, and is unique to each user, it effectively functions as a permanent identifier of that user.
5 Findings
In this section, we describe our analysis of BlueTrace from privacy and security angles. The findings presented here are based on and reference our detailed investigation into the inner workers of BlueTrace, which we describe in Section 7.
5.1 Similarity to OpenTrace
BlueTrace appears to be largely similar to OpenTrace. The main architecture and design are similar, as are the locations of the various components. As the version of BlueTrace we looked at was released after the OpenTrace source release, any differences are likely to be bug fixes or additional features added after the code bases diverged.
Key differences we noted between the code bases are listed below.
- Pause feature: BlueTrace has a feature that allows a user to pause it for a fixed amount of time. This is not present in OpenTrace.
- SwiftMED detection: BlueTrace detects the presence of SwiftMED, which appears to be a custom version of BlueTrace for use by Singapore Armed Forces personnel.
- Metrics: BlueTrace has an additional metrics component that is not present in OpenTrace, which regularly sends contact tracing statistics to a remote server.
- New FCMService features: FCMService listens for messages sent from a remote server, via Firebase Cloud Messaging. BlueTrace performs additional actions based on the messages: it can issue commands to BluetoothMonitoringService, such as to start scanning, and can also request for metrics to be uploaded to the server. OpenTrace does not have these additional features.
5.2 Privacy
A major concern surrounding BlueTrace is to what extent it affects the privacy of its users. At a high level, there are three privacy-related questions to consider: "Who are you?", "Where are you?", and "Who was nearby you?" We wish to preserve a user's privacy by preventing an attacker from determining the answers to these questions; to do so we consider the types of personal information that an attacker could make use of.
According to the TraceTogether website 3, BlueTrace only collects three kinds of data:
- Personal identification information, e.g. NRIC number, phone number; stored remotely and never shared
- Contact tracing records; stored within the device and only uploaded to a remote server when contact tracing is required
- App analytics data; stored remotely
At the same time, it also states that BlueTrace does not gather or make use of physical location information.
In other words:
- The BlueTrace app and the remote server know the identity of the user
- Only the BlueTrace app will know who the user has been in contact with (and to a limited extent), outside of contact tracing being activated
- Neither the BlueTrace app nor the remote server know the physical location of the user.
We thus need to verify the following:
- Personal identification information cannot be accessed by any other party besides the app and the remote server
- Contact tracing records stay on the device, except when contact tracing is initiated by a health authority
- Location information is never accessed, transmitted or stored
- Metadata (e.g. metrics, analytics) does not reveal contact tracing information nor location information.
In addition, we also consider a few different threat scenarios, and what kinds of personal information about a user could be obtained by a malicious actor.
Note that for this report, we do not go into the relative merits and flaws of centralized contact tracing record processing, nor the necessity of registering personal information with a central server (both of which BlueTrace does); our goal is to purely to verify the above guarantees.
5.2.1 Source vs sink analysis
When analyzing the flow of privacy-related information through a system, we can either begin at the sources where the information is obtained, or at the sinks where the data leaves the system (and e.g. enters another app, or a remote server). Static sink analysis on Android is tricky due to the sheer number of ways an app can send data over the network. As such, we focus on tracing the flow of said information through the app from the place where it was created, to verify that the information does not reach anywhere it should not.
5.2.2 Firebase security
BlueTrace makes use of Firebase for cloud operations. In this report, we assume the Firebase transport layer is secure from man-in-the-middle attacks, i.e. any data sent between the app and the remote server via Firebase cannot be eavesdropped or modified, except in Section 5.2.11 where we consider the impact of an attacker that is able to eavesdrop on Firebase traffic.
5.2.3 Identification and contact details
Identification and contact details are gathered from the user during the registration process. This information includes a contact phone number, and also some other identifying information, such as the user's NRIC or passport number.
As we describe in Section 7.8, once identification information is collected, BlueTrace will send the information to the server via a Firebase function call. At this point, we assume the data is safely stored on the server end; analysis of the server infrastructure is out of the scope of this report.
At the same time, the identification information is also encrypted, and
stored on disk as the preferences ENCRYPTED_USER_DATA
and
ENCRYPTED_PHONE_NUMBER
. The encryption logic uses AES-GCM, and the
secret key is generated and stored using Android's Keystore
system. Barring any flaws in Keystore, this method of storing the data
should be secure, and should be encrypted data be somehow leaked, it would
not be possible to decrypt it without at least privileged access to the
device.
BlueTrace decrypts the identification information preferences only in one place: when displaying user profile information in the UI.
Thus, as far as the BlueTrace client app is concerned, identification and contact details are kept private. While the information is actually present on the device and not just on the remote server, it is stored in an encrypted form.
Note that an unencrypted identifier is stored on the device: the
TTID. This value is stored in the preference TTID
, and is transmitted
whenever Firebase functions are called. However, given only the TTID, it
would not be possible for a third party to determine a user's actual
identity.
5.2.4 Contact tracing records
We verified that contact tracing records received by BlueTrace stay on the
device and do not get transmitted to any remote location. When BlueTrace
receives a contact tracing record, it processes it, and then saves it into
an SQLite database record_database
. As described in Section
7.4.5 (Data Access Analysis), the only time contact
tracing records are accessed from this database and transmitted out of the
device is after contact tracing has been initiated. Thus, contact tracing
records do not get uploaded to the server or anywhere else in normal use.
Metadata related to the contact tracing data is transmitted however, which we will discuss in Section 5.2.8.
5.2.5 TempIDs as a form of personal information
As discussed previously, TempIDs can only be linked to a permanent user ID (and thus personal information) by decrypting them with a secret key (referred to as the TempID secret key), which only the health authority has. However, links between TempIDs and other identifying information might be possible. If, for example, it is known that a specific device sent out a particular TempID at a particular time, then collected contact tracing records could be looked up to see if they contain that TempID. If so, then it would be known that the device must have been nearby the location where the contact tracing record was received. We thus need to verify that TempIDs are never exposed such that they may be linked with some other identifying information.
TempIDs are created by the remote server, and downloaded via a Firebase function call (see Section 7.3 for more details). We assume that the remote server and Firebase transport are secure. The downloaded TempIDs are kept in a local store managed by TempIDManager.
We investigated all places where TempIDs are read from the local store,
and determined that they only leave the app when they are sent in
BlueTrace Encounter Messages, which do not contain any identifying
information. We also looked at log messages; while BlueTrace does actually
log messages containing TempIDs (see TracerApp.thisDeviceMsg()
), they
are only written when BlueTrace is compiled in debug mode, and thus do not
appear on the non-debug versions of BlueTrace available on the Play Store.
Finally, we verified that BlueTrace does not retain any of the contact
tracing records sent to other devices (which would contain
TempIDs). TempIDs are cached momentarily in memory in the GattServer
component (readPayloadMap
), but this cache is usually flushed once the
record exchange is complete. It should thus be unlikely for a third
party external to the app to obtain any TempIDs, or link them to the
device.
We next considered a scenario where an attacker might have privileged access to data within the BlueTrace app; for example, if BlueTrace was running on a compromised device, or some vulnerability was used to access data private to BlueTrace.
In such a situation, an attacker would be able to access the local
TempID store. As detailed in Section 7.3, the store is backed by
a file tempIDs
, which contains a JSON-serialized list of TempIDs. There
are usually enough TempIDs to last over a 24 hour period. TempIDs that
have expired are not removed; they will only be removed when the file is
overwritten, which happens when a new batch of TempIDs are downloaded (via
TempIDManager.getTemporaryIDs()
). The server decides when the next fetch
time will be (the value can be found in the preference NEXT_FETCH_TIME
);
we found it to be an interval of around 12 hours on a test device. Also
note that the tempIDs
file will not be overwritten if new TempIDs could
not be downloaded, e.g. due to lack of Internet connectivity.
All TempIDs found in the local store (about 12 hours worth) would thus be linked to the device containing the store. An attacker with access to those TempIDs would then be able to link the device to any other device containing contact tracing records with matching TempIDs.
We now consider a possible attack that a malicious actor with sufficient privileges could conduct.
5.2.6 TempID Exposure Attack
Consider devices A and B. Let the critical period be the time interval in
between the LAST_FETCH_TIME
and NEXT_FETCH_TIME
values on A. Suppose A
and B were in close proximity ("met") during the critical period. An
attacker can determine that A and B met if:
- the attacker was able to read A's
tempIDs
file at any time during the critical period - the attacker has access to B's contact tracing records covering the critical period
Note that device B does not need to be a BlueTrace device, but could be a
rogue device implementing the BlueTrace protocol but with direct access to
received contact tracing records. In the case of a BlueTrace device, the
attacker would have 21 days from LAST_FETCH_TIME
to access the records,
as collected contact tracing records will only be purged after 21
days. 4
There are a few mitigating factors for this attack. First, being able to access the TempID store on A is not easy. It might require fully compromising A, in which case the attacker might also have access to more useful information, such as A's GPS coordinates. Second, accessing B's contact tracing records is not easy as well, if B is a BlueTrace device; it might also require compromising B.
Here are some possible situations in which the attack might be useful:
- The attacker can carry out the attack, but does not have sufficient privileges to obtain any other location information from A nor B.
- The attacker is only able to carry out the attack after A and B met. At this point, the devices might not contain any location history that could be used to determine that A and B met, but the attack would be able to do so.
The second scenario would include situations where the attacker could read the file on A during the critical period, but could only obtain contact tracing records from B some time later.
Preventing this attack is difficult, as BlueTrace needs to maintain a
local store of TempIDs to operate when offline. Only a design change to
using asymmetric encryption would fully protect against it. To reduce the
effectiveness of the attack, BlueTrace could try to reduce the number of
TempIDs present in the system where possible. For example, it could remove
all expired TempIDs from the tempIDs
file (in
TempIDManager.retrieveTemporaryID()
), which would reduce the length of
the critical period, and prevent an attacker from determining that A and B
had met in the past.
We also note that it might be possible to locate TempIDs within the device's memory, and there could also be side-channel attacks allowing another process to extract TempIDs, whether from memory, or from the BLE stack.
5.2.7 Use of location information
As mentioned above, BlueTrace is designed to not keep track of a user's
physical location. However, due to Android's permission model, it requires
the ACCESS_FINE_LOCATION
permission in order to make use of BLE; this
permission also gives BlueTrace the ability to query a user's physical
location, should it wish to. We thus need to verify that it does not
do this.
On Android, fine-grained location information can be obtained using
LocationManager, by calling LocationManager.getLastKnownLocation()
. We
searched for all instances in the code where a call is made to
getLastKnownLocation()
. We found only one such call, in the TwilightManager
class (androidx.appcompat.app.TwilightManager). This is a standard
library and is not part of BlueTrace. As such, we conclude that BlueTrace
does not use getLastKnownLocation()
to access the device's location.
We note that it is possible to determine a device's physical location through other means. However we have not found any code within BlueTrace that appears to do this.
5.2.8 Analytics and Metadata
BlueTrace contains two components that collect and send metadata to the remote server: Firebase Analytics and Metrics.
Firebase Analytics is used to send additional analytics information to the server. This tends to be during erroneous conditions, and none of the messages appear to contain any personal information nor data related to contact tracing.
As described in Section 2.2.2, BlueTrace periodically collects contact tracing statistics and sends them back to the server. This happens either after new TempIDs are downloaded, or when a specific Firebase Cloud Message is received (see Section 5.2.10 below, and Section 7.7). In either case, simple data about the system state, the number of contact tracing records collected over the previous day, and the timestamp of the latest record collected are sent to the server.
The amount of metadata sent to the server is minimal, and unlikely to have a big impact on privacy. However, the collection counts could be used to infer some information about the user's location; a user with a higher collection count would be more likely to have been in a place with many people, or have been moving through different locations. In general, we do not consider this to be a significant breach of privacy.
5.2.9 Analysis of Firebase sinks
In addition to the above source analyses, we also inspected all places in BlueTrace where Firebase is used to send data to the cloud. We verified that sensitive personal data is never uploaded, except for two cases:
- During the registration phase, the user's mobile number and NRIC/passport number are uploaded.
- When an upload request is triggered by a health authority, after PIN verification, the user's contact tracing information is uploaded.
Both of these cases are as expected.
5.2.10 Remote command activation
BlueTrace contains a feature that allows the server to remotely issue commands to client devices (see 7.7 for more details). The list of possible commands and what they do can be found in Section 7.2.7; they consist of commands that start BlueTrace, start scanning or advertising, and so on. This is not of much concern, as those actions already happen regularly on the device.
5.2.10.1 Remote Unpausing
One particular command we were concerned about was the ACTION_USER_PAUSE
command. This command is used not only to pause BlueTrace, but also to
unpause it (see Section 7.2.7, ACTION_USER_PAUSE
). If a
user has paused BlueTrace because they do not want their contact tracing
information to be collected, it would be unacceptable that the server
could then unpause BlueTrace and resume collection.
Fortunately, it is not possible for the server to unpause BlueTrace, because to do so, an argument must be supplied along with the command. FCMService does not provide a way for the remote server to specify command arguments.
We note however, that this appears to be circumstantial, and not by
design; a future update to BlueTrace which allows remote commands to set
arguments would then inadvertently enable remote unpausing. We recommend
that FCMService make an explicit check on the command index specified by
the remote server, and ignore it if it is ACTION_USER_PAUSE
. This would
give users more certainty that BlueTrace will always honour the pause
setting. We discuss the pause feature further in Section 5.2.12 (Pausing).
5.2.11 Threat scenarios
We now consider a few specific threat scenarios specific to privacy concerns. We will refer to the BlueTrace users whose privacy we wish to preserve as Alice and Bob. The goal of the attackers is to obtain private information about Alice and Bob: their personal identification information, and whether or not Alice and Bob have been in close proximity with each other.
5.2.11.1 Proximate Malicious User
Consider a malicious user Carol, with one or more rogue devices that implement the BlueTrace protocol while providing full access to all data received. Carol's devices can be in close proximity to Alice's or Bob's.
Carol would be able to see the contact tracing records sent by both. However since the TempIDs cannot be linked to any permanent identity, accessing those records would not pose a privacy risk. The TempIDs also do not reveal whether or not Alice and Bob have met.
5.2.11.2 Compromised Client
Now consider an attacker Dan, who is able to compromise Alice's device in a way that gives him privileged access to BlueTrace. Dan does not need to be physically proximate to Alice or Bob.
Dan would then have access to:
- Alice's TTID, a permanent identifier. But this identifier cannot be used to determine any personal information.
- Alice's contact tracing log. But without the TempID secret key, he cannot determine who the contacts in the log are.
- Alice's
ENCRYPTED_USER_DATA
andENCRYPTED_PHONE_NUMBER
values. Depending on his privilege level and the security of the Android Keystore setup, he may or may not be able to decrypt the values to obtain personal identification information.
Dan would also have access to Alice's tempIDs
file. If he is able to
compromise Bob's device as well, he would be able to conduct the attack
described in 5.2.6.
One possible scenario is where Dan captures Alice and Bob at some point shortly after they have met, and compromises both their phones in order to prove that they have met.
5.2.11.3 Firebase Eavesdropper
Next, consider an attacker Eve, who is able to eavesdrop on the traffic that travels between BlueTrace and Firebase.
Eve would be able to see Alice and Bob's personal identification information, if they registered after Eve began eavesdropping. Eve would also be able to determine Alice and Bob's TTIDs, and see all their TempIDs.
However, she would need some other mechanism to obtain their contact tracing records in order to know if they have met. Besides compromising their devices, she could plant rogue BlueTrace devices in areas where Alice and Bob might meet; if the rogue device detects TempIDs that belong to both Alice and Bob, she would then know they were in the same location.
5.2.11.4 Compromised Server
Consider an attacker Frank, that is able to compromise the server backend used by BlueTrace.
Frank would have access to important BlueTrace credentials. He would be able to look up the personal identification information of any BlueTrace user. He would also have access to the TempID secret key, allowing him to decrypt the TempIDs in any contact tracing record he has access to.
However, just like Eve, Frank does not have access to Alice and Bob's contact tracing records, and would need some other mechanism to obtain them.
Frank would possess the PIN needed to make Alice or Bob's devices upload contact tracing records to the server (which Frank controls). If Frank could coerce or deceive Alice or Bob into performing an upload, he would then be able to access their contact tracing records.
5.2.11.5 Government Surveillance
Finally, consider a government-sponsored attacker Grace. Grace is able to access the same set of credentials as Frank, and thus have the same level of access, but with the same limitations. Grace might, however, be more capable of conducting the rogue device attack at a large scale, or to coerce Alice or Bob to reveal their contact tracing records.
We should note here, however, that Grace would also have access to many other mechanisms that could allow her to achieve similar levels of surveillance. For example, Grace might be able to make use of information from telecommunication providers to determine Alice and Bob's physical location. Grace might also be able to use camera footage or similar infrastructure to locate them.
In particular, Singapore already has surveillance infrastructure that would provide at least as much surveillance as what could be achieved by abusing BlueTrace, without needing to deploy a vast array of rogue BlueTrace devices. As such, we believe that using BlueTrace does not greatly increase an individual's risk of being surveilled by the government 5.
5.2.11.6 Discussion
As can be seen from the above scenarios, the partial decentralization strategy in BlueTrace is effective: in general, an attacker would need both information from the server, and from the client, in order to obtain personal information.
5.2.12 Other privacy features
We go through a few other privacy-centric features of BlueTrace, and whether they are implemented correctly.
5.2.12.1 Data purging
5.2.12.2 Pausing
Another privacy-related feature is pausing. A user can pause BlueTrace for a set amount of time through the user interface. We verified that BlueTrace correctly suspends scanning and advertising services during the period where it is paused, except for any scanning and advertising that might be in progress at the point of pausing. No contact tracing records are sent or received while paused. See Section 7.2.10 for a full analysis.
As mentioned in the Section 5.2.10, in the version of BlueTrace we analyzed, it is not possible for the remote server to unpause BlueTrace.
While we have verified that pausing works as expected, we would still recommend BlueTrace users to additionally disable Bluetooth on their devices during periods at which they would not want contact tracing to take place.
5.3 Security
BlueTrace is an app constantly sending and receiving data from other untrusted devices. As such, the security of BlueTrace is of great concern, as a security flaw could mean that a large number of devices could be quickly compromised in a short period of time.
5.3.1 Threat Model
We assume an attacker that is able to place one or more devices in close proximity to a target device, and be able to send and receive BlueTrace contact tracing records to and from the target. The goal of the attacker is to either cause a denial of service to the target device, or achieve remote code execution on the target device.
Note that in this report we do not investigate attacks that compromise the integrity of the contact tracing process, for example by creating fake contact tracing records, as we are primarily concerned with the risks to the end user.
5.3.2 Attack Surface
Given our threat model, the attack surface consists of BLE layer messages sent from the attacker device to BlueTrace. This happens in the following situations:
- When the target scans for devices and processes the attacker's advertisement
- When the target (as a Central device) connects to the attacker and retrieves an Encounter Message (reading a characteristic)
- When the target (as a Peripheral device) is connected to by the attacker and is sent an Encounter Message (writing a characteristic)
Our analysis only focuses on potential vulnerabilities in BlueTrace; as such, we do not consider vulnerabilities in lower layer code such as Android's implementation of BLE, or the Bluetooth firmware or hardware.
We now go through each potential attack vector in detail to verify whether or not it can be used to compromise the target.
5.3.3 Advertisements
StreetPassScanner is the BlueTrace component that manages scanning of
devices. It registers a BleScanCallback object to handle advertisements
from remote devices: BleScanCallback.onScanResult()
will process each
scan result.
Most of the advertisement data sent by the remote device are parsed in
lower layer code. onScanResult()
only reads two untrusted values: an
integer representing the transmission power, and an array of bytes known
as the manufacturer-specific data. The values are stored in an object and
then passed on for further processing. Based on our analysis, none of
these values are used in situations that could lead to compromise; in fact
the manufacturer-specific data is never accessed.
Please refer to Section 7.4.1 for more details.
5.3.4 Central Characteristic Read
StreetPassWorker manages the exchange of contact tracing records when a device acts as a Central device, and CentralGattCallback is the callback class that handles the data sent by remote devices. When a remote BlueTrace device is detected via scanning, StreetPassWorker will connect to it, and read the value of a specific characteristic. The value returned by this operation is an untrusted byte array.
CentralGattCallback will eventually decode the value byte array into a UTF-8 string, and then deserialize it as a JSON string (using the Gson library), into a V2ReadRequestPayload object. Please see Sections 7.4.2 (CentralGattCallback) and 7.5 for more details.
The values from the deserialized V2ReadRequestPayload object are used to
initialize a ConnectionRecord object, which is then attached to an
ACTION_RECEIVED_STREETPASS
intent and locally broadcasted.
StreetPassReceiver receives the intent, extracts the ConnectionRecord, and uses the values within to create a new StreetPassRecord, which is finally saved to the database.
From this point, the values within the database are generally not accessed, except when uploaded to the server for contact tracing. See Section 7.4.5 (Data Access Analysis) for more details.
5.3.5 Peripheral Characteristic Write
StreetPassServer manages the exchange of contact tracing records when a
device acts as a Peripheral device, and the gattServerCallback
object
created as part of GattServer handles data sent by remote devices. When a
remote BlueTrace device sends a request to write to a characteristic,
the function onCharacteristicWriteRequest()
will be called, with
untrusted values.
Once the value is received fully as an array of bytes, it will be passed
to the saveDataReceived()
function, which will ultimately decode the
byte array into a UTF-8 string, and then deserialize it as a JSON string
(using the Gson library), into a V2WriteRequestPayload object. Note that
this is symmetric to the processing happening in the Central Read data
flow. Please see Sections 7.4.4 (GattServer Callbacks) and
7.5 for more details.
As in the Central Read case, the values from the deserialized
V2WriteRequestPayload object are used to initialize a ConnectionRecord
object, and then broadcasted with an ACTION_RECEIVED_STREETPASS
intent.
StreetPassReceiver receives the intent, and proceeds identically to the Central case, eventually writing the data to the database, which from that point on will generally not be accessed.
5.3.6 Deserialization
Deserialization is often a source of vulnerabilities in Java code. We took a close look at the use of deserialization in BlueTrace to verify there are no bugs in how it is being used.
The Gson library is used to deserialize an untrusted byte array into an
object in both the Central Read and Peripheral Write cases. As the
deserialization call to Gson.fromJson()
explicitly provides the type of
the target object, it is not possible to cause the JSON string to be
deserialized into an arbitrary Java object. The target objects
(V2ReadRequestPayload, V2WriteRequestPayload) are simple and consist of a
few string and integer fields. As such we did not find anything of concern
in the deserialization code.
Nevertheless, we feel it might not be ideal to feed untrusted data from arbitrary Bluetooth devices into a complex parser, as is required with JSON deserialization. Any exploitable vulnerabilities in Gson would then become exploitable in BlueTrace. Given the simplicity of the data being sent between devices, it might be better to use a simpler format, e.g. a sequence of fixed-width values.
5.3.7 SQL Attacks
As untrusted data ultimately gets written into the database, we also investigated if there were any issues with the SQL commands used to insert data. Fortunately, all SQL queries are properly parameterized, and thus it should not be possible to conduct an SQL injection attack or similar.
5.3.8 Minor Bugs
We discovered a few minor bugs in BlueTrace, that do not have any impact on security. We summarize them here:
- In StreetPassWorker, when a new Work object is used to replace an older one, an associated timeout handler is not replaced.
- In StreetPassServer, when handling long writes, data is not written to the provided offset value but always appended to previously collected data.
- In StreetPassServer, when handling long writes, a request to cancel a write is ignored.
5.3.9 Discussion
As can be seen in the above analysis, we did not find any vulnerabilities in how untrusted data is processed within BlueTrace. This does not rule out the existence of any vulnerabilities, in particular in components we did not look at, such as lower-layer components like Gson, SQLite or the BLE stack. However, we conclude that users can have a level of assurance that BlueTrace is written securely.
6 Conclusion and Future Work
Based on our investigation, we conclude that:
- BlueTrace is generally respectful of a user's privacy, with only one minor privacy issue relating to TempID storage.
- BlueTrace does not appear to have any significant security flaws, and only a few minor bugs.
As our investigation only consists of a static analysis of BlueTrace's backend implementation, there is more research that can be done. We list a few possibilities here:
- The uploading component that sends contact tracing records to the central server
- SafeEntry, in the new 2.1.4 version
- UI components
- Dynamic analysis, including fuzzing of the BLE components
- Analysis of Firebase communications: enumeration of APIs accessible by a user and if any of these APIs leak information
7 Detailed Investigation
In this section, we go through each component of BlueTrace in detail. Note that all information presented here is based on the reverse engineering of the BlueTrace Android application, and not on the OpenTrace source code.
7.1 Overview
7.1.1 Main Components
BlueTrace's backend implementation can be divided into six major components:
- BluetoothMonitoringService (services.BluetoothMonitoringService), the top-level service that is launched during startup, and coordinates all other services.
- TempIDManager (idmanager.TempIDManager), downloads TempIDs from the server, stores them on disk, and provides the currently valid TempID to other components.
- StreetPass (streetpass.*), services that manage the dissemination and storage of contact tracing records: scanning, exchanging of records, and storing on disk.
- BlueTraceProtocol (protocol.*), implements the over-the-air message format used by BlueTrace, and is used by StreetPass.
In addition, there are also a few minor components worth investigating:
- Metrics (metrics.*), a service that collects and uploads app metrics
- FCMService (services.FCMService) is a service that receives remote messages sent via Firebase Cloud Messaging (FCM).
- User Registration (onboarding.newOnboard.register.*) contains the logic that registers a new user with the BlueTrace server components, including personal information.
- Debug Actions (PeekActivity and PlotActivity), special UI views for debugging purposes that are not normally accessible.
7.1.2 Event Model
Android apps are event-driven: apps handle intents sent from within the app or from external services. BlueTrace processes 4 different kinds of events:
- Global intents
- Local intents sent via LocalBroadcastManager
- CommandHandler messages, internal to BluetoothMonitoringService
- Firebase Cloud Messaging remote messages
7.1.3 Data Model
BlueTrace processes contact tracing records. These records are a log of encounters between two BlueTrace devices, where a Central device which connects to a Peripheral device. Both devices generate a record containing the TempID of the other device, as well as other required information.
As described in the white paper, Encounter Messages are transmitted between devices using BLE. These Encounter Messages are the lowest level form of contact tracing records, and are UTF-8 encoded JSON strings. The strings can be deserialized into JSON objects containing these values:
- protocol version
- organization code
- TempID of sending device
- device model of sending device
- received signal strength indicator (RSSI), when sent by a Central device.
After an Encounter Message is received, BlueTrace converts it into a ConnectionRecord, which is the same for both Central and Peripheral devices. The ConnectionRecord contains:
- protocol version
- organization code
- TempID of other device
- Bluetooth address and device model of Peripheral device
- Bluetooth address and device model of Central device
- RSSI
- transmission power: measured power as reported by a Peripheral device during BLE advertising, this is null if this device was the Peripheral .
The information in the ConnectionRecord is eventually converted into a StreetPassRecord that can be written into the database. StreetPassRecords contain the following fields:
- protocol version
- organization code
- TempID of other device
- device model of Peripheral device
- device model of Central device
- RSSI
- transmission power (might be null)
- timestamp, set to the time at which the StreetPassRecord was created.
7.1.4 Build Configuration
As we reverse engineered the compiled version of BlueTrace, configuration
variables set when the app was built appear as literal values. As such, in
this report, we will report those values as they appear, even if there is
evidence those values originated from a build configuration. For example,
BuildConfig.APPLICATION_ID
will be reported as "sg.gov.tech.bluetrace".
7.2 BluetoothMonitoringService
BluetoothMonitoringService is the top-level service in BlueTrace. The implementation can be found in the class services.BluetoothMonitoringService. For brevity we will abbreviate BluetoothMonitoringService as BMS for the remainder of this report, and abbreviate the services.BluetoothMonitoringService namespace as BMS in class and function names. In this section, all non-qualified names are methods or properties of BMS.
BMS is started when a service intent is sent to it via
Context.startService()
(i.e. typical Android service behaviour). This
will trigger onCreate()
to perform initialization if BMS is not already
running, and then onStartCommand()
with the associated intent.
7.2.1 SwiftMED
BlueTrace makes reference to a separate app called SwiftMED (com.swiftoffice.swiftmed). This appears to be a version of BlueTrace customized for use by the Singapore Armed Forces. BMS detects the presence of SwiftMED and disables BlueTrace while it is installed.
7.2.2 Initialization
When a new BMS instance is created, onCreate()
is called. This function
calls the setup()
function to do the following:
- Initialize CommandHandler, for handling commands internally.
- Initialize StreetPassWorker, which will register a few intent receivers.
- Register receivers for local intents via LocalBroadcastManager:
- StreetPassReceiver for
ACTION_RECEIVED_STREETPASS
- StatusReceiver for
ACTION_RECEIVED_STATUS
- StreetPassReceiver for
- Register receivers for global intents:
- BluetoothStatusReceiver for
android.bluetooth.adapter.action.STATE_CHANGED
- SwiftmedInstalledReceiver for
android.intent.action.PACKAGE_ADDED
- SwiftmedRemovedReceiver for
android.intent.action.PACKAGE_FULLY_REMOVED
- BluetoothStatusReceiver for
- Register listener for preference changes; this just updates
notifications when there is a change in language, via the
PREFERRED_LANGUAGE
preference. - Initialize databases:
- Creates a new StreetPassRecordStorage object
- Creates a new StatusRecordStorage object
- Creates a new Volley request queue; this does not seem to actually be used.
- Set up the notification manager and channel
- Get an instance of the FirebaseFunctions object for region "asia-east2"
- Retrieve and store the current valid TempID into
broadcastMessage
, usingTempIDManager.retrieveTemporaryID()
.
7.2.3 Event Handling
BMS's main event handler is a function called runService()
, which
handles Command objects. The top-level event handlers for intents and
messages eventually create Commands which are passed in to
runService()
.
7.2.4 Command Objects
Command is an enumeration class, representing possible actions that can be
performed by BMS. In general, Commands derive from intents; the enum index
representing a Command is extracted from an intent's extra data using the
key COMMAND_KEY
(sg.gov.tech.bluetrace_CMD
). Commands may also have an
associated argument, which can be extracted from the associated intent's
extra data using the key COMMAND_ARGS
(sg.gov.tech.bluetrace_ARG
).
These are the known Command indices in BlueTrace version 2.0.15:
Command | Index |
---|---|
INVALID |
-1 |
ACTION_START |
1 |
ACTION_SCAN |
2 |
ACTION_STOP |
3 |
ACTION_ADVERTISE |
4 |
ACTION_SELF_CHECK |
5 |
ACTION_UPDATE_BM |
6 |
ACTION_PAUSE_FOR_SWIFTMED |
7 |
ACTION_PURGE |
8 |
ACTION_USER_PAUSE |
9 |
7.2.5 Intent Handling
Intents sent to BMS are handled via onStartCommand()
. This function
extracts a BMS Command from the intent's extra data as described in
7.2.4. If no intent is supplied (possible when
onStartCommand()
is called to restart the service due to the
START_STICKY
flag, see documentation of Service.onStartCommand
), or if
the intent does not contain a Command index, then the Command is set to
ACTION_INVALID
.
onStartCommand()
then checks to see if BlueTrace is supposed to be
paused, by checking the value of the preference PAUSE_TS
; if the current
time is before the time represented by PAUSE_TS
, then BlueTrace should
be paused.
If BlueTrace is to be paused, onStartCommand()
will return immediately,
except if the Command associated with the intent is
ACTION_USER_PAUSE
. For more details on pausing, please see Section
7.2.10.
Otherwise, onStartCommand()
will then verify that the ACCESS_FINE_LOCATION
permission is available, and Bluetooth is enabled. Again, it skips this
verification step if the Command is ACTION_USER_PAUSE
.
Finally, if the intent is not null, runService()
will be called with the
Command and intent, in order to process the Command. Otherwise, an
ACTION_START
command will be queued for processing using CommandHandler.
7.2.6 Command Handler
CommandHandler (services.CommandHandler) is a subclass of Handler, and implements a separate message queue for Commands, internal to BMS. BMS uses it to schedule Commands for processing, without needing to create associated intents, and also to query the current status of scheduled Commands.
Messages are sent using Handler.sendMessage()
or
Handler.sendMessageDelayed()
, with an associated Command's index as the
what
argument to the message. CommandHandler eventually handles the
messages using handleMessage
, and processes the associated Command by
calling runService()
with the Command and a null intent.
7.2.7 Command Processing
Actual processing of commands takes place in the runService()
function.
Before processing the Command, runService()
performs the same checks as
in onStartCommand()
: it checks if BlueTrace is not supposed to be
paused, if location permissions are available, and if Bluetooth is
enabled. If not, unless the command is ACTION_USER_PAUSE
, runService()
will return immediately.
Otherwise, the Command will be processed as per below.
7.2.7.1 ACTION_START
runService()
calls setupService()
, which creates the following
objects:
- StreetPassServer with UUID of
serviceUUID
(B82AB3FC-1595-4F6A-80F0-FE094CC218F9) - StreetPassScanner with UUID of
serviceUUID
and scan duration ofscanDuration
(8s) - BLEAdvertiser with UUID of
serviceUUID
These classes are described in their corresponding sections.
Next, events are scheduled using AlarmManager to send a service intent to BMS with specific Commands:
ACTION_SELF_CHECK
athealthCheckInterval
(900s), will auto-repeatACTION_PURGE
everypurgeInterval
(24h) since the last purge (as stored in the preferenceLAST_PURGE_TIME
, scheduled usingAlarmManager.setRepeating()
6.ACTION_UPDATE_BM
atbmCheckInterval
(540s), will auto-repeat
Finally, actionStart()
is called, which requests for TempIDManager to
download new TempIDs from the server via
TempIDManager.getTemporaryIDs()
. This is an asynchronous Task that makes
a Firebase call. When the call completes, the current valid TempID is
retrieved using TempIDManager.retrieveTemporaryID
and used to set
broadcastMessage
. Then, setupScanCycles()
and
setupAdvertisingCycles()
are called, which use CommandHandler to send
ACTION_SCAN
and ACTION_ADVERTISE
Commands for processing.
7.2.7.2 ACTION_SCAN
The next scan is first scheduled via CommandHandler: an ACTION_SCAN
Command is scheduled to be processed after a delay. The delay is
calculated by adding a random value between minScanInterval
(36s) and
maxScanInterval
(43s) to scanDuration
(8s).
runService()
then checks to see if the SwiftMED app is installed, and
does nothing if so. Otherwise, it calls actionScan()
.
actionScan()
first checks if no existing TempID is set (in
broadcastMessage
), or if a new batch of TempIDs should be downloaded. If
not, performScan()
is called, which we detail below. Otherwise,
TempIDManager.getTemporaryIDs()
is called asynchronously download new
TempIDs. When the download is completed, a new TempID is retrieved, and
used to set broadcastMessage
. performScan()
is then called.
performScan()
sets up a new StreetPassScanner if required, and then
starts a scan by calling StreetPassScanner.startScan()
, unless
StreetPassScanner reports that a scan is already running.
The actual scan and actions relating to it are covered in Section 7.4.
Note: BMS contains a variable, infiniteScanning
, whose value is
occasionally checked. This value is statically set to false and is never
updated; hence we do not report behavioural changes due to this variable.
7.2.7.3 ACTION_STOP
actionStop()
is called, which calls Service.stopForeground()
and
Service.stopSelf()
to terminate the BMS service. This will eventually
lead to a call to onDestroy()
, which calls stopService()
. We describe
this in Section 7.2.8.
7.2.7.4 ACTION_ADVERTISE
The next advertisement is first scheduled via CommandHandler: an
ACTION_ADVERTISE
Command is scheduled to be processed after a delay of
advertisingDuration
(180s) + advertisingGap
(5s).
runService()
then checks to see if the SwiftMED app is installed, and
does nothing if so. Otherwise, it calls actionAdvertise()
.
actionAdvertise()
sets up a new BLEAdvertiser if required, and then
calls BLEAdvertiser.startAdvertising()
with an advertising duration of
advertisingDuration
(180s).
BLEAdvertiser (bluetooth.BLEAdvertiser) is a utility class wrapping
around Android's BluetoothLeAdvertiser
(android.bluetooth.le.BluetoothLeAdvertiser), abbreviated BLA
below. When startAdvertising()
is called, it will start advertising
using BLA.startAdvertising()
, with the UUID specified when
BLEAdvertiser was created (in practice, BMS.serviceUUID
), and with a
manufacturer ID of 0x33 and data of random bytes 7. After the specified advertising duration is over,
BLA.stopAdvertising()
is called to end advertising.
7.2.7.5 ACTION_SELF_CHECK
The next self-check (also referred to as a health check) is first
scheduled using AlarmManager: a service intent for BMS containing a
ACTION_SELF_CHECK
Command is scheduled to be sent after a delay of
healthCheckInterval
(900s).
runService()
then checks to see if the SwiftMED app is installed, and
does nothing if so. Otherwise, it calls actionHealthCheck()
.
actionHealthCheck()
calls performUserLoginCheck()
, which checks if the
onboarding is complete but the user is not logged in. If so, it logs an
event using Firebase analytics.
Next, performHealthCheck()
is called. If location permissions are not
available or Bluetooth is turned off, it does nothing. Otherwise, it calls
setupService()
to initialize the StreetPassServer, StreetPassScanner
and BLEAdvertiser objects should they not already be setup (see
Section 7.2.7.1).
The CommandHandler is then queried to check if a scan is scheduled; if not, a new scan is scheduled with a delay of 100ms. A similar check is done to see if an advertisement is scheduled, and a new advertisement is scheduled if not.
Finally, actionHealthCheck()
reschedules the ACTION_PURGE
Command to
be repeatedly sent purgeInterval
(24h) after preference
LAST_PURGE_TIME
, as was done in 7.2.7.1 8.
7.2.7.6 ACTION_UPDATE_BM
Note: "BM" here refers to broadcast message, which is another term for the current valid TempID.
The next TempID update is first scheduled using AlarmManager: a service
intent for BMS containing an ACTION_UPDATE_BM
Command is scheduled to be
sent after a delay of bmCheckInterval=
(540s).
runService()
then calls actionUpdateBm()
. This function checks if a
new batch of TempIDs should be downloaded. If so, or if broadcastMessage
is null, TempIDManager.getTemporaryIDs()
is called asynchronously. When
the TempIDs are successfully downloaded, a new TempID is retrieved, and
used to set broadcastMessage
.
7.2.7.7 ACTION_PAUSE_FOR_SWIFTMED
This action just changes the service's notification message to reflect that BlueTrace is paused due to SwiftMED.
7.2.7.8 ACTION_PURGE
actionPurge()
is called, which calls performPurge()
, which starts a
Kotlin coroutine. The coroutine calls
StreetPassRecordStorage.purgeOldRecords()
and
StatusRecordStorage.purgeOldRecords()
, with a purge timestamp of
purgeTTL
milliseconds (21 days) before the current time.
Note that this action was removed in BlueTrace 2.1.4, with its functionality moved to the LightLifter component. Please see Section 8 for more details.
7.2.7.9 ACTION_USER_PAUSE
runService()
first extracts the pause timestamp from the associated
intent's extra data; this timestamp is the time up till which BlueTrace
should be paused, and we denote it as pause_ts
below.
If the pause_ts
is non-negative, and in the future, runService()
sets
the preference PAUSE_UNTIL
to pause_ts
, and schedules an intent with
action ACTION_UNPAUSE
to be sent to UnpauseAlarmReceiver
(receivers.UnpauseAlarmReceiver) at the time pause_ts
.
When UnpauseAlarmReceiver receives the intent, it will send a service
intent to BMS with Command ACTION_START
.
If, however, pause_ts
is negative, then the preference PAUSE_UNTIL
is
set to that negative value, and BMS is immediately sent a service intent
with Command ACTION_START
. This effectively unpauses BlueTrace.
7.2.8 Teardown
BMS is shut down when it receives the ACTION_STOP
Command, or when other
circumstances require it to be terminated. This will eventually call
stopService()
, which we describe here.
stopService()
first calls teardown()
, which will in turn call
BLEAdvertiser.stopAdvertising()
(which calls BLA.stopAdvertising()
),
StreetPassServer.tearDown()
, and StreetPassScanner.stopScan()
. All
pending messages in CommandHandler are removed, and any pending intents
for scans, advertisements and TempID updates are cancelled.
All the receivers setup during initialization are unregistered.
Finally, StreetPassWorker is cleaned up by calling
StreetPassWorker.terminateConnections()
and
StreetPassWorker.unregisterReceivers()
.
7.2.9 System Startup
BMS is started on boot via the BOOT_COMPLETED
and QUICKBOOT_POWERON
intents. As specified in AndroidManifest.xml
, the class
boot.StartOnBootReceiver will receive these intents. This utility class
calls Utils.scheduleStartMonitoringService()
, which will send a service
intent to BMS with the ACTION_START
Command after 500ms.
7.2.10 Pause Feature Implementation
BMS implements BlueTrace's pause feature, via the Command
ACTION_USER_PAUSE
and the preference PAUSE_UNTIL
. When a pause is
requested via the UI, an ACTION_USER_PAUSE
Command is sent to BMS via an
intent, with a timestamp indicating the time at which BMS should be paused
till. As described in Section 7.2.7.9, this will set the
PAUSE_UNTIL
preference to the timestamp. Subsequently, checks in
onStartCommand()
and runService()
will avoid performing any actions
while the time PAUSE_UNTIL
is in the future.
Note however that there are no checks on PAUSE_UNTIL
in other components
such as StreetPass, BLE Services and FCMService; these components will
still continue running. Due to the architecture of BlueTrace, this means
that scanning and advertising will still continue until the scanning and
advertising duration is reached; at that point, new scans/advertisements
will be scheduled via CommandHandler, but as these are processed by
runService()
, they will be skipped.
BlueTrace will automatically unpause itself once the PAUSE_UNTIL
timestamp is in the past. The UnpauseAlarmReceiver registered when pausing
takes place serves to trigger BMS to begin operations immediately at that
point, instead of waiting till the next scheduled scan or advertisement.
BlueTrace can be manually unpaused by providing a negative timestamp as an
argument to the ACTION_USER_PAUSE
Command. This happens when the user
clicks the "unpause" button in the UI.
7.2.11 SwiftMED Detection
BMS detects when SwiftMED is installed via SwiftmedInstalledReceiver
(receivers.SwiftmedInstalledReceiver), which receives
android.intent.action.PACKAGE_ADDED
intents. When SwiftMED is installed,
SwiftmedInstalledReceiver will send an intent to BMS with an
ACTION_PAUSE_FOR_SWIFTMED
Command. Similarly, SwiftmedRemovedReceiver
(receivers.SwiftmedRemovedReceiver) detects when SwiftMED is removed, and
will send an intent to BMS with an ACTION_START
Command.
The presence of SwiftMED is checked inside runService()
, before
advertising, scanning, and performing self-checks, and none of those
actions are taken should SwiftMED be installed.
7.3 TempIDManager
TempIDManager manages BlueTrace TempIDs. It downloads TempIDs from a remote server, stores them on disk, and provides a TempID valid for the current time. The main class is idmanager.TempIDManager.
There are two main management tasks required for TempIDs. First, the local store of TempIDs from the server needs to be kept populated, so the device will always have a valid TempID available locally. Second, a valid TempID should always be provided when sending that TempID to another device.
7.3.1 TemporaryID
TempIDs are represented using the TemporaryID class (idmanager.TemporaryID). The class has the following fields:
- start time, time at which this TempID is valid
- expiry time, time at which this TempID expires
- contents, the actual TempID value in the form of a Base64 string.
7.3.2 Populating Local TempID Store
The function getTemporaryIDs()
is used to download new TempIDs from the
remote server. It is asynchronous and returns a Task.
getTemporaryIDs()
makes an asynchronous Firebase function call to the
function getTempIDsV2
, with the following arguments:
ttId
, set to the user's TTIDappVersion
, set to the version of the app as reported by PackageManager
If the call completes successfully, the callback registered by
getTemporaryIDs()
extracts the values tempIDs
, status
, and
refreshTime
from the returned result. It then verifies that tempIDs
is
an array of TemporaryID objects, and that status
is a string of value
"success". tempIDs
is then converted into a JSON string using the Gson
library, and then saved into the file tempIDs
with UTF-8 encoding.
The preference NEXT_FETCH_TIME
is set to the value of refreshTime
after converting it to milliseconds. The preference LAST_FETCH_TIME
is
set to the current time.
Upon completion of the call, whether or not successful, a callback
registered by getTemporaryIDs()
creates a new Metrics object, and
calls Metrics.upload()
. See Section 2.2.2 for more information
about this component.
7.3.2.1 Keeping the store populated
The logic for keeping the store populated mostly resides in BMS. We will describe it here for clarity.
BMS will always call getTemporaryIDs()
when it is started, via the
ACTION_START
Command. Subsequently, it checks to see if the TempID store
needs to be updated at two different places. First, when processing the
ACTION_UPDATE_BM
Command, which is scheduled to happen every
bmCheckInterval=
ms (540s). Next, when processing the ACTION_SCAN
Command, before a scan takes place, which is scheduled to happen slightly
more than once every minute.
BMS determines if new TempIDs need to be downloaded by calling the
needToUpdate()
function. needToUpdate()
returns true if the current
time is past the preference NEXT_FETCH_TIME
.
Thus, while BMS is operating normally, a new batch of TempIDs will be
requested from the server within a few minutes from the time specified by
the server, as stored in the preference NEXT_FETCH_TIME
. Assuming the
server always provides more than sufficient TempIDs for that interval, and
that BlueTrace is able to successfully download the TempIDs when needed,
the local TempID store should always contain a currently valid TempID.
7.3.3 Retrieving Valid TempID
Other components can ask TempIDManager for a currently valid TempID by
calling the function retrieveTemporaryID()
.
retrieveTemporaryID()
reads the contents of the file tempIDs
, and then
converts it into an array of TemporaryID objects by deserializing it using
Gson. The array is then sorted by start time, and then converted into a
Queue by creating a LinkedList and inserting the elements of the array
into the list. Finally, the first TemporaryID in the queue that is valid
(the current time falls between the start time and the expiry time) is
returned.
7.3.3.1 Enforcing use of valid TempID
The logic to ensure only valid TempIDs are sent to other devices is located in a few different components. We describe the code flow here.
TempIDs are sent to other devices via StreetPass, which constructs the
contact tracing records to be transmitted. StreetPass always calls the
function TracerApp.thisDeviceMsg()
to retrieve the currently valid
TempID.
TracerApp.thisDeviceMsg()
will first inspect the TemporaryID object
stored at BMS.broadcastMessage
. If this
TemporaryID is valid, then the contents (i.e. the actual TempID as a
Base64-encoded string) is returned. Otherwise, retrieveTemporaryID()
is
called to get a new, valid TemporaryID object from the local store. This
object is then set as the new value of broadcastMessage
, and its
contents is returned. Thus, StreetPass will always send a valid TempID to
other devices.
At the same time, BMS updates the value of broadcastMessage
in several
places: after downloading new TempIDs via getTemporaryIDs()
when
processing ACTION_START
, ACTION_SCAN
, and ACTION_UPDATE_BM
/Command/s. In each of these cases, retrieveTemporaryID()
is used to get
a valid TemporaryID object.
7.4 StreetPass
StreetPass is a collection of classes that manage the dissemination and storage of contact tracing records. They are mostly located in the streetpass.* namespace, and consist of the following:
- StreetPassScanner (streetpass.StreetPassScanner), manages scanning of BlueTrace devices
- StreetPassWorker (streetpass.StreetPassWorker), processes a list of found BlueTrace devices, retrieving and sending contact tracing records
- StreetPassReceiver (BMS.StreetPassReceiver), coordinate storing of contact tracing records
- StreetPassRecordDatabase (streetpass.persistence.*), database for storing contact tracing records
- StreetPassServer (streetpass.StreetPassServer), listens for requests from other BlueTrace devices and responds accordingly.
Unless otherwise specified, class names in this section will be relative to the namespace sg.gov.tech.bluetrace.streetpass.
7.4.1 StreetPassScanner
StreetPassScanner (StreetPassScanner
) is used to detect nearby BlueTrace
devices, and add them to a worklist for processing. It uses a BLEScanner,
part of the BLE Services component, to perform the actual scanning.
7.4.1.1 Initialization
A StreetPassScanner object is created by BMS when processing the
ACTION_START
Command. This will create a new BLEScanner object
associated with the StreetPassScanner, with the service UUID that was
provided by BMS. In addition, a BleScanCallback
(StreetPassScanner.BleScanCallback) object is created, which is a
callback object for BLE events (see documentation for
android.bluetooth.le.ScanCallback).
7.4.1.2 Scanning
When processing an ACTION_SCAN
Command, BMS will perform a scan by
calling StreetPassScanner.startScan()
.
startScan()
will call BLEScanner.startScan()
, with the
BleScanCallback instance as the callback object. The counter
scannerCount
is also incremented. startScan()
then schedules a
callback to run after a delay of scanDurationInMillis
(BMS sets this to
BMS.scanDuration
, 8s), which will call stopScan()
to terminate
scanning. At this point, startScan()
returns, and subsequent processing
happens when the callbacks in BleScanCallback are called.
BLEScanner is a utility class wrapping around Android's
BluetoothLeScanner class (android.bluetooth.le.BluetoothLeScanner). It
scans for nearby BLE devices that advertise a specific UUID
(BMS.serviceUUID
, B82AB3FC-1595-4F6A-80F0-FE094CC218F9), and calls
onScanResult()
on the registered callback object (i.e. BleScanCallback)
for each matching device found.
When BleScanCallback.onScanResult()
is called, it is provided a single
scan result corresponding to a discovered device. It extracts the
manufacturer-specific data for manufacturer ID 0x3ff
9 as a UTF-8 string, and creates a
ConnectablePeripheral object containing the aforementioned data, the
transmission power, and the RSSI.
It then uses LocalBroadcastManager to send an ACTION_DEVICE_SCANNED
intent with the Blueteooth device and ConnectablePeripheral as extra
data. This intent will be picked up by StreetPassWorker to continue
processing the detected device.
7.4.1.3 Teardown
StreetPassScanner terminates the scanning process when stopScan()
is
called, by calling BLEScanner.stopScan()
. It also decrements
scannerCount
. stopScan()
is scheduled to be called by startScan()
,
and is also called when BMS terminates.
7.4.2 StreetPassWorker
StreetPassWorker manages the exchange of contact tracing records when a device acts as a Central device (StreetPassServer manages the logic when devices acts as Peripheral devices). Peripheral devices discovered by StreetPassScanner are added to a work queue in StreetPassWorker, which will go through each device and exchange records as required by the protocol.
7.4.2.1 Initialization
A StreetPassWorker object is created in BMS.setup()
. The constructor
performs the following actions:
- Create a work queue (
workQueue
) of pending Work objects - Create a blacklist (
blacklist
) of devices that have already been processed - Initialize a work timeout listener,
onWorkTimeoutListener
- Register ScannedDeviceReceiver to receive local
ACTION_DEVICE_SCANNED
intents - Register BlacklistReceiver to receive local
ACTION_DEVICE_PROCESSED
intents - Create Handlers for scheduling callbacks
ScannedDeviceReceiver will receive the intents broadcasted by StreetPassScanner whenever a BlueTrace device is detected.
StreetPassWorker keeps track of the current Work object being processed in
the property currentWork
.
7.4.2.2 Work objects
The Work class represents a unit of work that needs to be done by StreetPassWorker. Each Work object corresponds to a single BlueTrace peripheral device that must be processed, i.e. that contact tracing records must be exchanged with. Each Work object has the following properties:
- a checklist, which keeps track of what actions have already taken place for this unit of work
- the associated ConnectablePeripheral object
- the associated Bluetooth device
- an associated BluetoothGatt object, used to perform GATT operations
- a creation timestamp, set to the time at which the Work was created
- a timeout timestamp, indicating the time at which this work unit will time out
- a callback to be called when the work unit times out.
A Work object is started by calling Work.startWork()
, with a
CentralGattCallback (StreetPassWorker.CentralGattCallback) object as
an argument. startWork()
will then call BluetoothDevice.connectGatt()
with the provided callback; the callback will handle subsequent operations
for this work unit. The associated BluetoothGatt object will also be
initialized to the return value of connectGatt()
.
7.4.2.3 Receiving ACTION_DEVICE_SCANNED Intents
Local ACTION_DEVICE_SCANNED
intents are received by
ScannedDeviceReceiver. The associated Bluetooth device and
ConnectablePeripheral object are extracted from the intent's extra data,
and used to construct a new Work object, along with
onWorkTimeoutListener
as the timeout handler.
ScannedDeviceReceiver then calls StreetPassWorker.addWork()
to add the
Work object to the queue, and then StreetPassWorker.doWork()
to process
the work queue.
7.4.2.4 Adding Work
StreetPassWorker.addWork()
adds a Work object to the work queue, given
the following conditions:
- The Bluetooth address of the device associated with the Work object does
not match that of the Work object stored in
StreetPassWorker.currentWork
. - The address is not in the blacklist of already-processed devices.
Next, the work queue is checked to see if it contains any Work with the
same address as the new Work object. If no such Work was found, then the
new Work is added to the queue, and a callback is scheduled to run
after a delay of BMS.maxQueueTime
(7s). This callback will remove the
Work object from the queue should it still be present.
If an older Work object is found, then it is removed from the queue,
and the new Work object is added to the queue. However, the timeout
handler that was previously registered for the older Work object does not
get removed, and a new timer is not created either. This results in a bug:
this particular timeout mechanism does not work properly when a new Work
object replaces an older one. However, as expired objects get removed from
the work queue in doWork()
as well, this ultimately does not cause any
problems.
7.4.2.5 Processing Work
Work in the work queue is processed when StreetPassWorker.doWork()
is
called, which happens when ScannedDeviceReceiver handles an
ACTION_DEVICE_SCANNED
intent. doWork()
also gets called in several
other places, mostly after some work processing has been done, e.g. after
a Work object has been marked as finished in
StreetPassWorker.finishWork()
. The logic in doWork()
is somewhat
convoluted, possibly due to repeated changes to handle different kinds of
edge cases.
doWork()
first checks to see if StreetPassWorker.currentWork
is
set. If so, it checks if currentWork
has been marked finished, or if the
timeout has passed. If this is also true, then
BluetoothGatt.disconnect()
is called on the associated BluetoothGatt
object; this is meant to handle dangling connections. Note that this
eventually triggers a callback associated with the BluetoothGatt object
(CentralGattCallback.onConnectionStateChange()
) which sets currentWork
to null.
Otherwise, a suitable Work object is taken from the queue. If the queue is
empty, doWork()
returns. Otherwise, Work objects whose creation
timestamp is more than BMS.maxQueueTime
(7s) ago are removed from the
front of the queue, till a Work object that has not timed out is
retrieved. If no such Work object can be found, then doWork()
returns.
Should a suitable Work object be found, it is first checked to see if it
is in the blacklist, and is skipped if so. doWork()
is called again to
process the next Work object.
Next, doWork()
checks if there is already an active Bluetooth connection
to the associated device. If so, the Work object is marked as skipped and
finishWork()
is called; see Section 7.4.2.7.
Otherwise, the Work object represents a new, uncontacted BlueTrace
device with which we need to exchange contact tracing records. doWork()
will then create a new CentralGattCallback object, call
Work.startWork()
with this callback, and call BluetoothGatt.connect()
to establish the BLE connection. Subsequent processing then happens within
the CentralGattCallback object.
A callback to call the Work object's timeout handler is scheduled after a
delay of BMS.connectionTimeout
(6s). The timeout value of the Work
object is also set to BMS.connectionTimeout
(6s) in the future.
If the attempt to establish the BLE connection fails, doWork()
will move
on to process the next work unit in the queue.
7.4.2.6 CentralGattCallback
CentralGattCallback (StreetPassWorker.CentralGattCallback) manages the actual contact tracing record exchange. Callback functions named here are relative to the class namespace. We describe the callbacks in the order at which they are triggered.
The callback onConnectionStateChange()
gets triggered when
BluetoothGatt.connect()
is called in StreetPassWorker.doWork()
, with a
new state of BluetoothProfile.STATE_CONNECTED
(0x2). CentralGattCallback will then request for the MTU of the
connection to be changed to 512 bytes.
The MTU change triggers onMtuChanged()
. Regardless of whether the change
is successful, onMtuChanged()
will discover the available services on
the remote device, by calling BluetoothGatt.discoverServices()
on the
BluetoothGatt object associated with the work unit.
This in turn triggers onServicesDiscovered()
, which will search for a
service with UUID StreetPassWorker.serviceUUID
(B82AB3FC-1595-4F6A-80F0-FE094CC218F9), containing a characteristic with
UUID StreetPassWorker.characteristicV2
(117BDD58-57CE-4E7A-8E87-7CCCDDA2A804). This characteristic can be read
from in order to retrieve the remote device's contact tracing record.
onServicesDiscovered()
will request to read the value of the
characteristic by calling BluetoothGatt.readCharacteristic()
. If the
service or characteristic are not found, onServicesDiscovered()
will
terminate the connection by calling endWorkConnection()
.
When the value of the characteristic is read successfully, the callback
onCharacteristicRead()
is triggered. If the read was successful,
onCharacteristicRead()
first determines a suitable protocol
implementation (subclass of BlueTraceProtocol) to handle the
characteristic, based on the UUID value 10. As this device is operating
as a Central device, the BlueTraceProtocol.central
object is used, which
implements CentralInterface.
central.processReadRequestDataReceived()
is then called with the
value of the characteristic, the address of the remote device, and the
RRSI and transmission power values from the ConnectablePeripheral
associated with the Work object. Note that the value of the
characteristic here is the Encounter Message sent by the Peripheral.
processReadRequestDataReceived()
returns a ConnectionRecord object.
An ACTION_RECEIVED_STREETPASS
intent is then broadcasted with the
ConnectionRecord bundled. StreetPassReceiver will receive this intent
and complete processing of the received contact tracing record.
Regardless of whether the read was successful, onCharacteristicRead()
will move on writing to the same characteristic, in order to send this
device's contact tracing record to the remote device. A suitable protocol
implementation is retrieved, and the Encounter Message is generated using
central.prepareWriteRequestData()
. The RSSI and
transmission power values from the ConnectablePeripheral are passed
in. onCharacteristicRead()
then calls
BluetoothGattCharacteristic.setValue()
to set the value of the
characteristic to the Encounter Message, and
BluetoothGatt.writeCharacteristic()
to send the value to the remote
device.
Note that onCharacteristicRead()
calls TempIDManager.bmValid()
,
supposedly to check if the TempID stored at BMS.broadcastMessage
is
valid for the current time, but this function always returns true. As
detailed in Section 7.3, ultimately the TempID used in
contact tracing records sent out by the device are enforced to be valid
via TracerApp.thisDeviceMsg()
.
Once the write to the remote device is complete, the callback
onCharacteristicWrite()
is triggered. onCharacteristicWrite()
records
if the write was successful, and then calls endWorkConnection()
to clear
up.
- Closing the connection
endWorkConnection()
is called whenever the connection needs to be terminated. It callsBluetoothGatt.disconnect()
to disconnect from the remote device.When the BLE connection is disconnected, whether via
endWorkConnection()
or something else,onConnectionStateChange()
will be triggered with a status ofBluetoothProfile.STATE_DISCONNECTED
. When this happens, the following actions are taken:- The timeout callback of the Work object is removed.
- If the associated address of
StreetPassWorker.currentWork
matches that of the address associated with this CentralGattCallback,currentWork
will be set to null. - The BLE connection is closed via
BluetoothGatt.close()
. - The Work object is marked finished by calling
StreetPassWorker.finishWork()
.
7.4.2.7 Finishing Work
Work objects are cleaned up by calling finishWork()
. This function
first verifies the Work object has not already been marked finished. It
then inspects the Work object's checklist to see if critical actions have
been completed: that a connection was established, a contact tracing
record was received, and a contact tracing record was sent; or that the
Work object was marked as skipped. If so, a local intent
ACTION_DEVICE_PROCESSED
is broadcasted including the address of the
remote device.
The timeout callback of the Work object is removed, if it has not been
already. The Work object is marked finished. doWork()
is then called
to process the next item on the work queue.
7.4.2.8 Queue and Work Timeouts
StreetPassWorker manages the work queue and Work objects through two timeout mechanisms to clean up and remove old work units.
The first timeout mechanism removes Work objects from the queue if they
have been in the queue for longer than BMS.maxQueueTime
(7s). This
happens in two places: when a callback set in addWork()
is triggered at
the timeout time, and when timed-out Work objects are removed from the
queue in doWork()
. Since Work objects in the queue have yet to be
processed, so there is no additional clean-up required for them.
Work objects are only processed when they are removed from the queue in
doWork()
. At this point, a Work object being processed is assigned to
currentWork
. The second timeout mechanism is setup here: a timeout value
is set on the Work object for BMS.connectionTimeout
(6s), and the
Work object's timeout handler (effectively onWorkTimeoutListener
) is
set to trigger after that same delay. onWorkTimeoutListener
performs the
necessary cleanup of a Work object that has timed out while processing
is taking place.
In addition, doWork()
also checks the timeout value of currentWork
(which was set when the Work object was first assigned to currentWork
,
as above). If currentWork
has timed out, then
BluetoothGatt.disconnect()
is called to disconnect from the associated
device; this will eventually lead to finishWork()
being called.
The onWorkTimeoutListener
callback handles the various situations Work
objects could be in when a timeout occurs. It goes through the Work
object's checklist to determine its state, and acts accordingly:
- No connection established:
- Set
currentWork
to null - Close BluetoothGatt client
- Call
finishWork()
- Set
- Already disconnected:
- Do nothing, wait for
CentralGattCallback.onConnectionStateChange()
to do the cleanup.
- Do nothing, wait for
- Connected:
- Set
currentWork
to null - Disconnect from remote device
- Call
finishWork()
- Set
Thus in all cases, the connection to the remote device will be closed if
necessary, finishWork()
will be called to do the final cleanup, and
currentWork
will be set to null, allowing doWork()
to begin processing
a new Work object.
7.4.2.9 Blacklist Management
BlacklistReceiver (StreetPassWorker.BlackListReceiver) is a simple
class that manages StreetPassWorker's blacklist
(StreetPassWorker.blacklist
): a list of devices that have already been
processed and thus do not need to be re-contacted. BlacklistReceiver
receives ACTION_DEVICE_PROCESSED
intents, sent by StreetPassWorker in
finishWork()
, and StreetPassServer when a record has been received from
a Central device. It will then add the associated address to the
blacklist, and also schedule a callback to remove the address from the
blacklist after a delay of BMS.blacklistDuration
(100s).
7.4.3 StreetPassReceiver
StreetPassReceiver (BMS.StreetPassReceiver) is a small class that is
registered to receive ACTION_RECEIVED_STREETPASS
intents. For some
reason it is implemented as an inner class of BluetoothMonitoringService.
When an ACTION_RECEIVED_STREETPASS
intent is received,
StreetPassReceiver will extract the associated ConnectionRecord, and
then use the values within to create a new StreetPassRecord. A coroutine
is then launched to save the StreetPassRecord, by calling
StreetPassRecordStorage.saveRecord()
.
7.4.4 StreetPassServer
StreetPassServer manages the exchange of contact tracing records when a
device acts as a Peripheral device (see StreetPassWorker for corresponding
Central device code). A new StreetPassServer object is created by BMS when
processing the ACTION_START
Command.
7.4.4.1 Initialization
When a StreetPassServer object is created by BMS, serviceUUIDString
is
set to a value of BMS.serviceUUID
(B82AB3FC-1595-4F6A-80F0-FE094CC218F9). A GattServer object,
gattServer
is then created using setupGattServer()
, with a UUID of
serviceUUIDString
. StreetPassServer then starts the server with
GattServer.startServer()
, creates a new GattService with the same UUID,
and adds the service to the server using GattServer.addService()
. Once
this is complete, a service will be running that allows BlueTrace to act
as a Peripheral device.
7.4.4.2 GattServer and GattService
GattServer (bluetooth.gatt.GattServer) is an implementation of a BLE server that responds to GATT requests from a Central devices. It is mostly a a wrapper around Android's BluetoothGattServer (android.bluetooth.BluetoothGattServer) class.
When startServer()
is called by StreetPassServer, GattServer creates a
new BluetoothGattServer object by calling
BluetoothManager.openGattServer()
, providing gattServerCallback
as the
callback class.
GattService (bluetooth.gatt.GattService) is similarly a wrapper around Android's BluetoothGattService (android.bluetooth.BluetoothGattService). When initialized, it creates a new BluetoothGattService with the provided UUID, and also adds two BluetoothGattCharacteristic objects (android.bluetooth.BluetoothGattCharacteristic):
characteristicV1
, same UUID as service, read and write permissionscharacteristicV2
, UUID 117BDD58-57CE-4E7A-8E87-7CCCDDA2A804, read and write permissions
A created GattService object can be added to a GattServer using
addService()
. addService()
calls BluetoothGattServer.addService()
with the GattService's underlying BluetoothGattService object, to
register the service with the server.
After creating a GattServer and adding a GattService, the underlying BluetoothGattServer is initialized and ready to accept requests from BlueTrace Central devices.
7.4.4.3 GattServer Callbacks
GattServer registers an anonymous callback object, gattServerCallback
,
with the underlying BluetoothGattServer object. Callbacks in this object
get triggered when specific GATT actions happen. See the Android
documentation for BluetoothGattServer for more details.
The callback object contains 3 maps for storing data, all indexed by device address:
readPayloadMap
, a cache of previously sent Encounter MessageswriteDataPayload
, to temporarily store data received from Central devicesdeviceCharacteristicMap
, to keep track of which characteristic was requested by which device
As per the BlueTrace protocol, the series of events when a BlueTrace Central devices contacts a Peripheral device is as follows:
- Connection is established
- Central requests to read value of characteristic, i.e. we need to send a contact tracing record
- Central requests to write a value to the characteristic, i.e. we need to receive a contact tracing record
- Connection is closed
We will describe the callbacks in gattServerCallback
based on the order
above.
When a connection is first established, onConnectionStateChange()
is
called, but it does not do anything when the new state is
STATE_CONNECTED
.
Next, the Central requests to read the value of a characteristic,
triggering onCharacteristicReadRequest()
. The callback first determines
a suitable protocol implementation (subclass of BlueTraceProtocol) to
handle the requested characteristic (see Section 7.5 for
more details), and uses the BlueTraceProtocol.peripheral
PeripheralInterface object. Then, readPayloadMap
is consulted to see
if we have a cached copy of the Encounter Message to be sent to the
Central device, based on the Central device's address. If not, an
Encounter Message is generated using peripheral.prepareReadRequestData()
and cached.
The read request from the Central can have an offset specified;
onCharacteristicReadRequest()
offsets the Encounter Message based on
the provided value. The result is then sent using
BluetoothGattServer.sendResponse()
.
After the Central successfully reads the characteristic, it will then
request to write to the characteristic. This triggers
onCharacteristicWriteRequest()
. GATT supports two forms of write
requests, a normal write and a long write. For long writes, the
preparedWrite
argument is set to true, and when the write is complete,
the onExecuteWrite()
callback is triggered. GattServer handles both
cases.
onCharacteristicWriteRequest()
first verifies the characteristic being
written to matches a valid protocol implementation. It stores the UUID of
the characteristic into deviceCharacteristicMap
for the Central device's
address. Then, it checks writeDataPayload
for any previously-stored data
for the device. If previous data exists, then the new data sent in this
request is appended to it, and the new value is stored back into
writeDataPayload
.
If preparedWrite
is false, i.e. this is not a long write,
saveDataReceived()
is called. In either case, if responseNeeded
is
true, BluetoothGattServer.sendResponse()
is called with a status of
BluetoothGatt.GATT_SUCCESS
.
saveDataReceived()
retrieves the Encounter Message sent by the Central
device from writeDataPayload
, based on the device's address. It then
consults deviceCharacteristicMap
to determine the characteristic that
was written to, and uses that to determine a suitable protocol
implementation. The Encounter Message is then processed using
peripheral.processWriteRequestDataReceived()
to yield a
ConnectionRecord object. The ConnectionRecord is then locally
broadcasted with an ACTION_RECEIVED_STREETPASS
intent; this will be
received by StreetPassReceiver, documented above. An
ACTION_DEVICE_PROCESSED
intent is also broadcasted, which is received by
BlacklistReceiver.
Finally, saveDataReceived()
removes the entries associated with this
device from readPayloadMap
, writeDataPayload
, and
deviceCharacteristicMap
.
When a long write is being performed, onExecuteWrite()
will eventually
be triggered. onExecuteWrite()
will retrieve the Encounter Message from
writeDataPayload
, just to verify that it is not null. Then it calls
saveDataReceived()
, and BluetoothGattServer.sendResponse()
with a
status of GATT_SUCCESS
.
Finally, the Central device will disconnect, triggering
onConnectionStateChange()
again. This callback will remove the
associated entry for the device from readPayloadMap
. Note that this also
happens if the connection is severed before the protocol completes.
- Bugs in long write implementation
There are a few minor bugs in the handling of long writes. First, in
onCharacteristicWriteRequest()
, the provided data is always appended to the existing data insidewriteDataPayload
. However, the specification indicates that the providedoffset
argument indicates the offset of the sent data, which might not always be at the end of the existing data.onCharacteristicWriteRequest()
should thus insert the new data at the right offset instead.onExecuteWrite()
also fails to check the value of theexecute
argument, which if false, indicates the write should not proceed. This prevents a device from cancelling a long write, as is allowed by the specification.In practice, neither of these bugs are likely to manifest with properly-functioning BlueTrace devices on typical BLE stacks, and even if they do, are unlikely to severely impact the functionality of BlueTrace.
7.4.4.4 Teardown
StreetPassServer is cleaned up by calling tearDown()
. This function
simply calls GattServer.stop()
on the associated GattServer, which calls
BluetoothGattServer.clearServices()
and BluetoothGattServer.close()
on
the underlying BluetoothGattServer object.
7.4.5 StreetPassRecordDatabase
StreetPassRecordDatabase (persistence.*) is the database providing persistency to StreetPass. Besides the main StreetPassRecordDatabase class, there are also a few other classes to represent database-backed objects. StreetPassRecordDatabase is implemented using Room, which generates the implementation of the database code during compile-time. The implementation classes have a suffix of "_Impl".
StreetPassRecordDatabase is backed by an SQLite3 database file named
record_database
. The database consists of two tables:
record_table
, holding contact tracing recordsstatus_table
, holding status messages
The status messages stored in status_table
are added when
ACTION_RECEIVED_STATUS
intents are received by StatusReceiver in BMS. In
practice the only status messages that are sent are by StreetPassScanner
when scanning starts and stops, and are not of much interest, and so we do
not focus on it in this report.
StreetPassRecord objects hold the actual contact tracing information,
and are backed by rows in record_table
via the StreetPassRecordDao
data access object. StreetPassRecord objects were already described in
Section 7.1.3; we note down the table columns corresponding to each
property of the object here.
Column | Property |
---|---|
v |
protocol version |
org |
organization code |
msg |
TempID of other device |
modelP |
device model of Peripheral device |
modelC |
device model of Central device |
rssi |
RSSI |
txPower |
transmission power (might be null) |
timestamp |
timestamp |
id |
table primary key |
StreetPassRecordDao provides the following functions:
insert()
; to insert new StreetPassRecord objectspurgeOldRecords()
; to delete old StreetPassRecord objects given a timestampgetCurrentRecords()
,getLastRecord()
,getMostRecentRecord()
,getRecords()
,getRecordsViaQuery()
; to query for StreetPassRecord objects stored in the databasecountRecordsInRange()
,liveCountRecordsInRange()
; to count the number of StreetPassRecord objects within a certain time periodnukeDb()
; to delete all StreetPassRecord objects from the database, referred to as nuking the database
7.4.5.1 StreetPassRecordStorage
StreetPassRecordStorage (persistence.StreetPassRecordStorage) is a higher-level interface to common database operations. It has the following functions:
getAllRecords()
; callsStreetPassRecordDao.getCurrentRecords()
nukeDb()
; callsStreetPassRecordDao.nukeDb()
purgeOldRecords()
; callsStreetPassRecordDao.purgeOldRecords()
saveRecord()
; callsStreetPassRecordDao.insert()
7.4.5.2 Data Access Analysis
We analyzed the callers of all database access functions in StreetPassRecordDao, to determine where the data within the database is used. We identified the following operations that access the database:
- Metrics, to determine number of records in a day, and the timestamp of the latest record
- PeekActivity and PlotActivity, to view records, and to nuke the database. These are debug views that are not normally accessible. See Section 7.9 for more information.
- Upload Service, to extract the records and upload them to the server after a successful PIN has been entered. The PIN ensures only a health authority will be able to trigger this action.
- Home activity, observes when the most recent record changes. Appears to be meant to perform an animation whenever a new record is inserted, but currently it does not do anything.
- Home activity, to update the count of the number of exchanges that have occurred on the current day.
- StreetPassReceiver, to insert a received record, as elaborated above.
- BMS, to purge old records when the
ACTION_PURGE
Command is processed.
Based on our analysis, the only time contact tracing records stored in the database are transmitted out of the device is in the uploading service. This will only happen after the correct PIN has been entered, which only a health authority should have. Thus, contact tracing data in BlueTrace should stay private, as expected. Some metadata is sent out via the Metrics component, however.
Note however that the debug activities (PeekActivity and PlotActivity) access the contact tracing data. We have not verified if they transmit any of that data externally. However, since these activities can typically only be accessed by explicitly launching them from a rooted device, this is unlikely to pose a privacy risk.
7.4.5.3 SQL Injection Analysis
The SQL queries used in StreetPassRecordDatabase are created by Room at compile-time. All queries are parameterized, and thus should not lead to an SQL injection attack from any attacker-controlled inputs.
7.5 BlueTraceProtocol
BlueTraceProtocol (protocol.*
) is the implementation of the data format
used to exchange contact tracing records between BlueTrace devices
(i.e. Encounter Messages). It is used by StreetPass to convert
ConnectionRecord objects to Encounter Messages and vice versa, when
sending and receiving data from remote devices.
BlueTraceProtocol is designed to allow for multiple protocol versions. Each version would have its own implementation, as a subclass of the parent protocol class BlueTraceProtocol, along with the required implementations of CentralInterface and PeripheralInterface, which we describe shortly.
A utility class, BlueTrace, contains several functions that are used to retrieve the correct implementation for a particular version.
7.5.1 BlueTraceProtocol Interface
The parent class BlueTraceProtocol contains the following properties:
- version of the protocol
- API object for acting as a Central device
- API object for acting as a Peripheral device
The two API objects must be implementations of the Kotlin interfaces CentralInterface and PeripheralInterface respectively.
CentralInterface has two methods that must be implemented:
prepareWriteRequestData()
, generates an Encounter Message containing this device's TempID, to be sent to a Peripheral deviceprocessReadRequestDataReceived()
, processes the provided Encounter Message from a Peripheral device, to generate a ConnectionRecord object
PeripheralInterface has two methods that must be implemented:
prepareReadRequestData()
, generates an Encounter Message containing this device's TempID, to be sent to a Central deviceprocessWriteRequestDataReceived()
, processes the provided Encounter Message from a Central device, to generate a ConnectionRecord object
These 4 functions are used in StreetPass to convert between Encounter Messages sent/received via GATT, and ConnectionRecord objects.
7.5.2 Version 2 Implementation
BlueTrace version 2.0.15 contains only one protocol implementation, for version 2 of the BlueTrace protocol (the version described in the white paper). This is implemented by classes in the protocol.v2 namespace: BlueTraceV2 is the protocol class, with API objects of classes V2Central and V2Peripheral.
The majority of the work is actually done in two utility classes, V2ReadRequestPayload and V2WriteRequestPayload These classes represent the version 2 Encounter Messages.
7.5.2.1 V2ReadRequestPayload
V2ReadRequestPayload represents Encounter Messages sent by Peripheral devices to Central devices, and contains the same fields: version, organization, TempID, and device model.
The fromPayload()
function accepts an Encounter Message in the form of a
byte array, decodes it as a UTF-8 string, and then uses the Gson library
to deserialize the string into a V2ReadRequestPayload object.
The getPayload()
function converts a /V2ReadRequestPayloa/d object into an
Encounter Message in the form of a byte array, by first using Gson to
serialize the V2ReadRequestPayload into a JSON string, and then UTF-8
encoding the string.
7.5.2.2 V2WriteRequestPayload
V2WriteRequestPayload represents Encounter Messages sent by Central devices to Peripheral devices, and contains the same fields: version, organization, TempID, device model, and RSSI.
Like V2ReadRequestPayload, it has fromPayload()
and getPayload()
functions that perform equivalent operations, also using Gson and UTF-8
for encoding and serialization.
7.5.2.3 V2Central and V2Peripheral
The implementations of V2Central and V2Peripheral are straightforward.
V2Central implements the required functions as follows:
prepareWriteRequestData()
: Create a new V2WriteRequestPayload object, with TempID set toTracerApp.thisDeviceMsg()
, and then callV2WriteRequestPayload.getPayload()
to convert it into an Encounter Message.processReadRequestDataReceived()
: Deserialize an Encounter message into a V2ReadRequestPayload object usingV2ReadRequestPayload.fromPayload()
, and then create a ConnectionRecord using the fields from the V2ReadRequestPayload object.
V2Peripheral implements the required functions as follows:
prepareReadRequestData()
: Create a new V2ReadRequestPayload object, with TempID set toTracerApp.thisDeviceMsg()
, and then callV2ReadRequestPayload.getPayload()
to convert it into an Encounter Message.processWriteRequestDataReceived()
: Deserialize an Encounter Message into a V2WriteRequestPayload object usingV2WriteRequestPayload.fromPayload()
, and then create a ConnectionRecord using the fields from the V2WriteRequestPayload object.
7.5.3 Protocol Selection
BlueTraceProtocol contains a protocol selection class named BlueTrace that is used to retrieve the correct protocol implementation given a characteristic UUID. For the sake of clarity, we will refer to this BlueTrace class as the "protocol selector".
As per the BlueTrace protocol design, each version of the protocol will
use a unique UUID value for the characteristic to be read and written via
GATT (see 7.4 for more details). The protocol selector maintains a
mapping from characteristic UUID values to protocol versions, in
characteristicToProtocolVersionMap
; and another mapping from versions to
implementations, in implementations
.
characteristicToProtocolVersionMap
is statically set to contain a single
entry, mapping the UUID 117BDD58-57CE-4E7A-8E87-7CCCDDA2A804 to version 2.
implementations=
is statically set to contain a single entry, mapping
version 2 to a BlueTraceV2 object.
The function getImplementation()
is used by StreetPass to retrieve an
appropriate protocol implementation given a UUID. It does this by looking
up the two maps. Due to the values of the map, this means that it will
only handle the UUID 117BDD58-57CE-4E7A-8E87-7CCCDDA2A804, and return the
BlueTraceV2 object.
The function supprtsCharUUID()
is used by StreetPass to check if there
is an implementation to support a given UUID. It does this by looking up
characteristicToProtocolVersionMap
, and thus will only return true when
the UUID is 117BDD58-57CE-4E7A-8E87-7CCCDDA2A804.
7.6 Metrics
BlueTrace contains a Metrics component (metrics.*) that periodically gathers statistics about contact tracing. The main class is Metrics.
A Metrics object is created in two other components: TempIDManager when
new TempIDs are downloaded from the server, and FCMService, when a
RemoteMessage has a payload containing they key "purpose". In both
cases, Metrics.upload()
is called immediately after creating the Metrics
object.
Metrics objects gather data during construction, and also when
upload()
is called. This is the data gathered by the class:
- platform name (hardcoded to "android" for the version we are looking at)
- app version
- current timestamp
- TTID, taken from preferences
- whether the
ACCESS_FINE_LOCATION
permission has been granted - whether notifications are enabled
- count of number of contact tracing records collected over the previous day
- timestamp of the latest contact tracing record in the database
After gathering the above information, upload()
serializes it into a
JSON string. It then makes a Firebase function call sendHeartbeat()
, on
the instance "asia-east2", with the JSON string as the argument. This will
upload the gathered data to the remote server.
7.7 FCMService
FCMService (services.FCMService) is a top-level service for handling
messages sent via Firebase Cloud Messaging (FCM). It is a subclass of
FirebaseMessagingService
(com.google.firebase.messaging.FirebaseMessagingService) and is defined
as a service in AndroidManifest.xml
to handle
com.google.firebase.MESSAGING_EVENT
intents.
Messages sent via FCM are processed by the
FCMService.onMessageReceived()
function. onMessageReceived()
will
inspect the RemoteMessage
(com.google.firebase.messaging.RemoteMessage) argument, and perform
various actions based on the attached payload data, which is in the form
of a map.
If the payload contains the key "body", then the value associated with
"body" is used to set the ANNOUNCEMENT
preference. This announcement
appears on the home activity.
If the payload contains the key "command", the value associated with
"command" is converted into an integer, and used as a Command index. BMS
is started using Context.startService()
, with an intent that has
COMMAND_KEY
set to the Command index. This allows a remote message to
trigger BMS to process a chosen Command, such as ACTION_SCAN
.
If the payload contains the key "purpose", then a new Metrics object is
created and Metrics.upload()
is called, which will upload contact
tracing metrics to the server.
Finally, if a notification is attached to the RemoteMessage, it will be displayed.
7.8 User Registration
When a new user installs BlueTrace on their device, they must go through a registration step where personal details are gathered and then used to register a new account. The core logic is part of the UI code, and is in the onboarding.newOnboard.register.* namespace.
The exact personal information that is gathered depends on the method by which the user wishes to register: for example they can use their NRIC, or a passport number. In all cases, a contact phone number is also required.
After the UI gathers and validates the required data from the user, it
will create a RegisterUserData object containing the personal
information. This object is then passed to the function
Utils.registerUserInfo()
, which performs the actual registration.
registerUserInfo()
makes a Firebase function call updateUserInfo
,
providing the RegisterUserData object serialized using JSON. If the call
succeeds, registerUserInfo()
extracts the value ttId
from the returned
result. This is the user's TTID, and is stored into the preference TTID
.
Next, the RegisterUserData object is saved in an encrypted form, by
calling Preference.saveEncryptedUserData()
. On modern Android versions,
this function ultimately uses Android's Keystore system to encrypt a
JSON-serialized version of RegisterUserData using a secret key protected
by the Keystore (the secret key has alias "USER_DATA"). The encryption
algorithm used is AES-GCM. The encrypted data is then stored in the
preference ENCRYPTED_USER_DATA
.
Similarly, the contact phone number provided by the user is encrypted
using a key with alias "PHONE_NUMBER_KEY", and then stored in the
preference ENCRYPTED_PHONE_NUMBER
.
At this point the user registration process is completed.
7.9 Debug Activities
BlueTrace contains two "hidden" activities, which can be found in
AndroidManifest.xml
, but are not normally accessible via the
UI. PeekActivity displays a list of all contact tracing records in the
database, while PlotActivity displays a few graphs showing contact
tracing statistics. These appear to be for debugging purposes.
OpenTrace contains the same activities, and inspecting OpenTrace's code
reveals that they can be accessed from the UI by tapping twice on the top
animation of the home activity, if BuildConfig.DEBUG
is set to true. The
version of BlueTrace available in the Play Store is not a debug version,
and thus the trigger code is missing.
PeekActivity and PlotActivity can be started manually, however, if
BlueTrace is running on a rooted device. By issuing the am
command with
root privileges via adb, arbitrary activities in apps can be started, even
if they are not marked exported in AndroidManifest.xml
, as is the case
with PeekActivity and PlotActivity. For example, the command below will
start PeekActivity:
device:/ # am start -n \ > sg.gov.tech.bluetrace/sg.gov.tech.bluetrace.PeekActivity
The two activities may not be fully functional as they are not meant to
run on non-debug builds; some code may be missing due to the
BuildConfig.DEBUG
flag. We have not explored their features in detail.
As PeekActivity and PlotActivity can only be launched on rooted devices, via an adb connection, they are unlikely to pose any kind of privacy or security risk.
7.10 Use of Firebase in BlueTrace
BlueTrace makes use of the Firebase platform for cloud operations. All data exchanged between the BlueTrace app and the remote server happen via Firebase services, with namespace com.google.firebase.*.
Firebase Authentication (auth.*
) is used for user registration and
identification. We did not explore this component in detail.
Firebase Cloud Functions (functions.*) is used to send and receive data from the server via remote procedure calls. For example, BlueTrace uses this to download new TempIDs in TempIDManager. It is also used during new user registration, when uploading metrics information, and when uploading contact tracing data upon request by a health authority. We examined all places in BlueTrace where Firebase cloud functions were called, and verified that none of them appear to send anything unusual.
Firebase Cloud Messaging (messaging.*
) is used to send notifications and
other kinds of messages from the server. This is described in more detail
in Section 7.7.
Firebase Analytics is an analytics service that sends analytics information back to the server. We examined all places in BlueTrace where analytics information is transmitted, and determined that all the information sent is harmless: mainly error messages that do not contain any personal information.
8 Analysis of BlueTrace 2.1.4
We performed a quick differential analysis of BlueTrace 2.1.4 to determine what changes were made since the version we analyzed in detail, 2.0.15. We disassembled the APKs of versions 2.0.15 and 2.1.4 into smali files using apktool, and performed a diff of the two code bases. In general, our findings remain the same for 2.1.4.
The following are the differences we found between the two code bases.
8.1 SafeEntry Support
SafeEntry is a visitor check-in system developed by GovTech that keeps track of an individual's entry and exit of particular location. In Singapore, it is currently mandatory to use SafeEntry when visiting public places such as shopping malls, restaurants and offices. Users can check-in and check-out of SafeEntry by scanning a QR code to access a URL, and filling up the form on the website at that URL. Users must supply identifying information such as their NRIC number. A central server stores the check-in and check-out records.
BlueTrace 2.1.4 has a new feature allowing it to manage SafeEntry check-ins. It includes a QR code scanner, and a backend that calls Firebase functions to convert the scanned URL into information used for checking in, and to perform the actual check-in and check-out.
A successful check-in or check-out eventually gets stored on disk, in the
same database used for StreetPass records (record_database
). The entries
are stored in the table safe_entry_table
, and include information about
the location visited, with check-in and check-out time.
SafeEntry support appears to be disabled if the user account is registered using a passport number.
8.1.1 Privacy Impact
As BlueTrace stores SafeEntry records on disk, an attacker with privileged access to BlueTrace (like Dan from Section 5.2.11) would be able to determine all the locations visited by the user. SafeEntry records are purged every 25 days 11, so there would be up to 25 days worth of check-ins exposed.
However, most people using SafeEntry without BlueTrace would load the SafeEntry URL into their web browser. This URL would then be logged in the browser's history. The URL itself contains enough information to infer the location visited by the user. Thus, unless the user takes care to clear their browser history of SafeEntry URLs, or uses some alternate setup to access the SafeEntry URLs without having them being logged, the relative loss of privacy from using BlueTrace's SafeEntry feature is minimal.
Since SafeEntry by design involves logging an individual's access to a location to a central server, there is no privacy impact from BlueTrace contacting the Firebase server to process check-ins and check-outs. The Firebase calls do end up linking the user's TTID with the SafeEntry records, however, but such a link already exists since both BlueTrace and SafeEntry systems would have common identifying information about an individual, such as their NRIC.
8.2 LightLifter
BlueTrace includes a new component known as LightLifter
(services.light.*). This appears to be a simple service that manages
routine scheduled tasks. It is started in BMS.onCreate()
, and uses
Android's WorkManager (androidx.work.WorkManager) to schedule two
repeating tasks:
- Collect and upload metrics every 6 hours
- Purge old records every 12 hours
The metrics upload task is identical to what is described in Section 2.2.2. In 2.0.15, metric uploading happened after TempIDManager completed downloading new TempIDs from the server, which would happen roughly every 12 hours. Thus this new feature would cause metrics to be uploaded more frequently.
The purge task works similarly to the ACTION_PURGE
Command in 2.0.15. It
calls the purgeOldRecords()
functions on the three data stores:
StreetPassRecordStorage, StatusRecordStorage, and the new
SafeEntryRecordstorage. However, unlike in 2.0.15, purging happens once
every 25 days, and not 21 days. We are unsure why the purge interval was
changed.
Since LightLifter performs the database purging, the code relating to the
ACTION_PURGE
Command was removed from BluetoothMonitoringService: BMS no
longer processes ACTION_PURGE
Commands, and does not schedule intents
related to ACTION_PURGE
.
8.3 Miscellaneous
BlueTrace 2.1.4 also includes another native library,
libpl_droidsonroids_gif.so
. This appears to be used only to render a GIF
animation that is shown after upgrading.
The Play Store "what's new" entry for version 2.1 suggests that the update allows Android devices to contact iOS devices that have BlueTrace running in the background. However we did not notice any changes to the code relating to BLE. As such we are unsure what this comment refers to.
Footnotes:
Version 2.1 includes a native library to render a GIF animation shown after upgrading.
This has changed to 25 days as of BlueTrace 2.1.4.
Or more precisely, an individual's risk of being surveilled is already high, with or without BlueTrace.
This has been removed as per BlueTrace 2.1.4, as the purge task has been moved to LightLifter. Please see Section 8 for more details.
The manufacturer-specific data appears to be set for the use of iOS BlueTrace devices.
This has been removed as per BlueTrace 2.1.4, as the purge task has been moved to LightLifter. Please see Section 8 for more details.
BlueTrace for
Android appears to send advertisement packets with manufacturer-specific
data for manufacturer ID 0x3ff
in order to overcome some limitations
with the iOS implementation.
In the version of BlueTrace we are analyzing, there is only one protocol implementation, BlueTraceV2, and it only handles the characteristic with UUID 117BDD58-57CE-4E7A-8E87-7CCCDDA2A804, which is what is expected. See Section 7.5 for more details.)
BlueTrace 2.1.4 introduced a new component, LightLifter, which handles record purging, and the purge interval is set to 25 days.