AUTONOMY DIRECTORATE

🏠 Main

🧪 Interactive Apps

📰 News

🛡️ PQ Crypta Proxy

👤 Account

⟨ QUANTUM ERROR PORTAL ⟩

Navigate the Error Dimensions

PQ Crypta Logo

Encrypt & Share — Dual-KEM Zero-Knowledge Architecture

How PQ Crypta delivers quantum-safe encrypted message links: two independent NIST Level 5 algorithms, a key that never leaves your browser, and ciphertext that self-destructs.

ML-KEM-1024 + HQC-256 Zero-Knowledge Dual-KEM Allan Riddel · PQ Crypta · April 2026
Abstract

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 master wrap key exists only in your URL fragment — a browser mechanism that is never transmitted to any server. Three domain-separated sub-keys are derived from it via HKDF: one for AES-256-GCM private-key wrapping, one for HMAC-SHA-256 metadata signing, and one for authenticated message destroy. An optional passphrase layer (AES-256-GCM keyed from PBKDF2-SHA-256) can be applied before the dual-KEM envelope, ensuring URL disclosure alone cannot decrypt the message. A preview hash fingerprint (SHA-256[:8]) embedded in the URL lets the recipient verify the server returned the correct message. An in-memory link health check verifies the full decrypt roundtrip before the share URL is presented. A client-side integrity badge displays the outcome of every verification layer after decryption, and a self-audit mode surfaces the exact cryptographic parameters on demand. PQ Crypta stores ciphertext only. This paper describes the architecture, security model, and implementation of the v3 system.

🔐
Try it now — no account required This whitepaper describes the live system at pqcrypta.com/share/. Send a quantum-safe encrypted message in seconds.
Encrypt & Share
The harvest-now-decrypt-later problem: Nation-state actors are already intercepting and archiving today’s TLS-encrypted traffic, waiting for quantum computers capable of breaking RSA and ECDH. Sensitive messages shared today with classical encryption tools can be decrypted in the future. Encrypt & Share was designed from the ground up for exactly this threat model.

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

Dual-KEM (This System)
  • 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
Single-KEM Systems
  • 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
Why it matters “Algorithmic monocultures fail catastrophically; diversity fails gracefully.”

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. Dual-KEM means the attacker must break two unrelated mathematical problems at once — a requirement so extreme that it exceeds any known quantum or classical attack model.

3. Algorithm Suite

Algorithm Standard Role Level Quantum Threat
ML-KEM-1024 FIPS 203 Key Encapsulation (lattice) Level 5 Resistant ✓
HQC-256 NIST 2025 Key Encapsulation (code-based) Level 5 Resistant ✓
ML-DSA-87 FIPS 204 Envelope Authenticity (API response signing) Level 5 Resistant ✓
SLH-DSA-SHA2-256s FIPS 205 Envelope Authenticity, Hash-Based (API response signing) Level 5 Resistant ✓
AES-256-GCM FIPS 197 Private Key Wrap (URL fragment only) 256-bit Halved by Grover*
HMAC-SHA-256 FIPS 198-1 Metadata Integrity (HKDF-derived key) 256-bit Resistant ✓
HKDF-SHA-256 RFC 5869 Key Derivation (AES / HMAC / Destroy sub-keys) 256-bit Resistant ✓
* AES-256-GCM is used solely to wrap the ML-KEM and HQC private keys inside the URL fragment. It is never used to encrypt your message. The AES key is HKDF-derived from the master wrap key (context: pqcrypta-share-v3-aes-wrap). Grover's algorithm reduces AES-256 to a 128-bit equivalent security level — still computationally infeasible with any foreseeable quantum hardware.

4. Key Properties

