Serverless, Decentralized, Asynchronous Messaging via the BitTorrent DHT (BEP 44)
What if a messaging application could operate without running any central messaging server or infrastructure?
AnoDrop turns the global, millions-of-nodes BitTorrent Distributed Hash Table (DHT) into a decentralized, anonymous, asynchronous message-drop system.
- What is AnoDrop?
- Key Features
- Quick Start & Installation
- How It Works Under the Hood
- Sequence Diagrams
- Forensic & Diagnostic Tool (
decrypt_store) - Testing & Quality Assurance
- License
Traditional messengers (Signal, Telegram, WhatsApp, Matrix) rely on central relays or home servers. If those servers are down, censored, blocked, or compromised, communication breaks down or exposes communication metadata.
AnoDrop eliminates servers entirely:
- No central server, no accounts, no phone numbers, no metadata harvesting.
- Two users agree out-of-band on a shared channel passphrase (e.g.,
secret-rendezvous-channel). - Using BEP 44 (Storing arbitrary data in the BitTorrent DHT), both users find each other on the DHT at an encrypted rendezvous target.
- They perform a 3-way mutual key exchange using X25519 ECDH to establish a shared conversation secret that is never transmitted over the wire.
- Messages are deposited into derived DHT targets. The recipient queries the DHT and retrieves them asynchronouslyβeven if the sender is already offline.
- Decentralized & Serverless: Leverages the public BitTorrent DHT network. No proprietary relay servers, no centralized federation.
- Mutual Peer Confirmation Handshake: Symmetric discovery with explicit mutual confirmations (
confirmed_peer) and CSMA/CA-inspired adaptive timers, completely eliminating handshake split-brain and impersonation vulnerabilities. - Outbox Architecture: Payload-first replication guarantee (
fsyncto local WAL β PUT payload to DHT β wait for replication ACK β PUT sequence advance). Prevents retrieval race conditions where a peer sees a sequence bump before the message payload arrives at DHT replicas. - Strict Durability Before Visibility: No state transition is visible in RAM before being authenticated and
fsynced to disk. - Memory Hardening (
SecureString): Plaintext message contents are encapsulated inSecureStringand automatically wiped from RAM upon destruction viaOPENSSL_cleanse. - Authenticated WAL & Envelope Encryption: Storage relies on
EncryptedJournal(transactions.v1.enc), protected with inter-processflock, positional AAD ("AnoDropJournal/v1/<index>"), and AES-256-GCM. The master password derives a Key Encryption Key (KEK) via Argon2id to unwrap the DEK invault.enc. Master password rotations only re-encrypt the 32-byte DEK invault.encwithout re-writing message history. - Atomic Compaction: Obsolete intermediate state transactions are consolidated in the background into single-pass snapshots via atomic file replacement (
rename+ directoryfsync), eliminating disk bloat. - Metadata & Timezone Privacy: Presence heartbeats use negotiated synthetic timezone offsets, hiding the user's real geographic location and IP time offsets.
- Hardware/Interface Kill-Switch: Configurable exclusive binding to specific VPN or WireGuard network interfaces (
wg0,tun0,nordlynx). - Comprehensive Terminal Interface (TUI): Fast, flicker-free ncurses interface using MVVM architecture with non-blocking worker threads.
AnoDrop requires a Linux environment with a C++23 compliant compiler (g++-13+ or clang++-17+), CMake, and the following libraries:
sudo apt update
sudo apt install -y \
build-essential \
cmake \
libtorrent-rasterbar-dev \
libssl-dev \
libncurses-dev \
libspdlog-dev \
nlohmann-json3-dev \
libargon2-devsudo pacman -S base-devel cmake libtorrent-rasterbar openssl ncurses spdlog nlohmann-json argon2sudo dnf install -y \
gcc-c++ \
cmake \
rb_libtorrent-devel \
openssl-devel \
ncurses-devel \
spdlog-devel \
json-devel \
libargon2-devel# Clone the repository
git clone https://github.com/PixiesRAC/AnoDrop.git
cd AnoDrop
# Configure with CMake (Release mode recommended)
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON
# Compile the AnoDrop executable, tools, and tests
cmake --build build -j$(nproc)The build produces:
build/AnoDrop: Main messaging application with ncurses TUI.build/decrypt_store: Forensic and recovery CLI utility to inspect offline encrypted stores.build/tests/regression/anodrop_regression: Contract and logic regression suite.build/tests/regression/anodrop_e2e: Functional end-to-end test runner driving real virtual terminals (forkpty).
AnoDrop reads its configuration from config.json by default (or via -c /path/to/config.json). Copy the provided example to get started:
cp config.example.json config.json{
"store_path": "./store",
"log_path": "./anodrop.log",
"fetch_interval_ms": 5000,
"argon2_iterations": 5,
"max_concurrent_fetches": 16,
"extra_salt_data_for_lobby_handshake": "AnoDrop",
"log_level": "info",
"network_interface": "any"
}| Parameter | Description |
|---|---|
store_path |
Directory where vault.enc and transactions.v1.enc are stored. |
log_path |
Path to the structured application log file. |
fetch_interval_ms |
Interval (in ms) between background DHT polling cycles (default: 5000). |
argon2_iterations |
Computational cost for Argon2id key derivation (default: 5). |
max_concurrent_fetches |
Maximum parallel DHT get queries in flight (default: 16). |
extra_salt_data_for_lobby_handshake |
Extra domain value included in the serialized Argon2id salt parameters (default: "AnoDrop"). All communicating parties must use identical values; this string is not itself the BEP 44 salt. |
log_level |
Logger verbosity: trace, debug, info, warn, err, critical, off. |
network_interface |
Kill-switch binding: "any" (default) or network device name (e.g., "tun0", "wg0"). |
# Run with default config (looks for ./config.json)
./build/AnoDrop
# Or specify a custom config file
./build/AnoDrop -c /path/to/my_config.json-
Master Password Prompt (
ui::Unlock):- On the very first run, enter a strong master password to initialize the encrypted vault (
vault.enc). The master password derives a Key Encryption Key (KEK) via Argon2id to wrap the Data Encryption Key (DEK). - On subsequent runs, enter the master password to derive the KEK via Argon2id, unwrap the DEK in
vault.enc, and replay the encrypted journal (transactions.v1.enc).
- On the very first run, enter a strong master password to initialize the encrypted vault (
-
Main Menu Navigation (
ui::GuiBuilder): Once unlocked, AnoDrop presents a numbered interactive terminal menu:=== AnoDrop: Decentralized Messaging === 1. Handshake (Find new lobbies) 2. Display Unlocked Lobbies 3. Change Master Password 4. Flag Lobby Anomaly 5. Exit Use ARROWS to navigate, ENTER to select.UP/DOWN: Move selection.ENTER: Open the selected option.q/Q: Exit the application.
-
Option 1: Handshake (Join / Create a Lobby):
- Select
1and pressENTER. - Enter Channel Name (Passphrase): Enter the shared rendezvous passphrase agreed upon with your peer (e.g.,
secret-channel). - Enter your Pseudo: Enter your local display handle.
- AnoDrop generates ephemeral X25519/Ed25519 keys, begins DHT rendezvous discovery via
HandshakeCoordinator, and adds the lobby to the local store.
- Select
-
Option 2: Display Unlocked Lobbies (
ui::LobbiesView):- Lists all registered lobbies and their real-time state:
[PENDING]: 3-way handshake in progress on the DHT.[READY]: Handshake completed (HANDSHAKE_DONE), mutual keys exchanged and verified.[Connected]/[Disconnected]: Peer presence status based on heartbeat freshness (<= 300 s).* NEW MSG *: Unread messages waiting in the lobby.[ERROR]: Anomaly flagged (LobbyStatus::ANOMALY). Displays- Anomaly: <reason>in red. Chat remains accessible and messages can still be exchanged.
UP/DOWN: Select a lobby.ENTER: Open the chat view (available once status is[READY]or[ERROR]).d/DEL/Backspace: Forget / hide the selected lobby.q/Q: Return to the main menu.
- Lists all registered lobbies and their real-time state:
-
Chat View & Messaging (
ui::ChatView):- Displays real-time conversation history with delivery indicators:
[Sending...]: Message committed to local WAL; payload uploading to DHT replicas.[Sent]: Payload confirmed on DHT replicas; sequence counter announced.[Ack]: Peer retrieved and committed the message to their local store.[Sending resync...]/[Sent (via resync)]/[Ack (via resync)]: Status indicators during unacknowledged message recovery.
- Interactive Elements:
- Message Input Prompt (
> ..._): Type text directly. [ Leave Chat ]Button: Return to the lobbies menu.[ Resync Pending ]Button: Trigger manual resynchronization of unacknowledged messages.
- Message Input Prompt (
- Displays real-time conversation history with delivery indicators:
| Screen | Key | Action |
|---|---|---|
| Main Menu | Up / Down |
Move menu selection (options 1 to 5) |
| Main Menu | Enter |
Activate selected option |
| Main Menu | q / Q |
Exit application |
| Lobbies List | Up / Down |
Select lobby |
| Lobbies List | Enter |
Open chat for the selected lobby (when [READY]) |
| Lobbies List | d / Del / Backspace |
Forget / hide selected lobby |
| Lobbies List | q / Q |
Return to Main Menu |
| Chat View | Tab |
Cycle focus: Input box β [ Leave Chat ] β [ Resync Pending ] |
| Chat View | Left / Right |
Toggle between [ Leave Chat ] and [ Resync Pending ] |
| Chat View | Up / Down |
Switch focus between input and buttons / scroll history |
| Chat View | PageUp / PageDown |
Fast scroll message history |
| Chat View | Enter (on input) |
Send message |
| Chat View | Enter (on [ Leave Chat ]) |
Return to Lobbies list |
| Chat View | Enter (on [ Resync Pending ]) |
Resynchronize pending messages (ChatResyncer) |
| Password / Anomaly | Enter / q |
Confirm input / cancel and return |
All communication occurs via mutable BEP 44 items. Target addresses are deterministically derived:
Argon2Salt = Serialize(SaltParams(extraValue = config.extra_salt_data_for_lobby_handshake))
LobbySalt = Argon2id(Channel_Passphrase, salt = Argon2Salt,
iterations = config.argon2_iterations,
memory = 65536 KiB, parallelism = 1, output = 32 bytes)
Seed = HKDF-SHA256(Input, salt = empty, info = "DHTSeed", output = 32 bytes)
KeyPair = Ed25519-From-Seed(Seed)
Target ID = SHA1(PublicKey_Ed25519 || LobbySalt)
Here, || denotes byte concatenation.
Every record in the same lobby uses the same 32-byte LobbySalt as its BEP 44 salt. Argon2Salt is the input salt to Argon2id; LobbySalt is its output. The serialized input includes the version, domain ID, key purpose, epoch marker, and configured extra value. Both peers must use matching derivation parameters, including argon2_iterations (default: 5). The raw LobbySalt bytes are used for the target hash and passed to DHT GET and PUT operations.
The message number and the _ACK / _HEARTBEAT_ suffixes belong to Input, which determines the Ed25519 key pair. They are not separate BEP 44 salts. Implementation: SaltParams, Hasher, DhtAddress, and HandshakeKeyService.
Where S is the shared secret derived via X25519 ECDH (S = X25519(K_priv, K_peer_pub)), and A_pub / B_pub are the X25519 public keys.
| Channel / Item | Target Derivation | BEP 44 Salt | Payload & Purpose |
|---|---|---|---|
| Rendezvous Slot | ID(Channel_Passphrase) |
LobbySalt |
AES-256-GCM encrypted 3-way handshake transcript |
| Sequence Announcement | ID(S + A_pub) |
LobbySalt |
Signed plaintext decimal N announcing the latest published message; BEP 44 sequence number is also N |
Message Payload N |
ID(S + A_pub + N) |
LobbySalt |
Sealed Box (ECIES): ephemeral X25519 + AES-256-GCM encrypted message payload |
| Monotonic ACK | ID(S + A_pub + "_ACK") |
LobbySalt |
Signed plaintext cumulative receipt followed by selective message numbers (e.g. "5,7": Alice saved Bob's messages through 5, plus 7) |
| Presence Heartbeat | ID(S + A_pub + "_HEARTBEAT_") |
LobbySalt |
Signed plaintext timestamp adjusted for the agreed fake timezone |
| Anomaly Tombstone | ID(Channel_Passphrase) |
LobbySalt |
Tamper alert published at seq = INT64_MAX replacing handshake |
To prevent impersonation, split-brain states, and race conditions during simultaneous connections:
-
Symmetric Discovery: Every handshake pass starts with a
GETbefore deciding whether toPUT. Blind writes never occur. -
Mutual Confirmation (
confirmed_peer): Alice publishesA_pub. Bob readsA_puband publishes a record containing both keys withconfirmed_peer = A_pub. Alice reads this, validates Bob, and publishes a finalized record withconfirmed_peer = B_pub. -
Adaptive CSMA/CA Delays: If both clients write simultaneously, collisions are resolved using:
Delay = BaseInterval + PubkeyOffset(K_pub) + RandomJitter(0..20 s) -
Deterministic Timezone Tie-Break: Whichever client has the lexicographically smaller Ed25519 public key dictates the synthetic timezone offset for heartbeat calculations.
Publishing the sequence number before the message payload causes a classic distributed systems race condition: a fast recipient notices the sequence bump, attempts to fetch the payload, and fails because DHT storage replicas have not received it yet.
AnoDrop strictly enforces the Outbox Pattern:
- Local WAL Append: Message is assigned sequence
Nand written totransactions.v1.encwithfsync(). - Payload Deposit: Message body is published to
ID(S + A_pub + N). - Replication Confirmation: The DHT engine waits for
dht_put_alertconfirmation from storage nodes. - Sequence Advancement: Only then is the sequence counter at
ID(S + A_pub)updated toN. - Bounded Lookahead Planning: The recipient's
ReceivePlannerdetects the sequence gap and fetches missing items up tomax_concurrent_fetches, avoiding bandwidth spikes.
Use Case & Context:
Alice and Bob want to establish a private, serverless conversation. They have no shared directory, server, or user accounts, but they have agreed out-of-band on a shared channel passphrase (e.g.,meeting-alpha-2026).
Problem Solved:
On an uncoordinated public network like the BitTorrent DHT, concurrent writes can cause split-brain collisions where different storage replicas hold conflicting keys. AnoDrop's 3-way handshake with explicitconfirmed_peertokens and adaptive delays guarantees that both parties converge on a single, mutually verified cryptographic session before any messages are sent.
Two clients meet at the rendezvous target ID(Channel_Passphrase), exchange keys symmetrically, and confirm each other's identity without a server. All payloads on this slot are AES-256-GCM encrypted using a key derived from the shared channel passphrase (HandshakeKey).
sequenceDiagram
autonumber
participant A as Alice
participant C as DHT Rendezvous Channel<br/>ID(Channel_Passphrase)
participant B as Bob
Note over A,B: Channel passphrase derives target ID(Channel_Passphrase) and AES-256 key
A->>A: Generate ephemeral A_priv and A_pub, save to journal
B->>B: Generate ephemeral B_priv and B_pub, save to journal
Note over A,C: Step 1 (Alice Pass 1): GET finds empty slot, initial PUT offer
A->>C: GET ID(Channel_Passphrase)
C-->>A: Empty or not found
A->>C: PUT handshake payload [Alice] (seq = 1)
Note over B,C: Step 2 (Bob Pass 1): GET finds Alice, PUT merged offer with confirmation
B->>C: GET ID(Channel_Passphrase)
C-->>B: Payload [Alice]
B->>B: Validate Alice pubkey, set confirmed_peer = A_pub
B->>B: Derive shared secret S = X25519(B_priv, A_pub)
B->>C: PUT merged payload [Alice, Bob] confirming Alice (seq = 2)
Note over A,C: Step 3 (Alice Pass 2): GET finds Bob, PUT finalized transcript
A->>C: GET ID(Channel_Passphrase)
C-->>A: Payload [Alice, Bob] (Bob confirmed A_pub)
A->>A: Validate Bob pubkey, set confirmed_peer = B_pub
A->>A: Derive shared secret S = X25519(A_priv, B_pub)
A->>C: PUT finalized transcript with 2-way confirmations (seq = INT64_MAX - 1)
C-->>A: PUT confirmed by DHT (Status: [READY])
Note over B,C: Step 4 (Bob Pass 2): GET reads finalized transcript
B->>C: GET ID(Channel_Passphrase)
C-->>B: Finalized transcript (Both confirmed)
B->>B: Both confirmations verified (Status: [READY])
Note over A,B: Both compute conversation channels and stop rendezvous polling
The payload stored in the rendezvous item is a JSON array of PeerInfo structs, encrypted with AES-256-GCM. Items are sorted canonically by x25519_pub to ensure deterministic ordering.
- BEP 44 DHT Sequence Number (
dht_seq):1 - DHT Target & Salt:
ID(Channel_Passphrase), BEP 44 salt =LobbySalt(32 bytes derived from the lobby) - Alice publishes only her own information. Her
confirmed_peeris empty because she has not seen Bob yet:
[
{
"pseudo": "Alice",
"x25519_pub": "a1b2c3d4... (Alice's 64-char hex X25519 public key)",
"fake_timezone": {
"offset_seconds": 3600,
"name": "UTC+1"
},
"confirmed_peer": "",
"protocol_version": 2
}
]- BEP 44 DHT Sequence Number (
dht_seq):2 - DHT Target & Salt:
ID(Channel_Passphrase), BEP 44 salt =LobbySalt(32 bytes derived from the lobby) - Bob fetches Alice's record, derives the shared root
S = X25519(Bob_priv, Alice_pub), and publishes a merged record where he explicitly confirms Alice's public key:
[
{
"pseudo": "Alice",
"x25519_pub": "a1b2c3d4... (Alice's X25519 public key)",
"fake_timezone": {
"offset_seconds": 3600,
"name": "UTC+1"
},
"confirmed_peer": "",
"protocol_version": 2
},
{
"pseudo": "Bob",
"x25519_pub": "d4e5f6a7... (Bob's X25519 public key)",
"fake_timezone": {
"offset_seconds": 7200,
"name": "UTC+2"
},
"confirmed_peer": "a1b2c3d4...",
"protocol_version": 2
}
]- BEP 44 DHT Sequence Number (
dht_seq):9223372036854775806(INT64_MAX - 1) - DHT Target & Salt:
ID(Channel_Passphrase), BEP 44 salt =LobbySalt(32 bytes derived from the lobby) - Alice fetches Bob's offer, verifies that Bob confirmed her key (
confirmed_peer == Alice_pub), derives the same rootS, sets her ownconfirmed_peer = Bob_pub, harmonizes the fake timezone (the lexicographically smaller public key wins), and writes the finalized transcript at sequenceINT64_MAX - 1:
[
{
"pseudo": "Alice",
"x25519_pub": "a1b2c3d4...",
"fake_timezone": {
"offset_seconds": 3600,
"name": "UTC+1"
},
"confirmed_peer": "d4e5f6a7...",
"protocol_version": 2
},
{
"pseudo": "Bob",
"x25519_pub": "d4e5f6a7...",
"fake_timezone": {
"offset_seconds": 3600,
"name": "UTC+1"
},
"confirmed_peer": "a1b2c3d4...",
"protocol_version": 2
}
]- BEP 44 DHT Sequence Number (
dht_seq): Verifies receiveddht_seq == 9223372036854775806(INT64_MAX - 1) - Bob polls
ID(Channel_Passphrase), verifies that both participants have mutually confirmed each other (ourConfirmationPresent && peer.confirmed_peer == Bob_pub), and marks the lobby as[READY]. Both clients cease polling the rendezvous slot and transition to conversation-specific channels.
Use Case & Context:
Alice wants to send a message ("Hi Bob!") to Bob in an established lobby. Alice and Bob cannot connect to each other directly because either or both may be behind symmetric NATs, mobile firewalls, or offline.
Problem Solved:
In distributed hash tables, nodes can be slow or drop packets. If Alice were to increment her sequence counter before the message body was fully replicated, Bob would attempt to fetch a non-existent payload and trigger false alarm errors. AnoDrop enforces the Outbox Pattern: the payload is uploaded and confirmed on DHT storage nodes first, and only after receipt ofdht_put_alertis the public sequence announcement updated.
sequenceDiagram
autonumber
participant A as Alice
participant M as DHT Message 7<br/>ID(S + A_pub + 7)
participant Q as DHT Sequence<br/>ID(S + A_pub)
participant B as Bob
Note over A,B: S is the shared secret (private_node_discussion), A_pub is Alice's public key
A->>A: Commit message 7 to local encrypted WAL
A->>M: PUT encrypted message 7 payload (Body first)
M-->>A: DHT replication confirmed (dht_put_alert)
A->>Q: PUT latest sequence announcement = 7 (Announce progress)
Q-->>A: DHT sequence confirmed
B->>Q: GET Alice's latest sequence number at ID(S + A_pub)
Q-->>B: Latest sequence = 7 (new message detected)
B->>B: ReceivePlanner compares announced (7) with local history (1..6)
B->>M: GET missing message 7 payload at ID(S + A_pub + 7)
M-->>B: Ciphertext payload
B->>B: Decrypt and commit to local journal
-
DHT Channel / Target:
ID(S + A_pub + 7) -
BEP 44 Salt:
LobbySalt(32 bytes derived from the lobby) -
BEP 44 DHT Sequence Number (
dht_seq):7(authoritative message sequencemsg.seq_num) -
Local Persistence: Retained in plaintext JSON inside the local WAL journal
transactions.v1.enc(which is already encrypted on disk byEncryptedJournalusing AES-256-GCM via the Master Key). -
Wire Encryption (Sealed Box): Right before the DHT PUT, the payload is sealed using ECIES:
- An ephemeral one-shot X25519 key pair
(E_priv, E_pub)is generated. - The shared secret
S_box = X25519(E_priv, Bob_pub)is derived. - A 256-bit key is derived via
HKDF-SHA256(S_box, salt = E_pub || Bob_pub, info = "AnoDrop/SealedBox/v1"). - The JSON payload is encrypted using AES-256-GCM with sequence-bound AAD
"AnoDropMessage/v1/7". - The ephemeral private key
E_privis immediately wiped from memory (OPENSSL_cleanse). - Sealed Guarantee: Because
E_privis destroyed and Alice does not haveBob_priv, even Alice cannot decrypt the network packet once sealed. Only Bob can decrypt it usingBob_priv.
- An ephemeral one-shot X25519 key pair
-
DHT Wire Binary Framing:
[ 32 Bytes Ephemeral X25519 PubKey ] [ 12 Bytes Random IV ] [ AES-256-GCM Ciphertext ] [ 16 Bytes GCM Tag ]
- Decrypted Plaintext JSON View (as materialized in memory by Bob upon receiving):
{
"direction": 0,
"content": "Hi Bob! This is message 7 over BitTorrent DHT.",
"fake_timestamp": 1774001234,
"seq_num": 7
}- DHT Channel / Target:
ID(S + A_pub) - BEP 44 Salt:
LobbySalt(32 bytes derived from the lobby) - BEP 44 DHT Sequence Number (
dht_seq):7(tracks highest confirmed published sequence) - Plaintext Payload:
"7"
Use Case & Context:
Alice has sent messages 1 through 7, but due to UDP packet loss or DHT churn, message 6 never reached Bob's local store. Bob has successfully received and saved messages 1 through 5, as well as message 7.
Problem Solved:
A cumulative receipt alone cannot report that message 7 arrived while message 6 is missing. AnoDrop encodes a cumulative receipt followed by selective message numbers ("5,7": all messages through 5, plus 7), allowing Alice'sNetworkResyncHandler(or the user pressing[ Resync Pending ]) to identify unacknowledged messages for retransmission.
sequenceDiagram
autonumber
participant A as Alice
participant M as DHT Alice Message 6<br/>ID(S + A_pub + 6)
participant Q as DHT Alice SeqNum<br/>ID(S + A_pub)
participant K as DHT Bob ACK<br/>ID(S + B_pub + ACK)
participant B as Bob
Note over A,B: Bob sent ACK "5,7" -- message 6 was lost in transit
A->>K: GET Bob's ACK entry
K-->>A: Bob ACK is "5,7" (sequence 6 missing)
A->>M: Outbox retry: republish message 6 at ID(S + A_pub + 6) (dht_seq = 6)
M-->>A: DHT confirmation refreshed
A->>Q: Refresh sequence announcement for 7 (dht_seq = 7)
B->>Q: GET Alice's latest seqNum
Q-->>B: Latest seqNum is 7
B->>B: ReceivePlanner flags gap for 6
B->>M: Retry GET for missing message 6
M-->>B: Encrypted message 6
B->>B: Save only if not already in downloaded_seq_nums
B->>K: PUT updated ACK "7" (dht_seq = timestamp)
A->>K: GET updated ACK
K-->>A: Receipt of 1-7 confirmed (Stop retrying 6)
- DHT Channel / Target:
ID(S + B_pub + "_ACK") - BEP 44 Salt:
LobbySalt(32 bytes derived from the lobby) - BEP 44 DHT Sequence Number (
dht_seq):1774001500(timestamp-based monotonic sequence:targetSeq = fakeTimestamp > current_dht_seq ? fakeTimestamp : current_dht_seq + 1) - Payload (signed plaintext):
"5,7"
Meaning: The first number is the cumulative receipt: Bob has persistently stored all messages through 5. Subsequent numbers identify additional stored messages, here 7. Message 6 is the missing gap. Once 6 is stored, the receipt becomes
"7".
- DHT Channel / Target:
ID(S + A_pub + 6) - BEP 44 Salt:
LobbySalt(32 bytes derived from the lobby) - BEP 44 DHT Sequence Number (
dht_seq):6(NetworkResyncHandlerrepublishes the unconfirmed slot) - Plaintext JSON Payload:
{
"direction": 0,
"content": "Hi Bob! This is message 6.",
"fake_timestamp": 1774001100,
"seq_num": 6
}- DHT Channel / Target:
ID(S + A_pub + 8) - BEP 44 Salt:
LobbySalt(32 bytes derived from the lobby) - BEP 44 DHT Sequence Number (
dht_seq):8(assigned byDhtChat::sendMessageasmy_seq_num + 1) - Plaintext JSON Payload:
{
"direction": 0,
"content": "[Resync of Seq:6 14:30:00] Hi Bob! This is message 6.",
"fake_timestamp": 1774001500,
"seq_num": 8
}- DHT Channel / Target:
ID(S + A_pub) - BEP 44 Salt:
LobbySalt(32 bytes derived from the lobby) - BEP 44 DHT Sequence Number (
dht_seq):8 - Plaintext Payload:
"8"
Use Case & Context:
Bob wants to know if Alice is actively online and listening for messages without triggering active ping/pong probes that leak traffic patterns.
Problem Solved:
Real-time timestamps would leak the user's geographic location (local midnight, working hours). AnoDrop harmonizes a synthetic fake timezone during the handshake and publishes obfuscated timestamps to a dedicated heartbeat channel.
sequenceDiagram
autonumber
participant A as Alice
participant H as DHT Alice heartbeat<br/>ID(S + A_pub + HB)
participant B as Bob
A->>H: PUT latest activity timestamp (adjusted for agreed timezone)
B->>H: GET Alice's heartbeat at the same target
H-->>B: Latest activity timestamp
B->>B: Check timestamp age (<= 300s: Connected, > 300s: Disconnected)
- DHT Channel / Target:
ID(S + A_pub + "_HEARTBEAT_") - BEP 44 Salt:
LobbySalt(32 bytes derived from the lobby) - BEP 44 DHT Sequence Number (
dht_seq):1774001800(timestamp-based monotonic seq:fakeTimestamp > current_dht_seq ? fakeTimestamp : current_dht_seq + 1) - Payload: Signed plaintext Unix timestamp string, adjusted for the agreed fake timezone; the timestamp is not encrypted.
"1774001800"
Online Evaluation: Bob evaluates
abs(t_peer_now - t_heartbeat) <= 300 s. If within 5 minutes, status displays[Connected]; otherwise[Disconnected].
Use Case & Context:
An active attacker attempts a replay attack, injects corrupted sequence numbers, or tampers with encrypted payloads on the DHT.
Problem Solved:
Without central moderation, an attack could cause clients to silently discard messages or desynchronize. AnoDrop triggers an irreversible security tombstone published at the rendezvous target withseq = INT64_MAX, permanently locking the rendezvous slot and alerting both peers in the UI with a persistent[ERROR] - Anomalybanner.
sequenceDiagram
autonumber
participant A as Alice
participant C as DHT Rendezvous Channel<br/>ID(Channel_Passphrase)
participant B as Bob
A->>A: Detect timestamp corruption or security violation
A->>A: Encrypt warning with channel key using AES-256-GCM
A->>C: PUT anomaly record at ID(Channel_Passphrase) with seq = INT64_MAX
A->>A: Persist ANOMALY status locally in journal
B->>C: GET ID(Channel_Passphrase) during routine poll
C-->>B: Encrypted anomaly record
B->>B: Decrypt with channel key (Status set to ANOMALY)
- DHT Channel / Target:
ID(Channel_Passphrase)(rendezvous slot) - BEP 44 Salt:
LobbySalt(32 bytes derived from the lobby) - BEP 44 DHT Sequence Number (
dht_seq):9223372036854775807(INT64_MAX, the maximum possible 64-bit integer) - Plaintext JSON Payload (before AES-256-GCM encryption with channel key):
{
"anomaly": true,
"message": "Corrupted timestamp received from peer"
}Irreversibility Guarantee: In BitTorrent BEP 44, nodes accept updates only if
new_seq > old_seq. BecauseINT64_MAXis the absolute upper bound, no subsequent write can ever overwrite this slot. The rendezvous channel is permanently poisoned and flagged as compromised.
Use Case & Context:
AnoDrop must store private messages, ratchet states, and lobby metadata locally on disk without ever leaking plaintext to unencrypted disk sectors or risking database corruption on sudden power loss or process kill.
Problem Solved:
Traditional SQLite or flat files risk data corruption during interrupted writes and can leak unencrypted data in temporary journals. AnoDrop uses a custom zero-dependency append-only WAL (transactions.v1.enc) protected by AES-256-GCM with positional AAD, atomic::fsync, and zero-downtime compaction via temporary atomic rename. The master password derives a Key Encryption Key (KEK) via Argon2id to unwrap the DEK invault.enc.
sequenceDiagram
autonumber
participant App as Application / Chat
participant Store as LobbyStore
participant IO as IoWorker
participant J as transactions.v1.enc
participant L as Lobby in Memory
App->>Store: transact or commit(LobbyUpdate)
Store->>IO: Enqueue transaction
IO->>J: Append AES-256-GCM record
IO->>J: fsync to durable media
IO->>L: onUpdate to refresh memory index and sequences
Store-->>App: Durable commit completed
Note over Store,J: Periodic Compaction workflow
Store->>IO: compactUnlocked
IO->>IO: Write active messages and snapshot to temp journal
IO->>J: Atomic rename over transactions.v1.enc and directory fsync
IO->>L: resetHistoryRecords to rebind lazy loaders without restart
- Storage Layer: Local Disk Journal (managed by
IoWorkervia single-writer thread) - Record Framing:
[ 4 Bytes LE Payload Length ] [ 12 Bytes Random IV ] [ AES-256-GCM Ciphertext ] [ 16 Bytes GCM Tag ]
- Positional Authenticated Additional Data (AAD):
"AnoDropJournal/v1/<record_index>"(e.g.,"AnoDropJournal/v1/0","AnoDropJournal/v1/1", preventing transposition or truncation attacks).
{
"version": 1,
"lobby": "secret-channel",
"update": {
"my_seq_num": 7,
"new_message": {
"direction": 0,
"content": "Hi Bob! This is message 7 over BitTorrent DHT.",
"fake_timestamp": 1774001234,
"seq_num": 7
},
"message_sent_confirm": 7,
"peer_acked_seq_nums": {
"through": 5,
"selective": [7]
}
}
}AnoDrop includes decrypt_store, a standalone diagnostic binary that inspects, audits, and recovers vault.enc and transactions.v1.enc offline without starting a network daemon.
./build/decrypt_store <master_password> [store_directory=store]================================================================================
ANODROP - ENCRYPTED JOURNAL INSPECTOR
================================================================================
Store directory: "store"
--- [1] VAULT INSPECTION (vault.enc) ---
Vault size: 76 bytes (expected: 76 bytes)
π ENCRYPTED VAULT VIEW (HEX):
β’ Argon2id Salt (16 bytes): 4a 1b 88 c3 ...
β’ Vault IV (12 bytes) : 11 9f 44 e2 ...
β’ Encrypted DEK (32 bytes): 7d 3a 90 f1 ...
β’ GCM Tag (16 bytes) : c5 a1 0e 84 ...
π DECRYPTED VAULT VIEW (DEK / MASTER KEY):
β Master password valid! Successful DEK decryption via KEK.
β’ DEK / MasterKey (HEX) : 3f 8c 12 e9 ...
--- [2] JOURNAL RECORD PARSING (transactions.v1.enc) ---
[Transaction #0 | Offset: 0]
π 1. ENCRYPTED DISK VIEW (HEX & DECIMAL):
β’ Length Prefix (4B LE) : 214 bytes [ d6 00 00 00 ]
β’ Random IV (12B) : [ c3 82 a4 ... ]
β’ GCM Tag Seal (16B) : [ 90 fb 12 ... ]
β’ Positional AAD : "AnoDropJournal/v1/0"
β’ Ciphertext (186 bytes) :
0000 : 8f 3a 21 ...
π 2. DECRYPTED VIEW (PLAINTEXT PAYLOAD):
β Authenticity verified by AES-GCM tag!
{
"type": "create_lobby",
"lobby_name": "secret-channel",
"timestamp": 1774000000
}
================================================================================
Total transactions parsed: 42
Total journal size : 9814 bytes
================================================================================
AnoDrop features a comprehensive 35-test regression and functional test suite with zero Python dependencies, utilizing CMake/CTest and POSIX virtual terminals (forkpty).
# Run all offline regression and contract tests
ctest --test-dir build -L offline --output-on-failure
# Or execute specific test runners directly:
./build/tests/regression/anodrop_regression
./build/tests/regression/anodrop_cpp_runtime
./build/tests/regression/anodrop_network_runtime
./build/tests/regression/anodrop_e2eanodrop_regression(27 tests): Validates state machines, delivery status resolutions, selective ACK compression, sequence gap detection, journal WAL integrity, and password rotation.anodrop_cpp_runtime(7 tests): Fault injection tests verifying exception safety, memory wiping (SecureString), zero leaks on unhandled alerts, and deadlock-free worker draining.anodrop_e2e: End-to-end integration driver that spawns two full AnoDrop processes under POSIX pseudo-terminals (forkpty), driving real ncurses screen buffers, performing handshake negotiation, and exchanging encrypted messages.
This project is licensed under the MIT License β see the LICENSE file for details.