Contact verification
Zentalk provides two human-checkable verification mechanisms: a 60-digit safety number derived from both parties’ long-term identity keys, and a short per-session SAS code (6 digits or 4 words) derived from the live Double Ratchet root key. Both exist to detect man-in-the-middle attacks that automated checks cannot rule out.
Implementation: zentalk/src/services/e2ee/safety-numbers.ts, sas.ts, sas-verification.ts, sas-session.ts.
Safety numbers
Section titled “Safety numbers”A safety number fingerprints the identity keys of a conversation. It follows the Signal Protocol construction: each party gets a 30-digit fingerprint; the safety number is both fingerprints concatenated — 60 digits, displayed as 12 groups of 5.
Derivation
Section titled “Derivation”Per party, from their Ed25519 identity public key and wallet address:
input_0 = version(0x0001) || public_key || utf8(lowercase(address))input_i = SHA-512(input_{i-1}) || public_key — repeated 5,200 timesdigits = first 30 decimal digits mapped from the final SHA-512 outputThe 5,200-round iterated hash provides key stretching, and re-appending the public key each round binds every iteration to the key. The two fingerprints are ordered by lowercase address (lower address first), so both parties compute the identical 60-digit number regardless of who initiates.
Properties
Section titled “Properties”- Depends only on long-term identity keys — stable across sessions, device restarts, and ratchet steps.
- Changes only when a party’s identity key changes (new device without key transfer, re-registration, or an attack).
- Verified out-of-band (in person, video call, or another trusted channel) by comparing all 12 groups.
SAS codes
Section titled “SAS codes”The SAS (Short Authentication String) fingerprints the live session rather than the identity keys. It is derived from the Double Ratchet root key, which both parties share only if the X3DH exchange was not tampered with:
info = "zentalk.sas/v1:{addr_low}:{addr_high}" — addresses sorted, normalizedderived = HKDF-SHA256(root_key, salt = 32 zero bytes, info, 5 bytes)
digits = (40-bit big-endian integer from the 5 bytes) mod 1,000,000 → 6 digitswords = first 4 bytes, each indexing a fixed 256-word listSorting the addresses into the info string guarantees symmetry: derive(root, A, B) = derive(root, B, A). The word list is a 256-entry subset of the EFF short wordlist, chosen to avoid commonly confused spoken pairs; a byte’s value is its word index, so the list must never be reordered.
Digits render as 123 456; the word form (“acorn bamboo cedar crane”) is designed to be read aloud on calls.
Comparison flow
Section titled “Comparison flow”- Both parties open the verification view (in calls, the lock indicator) once the ratchet is established.
- Each device independently derives the SAS from its session root key.
- The parties compare codes over the channel itself (voice/video) — a MITM proxying two separate sessions cannot make both sides show the same code.
- On confirmation, each user marks the contact verified.
Verification state
Section titled “Verification state”Confirmations persist locally with the code that was showing at confirmation time. On every render the live SAS is reconciled against the stored record:
| Status | Meaning |
|---|---|
not-established |
No completed ratchet session yet — nothing to verify |
established-unverified |
Session live, user has not yet confirmed the code |
verified |
Stored code matches the live SAS |
changed |
Live SAS differs from the code confirmed earlier — the session was rebuilt or re-keyed; re-verify |
A changed status is expected after a legitimate session rebuild (fresh X3DH after a broken session) but is exactly the signal an attack would also produce — the UI surfaces it as a “safety number changed — re-verify” warning rather than silently re-trusting.
The session snapshot also exposes whether the session negotiated the post-quantum hybrid handshake (pqcEnabled), which drives the “quantum-protected” badge.
Server-side verified flag
Section titled “Server-side verified flag”Marking a contact verified can be synchronized via the API:
| Endpoint | Method | Purpose |
|---|---|---|
/api/v1/verify-contact |
POST | Mark a contact’s safety number as verified |
/api/v1/unverify-contact |
POST | Remove the verified flag |
The flag is UI state only — the cryptographic comparison always happens on-device. See the REST API for request schemas.
When users should verify
Section titled “When users should verify”- First sustained contact — before exchanging sensitive content with a new peer.
- After any “changed” warning — from the SAS reconciliation or from a Key Transparency Log
changedstatus. - When KTL reports
unverifiable— the log cannot vouch for the key, so the out-of-band check is the only remaining assurance. - High-value conversations — the threat model in the
zentalk-webrepository (THREAT_MODEL.md) explicitly relies on out-of-band SAS verification to close the KTL split-view residual against active MITM adversaries.
Defense in depth
Section titled “Defense in depth”| Layer | Checks | Detects |
|---|---|---|
| X3DH signed-prekey verification | Automatic, cryptographic | Tampered prekeys |
| Key Transparency Log | Automatic, background | Server-side key substitution (tamper-evident) |
| Safety numbers / SAS | Manual, out-of-band | Everything above, plus split-view attacks |