🗝️
Zero-Knowledge
The wrap key exists only in the URL fragment. Fragments are never sent to any server by design. PQ Crypta cannot read your message under any circumstances.
🧬
Algorithm Diversity
ML-KEM-1024 (MLWE) and HQC-256 (QC-MDPC) are mathematically independent. Breaking one does not help break the other.
📏
Length-Hiding Padding
All messages are padded to a fixed 4 KB block boundary before encryption. A 5-word note and a 3,000-word letter produce identically-sized ciphertext.
🔏
Tamper-Evident Metadata
Expiry, burn-after-reading flag, and envelope version are HMAC-SHA-256 signed with a key derived from the wrap key. Any server-side tampering is detected.
🔥
Burn After Reading
The server permanently destroys the ciphertext immediately after the first retrieval, making the link invalid for all subsequent access attempts.
📦
Versioned Envelope
Each message is wrapped in a versioned envelope (v3: dual-KEM + HKDF). Older link formats (v1, v2) still decrypt correctly — backward compatible.
🔑
HKDF Key Separation
Three domain-separated 32-byte sub-keys are derived from the master wrap key via HKDF-SHA-256: AES wrap key, HMAC signing key, and destroy auth key. No key material is shared between operations.
🛡️
Authenticated Destroy
Destroying a message requires proof-of-knowledge of the wrap key. The server stores only a SHA-256 hash of the HKDF-derived destroy key. UUID discovery alone cannot destroy your message.
🔗
Short Links
A 6-character base-58 short code provides compact sharable links. The wrap key fragment is never included in the short code — only the UUID lookup.
🚫
No Account Required
No registration, no email, no tracking. IP addresses are one-way SHA-256 hashed with a per-service salt before storage — not reversible.
🔑
Passphrase Layer
Optional AES-256-GCM(PBKDF2-SHA-256, 200k iterations) encryption of the plaintext before dual-KEM. Even full URL disclosure cannot decrypt without the passphrase — a second credential shared out-of-band.
Integrity Badge
After decryption, every verification outcome is displayed: dual-KEM cross-verify, HMAC signature, padding, envelope version, passphrase layer, and preview hash. No black-box trust required.
🔎
Preview Hash Fingerprint
SHA-256[:8] of the plaintext computed before encryption, embedded in the URL fragment. On decrypt, recomputed and compared — detects a server substituting a different message for the recipient.
🩺
Link Health Check
Full in-memory decrypt roundtrip immediately after encryption. No server call, no burn risk. Catches URL encoding bugs, corrupted ciphertexts, and API regressions before the link is shared.
🔍
Self-Audit Mode
A toggle on the decrypt view surfaces the exact cryptographic parameters: envelope version, KEM names, HKDF context strings, padding block size, passphrase status, and truncated HMAC signature. For independent verification without reading source code.

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.

