Skip to content

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.

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.

Per party, from their Ed25519 identity public key and wallet address:

Per-party fingerprint
input_0 = version(0x0001) || public_key || utf8(lowercase(address))
input_i = SHA-512(input_{i-1}) || public_key — repeated 5,200 times
digits = first 30 decimal digits mapped from the final SHA-512 output

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

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

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:

SAS derivation
info = "zentalk.sas/v1:{addr_low}:{addr_high}" — addresses sorted, normalized
derived = 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 digits
words = first 4 bytes, each indexing a fixed 256-word list

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

  1. Both parties open the verification view (in calls, the lock indicator) once the ratchet is established.
  2. Each device independently derives the SAS from its session root key.
  3. The parties compare codes over the channel itself (voice/video) — a MITM proxying two separate sessions cannot make both sides show the same code.
  4. On confirmation, each user marks the contact verified.

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.

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.

  • 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 changed status.
  • 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-web repository (THREAT_MODEL.md) explicitly relies on out-of-band SAS verification to close the KTL split-view residual against active MITM adversaries.
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