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.
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. 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
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
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(). 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.
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.
POST /encrypt. This produces two independent ciphertexts.
POST /share/api.php (store action).
#<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.
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.
pqcrypta-share-v3-meta-sig). Verified before decryption begins — any server-side metadata tampering is detected.
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.
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.
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.
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.
blob = "v3:<base64(JSON({pq,hq}))>"pq— ML-KEM-1024 ciphertext (base64)hq— HQC-256 ciphertext (base64)- Key blobs packed separately in
key_blobcolumn - 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
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
blob = "v1:<base64_ciphertext>"- ML-KEM-1024 only — no HQC
- Raw wrap key (no HKDF)
- Fully backward compatible — decrypts correctly
- 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.
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.
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 wrappingpqcrypta-share-v3-meta-sig→ HMAC-SHA-256 metadata signingpqcrypta-share-v3-destroy→ destroy auth pre-image
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.
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.
- Key derivation:
PBKDF2-SHA-256(passphrase, randomSalt, 200,000 iterations)→ 256-bit AES key - Encryption:
AES-256-GCMwith 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.
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).
- 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
- 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.
crypto.getRandomValues(32B)— master wrap key generationcrypto.getRandomValues(12B)— AES-GCM IV generation (per wrap)crypto.subtle.importKey('raw', key, 'HKDF')— import wrap key as HKDF key materialcrypto.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 keyscrypto.subtle.decrypt('AES-GCM')— unwrap both private keyscrypto.subtle.importKey('raw', key, 'HMAC')— import derived HMAC key (sign + verify, once each)crypto.subtle.sign('HMAC')— metadata signaturecrypto.subtle.verify('HMAC')— metadata verificationcrypto.subtle.digest('SHA-256')— destroy auth hash
POST /keys/generate×2 — separate calls formax-secure-pure-pq(ML-KEM-1024) andhqc-256(HQC-256), run in parallelPOST /encrypt×2 — one call per algorithm, run in parallelPOST /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:
- 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
- Accepts UUID
idor short codes - 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
- Accepts UUID
id+ optionaldestroy_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=''
- 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.
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.
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.
- AES-unwraps both private keys from the key blobs already in memory
- Calls
POST /decrypttwice 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
- The check calls
POST /decryptdirectly — notPOST /share/api.phpretrieve - The retrieve action increments
view_countand 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.