Encrypt & Share — Full Encryption Flow
1
Key Generation (parallel). Browser generates ML-KEM-1024 keypair (via max-secure-pure-pq engine) and HQC-256 keypair simultaneously via two parallel API calls to POST /keys/generate.
2
Master Wrap Key + HKDF Sub-key Derivation. Browser generates a 32-byte random master wrap key using crypto.getRandomValues(). Three domain-separated sub-keys are immediately derived via HKDF-SHA-256: an AES wrap key (pqcrypta-share-v3-aes-wrap), an HMAC signing key (pqcrypta-share-v3-meta-sig), and a destroy auth key (pqcrypta-share-v3-destroy). Only the master key travels in the URL — sub-keys are always re-derived from it.
3
Preview Hash + Optional Passphrase Layer. Browser computes SHA-256(plaintext)[:8] as an 8-hex-char fingerprint. If a passphrase is set, the plaintext is encrypted with AES-256-GCM keyed from PBKDF2-SHA-256(passphrase, randomSalt, 200,000 iterations) and prepended with the pp1: magic prefix. This wrapped blob replaces the plaintext for all subsequent steps. URL theft alone cannot decrypt without the passphrase.
4
Plaintext Padding. The (optionally passphrase-wrapped) message is UTF-8 encoded and padded to a fixed 4 KB (4096-byte) block boundary using random bytes. This ensures ciphertext length reveals nothing about message length.
5
Dual Encryption (parallel). The padded plaintext is encrypted under ML-KEM-1024 and HQC-256 simultaneously via two parallel calls to POST /encrypt. This produces two independent ciphertexts.
6
Private Key Wrapping (HKDF-derived AES key). Both private keys (ML-KEM and HQC) are individually wrapped with AES-256-GCM using the HKDF-derived AES sub-key and unique random 96-bit IVs. The AES sub-key is distinct from the HMAC and destroy sub-keys — domain separation via HKDF context strings.
7
Metadata HMAC + Destroy Auth Hash. An HMAC-SHA-256 signature is computed over the serialised metadata (message ID, expiry, burn flag, envelope version) using the HKDF-derived HMAC sub-key. Additionally, the SHA-256 hash of the HKDF-derived destroy key is computed. Both are sent to the server; the server stores the destroy hash only — the destroy key (pre-image) stays in the browser.
8
Versioned Envelope Assembly. A v3 JSON envelope is assembled containing both ciphertexts and both AES-wrapped key blobs. The envelope plus the destroy auth hash are sent to POST /share/api.php (store action).
9
URL Construction. The server returns a UUID message ID and short code. The browser constructs the share URL as #<UUID>:<masterWrapKey>:<metaSig>:<previewHash>. All four values travel only in the URL fragment — never sent to any server. The preview hash lets the recipient verify the server returned the correct message.
10
Link Health Check. Before the share URL is shown to the sender, the browser performs a full in-memory decrypt roundtrip: it re-derives private keys from the already-held key blobs, calls POST /decrypt twice in parallel with the known ciphertexts, cross-verifies both plaintexts, and confirms they equal the original message. No retrieve call is made — the burn counter is never incremented. If the check fails, the sender sees a warning before sharing the link.

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.

Share URL Anatomy
https://pqcrypta.com/share/?id= 550e8400-e29b-41d4-a716-446655440000 # Zm9vYmFyYmF6cXV4cXV4cXV4cXV4cQ==:a1b2c3d4e5f6a1b2:3f9c2a1e
?id=<UUID> Server-side lookup key. Sent to server on GET request. Used to retrieve the ciphertext envelope from the database.
?s=<code> Alternate short link form. 6-character base-58 code. Server resolves to UUID and redirects (fragment preserved). Never sent as plaintext.
#wrapKey 32-byte AES-256-GCM wrap key, base64url-encoded. Used to unwrap both ML-KEM and HQC private keys from the key blobs. Never transmitted to any server.
:metaSig HMAC-SHA-256 over metadata (ID, expiry, burn flag, envelope version), signed with the HKDF-derived HMAC sub-key (pqcrypta-share-v3-meta-sig). Verified before decryption begins — any server-side metadata tampering is detected.
:previewHash First 8 hex characters of SHA-256(plaintext), computed before encryption. On decrypt, recomputed against the decrypted message and compared. Detects a server returning a different message envelope than the one the sender encrypted.

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.

Decrypt Flow — Full Sequence
1
URL Parse. Browser extracts message ID from query string and wrap key + HMAC from URL fragment. Fragment is never sent to the server.
2
Fetch Envelope. Browser calls POST /share/api.php (retrieve action) with the message ID. Server returns the ciphertext envelope (or an error if burned/expired/not found).
3
HKDF Sub-key Re-derivation + Metadata Verification. Browser derives three sub-keys from the master wrap key via HKDF-SHA-256. Using the HMAC sub-key, it recomputes the metadata signature and compares against the value in the URL. Any mismatch indicates server-side tampering and decryption is aborted immediately.
4
Key Unwrap (HKDF-derived AES key). Browser decrypts both key blobs with AES-256-GCM using the HKDF-derived AES sub-key. Recovers the ML-KEM-1024 private key and the HQC-256 private key. The AES sub-key is domain-separated from the HMAC and destroy keys.
5
Dual Decryption (parallel). Browser calls POST /decrypt twice in parallel — once with the ML-KEM ciphertext and recovered private key, once with the HQC ciphertext and its private key.
6
Cross-Verification. Browser compares the two decrypted plaintexts byte-for-byte. If they differ, decryption is aborted — indicating ciphertext tampering on the server side.
7
Unpad + Passphrase Layer Detect. Padding bytes are stripped. If the result begins with pp1:, the browser prompts for a passphrase, derives the PBKDF2 key, and AES-GCM decrypts the inner blob. This step is transparent to the dual-KEM — the passphrase layer is unwrapped after cross-verification, not before.
8
Preview Hash Verification. Browser recomputes SHA-256(plaintext)[:8] and compares against the value in the URL fragment. A match confirms the server returned exactly the message the sender encrypted. A mismatch is flagged in the integrity badge.
9
Display + Integrity Badge + Self-Audit. The message is inserted into the DOM via textContent (no XSS risk). The integrity badge renders all verification outcomes: HMAC, cross-verify, padding, envelope version, passphrase layer, and preview hash. The self-audit toggle lets the user inspect the exact cryptographic parameters used: HKDF contexts, KEM names, padding block, and truncated HMAC signature.

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.

