Measured, not assumed
Every security page on the internet says its cryptography is “constant-time” and its metadata is “hidden”. Almost none of them say how they know. For the last six weeks we have been finding out how we know — by measuring the shipped code on the class of phone it ships to, with the pass/fail criteria written down before each run, and by publishing the results whether or not they flattered us.
This post is the digest. The full protocols, with the predictions that missed, are the source for the whitepaper’s new §7, §9 and §11.
The method, because it is the point
Each measurement is a card: a question, a named adversary, acceptance criteria and predictions committed before the run, a null control and a positive control in every comparison, three replications, and a ledger of which predictions held. The ledger is not decorative. One card records four misses against one hit; another records that all three of our predictions about an adversary’s strength were wrong in the same direction, by 6–24×. A method that cannot embarrass you cannot correct you either.
Two rules did most of the work. Measure on the target. Our ML-KEM reference had a secret-dependent division that was invisible on a desktop — the compiler strength-reduces a constant divisor — and produced a 15.6 % timing delta on the ARM64 phone. An audit that stopped at the desktop would have signed it off. A negative result needs a positive control. Our first timing run of the Ed25519 signer, in a debug build, saw nothing. The positive control — a leak we already knew was there — also saw nothing, so the run was thrown out, and the release build found the leak at |t| = 71.
What we found and fixed
- ML-KEM-768 — the KyberSlash class: division and modulo on secret data in the number-theoretic transform and in compression. Replaced with Barrett reduction; verified on the phone (|t| 339 → 3.9); no
%or~/remains anywhere in the implementation; the transform is 12 % faster than what it replaced. - Shamir’s arithmetic — the table-driven GF(2⁸) multiply leaked at |t| up to 1222 on ARM64. Rewritten branch-free and table-free, proven equivalent over all 65 536 input pairs. A useful negative along the way: splitting a secret never depended on the secret byte; only combining did.
- Ed25519 — the pure-Dart signer recomputed the public key on every call and leaked the nonce and the long-term scalar. Signing moved to a native, constant-time implementation through FFI: |t| 60 / 71 → 2.6 / 1.6, byte-identical signatures, and 21× faster as a side effect.
- X25519 — clean on ARM64. We say so because we measured it, not because the library says so.
- Frame sizes — the same message text came out at four different sizes depending on the conversation mode and whether a ratchet was running: two bits of secret state, readable from a single frame. Group rooms were not padded at all. Both fixed: buckets are now computed from the size on the wire with a 384-byte floor, rooms pad like everything else, and decoy frames draw their size from the same distribution as real ones — zero frames the relay can call “certainly real”.
- The cover-traffic grid carried its phase across address rotations, so a new address matched its old one 86.5 % of the time. The grid is now re-phased from a random source at every rotation: 12.0 %, against a 9.0 % chance level.
- The background poll was a device fingerprint: one timer touching every contact of every identity at once grouped a phone’s addresses 77× above chance. Redesigned so that no device-wide clock is both periodic and shared — and then measured again, which is the next section.
- The backup file’s key derivation had been lowered from 256 MiB / 4 passes to 128 MiB / 6 passes on the argument that iterations recover cost. They do not — the attacker’s cost goes with memory squared — and the premise that 256 MiB might not fit was false by 4×. Restored to 256 MiB / 4 on measurement; one guess now costs about 3.6 GiB of memory traffic.
- A silent data loss: a message over 65 495 bytes was dropped without an error. It is now refused with one.
What we found and have not fixed
- AES-256-GCM. Our AEAD library leaks key-dependent timing through its GHASH — a “constant-time” mask that the Dart compiler compiles to a branch on ARM64 — and a data-dependent branch in a shift. The effect is ~1.4 % of a half-millisecond operation and needs a local, low-noise clock; the relay and Tor cannot see it. It is still a leak. We are replacing the library with a native one, and until then we make no constant-time claim for message encryption, anywhere on this site.
- One unexplained SHA3/SHAKE timing anomaly in a third-party library. Named, not resolved.
- The relay’s stopwatch — below.
Three claims we withdrew
This is the part most projects would edit quietly.
“The relay cannot batch addresses into one device.” The whitepaper said that because conversation addresses rotate every 15 minutes with a per-conversation phase, the relay could not tell which addresses belong to one phone. After redesigning the poll, a phase-histogram adversary confirmed it: grouping at 1.9–3.1× chance. Then we built the adversary we should have built first — a relay computing likelihood ratios over the public schedule — and it groups a device’s addresses 85–90 % after one epoch, 99.8 % within 30 minutes, with a thousand devices in the population making no difference. The resume-after-background timings do the same at 96–98 % after three resumes. The earlier number was a property of the weak measure, not of the schedule. The claim is gone from §6; §7 now says what the relay learns — a persistent pseudonym per device, from timing alone — and that two designs to close it are measured and under decision. The related claim that separate personas “don’t reveal that they share a device” fell with it: the poll round covered every identity at once.
“The relay cannot tell when you actually send.” Cover traffic does what it does inside an open conversation — 0.0018 bit between a real frame and a decoy. It does not hide that a conversation is open, it is off by default, and in the default configuration the relay keeps 98.5 % of the information about when you talk. The sentence now reads: with cover on, the relay cannot tell which frames are yours.
“The strength you see is the strength you have.” We measured the entropy meter against password classes of known construction. For a “word + digits + symbol” password it overstates by a median of 45 bits and waves 97 % of that class through the backup export gate without comment — while rejecting 37 % of genuinely random 8-character secrets. The meter is now labelled as a ceiling — at most this strong — and the export dialog says so. What protects a sealed file is the cost of one guess, not the number on the meter.
A fourth correction is smaller but of the same kind: our documents said the backup file’s padding “hides the scale of the profile”. It hides the number of contacts, rooms and unlock conditions; it does not hide the order of magnitude of your history (1.5 of 2 bits leak, and no padding within budget fixes it). So the export now offers a file without history.
What this is not
It is not an audit, and we do not call it one. An audit is someone else, with no stake in the answer, checking the work. What we have done is the work they would check, done in the open and before we ask anyone to check it — with the unfixed findings listed next to the fixed ones, because a list with only the fixed ones is marketing.
The whitepaper carries every number above and a changelog of what it used to say. If you can build a stronger adversary than ours, we want to hear about it.
Your key, your rules — everything else is redacted.