Post-Quantum Encrypted · Decentralized · Zero PII

NullNode Messenger

Decentralized, post-quantum encrypted messaging — no phone, no email, no PII. Identity is derived entirely from a local GPG key pair; the server never sees a real-world identifier.

Messages are encrypted with ML-KEM (Kyber-768), the NIST FIPS-203 post-quantum standard, via GnuPG 2.5.20. Think of it as a BitTorrent for messaging.

curl -fsSL https://raw.githubusercontent.com/gnoppix/NullNode/main/install.sh | bash

Why Post-Quantum Messaging?

Newer and faster computers will soon make it possible to decrypt today's messages on "normal" chat programs. Furthermore, with backdoors and decryption methods built into these platforms, mass worldwide surveillance becomes effortless.

With NullNode, that is impossible. There is no central server in between, and your messages aren't just strongly encrypted — they are super strongly encrypted.


Core Features

Zero-Knowledge Identity

8-character Null ID (NN-XXXX-XXXX) is a deterministic hash of your GPG fingerprint. No sign-up, no account.

Post-Quantum Encryption

Every message encrypted with Kyber-768 + AES256 via gpg --require-pqc-encryption.

Forward Secrecy

Double ratchet with per-message ephemeral keys. Past messages remain unreadable even if the long-term key is compromised.

Peer-to-Peer Messaging

Direct WebSocket connections when both peers are online. Handshake with proof-of-work + signature verification.

DHT Mailbox

Encrypted messages stored in a Kademlia-style DHT when the recipient is offline. Retrieved on reconnect (polled every 30s).

Anti-Spam PoW

DHT writes require difficulty 16 (~0.5s), P2P handshakes require difficulty 12 (~0.1s).

NAT Traversal

STUN + UDP hole punching for clients behind home routers.

Federated Relays

Relays can peer with each other for cross-relay message delivery with HMAC-authenticated challenge-response.

CLI-First

Full-featured terminal client; ideal for lean environments, SSH sessions, and automation.

Features Coming Soon

  • A cool desktop UI
  • File sharing
  • Voice and video calls

Quick Start

Prerequisites

1 Alice creates an identity

cd /home/Gnoppix/messenger
source venv/bin/activate

./nullnode.sh init
# -> identity created: NN-P4DM-WZPF

./nullnode.sh id
# -> Null ID:     NN-P4DM-WZPF
# -> fingerprint: F5B0F201378A72EF973A88D170B7096AD5713AA7

./nullnode.sh export > alice_pub.asc

2 Bob creates an identity

cd /home/Gnoppix/messenger
source venv/bin/activate
export NULLNODE_GNUPGHOME=~/.nullnode-bob

./nullnode.sh init
# -> identity created: NN-VJWY-YQMK

./nullnode.sh export > bob_pub.asc

3 Exchange public keys

# Alice imports Bob's key
./nullnode.sh import bob_pub.asc --alias NN-VJWY-YQMK

# Bob imports Alice's key
NULLNODE_GNUPGHOME=~/.nullnode-bob ./nullnode.sh import alice_pub.asc --alias NN-P4DM-WZPF

Verify fingerprints out-of-band before trusting! Then set trust:

# Alice sets trust for Bob
python3 -c "from crypto import set_key_trust; set_key_trust('BOB_FP', 'ultimate')"

# Bob sets trust for Alice
NULLNODE_GNUPGHOME=~/.nullnode-bob python3 -c "from crypto import set_key_trust; set_key_trust('ALICE_FP', 'ultimate')"

4 Start P2P nodes

# Alice
./nullnode.sh p2p --port 9001

# Bob (different terminal)
NULLNODE_GNUPGHOME=~/.nullnode-bob ./nullnode.sh p2p --port 9002

5 Chat

# Alice sends to Bob
./nullnode.sh send NN-VJWY-YQMK "Hello post-quantum world!" --fingerprint BOB_FP

# Or interactive chat
./nullnode.sh chat NN-VJWY-YQMK --fingerprint BOB_FP
> Hello Bob!
> /quit

CLI Reference

Command Description
initGenerate a PQC identity (Kyber-768 + brainpoolP384r1)
idShow your Null ID and GPG fingerprint
exportPrint your armored PGP public key to stdout
import <file>Import a peer's public key from file (or stdin)
import <file> --alias <NID>Import and register as a contact
contactsList registered contacts (NID → fingerprint)
p2p --port NStart P2P node and listen for messages
send <NID> <msg>Send a message to a peer (P2P or DHT mailbox)
send <NID> <msg> --fingerprint <FP>Send using explicit fingerprint
chat <NID>Interactive P2P chat session
chat <NID> --fingerprint <FP>Chat with explicit fingerprint
dhtDHT diagnostics (find, advertise)
relayStart the legacy WebSocket relay server