v3 Envelope (Dual-KEM + HKDF, current)
  • blob = "v3:<base64(JSON({pq,hq}))>"
  • pq — ML-KEM-1024 ciphertext (base64)
  • hq — HQC-256 ciphertext (base64)
  • Key blobs packed separately in key_blob column
  • AES sub-key: HKDF(master, "pqcrypta-share-v3-aes-wrap")
  • HMAC sub-key: HKDF(master, "pqcrypta-share-v3-meta-sig")
  • Destroy key: HKDF(master, "pqcrypta-share-v3-destroy")
  • Destroy auth hash: SHA-256(destroyKey) stored server-side
v2 Envelope (Dual-KEM, raw keys — legacy)
  • blob = "v2:<base64(JSON({pq,hq}))>"
  • Same dual-KEM ciphertext format as v3
  • AES and HMAC keys: raw wrapKeyBytes (no HKDF)
  • No authenticated destroy support
  • Fully backward compatible — decrypts correctly
v1 Envelope (single-KEM, legacy)
  • blob = "v1:<base64_ciphertext>"
  • ML-KEM-1024 only — no HQC
  • Raw wrap key (no HKDF)
  • Fully backward compatible — decrypts correctly
Key Protocol Summary
  • v3: HKDF domain-separated sub-keys — current
  • v2: raw wrapKeyBytes for both AES and HMAC — legacy, still decryptable
  • v1/v0: single-KEM, raw keys — oldest legacy, still decryptable
  • Envelope version is verified during MetaSig before any decryption

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,
    destroy_auth_hash VARCHAR(64)              -- SHA-256(HKDF destroy key) — null on v1/v2 legacy
);

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

⚛️
See the architecture in action Open DevTools on pqcrypta.com/share/, send a message, and watch the wrap key appear only in the URL fragment — never in any network request.
Open App

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 URL Fragment Guarantee: RFC 3986 defines the fragment identifier as the component of a URL that begins with #. 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.

ML-KEM-1024 Security Basis
  • 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)
HQC-256 Security Basis
  • 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).

4 KB
Fixed block boundary
10,000
Max plaintext chars
500 KB
Max ciphertext blob
≡0
Length information leaked
Independent ciphertexts per message

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.

HKDF domain separation (v3): All sub-keys are derived via HKDF-SHA-256 using crypto.subtle.deriveBits() with a zero salt and context-specific info strings. The three derived keys are:
  • pqcrypta-share-v3-aes-wrap → AES-256-GCM private-key wrapping
  • pqcrypta-share-v3-meta-sig → HMAC-SHA-256 metadata signing
  • pqcrypta-share-v3-destroy → destroy auth pre-image
Each sub-key is computationally independent of the others. Compromise of one sub-key does not assist in deriving the master wrap key or any other sub-key.

Threat Model

