pq-share, a guide
pq-share is end-to-end encrypted file sharing where every send picks its own cipher suite. Classical curves, hybrid post-quantum, pure ML-KEM, CNSA 2.0 — all of it, live, in the same browser tab. This guide walks through the application: the eight keypairs each account carries, the picker and what it actually controls, the wire format, and the threat model. Built for people who'd rather know what's happening than take our word for it.
what pq-share is
pq-share is a small file-sharing application that runs in your browser. You sign up with an email and a password, generate keys locally, and from then on every file you send is end-to-end encrypted to the recipient's public keys. The server never sees plaintext, never sees your private keys, and could not decrypt your files if a court asked it to.
What makes pq-share different from any other E2E file-sharing demo is that the cipher suite is not fixed. Most demos pin one specific combination — say, X25519 plus AES-256-GCM — and call it a day. pq-share lets you pick a different combination on every send, then shows you what changes: the byte sizes on the wire, which primitives are exercised, where in the modern cryptographic landscape your choice sits. The intent is educational: hold two sends side-by-side, one classical and one CNSA 2.0, and watch the substance shift.
The application is small enough to read in a sitting. The crypto layer is roughly five hundred lines of JavaScript, dispatched through a suite-aware operations table. The backend is a FastAPI service that stores ciphertext and routes notification email — nothing privileged, no plaintext access.
Throughout this guide, monospace identifiers refer to
primitives, fields, or code paths. Tier badges
(classical
hybrid
pqc
cnsa)
match the colors used in the picker UI.
the eight keypairs
Every pq-share account carries eight keypairs — four for key exchange, four for signatures, two of each pair drawn from the classical and post-quantum families. The set covers every primitive choice the picker offers. If you're going to demo "send this file under CNSA 2.0", your account needs the ML-KEM-1024 keypair to receive under, and your peer's account needs the ML-DSA-87 keypair to verify under, or the picker will block the suite at send-time.
Key exchange (KEM) keypairs
- X25519
- classical 32-byte private, 32-byte public. Curve25519 ECDH per RFC 7748. Fast, well-studied, broken by Shor's algorithm in polynomial time once a sufficiently large quantum computer exists.
- secp384r1
- classical 48-byte private, 49-byte compressed public. NIST P-384 ECDH per FIPS 186-5 §6. Larger curve, same Shor problem.
- ML-KEM-768
- pqc ~2400-byte private, 1184-byte public, 1088-byte ciphertext. The NIST level-3 lattice-based KEM, FIPS 203 (formerly CRYSTALS-Kyber). The default post-quantum choice.
- ML-KEM-1024
- cnsa ~3168-byte private, 1568-byte public, 1568-byte ciphertext. NIST level 5; the KEM specified by CNSA 2.0 for national security systems.
Signature keypairs
- Ed25519
- classical 32-byte private, 32-byte public, 64-byte signature. EdDSA per RFC 8032. Fast and constant-time. Forgeable post-quantum.
- ECDSA-P384
- classical 48-byte private, 49-byte compressed public, 96-byte signature (raw R || S). FIPS 186-5. Note that signing a message under ECDSA requires hashing first; pq-share uses the suite's chosen hash, which makes some hash/curve combinations non-FIPS-approved but still cryptographically sound.
- ML-DSA-65
- pqc ~4032-byte private, 1952-byte public, 3309-byte signature. NIST level-3 module-lattice signature, FIPS 204 (formerly CRYSTALS-Dilithium).
- ML-DSA-87
- cnsa ~4896-byte private, 2592-byte public, 4627-byte signature. NIST level 5; the signature specified by CNSA 2.0.
All keypairs are generated client-side in your browser at signup,
using @noble/curves for the elliptic-curve primitives
and @noble/post-quantum for the lattice ones. The
private keys never leave your browser in plaintext form — they're
packed into a length-prefixed binary bundle (about 17 KB
total), encrypted under an AES-GCM key derived from your password
via Argon2id, and uploaded to the server in that wrapped form.
The server stores only the ciphertext.
signup & the recovery code
Signup happens entirely in your browser, with one HTTP POST at the end. Concretely:
- You enter an email and a password (minimum 12 characters).
- The browser generates two random 16-byte salts (one for the
password, one for a recovery code) and a 24-byte recovery code.
The recovery code is encoded in Crockford-style base32
(alphabet excludes
0/1/l/o) and grouped into 4-character chunks for legibility. - The browser runs Argon2id over the password to derive a
32-byte master key, then HKDFs that into a 32-byte
wrapKey(used to encrypt the private-key bundle) and a 32-byteauthSecret(sent to the server as the password proof). - The browser runs Argon2id over the recovery code to derive
a parallel
recoveryKey— independently of the password. - The browser generates the eight keypairs described above.
- The private keys are concatenated into a versioned bundle
and encrypted twice: once under
wrapKey(wrapped_priv_password), once underrecoveryKey(wrapped_priv_recovery). Either key can later unlock the same bundle. - The browser also encrypts
recoveryKeyitself underwrapKey(wrapped_recovery_key). This sounds redundant — and is, for security purposes — but it lets the application transparently re-wrap the recovery bundle on future logins without ever asking for the recovery code again. - One POST to
/api/auth/signupsends: email, salts, KDF parameters, the eight public keys, the two wrapped bundles, the wrapped recovery key, andauthSecret. - The server emails a confirmation link. Click it, log in, you're in.
The recovery code is the only thing in this whole flow that you have to actively save. After signup, you'll see it once on a screen titled Save your recovery code. There is a "Download as recovery-code.txt" button — use it. The code is not stored anywhere on the server or in your account; only the recovery-key derived from it is, and only in encrypted form.
If you lose your password and your recovery code, the encrypted bundle on the server is permanently undecryptable. Your account exists, the public keys exist, but no one — not you, not us — can recover the private keys. We can delete the account; we cannot recover it.
Why two independent unlocking paths?
The standard threat model for password-protected key bundles includes "user forgot password." The recovery code provides a second derivation path with the same content — same private keys, same security level — but reachable from a completely independent secret. You can think of it as two locks on the same box: either key opens it, neither key implies the other.
The third blob, wrapped_recovery_key, is a small
convenience that does not reduce the security of the
two-lock setup. Storing
AES-GCM(wrapKey, recoveryKey) means an attacker who
already has the password can derive wrapKey, decrypt
the bundle directly, and own everything — having
recoveryKey on top doesn't tell them anything new.
What it lets the legitimate user do is re-wrap the recovery
bundle when the keyset is upgraded (more on that below) without
needing to type the recovery code in.
sending a file
The send flow is two screens: the recipient/file form, then the cipher suite picker. The picker is what makes pq-share interesting, so the bulk of this guide treats it separately. Here, the end-to-end byte path:
Two things worth noticing in the diagram. First, the file's
AEAD key (fileKey) is generated fresh per send —
no long-term symmetric key is reused across files or
recipients. Second, the wrapping for each recipient uses a
fresh KEM ephemeral, so the same file going to ten recipients
is encapsulated ten distinct times. Forward secrecy at the
KEM layer is therefore per-send, per-recipient.
The metadata_json blob captures non-secret details
about the file (MIME type, plaintext size, creation timestamp).
It is part of the signing transcript, so any tampering with
those fields invalidates the signature.
the picker, walked through
The cipher suite picker is the experimental surface of pq-share. Five preset chips at the top, a TLS-version toggle below them, then five categories — Random Number Generator, Key Exchange, Hash & HKDF, Symmetric Encryption, Digital Signature — each with the available primitives as pill toggles. The right rail shows the selected recipient, a posture badge, the cipher chain visualization, and the share button.
Posture presets
Click a preset and all five categories snap to the primitives that preset specifies. Disallowed primitives fade to ~15% opacity rather than disappearing — the point is to see what the preset didn't let you pick. Hand-editing any pill flips the active preset back to Custom.
- Custom
- aggregate No lock. Pick any combination within the TLS-version constraints. Posture is computed weakest-link.
- NIST SP 800-52 R2
- classical A pre-PQ FIPS-approved baseline: AES-CTR-DRBG-256, secp384r1, SHA-384, AES-256-GCM, ECDSA-P384, TLS 1.2. Useful for showing what the world looked like before lattices.
- FIPS 140-3 hybrid
- hybrid The current pragmatic recommendation: AES-CTR-DRBG-256, X25519+ML-KEM-768, SHA-384, AES-256-GCM, dual Ed25519+ML-DSA-65, TLS 1.3. This is what most modern PQ-aware deployments actually run.
- PQC-only
- pqc HMAC-DRBG-SHA512, ML-KEM-768, SHA3-512, AES-256-GCM, ML-DSA-65. No classical hedge. Useful if you trust the lattice constructions and want minimal classical cryptography in the path.
- CNSA 2.0
- cnsa AES-CTR-DRBG-256, ML-KEM-1024, SHA-512, AES-256-GCM, ML-DSA-87, TLS 1.3. NSA's Commercial National Security Algorithm Suite version 2 — the highest level pq-share offers.
TLS toggle
The TLS-version toggle is partly cosmetic and partly real. It doesn't affect the actual TLS handshake your browser performs against pq-share's server (your browser does whatever your browser does). What it does affect is which primitives the picker exposes: PQ KEX groups and PQ signatures don't have assigned codepoints in TLS 1.2, so selecting TLS 1.2 retracts those pills and forces a classical-only suite. Treat it as a teaching tool that says "if you were sending this same suite over TLS 1.2, here's what would survive."
The five categories
Random number generator
Three options. In a browser context all three actually delegate
to crypto.getRandomValues under the hood; the choice
of DRBG is therefore a label rather than a different code path.
In a FIPS-validated module, the difference would be substantive.
AES-CTR-DRBG-256 is the CNSA-mandated DRBG;
HMAC-DRBG-SHA256 and HMAC-DRBG-SHA512
are the other two FIPS-approved constructions.
Key exchange
Five options. This is where the most visible byte-level change happens between suites — the KEM material on the wire ranges from 32 bytes (X25519 ephemeral) to 1568 bytes (ML-KEM-1024 ciphertext). The picker shows a live byte estimate next to the chain.
X25519— classical Curve25519 ECDH. Tiny, fast, post-quantum-broken.secp384r1— classical NIST P-384 ECDH. Same Shor problem, larger curve.X25519MLKEM768— hybrid IETF-standardized hybrid: derive a shared secret from both an X25519 exchange and an ML-KEM-768 encapsulation, HKDF them together. Currently the IETF default for TLS 1.3 PQ migration.ML-KEM-768— pqc Pure post-quantum, no classical hedge. NIST level 3.ML-KEM-1024— cnsa NIST level 5. Required by CNSA 2.0.
Hash & HKDF
Used in two places: as the HKDF hash that turns the KEM shared
secret into wrapKey, and as the digest applied to
the ciphertext when building the signing transcript. Selecting
the same hash for both keeps the suite internally consistent.
SHA-256— classical 128 bits of collision resistance. Pairs poorly with AES-256 and large-curve ECDH (the hash becomes the security floor).SHA-384— hybrid 192 bits of collision resistance. Pairs cleanly with AES-256 and P-384.SHA-512— hybrid 256 bits of collision resistance. Required by CNSA 2.0.SHA3-512— pqc Keccak-based; structurally different from the SHA-2 family. Useful as a hedge.
Symmetric encryption
Two options today. ChaCha20-Poly1305 and
AES-256-GCM-SIV appear in the picker but are
currently disabled — neither is in the WebCrypto API, so wiring
them up requires bringing in a JavaScript implementation.
That's on the punch list.
AES-128-GCM— classical 16-byte key, 128-bit security classically; under Grover's algorithm a quantum adversary halves the effective security to 64 bits, which is uncomfortably small for archival data. Acceptable for short-lived secrets.AES-256-GCM— hybrid 32-byte key, 128-bit post-quantum security via Grover. The current safe default and the only AEAD CNSA 2.0 approves.
Digital signature
Five options, two of which sign with both a classical and a PQ algorithm so a verifier can fail closed if either is later broken.
Ed25519— classical EdDSA. 64-byte signatures. Forgeable post-quantum.ECDSA-P384— classical 96-byte raw R || S. The transcript is hashed with the suite's hash before signing; pairing P-384 with SHA-256 is non-FIPS-approved but cryptographically valid.Ed25519+ML-DSA-65— hybrid Both signatures travel; the recipient verifies both and accepts only if both pass. Robust to surprises in either family.ML-DSA-65— pqc Pure PQ. 3309-byte signatures. NIST level 3.ML-DSA-87— cnsa 4627-byte signatures. NIST level 5. CNSA 2.0.
The cipher chain & posture
The right rail's cipher chain stack visualizes the five primitives the suite will actually exercise, split into a transport stack (the handshake side) and an envelope stack (the file side). Each row carries a tier-colored dot. The whole stack glows with the weakest tier's color — pick SHA-1 anywhere in your chain and the entire stack reads as classical.
The posture badge above the chain rolls the chain up into a
single tier label. For the four non-Custom presets, the badge
simply reflects the preset's claim: clicking CNSA 2.0
gives you the mauve "CNSA 2.0" badge, even though
SHA-512 and AES-256-GCM live at hybrid
tier in their per-primitive classification. The reasoning is
that the user has made a regulatory claim, and the badge
should honor it. Custom mode aggregates by weakest-link with
no regulatory shortcut.
The byte estimate ("+3.3 KB total") is an approximation of the handshake key share plus the signature plus AEAD framing. Real-world overhead on the wire matches it within a few bytes.
receiving & decrypting
When someone sends you a file, you get an email notification pointing at the inbox. Open the inbox while logged in, and the table lists every file with its sender, size, suite badge, and timestamp. The suite badge is colored by tier, so you can tell at a glance how the sender chose to send.
Click Download on a row and the browser, in order:
- Fetches the file's metadata
(
GET /api/files/:id/meta): suite description, the sender's eight public keys, the wrapped file key, the KEM ephemeral material, the signatures, and the encrypted filename. - Fetches the ciphertext blob
(
GET /api/files/:id/blob). - Looks up the dispatch operations for the suite. If
suiteis null on the metadata (legacy uploads from before the picker existed), the client falls back to a built-inLEGACY_SUITEthat matches what the original hardcoded build did. - Computes the signing transcript the same way the sender did — hash the ciphertext under the suite's chosen hash, concatenate the metadata JSON — and verifies both signature components using the sender's appropriate public key(s). If verification fails, the download aborts with an error.
- Decapsulates the KEM material under your relevant private
key (
x25519Priv,mlkem1024Priv, whichever the suite called for) to derive the wrap key. - Unwraps
fileKeyfromwrapped_key. - Decrypts the ciphertext and the encrypted filename with
fileKey. - Triggers a browser download with the original filename.
All of this happens locally; the server is uninvolved past
step 2. If the signature fails — say, because someone
tampered with the metadata blob server-side — the file is
never decrypted. The signing transcript covers
hash(ciphertext) || metadata_json, so a tweak to
either invalidates the signatures.
the wire format
The upload schema is older than the picker. When pq-share was first written, it had four wire-format fields specific to the hybrid X25519+ML-KEM-768 / Ed25519+ML-DSA-65 combination:
ephemeral_x25519_pub // 32B — sender's ephemeral X25519 public
kem_ciphertext // 1088B — ML-KEM-768 ciphertext
sig_ed25519 // 64B — Ed25519 signature
sig_mldsa65 // 3309B — ML-DSA-65 signature
When the picker landed, the choice was either to add new fields for every primitive (clean but invasive) or to reuse the existing slots and let the suite metadata disambiguate (compact, slightly misleading names). pq-share went with reuse:
- ephemeral_x25519_pub
- Carries 32 B for X25519 KEX, 32 B for X25519MLKEM768 hybrid, 49 B for secp384r1 (compressed P-384 pub), or 0 B for ML-KEM-only suites.
- kem_ciphertext
- 1088 B for ML-KEM-768 (alone or in hybrid), 1568 B for ML-KEM-1024, or 0 B for classical-only KEX.
- sig_ed25519
- 64 B for Ed25519, 96 B for ECDSA-P384, 0 B for PQ-only signature suites. The slot name reflects history; today it's "the classical-position signature."
- sig_mldsa65
- 3309 B for ML-DSA-65, 4627 B for ML-DSA-87, 0 B for classical-only signature suites.
Backend validation enforces the exact byte count the suite
claims for each slot, refusing uploads where any field's length
doesn't match. This also catches the case where a malicious
client lies about the suite — an upload claiming
kex: "X25519" but providing 1088 bytes in
kem_ciphertext is rejected at the API boundary.
Schema metadata is captured in app/suites.py on
the backend (a Pydantic CryptoSuite model with
cross-field validators) and mirrored on the frontend in
picker.js. Adding a new primitive is two
definition-table updates and a length entry; the dispatch and
validation pick it up automatically.
threat model
End-to-end encryption is precise about what it protects and what it doesn't. Here's what pq-share does and doesn't shield against.
What's protected
- The file contents. Plaintext never leaves your browser. The server only ever holds AEAD ciphertext.
- The filename. Encrypted under the same per-file key as the body; revealed only to the recipient.
- Your private keys. Encrypted under
wrapKey(orrecoveryKey) before upload; the server holds only ciphertext. - Authenticity. Files are signed under your long-term keys; tampering with ciphertext or metadata invalidates the signatures. Recipients won't decrypt unverified content.
- Forward secrecy at the KEM layer. Each send uses a fresh KEM ephemeral; compromising one wrap doesn't compromise others.
- Transit confidentiality. All traffic to
pq-share.theqissilent.appruns over TLS 1.3 with the X25519MLKEM768 hybrid PQ KEX configured at the server. So the connection itself is already PQ-protected before the application-layer crypto kicks in.
What's not
- The metadata trail. The server sees who sent files to whom, when, the ciphertext sizes, and which suite each send used. If that traffic graph is sensitive, an E2E demo isn't the right tool.
- The browser as a TCB. Anything that runs JavaScript in the same origin can read keys and plaintext. Browser extensions, malicious bookmarklets, or a server compromise that injected a script tag would all defeat the end-to-end story. Content Security Policy helps but doesn't remove the trust.
- Long-term password compromise. The
wrapped private bundle is encrypted under
wrapKey, which is deterministic from the password and salt. There is no forward secrecy on the bundle. If the password leaks and the attacker has access to the server's data at any point, every past wrapping is compromised. - Loss of both password and recovery code. Unrecoverable. The server cannot help.
- Recipient identity. The recipient's email is plaintext on the upload (the server has to know who to notify). A surveillance graph is therefore visible to the operator.
The pq-share instance at
pq-share.theqissilent.app is run by the same
people who run this site. The threat model assumes a
cooperative operator: we won't try to inject scripts to
steal your keys, and our server logs are minimal. If your
threat model includes us, run your own instance — the source
is public and the dependencies are all open.
known limitations
The application is a working demo, not production infrastructure. Known gaps as of v0.2:
- ChaCha20-Poly1305 and AES-256-GCM-SIV are listed in the picker but disabled. WebCrypto exposes neither; a JS library has to be wired in. Until then, the symmetric category is effectively binary (AES-128-GCM vs AES-256-GCM).
- The TLS row is largely cosmetic. Selecting TLS 1.2 in the picker doesn't affect the actual browser-to-server handshake; it only constrains which primitives the picker will let you choose. Demonstrating TLS-layer differences for real would require multiple hostnames with distinct nginx configurations.
- File size limit: 100 MiB. The server rejects larger uploads. This is a sanity check, not a cryptographic constraint.
- No multi-device key sync. Each browser session unwraps its own copy of the bundle. If you log in on a new device, you re-derive the same keys from your password, but there is no cross-device messaging or shared state.
- No file expiry. Files sit on the server until either party explicitly deletes them.
- No groups (yet). Files can be addressed to multiple recipients on a send, but there's no notion of a named group you can re-use.
- The recovery code shown at signup is one-time. If you dismiss the screen without saving it, you cannot ask for it back. The server never sees it.
references
Specifications
- FIPS 203 — ML-KEM (formerly CRYSTALS-Kyber)
- FIPS 204 — ML-DSA (formerly CRYSTALS-Dilithium)
- FIPS 186-5 — Digital Signature Standard (ECDSA, EdDSA)
- RFC 7748 — Curve25519, X25519
- RFC 8032 — Ed25519
- RFC 5869 — HKDF
- draft-ietf-tls-hybrid-design — Hybrid PQ KEX in TLS 1.3
- CNSA 2.0 — NSA Commercial National Security Algorithm Suite v2
Libraries pq-share depends on
@noble/curves— X25519, P-384, Ed25519@noble/post-quantum— ML-KEM, ML-DSA@noble/hashes— SHA-2, SHA-3, HKDFhash-wasm— Argon2id- Browser-native:
SubtleCryptofor AES-GCM
Adjacent
- pq-share — what the scanners catch — Semgrep / CodeQL findings on this codebase, walked through one at a time
- Cloudflare on TLS PQ migration — context for hybrid KEX in TLS 1.3
- Chrome's PQ rollout — what shipped to users
- oqs-provider — OpenSSL provider for additional PQ algorithms