Update 2024-03-09: the two digits can be overwritten by the user - thanks for pointing it out, Stefan!
Introduction
My favorite messaging app Signal recently launched a major feature where they try to remove phone numbers as identifiers as much as possible. A recent blog “Keep your phone number private with Signal usernames” describes it. Instead they use usernames like “meisterluk.42” now. This username can be chosen by the user. Recognize that these usernames are temporary to find a user right away, but the identifier can be easily given up and there is no directory of usernames. This feature also includes a QR code generation specific for your username:
You can also generate a QR code or link that directs people to your username, letting them quickly connect with you on Signal.
We wondered whether this QR code URL encodes only the username. If so, the scanning user might reach another person, because the username was given up and another person now uses it. Can this happen? If there is additional information (e.g. when the username was assigned), this case can be prevented.
FOSS (abbr. free open source software) has the beautiful property that this technical aspect can be checked by anyone.
Source code analysis
Heads-up: the app is written in Kotlin; an obvious choice for the Android application.
-
In order to get started, we need to identify where the QR is shown. In QrCodeBadge, we can see that the screen is divided into rows and columns to generate the user interface. So this provides the starting point where we can identify which information is used for QR code generation.
-
So we generate a QrCode instance to be represented. Does it really reveal which information is encoded? No, because we only deal with pixels and bits. The code does not indicate how the bits have been created.
-
So far we understand that the UI component QRCode is generated with
data.data
as information source. Thus thedata
member of QrCodeState which is of type QrCodeData. -
Once more, QrCodeData only considers the bits and not semantic elements of the stored information. At this point, we need to get back to the first step. So we generate the QR code, but who fill data into the
data
member? -
Most likely UsernameLinkShareScreen does it. There are several calls of
QrCodeBadge
(the function we looked at in the first step), but I found this one most plausible. -
If so, the information is encoded in is
qrCodeState
of an object of class UsernameLinkSettingsState. -
And which value does
qrCodeState
have? Well, this really depends on the touch user interface state (loading screen versus ready). But likely the relevant information is stored in_state.qrCodeState
. -
Where is
qrCodeState
set? This happens based on a callback once the data is available. -
Based on the callback response data, the link data must be generated.
toLink()
does it. -
Where is
toLink()
implemented? In UsernameRepository. -
entropy and serverId are the components. This corresponds to the documentation of UsernameLinkComponents:
“[…] for passing around the two components of a username link: the entropy and serverId, which when combined together form the full link handle.”
-
But let us take one step back to
UsernameRepository
. Its comprehensive documentation reveals the solution to our question:
We want usernames to be assigned a random numerical discriminator to avoid land grabs
We don’t want to store plaintext usernames on the service
We don’t want plaintext usernames in username links
We want username links to be revocable and rotatable without changing your username
We want users to be able to turn a link into a displayable username in the app
There’s three main components to username links:
An encrypted username blob
A serverId (which is a UUID)
"entropy" (some random bytes used to encrypt the username)
The service basically stores a map of (serverId → encrypted username blob). We can ask the service for the encrypted username blob for a given serverId, and then decrypt it with the entropy. Simple enough.
How are those pieces shared? Well, the link looks like this: https://signal.me/#eu/<32 bytes of entropy><16 bytes of serverId uuid>
So, when we get a link, we parse out the entropy and serverId. We then use the serverId to get the encrypted username, and then decrypt it with the entropy.
This gives us everything we want:
We can rotate our link without changing our username by just picking new (serverId, entropy) and storing a new blob on the service.
When the user decrypts the username, they see it displayed exactly how the user uploaded it.
The service has no idea what links correspond to what usernames — it’s just storing encrypted blobs.
Summary
So the URL is decoded as follows:
-
serviceID
is extracted from the URL (final 16 bytes) -
A Signal server responds back which encrypted username blob is associated with this
serviceID
-
We use the entropy of the URL (first 32 bytes) to decrypt the encrypted username blob. We get a username blob.
-
By requirement #5, this is decoded into the displayable username together with the discriminator (the two digits as part of the username).
But is really just (username, discriminator)?
The source code reveals it is the tuple (username, ACI). What is ACI?
Let’s make it easy here and the user is that ACI just means that it is the hashed username, whose existence is verified by making an API call to this URL.
Conclusion
The QR code only contains the displayable username with the two digits. But the Signal server never sees this value and a whole lot of different QR codes can be generated for this one identical value.
And I like FOSS as well as the Signal source code.