guide · pq-share v0.2 · ~25 minute read

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.

§ 01

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.

conventions

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.

§ 02

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.

§ 03

signup & the recovery code

Signup happens entirely in your browser, with one HTTP POST at the end. Concretely:

  1. You enter an email and a password (minimum 12 characters).
  2. 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.
  3. 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-byte authSecret (sent to the server as the password proof).
  4. The browser runs Argon2id over the recovery code to derive a parallel recoveryKey — independently of the password.
  5. The browser generates the eight keypairs described above.
  6. The private keys are concatenated into a versioned bundle and encrypted twice: once under wrapKey (wrapped_priv_password), once under recoveryKey (wrapped_priv_recovery). Either key can later unlock the same bundle.
  7. The browser also encrypts recoveryKey itself under wrapKey (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.
  8. One POST to /api/auth/signup sends: email, salts, KDF parameters, the eight public keys, the two wrapped bundles, the wrapped recovery key, and authSecret.
  9. 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.

irreversible

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.

§ 04

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:

sender (you) server recipient │ │ │ │ pick recipient(s) and file │ │ │ pick cipher suite │ │ │ │ │ │ GET /api/users/lookup?email=… │ │ │ ─────────────────────────────────────────► │ │ │ ◄───────────────── 8 public keys per user ─┤ │ │ │ │ │ generate fileKey │ │ │ (16B for AES-128, 32B for AES-256) │ │ │ ciphertext = AEAD(fileKey, plaintext) │ │ │ filename_enc = AEAD(fileKey, filename) │ │ │ │ │ │ for each recipient r: │ │ │ KEX.encapsulate(r.pubs) │ │ │ → wrapKey, ephemeral material │ │ │ wrapped_key = AEAD(wrapKey, fileKey) │ │ │ │ │ │ transcript = digest(suite.hash, │ │ │ ciphertext) │ │ │ || metadata_json │ │ │ sigs = sign(transcript, your privates) │ │ │ │ │ │ POST /api/files │ │ │ ┌── blob: ciphertext │ │ │ └── meta: filename_enc, metadata_json, │ │ │ sigs, recipients[], │ │ │ suite │ │ │ ─────────────────────────────────────────► │ │ │ │ email notification ────►│ │ │ │ │ │ GET /api/files/:id/meta │ │ │ ◄────────────────────────┤ │ │ GET /api/files/:id/blob │ │ │ ◄────────────────────────┤ │ │ │ │ │ verify sig ✓ │ │ │ KEX.decapsulate ✓ │ │ │ unwrap fileKey ✓ │ │ │ decrypt → save to disk │ │ │ │

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.

§ 05

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.

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.

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.

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.

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.

§ 06

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:

  1. 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.
  2. Fetches the ciphertext blob (GET /api/files/:id/blob).
  3. Looks up the dispatch operations for the suite. If suite is null on the metadata (legacy uploads from before the picker existed), the client falls back to a built-in LEGACY_SUITE that matches what the original hardcoded build did.
  4. 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.
  5. Decapsulates the KEM material under your relevant private key (x25519Priv, mlkem1024Priv, whichever the suite called for) to derive the wrap key.
  6. Unwraps fileKey from wrapped_key.
  7. Decrypts the ciphertext and the encrypted filename with fileKey.
  8. 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.

§ 07

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.

§ 08

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

What's not

on 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.

§ 09

known limitations

The application is a working demo, not production infrastructure. Known gaps as of v0.2:

§ 10

references

Specifications

Libraries pq-share depends on

Adjacent