The protocol, written down
Key sizes, KDF inputs, wire layouts and the limits of each construction, taken from the design documents the app is built against. What each part does for you is on the security page.
Each section below names the construction the Android app runs, in the notation of the specification it follows, and then lists what it does not protect against. Under the notation is the function that implements it, cut from the source file at the line numbers shown each time this site is built, with the comments left out. When a function is renamed or removed the build fails, so a stale excerpt cannot stay online.
The labels inside the KDF calls are the app's own domain-separation strings and are reproduced verbatim, so an independent implementation can derive the same keys.
Identity
Three keypairs and an id you cannot be handed
An identity is generated on the phone at first launch: an X25519 key for key agreement, an Ed25519 key that signs every prekey the identity publishes, and an ML-KEM-1024 key for the sealed-sender envelope. The user id is a UUID derived from the two public keys, and registration is refused when the id does not match the keys or when the registrant cannot sign a proof that names the relay it is registering with. A relay cannot therefore issue an id for keys it does not hold.
Alongside the identity, the app publishes prekeys for people who want to start a conversation while it is offline: a signed X25519 prekey, a pool of one-time X25519 prekeys, a signed ML-KEM-1024 prekey and a pool of signed one-time ML-KEM-1024 prekeys. The relay stores public keys. Every private key lives in the vault.
identity_key X25519 key agreement
signing_key Ed25519 signs every prekey this identity publishes
sealed_kem_key ML-KEM-1024 long-lived, targets the sealed-sender envelope
user_id UUIDv8( SHA-256(identity_pk ‖ signing_pk)[0..16] )
identity_pk = 01 02 … 20, signing_pk = 21 22 … 40 → 20a7ec84-684f-8fe1-a4cb-3727d049734a
prekeys SPK X25519 (signed) · OPK X25519 · PQSPK ML-KEM-1024 (signed) · PQOPK ML-KEM-1024 (signed)pub fn derive_user_id(identity_public_key: &[u8], identity_signing_key: &[u8]) -> Uuid {
let mut h = Sha256::new();
h.update(identity_public_key);
h.update(identity_signing_key);
let digest = h.finalize();
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&digest[..16]);
bytes[6] = (bytes[6] & 0x0F) | 0x80;
bytes[8] = (bytes[8] & 0x3F) | 0x80;
Uuid::from_bytes(bytes)
}Limits
- The signatures are Ed25519 and hold against a classical forger. An attacker with a quantum computer online, at the time of the handshake, could forge them; the same attacker could break the X25519 identity key, so both sit at one level. Moving the signature slot to ML-DSA is planned.
- First contact is trust-on-first-use: the app trusts the id it was given. Every key fetched for that id must hash to it, or the app refuses the session. The safety numbers cover the whole key exchange for a manual check over another channel.
Implemented in X25519.kt · Ed25519.kt · MLKEM.kt · KeyManager.kt
Handshake
PQXDH with ML-KEM-1024
The initiator fetches the peer's prekey bundle, verifies every signature under the peer's signing key and refuses the bundle when a signature fails or no ML-KEM prekey is present. The handshake takes the post-quantum material as a non-nullable argument, so a session without it does not compile, and a relay that strips the ML-KEM prekey from a bundle gets a refusal.
Four X25519 agreements (three when the pool of one-time prekeys is empty) and one ML-KEM-1024 encapsulation go into one HKDF-SHA-512 call. The initiator encapsulates to a one-time ML-KEM prekey when the bundle has one and to the signed last-resort prekey otherwise. The 32-byte output is the first root key of the ratchet.
The first message carries the ephemeral key, the ids of the prekeys used and the KEM ciphertext. When the responder cannot decapsulate, because a one-time prekey was consumed by a retry or lost in a restore, the two sides derive different secrets and the AEAD tag fails. The session resets and re-establishes from a fresh bundle.
DH1 = X25519(IK_A, SPK_B)
DH2 = X25519(EK_A, IK_B)
DH3 = X25519(EK_A, SPK_B)
DH4 = X25519(EK_A, OPK_B) when a one-time prekey was available
(CT, SS) = ML-KEM-1024.Encaps(PQPK_B) one-time PQ prekey preferred, else the signed one
SK = HKDF-SHA-512( salt = 0⁶⁴,
ikm = 0xFF³² ‖ DH1 ‖ DH2 ‖ DH3 [‖ DH4] ‖ SS,
info = "Aphotic_CURVE25519_SHA-512_ML-KEM-1024",
L = 32 )fun initiateSession(
ourIdentityPrivate: PrivateKey,
ourIdentityPublic: ByteArray,
theirIdentityPublic: PublicKey,
theirSignedPrekey: PublicKey,
theirOneTimePrekey: PublicKey?,
theirPqPrekey: ByteArray,
): X3DHResult {
val ephemeral = X25519.generateKeyPair()
val dh1 = X25519.dh(ourIdentityPrivate, theirSignedPrekey)
val dh2 = X25519.dh(ephemeral.private, theirIdentityPublic)
val dh3 = X25519.dh(ephemeral.private, theirSignedPrekey)
val dhConcat: ByteArray
if (theirOneTimePrekey != null) {
val dh4 = X25519.dh(ephemeral.private, theirOneTimePrekey)
dhConcat = dh1 + dh2 + dh3 + dh4
CryptoUtils.zeroize(dh4)
} else {
dhConcat = dh1 + dh2 + dh3
}
val (kemCiphertext, ss) = MLKEM1024.encapsulate(theirPqPrekey)
val ikm = KDF_F + dhConcat + ss
CryptoUtils.zeroize(ss)
val sharedSecret = CryptoUtils.hkdfSha512(HKDF_SALT, ikm, HKDF_INFO_PQ, 32)
CryptoUtils.zeroize(dh1)
CryptoUtils.zeroize(dh2)
CryptoUtils.zeroize(dh3)
CryptoUtils.zeroize(ikm)
CryptoUtils.zeroize(dhConcat)
return X3DHResult(
sharedSecret = sharedSecret,
ephemeralKeyPair = ephemeral,
kemCiphertext = kemCiphertext,
)
}Limits
- SK is recoverable only by an attacker who breaks X25519 and ML-KEM-1024 both. A quantum computer built later cannot recover SK from traffic recorded today.
- PQXDH protects the setup. What happens to the keys afterwards is the ratchet's job, below.
Implemented in X3DHManager.kt · SessionManager.kt
Ratchet
Triple Ratchet: a KEM step on every DH step
Every session runs the Double Ratchet: a symmetric chain that gives each message its own key and destroys it after use, and a DH ratchet that replaces the root key whenever the conversation changes direction. A third ratchet runs in lockstep with the second. On every DH step the sender generates a fresh ML-KEM-768 keypair, encapsulates to the peer's current one, and the KEM secret goes into the root KDF beside the DH output.
Every message carries the sender's current ML-KEM public key and, on any chain that follows a step, the ciphertext for the peer. Any single message of a chain can therefore drive the peer's step; nothing is split across messages or reassembled. A message may arrive up to 100 positions ahead of the last one received. The keys it skips are kept for 7 days, up to 500 per session, so late messages still decrypt. At 2²⁴ messages a chain ends and the session is re-established.
State is written to the vault only after a decryption verifies. A failed decrypt rolls the whole session back to the state before the attempt.
chain step MK = HMAC-SHA-256(CK, 0x01) CK' = HMAC-SHA-256(CK, 0x02)
message AES-256-GCM(MK, plaintext, AD)
AD IK_sender ‖ IK_receiver ‖ 0x03 ‖ dhPub ‖ counter ‖ prev ‖ flags ‖ kemGen ‖ kemPub [‖ ctTargetGen ‖ kemCt]
root step (RK', CK) = HKDF-SHA-256( salt = RK,
ikm = DH(32) ‖ SS(32),
info = "Aphotic_TripleRatchet",
L = 64 )
DH: X25519 between the new ratchet key and the peer's
SS: ML-KEM-768, Encaps to the peer's current key on send, Decaps on receive
wire 0x03 ‖ flags ‖ kemGen(u32) ‖ kemPub(1184 B) [‖ ctTargetGen(u32) ‖ kemCt(1088 B)] ‖ AEAD output
+1190 B on every message, +2282 B on a chain that follows a stepfun kdfRk3(rootKey: ByteArray, dh: ByteArray, ss: ByteArray?): Pair<ByteArray, ByteArray> {
val ikm = if (ss != null) dh + ss else dh
val output = CryptoUtils.hkdf(rootKey, ikm, TRIPLE_RATCHET_INFO, 64)
val newRootKey = output.copyOfRange(0, 32)
val chainKey = output.copyOfRange(32, 64)
if (ss != null) CryptoUtils.zeroize(ikm)
CryptoUtils.zeroize(output)
return Pair(newRootKey, chainKey)
}Limits
- With no compromise, a passive attacker holding the full transcript and a quantum computer learns nothing: every root transition after setup mixes in a KEM secret it cannot recover, and the setup is PQXDH.
- After a one-time read-out of a phone's state, the victim's sending direction heals at their next DH step and the receiving direction one round trip later, against a passive quantum attacker.
- An attacker who holds a full copy of the state and actively sits in the path defeats every ratchet in this family, this one included.
- The ratchet format is fixed at session setup from a native capability and never read off the wire. A peer the app has pinned as running the KEM ratchet is refused any session without it, in both directions.
Implemented in DoubleRatchet.kt · TripleRatchet.kt · RatchetSession.kt
Direct messages
Sealed sender, post-quantum
A direct message is posted to the recipient's relay without an Authorization header. The request names the recipient and carries one opaque blob; the sender's id is inside the encryption. The seal key comes from an ephemeral X25519 key and an ML-KEM-1024 encapsulation to the recipient's long-lived sealed-KEM key, with the ephemeral key, the recipient's identity key and the recipient's KEM key bound into the HKDF info.
The zero nonce is safe because both inputs to the seal key are fresh per message. The recipient's KEM key is signed under their Ed25519 signing key and rides the prekey bundle, so a relay cannot substitute its own. An envelope without the ML-KEM leg is refused on decrypt, and a missing KEM key fails the send; either fallback would let a relay strip the post-quantum leg by deleting one field. Without that leg, an attacker recording direct messages today and breaking X25519 later would recover the sender of every one of them.
Sending proves a licence with a random handle that rotates every hour, over a Tor circuit of its own (see licence tokens below). The relay pads every stored envelope to a fixed-size bucket.
(eph_sk, eph_pk) = X25519.KeyGen()
dh = X25519(eph_sk, IK_recipient)
(ct, ss) = ML-KEM-1024.Encaps(SealedKEM_recipient)
header = 0x02 ‖ eph_pk ‖ ct 1 + 32 + 1568 B
seal_key = HKDF-SHA-256( salt = 0³², ikm = dh ‖ ss,
info = "Aphotic_SealedSender_v2" ‖ eph_pk ‖ IK_recipient ‖ SealedKEM_recipient,
L = 32 )
inner = len(sender_id) ‖ sender_id ‖ ratchet message
blob = header ‖ AES-256-GCM(seal_key, nonce = 0¹², aad = header, inner)fun seal(
recipientIdentityKey: ByteArray,
recipientSealedKemKey: ByteArray,
senderId: String,
innerCiphertext: String,
): ByteArray {
require(recipientIdentityKey.size == EPH_PUB_LEN) {
"recipient identity key must be $EPH_PUB_LEN bytes, got ${recipientIdentityKey.size}"
}
require(recipientSealedKemKey.size == MLKEM1024.PUBLIC_KEY_LEN) {
"recipient sealed-sender KEM key must be ${MLKEM1024.PUBLIC_KEY_LEN} bytes, " +
"got ${recipientSealedKemKey.size}"
}
val recipientPub = X25519.publicKeyFromBytes(recipientIdentityKey)
val ephemeral = X25519.generateKeyPair()
val ephPubBytes = X25519.publicKeyToBytes(ephemeral.public)
val dh = X25519.dh(ephemeral.private, recipientPub)
val (kemCt, kemSecret) = MLKEM1024.encapsulate(recipientSealedKemKey)
val header = byteArrayOf(VERSION_V2) + ephPubBytes + kemCt
val key = deriveKey(dh, kemSecret, ephPubBytes, recipientIdentityKey, recipientSealedKemKey)
CryptoUtils.zeroize(dh)
CryptoUtils.zeroize(kemSecret)
val senderBytes = senderId.toByteArray(Charsets.UTF_8)
val innerBytes = innerCiphertext.toByteArray(Charsets.UTF_8)
val plaintext = u32le(senderBytes.size) + senderBytes + innerBytes
val aead = CryptoUtils.aesGcmEncryptWithNonce(key, ZERO_NONCE, plaintext, header)
CryptoUtils.zeroize(key)
CryptoUtils.zeroize(plaintext)
CryptoUtils.zeroize(senderBytes)
CryptoUtils.zeroize(innerBytes)
return header + aead
}private fun deriveKey(
dh: ByteArray,
kemSecret: ByteArray,
ephPub: ByteArray,
recipientIdentityKey: ByteArray,
recipientSealedKemKey: ByteArray,
): ByteArray {
val ikm = dh + kemSecret
val info = INFO_PREFIX + ephPub + recipientIdentityKey + recipientSealedKemKey
val key = CryptoUtils.hkdf(HKDF_SALT, ikm, info, 32)
CryptoUtils.zeroize(ikm)
return key
}Limits
- This layer has no forward secrecy: it encrypts to long-term recipient keys. The ratchet message inside supplies it.
- The recipient is plaintext, because the relay has to route. A relay sees who receives what and when, and since it picks the instant it wakes the recipient's waiting request, it can pair a send with a receive as it happens. Sealed sender hides the sender's identity from the relay. It does not stop a relay from correlating the two ends of a conversation by timing, and with few devices rotating handles in the same hour the crowd a sender hides in is small.
Implemented in SealedSender.kt
On the phone
A random key under two wraps
Messages, contacts, sessions and the long-term private keys are one SQLCipher database. Its key is a random 256-bit value that is never derived from the PIN. It sits under two AES-256-GCM layers: an inner one keyed from the PIN through Argon2id, and an outer one keyed by a non-exportable AES key in the Android Keystore, in StrongBox on phones that have it.
A wrong PIN fails a GCM tag; there is no separate verification value in the metadata to attack. Five wrong attempts wipe the database, its metadata and the decrypted media cache. The panic PIN is a second wrapped blob holding a sentinel. The two PINs derive different keys, so at most one blob ever opens, and a valid decrypt of the sentinel runs the same wipe behind a normal-looking unlock.
With the optional master passphrase enabled, the key is re-wrapped under Argon2id of the passphrase at 128 MiB, and a time-limited Keystore key opens a PIN window (8 hours to 7 days, 48 hours by default) in which the six-digit PIN alone unlocks. The window is enforced by the Keystore's own validity period, an app-side expiry and a monotonic clock guard against rollback. A reboot asks for the passphrase again.
Backups are one file re-encrypted under Argon2id of a passphrase at 128 MiB. Moving phones is a direct device-to-device transfer. The app opts out of Android backup.
K_pin = Argon2id( PIN, salt; m = 64 MiB, t = 4, p = 1 )
K_hw = AES-256 in Android Keystore, non-exportable, StrongBox preferred
wrapped_dek = AES-256-GCM( K_hw, AES-256-GCM( K_pin, DEK ) )
unlock strip the outer layer with K_hw (this phone only), then the inner with K_pin
the GCM tag is the PIN check
panic AES-256-GCM( K_hw, AES-256-GCM( K_panic, SENTINEL ) ) a valid decrypt wipes
passphrase K_master = Argon2id( passphrase ≥ 12 chars, salt; m = 128 MiB, t = 4 )
backup Argon2id( passphrase, salt; m = 128 MiB, t = 4 ), AES-256-GCMprivate fun wrap(payload: ByteArray, kPin: ByteArray): String {
val inner = CryptoUtils.aesGcmEncrypt(kPin, payload)
val outer = keystoreEncrypt(getWrapKey(), inner)
return CryptoUtils.toBase64(outer)
}Limits
- A copied vault file is useless without K_hw, which is what makes a six-digit PIN acceptable. Code running as the app on a rooted phone can ask the Keystore to strip the outer layer and then guess the PIN offline. The master passphrase exists for that threat.
- When the Keystore key is gone (factory reset, cleared app data), the vault cannot be opened. A backup file is the only way back, and a forgotten passphrase is unrecoverable.
- Argon2id uses 64 MiB for the daily PIN because low-end phones run out of memory above that. The hardware wrap is the primary barrier there; the KDF cost is the second.
Implemented in PinManager.kt · VaultDatabase.kt · VaultKeyStore.kt · VaultBackup.kt · Argon2id.kt
Licensing
Blind-signed licence tokens
The shop knows the licence and the payment. The chat relay knows the identity. No one may join the two, the operator included. A licence proves itself with RSA blind signatures: the app generates random 32-byte token ids, blinds them and sends the blinded values to the licensing service, which signs them with a key that exists for one hour and counts how many it signed per licence. It never sees an unblinded id.
The app unblinds the signatures and later spends a token at the relay, which verifies it under that hour's public key, records the id against replay and marks the identity licensed until the hour ends. The relay learns that the holder is licensed this hour and nothing about which licence.
The sealed-sender handle is licensed by a second token on a route with no authentication at all, so a handle and an identity are never in one request. The client runs three Tor SOCKS ports (licensing service, identity session, sealed sends) so the three never share a circuit, and a fourth for guest identities on other relays. When the handle rotates, the pooled connections of its circuit are dropped so one connection cannot chain two pseudonyms.
suite RSA-2048, RSASSA-PSS, SHA-256, salt 32 (RFC 9474)
epoch floor(unix_time / 3600); one signing keypair per epoch, pruned when it ages out
issue licensed request: blinded ids → blind signatures; ≤ 10 × seats + 2 per licence per epoch
spend no credentials: (epoch, key_id, token_id, signature)
verify → SADD spent:{epoch} token_id → licensed until the epoch ends
two spends one for the identity's session, one for the sealed-sender handlepub async fn verify_and_spend(
state: &AppState,
epoch: i64,
_key_id: &str,
token_id: &[u8],
signature: &[u8],
) -> Result<i64, AppError> {
let cur = current_epoch();
if epoch < cur - 1 || epoch > cur + 1 {
return Err(AppError::LicenseInvalid(
"redeem_required: token epoch out of range".into(),
));
}
if token_id.len() != 32 {
return Err(AppError::BadRequest("token_id must be 32 bytes".into()));
}
let key = get_key(state, epoch).await?;
let sig = Signature(signature.to_vec());
sig.verify(&key.pk, None, token_id, &opts())
.map_err(|_| AppError::LicenseInvalid("redeem_required: invalid token".into()))?;
let spent_key = format!("spent:{}", epoch);
let token_hex = hex::encode(token_id);
let added: i64 = state.redis.sadd(&spent_key, token_hex).await?;
let _: () = state
.redis
.expire(&spent_key, EPOCH_SECS + 600, None)
.await
.unwrap_or_default();
if added == 0 {
return Err(AppError::LicenseInvalid(
"redeem_required: token already redeemed".into(),
));
}
Ok(epoch_end(epoch))
}Limits
- A licence shared beyond its seats runs its hourly allowance dry mid-hour. That is the whole enforcement; a patched app gains nothing, because the relay's verdict can only be set by an unspent, correctly signed token.
- A device's two redemptions happen in the same hour, jittered by 20 seconds. The crowd a device hides in is the number of devices redeeming in that window. At a small user count, timing correlation of the two is practical.
Implemented in blindToken.ts · token_verify.rs · token_keys.rs
Communities
MLS with a hybrid KEM
Communities of up to 5,000 members run RFC 9420 MLS with TreeKEM, from a clean-room implementation gated by the RFC's test vectors. Communities use a private-use ciphersuite, 0xF043: the KEM is X25519 combined with ML-KEM-768 through a combiner that binds both ciphertexts, so the hybrid holds if either component does; the AEAD is AES-256-GCM, the hash SHA-256, the signature Ed25519.
Each leaf credential carries the member's user id and is checked when the member is admitted, so rank enforcement keys on the authenticated sender that MLS decryption returns. The founder's key is the trust anchor and rides the invite link.
A Welcome carries the tree by SHA-256 reference, with the tree itself sent once alongside the commit, so a Welcome stays flat in size at any member count. Commits also go to a durable log, so a member who was offline past the 7-day stream replays them in order under its existing leaf. Any member may issue a self-update commit; that is how the tree fills and each commit stays logarithmic in the member count.
suite 0xF043 KEM = X25519 + ML-KEM-768 (combined over both ciphertexts) · AES-256-GCM · SHA-256 · Ed25519 leaf credential = member's user id, verified at admission welcome GroupInfo + GroupSecrets; tree by SHA-256 reference, sent once with the commit history commits in a durable log; content on a 7-day stream
private fun combine(xDh: ByteArray, mlkemSs: ByteArray, enc: ByteArray): ByteArray {
val prk = CipherSuite.hkdfExtract(ByteArray(0), xDh + mlkemSs)
val info = "aphotic-hybrid-kem-v1-X25519-MLKEM768".toByteArray(Charsets.US_ASCII) + enc
return CipherSuite.hkdfExpand(prk, info, SHARED)
}Limits
- The relay sees the roster and the epoch number of each community.
- Everyday groups of up to 20 stay pairwise: each message is encrypted separately for every member with the ratchet above, so no group key exists on the relay.
Implemented in CipherSuite.kt · HybridKem.kt · MlsGroup.kt · Welcome.kt · KeySchedule.kt
On the relay
What is stored, and for how long
The relay is a Tor hidden service with no clearnet endpoint. It stores public keys, the user id derived from them, group and community rosters, and queued ciphertext for at most 7 days. Every stored envelope is padded to a bucket of 256, 512, 1024, 2048, 4096 or 8192 bytes and in 4 KiB steps above that, with a 256 KB ceiling. The tables have no columns for sign-up or last-seen times. Read receipts, typing indicators and presence do not exist on the wire.
Replies carry no server or version header. Traffic reaches the relay from the local Tor process, so no request carries a client IP address to log.
pub fn padded_size(actual: usize) -> usize {
for &bucket in BUCKETS {
if actual <= bucket {
return bucket;
}
}
let last = *BUCKETS.last().unwrap();
last + ((actual - last + LARGE_BOUNDARY - 1) / LARGE_BOUNDARY) * LARGE_BOUNDARY
}Limits
- Timing is left to Tor. A watcher who sees both first hops at once can correlate them, and Tor states that limit openly; a delay added inside the app would shift both ends equally.
Implemented in padding.rs · sealed.rs · longpoll.rs
What the constructions are tested against
Each construction ships with executable tests, and the ones that follow a public specification are pinned to that specification's vectors.
- PQXDH: HKDF-SHA-512 vectors from the specification's own construction, plus seed-to-public-key pins for ML-KEM
- Double and Triple Ratchet: round trip, out-of-order delivery, post-compromise healing and restart survival, on the JVM and on a phone
- Sealed sender: round trip, an envelope without the KEM leg refused, tamper detection on every header field
- X25519: rejection of a set high bit, u ≥ p and each small-order point, with a hand-built key for each case
- Argon2id: known-answer tests against the reference output
- MLS: the official RFC 9420 vectors for tree math, crypto basics, key schedule, secret tree, tree validation and Welcome, plus multi-member round trips
- Blind tokens: the TypeScript client verified byte for byte against the audited Rust crate the services use
Found a gap?
Every message through the contact page reaches the operator as one email. A finding that holds up gets an answer and a line on the transparency page.