Why Math Matters in Encryption
Every time you send a WhatsApp message, your phone performs elliptic curve multiplications, hash computations, and Diffie-Hellman key exchanges — all in under 50 milliseconds. The math is what makes it impossible for anyone (including WhatsApp itself) to read your messages.
Anchora uses the exact same algorithms. This blog explains what's actually happening under the hood — no PhD required.
1. Elliptic Curves — The Foundation of Everything
All keys in the Anchora system are points on a mathematical curve called Curve25519.
The Curve Equation
y² = x³ + 486662x² + x (mod p)
where p = 2²&sup5;&sup5; - 19 (a very large prime number — that's where "25519" comes from)
This isn't a curve you'd draw in school. It exists in a "finite field" — a world where all numbers wrap around at p (like a clock wraps at 12). Every valid (x, y) pair that satisfies this equation is a "point" on the curve.
Why Curves? The Trapdoor
The magic of elliptic curves is point multiplication — you can multiply a point by a number, but you can't reverse it:
Easy (fast): Public Key = private_key × G (takes microseconds)
Hard (impossible): private_key = Public Key ÷ G (would take billions of years)
G is a known "generator point" — a specific starting point on the curve that everyone agrees on. Multiplying G by your private key (a random 256-bit number) gives you a public key (another point on the curve).
This one-way property is called the Elliptic Curve Discrete Logarithm Problem (ECDLP). No one has found an efficient way to solve it. The best known attack against Curve25519 requires approximately 2¹²&sup8; operations — more than all the atoms in the observable universe.
2. Key Generation — What Happens When You Call generateKeySet()
Ed25519 Key Pair (Identity Key)
Step 1: Generate 32 random bytes (256 bits of entropy)
seed = crypto.randomBytes(32)
Step 2: Hash with SHA-512 to get 64 bytes
h = SHA-512(seed)
Step 3: "Clamp" the first 32 bytes (private scalar)
h[0] &= 248 // Clear lowest 3 bits → ensures divisible by 8
h[31] &= 127 // Clear highest bit → keeps number < 2²&sup5;&sup5;
h[31] |= 64 // Set second-highest → ensures consistent bit length
WHY CLAMP?
- Divisible by 8: prevents small-subgroup attacks
- < 2²&sup5;&sup5;: prevents wrap-around modular reduction
- Fixed high bit: ensures constant-time operations (prevents timing attacks)
Step 4: Compute public key
public_key = clamped_scalar × B
(B is the Ed25519 base point, different from X25519's)
Result:
Private key: 32 bytes (the clamped scalar — NEVER leaves the device)
Public key: 32 bytes (a point on the curve — stored on server as 64-char hex)
X25519 Key Pair (Pre-Keys and OPKs)
Step 1: Generate 32 random bytes
private_key = crypto.randomBytes(32)
Step 2: Clamp (same rules as Ed25519)
private_key[0] &= 248
private_key[31] &= 127
private_key[31] |= 64
Step 3: Compute public key
public_key = private_key × G
(G is the X25519 base point: x-coordinate = 9)
Result:
Private key: 32 bytes (clamped random — NEVER leaves the device)
Public key: 32 bytes (x-coordinate only — stored on server as 64-char hex)
Why Two Different Curves?
Ed25519 and X25519 are actually the same underlying curve (Curve25519), but used in two different forms:
| Ed25519 | X25519 | |
|---|---|---|
| Form | Twisted Edwards | Montgomery |
| Used for | Digital signatures | Key exchange (Diffie-Hellman) |
| Output | (x, y) coordinates | x-coordinate only |
| Why this form | Faster, constant-time signing | Faster, simpler DH computation |
They're mathematically equivalent — you can convert between them. But using the right form for each purpose is faster and safer.
3. Digital Signatures — How Ed25519 Signing Works
When you register keys, the Signed Pre-Key must be signed by the Identity Key. This proves "this pre-key belongs to me." Here's the exact math:
Signing
Inputs:
message = the signed pre-key's public key (32 bytes)
private_key = Ed25519 private key (from seed)
public_key = corresponding Ed25519 public key
Step 1: Compute deterministic nonce
r = SHA-512(private_key_second_half || message)
r = r mod L (L is the curve order)
WHY DETERMINISTIC?
- No random number generator needed during signing
- If RNG is broken/biased, signature is STILL secure
- Same message always produces same signature (reproducible)
- PS3 was hacked because Sony used a FIXED nonce in ECDSA
— Ed25519 prevents this by design
Step 2: Compute commitment point
R = r × B (B = base point)
Step 3: Compute challenge
h = SHA-512(R || public_key || message)
h = h mod L
Step 4: Compute response
s = (r + h × private_scalar) mod L
Step 5: Signature = (R, s) — 64 bytes total
First 32 bytes: R (compressed point)
Last 32 bytes: s (scalar)
Verification
Inputs:
message = the data that was signed
signature = (R, s) — 64 bytes
public_key = signer's Ed25519 public key
Step 1: Recompute challenge
h = SHA-512(R || public_key || message)
h = h mod L
Step 2: Check the equation
s × B == R + h × public_key ?
WHY THIS WORKS:
s × B = (r + h × private) × B ← substitute s
= r × B + h × private × B ← distribute
= R + h × public_key ← because R = r×B and public = private×B
✓ MATCHES!
Without the private key, you can't produce s that satisfies this equation.
Forging a signature = solving ECDLP = computationally impossible.
What This Looks Like in Anchora
Identity Key (Ed25519):
Private: f1a2b3c4... (32 bytes, stays on device)
Public: 9c5ae952... (32 bytes, stored on server)
Signed Pre-Key (X25519):
Public: f00082dc... (32 bytes)
Signature = Ed25519.sign(signed_pre_key_public, identity_private)
Result: edaa2533... (64 bytes = 128 hex chars, stored on server)
Anyone can verify:
Ed25519.verify(signed_pre_key_public, signature, identity_public)
→ true (this pre-key really belongs to this identity)
4. Diffie-Hellman Key Exchange — The Core Magic
This is the most elegant piece of mathematics in all of cryptography. Two strangers create the same secret without ever exchanging secrets.
The Basic Idea
Public knowledge: G (generator point), p (prime modulus)
Alice (private = a): Bob (private = b):
A = a × G (public key) B = b × G (public key)
Publishes A →→→→→→→ Publishes B
←←←←←←←
Alice computes: Bob computes:
shared = a × B shared = b × A
= a × (b × G) = b × (a × G)
= ab × G = ba × G
= SAME! = SAME!
An eavesdropper sees A and B but CANNOT compute ab × G.
This is the Computational Diffie-Hellman (CDH) problem — no efficient solution known.
X25519 Specifically
shared_secret = X25519(my_private_key, their_public_key)
Internally:
1. Decode their_public_key as x-coordinate on Curve25519
2. Perform scalar multiplication: result = my_private × their_point
3. Return the x-coordinate of the result (32 bytes)
Properties:
- Always 32 bytes output
- Constant-time (immune to timing side-channel attacks)
- No invalid curve attacks (every 32-byte input is valid)
- Takes ~150 microseconds on modern hardware
5. X3DH — The 4 DH Operations Explained
When Alice wants to message Bob, the SDK performs X3DH (Extended Triple Diffie-Hellman). Here's the exact math:
Alice has: Bob's bundle (from server):
IK_A = Ed25519 identity key pair IK_B = Ed25519 identity public key
EK_A = X25519 ephemeral key pair SPK_B = X25519 signed pre-key public
OPK_B = X25519 one-time pre-key public
NOTE: Ed25519 keys are converted to X25519 for DH operations.
(This is a standard birational map between twisted Edwards and Montgomery forms)
The 4 DH Operations
DH1 = X25519(IK_A_private, SPK_B_public)
Alice's IDENTITY × Bob's SIGNED PRE-KEY
PURPOSE: Mutual authentication
PROPERTY: Only works if Alice is who she claims (has IK_A private)
and the pre-key really belongs to Bob (signed by IK_B)
DH2 = X25519(EK_A_private, IK_B_public)
Alice's EPHEMERAL × Bob's IDENTITY
PURPOSE: Forward secrecy for the initiator
PROPERTY: EK_A is thrown away after this — even if Alice's identity
key is compromised later, this DH result can't be recomputed
DH3 = X25519(EK_A_private, SPK_B_public)
Alice's EPHEMERAL × Bob's SIGNED PRE-KEY
PURPOSE: Asynchronous capability
PROPERTY: Works even when Bob is offline (SPK is already on server)
DH4 = X25519(EK_A_private, OPK_B_public)
Alice's EPHEMERAL × Bob's ONE-TIME PRE-KEY
PURPOSE: Per-session uniqueness (forward secrecy)
PROPERTY: OPK is consumed (used once, deleted) — even if everything
else is compromised, each session has a unique component
Why 4 Operations?
Each DH protects against a different attack scenario:
| Attacker compromises... | Affected DH | Impact |
|---|---|---|
| Alice's identity key (IK_A) | DH1, DH2 | DH3, DH4 still secret |
| Bob's signed pre-key (SPK_B) | DH1, DH3 | DH2, DH4 still secret |
| Alice's ephemeral key (EK_A) | DH2, DH3, DH4 | DH1 still secret (but EK_A is deleted!) |
| Bob's one-time pre-key (OPK_B) | DH4 | DH1, DH2, DH3 still secret |
| ALL of Alice's long-term keys | DH1, DH2 | DH3, DH4 (ephemeral/OPK) protect past sessions |
Combining the Results
raw_material = DH1 || DH2 || DH3 || DH4 (128 bytes concatenated)
This raw material goes into HKDF (next section) to produce the actual encryption key.
If OPK is not available (exhausted):
raw_material = DH1 || DH2 || DH3 (96 bytes — still secure, slightly weaker)
6. HKDF — Turning Raw DH Output into Proper Keys
The raw DH outputs aren't suitable as encryption keys directly. They might have biased bits or predictable patterns. HKDF (HMAC-based Key Derivation Function) from RFC 5869 solves this.
Two-Phase Process
Phase 1: EXTRACT — compress randomness into a fixed-size key
salt = 32 bytes of zeros (or a random value)
IKM = DH1 || DH2 || DH3 || DH4 (input keying material)
PRK = HMAC-SHA-256(salt, IKM)
= 32 bytes of high-quality pseudorandom material
WHAT THIS DOES:
- Even if DH outputs have some structure, HMAC destroys it
- Output is indistinguishable from random (proven property of HMAC)
- Fixed 32-byte output regardless of input size
Phase 2: EXPAND — derive multiple keys from the PRK
info = "anchora-x3dh-shared-secret" (context string, prevents cross-protocol attacks)
T(1) = HMAC-SHA-256(PRK, info || 0x01) → 32 bytes (encryption key)
T(2) = HMAC-SHA-256(PRK, T(1) || info || 0x02) → 32 bytes (authentication key)
T(3) = HMAC-SHA-256(PRK, T(2) || info || 0x03) → 32 bytes (more keys if needed)
WHAT THIS DOES:
- Derives MULTIPLE independent keys from one shared secret
- Each key is cryptographically independent (knowing one doesn't reveal others)
- The "info" string ensures keys derived for different purposes are different
Final result:
shared_secret = T(1) = 32-byte AES-256-GCM key
This is what the SDK returns as session.sharedSecret
Both Alice and Bob derive the EXACT SAME 32 bytes
Why Not Just Use SHA-256 Directly?
| Approach | Problem |
|---|---|
SHA-256(DH1 || DH2 || DH3 || DH4) |
No salt support (vulnerable to pre-computation attacks), no domain separation, can only derive one key |
HKDF-SHA-256(salt, DH..., info) |
Salt adds randomness, info string separates uses, can derive unlimited independent keys. RFC 5869 — formally proven secure |
7. AES-256-GCM — Encrypting the Actual Message
Once the shared secret is derived, messages are encrypted with AES-256-GCM (Galois/Counter Mode).
How It Works
Inputs:
key = 32 bytes (from HKDF — the shared secret)
plaintext = the message to encrypt
nonce = 12 random bytes (MUST be unique per message with the same key)
aad = optional "additional authenticated data" (authenticated but not encrypted)
Encryption:
Step 1: Generate counter blocks
CTR_0 = nonce || 0x00000001
CTR_1 = nonce || 0x00000002
CTR_2 = nonce || 0x00000003
...
Step 2: Encrypt (Counter mode)
keystream_1 = AES-256(key, CTR_1) ← 16-byte block
keystream_2 = AES-256(key, CTR_2)
...
ciphertext = plaintext XOR keystream ← same length as plaintext
Step 3: Authenticate (GHASH — the "G" in GCM)
tag = GHASH(H, aad, ciphertext) ← 16-byte authentication tag
where H = AES-256(key, 0¹²&sup8;) ← hash key derived from encryption key
Result:
output = nonce || tag || ciphertext
(nonce is prepended so the receiver knows what nonce was used)
Why GCM?
| Mode | Encrypts | Authenticates | Limitation |
|---|---|---|---|
| AES-CBC | Yes | No | Attacker can modify ciphertext without detection (padding oracle, bit flipping) |
| AES-CTR | Yes | No | Parallelizable but still no integrity check |
| AES-GCM | Yes | Yes | None — encrypts AND authenticates in ONE pass (~4 GB/s with AES-NI) |
The Numbers
AES-256 security level: 256-bit key = 2²&sup5;&sup6; possible keys
To brute-force: ~10&sup7;&sup7; operations
If you checked 10¹&sup8; keys/second: would take 10&sup5;&sup9; years
Age of universe: ~10¹&sup0; years
Even quantum computers (Grover's algorithm) only reduce this to 2¹²&sup8; operations
— still utterly infeasible.
Performance:
Encrypt 1 KB: < 0.1 ms
Encrypt 1 MB: < 1 ms
Encrypt 100 MB: < 50 ms
8. Argon2id — Protecting Key Backups
When users back up their private keys with a password, Argon2id converts the password into an encryption key.
Why Not Just SHA-256 the Password?
SHA-256("mypassword") takes 0.0001 ms
→ An attacker with a GPU can try 10 BILLION passwords per second
→ All 8-character passwords cracked in < 1 hour
Argon2id("mypassword") takes ~300 ms and uses 64 MB of RAM
→ A GPU can only try ~1,000 passwords per second (needs 64 MB per attempt)
→ All 8-character passwords would take ~200,000 years
How Argon2id Works
Inputs:
password = user's backup password
salt = 16 random bytes (stored alongside the backup)
timeCost = 3 (number of iterations)
memoryCost = 65536 KB (64 MB of RAM)
parallelism = 4 (lanes/threads)
outputLen = 32 bytes (AES-256 key)
Step 1: Initialize memory matrix
Allocate 64 MB as a grid of 1 KB blocks
Fill first row using Blake2b(password || salt || params)
Step 2: Fill memory (data-dependent + data-independent hybrid)
For each iteration (t = 1 to timeCost):
For each lane (l = 1 to parallelism):
For each block position:
Argon2id alternates between:
- Argon2i mode (first half): index computation is data-INDEPENDENT
→ resistant to side-channel attacks (timing, cache)
- Argon2d mode (second half): index computation is data-DEPENDENT
→ resistant to GPU/ASIC time-memory tradeoff attacks
new_block = G(old_block, reference_block)
where G is a compression function based on Blake2b
Step 3: Finalize
XOR the last block from each lane
Hash the result with Blake2b to produce the 32-byte output
Step 4: Use the output as AES-256-GCM key
encrypted_backup = AES-GCM(argon2_output, nonce, private_keys)
Why 64 MB Memory Matters
With Argon2id (64 MB per hash): 24 GB VRAM ÷ 64 MB = 375 parallel hashes MAX. Cost to try 10 billion passwords: ~$2,600,000. The 64 MB requirement makes parallel attacks 7 MILLION times more expensive.
9. SHA-256 — Hashing in the System
SHA-256 appears in several places in Anchora's key system:
Where It's Used
1. Key Fingerprints
fingerprint = identityKey.substring(0, 16)
(First 16 chars of the hex-encoded public key — quick visual verification)
2. Backup Checksums
checksum = SHA-256(encryptedBundle)
(Verify the backup blob wasn't corrupted during storage/transfer)
3. Inside HKDF
HKDF uses HMAC-SHA-256 internally for both extract and expand phases
4. Inside Ed25519
Ed25519 uses SHA-512 (SHA-256's bigger sibling) for nonce generation and hashing
5. Anchora Core (Existing)
record_hash = SHA-256(data) → Merkle tree → blockchain
(Data integrity anchoring — separate from E2E encryption)
SHA-256 Properties
Input: any data (0 to 2&sup6;&sup4; bits)
Output: always exactly 256 bits (32 bytes, 64 hex chars)
Properties:
1. Deterministic: SHA-256("hello") ALWAYS = 2cf24dba5fb0a30e...
2. Avalanche: SHA-256("hello") vs SHA-256("hellp") = completely different
3. One-way: Given the hash, can't find the input
4. Collision-free: Can't find two inputs with the same hash (2¹²&sup8; operations)
5. Fast: ~500 MB/s on modern CPUs
How it works (simplified):
1. Pad message to multiple of 512 bits
2. Split into 512-bit blocks
3. For each block, run 64 rounds of:
- Bitwise rotations, shifts, XORs
- Modular additions
- Non-linear functions (Ch, Maj)
- Round constants (derived from cube roots of first 64 primes)
4. Output = 8 × 32-bit words = 256 bits
10. Putting It All Together — The Complete Math Pipeline
When Alice sends Bob an encrypted message through Anchora, here's every mathematical operation:
SETUP (one-time):
——————————————————————————————
Alice's device:
1. crypto.randomBytes(32) → identity seed
2. SHA-512(seed) → clamp → Ed25519 private key
3. private × B → Ed25519 public key (identity)
4. crypto.randomBytes(32) → clamp → X25519 private key (signed pre-key)
5. private × G → X25519 public key (signed pre-key)
6. Ed25519.sign(SPK_public, IK_private) → signature (128 hex chars)
7. Repeat steps 4-5 for 20 OPKs → 20 X25519 key pairs
Total: 23 key generations + 1 signature = ~5 ms
SERVER (register):
8. Store public keys + signature → MongoDB (UserKey collection)
9. AuditLog.create(KEYS_REGISTERED) → MongoDB (KeyAuditLog)
MESSAGE ENCRYPTION:
——————————————————————————————
Alice's device:
10. Fetch Bob's bundle from server → HTTP GET /v1/keys/bundle/bob
11. crypto.randomBytes(32) → clamp → ephemeral X25519 key pair
12. X25519(IK_A_priv, SPK_B_pub) → DH1 (32 bytes)
13. X25519(EK_A_priv, IK_B_pub) → DH2 (32 bytes)
14. X25519(EK_A_priv, SPK_B_pub) → DH3 (32 bytes)
15. X25519(EK_A_priv, OPK_B_pub) → DH4 (32 bytes)
16. HKDF-SHA-256(DH1||DH2||DH3||DH4) → 32-byte shared secret
17. crypto.randomBytes(12) → AES-GCM nonce
18. AES-256-GCM(shared_secret, nonce, plaintext) → ciphertext + 16-byte tag
Total: 4 DH + 1 HKDF + 1 AES-GCM = ~2 ms
19. DELETE ephemeral private key from memory (forward secrecy!)
SERVER (shared-secret facilitation):
20. Atomic OPK consumption → MongoDB updateOne with $elemMatch
21. SharedSecret.create(sessionId) → MongoDB (SharedSecret collection)
22. AuditLog entries → MongoDB (KeyAuditLog)
DECRYPTION (Bob's device):
——————————————————————————————
23. X25519(SPK_B_priv, IK_A_pub) → DH1' (same as DH1!)
24. X25519(IK_B_priv, EK_A_pub) → DH2' (same as DH2!)
25. X25519(SPK_B_priv, EK_A_pub) → DH3' (same as DH3!)
26. X25519(OPK_B_priv, EK_A_pub) → DH4' (same as DH4!)
27. HKDF-SHA-256(DH1'||DH2'||DH3'||DH4') → SAME 32-byte shared secret
28. AES-256-GCM-decrypt(shared_secret, nonce, ciphertext) → plaintext
Total: 4 DH + 1 HKDF + 1 AES-GCM = ~2 ms
29. DELETE OPK_B private key (one-time use — never used again)
BACKUP (optional):
——————————————————————————————
30. crypto.randomBytes(16) → Argon2 salt
31. Argon2id(password, salt, 64MB, 3 iterations) → 32-byte backup key (~300ms)
32. crypto.randomBytes(12) → AES-GCM nonce
33. AES-256-GCM(backup_key, nonce, all_private_keys) → encrypted bundle
34. SHA-256(encrypted_bundle) → checksum
35. Store on server → HTTP POST /v1/keys/backup
TOTAL MATH OPERATIONS PER MESSAGE:
Key generations: 1 (ephemeral)
Scalar multiplications: 4 (DH operations per side)
HMAC-SHA-256 calls: 3 (HKDF extract + expand)
AES-256 block encryptions: plaintext_length / 16
GHASH multiplications: ciphertext_length / 16
Wall clock time: < 5 ms total (faster than a screen refresh)
Security Guarantees — What the Math Proves
| Property | Guaranteed by | What it means |
|---|---|---|
| Confidentiality | AES-256-GCM + X3DH | Only Alice and Bob can read the message |
| Integrity | GCM authentication tag | Any modification is detected |
| Authentication | Ed25519 signatures + DH1 | Alice knows it's really Bob (and vice versa) |
| Forward secrecy | Ephemeral keys + OPKs | Past messages stay safe even if keys are later compromised |
| Deniability | No long-term signing of messages | Bob can't prove to a third party that Alice sent a specific message |
| Offline capability | Pre-key bundle system | Alice can encrypt for Bob even when Bob is offline |
| Brute-force resistance | 256-bit keys everywhere | 2²&sup5;&sup6; possible keys — heat death of universe before cracking |
| Side-channel resistance | Constant-time implementations | Timing attacks don't leak key bits |
| Password protection | Argon2id (64 MB, 3 iterations) | GPU brute-force of backups is economically infeasible |
Comparison with Other Platforms
| Feature | Anchora | Signal | Telegram | |
|---|---|---|---|---|
| X3DH key agreement | Yes | Yes | Yes | No (custom MTProto) |
| Ed25519 signatures | Yes | Yes | Yes | No (RSA-2048) |
| X25519 key exchange | Yes | Yes | Yes | No (DH-2048) |
| AES-256-GCM | Yes | Yes | Yes | No (AES-256-IGE) |
| HKDF-SHA-256 | Yes | Yes | Yes | No (custom KDF) |
| Argon2id backups | Yes | Yes (PIN) | No | No |
| Blockchain anchoring | Yes | No | No | No |
| Open API for developers | Yes (10 APIs) | No | No | Partial |
| Race-safe OPK consumption | Yes (tested) | Presumably | Unknown | N/A |
Anchora uses the same battle-tested algorithms as Signal — the gold standard of secure messaging — and adds blockchain data integrity on top.
Further Reading
- Curve25519 paper (Daniel J. Bernstein) — the original 2006 paper
- Ed25519 paper — high-speed high-security signatures
- X3DH specification (Signal) — the key agreement protocol
- RFC 5869 (HKDF) — HMAC-based key derivation
- Argon2 paper — Password Hashing Competition winner
- NIST SP 800-38D (AES-GCM) — authenticated encryption
Ready to implement E2E encryption?
Explore the Key Management API documentation and start building with Signal Protocol-level encryption today.
View Keys API Docs