Threat Status Mechanism
Classical computer attacks (RSA/ECDH break) Mitigated ✓ No classical key exchange used. ML-KEM + HQC only.
Harvest-now-decrypt-later (HNDL) Mitigated ✓ Dual NIST Level 5 PQC. Both must be broken simultaneously.
Server-side breach / database dump Mitigated ✓ Zero-knowledge: wrap key never transmitted. DB holds ciphertext only.
Server-side metadata tampering Mitigated ✓ HMAC-SHA-256 over metadata verified client-side before decryption.
Ciphertext tampering (one algorithm) Mitigated ✓ Cross-verification: both plaintexts must match. Mismatch aborts.
Traffic analysis (message length) Mitigated ✓ 4 KB block padding: all ciphertexts are same size modulo block.
Re-use of one-time link Mitigated ✓ Burn-after-reading: ciphertext wiped immediately after first retrieve.
Man-in-the-middle (TLS layer) Mitigated ✓ Fragment never sent over any transport. TLS intercept gets ciphertext only.
UUID-only message destruction (abuse) Mitigated ✓ Authenticated destroy (v3): server requires SHA-256 pre-image proof. UUID alone cannot destroy a message.
Server substituting a different message for the recipient Detected ✓ Preview hash (SHA-256[:8]) computed by sender, embedded in URL, recomputed on decrypt — mismatch surfaces in integrity badge.
URL channel compromise (URL observed by attacker) Mitigated (opt-in) ✓ Passphrase layer: AES-256-GCM(PBKDF2) before dual-KEM. URL theft alone insufficient. 5-attempt brute-force limit: message destroyed server-side on failure.
URL shared with wrong recipient User responsibility The URL is the credential. Use passphrase layer for additional protection. Handle it like a password.
Compromised recipient device Out of scope Endpoint security is outside any cryptographic system’s scope.

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.

Passphrase Layer

The passphrase layer is an optional second encryption pass applied to the plaintext before it enters the dual-KEM envelope. It addresses a specific threat: URL channel compromise. If the share URL is observed by an attacker — through URL shortener logs, browser history, or a compromised communication channel — the wrap key in the fragment is exposed. Without the passphrase layer, the wrap key is sufficient to decrypt. With it, the attacker still needs a second credential.

Passphrase layer cryptographic details:
  • Key derivation: PBKDF2-SHA-256(passphrase, randomSalt, 200,000 iterations) → 256-bit AES key
  • Encryption: AES-256-GCM with random 96-bit IV
  • Output prefix: pp1: magic byte sequence (signals passphrase layer to recipient)
  • Salt and IV are stored in the wrapped blob (base64-encoded JSON), never server-side
  • The wrapped blob replaces the plaintext for all subsequent steps (padding, dual-KEM)
  • The passphrase itself never appears in the URL, the envelope, or any network request
  • 200,000 PBKDF2 iterations: ~200ms on modern hardware, brute-force infeasible

On decryption, the browser checks for the pp1: prefix after dual-KEM unwrap and before displaying the message. If found, it prompts the recipient for the passphrase. The passphrase layer is transparent to the dual-KEM — it operates on a layer above it. A recipient without the passphrase sees a prompt, not an error, and cannot proceed.

Brute-force protection — destroy on failure: The browser enforces a hard limit of 5 passphrase attempts. On the fifth wrong attempt, the browser calls the authenticated destroy endpoint (POST /share/api.php destroy action) using the HKDF-derived destroy key already in memory. The server verifies SHA-256(destroy_token) == stored destroy_auth_hash and permanently wipes the ciphertext. Reloading the URL returns a not-found error — the message is irrecoverable. This counter is client-enforced but the destruction is server-side and irreversible, so reload or DevTools manipulation cannot bypass it once the limit is reached. Senders should ensure recipients know the passphrase before sharing the link.

Preview Hash Fingerprint

Before encryption, the browser computes SHA-256(UTF-8(plaintext)) and takes the first 8 hex characters. This fingerprint is embedded in the URL fragment as the fourth colon-separated component, alongside the wrap key, HMAC signature, and is signed implicitly by the HMAC sub-key derivation. On decryption, the browser recomputes it from the decrypted plaintext and compares against the URL value.

