Safety numbers and fingerprints: verifying the person on the other end
X3DH keeps the bytes secret, but it cannot tell you who you are actually talking to. Here is the mathematics of the fingerprint that does.
The encryption is only as good as the key exchange. This is the line that ends most cryptography blog posts. But it deserves a follow-up question, because it is where the real attack surface lives: how do you know the key you exchanged belongs to the person you think you are talking to?
Every encryption scheme in Enchant assumes the handshake bound Alice's device to Bob's identity key. If a man in the middle substituted his own keys during that handshake — and his server-controlled position gives him every opportunity — the strongest cipher in the world delivers your messages to him, elegantly sealed. The defense is not a protocol. It is a fingerprint: a short, human-checkable digest of the public keys, computed from both sides, that two people can compare by voice, in person, or across a second channel.
Enchant ships two such constructions. They solve different problems, and the differences matter. This post derives both — exactly as they run in the LibEnchant cryptographic library — starting with the one that authenticates the session.
The safety number: authenticating a device
What it is. A 60-digit number that summarizes the identity keys of the two devices talking to each other. It is shown to both parties at session establishment, and it changes the moment either side's identity key changes — which is the whole point.
Why it exists. Enchant sessions are multi-device: Alice's laptop and Alice's phone both talk to Bob. The safety number must therefore pin down the exact device pair — if Bob's phone is replaced, Alice's laptop must see a different number and so must Bob's phone.
How it is computed. Two identity keys (32 bytes each) plus the device's address name plus the device ID, hashed once with SHA-256, and the first 30 bytes are kept. In LibEnchant the input is literally:
The raw 30 bytes are rendered as 60 decimal digits — two digits per
byte, each byte mapped into — and displayed in groups of
five: 88450 22933 17384 90254 67102 34935. Every byte of the digest
matters; a single differing bit in either identity key flips roughly
half the digits. (In code: enchant_safety_number_generate; the
digits are grouped by the constant SAFETY_NUMBER_DISPLAY_LEN = 60.)
Why only 30 bytes? The digest is not a secret and not a MAC; it is
an identity check, and it is compared under adversarial conditions.
The honest concern is an attacker who can predict a partial collision —
who substitutes keys whose digest matches the first few digits the
victim will read out loud. Thirty bytes (240 bits) leaves no room for
that. The comparison itself runs in constant time via
sodium_memcmp (enchant_safety_number_compare), so the comparison
leaks no timing information about where the two digests differ.
The trust model. The safety number is shown at session establishment; trusting it is a user decision, and Enchant's Sesame layer encodes the states explicitly: unverified, trusted on first use, verified. The identity key store tracks whether an identity is trusted for sending. The moment a peer's identity key changes — a new phone, a reinstalled app, a stolen key — the store flags the change and the new session shows a new number, unverified. A safety number is a state machine, not a string.
The fingerprint: a stronger, slower check
What it is. A second, heavier digest used when both parties can compare out-of-band (face to face, QR scan, second device). The fingerprint runs a deliberately expensive iterative hash so that forging a collision becomes computationally infeasible even against a well-resourced adversary.
Why it exists. The safety number is fast and cheap — a single SHA-256. That is fine for "does this number match what I expect." But an attacker with real compute could in principle hunt for a substitution whose digest matches; the safety number's 240-bit commitment is strong but cheap to verify on both sides. The fingerprint raises the cost of forging the entire digest by many orders of magnitude — no shortcut is possible, because the attacker would have to solve 5,200 sequential hash preimages.
How it is computed. Two inputs are combined: a stable ID — the SHA-512 of the concatenated local ID, local identity key, remote ID, and remote identity key — and the local key itself:
Then a loop runs 5,200 iterations (the constant
FINGERPRINT_DEFAULT_ITERATIONS). In each iteration the input is the
version (2 bytes, big-endian, value 0), the iteration counter (4
bytes, big-endian), and — alternating by parity — either the local
identity key (even iterations) or the stable ID (odd iterations):
Each 64-byte output is XOR-folded into the running accumulator — a 512-bit sponge that absorbs every iteration:
After all 5,200 rounds, the accumulator's first 30 bytes
(FINGERPRINT_RAW_BYTES = 30) are the fingerprint, rendered as the
same 60-digit grouped display. Because every round feeds the previous
state, an attacker cannot precompute a table of "plausible
fingerprints" — they must run the entire chain per candidate. This is
deliberately not a fast primitive. It is the opposite of one.
The version byte exists so the iteration count and input layout can be
bumped in the future without breaking old verifiers; the scannable
format packages it explicitly: version (2 bytes) ‖ local fingerprint
(30 bytes) ‖ remote fingerprint (30 bytes), compared constant-time
with enchant_fingerprint_compare.
Safety number vs. fingerprint: which one when?
| Safety number | Fingerprint | |
|---|---|---|
| Input | Identity keys + name + device ID | Stable ID + local key, 5,200× SHA-512 |
| Cost | One SHA-256 | 5,200 SHA-512 + XOR fold |
| Purpose | Fast session-time check | Strong out-of-band verification |
| Changes with | Identity key change | Identity key change |
| Strength | 240-bit digest | 240-bit digest * hardened by work factor |
Both live behind the same trust state machine (enchant_sesame_* in
LibEnchant: establish trust, verify identity, validate trust for
sending, compute safety numbers on demand). They are not competing
features; they are two speeds of the same commitment — the cheap daily
check and the expensive ceremony.
Why this matters more than the cipher
Consider what an attacker with control of the directory can do. They can substitute their own pre-key bundle, establish a session, and read everything — if the victim never checks the digest. That is the entire threat model of endpoint verification, and it is why Enchant shows the safety number at session establishment and marks identity changes as high-severity events, not cosmetic notices.
The math guarantees something narrower and precise: if both parties compare the same 60 digits out-of-band, the session is authenticated. Fabricating a matching digest requires either solving a 5,200-iteration hash chain (fingerprint) or finding a 240-bit collision (safety number) — neither is remotely feasible. The number is short enough to read over a call, strong enough to make forgery pointless, and cheap enough to check every time. That triangle — verifiable, unforgeable, usable — is what turns "the keys match" into "I am talking to the person I think I am."
Continue reading

