How the defenses actually work
A club that never checks ID
and keeps no cameras —
but makes every stranger
prove they're not a robot.
Privex can't ban a troublemaker by name, email, or IP, because it deliberately never learns any of those. So every defense here works a different way: by cost, by shape, and by math — never by identity.
Toggle Plain / Technical above at any time — every section below has both versions.
The shape of the problem
Most apps stop abuse the easy way: they know your email, your phone number, your IP address — so they can just block you. Privex refuses to know any of that, on purpose. That's the whole point of the app.
Which means it can't build a blocklist. Instead, every gate in this system is built to ask a different question — not "who are you?" but "did you pay the right cost to get this far?" A real person pays it once, without noticing. A botnet has to pay it thousands of times, on purpose, in a way that gets more expensive the more of them show up.
Zero-knowledge architecture removes the usual anti-abuse toolkit (IP throttling, device fingerprints, account reputation) by design — the server has no identity signal to key on. Every public, unauthenticated endpoint is therefore either a) proof-of-work gated (cost-based), b) validated to an exact wire shape (structure-based), or c) capped by an aggregate, identity-free pressure counter in Redis. Authenticated endpoints get one more lever: a per-pseudonym rate limit keyed by an HMAC of the session token — never a raw user_id.
Adversaries this system was built for
Seven tiers of attacker, from a coffee-shop snoop to a nation-state with a court order. Each one gets a different answer when they try to break Privex.
| Tier | Adversary | What they get | Outcome |
|---|---|---|---|
| 1 | ISP / DNS provider / Wi-Fi eavesdropper | Sees encrypted traffic only. Cannot confirm Privex is in use. | BLOCKED — DoH + Nym mixnet route + no server-visible IP |
| 2 | Active network MITM | Cannot read, modify, or replay any message undetected. | BLOCKED — TLS 1.3 + Nym layers + HKDF auth tags on every message |
| 3 | Compromised Privex server (hacked, insider, coerced operator) | Full database access yields zero usable information about any user. | BLOCKED — all content encrypted client-side; Sealed Sender hides sender; no PII in DB; UNLOGGED tables turn to ash on crash |
| 4 | Nation-state with legal authority (subpoena, NSL) | Even with a valid court order, there is legally nothing to provide. | BLOCKED — Iceland jurisdiction; server holds no identities, no content, no social graph, no IPs; warrant canary signals coercion |
| 5 | VPS host with physical access (RAM dump, disk read) | Sees relay IPs, encrypted blobs, and pseudonymous IDs only. | BLOCKED — all connections arrive via Nym/onion relays (no user IPs); DB holds only encrypted payloads; LUKS volumes at rest |
| 6 | Future quantum computer | Cannot retroactively decrypt captured traffic. | BLOCKED — CRYSTALS-Kyber-1024 + X25519 hybrid key exchange; Dilithium3 + Ed25519 hybrid signatures; AES-256-GCM symmetric |
| 7 | Root-malware on endpoint device | Screen content and in-memory messages are readable. Keys non-exportable. | PARTIAL — WebCrypto non-extractable + WASM zero-fill after each op; SE/Keystore on mobile; kernel-level root is out of scope |
How it all connects
One request, whether from you or a bot, flows through the same pipe. The pipe can't tell you apart by identity — so each stage checks something else instead.
The insight: your message content never even reaches this diagram unencrypted — it was already sealed shut on your device before it left. Everything above exists to stop abuse and stop leaks, not to protect what's inside the envelope. The envelope protects itself.
The pipeline above governs abuse-resistance and metadata exposure, not confidentiality. Content is PQXDH+Double-Ratchet encrypted client-side (WASM) before a single byte reaches fetch() — nothing server-side ever holds plaintext, a key, or a sender identity to leak in the first place.
Ten lines of defense
Every layer in the diagram above, expanded — what it actually does and why it's built that way.
Proof-of-Work
Anyone can walk up and ask for a key or an account — Privex can't check ID, so instead it makes every knock cost real effort, like a bouncer who skips the ID check but makes you solve a puzzle first. One person barely notices. A thousand fake bots need a thousand solved puzzles, and the puzzles get harder the more bots show up.
Public, target-revealing endpoints (registration, key-bundle fetch, KT proof, OPAQUE login-init) require a solved hashcash-style challenge instead of an IP rate limit. As of the Argon2id hybrid layer: find a nonce whose SHA-256 hash clears a leading-zero-bit pre-filter, then run Argon2id (32 MiB, memory-hard) on that hash and clear a second bit target. SHA bits run 22->31, Argon2id bits run 1->4, both driven by the same aggregate Redis pressure counters — no per-IP or per-user state exists to key on.
Per-user rate limiting
Once you're inside and chatting, Privex still watches how fast you're doing things — like a bartender who'll cut off someone ordering 200 drinks a minute. But your "tab" is just a random daily token, so the bartender can slow you down without ever learning your name.
Fixed-window limits keyed by HMAC(session token) — e.g. messages/send 120/60s, blob download 120/60s, spk/rotate 30/3600s, history delete 10/600s.
Zero-log infrastructure
Most services keep a guest list "just in case." Privex's building was built without the wiring for a camera system at all. No IP is ever written down, not even for a second. The important database tables are set up to simply vanish if the server ever crashes, rather than persist. A court order asking for footage gets the true answer: there isn't any.
message_queue, blob_index, kt_log, pow_challenges are UNLOGGED Postgres tables (no WAL, truncated on crash). Redis runs save "" / appendonly no. The reverse proxy never mounts an access-log sink. A private verification harness greps the source tree for ip_address and any user_id on a logging call.
End-to-end encryption
Even Privex can't read your notes, because you and your friend write in a language only the two of you agreed on, before the note ever left your hand. Part of that language was chosen specifically to resist a future computer powerful enough to break today's codes.
PQXDH (X3DH + CRYSTALS-Kyber-1024) key exchange — hybrid classical + post-quantum from day one. Double Ratchet gives forward secrecy and break-in recovery per message. Identity assertions are signed with both Ed25519 and Dilithium3; breaking the scheme requires breaking both primitives at once.
Sealed Sender
When a note is dropped off for you, the doorman only reads "deliver to" — never "from," because the sender's name is folded up inside the sealed note. The doorman genuinely cannot tell you who left it.
The sender's certificate is encrypted inside the payload with an ephemeral X25519 key, never carried on the wire. message_queue has no sender_id column at all — enforced by schema, verified by test.
Key Transparency
What stops someone from quietly swapping your friend's mailbox key for their own? Privex keeps a public, tamper-evident receipt book of every key ever issued. Your app checks it automatically — a swapped key either shows up in the book (and your app alerts you) or gets refused outright.
Append-only Merkle tree of every registration/rotation. Each key fetch returns an inclusion proof verified against a signed root — cached as a single O(1) snapshot per log generation, rebuilt only when the log actually grows. A substituted key must appear publicly or the client rejects it.
Client-side key storage & app lock
Your keys never leave your device, and while you're using the app they sit in a part of the browser's memory even Privex's own code can't read out — a locker only your fingerprint or passphrase opens. Lock the app and the locker snaps shut: the connection drops, the decoy traffic stops, and the token proving who you are is wiped from memory.
Master key: non-extractable WebCrypto AES-GCM wraps IndexedDB-stored session state. Passphrase path: Argon2id (32 MiB, t=3) key-wrapping. Biometric path (WebAuthn-PRF) is the brute-force-resistant option — the secret lives in hardware, OS-rate-limited. Locking tears down the whole live session: WS disconnected, cover traffic stopped, token cleared — re-triggers on bfcache restore and page-freeze so a backgrounded tab can't resurrect a decrypted view.
OPAQUE password recovery
If you want to recover your account with a password, here's the trick: the server is built so it never actually sees your password, not even for a second, not even hashed. It's mathematically blind to it — so stealing the whole database still doesn't let anyone try passwords against it offline, unless they also steal a second secret kept elsewhere.
OPAQUE (Ristretto255 + Argon2id KSF) — the server stores only an OPRF record and an encrypted key envelope, never anything derivable from the password itself. Fully opt-in: disabling it hard-deletes the row rather than flipping a flag, so non-opted-in accounts are unattackable offline even after a full database theft.
Browser security headers
Before a request even reaches the app, a guard at the street checks you're visiting the real building, not an impostor wearing its skin. Browsers are told: run nothing that wasn't part of the original build, never let this page be framed inside someone else's page, never leak which page sent you here.
Strict CSP (default-src 'none', script-src WASM-only, no inline JS), COOP/COEP (needed for SharedArrayBuffer), X-Frame-Options: DENY, Referrer-Policy: no-referrer, and a Permissions-Policy blocking camera/mic/geolocation until explicitly used. CORS and WebSocket origin allowlists are required non-empty at boot — an unset allowlist is a hard startup failure, never a silent fallback to allow-all.
Input validation
Every file ID, ticket number, or reference you hand over gets checked against an exact expected shape before it goes anywhere near a back room. A fake ID shaped like "redirect me to a private endpoint instead" is rejected on the spot, before it gets anywhere close to a door it could trick open.
Every wire field is validated to an exact shape before use — a chunk ID must be 64 lowercase hex chars (a SHA-256 shape) checked before it can reach a fetch URL, since a crafted value could otherwise drive an authenticated request to an arbitrary same-origin endpoint. UUIDs, history-blob IDs, share indices, and enum fields are all similarly pinned, client and server side.
Beyond messages: files, calls, groups, and trust
The ten layers above protect messaging. But Privex also sends files, makes audio/video calls, manages groups, and checks images for CSAM — all while keeping the same zero-knowledge promise.
Server is a dumb locker
Files are chopped into pieces, each piece locked with its own key, then scattered across a storage room. The server has no idea what was uploaded, who sent it, or even that the pieces belong to the same file. The instructions to reassemble them travel inside an already-encrypted message, visible only to the recipient.
Content Encryption Key (CEK) generated client-side. File split into 4 MB chunks, each encrypted with AES-256-GCM using a unique HKDF-derived chunk key. Chunks stored at content-addressed IDs (SHA-256 of ciphertext). File manifest (with CEK) sent inside a Sealed Sender Double Ratchet message. Recipient unwraps CEK via X25519 DH with their identity key. SHA-256 integrity check on reassembly.
Audio/video without the leak
Call setup happens through the same sealed message channel — so the server never knows a call even happened. The media itself is double-encrypted: once at the transport level (standard), then again at the application level with keys only the two of you hold.
Call signaling uses Sealed Sender messages (no call record on server). DTLS-SRTP for transport encryption, then SFrame (RFC 9605) for end-to-end frame encryption with keys derived from the PQXDH shared secret. TURN relay sees only DTLS-SRTP-encrypted streams, authenticated with ephemeral HMAC tokens (60s validity, no user identity).
MLS at scale
Group chats use a tree-based system so adding or removing someone only costs a few operations instead of re-keying everyone. Eject a member and they instantly lose the ability to read new messages — no matter when they joined.
MLS protocol (RFC 9420) with a binary ratchet tree (TreeKEM). Operations are O(log N): adding a member updates log2(N) internal tree nodes. Each Add/Remove/Update creates a new epoch with a fresh group secret; previous epoch keys are permanently deleted. Up to 500 members with full MLS; 500-5000 uses Sender Keys model for practical scaling.
Client-side, zero-knowledge
Before an image leaves your device, it's checked against a known-blocklist using a protocol where neither the server learns about your image nor your app learns about the full blocklist. If the check passes, your app generates a tiny cryptographic proof that the check was done correctly — the server verifies the proof without ever seeing the image.
PDQ perceptual hash (256-bit, similarity-tolerant) computed client-side on every outgoing image. Private Set Intersection via OPRF on Ristretto255: client blinds the hash, server evaluates against NCMEC blocklist, client unblinds to check membership. Server learns nothing about the hash. If clear, client generates a Groth16 ZK proof (~256 bytes, <5ms verify) that the PSI check was performed honestly. Message rejected without valid proof.
Hide signal in the noise
Even if someone watches the wire, real messages are mixed with fake ones that look identical. The server handles decoy messages the same way as real ones — so a watcher can't tell which messages are genuine by watching the server's behavior.
Cover traffic sent on a Poisson schedule indistinguishable from real message timing. Decoy messages addressed to nonexistent mailboxes; server drops them indistinguishably from real delivery failures. All messages padded to fixed 1024-byte blocks. Jittered receipt acknowledgments (5s floor) prevent timing correlation of delivery confirmations.
The cost curve: why bots can't win
The proof-of-work puzzle doesn't just cost a fixed amount. It gets more expensive the harder the attacker pushes — exponentially — with no way to share the cost across parallel workers.
This is why a botnet can't win. The cost curve isn't a straight line — it's a cliff. Drag the slider above to see how the total attacker cost explodes as the fleet scales up. Every single bot pays the full inflated price independently. There's no bulk discount. No shared credit. No parallelism trick that helps. Aggregate Redis pressure counters drive a two-layer hybrid PoW: SHA-256 pre-filter (22–31 leading-zero bits) and Argon2id (32 MiB, 1–4 memory-hard bits). Both layers track the same 60-second sliding window. A sustained 10,000 bot/min attack saturates the ceiling: SHA at 2³¹ (512× harder than idle), Argon2id at 4 bits (16× harder). The layers compound, not add. An attacker cannot amortize — every connection pays independently.
Attack coverage map
Nine attack classes. Each one collides with multiple independent defenses. No single bypass breaks more than one shield — and every shield has a backup.
Every attack hits a wall — never a single guard. To break Privex, an attacker would need to defeat every shield in a row simultaneously. Break one layer, and the others still hold. No two attacks share the exact same set of defenses, because each one works differently. This is a map of overlapping protections that cover each other's blind spots. Defense in depth validated against the codebase. All mechanisms are active in the current build. The model assumes every layer can fail independently, and relies on overlap rather than perfection. Each card above lists the specific mechanisms that block that attack class.
Attacker vs. defense
Fifteen ways someone might actually try to hit Privex, and the specific mechanism already standing in the way.
Mass-register thousands of fake accounts
Register faster than a human ever could, using scripts across many machines.
Automated registration burst across many workers, no shared IP to throttle by.
Every account costs a solved puzzle that gets harder as the crowd grows.
Hybrid PoW: SHA pre-filter (22 to 31 bits) + Argon2id (32 MiB, 1 to 4 bits), difficulty auto-climbing from aggregate pressure — no per-IP state to evade.
Enumerate who has an account
Probe ID after ID to build a map of who's using Privex.
Walk the key-directory fetch endpoint to test existence of arbitrary IDs.
Every single lookup costs a puzzle too — mapping the userbase gets expensive fast.
Key-bundle fetch is PoW-gated (same hybrid puzzle) plus a 30/60s per-target cap as defense in depth.
Force expensive checks by spamming junk
Send garbage answers on repeat, hoping the server does costly work anyway.
Submit invalid/near-miss PoW solutions to force repeated server-side hashing.
Every puzzle is single-use — even a wrong answer burns it, so it can't be replayed.
Invalid attempts still consume the single-use challenge; a concurrency semaphore caps how many memory-hard checks run at once, bounding peak memory.
Brute-force a recovery password
Steal the database and try guessing passwords against it, offline, forever.
Offline dictionary attack against a stolen credential table.
There's nothing to guess against — the server never even saw the password.
OPAQUE: server stores an OPRF record, never a password-derived value; opt-in, hard-deleted on disable.
Swap someone's key mid-conversation
Quietly replace a friend's public key so messages get redirected.
Silent key substitution in the directory to intercept a session.
Every key is in a public receipt book. A swap shows up there, or gets refused.
Key Transparency Merkle log with client-verified inclusion proofs against a signed root.
Forge a message from someone else
Send a message that appears to come from a user whose keys you don't hold.
Craft a Sealed Sender envelope with a fake sender certificate.
Every sender certificate carries two unforgeable signatures. A forged one is rejected on arrival.
Sender cert is Ed25519 + Dilithium3 dual-signed; recipient verifies both before accepting. KT log confirms the signing keys are authentic.
Seize or legally compel the server
Demand logs, message history, or user identities from the operator.
Legal or physical seizure of server/database contents.
There's nothing there. No IP, no names, no sender field — even the tables that would hold it are wired to erase themselves.
UNLOGGED queue/blob/log tables, no PII columns, Sealed Sender means sender identity is never server-side to begin with.
Steal an unlocked device
Walk off with a laptop or phone that's logged in.
Physical device compromise while the app session is active.
Locking the app doesn't just hide the screen — it tears the whole session down.
Non-extractable master key + Argon2id/WebAuthn-PRF wrap; lock disconnects the socket, stops cover traffic, clears the token from memory.
Watch traffic to see who talks to whom
Sit on the network and count who's sending messages to whom, and when.
Passive traffic analysis of message timing, size, and pairing.
The server only ever sees a recipient, never a sender — and a constant stream of fake decoy traffic hides real activity in the noise.
Sealed Sender + Poisson-scheduled cover traffic + fixed 1024-byte padding. Caveat below — the connection endpoint itself is still visible in Phase 1.
Hammer a public endpoint to run up costs
Flood a free, public health-check to burn server and cloud-storage bills.
Unauthenticated flood of a health-check route that fans out to paid dependencies.
The answer is cached for two seconds — a thousand knocks in that window get one real check, not a thousand.
Readiness probe result cached process-wide (2s TTL), single-flighted so concurrent misses share one dependency check.
Steal encrypted file chunks from blob store
Access the raw blob storage and download all encrypted chunks.
Compromise blob store credentials or exploit storage-layer vulnerability.
Every chunk is individually encrypted with its own key. Without the manifest (sent inside an E2EE message), chunks are indistinguishable from random noise.
Each 4 MB chunk encrypted with unique AES-256-GCM key derived from CEK via HKDF. Server has no file-to-chunk mapping. Chunks content-addressed by SHA-256 of ciphertext.
Resend a captured message to confuse ordering
Capture a valid encrypted message and re-inject it later.
Replay an old Double Ratchet or Sealed Sender message to the recipient's inbox.
Every message has a unique counter. The recipient's app will reject a duplicate or out-of-order replay immediately.
Double Ratchet message counter + prev_counter fields; Sealed Sender includes a 24h expiry on sender cert. Redis key for recent message IDs prevents replay within the delivery window.
Correlate message timing to identify conversation pairs
Watch both sides of a conversation and match send times to deduce who talks to whom.
Correlate send timestamps on Alice's outbound with delivery timestamps on Bob's inbound.
Delivery receipts are artificially delayed by random amounts. Cover traffic fills the gaps. The timing signal is deliberately degraded.
Receipt jittered with 5s minimum floor + Poisson distribution. Cover traffic on independent Poisson schedule. Fixed 1024-byte padding eliminates size correlation.
Present an outdated KT log root to hide a key swap
Serve a stale signed tree root so the client doesn't see a newly-substituted key.
Freeze or roll back the Key Transparency log to a prior state where the victim's key was attacker-controlled.
The client verifies the log root against gossip consensus. A rollback produces an older root that doesn't match the current epoch.
KT root signed with log's epoch key; clients cache the latest known root and reject monotonicity violations. Gossip-based consistency check across independent sources.
Distribute known CSAM through the platform
Upload and share known child sexual abuse material before detection.
Send images hashing to NCMEC blocklist entries, evading server-side scanning.
Every outgoing image is hashed locally and checked via a private set intersection. Matches are blocked before the image ever leaves the device.
PDQ perceptual hash + OPRF-based PSI on Ristretto255. Server learns nothing about non-matching images. Groth16 ZK proof attached to every image-bearing message; server rejects without valid proof.
What we're honest about
Privex's own documentation calls these out by name. Repeating them here on purpose — a security page that only lists wins isn't a trustworthy one.
Your ISP can still tell you're connecting to Privex — for now.
Phase 1 uses a direct WebSocket, not yet routed through the Nym mixnet.
They can't see what you're saying, who to, or when — but they can see the connection itself. Hiding that entirely is the next major milestone.
Content, sender, and contacts stay protected (TLS + Sealed Sender + no server logs); only the connection endpoint is visible on the wire. Full Nym gateway wiring is the headline Phase 2 deliverable.
The anonymity crowd is still small.
The onion relay network is a limited node set today.
Traffic-hiding tricks work better the more people are using them at once — like it's easier to blend into a packed room than an empty one. Privex is early, so the crowd is still growing.
3-hop circuits across a small operator set; anonymity-set size scales with adoption, not architecture alone.
A short PIN alone isn't unbreakable if your device is stolen.
Passphrase-only app lock has an offline-guessing ceiling.
A short passphrase can eventually be ground down by someone with your device in hand and time to spare. Turning on fingerprint/Face ID unlock closes this gap completely — that secret never leaves hardware.
Argon2id bounds but doesn't eliminate offline brute-force against a short secret. WebAuthn-PRF (hardware-backed, OS-rate-limited) is the only truly brute-force-resistant factor.
iPhone notifications are limited by Apple, not by Privex.
iOS background execution constrains closed-app push delivery.
Apple doesn't allow the kind of always-on background connection that would make closed-app notifications instant on iPhone, for any app — that's a platform rule, not a corner Privex cut.
No real closed-app Web Push was built deliberately — a subscription endpoint would require a third-party record that deanonymizes the pseudonym and leaks arrival timing.
Call privacy is weaker than message privacy.
TURN relay sees IPs of both participants during a call.
Your call content is encrypted (double-layered), but the TURN server that relays the media sees the IP addresses of both sides. This is the same tradeoff Signal, Wire, and every other E2EE calling app makes.
TURN authentication uses ephemeral HMAC tokens (60s validity, no user identity). TURN logging disabled. SFrame encrypts each frame end-to-end. Residual risk: the relay operator knows two IPs connected for a call.
Server-side history backup (if enabled) weakens forward secrecy.
Opt-in history storage is the only server-side plaintext-adjacent data.
If you turn on history backup, encrypted copies of your messages live on the server. Your password is still required to decrypt them, but the encrypted blobs exist to be attacked. Default is OFF.
History blobs encrypted with a key derived from OPAQUE master seed. Server holds encrypted blobs only. Default off with clear warning. High-threat users should leave it disabled.
Technical reference
The numbers behind the diagrams above, for anyone who wants to verify the claims rather than trust the metaphors.
Proof-of-work difficulty, by aggregate pressure
| Recent activity | Final difficulty | SHA pre-filter bits | Argon2id bits |
|---|---|---|---|
| Normal (0-5 registrations / min) | 22 | 21 | 1 |
| Elevated (16-40 / min) | 25 | 23 | 2 |
| Heavy (101-300 / min) | 29 | 26 | 3 |
| Ceiling (301+ / min) | 31 | 27 | 4 |
Per-pseudonym rate limits (selected)
| Route | Limit | Window |
|---|---|---|
| messages / send | 120 | 60s |
| messages / ack | 200 | 60s |
| blobs — download | 120 | 60s |
| blobs — upload / delete | 60 | 60s |
| spk / rotate | 30 | 3600s |
| history — delete all | 10 | 600s |
| OPAQUE — login init (per target) | 10 | 60s |
| KT — proof fetch (per target) | 30 | 60s |
Cryptographic primitives
| Component | Algorithm | Standard |
|---|---|---|
| Key exchange (classical) | X25519 + X3DH | RFC 7748 / Signal PQXDH spec |
| Key exchange (post-quantum) | CRYSTALS-Kyber-1024 | NIST FIPS 203 |
| Identity signatures (classical) | Ed25519 | RFC 8032 |
| Identity signatures (post-quantum) | CRYSTALS-Dilithium3 | NIST FIPS 204 |
| Message encryption | AES-256-GCM | NIST SP 800-38D |
| Ratchet / key derivation | HKDF-SHA256 | RFC 5869 |
| Sealed Sender encryption | XChaCha20-Poly1305 | libsodium |
| Proof-of-Work (Layer 1) | SHA-256 hashcash | Adam Back / RFC 8011-style |
| Proof-of-Work (Layer 2) | Argon2id (32 MiB, t=3) | RFC 9106 / PHC winner |
| Password recovery (opt-in) | OPAQUE (Ristretto255) | IETF OPAQUE draft |
| Group messaging | MLS (TreeKEM) | RFC 9420 |
| AV call encryption | DTLS-SRTP + SFrame | RFC 9605 |
| File chunk encryption | AES-256-GCM per chunk | HKDF-derived subkeys |
| CSAM perceptual hash | PDQ | Facebook/Meta, Apache 2.0 |
| CSAM ZK proof system | Groth16 (Circom 2.0) | 256-byte proofs, <5ms verify |
| App lock (PIN path) | Argon2id (32 MiB, t=3) | wraps WebCrypto AES-GCM key |
| App lock (biometric) | WebAuthn-PRF | WebAuthn Level 2 |
Service-level agreements for security properties
| Property | Guarantee | What it means for you | Mechanism |
|---|---|---|---|
| Content confidentiality | Absolute | No one but the intended recipient can read your messages. | PQXDH + Double Ratchet + Sealed Sender; server never holds a plaintext byte or a key. |
| Sender anonymity | Absolute | The server never knows who sent a message. | Sealed Sender: sender cert encrypted inside payload, no sender_id column. |
| Forward secrecy | Absolute | If today's keys are stolen, yesterday's messages stay secret. | Double Ratchet: message key deleted immediately after use; DH ratchet on every message. |
| Post-quantum security | Absolute | Harvest-now-decrypt-later attacks fail by design. | Kyber-1024 + Dilithium3 from session one; hybrid classical/PQ key exchange. |
| Key integrity | Absolute | Keys can't be swapped without detection. | Key Transparency: append-only Merkle log with client-verified inclusion proofs. |
| Traffic analysis resistance | Strong | Who you talk to and when is hidden in decoy traffic. | Sealed Sender + cover traffic (Poisson) + 1024B padding + jittered receipts. |
| ISP-level usage detection | Phase 2 | Currently visible; Nym routing will hide it in Phase 2. | Phase 1: direct WebSocket. Phase 2: Nym mixnet for network-layer metadata resistance. |
Hardening changelog (referenced in source, by ID)
- PVX-06 Revocation check fails closed if Redis is unreachable — never silently skips.
- PVX-08 Auth-verify does equal work on an absent user — no existence timing oracle.
- PVX-09 Empty CORS/WS origin allowlist is a hard boot failure, never allow-all.
- PVX-10 File-manifest IDs validated to exact hex shape before ever reaching a fetch URL.
- PVX-11 A persistently-failing cleanup sweep raises an alert instead of failing silently.
- PVX-18 Blob/share IDs rejected unless they match a UUID or key shape.
- PVX-20 Auth-verify capped at 5/60s per target — anti brute-force.
- PVX-21 Domain-separated signing context stops cross-protocol signature reuse.
- PVX-22 App-lock passphrase minimum raised after a 6-char PIN proved too weak offline.
- PVX-23 Key-Transparency tree cached as an O(1) snapshot, not rebuilt per fetch.
- PVX-24 Every Redis/HMAC key is a purpose-bound subkey — never the root secret.
- PVX-26 Malformed inputs are rejected outright instead of throwing at render time.