PQ Crypta’s Encrypt & Share is a zero-knowledge encrypted message sharing system built on a dual-KEM architecture. Every message is independently encapsulated under ML-KEM-1024 (lattice-based, FIPS 203, NIST Level 5) and HQC-256 (code-based, NIST 2025, NIST Level 5). These two algorithms are founded on entirely different mathematical hard problems — Module Learning With Errors (MLWE) for ML-KEM-1024 and the Quasi-Cyclic Moderate Density Parity Check (QC-MDPC) problem for HQC-256. An attacker must break both simultaneously to read any message. The 32-byte AES-256-GCM wrap key that unlocks the private keys exists only in your URL fragment — a browser mechanism that is never transmitted to any server. PQ Crypta stores ciphertext only. This paper describes the architecture, security model, and implementation of the system.
1. Origin and Motivation
Most “secure message” tools encrypt messages under a single algorithm — typically AES-256-GCM or ChaCha20-Poly1305 for symmetric encryption, with an RSA or ECDH key exchange. This is secure against classical computers today, but it provides no protection against a capable quantum adversary. Shor’s algorithm breaks RSA and ECDH in polynomial time on a sufficiently large quantum computer. AES-256 survives (Grover’s algorithm halves the key search space to 128-bit equivalent, still infeasible), but the key exchange that delivers the AES key does not.
NIST standardised three post-quantum algorithms in 2024: ML-KEM-1024 (key encapsulation, FIPS 203), ML-DSA-87 (signatures, FIPS 204), and SLH-DSA-SHA2-256s (hash-based signatures, FIPS 205). In 2025, NIST standardised HQC as a fourth algorithm — a code-based KEM specifically chosen as a backup to ML-KEM, designed for precisely the scenario where a structural weakness in lattice-based cryptography is discovered.
Encrypt & Share uses all four. ML-KEM-1024 and HQC-256 provide redundant key encapsulation from different mathematical families. ML-DSA-87 and SLH-DSA-SHA2-256s provide digital signatures. The system was built because no available encrypted message tool used this combination — tools that did use post-quantum cryptography used only one algorithm.
2. Why Dual-KEM?
Single-algorithm post-quantum systems carry a structural risk: if a mathematical breakthrough weakens the underlying hard problem, the entire security stack collapses. The history of cryptography is full of algorithms that were believed secure and later broken — including NIST-standardised ones (DUAL_EC_DRBG in 2013, SIKE in 2022).
- ML-KEM-1024: MLWE hard problem (lattice)
- HQC-256: QC-MDPC hard problem (code-based)
- Different mathematical families — independent security assumptions
- Both must be broken simultaneously
- A quantum breakthrough against lattices leaves HQC intact
- Both are NIST-standardised, NIST Level 5
- One algorithm — one security assumption
- Breaking the underlying problem breaks all messages
- Even ML-KEM-only systems: if MLWE is weakened, all traffic is exposed
- No algorithmic diversity or fallback
- Standard practice today, but unnecessary risk when dual-KEM is feasible
NIST explicitly designed HQC as a backup to ML-KEM for this reason: “to have a KEM that is not based on structured lattices in case ML-KEM is compromised.” Encrypt & Share operationalises NIST’s own recommended dual-KEM strategy.
3. Algorithm Suite
4. Key Properties
Encrypt Flow
Encryption is entirely client-side. The browser generates keypairs and performs all
cryptographic operations via the PQ Crypta API (api.pqcrypta.com). The server
only ever receives ciphertext and encrypted key blobs — it never sees plaintext or
the wrap key.
max-secure-pure-pq engine)
and HQC-256 keypair simultaneously via two parallel API calls to
POST /keys/generate.
crypto.getRandomValues(). This key will wrap both private keys
and will live only in the URL fragment.
POST /encrypt. This produces two independent ciphertexts.
POST /share/api.php (store action).
https://pqcrypta.com/share/?id=<UUID>#<wrapKey>:<metaMac>.
The wrap key and HMAC travel only in the fragment — never sent to any server.
URL Structure
The URL is the complete security boundary. Everything before the # can be
seen by the server. Everything after it is private to the browser.
Decrypt Flow
When a recipient opens the share URL, all decryption happens in the browser. The server provides only the opaque ciphertext envelope. The wrap key in the fragment unlocks everything — and the server never sees it.
POST /share/api.php (retrieve action) with the message ID.
Server returns the ciphertext envelope (or an error if burned/expired/not found).
POST /decrypt twice in parallel — once with the ML-KEM ciphertext
and recovered private key, once with the HQC ciphertext and its private key.
Versioned Envelope
Every message is stored in a versioned JSON envelope. The version field ensures forward compatibility: as the encryption format evolves, older links continue to decrypt correctly using the appropriate version handler.
ver: 2— envelope versionpq— ML-KEM-1024 ciphertext (base64)hqc— HQC-256 ciphertext (base64)pq_kb— ML-KEM key blob (wrapped keys)hqc_kb— HQC key blob (wrapped keys)mac— metadata HMAC hexexp— expiry hoursburn— burn-after-reading flag
ver: 1— envelope versionpq— ML-KEM-1024 ciphertext onlypq_kb— ML-KEM key blob onlymac— metadata HMAC hexexp— expiry hoursburn— burn-after-reading flag- HQC fields absent — backward compatible
Database Schema
The share_messages table is the only persistent store. It holds
the ciphertext envelope but nothing that can identify the message content or the
parties involved.
CREATE TABLE share_messages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), blob TEXT NOT NULL, -- JSON envelope (ciphertext only) key_blob TEXT NOT NULL DEFAULT '', -- AES-wrapped private keys algorithm VARCHAR(64) NOT NULL DEFAULT 'ml-kem-1024+hqc-256', created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, view_count INT DEFAULT 0, burn_after_read BOOLEAN DEFAULT FALSE, destroyed BOOLEAN DEFAULT FALSE, ip_hash TEXT, -- SHA-256 (ip + salt), not reversible message_size INT DEFAULT 0, short_code VARCHAR(12) UNIQUE, -- 6-char base-58 short link envelope_ver SMALLINT NOT NULL DEFAULT 1 ); -- Partial indexes: only live messages are queried CREATE INDEX idx_share_expires ON share_messages(expires_at) WHERE NOT destroyed; CREATE UNIQUE INDEX idx_share_short ON share_messages(short_code) WHERE short_code IS NOT NULL;
The blob column holds the JSON envelope. Without the wrap key from the URL
fragment, this data is computationally indistinguishable from random bytes. The
key_blob column holds the AES-wrapped private keys. Neither field contains
recoverable plaintext without the wrap key.
Zero-Knowledge Architecture
The defining security property of Encrypt & Share is zero-knowledge: the server holds ciphertext and cannot decrypt it, even with full administrative access and unlimited compute. This is not a policy claim — it is an architectural constraint.
#. The HTTP specification (RFC 7230) explicitly states that browsers must
not include the fragment in requests to the server. This is a fundamental browser
mechanism, not a configuration option. The wrap key appended after # is
processed only by JavaScript running in the browser. It never reaches any network
socket. It never appears in server logs, access logs, or database records.
This means:
- A subpoena to PQ Crypta produces only ciphertext.
- A server breach leaks only ciphertext.
- A database dump leaks only ciphertext.
- A network intercept of the API call leaks only ciphertext and the UUID lookup.
The only copy of the wrap key exists in the URL as held by the sender and recipient. If the URL is kept private, the message is private — against any adversary that does not have access to the sender’s or recipient’s device.
Algorithm Diversity
ML-KEM-1024 is based on the Module Learning With Errors (MLWE) problem — a variant of LWE with module structure, whose hardness assumption is the difficulty of distinguishing module lattice samples from random. HQC-256 is based on the Quasi-Cyclic Moderate Density Parity Check (QC-MDPC) problem — a code-based problem from information theory unrelated to lattices.
- Module Learning With Errors (MLWE)
- Security reduces to hardness of MLWE over module lattices
- Dimension: 1024-bit, module rank k=4
- Best known quantum attack: BKZ lattice sieving
- Classical: ~256-bit, quantum: ~256-bit (Grover does not apply)
- Standardised: FIPS 203 (2024)
- Quasi-Cyclic Moderate Density Parity Check (QC-MDPC)
- Security reduces to hardness of decoding random quasi-cyclic codes
- Different mathematical structure: algebraic coding theory
- Best known quantum attack: ISD (information set decoding)
- Specifically selected by NIST as ML-KEM fallback for algorithm diversity
- Standardised: NIST 2025 (HQC)
An attack that breaks ML-KEM-1024 must exploit the MLWE structure — this gives zero information about HQC-256’s QC-MDPC structure, and vice versa. The two security assumptions are mathematically independent. Both must fail simultaneously for any message in the system to be readable.
Length-Hiding Padding
Without padding, ciphertext length leaks message length. An adversary observing traffic can distinguish a “password” from a “3,000-word legal document” by their ciphertext sizes. This is a traffic analysis attack that survives end-to-end encryption.
Encrypt & Share pads all messages to a fixed 4,096-byte block boundary before encryption. The padding consists of cryptographically random bytes appended to the UTF-8 encoded message. A separator byte marks the boundary between content and padding to enable reliable unpadding. A 10-character note and a 3,000-character letter produce identically-sized ciphertexts (subject to the block-size multiple).
Tamper-Evident Metadata
Message metadata (expiry hours, burn-after-reading flag, envelope version) is stored in the database alongside the ciphertext. Without protection, a malicious server operator or attacker with database access could modify this metadata — for example, disabling the burn-after-reading flag to read a message multiple times, or extending the expiry of a message that should have been destroyed.
Encrypt & Share protects against this with an HMAC-SHA-256 signature over the metadata, using a 32-byte key derived from the wrap key via a key derivation step. This MAC travels in the URL fragment alongside the wrap key. On decryption, the browser recomputes the MAC and compares it against the metadata in the retrieved envelope before proceeding with decryption. Any mismatch aborts the operation.
HMAC-SHA-256(wrapKey, "meta-hmac-v1"), producing a separate 32-byte key.
This ensures the MAC key and encryption key are distinct — a standard domain
separation practice.
Threat Model
Cross-Verification
Cross-verification is the final layer of integrity checking. After both ciphertexts are decrypted independently by their respective algorithms, the resulting plaintexts are compared byte-for-byte. Correct dual encryption of the same plaintext under two independent algorithms must produce identical plaintexts on decryption.
If the plaintexts differ, one of the following has occurred: (1) the server has replaced or corrupted one ciphertext; (2) the private keys have been tampered with such that only one decryption path succeeds; or (3) an implementation bug exists. In all cases, the mismatch is surfaced to the user and decryption is aborted. This provides an active tamper detection mechanism beyond what AES-GCM authentication tags alone provide.
Client-Side Cryptography
All cryptographic operations are performed client-side in the browser via the PQ Crypta
API. The JavaScript client (/share/js/share.js) is a self-contained IIFE
with no external dependencies beyond the platform’s Web Crypto API and the PQ Crypta
REST API.
crypto.getRandomValues()— wrap key generationcrypto.subtle.importKey()— wrap key importcrypto.subtle.encrypt()— AES-256-GCM private key wrapcrypto.subtle.decrypt()— AES-256-GCM private key unwrapcrypto.subtle.sign()— HMAC-SHA-256 metadata MACcrypto.subtle.verify()— HMAC verificationcrypto.subtle.deriveBits()— HMAC key derivation
POST /keys/generate— ML-KEM-1024 + HQC-256 keypairsPOST /encrypt— post-quantum encryption of padded plaintextPOST /decrypt— post-quantum decryption of ciphertext- All heavy-duty PQC operations (multi-hundred KB public keys)
- Algorithm-specific key serialisation and formatting
The wrap key and all decrypted key material exist only in JavaScript variables for
the duration of the operation. They are not stored in localStorage,
sessionStorage, cookies, or any persistent browser storage.
API Layer
The PQ Crypta API runs at api.pqcrypta.com on port 3003. It is a
Rust-based HTTP server providing key generation, encryption, and decryption endpoints
for all 31 supported post-quantum algorithms. The max-secure-pure-pq
engine handles ML-KEM-1024 (combined with ML-DSA-87 and SLH-DSA-SHA2-256s for the
signature layer), and hqc-256 handles the code-based KEM.
The share page uses API key authentication via the X-API-Key header,
loaded from /api_config.php at runtime. The API enforces granular
permissions per endpoint per key.
Server-Side Storage
/share/api.php handles four actions over a PostgreSQL database:
- Validates base64 blob, key_blob, algorithm, envelope_ver
- Rejects blobs over 256 KB (prevents abuse)
- Generates short code (6-char base-58, 10 collision-avoidance attempts)
- SHA-256 hashes the IP address with a per-service salt
- Inserts row; returns UUID
id+short_code
- Accepts UUID
idor short codes - Verifies message exists, is not expired, is not destroyed
- Increments
view_count - If
burn_after_read: immediately sets destroyed=true, blob='' - Returns blob + key_blob + envelope metadata
- Accepts UUID
id - Sets
destroyed=true,blob='',key_blob='' - Prevents any further retrieval of the message
- Used by the “Destroy this message now” button
- Returns row count and recent message counts
- Used by internal health check monitoring
- Triggers cleanup of expired messages on every call
Short Links
Every encrypted message receives a 6-character base-58 short code in addition to its
full UUID URL. The alphabet excludes visually ambiguous characters (0, O, I, l) to
minimise transcription errors:
23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz.
The short link form is pqcrypta.com/share/?s=Ab3Xk9#wrapKey:metaMac.
The server resolves the short code to a UUID internally. The wrap key fragment
is never included in the short code and never sent to the server as part of
the short-code lookup. It is always appended to the full resolved URL by the browser.
Expiry, Burn-After-Reading, and Client-Side Timers
Server-Side Expiry
Eight expiry durations are available at message creation:
Expiry is enforced at two independent levels: the expires_at column is
checked on every retrieve attempt and returns an error immediately on any expired
message, and a cleanup sweep runs on every API call to set destroyed=true
and clear blob='' on all rows where expires_at < NOW().
Expiry hours are stored in the envelope and signed by the metadata HMAC — the server
cannot silently extend a link’s lifetime without breaking the MAC.
Burn-After-Reading
Burn-after-reading is implemented atomically within the retrieve operation: the
destroyed flag is set and blob is cleared in the same
database update that returns the row to the client. A race condition between two
simultaneous retrieve requests for the same burn-message is resolved by the
PostgreSQL row-level update: only the first succeeds; the second receives an
empty blob and an error response.
Client-Side Security Timers
Two independent JavaScript timers run in the browser to minimise the window during which sensitive content is visible on screen. Neither timer is negotiable or bypassable through the URL — they are hardcoded security constants.
- Starts immediately when the share link is displayed after encryption
- Counts down unconditionally — no reset on activity
- At 10 seconds remaining: badge turns urgent (red)
- At 0: link view is cleared from the DOM and compose view is restored
- Purpose: limits the window a share URL is visible on an unattended screen
- Constant:
RESULT_TIMEOUT_SEC = 60
- Starts when the decrypted message is displayed
- Resets on any user activity:
mousemove,mousedown,keydown,scroll,touchstart,click - Badge appears when ≤30 seconds of idle remain
- At 10 seconds remaining: badge turns urgent (red)
- At 0: decrypted message is cleared from the DOM
- Purpose: protects against shoulder-surfing on an unattended device
- Constants:
DECRYPT_IDLE_SEC = 120,DECRYPT_SHOW_AT_SEC = 30
CSP and No-Leak Design
The share page enforces a strict Content Security Policy that blocks inline scripts,
inline styles, and event handler attributes. All scripts are loaded as external files
with nonces. The connect-src directive limits API calls to
api.pqcrypta.com. The frame-ancestors 'none' directive
prevents the page from being loaded inside an iframe, protecting against clickjacking.
A Referrer-Policy: no-referrer header ensures the share URL (including
any cached URL that might contain the fragment) is not sent as a Referer to any
external resource.
The decrypted message content is inserted into the DOM using
textContent, not innerHTML — eliminating any XSS
vector from the message payload. No user-supplied content ever reaches
innerHTML, eval(), or any equivalent execution sink.