Static Analysis of TraceTogether for Android

zerotypic (@zerotypic)

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:

  1. Is the closed-source TraceTogether app identical or largely similar to the open source OpenTrace app?
  2. How does using TraceTogether impact a person's privacy?
  3. 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:

  1. Personal identification information, e.g. NRIC number, phone number; stored remotely and never shared
  2. Contact tracing records; stored within the device and only uploaded to a remote server when contact tracing is required
  3. 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:

  1. The BlueTrace app and the remote server know the identity of the user
  2. 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
  3. Neither the BlueTrace app nor the remote server know the physical location of the user.

We thus need to verify the following:

  1. Personal identification information cannot be accessed by any other party besides the app and the remote server
  2. Contact tracing records stay on the device, except when contact tracing is initiated by a health authority
  3. Location information is never accessed, transmitted or stored
  4. 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:

  1. the attacker was able to read A's tempIDs file at any time during the critical period
  2. 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:

  1. During the registration phase, the user's mobile number and NRIC/passport number are uploaded.
  2. 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 and ENCRYPTED_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

BlueTrace removes contact tracing records that are older than 21 days, as older records are not useful for contact tracing. We verified that the BlueTrace code does correctly remove old records. For more details, see the Sections 7.2.7 (ACTION_PURGE) and 7.4.5.

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:

  1. In StreetPassWorker, when a new Work object is used to replace an older one, an associated timeout handler is not replaced.
  2. In StreetPassServer, when handling long writes, data is not written to the provided offset value but always appended to previously collected data.
  3. 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:

  1. BlueTrace is generally respectful of a user's privacy, with only one minor privacy issue relating to TempID storage.
  2. 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:

  1. Global intents
  2. Local intents sent via LocalBroadcastManager
  3. CommandHandler messages, internal to BluetoothMonitoringService
  4. 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
  • 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
  • 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, using TempIDManager.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 of scanDuration (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 at healthCheckInterval (900s), will auto-repeat
  • ACTION_PURGE every purgeInterval (24h) since the last purge (as stored in the preference LAST_PURGE_TIME, scheduled using AlarmManager.setRepeating() 6.
  • ACTION_UPDATE_BM at bmCheckInterval (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 TTID
  • appVersion, 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 calls BluetoothGatt.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 of BluetoothProfile.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()
  • Already disconnected:
    • Do nothing, wait for CentralGattCallback.onConnectionStateChange() to do the cleanup.
  • Connected:
    • Set currentWork to null
    • Disconnect from remote device
    • Call finishWork()

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 permissions
  • characteristicV2, 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 Messages
  • writeDataPayload, to temporarily store data received from Central devices
  • deviceCharacteristicMap, 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:

  1. Connection is established
  2. Central requests to read value of characteristic, i.e. we need to send a contact tracing record
  3. Central requests to write a value to the characteristic, i.e. we need to receive a contact tracing record
  4. 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 inside writeDataPayload. However, the specification indicates that the provided offset 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 the execute 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 records
  • status_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 objects
  • purgeOldRecords(); to delete old StreetPassRecord objects given a timestamp
  • getCurrentRecords(), getLastRecord(), getMostRecentRecord(), getRecords(), getRecordsViaQuery(); to query for StreetPassRecord objects stored in the database
  • countRecordsInRange(), liveCountRecordsInRange(); to count the number of StreetPassRecord objects within a certain time period
  • nukeDb(); 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(); calls StreetPassRecordDao.getCurrentRecords()
  • nukeDb(); calls StreetPassRecordDao.nukeDb()
  • purgeOldRecords(); calls StreetPassRecordDao.purgeOldRecords()
  • saveRecord(); calls StreetPassRecordDao.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 device
  • processReadRequestDataReceived(), 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 device
  • processWriteRequestDataReceived(), 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 to TracerApp.thisDeviceMsg(), and then call V2WriteRequestPayload.getPayload() to convert it into an Encounter Message.
  • processReadRequestDataReceived(): Deserialize an Encounter message into a V2ReadRequestPayload object using V2ReadRequestPayload.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 to TracerApp.thisDeviceMsg(), and then call V2ReadRequestPayload.getPayload() to convert it into an Encounter Message.
  • processWriteRequestDataReceived(): Deserialize an Encounter Message into a V2WriteRequestPayload object using V2WriteRequestPayload.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:

2

Version 2.1 includes a native library to render a GIF animation shown after upgrading.

4

This has changed to 25 days as of BlueTrace 2.1.4.

5

Or more precisely, an individual's risk of being surveilled is already high, with or without BlueTrace.

6

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.

7

The manufacturer-specific data appears to be set for the use of iOS BlueTrace devices.

8

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.

9

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.

10

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.)

11

BlueTrace 2.1.4 introduced a new component, LightLifter, which handles record purging, and the purge interval is set to 25 days.