The preview hash protects against a specific attack not covered by cross-verification: a malicious server returning a completely different, validly-structured envelope to the recipient. Cross-verification detects tampering within one pair of ciphertexts. The preview hash detects substitution of the entire envelope. The sender can also communicate the fingerprint out-of-band (“expect fingerprint a3f9c2b1”) as an additional assurance channel.

Integrity Badge

After every successful decryption, the browser renders an integrity badge displaying the outcome of each verification step performed during that session. The badge is constructed entirely from local verification results — no server input. Each item is one of three states: passed (green), failed (red, decryption aborted), or not applicable (grey, feature absent in this link version).

Integrity Badge Items
  • Dual-KEM cross-verified — both plaintexts matched byte-for-byte
  • Metadata signature verified — HMAC over expiry/burn/version matched URL
  • Padding integrity verified — padding block present and parseable
  • Envelope vN verified — version field matches expected format
  • Passphrase layer decrypted — PBKDF2/AES-GCM succeeded (if applicable)
  • Message fingerprint matched — SHA-256[:8] of plaintext matched URL value
Failure Behaviour
  • Any red item means decryption was aborted before message display
  • HMAC failure: metadata tampered — abort immediately
  • Cross-verify failure: ciphertext substitution — abort
  • Fingerprint mismatch: envelope substitution by server — flagged
  • Passphrase failure: wrong passphrase — prompts retry
  • Grey items: feature absent in this link (legacy envelope or no passphrase set)

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. An API key is loaded at startup from /api_config.php (same-origin) and attached to every PQC API request via the X-API-Key header.

Cryptographic Operations (Browser)
  • crypto.getRandomValues(32B) — master wrap key generation
  • crypto.getRandomValues(12B) — AES-GCM IV generation (per wrap)
  • crypto.subtle.importKey('raw', key, 'HKDF') — import wrap key as HKDF key material
  • crypto.subtle.deriveBits('HKDF', ...) — derive AES, HMAC, and destroy sub-keys (one call each)
  • crypto.subtle.importKey('raw', key, 'AES-GCM') — import derived AES key (encrypt + decrypt, once each)
  • crypto.subtle.encrypt('AES-GCM') — wrap both private keys
  • crypto.subtle.decrypt('AES-GCM') — unwrap both private keys
  • crypto.subtle.importKey('raw', key, 'HMAC') — import derived HMAC key (sign + verify, once each)
  • crypto.subtle.sign('HMAC') — metadata signature
  • crypto.subtle.verify('HMAC') — metadata verification
  • crypto.subtle.digest('SHA-256') — destroy auth hash
Delegated to PQ Crypta API
  • POST /keys/generate ×2 — separate calls for max-secure-pure-pq (ML-KEM-1024) and hqc-256 (HQC-256), run in parallel
  • POST /encrypt ×2 — one call per algorithm, run in parallel
  • POST /decrypt ×2 — one call per algorithm, run in parallel; results cross-verified
  • 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; ML-DSA-87 and SLH-DSA-SHA2-256s authenticate the API response envelope — they prove the encryption came from a valid PQ Crypta API instance, not a tampered intermediary. They do not sign the user’s plaintext. 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:

store action
  • Validates base64 blob, key_blob, algorithm, envelope_ver
  • Rejects blobs over 500 KB (prevents abuse)
  • Rate-limited: max 20 stores per IP per hour
  • Stores destroy_auth_hash (SHA-256 of HKDF destroy key)
  • 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
retrieve action
  • Accepts UUID id or short code s
  • Rate-limited: max 60 retrievals per IP per minute
  • 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
destroy action (authenticated)
  • Accepts UUID id + optional destroy_token
  • v3 messages: requires destroy_token (HKDF pre-image)
  • Server verifies SHA-256(destroy_token) == stored destroy_auth_hash
  • Uses hash_equals() — constant-time comparison, no timing leaks
  • v1/v2 legacy messages: destroy allowed without token (no hash stored)
  • Sets destroyed=true, blob=''
