Devious by DC stores the most sensitive category of data an app can hold: the inner workings of people's D/s relationships. That made end-to-end encryption non-negotiable from day one — the server relays ciphertext and stores what it must; it is a postman, not a filing cabinet. But "we use end-to-end encryption" is a sentence, not a property. Before launch we ran repeated adversarial security reviews against our own design and a live, disposable clone of our own infrastructure, with a standing instruction to break things and price the attacks in real numbers. This post is what that process caught — including the claims we had to take back. Everything described below was found and fixed before or during our closed beta; where a fix shipped in a specific version, we say which.
01 The claim we retracted.
Our internal docs claimed our message ratchet provided forward secrecy — compromise a device today, and past messages stay safe. Review demonstrated it didn't: the pairing root key was retained and could re-derive every chain from the beginning, so a chain-state compromise exposed history, bounded only by how long ciphertext exists to decrypt. We had two options: rebuild the ratchet before launch, or say the true thing. We rewrote the claim. The security documentation now states plainly that there is no forward secrecy, that the pairing root re-derives chains, and that exposure is bounded by our message-retention window instead.
A strong claim you can't defend is worse than a modest claim you can. Attackers read your docs too — the difference is they test them.
Why is the exposure bounded at all? Because deletion is the other half of encryption. Since v1.0.2, relayed messages are deleted from the server the moment the recipient's phone acknowledges them, with a 7-day backstop for a partner who's offline. Our privacy policy had actually claimed rapid deletion before the code delivered it — we found that mismatch in review too, and fixed the code rather than the policy. The least defensible ciphertext is ciphertext that no longer exists.
02 Arithmetic beats assurance.
When two partners disconnect, a "connection key" gives them 48 hours to undo it. The first design was six random words — 2^36 possibilities — checked against a fast SHA-256 hash. That sounds fine until you price it: 2^36 SHA-256 evaluations is seconds of work for one GPU. An ex-partner with a copy of the database could brute-force the key and silently undo a disconnect the other person deliberately performed. The shipped version uses eight words (2^48) verified through scrypt with fixed, allowlisted parameters — moving the attack from seconds to decades of GPU time.
The allowlist matters as much as the algorithm. KDF parameters that arrive over the wire are attack surface: a malicious peer who can hand you N=2^30 can make your app choke on its own key derivation. Every KDF tuple we accept is compared against an exact allowlist — a rule we'd already learned once in the vault code, then reintroduced in a new code path, and paid for with a failed review cycle. Guardrails don't transfer to new code by osmosis.
03 A purge that purged nothing.
The scariest bug review found wasn't cryptographic. Our purge flow — the "destroy everything we shared" safety feature — deleted rows by the wrong ID namespace: it passed a remote user ID into a table keyed by local pairing ID. Zero rows matched. The purge then reported success, destroyed the encryption key, and left every row sitting there. The user had no way to detect this; the app told them their data was gone. The core fix was one line; the real work was making purge report honestly — per-step success tracking, and a failed purge that stays retryable instead of declaring victory. The lesson: a privacy feature that reports success must be verified against what's actually in the database, not against whether the function ran without throwing. Our review process now runs live probes against a disposable, rebuilt-from-scratch clone of the production stack before and after every security gate, because static reading of the code said this flow was fine.
The same review principle — the guarantee must not depend on the user's behaviour — reshaped purge expiry. Originally, expired purges were processed when the app came to the foreground. But the person a purge protects may have uninstalled the app and never come back. Now a server-side scheduled job destroys expired shared data every hour whether anyone opens the app or not — it began as a daily sweep, until review pointed out that a daily cadence nearly doubles the exposure window it exists to close. A safety promise that requires the person who fled to keep checking in is not a safety promise.
04 Publish your residuals.
End-to-end encryption hides content, not existence. Our server cannot read a single message — but it necessarily knows which accounts exchange ciphertext, when, and roughly how much, because routing requires it. That metadata residual sits in our privacy policy in plain language, right next to the encryption claim, not buried in a subclause.
Every E2EE system has residuals like this. The temptation is to lead with what's encrypted and stay quiet about what isn't. We think the durable move is the opposite: say what the operator can still see, in one sentence, next to the encryption claim. Users who care can then make a real decision, and you never have to walk back a claim you never inflated.
05 What we'd tell anyone building E2EE.
- Price every attack in numbers. "Hashed" is not an answer; 2^36 fast hashes is an afternoon. If you can't state the GPU-hours, you don't know if you're safe.
SECURITY DEFINERis not authentication. Several of our database functions took a caller-supplied user ID and trusted it. Review found them; the fix is boring (checkauth.uid(), revokePUBLIC, grant to authenticated) and absolutely load-bearing.- Webhooks are a trust boundary. Our payments webhook originally believed the user ID in the event body — which the client controls. Entitlement changes now re-verify against the payment provider's API before touching anyone's tier.
- Verify deletion, don't report it. Run a probe that checks the rows are gone.
- Adversarial review against live infrastructure, every substantive change. Static review told us things were correct that live probing proved were not — functions that referenced columns that didn't exist, sweeps that matched zero rows. "Looks correct" and "actually works" are different disciplines.
- When review catches your claim, fix the claim in public. It costs pride once. Inflated claims cost trust forever.
Devious by DC is an end-to-end-encrypted app for D/s relationships. Everything in this post was found and fixed before or during our closed beta; the running ledger of what we break and fix is public on the build log.