A little information to introduce the topic
Quantum computing is no longer a hypothetical threat on the horizon, but a very real challenge that requires rethinking the foundations of modern cryptography. Classic asymmetric algorithms such as RSA, Diffie-Hellman, and elliptic curve cryptography (ECC) rely on the computational complexity of factorization and discrete logarithm problems to achieve their security. However, in 1994, Peter Shor showed that a quantum computer could solve these problems in polynomial time, making the entire existing public key infrastructure vulnerable.
Of particular concern is the “harvest now, decrypt later” strategy: attackers can accumulate encrypted data today in order to decrypt it when quantum computers become powerful enough. Therefore, the transition to quantum-safe solutions is not a question of the distant future, but of the present time.
This same tactic applies not only to quantum computing, but to future key leaks.
Hybrid Protocols: A Bridge Between the Past and the Future
The transition to post-quantum cryptography isn't an overnight process. This is where hybrid cryptographic protocols come into play, like QuarkDash Crypto.
A hybrid protocol simultaneously uses at least one classical and one post-quantum cryptographic algorithm. The resulting shared secret remains secure as long as at least one of the components remains unbroken.
This approach is often referred to as a "belt and suspenders" strategy: even if the post-quantum algorithm is compromised (as happened with SIKE in 2022 or with Kyber/ML-KEM, where vulnerabilities in the implementation have been repeatedly found), the classical layer will continue to provide protection against current threats. Conversely, if a quantum computer hacks the classical part, the post-quantum layer will remain secure.
Why are hybrid protocols needed?
- Risk insurance for new technologies;
- Smooth transition for business;
- Cryptographic flexibility;
- Practical efficiency;
QuarkDash: A Next-Generation Hybrid Protocol
It is in this context that QuarkDash was created – a pure TypeScript library implementing a hybrid cryptographic protocol that provides post-quantum security, high performance, and attack resistance.
In the previous article, we already discussed the initial versions, and today we'll look at and analyze the new release of version 1.2.0, examining classic problems and their solutions in hybrid protocols.
0. The starting point, or what QuarkDash was like before
Initially, QuarkDash was a fair hybrid of Ring-LWE (with parameters N=256, Q=7681) and KDF based on SHAKE256, with encryption performed using ChaCha / Gimli + MAC on SHAKE256. Everything was written in pure TypeScript, without any third-party dependencies. The only thing I wrote in WASM (pure C) was the SHAKE256 processing to speed up the calculations.
However, after several months of using it in production, I noticed a few things:
- Keystream was implemented too greedily. 32 blocks (2 KB) at a time, even if only 100 bytes from the middle of a 100 MB file were needed. Memory was strained, and the GC wasn't happy either. This was a problem for streams.
- The key lived forever. Compromise in a month = decryption of everything. There was no periodic rotation. Basic protection was present, but rotation was still necessary.
- Password != key. There was no "give a phrase and get a 32B key" method. PBKDF2 had to be externalized.
- NTT was naive. There was no blinding, virtually no checking, and Math.random had a history of problems with other keys. It worked, but in reality, it provided little protection against timing and glitches.
- The nonce was a static 12x0, which led to keystream reuse issues.
- And a number of other minor issues that overlapped and produced a less than optimal picture for the algorithm that is used every day.
The goal of the updates was to close all gaps while minimizing changes to the API, so that the new version could be easily and painlessly implemented into existing processes.
1. Lazy Keystream - Why Not Another Buffer?
One of the key objectives of the redesign was optimal memory management, which was especially critical for working with big data (audio/video/documents/message history). It was also necessary to maintain maximum nativeness.
There were many ideas that were discarded one way or another during the process:
- Keep a 32-block batch. Simple, but on 2GB streams, you either keep the entire stream in RAM or slice it manually. Looking at a specific memory block is simply impossible without generating 1MB of garbage.
- Node Stream / Web Streams. Heavy, requires polyfills, but doesn't work well in workers.
- Make a stateful keystream (store the counter inside the cipher). Breaks decryption, since the peer must know the exact offset.
What I ended up doing:
First, I created a common interface and an abstract class for our lazy keystream:
getBytes(offset, length) // to get any blocks tail
xor(data, offset) // XOR without heavy allocations
xorInto(input, output, offset)
blocks(start) // infinite generator
seek/tell/rewind/read // utils seek methods
Why this is so:
- One method for the child: generateBlock(i). For ChaCha, this is 20 rounds, for Gimli, 24. Everything else is utilities for slicing, caching, and viewing data already in the base class implementation.
- A 64-block cache (LRU). 64×64B = 4 KB for ChaCha, 64×48B = 3 KB for Gimli. This is enough to ensure that XOR on 64KB doesn't recalculate the same thing, but also doesn't bloat memory. In some cases, I added the setCacheLimit() method if needed.
- Why 64 and not 128? Benchmarks showed that more than 64 yields almost no gain, while less than 32 starts to hurt the performance of the buffer with a random offset. Therefore, 64 is the golden mean.
- Why not SharedArrayBuffer? It is not available everywhere, and the winnings on 64B blocks are minimal.
As a result, QuarkDash now implements a default nonce for each message, where the metadata consists of 8 bytes for the timestamp and 4 bytes for the sequence. Previously, there was a single keystream for all messages, but now each message has its own, without an additional field in the packet.
// Simple keystream example
const ks = chacha.createKeystream();
ks.getBytes(1_000_000, 64*1024) // now is 1.9ms instead 47ms for 2MB
2. Even more security with key rotation
Why do we even need key rotation?
QuarkDash already has forward secrecy at the session level, but within a session, the key lives forever. This means that a leak in a month means decryption of all traffic. TLS rotates every 64 MB/10,000 messages: I did the same thing, but it's easier to use.
Let's first figure out what other alternatives there are:
- Completely re-run the handshake (new Ring-LWE). Secure, but 2-3ms and 1KB of traffic. This is painful for IoT.
- KDF chain, where the new key is the old one, but passed through SHAKE again without a salt. Deterministic, but if an attacker guesses one key, they can guess all the others.
- Auto-rotation based on a timer within encrypt itself. Convenient, but implicit; the peer might not be able to keep up, resulting in desynchronization.
What I chose:
- Explicit token. The first peer receives a new key (encrypted 0x51 | counter | salt), the second peer applies the token (same salt).
- This requires only one call on each side. There are helper methods for manually invoking key rotation.
- KDF: update the KDF using the old key and MAC, salt, and counter—that's 64 byte. The first 32 byte is the session key, the second is the macKey. Old keys are cleared from memory.
- Add policies, not magic: the ability to automatically rotate by the number of bytes/messages or at intervals.
// Key rotation by single line of code
if (peer.needsRekey()) await peer.rekey().then(t => peer2.applyRekey(t))
The default values are set to rotate every 64MB or 10K messages. Why is this? It's a balance: rotating more frequently results in more overhead (0.08ms per token), while rotating less frequently results in more data under a single key. This can be changed on the fly.
3. Passphrase Introduction via PBKDF2 + Argon2id-lite
Why do we even need a Passphrase (aka a password)? Sometimes, we don't have a proper handshake between peers (working in the CLI, local files, or the connection algorithm doesn't support handshake).
For flexibility, two algorithms were used: the classic PBKDF2 and the more interesting Argon2id in a lightweight version.
Why PBKDF2-HMAC-SHA256?
- Standard, available in Node (crypto.pbkdf2 is 2-3x faster), verified against RFC 6070. I use SHA256 (not SHA1).
- Additional implementation without dependencies: manual HMAC-SHA256 (oPad/iPad). If Node is unavailable, there's a fallback to pure TypeScript. A password from a string is simply a text translation into the key buffer and a memory wipe afterward.
Why Argon2id-lite and not bcrypt/scrypt?
- bcrypt, with its 72B limit, doesn't make memory-based hacking difficult.
- scrypt is good, but it requires a lot of dependencies and a native module.
- True Argon2 is native node-argon2; it runs node-gyp, and browser access would be a problem.
We need a lightweight, yet memory-protected version without native code, so it works both in a browser and on your bedroom lightbulb.
I created argon2id-lite using SHAKE256:
- The first step is processing the password, salt, and parameters via SHAKE256.
- The second step is stretching the keys in memory by the requested number of KB.
- The third step is shuffling pseudo-random blocks in memory using SHAKE256 with time complexity.
- Finally, we take the first 8 blocks and run them through SHAKE256, erasing the rest from memory.
So, this isn't a full Argon2, but it delivers the key: making brute force handle gigabytes. The default parameters are 32MB with a time complexity of 3, but for testing or less critical data, you can use 8MB parameters with a single time complexity (2.5ms vs. 36ms).
Usage example:
QuarkDashPassphrase.pbkdf2Sync("pwd", salt, 100_000, 32)
QuarkDashPassphrase.argon2idSync("pwd", salt, 32, 3, 32)
await QuarkDashPassphrase.derive("secret", {algorithm:"argon2id"})
const {sessionKey, macKey} = await QuarkDashPassphrase.deriveKeyForQuarkDash("pwd", salt)
4. How to protect your math without sacrificing speed. Breaking down Hardened NTT
One of the bottlenecks in the previous version of the protocol was the weak security of NTT. In this version, I decided to close all the gaps while maintaining a balanced performance.
What was wrong with the previous version:
- Serialization, for example, returned -1 (0xFFFF), but the deserializer expected <Q. Validation was missing, which led to the problem.
- b = (as+e) % Q in the JS implementation yielded a negative remainder e<0.
- NTT had no blinding or additional checks at all, and wlen was recalculated at each level. This was both a security and performance penalty.
How I improved the NTT implementation, making it more robust and faster:
-
Added normalization wherever it should be, using
(v%Q)+Q)%Q. -
Added blinding:
a·r, b·r⁻¹, whereris taken from a 2B random number, andr⁻¹is calculated usingmodInverse. This way, the product a b doesn't change, but the cache/time footprint is eroded. - Double checks: we run NTT a second time and compare them, catching errors.
-
wlenis now cached, usingpowMod. -
Introduced fixed-length cycles, as well as polynomial validation, where
v ∈ [-Q, Q)
Well, the protection is turned on in an elementary way:
lwe.setNTTProtection({blinding:true, doubleCheck:true})
The price of all this: Key generation 0.58ms → 0.73ms, and handshake 2.2ms → 2.4ms, which is almost free.
5. As a bonus, I added vehicle wraps.
To allow you to transparently implement QuarkDash over popular transport, we've created simple wrappers (of course, in reality they may be more complex, but for example use cases or testing, they're quite sufficient).
From the wrappers I added:
-
WebSocket: Both browser
WebSocketand Node'swsare suitable. The wrapper subscribes to themessage, decrypts it, and returns it inonDecrypted. Each connection has its own key. -
HTTP: Uses the
x-qd-encryptedheader and theoctet-streambody, so the encryption is visible. There's also a simple middleware for the Express framework and fetch wrapper for browser requests. - gRPC: I've implemented two approaches here: client and server interceptors for the native API, and a proxy wrapper, as the simplest way, without editing .proto. You can encrypt buffers, strings, or objects (but the object will be passed through JSON.stringify).
All wrappers are thin, without dependencies, fail silently (try/catch inside), and do not break sockets.
6. Results. Why is this necessary?
The protocol itself is designed to provide the most convenient, post-quantum encryption possible, secure where it's truly needed, without the need to rewrite individual components. Ongoing work and testing show that there's always room for improvement, but it's important to remember that when making improvements, care must be taken to ensure that nothing breaks.
What results have been achieved?
- Memory optimization for big data encryption (3x savings without overloading the garbage collector).
- Protection of mathematical functions, costing just a few tens of milliseconds.
- Protection against future attacks through key rotation and improved protocol mechanisms.
- Smooth transition with minimal API changes.
Where is such a protocol needed?
- Where post-quantum stability is required, as well as when working with encryption of large files or data.
- Where it's important for you to have a complete encryption chain, not just "run through AES," taking into account various types of attacks.
- For real-time messaging, where key exchange and connection security are critical, balanced with optimization and speed.
- If you want to understand how to work with secure protocols or use it as a ready-made alternative to protocols like MTProto.
- Blockchain verification and signing.
I'd appreciate your thoughts on improving and refining the protocol:
https://github.com/DevsDaddy/quarkdash
Thanks for reading.



Top comments (0)