retrieve (GET)
  • Accepts ?id=<uuid> or ?short=<code>
  • Rate-limited: max 60 retrieves per IP per minute
  • If burn-after-read: atomically destroys row and returns data in same response
  • Returns blob, key_blob, algorithm, envelope_ver, burn, expires_at
  • Stochastic cleanup (5% of requests) purges expired rows

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

Expiry is set using three independent dropdowns — days (0–7), hours (0–23), and minutes (0–59) — giving granular control over any duration from 1 minute to 7 days. When days is set to 7 (the maximum), hours and minutes are automatically zeroed and locked. The default is 1 hour. The server validates that the computed total falls within the allowed range before storing the message.

Constraint Value Enforcement Server action at expiry Link state
Minimum 1 minute Client UI + server validation blob wiped ✓ Invalid
Default 1 hour Pre-selected on page load blob wiped ✓ Invalid
Maximum 7 days (168 h) Client UI locks hrs/min at days=7 blob wiped ✓ Invalid

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.

Result View — Hard 60s Countdown
  • 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
Decrypt View — 120s Idle Countdown
  • 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
Two separate timeout layers: The server-side expiry controls how long the ciphertext exists in the database (1 minute to 7 days). The client-side timers control how long plaintext is visible on screen after decryption (max 120s idle, or 60s hard on the link view). They are independent — a 7-day link still clears the decrypted message from the screen after 120 seconds of inactivity.
1min–7d
Granular expiry range
60s
Hard link-view timeout
120s
Idle decrypt timeout
1
Max reads (burn mode)

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.

Link Health Check

After step 10 of the encrypt flow, and before the share URL is shown to the sender, the browser runs a full in-memory roundtrip verification using only key material already held in JavaScript variables from the encryption just completed.

What the health check verifies
  • AES-unwraps both private keys from the key blobs already in memory
  • Calls POST /decrypt twice in parallel (ML-KEM + HQC)
  • Strips padding from both decrypted outputs
  • Cross-verifies: both plaintexts must match each other
  • Roundtrip confirms: plaintext must equal the original message before encryption
  • If all pass: shows ✓ Link verified — decrypts correctly on this device
  • If any fail: shows warning with the specific failure reason
Why it is burn-safe
  • The check calls POST /decrypt directly — not POST /share/api.php retrieve
  • The retrieve action increments view_count and triggers burn logic
  • The decrypt API endpoint has no view count or burn semantics
  • No database row is touched during the health check
  • A burn-after-read message is safe to health-check before sharing
  • All ciphertexts and key material used are already held in browser memory

Self-Audit Mode

Self-audit mode is accessible on the decrypt view via a “Show audit data” toggle. It surfaces the full cryptographic configuration used for that specific message, rendered from data held in the browser during decryption. It requires no additional API calls and exposes no secrets.

// Audit panel fields (rendered client-side only)
Envelope:          v3
KEM 1:             max-secure-pure-pq   // ML-KEM-1024 + ML-DSA-87 + SLH-DSA
KEM 2:             hqc-256
AES HKDF context:  pqcrypta-share-v3-aes-wrap
HMAC HKDF context: pqcrypta-share-v3-meta-sig
Destroy HKDF ctx:  pqcrypta-share-v3-destroy
Padding block:     4096 bytes
Passphrase layer:  none | yes — verified | yes — failed
Fingerprint:       a3f9c2b1  (or — if absent)
Metadata MAC:      a1b2c3d4e5f6a1b2…   // first 16 chars + ellipsis

The audit panel is intended for developers and security-conscious users who want to verify the exact parameters used without reading the source code. All values are read from the JavaScript runtime during decryption — they reflect what actually ran, not what the documentation claims.

🔒
Live system — free, no account Create an encrypted message at pqcrypta.com/share/. Open DevTools → Network tab. Observe that the wrap key fragment is never present in any request.
Encrypt & Share Now