Protocol specification and threat model
Contents
This document exists because TwoKey is closed source. A customer's security team cannot read the implementation, so the design is published instead — the same answer WhatsApp gives with its security whitepaper. Kerckhoffs's principle applies: nothing here weakens the product, because security rests on the keys rather than on secrecy of the design.
Read §7 before §2–6. The limits matter more than the mechanisms, and a reader who takes the cryptography as the whole story has misunderstood the product.
1. What TwoKey is, in one paragraph
A one-to-one messenger in which two devices cannot establish a session at all without a pre-shared secret, exchanged by displaying a QR code that the other device scans — in person, in its intended use. Not a failed decryption — no session is ever created.
Stated precisely, because this document exists to be checked: what the protocol enforces is possession of a fresh pairing code, time-boxed to five minutes and burned on first use, followed by an out-of-band verification step (§4.6) that detects an interposed device. Physical proximity itself is not yet enforced; UWB ranging that enforces it is designed and planned. A code photographed and relayed to a distant device within its five-minute window will pair — which the verification step exists to catch, and which the code's owner is alerted to if the true scanner then also tries. Signal and SimpleX will both open a session with anyone holding an invite link; TwoKey has no equivalent, because there is nothing to hold. There are no accounts, no identifiers, no directory, and no way for a stranger to initiate contact.
2. Primitives
Everything is CryptoKit. There are no third-party dependencies anywhere in the cryptographic path.
| Purpose | Primitive |
|---|---|
| Key agreement | X25519 (Curve25519.KeyAgreement) |
| Handshake AEAD | ChaCha20-Poly1305, IETF construction, 96-bit nonce |
| Outer envelope AEAD | AES-256-GCM |
| Hash | SHA-256 |
| KDF | HMAC-SHA256, chained per Noise §4.3 — not RFC 5869 |
| CSPRNG | SecRandomCopyBytes, one call site (SecureRandom) |
The two encryption layers use different ciphers deliberately. Both were ChaCha20-Poly1305 in an early draft, which meant a single hypothetical ChaCha break would collapse both layers at once — a cascade in structure only. The layers now differ in both key material (DH-derived vs PSK-derived) and algorithm.
2.1 Why the handshake is hand-implemented
No audited Swift Noise library exists. The three candidates at time of writing had 10, 1 and 0 GitHub stars, with the most-starred last touched in 2021. Adopting an unmaintained, unaudited crypto package adds supply-chain risk without buying audit assurance.
The distinction that matters: "don't roll your own crypto" is a rule about primitives. Implementing a specified state machine on top of audited primitives is what every Noise library does, and is verifiable against published test vectors. See §8 for the honest status of that.
3. Identity
Each device generates on first launch, and never transmits the private halves:
- Static X25519 keypair — the long-term identity used in the Noise handshake.
- Ratchet X25519 keypair — seeds the Double Ratchet.
Both are stored in the iOS Keychain under kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. The ThisDeviceOnly half blocks iCloud Keychain synchronisation and excludes the items from device backups — two phones signed into the same Apple ID therefore hold different identities. The AfterFirstUnlock half is what allows a push-launched process to deliver a message on a locked phone: the keys are unreadable on a device that is powered off or has never been unlocked since boot, and readable once it has. WhenUnlocked was the original choice and was abandoned when it proved structurally incompatible with locked delivery; the trade — an attacker with code execution on a locked-but-booted phone can read the keys — is accepted, matching the class messengers with background delivery generally use.
There is no identity server, no registration, and no way to look up a device.
4. Pairing
4.1 The payload
A QR code carrying, in order:
| Bytes | Field |
|---|---|
| 1 | Version (0x02) |
| 32 | Pre-shared key |
| 32 | Static X25519 public key |
| 32 | Ratchet X25519 public key |
| 16 | Rendezvous identifier |
| 8 | Expiry, big-endian seconds since the Unix epoch |
| 1 | Label length |
| ≤64 | Label, UTF-8 |
| 1 | Relay length, 0 when absent |
| ≤128 | Relay URL, UTF-8 |
| 1 | Relay flags, present only when a relay is (bit 0: exclusive) |
Base64-encoded for display. Default validity 300 seconds; hard maximum 3600.
Version 0x01 — everything above the relay fields — is still accepted, so devices on different builds can pair.
The relay field, and why it exists. Relay addresses otherwise come only from each build's configuration, and a device posts to the first relay that accepts. An organisation running its own relay would therefore post every message to an address its correspondent has no reason to poll, and the message would be lost without any error — the sender's copy would look sent. Naming the relay in the code fixes that where it starts. It is also the only arrangement that keeps such a conversation on that organisation's own hardware; posting to every known relay would carry a copy through the default relays as well.
The field is absent in ordinary codes, which keeps them small and gives a scanner nothing extra to trust. It is constrained to https, on encoding and on decoding, so a scanned code cannot move the queue addresses onto a plaintext connection. A relay address that fails that check causes the code to be rejected rather than ignored: silently falling back to the default relays would reproduce the same invisible failure the field removes.
The exclusive flag. Bit 0 says the named relay is the only one this pairing may use, with no fallback to the defaults. It travels in the code rather than living in the advertiser's own settings because a policy only one side honoured would be a half-truth: the correspondent's app would still fall back to the default relays whenever that host was briefly unreachable, and the conversation would land on precisely the infrastructure the organisation runs its own relay to avoid — invisibly, from the other direction.
The cost belongs to whoever sets it. With no fallback, an unreachable relay means messages wait rather than finding another way through. A pairing that names no relay at all, on a device configured this way, has no relay transport and works over the local network only — the honest reading, rather than quietly using a default the setting forbids. Unknown flag bits are refused, not masked off: they mean a newer build asked for a policy this one cannot honour.
The address is exactly as trustworthy as the code carrying it — which is the trust the pre-shared key already rests on. Both devices record it, so reconnection uses the same relay without either asking the other. An application displaying a pairing that carries one should say so: a third party the user did not choose is carrying their messages, and that is a fact they are entitled to before they agree to the pairing.
4.2 Key size, and why 256 bits rather than more
The pre-shared key is 32 bytes. Larger is not stronger here, for a concrete reason: the Noise specification defines the PSK as exactly 32 bytes, ChaCha20 and AES-256 keys are 256-bit, and the KDF output feeding them is 256-bit. A 512- or 4096-bit secret would be compressed to 256 bits before it protected anything — not weaker, simply inert. 256-bit symmetric is beyond brute force including for a quantum adversary; Grover's algorithm reduces it to ~128-bit effective, still infeasible.
4.3 Handshake
Noise_XXpsk2_25519_ChaChaPoly_SHA256.
-> e
<- e, ee, s, es, psk
-> s, se
XXpsk2, not XXpsk3, despite only psk3 having published test vectors. Under psk3 the initiator transmits its static identity key in message 3 before the PSK is mixed, which leaks that key to anyone who spoofs a pairing response. The choice cost us reference vectors and is not negotiable; §8 explains how correctness is established without them.
Two implementation details that are easy to get subtly wrong, both of which produce a broken-but-passing implementation:
- In a PSK handshake every
etoken calls bothMixHash(e.public_key)andMixKey(e.public_key)(spec §9.2). Omitting theMixKeystill completes a handshake against itself, while being non-interoperable and losing the nonce-randomisation the PSK design depends on. - Noise's KDF is not RFC 5869. It is a specific HMAC chain (§4.3). Using a stock HKDF produces an inscrutable failure three layers up.
The PSK is mixed via MixKeyAndHash, so an attacker with full network control and both parties' long-term private keys still cannot complete a handshake. Precedent: WireGuard's optional pre-shared key does exactly this.
4.4 Peer identity is verified at message 2
The scanning device checks the peer's static key against the one in the QR the instant it becomes known, which is message 2 — not at handshake completion.
Verifying late would mean the scanner writes message 3, handing its own identity key to whoever answered, and only then noticing it was the wrong party. That would discard the exact property XXpsk2 was chosen for. On failure the handshake latches: rejecting a message is not enough on its own, because the underlying Noise state has already advanced and a caller that caught the error could still request message 3.
4.5 One code, one peer, forever
Four mechanisms, all required, because none suffices alone:
- Identity binding — the first successful claim records the peer's static key permanently. A later handshake presenting the same PSK under a different key is refused.
- Burn on use — the rendezvous is torn down when pairing completes.
- Expiry — 300 seconds by default, so a photographed code goes stale.
- Surfacing — a refused reuse is reported to the user as a security event. Silent rejection hides an attack in progress.
4.6 Short authentication string
Both devices display twelve characters in three groups, derived from the handshake transcript hash, in Crockford Base32 with I, L, O and U removed to avoid spoken ambiguity.
This is the only mechanism that detects an intercepted pairing code. If an attacker obtained the QR before the intended recipient scanned it — a photograph, a screen share, a forwarded image — they pair successfully and nothing else in the protocol notices. Comparing the string aloud is what closes that gap, and it is the one step that cannot be automated away.
5. Messaging
5.1 Double Ratchet
Standard Signal construction. Root key derived from the handshake hash, which already commits to every handshake byte including the PSK.
Three properties this provides that a static key would not:
- Forward secrecy. Seizing the device does not decrypt past traffic.
- Post-compromise security. The DH ratchet heals after a key compromise.
- Crypto-shredding. Per-message keys make deletion tractable — destroying a key renders its ciphertext noise, which is a guarantee that overwriting storage cannot give.
Skipped-key storage is bounded at 2000 entries with FIFO eviction, and retired keys are retained specifically to reject replays. Decryption is transactional: ratchet state advances only after the authentication tag verifies, so a forged message cannot desynchronise a session.
5.2 Outer envelope
Every frame on the wire — ratchet messages and handshake messages — is wrapped in the same envelope:
salt (16) || AES-256-GCM(kind (1) || length (4) || payload || zero padding) || tag (16)
The key is HKDF(PSK, salt, "twokey-outer-v2"), freshly derived per message. The GCM nonce is fixed, which is safe because the key is never reused — a random 16-byte salt makes each key unique. This reaches the same goal as an extended-nonce AEAD while staying inside audited primitives; CryptoKit provides no XChaCha20.
kind names what the frame is — 0x01 ratchet message, 0x02 handshake message, 0x03 session-reset request — and sits inside the AEAD, so only PSK holders can read it. It exists because its absence deadlocked reconnection: a device holding a live session that received a peer's re-handshake attempt could only interpret the frames as failed message decryptions, reporting tampering while destructive delivery consumed the handshake. With the kind visible after authentication, a live session recognises a re-handshake and answers it — adopting the new session only on completion, so replayed frames cannot cost a working session — and a handshake in progress skips ratchet frames instead of feeding them to the Noise state machine.
0x03 covers the asymmetric case in the other direction: the reconnect responder role belongs to the device that displayed the QR, so when that device is the one that lost its session, it deposits a reset request and the scanner initiates. A replayed reset causes one reconnect attempt against a device that is not listening, which times out and changes nothing.
The envelope's purpose beyond the second cipher is metadata: a relay sees no ratchet header, no message counter, no framing — and no distinction between pairing traffic and conversation traffic, which bare handshake messages previously disclosed through their characteristic sizes.
5.3 Padding
Plaintext is padded to 256, 1024 or 4096 bytes before sealing, rounding up in multiples of 4096 beyond that. An observer learns which bucket a message fell into and nothing finer.
5.4 Message lifetime
Negotiated, not imposed: one side proposes, the other accepts. Crossing proposals are tie-broken by comparing static identity keys, lower wins. Unsolicited lifetime changes are rejected — otherwise a peer could set your retention policy unilaterally.
All options are memory-only. No message is ever written to storage, at any setting, on either device.
The negotiation governs each device's own copy, and it always has — neither side could ever enforce deletion on the other, since a peer running modified code simply keeps what it likes. What the protocol guarantees is that both sides know the agreed lifetime.
TwoKey+ only, not yet built. A Leader will be able to retain their own copy on their own device. The client's copy still expires on the negotiated schedule, and the client is told that the other side is retaining — at pairing and persistently in the conversation. This is a disclosure obligation on the application, not a protocol change: nothing about the handshake, the ratchet or the envelope differs. PRODUCTS.md §2.1. In TwoKey the sentence above stands without exception, and a test asserts it.
6. Transport
6.1 Path selection
Local network first, relay as fallback. The LAN is an optimisation rather than the mechanism: two people meeting in person are routinely on different networks, and most corporate guest Wi-Fi enables client isolation, which blocks the mDNS discovery required for direct discovery.
The offering device listens on both paths simultaneously. This is safe because the scanning device commits to exactly one, so only one handshake can complete.
6.2 The relay
In-memory only. No database, no disk. Per message it stores exactly three things: a queue identifier, an opaque blob, and an expiry.
- Delete on fetch. A relay that keeps a copy is a relay with something to disclose.
- Two queues per conversation, one per direction, derived from the PSK via
HKDF(psk, "twokey-queue-{to-offerer|to-scanner}-v1-{epoch}"). Neither device ever transmits an address, and the relay is never told which two belong together. One shared mailbox would let either party collect their own messages back, and would let the relay pair a writer with a reader by watching one address. - No authentication, deliberately. Knowing the queue address is the only credential, and both devices derive it from the same secret. An added token would be theatre.
- Rate limiting keys on the queue, not the IP, so no record of who talks to the relay is built.
- No logging by default.
Clients may hold several relay addresses. Sends go to the first that accepts; receives poll all of them concurrently. That asymmetry exists because a relay reachable for one device and not the other — a national block, a firewall — would otherwise silently lose messages with both devices reporting success.
6.3 Push notification
Waking a backgrounded app requires APNs, and APNs requires a device token. Storing that token against a mailbox would hand the relay operator a stable per-device identifier tied to a conversation, surviving IP changes and app restarts — precisely the linkability the rest of the design refuses, reintroduced at the notification layer.
So there are two identifiers and two services:
| Service | Holds | Never sees |
|---|---|---|
| Relay | queue addresses, opaque push identifiers | device tokens |
| Notifier | push identifiers, device tokens | queue addresses |
Neither alone links a device to a conversation. The push payload is {"aps":{"content-available":1}} — no sender, no conversation, no count. The app wakes and checks all of its mailboxes.
The notification the user sees is composed on the device after decryption, which is the only place that can weigh it. By default it names nobody and never shows message text.
6.4 Persistence
Pairings persist in an AES-256-GCM sealed file, keyed from the Keychain under the same non-syncing, non-backed-up attribute as the identity. The file may be copied off the device and remains noise.
Ratchet state is deliberately not persisted. Persisting chain keys would hand a seized phone the material to decrypt recent traffic, which is exactly the forward secrecy the ratchet exists to provide. Reconnection re-handshakes instead, at the cost of one round trip.
A wipe destroys the key before touching the file. Deleting a file does not erase it — the filesystem marks blocks free, and flash wear-levelling means overwriting a logical block need not touch the physical one. Destroying the key is the only part that guarantees anything.
6.5 Reconnection
Roles are remembered, not negotiated: whoever scanned still initiates. Both sides stored which they were, so there is no window in which both initiate and neither listens.
Both sides enforce peer identity on a reconnect, unlike a first pairing where the offering device genuinely cannot know who will scan. Without this, anyone holding the PSK — an old backup, a stolen device — could complete a handshake as the peer. The PSK gates entry; the stored identity key is what says it is still the same person.
The reconnect rendezvous is derived freshly from the PSK rather than reusing the QR's, which was burned. Reuse would let anyone who photographed the original code sit on the address and watch for reconnections.
7. Threat model
7.1 Closed by the cryptography
| Attack | Why it fails |
|---|---|
| Unpaired device attempts a handshake | MixKeyAndHash(psk) diverges the chaining key; the first authenticated field fails to decrypt. No session is ever created. |
| Passive capture of ciphertext | Requires breaking X25519 or a 256-bit AEAD. |
| Active MITM with full network control | The attacker holds neither the PSK nor either static key. |
| Device seized, past traffic replayed | Those message keys were discarded: consumed keys are deleted from the ratchet state as each message is delivered, and the transcript itself is never written to disk. Current ratchet state is persisted (post-first-unlock, this device only) so queued messages survive app eviction — the concession is frames in flight at seizure time, not past traffic. |
| Malicious or compelled relay operator | Sees an opaque padded blob and a rotating queue identifier. |
| A stranger who obtained the PSK, on reconnect | Refused: the stored identity key does not match. |
7.2 An unexpected strength: post-quantum hedging
X25519 is not post-quantum secure, so "harvest now, decrypt later" is a real concern for any Signal-style messenger.
The PSK substantially mitigates this. A 256-bit symmetric secret mixed into the handshake is quantum-resistant. An adversary who records traffic today and later breaks X25519 with a quantum computer still cannot derive the session key without a secret that was never transmitted over any network.
This is why WireGuard added its optional PSK, and it is arguably the strongest single argument for this design over stock Signal.
7.3 NOT closed — ranked by real-world likelihood
1. Endpoint compromise. Overwhelmingly the most likely. Pegasus-class spyware reads plaintext at the UI layer, before encryption and after decryption. Nothing in this design stops it. This is how encrypted messengers are actually broken in the field — not by defeating Signal, but by owning the phone.
2. The authorised recipient. They can screenshot, photograph the screen with a second camera, forward, or simply repeat what was said. Screenshot detection and disappearing messages are speed bumps, not controls. Stated plainly: the PSK guarantees who can decrypt, never what they do afterwards.
3. Traffic analysis. Even with a blind relay and padded messages, an observer with network visibility sees IP addresses, timing and volume — revealing that two parties communicate and when. Full mitigation requires onion routing, which this design does not include. APNs adds a parallel signal: Apple learns that a device received a push and when, which is unavoidable for any iOS app using push.
4. Implementation error. The realistic cryptographic failure is not a broken cipher. It is a nonce reuse, a skipped tag check, or a state machine bug in our code. See §8.
5. Queue longevity. Mailbox addresses are currently fixed for the life of a pairing. The relay can therefore observe that some pair of endpoints keeps using the same two addresses over time. It still cannot tell who they are or read anything, but that longevity is a genuine traffic-analysis signal. Rotation is designed for — the epoch parameter exists and is tested — and not implemented, because the hard part is agreeing when to rotate when messages arrive out of order and a device may have been offline for a week. A rotation the two sides disagree about silently loses messages, which is worse than the leak it fixes.
6. Coercion. Device unlock can be compelled, and nothing in the cryptography addresses that. Any mitigation only works while an adversary does not know to look for it, so this document does not describe what exists.
7. Metadata held by Apple. Push timing, as above. Also the fact that the app is installed, which is visible in a device backup or an App Store purchase history.
7.4 Endpoint hardening implemented
Each closes a real plaintext leak that no amount of cryptography would touch:
- App-switcher snapshot — iOS writes an unencrypted image of the last screen to disk. A privacy curtain is applied before the snapshot is taken.
- Screenshots and screen recording — message content is hosted in a secure text field's capture-excluded layer, so captures come out blank. This relies on an undocumented view hierarchy and can break in any iOS release; the app falls back loudly to detection-and-warning rather than silently to nothing.
- Third-party keyboards — denied app-wide, closing the full-access keylogging vector.
- In-app keyboard — the system keyboard never sees message text, so nothing enters the iCloud-synced user dictionary and dictation never reaches Apple.
- Non-selectable message text — copy, cut and share are suppressed. The pasteboard is never written to at all.
- Contact labels are local — chosen on your device, never transmitted. The peer never learns what you called them.
- No telemetry — no analytics, crash reporting, advertising or attribution SDK in any build configuration.
8. Verification status, stated honestly
TwoKey has not been independently audited.
What has been done:
- The KDF, cipher state and symmetric state are validated against published Noise test vectors. Since
XXpsk2has no published vectors, handshake patterns are expressed as data so thatXXpsk3andNNpsk2vectors drive the same machinery — establishing the state machine is correct even where the specific pattern cannot be vector-tested directly. - Negative tests assert the properties that matter: a wrong PSK produces no session; a third identity replaying a consumed PSK is hard-rejected; a reconnecting stranger with the right PSK is refused from either side.
- 188 tests in the crypto core, 37 in the relay.
Passing tests is not an audit. Two consistent implementations of a misunderstanding agree perfectly. Before any release to users at risk, the handshake and symmetric state should be reviewed by a third party, or replaced with a bridge to a vetted C library.
This document, and the labelling inside the app, are the compensating control for that gap. They are not a substitute for it.
9. Deliberately absent
Each of these was considered and rejected, with the reason:
- Group messaging. A different promise — "only people the leader admitted" rather than "only the person I met" — and blending them would weaken the one that is distinctive.
- Message signatures. Signal-style deniability is preserved: messages are authenticated with MACs, not signatures, so a recipient cannot cryptographically prove to a third party that you sent something. For journalists and sources that is a liability worth avoiding, and it is trivially destroyed by a well-meaning "add signatures for integrity" change.
- A third encryption layer with a second hand-shared secret. If both secrets travel in the same QR at the same moment they are not independent: one compromised pairing channel yields both.
- Cloud backup or account recovery. Any recovery route is an entry route.
- A post-quantum hybrid handshake. CryptoKit now ships ML-KEM, making this a modest change rather than research. Not yet implemented; the PSK already provides the quantum hedge described in §7.2.