Hybride Postquanten-Nachrichten
Integrationsanleitung und API-Referenz: Hybride Postquanten-Nachrichten.
Diese ausführliche API-Referenz wird auf Englisch gepflegt und folgt dem verlinkten SDK-Quellcode. Beispiele mit Anwendungsvariablen benötigen Ihren Signer, Ihre Schlüssel, Ihren Speicher oder Ihre Oberfläche.
Hybrid, not replacement
hybridKey combines the KEM secret with the classic NIP-44 conversation key through a KDF, so the result is no weaker than either input. The post-quantum secret must never be used alone: a flaw in a comparatively young lattice scheme must not be able to make Nostr messaging worse than it is today.
The message envelope
A self-describing payload carrying an ML-KEM-1024 ciphertext and an XChaCha20-Poly1305-sealed message. It fits anywhere a string does — in practice as the content of a NIP-17 kind:14 rumor, sealed and gift-wrapped per NIP-59 with no change to either. Relays need no changes and clients that have not implemented it are unaffected.
import { encryptPq, decryptPq } from '@nostr-wot/pq';
const parties = { sender: myPubkey, recipient: theirPubkey };
const payload = encryptPq('hello', att.kem!, nip44ConversationKey, parties);
const text = decryptPq(payload, myKem.secretKey, nip44ConversationKey, parties);Two ways to look at a payload before opening it. isPqEnvelope(payload) is the boolean a cipher routes on. classifyEnvelope(payload) is the one a caller that reports failures should use, because a boolean cannot tell "somebody else's ciphertext" from "ours and broken":
classifyEnvelope | Means |
|---|---|
'pq' | A complete envelope at this version and algorithm. Identical to isPqEnvelope. |
'pq-unreadable' | The header names our version and we cannot open it: truncated, an algorithm byte we do not implement, or base64 this host cannot decode. |
'classic' | Nothing claims to be ours. NIP-44's version byte is 0x02, so its ciphertext lands here, as does anything that is not base64. |
The version and algorithm bytes are decoded from the first four base64 characters inline, without atob or Buffer, so a host that has neither still gets 'pq-unreadable' for a valid envelope rather than being told it is classic and sent to the wrong code. A payload claiming a version this package has never heard of cannot be distinguished from a classic ciphertext of that version, so it is called classic — the honest answer, not a guess.
Send a gift-wrapped direct message
Most callers want this rather than the raw envelope. It composes the envelope with NIP-17 and NIP-59 — the result is an ordinary kind:1059 gift wrap that today's relays accept and today's clients ignore.
import { createPqDirectMessage, openPqDirectMessage, inboxFilter } from '@nostr-wot/pq';
const wrap = createPqDirectMessage({
content: 'hello',
senderSecretKey: mySecretKey,
recipientPubkey: theirPubkey,
recipientKemKey: att.kem!, // from their kind:10203 attestation
});
await pool.publish(theirInboxRelays, wrap);
// On the other side:
for (const w of await pool.querySync(myInboxRelays, inboxFilter(myPubkey))) {
const msg = openPqDirectMessage({
wrap: w,
recipientSecretKey: mySecretKey,
recipientKemSecretKey: myKem.secretKey,
});
if (msg) console.log(msg.sender, msg.content); // null = an ordinary classic message
}openPqDirectMessage returns null for a classic gift wrap rather than throwing, so a post-quantum client does not choke on ordinary messages. It throws only when a message is malformed or fails authentication — including when a rumor claims an author the seal did not sign, which is the forgery a naive implementation would display as genuine.
Wire format
version 1 byte 0x01
alg 1 byte 0x01 = ML-KEM-1024 + NIP-44 conversation key, XChaCha20-Poly1305
kem_ct 1568 ML-KEM-1024 ciphertext
nonce 24 XChaCha20-Poly1305 nonce
sealed variable AEAD(padded plaintext), includes the 16-byte tagbase64-encoded for transport.
Why it looks like this
- Its own version byte, not NIP-44's. Overloading NIP-44's version registry would squat a number its authors own. This envelope is self-describing, so it can be adopted, renumbered or superseded without colliding with anyone.
- Hybrid, never bare. The KEM secret is combined with the NIP-44 conversation key through HKDF, so the result is no weaker than either input. Two tests assert this actually binds: a wrong conversation key fails, and a wrong ML-KEM key fails. If either passed, the construction would not be hybrid.
- The framing is authenticated. Version, algorithm and both pubkeys go into the AEAD's associated data, so a ciphertext cannot be replayed into another conversation, have its direction swapped, or have its algorithm byte downgraded.
- Length is padded using NIP-44's scheme, so ciphertext size does not leak message size on a public relay. Messages of 1, 2, 20 and 32 bytes all produce identical wire sizes.
- One generic error. Every decryption failure throws the same message. Distinguishing bad padding from a bad tag from a wrong key hands an attacker an oracle.
Gift-wrapped message size
Measured on the complete kind:1059 event, serialized as a relay sees it:
| message | classic NIP-17 | post-quantum | overhead | ratio |
|---|---|---|---|---|
| "hi" (2 chars) | 1,533 B | 4,605 B | +3,072 B | 3.0x |
| chat line (32) | 1,701 B | 4,605 B | +2,904 B | 2.7x |
| a tweet (280) | 2,213 B | 5,285 B | +3,072 B | 2.4x |
| a paragraph (1 KB) | 3,921 B | 7,333 B | +3,412 B | 1.9x |
| a long note (4 KB) | 11,429 B | 14,161 B | +2,732 B | 1.2x |
| a document (16 KB) | 38,737 B | 44,197 B | +5,460 B | 1.1x |
~3 KB constant overhead, about 2.4x on a typical chat message. At 100 messages a day that is +300 KB/day, or +107 MB/year per conversation — the number relay operators will care about, and the strongest argument anyone will make against adopting this.
Worth noting that NIP-59 is already expensive for its own reasons: a classic two-character "hi" costs 1,533 bytes. Gift wrap has a ~1.5 KB floor no matter what. Post-quantum raises that floor; it does not create it.
Where the layering saves 16-28%
NIP-59 base64-encodes at every layer, so anything placed in the rumor is expanded by 4/3 three times over. Putting the envelope at the seal layer instead of inside the rumor removes an entire expansion of the 1568-byte ML-KEM ciphertext:
| message | envelope in rumor | envelope at seal | saved |
|---|---|---|---|
| "hi" | 5,969 B | 4,605 B | 1,364 B (22.9%) |
| a tweet (280) | 7,333 B | 5,285 B | 2,048 B (27.9%) |
| 1 KB | 8,701 B | 7,333 B | 1,368 B (15.7%) |
| 4 KB | 16,893 B | 14,161 B | 2,732 B (16.2%) |
This is a framing optimisation, not a cryptographic compromise — nothing is weakened, and it is arguably the more natural placement, since the seal is already where NIP-59 puts the rumor's confidentiality. The envelope replaces that layer's encryption rather than nesting inside it.
Speed
1.27 ms to encrypt, 1.60 ms to decrypt a 280-byte message (Node 20, Apple silicon, average of 100).
What this does not do
- It does not stop event forgery. Events are still signed with secp256k1. A quantum adversary can sign as any user, and can publish a replacement attestation carrying their own keys to intercept future messages. This makes past messages permanently confidential; it does not protect future messages once secp256k1 is broken.
- It does not hide metadata beyond what gift wrap already hides. Who talks to whom, and when, is a NIP-59 question, not this envelope's.
Parameter sets
ML-KEM-1024 and ML-DSA-87 (NIST Category 5) — the sets mandated by NSA CNSA 2.0. Australia's ISM withdraws approval for ML-KEM-768 and ML-DSA-65 after 2030, so the smaller sets would mean shipping parameters already scheduled for withdrawal.