Environment Variables

Variable Default Description
NULLNODE_RELAYws://127.0.0.1:8765Legacy relay URL (fallback only)
NULLNODE_GNUPGHOME~/.nullnode/gnupgGPG home directory
NULLNODE_GPGgpgPath to the gpg binary
NULLNODE_DHT_BOOTSTRAP(3 built-in seeds)Comma-separated bootstrap DHT seeds

P2P Node

When you run p2p, the node starts a DHT node (joins the Kademlia network via bootstrap seeds), starts a P2P WebSocket listener on the specified port, advertises your address in the DHT, and polls your DHT mailbox every 30s for offline messages.

./nullnode.sh p2p --port 9001

Sending a Message

The client tries direct P2P first. If the peer is unreachable, it falls back to storing an encrypted blob in the DHT mailbox:

./nullnode.sh send NN-VJWY-YQMK "Hello!" --fingerprint BOB_FP

DHT Diagnostics

# Look up a peer's address
./nullnode.sh dht --find NN-VJWY-YQMK

# Advertise your address
./nullnode.sh dht --advertise "wss://your-public-ip:9001"

Legacy Relay Deployment

The relay is a legacy fallback for environments where P2P is not possible. The primary architecture is P2P + DHT.

Docker

docker build -t nullnode-relay .
docker run -d \
  --name nullnode-relay \
  --restart unless-stopped \
  -p 8765:8765 \
  nullnode-relay

Native

python relay.py --host 0.0.0.0 --port 8765 --verbose

The relay is stateless — all sessions and queues are in-memory. For horizontal scaling, add a shared Redis backend (not yet implemented; see relay.py).

Federation

Relays can peer with each other for cross-relay message delivery:

# On relay A: peer with relay B
python relay.py --port 8765 --peer wss://relay-b.example.com:8765 --peer-secret SHARED_SECRET

Architecture

End-to-End Message Flow

+--- ALICE'S MACHINE -----------------------------------------------------------+ | | | +-------------+ 1. generate_keypair() | | | gpg keyring | --> gpg --quick-gen-key ... pqc ... | | | (secret) | +-- primary: brainpoolP384r1 [SC] | | | + public | +-- subkey: ky768_bp256 [E] | | +------+------+ | | | fingerprint: F5B0F201378A72EF... | | | | | v | | +--------------+ 2. null_id(fingerprint) | | | Null ID | --> blake2b(fingerprint, 8) -> base32[:8] | | | NN-P4DM-WZPF | +-- "NN-XXXX-XXXX" (8 chars, no PII) | | +--------------+ | | | | +--------------+ 3. export_pubkey() | | | armored key | --> gpg --armor --export | | | (PGP packet) | +-- sent to peer out-of-band | | +--------------+ | | | | +--------------+ 4. DHT lookup("NN-VJWY-YQMK") | | | DHT query | --> Kademlia FIND_VALUE -> "wss://bob:9001" | | +--------------+ | | | | +--------------+ 5. P2P handshake (p2p-hello + PoW) | | | WebSocket | --> direct connection to Bob | | | handshake | +-- both sides solve PoW puzzle | | +------+-------+ +-- verify signatures | | | | | v | | +--------------+ 6. Double ratchet encrypt | | | ciphertext | --> fresh ephemeral Kyber encapsulation per message | | | (armored) | +-- AES256 encrypts plaintext | | +------+-------+ +-- sequence number + timestamp + hash | | | | | | base64(ciphertext) | | v | +---------+--------------------------------------------------------------------+ | | JSON envelope { type: "p2p-message", payload: { seq, ciphertext, msg_hash } } | v +--- BOB'S MACHINE ----------------------------------------------------------+ | | | +-------------+ 7. verify hash, decrypt | | | gpg keyring | --> Kyber-768 decapsulation -> session key | | | (secret) | +-- AES256 decrypt -> plaintext | | +------+------+ +-- verify sequence number (anti-replay) | | | | | v | | +--------------+ | | | plaintext | | | | "Hello Bob!" | | | +--------------+ | | | +----------------------------------------------------------------------------+

Identity and Key Exchange

