Python 3 and a terminal. Steps 1–4 need no phone, no Signal account and no network — the model runs entirely on your own machine.
Step 5 needs Signal installed and a second person to compare with. It is the only step that leaves the terminal.
You need no cryptography background. One idea carries the whole lab: a fingerprint is a short value derived from a key, so two people holding the same pair of keys always compute the same fingerprint — and anyone standing between them cannot.
Why Signal?
Signal is a free, open-source messaging app that provides end-to-end encryption for all messages, calls, and file transfers. Unlike most messaging apps, Signal collects virtually no metadata about its users.
Installing Signal
On Your Phone
On Desktop
Signal Desktop links to your phone and syncs messages:
Essential Privacy Settings
Open Signal Settings on your phone to configure these important options:
Disappearing Messages
Set a default timer for messages to automatically delete after a set period. Go to Settings > Privacy > Default timer for new chats and choose a duration (e.g., 1 week or 4 weeks).
Screen Security
Enable Settings > Privacy > Screen Security to prevent Signal content from appearing in the app switcher and block screenshots within the app.
Registration Lock
Enable Settings > Account > Registration Lock to prevent someone from re-registering your phone number on another device without your PIN.
If you enable Registration Lock and forget your PIN, you may be locked out of your own account for up to 7 days.
Using Signal Effectively
- Group chats: Create encrypted group conversations for family, friends, or teams
- Voice and video calls: All calls are end-to-end encrypted by default
- File sharing: Send documents, photos, and files securely
- Note to Self: Send messages to yourself as an encrypted notepad
- Verify safety numbers: Tap a contact's name to compare safety numbers in person, confirming there is no interception
Signal vs Other Messengers
Now Do It Yourself: Build a Safety Number and Watch It Catch an Interception
Signal's safety number is the one feature that turns "the app says it is encrypted" into something you can actually check — and it is the feature almost nobody uses. In twenty minutes you can build a working model of it, catch a simulated interception with it, and see precisely why the warning it produces is so often ignored.
You need Python 3. Steps 1–4 need no phone, no Signal account and no network — they are arithmetic. This is a faithful model of the structure, not Signal's exact algorithm: real Signal iterates the hash 5,200 times over real Curve25519 identity keys, and the numbers here will not match any real conversation. Every output below came from running these exact commands.
Go: open a terminal, then mkdir siglab and cd siglab. Signal is not needed for steps 1–4 and nothing is sent anywhere.
Do: save this as safetynumber.py. It is a working model of how Signal turns two identity keys into the 60 digits your phone shows:
import hashlib, sys
def fingerprint(identity_key: bytes, phone: str) -> str:
"""A person's half of a safety number: 30 digits derived from their key + identifier.
Signal iterates the hash 5200 times; 5000 here keeps the lab fast. The structure is the
point: the digits come from the KEY, so a different key gives different digits.
"""
h = b"\x00\x00" + identity_key + phone.encode()
for _ in range(5000):
h = hashlib.sha512(h + identity_key).digest()
return "".join(f"{int.from_bytes(h[i:i+5], 'big') % 100000:05d}" for i in range(0, 30, 5))
def safety_number(a_key, a_phone, b_key, b_phone) -> str:
"""Both people compute this and must get the SAME answer. Sorting makes it order-independent."""
halves = sorted([fingerprint(a_key, a_phone), fingerprint(b_key, b_phone)])
joined = "".join(halves)
return " ".join(joined[i:i+5] for i in range(0, len(joined), 5))
if __name__ == "__main__": # only runs when you execute THIS file directly
alice_key = b"A" * 32 # stand-ins for real identity keys
bob_key = b"B" * 32
print("What ALICE's phone shows:")
print(" ", safety_number(alice_key, "+15550001", bob_key, "+15550002"))
print("\nWhat BOB's phone shows:")
print(" ", safety_number(bob_key, "+15550002", alice_key, "+15550001"))
Run it:
python3 safetynumber.py
You should see: two phones, computing separately, arriving at exactly the same number:
What ALICE's phone shows:
04889 70742 81764 26723 23436 65923 67623 17767 77424 37247 58785 47306
What BOB's phone shows:
04889 70742 81764 26723 23436 65923 67623 17767 77424 37247 58785 47306
Neither phone asked a server what the number should be. Each derived it from the two keys it already holds, and sorting the halves makes the result independent of who is asking. That is why the number can be compared out loud — there is nothing to look up and nothing to trust.
If not: if it takes more than a second or two, that is normal — the hash runs 5,000 times on purpose. If you get ModuleNotFoundError, you are in a different folder from the file.
Go: the same folder.
Do: save this as onebyte.py. It computes the number twice,
with Bob’s key differing by a single byte at the very end:
from safetynumber import safety_number
alice = b"A" * 32
bob = b"B" * 32
bob2 = b"B" * 31 + b"C" # exactly one byte different
print("Bob key ending ...BBBB:")
print(" ", safety_number(alice, "+15550001", bob, "+15550002"))
print()
print("Bob key ending ...BBBC (one byte changed):")
print(" ", safety_number(alice, "+15550001", bob2, "+15550002"))
Then run it:
python3 onebyte.py
You should see: the first half identical — that is Alice, who did not change — and Bob's half completely different:
Bob key ending ...BBBB:
04889 70742 81764 26723 23436 65923 67623 17767 77424 37247 58785 47306
Bob key ending ...BBBC (one byte changed):
04889 70742 81764 26723 23436 65923 12991 09980 38610 94821 99705 60423
One byte in, thirty digits out, sharing nothing with the original. There is no "close" here — a safety number either matches or it does not, so comparing the first few groups and stopping is not a shortcut, it is the whole check skipped.
If not: if both lines are identical, the b2 line lost its final +b'C' when copying. The two keys must genuinely differ.
Go: the same folder.
Do: save this as mitm.py. Mallory relays messages between Alice and Bob, holding a separate encrypted conversation with each:
from safetynumber import safety_number
alice_key = b"A" * 32
bob_key = b"B" * 32
mallory_key = b"M" * 32 # the interceptor's own key
print("Alice believes she is talking to Bob, but Mallory is in the middle.")
print("Alice's phone was handed MALLORY's key and labelled it 'Bob':")
print(" ", safety_number(alice_key, "+15550001", mallory_key, "+15550002"))
print("\nBob's phone was handed MALLORY's key and labelled it 'Alice':")
print(" ", safety_number(bob_key, "+15550002", mallory_key, "+15550001"))
print("\nBoth chats are encrypted. Both show a padlock. Neither number matches the other.")
Run it:
python3 mitm.py
You should see: two different numbers where there should be one:
Alice believes she is talking to Bob, but Mallory is in the middle.
Alice's phone was handed MALLORY's key and labelled it 'Bob':
04889 70742 81764 26723 23436 65923 73155 49416 17918 17607 29185 54540
Bob's phone was handed MALLORY's key and labelled it 'Alice':
67623 17767 77424 37247 58785 47306 96140 08900 01723 96453 28547 77638
Both chats are encrypted. Both show a padlock. Neither number matches the other.
🔴 Read the last line again. Both conversations are genuinely end-to-end encrypted. Both phones display every reassuring sign the app has. Encryption was never the thing that failed — the keys were swapped before it started, and the only evidence is that two numbers which should be identical are not. Nothing inside either phone can see this. Only comparing the numbers with each other can.
If not: if both numbers come out the same, mallory_key is equal to one of the others — check it reads b"M" * 32.
Go: the same folder.
Do: save this as reinstall.py. Nobody is attacking here — Bob simply got a new phone, which gives Signal a new identity key:
from safetynumber import safety_number
alice_key = b"A" * 32
bob_key = b"B" * 32
bob_new = b"B2" + b"B" * 30 # Bob reinstalled Signal: brand-new identity key
print("Before Bob reinstalled:")
print(" ", safety_number(alice_key, "+15550001", bob_key, "+15550002"))
print("\nAfter Bob reinstalled (new phone, or restored from backup):")
print(" ", safety_number(alice_key, "+15550001", bob_new, "+15550002"))
print("\nSignal shows 'Your safety number with Bob has changed'.")
print("Identical to what an interception looks like. The app cannot tell them apart.")
Run it:
python3 reinstall.py
You should see: the number changes, exactly as it did under attack:
Before Bob reinstalled:
04889 70742 81764 26723 23436 65923 67623 17767 77424 37247 58785 47306
After Bob reinstalled (new phone, or restored from backup):
04889 70742 81764 26723 23436 65923 76980 49552 25797 27048 08046 62552
Signal shows 'Your safety number with Bob has changed'.
Identical to what an interception looks like. The app cannot tell them apart.
🔴 This is why the warning gets dismissed. A new phone and an interception produce the same alert, and the innocent explanation is overwhelmingly the common one — so people learn to tap through it. The app genuinely cannot distinguish the two, and that is not a flaw to be fixed: no software can tell whether a key changed because your friend upgraded or because someone stepped between you. Only your friend can, and only over a channel the attacker does not control.
If not: if the two numbers match, bob_new is not actually different — it must start b"B2" so the first bytes differ from bob_key.
Go: Signal on your phone. Open a conversation, tap the contact's name at the top, then View Safety Number.
Do: compare that 60-digit number with theirs over a channel Signal does not control — in person, or on a phone call where you recognise their voice. If you are together, scanning each other's QR code does the same comparison instantly. Then tap Mark as verified. Reading it aloud is fine; the number is not a secret and revealing it costs nothing.
You should see: a green checkmark on that conversation. From then on, Signal warns you if the number ever changes — and you now know from step 4 exactly what that warning does and does not mean.
This step needs the app and a second person, so it was not re-run for this page. Steps 1–4 were, and they are what the reasoning rests on.
🔴 Do not verify over Signal itself. If someone is in the middle, they are relaying those messages too, and can simply send each of you the number you expect. The comparison is only worth anything on a channel the attacker does not sit on — which is the entire reason the feature exists.
If not: if the numbers differ and neither of you changed phones, stop messaging anything sensitive and re-establish contact another way. If your contact recently reinstalled, that explains it — but confirm that by voice, not by asking in the chat, because in the case you are checking for, the chat is exactly what is compromised.
Signal tells you your safety number with a friend has changed. You message them in that same Signal chat and they reply "yes, I got a new phone." Are you now safe? Answer: No — you have learned nothing. If someone is intercepting, they are relaying that chat too and can send exactly that reply. Step 3 showed both sides seeing a normal, encrypted, padlocked conversation throughout. The reply has to come over a channel the attacker does not control: their voice on a call, or in person.
Now do it without the page: explain to someone why a safety number can be read aloud in a crowded room without weakening anything, while a password cannot. If you can say why one is a fingerprint and the other is a secret, you understand what the feature is for.
Summary
In this tutorial, you learned:
- Why Signal is recommended for private communication
- How to install Signal on phone and desktop
- Essential privacy settings to configure
- How to use Signal's features effectively
- How Signal compares to other messaging apps
Encourage your contacts to join Signal too. Encryption only works when both sides use it.