ALICE BOB | | | ./nullnode.sh init | ./nullnode.sh init | +-- gpg gen brainpoolP384r1 | +-- gpg gen brainpoolP384r1 | + ky768_bp256 subkey | + ky768_bp256 subkey | | | ./nullnode.sh export > key.asc | | ------------------------------->| | | ./nullnode.sh import key.asc | | +-- gpg --import | | +-- register_contact(NN-..., FP) | | | | | ./nullnode.sh export > key.asc | <-------------------------------| (verify fingerprint out-of-band!) | ./nullnode.sh import key.asc | | +-- gpg --import | | +-- register_contact(NN-..., FP)| | +-- set_key_trust(FP, ultimate) | | | | Now each side has the other's | | public key, verified fingerprint| | and explicit trust. |

Wire Protocol (P2P)

ALICE (initiator) BOB (responder) | | | p2p-hello | | { public_key: base64(FP), | | nonce: N, pow_bits: 12 } | | sig: base64(gpg_sig) | | -------------------------------------->| | | | p2p-hello-ack | | { public_key: base64(FP), | | nonce: M, pow_bits: 12 } | | sig: base64(gpg_sig) | | <--------------------------------------| | | | p2p-message | | { seq: 0, ciphertext: base64(ct), | | msg_hash: sha256_hex } | | -------------------------------------->| | | | p2p-ack | | { seq: 0, msg_hash: sha256_hex } | | <--------------------------------------|

Wire Protocol (DHT Mailbox)

ALICE DHT NETWORK | | | 1. Encrypt message with Bob's key | | 2. Sign with Alice's key | | | | dht-put | | { key: "NN-BOB-ID", | | value: base64(encrypted_blob), | | salt: hex, seq: 1, | | ttl: 86400, | | nonce: pow_solution, | | publisher_fp: "ALICE_FP" } | | sig: base64(gpg_sig) | | -------------------------------------->| | | | (DHT stores encrypted blob) | | | | | BOB DHT NETWORK | | | dht-get | | { key: "NN-BOB-ID" } | | -------------------------------------->| | | | dht-found | | { key: "NN-BOB-ID", | | value: base64(encrypted_blob), | | salt: hex, seq: 1 } | | <--------------------------------------| | | | 3. Verify signature (Alice's FP) | | 4. Decrypt with Bob's secret key |

Key Material Flow

ALICE P2P/RELAY BOB ----- --------- --- Null ID NN-ALICE NN-ALICE NN-ALICE GPG fingerprint F5B0F201... -- (never sent) F5B0F201... Secret key present -- -- Public key present -- present Plaintext "Hello" -- "Hello" Ciphertext (Kyber) present opaque blob present Session key (AES) derived -- derived IP address present present present Message timestamp present present present

Network Topologies

1. P2P + DHT (Current Default)

+----------+ +----------+ | Alice | WebSocket (direct) | Bob | | :9001 |<--------------------->| :9002 | +----+-----+ +----+-----+ | | | DHT (store-and-forward) | | +-----------+ | +->| DHT Node |<------------------+ | :6881 | +-----------+

Each client runs a P2P node + DHT node. Messages flow directly when both peers are online. Offline messages are stored in the DHT. No relay needed. Already implemented.

2. Legacy Relay (Fallback)

+----------+ | Relay | | :8765 | +----+-----+ +----+-----+ | | | +--+-+ +-+-+ +-+-+ | A | | B | | C | +----+ +---+ +---+

All clients register with one relay. The relay forwards messages to the right WebSocket. Offline messages are queued (max 100, TTL 300s). Implemented in relay.py. Kept as fallback.

3. Federated Relays

+--------------+ inter-relay +--------------+ | Relay Alpha | <------- WebSocket --------> | Relay Beta | | alice.net | | bob.io | +--+------+----+ +--+------+----+ +----+ +----+ +----+ +----+ +--+--+ +--+--+ +--+--+ +--+--+ | A | | B | | C | | D | +-----+ +-----+ +-----+ +-----+

Relays peer with each other over a separate inter-relay WebSocket. Routes gossiped every 60s. HMAC challenge-response authenticates peer connections. Implemented in relay.py.

4. Mesh (DHT Only, No Relays)

+------+ +------+ | A |<----->| B | +--+---+ +--+---+ | | +--+---+ +--+---+ | C |<----->| D | +------+ +------+

Every node runs a DHT client (Kademlia). To send a message: look up recipient in DHT, connect directly, handshake, exchange messages. Implemented in p2p.py + dht.py.

Topology Comparison

Topology SPOF Offline Delivery Address Discovery Complexity
P2P + DHT (default)NoYes (DHT mailbox)DHTMedium
Legacy relayYesYes (queue)None (same URL)Low
Federated relaysNoYes (per-relay queue)Gossip or DHTHigh
Mesh / DHTNoNoDHTMedium

Support the Project

Please consider supporting NullNode! The project cannot fund all of the required hosting servers on its own.

Become a Sponsor