Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

,____  _____ _____ ____   _____  _____ ___  ,______ 
|  _ \| ____| ____|  _ \ / _ \ \/ /_ _|  _ \| ____|
| |_) |  _| |  _| | |_) | | | \  / | || | | |  _|  
|  __/| |___| |___|  _ <| |_| /  \ | || |_| | |___ 
|_|   |_____|_____|_| \_\\___/_/\_\___|____/|_____|

ENCRYPTED BY DEFAULT. PSEUDONYMOUS BY DESIGN.
NO SERVERS. NO ACCOUNTS. NO GATEKEEPERS.
TRUST NO ONE. TALK TO ANYONE.

peeroxide-cli is a command-line toolkit for interacting with the peeroxide P2P networking stack. It provides a set of tools for peer discovery, connectivity diagnostics, and decentralized data transfer, all while maintaining full wire-compatibility with the existing Hyperswarm and HyperDHT networks.

The binary is named peeroxide.

Core Tools

The toolkit consists of eight primary commands:

  • init: Generate configuration files and install man pages.
  • lookup: Query the DHT to find peers announcing a specific topic.
  • announce: Announce your presence on a topic so others can discover you.
  • ping: Diagnose reachability through bootstrap checks, NAT classification, or targeted peer pings.
  • cp: Transfer files directly between peers over an encrypted swarm connection.
  • dd (Dead Drop): Perform anonymous store-and-forward messaging via the DHT. The dd command supports both v1 and v2 protocols, with v2 auto-selected for new put operations.
  • chat: Join topic-based interactive chat rooms.
  • node: Run a long-running DHT bootstrap / coordination node.

Quick Start

It’s recommended to run peeroxide init first to generate a default configuration and install system manual pages.

Key Concepts

To use peeroxide effectively, it helps to understand the underlying architecture:

init

The peeroxide init command handles environment setup by generating configuration files or installing man pages. It provides a non-interactive way to bootstrap your local environment before running other peeroxide subcommands.

Command Modes

The init command operates in two mutually exclusive modes.

Config Mode (Default)

In its default mode, init writes a fresh config.toml file to your configuration directory. It includes a [network] table and commented examples of available fields.

  • First run: Creates parent directories and writes the file.
  • Rerun without flags: Prints a message stating the config already exists and exits with code 0.
  • Rerun with --force: Overwrites the existing file entirely.
  • Rerun with --update: Merges new network.public or network.bootstrap values into the existing file while preserving comments and formatting.

Man-page Mode

When invoked with --man-pages, the command skips configuration and instead generates and installs system man pages.

CLI Flags

FlagTypeDefaultDescription
--forceboolfalseOverwrites an existing config file. Conflicts with --update.
--updateboolfalseUpdates specific fields in an existing config. Requires --public or --bootstrap. Conflicts with --force.
--publicboolfalseSets network.public = true. Adds default public HyperDHT bootstrap nodes.
--bootstrap <ADDR>Vec<String>[]Sets network.bootstrap. Repeatable. In update mode, this replaces the entire bootstrap list.
--man-pages [PATH]PathBuf/usr/local/share/man/Installs generated man pages. Writes to PATH/man1/.

Flag Conflicts

  • --man-pages cannot be used with --force, --update, --public, or --bootstrap.
  • --force and --update are mutually exclusive.
  • --update requires at least one field to change (--public or --bootstrap).

Global CLI Flags

The peeroxide binary defines several global flags that apply to most subcommands. peeroxide init itself only consumes two of them:

  • --config <FILE> — used as the target write path (and as the source path for --update).
  • -v / --verbose — controls tracing verbosity.

The remaining global flags listed below are accepted by the parser but do not affect init (which has its own local --public and --bootstrap flags applied to the generated/updated config). They take effect on subcommands that do DHT work (lookup, announce, ping, cp, dd, chat, node).

FlagTypeDescription
-v, --verboseu8 countIncreases logging level. -v for info, -vv for debug. RUST_LOG overrides this. (Used by init.)
--config <FILE>StringSpecifies a custom path for the config file. For init, this is the write target.
--no-default-configboolSkips loading the default configuration file. (Not consumed by init.)
--publicboolIncludes default public HyperDHT bootstrap nodes. (Not consumed by init; init has its own local --public for the generated config.)
--no-publicboolExcludes default public HyperDHT bootstrap nodes. Conflicts with --public. (Not consumed by init.)
--bootstrap <ADDR>Vec<String>Adds a bootstrap node address (host:port). Repeatable. (Not consumed by init; init has its own local --bootstrap for the generated config.)

Config File Locations

Target Path Precedence (init)

When init determines where to write the config file, it follows this order:

  1. Path provided via --config <FILE>
  2. Environment variable $PEEROXIDE_CONFIG
  3. $XDG_CONFIG_HOME/peeroxide/config.toml
  4. ~/.config/peeroxide/config.toml
  5. Default fallback .config/peeroxide/config.toml

Runtime Load Precedence

When running commands, peeroxide loads configuration in this order:

  1. Path provided via --config <FILE>
  2. Environment variable $PEEROXIDE_CONFIG
  3. $XDG_CONFIG_HOME/peeroxide/config.toml
  4. Platform-specific config directory (e.g., Library/Application Support on macOS)
  5. ~/.config/peeroxide/config.toml

Config Schema

The config file uses the TOML format.

[network]

FieldTypeDefaultDescription
publicboolNoneIf true, adds public bootstrap nodes. If false, removes them.
bootstrapVec<String>NoneList of host:port or ip:port bootstrap addresses.

[node]

FieldTypeDefaultDescription
portu1649737The local port to bind for DHT operations.
hostString"0.0.0.0"The local address to bind.
stats_intervalu6460Interval in seconds for logging node statistics.
max_recordsusize65536Maximum number of DHT records to store.
max_lru_sizeusize65536Maximum size of the LRU cache for routing.
max_per_keyusize20Maximum records allowed per key.
max_record_ageu641200Maximum age in seconds for DHT records.
max_lru_ageu641200Maximum age in seconds for LRU entries.

[announce] and [cp]

These tables are currently empty and reserved for future use.

Bootstrap Resolution

Peeroxide resolves the bootstrap-node list in two stages: a base-list selection from CLI/config (CLI overrides), then a public-default adjustment.

Stage 1 — pick the base list (in peeroxide-cli/src/config.rs):

  • If --bootstrap <ADDR> was supplied (one or more times) on the command line, use only those CLI bootstraps for the base list. The config file’s network.bootstrap is ignored in this case.
  • Otherwise, use the network.bootstrap list from the config file (if any).
  • If neither source supplied bootstraps, the base list starts empty.

Stage 2 — apply the public-default adjustment (in peeroxide-cli/src/cmd/mod.rs::resolve_bootstrap):

  1. If public=true (via flag or config), add the default public HyperDHT bootstrap nodes to the base list.
  2. If the list is still empty after step 1, automatically add the default public HyperDHT bootstrap nodes (so a fresh install with no config and no flags still connects).
  3. If public=false (via --no-public or config), remove all default public bootstrap nodes from the list.

This ensures the node is never isolated unless specifically requested by combining --no-public with an empty bootstrap list. The --no-public flag replaces the legacy --firewalled flag behavior.

Note: this resolution happens at runtime in subcommands that do DHT work (lookup, announce, ping, cp, dd, chat, node). peeroxide init uses its own local --public and --bootstrap flags to populate the generated/updated config file; the base-list selection and public-default adjustment do not run during init.

Man-page Installation

When running peeroxide init --man-pages, the tool:

  1. Identifies the target directory (default /usr/local/share/man/).
  2. Ensures the man1/ subdirectory exists.
  3. Cleans up any existing peeroxide*.1 files in that directory.
  4. Writes fresh man pages for the main command and all subcommands.

Exit Codes

  • 0: Success.
  • 1: Runtime error, file system error, or TOML parsing error.
  • 2: Usage error or invalid arguments provided to the CLI.

DHT and Routing

Peeroxide uses a Kademlia-based Distributed Hash Table (DHT) for peer discovery and coordination. This DHT is wire-compatible with the HyperDHT network used by Hyperswarm.

Kademlia Basics

The DHT operates on several core Kademlia principles:

  • XOR Distance: The “distance” between two nodes or a node and a key is calculated using the bitwise XOR of their 32-byte IDs. This metric defines the topology of the network.
  • Routing Table & k-buckets: Each node maintains a routing table organized into buckets (k-buckets). Each bucket covers a specific range of distances from the node’s own ID.
  • Iterative Lookup: Finding a value or node involves querying the closest known nodes to the target key, which then return even closer nodes, eventually converging on the target.

Peeroxide relies on the pkarr and mainline crates for much of its underlying DHT logic.

Bootstrap Nodes

A DHT is a decentralized network, but new nodes need an entry point to join. These entry points are called bootstrap nodes.

  • Public Network: By default, peeroxide uses a set of stable public bootstrap nodes to connect to the global HyperDHT network. If neither the config file’s network.bootstrap nor the command-line --bootstrap flag supplies any nodes, the runtime auto-fills the public bootstrap set so a fresh install still connects.
  • Configuration: You can supply custom bootstrap nodes via the --bootstrap flag or the network.bootstrap setting in your config file. Note: CLI --bootstrap overrides the config file’s network.bootstrap rather than combining with it.
  • Public Default Adjustments: --public explicitly adds the default public bootstrap nodes (useful when you have custom bootstraps but also want public connectivity). --no-public explicitly removes them from the resolved list.
  • Isolated Mode: Combining --no-public with no custom bootstraps (and no network.bootstrap in the config) yields an empty bootstrap list. In that state, the node has no entry point and can only be reached by peers who already know its address.

Connectivity

The DHT doesn’t just store peer records; it also facilitates connectivity:

  • Holepunching: The DHT helps two firewalled peers coordinate a direct UDP connection.
  • Relaying: If a direct connection is impossible, the DHT can help set up a relayed connection through other nodes.

For more details on how these primitives are used in practice, see the lookup and announce command documentation.

DHT Primitives

This page is a reference for the four core operations that peeroxide-dht exposes and that every higher-level subsystem (announce, lookup, cp, dd, chat) is built on top of. Once you understand DHT and Routing at the conceptual level, this is the next layer down: the actual operations you can perform against the network.

immutable_put / immutable_get — Content-Addressed Storage

Stores arbitrary bytes on DHT nodes, addressed by the BLAKE2b-256 hash of the value itself. Content-addressed: you can only retrieve a value if you already know its hash.

  • immutable_put(value: &[u8]) — computes target = hash(value), queries the K closest nodes to that target, commits the raw bytes. Returns the 32-byte hash.
  • immutable_get(target: [u8; 32]) — queries nodes closest to target; any node that has the value returns it. The client verifies hash(returned_value) == target.
PropertyDetail
Data storedRaw Vec<u8> — arbitrary bytes, no signing, no keys, no seq
Addressinghash(value) — immutable; changing the value yields a different address
Max payload~900–1000 bytes (UDP framing; no explicit code constant)
Wire commandsIMMUTABLE_PUT = 8, IMMUTABLE_GET = 9
DiscoverabilityThe reader must already know the hash (given out-of-band or via a mutable pointer)

mutable_put / mutable_get — Signed, Updateable Storage

Stores arbitrary bytes signed by an Ed25519 keypair, addressed by hash(public_key). The owner can update the value by incrementing a sequence number.

  • mutable_put(key_pair, value: &[u8], seq: u64) — computes target = hash(public_key), signs (seq, value) with the secret key, and sends MutablePutRequest { public_key, seq, value, signature } to the closest nodes.
  • mutable_get(public_key: &[u8; 32], seq: u64) — queries with target = hash(public_key) and a requested minimum seq. Nodes return the stored value only if stored.seq >= requested_seq. The client verifies the signature.
PropertyDetail
Data stored{ public_key: [u8;32], seq: u64, value: Vec<u8>, signature: [u8;64] }
Addressinghash(public_key) — one mutable slot per keypair
Max payload (value)~1002 bytes (token present, seq ≤ 252; derived in Size Budget for mutable_put below)
Seq semanticsStrictly monotonic. SEQ_REUSED (16) error if equal; SEQ_TOO_LOW (17) if lower
Salt supportNot implemented — there is no salt field; one record per keypair
Wire commandsMUTABLE_PUT = 6, MUTABLE_GET = 7

announce / lookup — Peer Discovery and Rendezvous

Originally designed as peer-discovery primitives — store structured peer records (public key + relay addresses) under a topic hash. In this workspace they are also used as a general-purpose rendezvous mechanism: the announcer’s public key acts as a pointer to a further mutable_put slot containing the actual record. See The Rendezvous Pattern below.

  • announce(target: [u8;32], key_pair, relay_addresses) — queries the closest nodes for the topic and sends a signed AnnounceMessage containing HyperPeer { public_key, relay_addresses }. Multiple peers can announce under the same topic simultaneously.
  • lookup(target: [u8;32]) — queries the closest nodes; they return LookupRawReply { peers: Vec<HyperPeer>, bump } — all peers that have announced on that topic (up to 20 per node).
PropertyDetail
Data storedHyperPeer { public_key: [u8;32], relay_addresses: Vec<Ipv4Peer> }
Multi-writerYes — up to 20 announcers per topic per node
IP in stored recordNo — the source IP is not stored in HyperPeer; only the pubkey + relay addresses
Announce with no addressesAllowed — relay_addresses = [] is valid
MAX_RECORDS_PER_LOOKUP20 per node (per-node cap; the total across all queried nodes can exceed 20)
MAX_RELAY_ADDRESSES3 (truncated on store)
Wire commandsLOOKUP = 3, ANNOUNCE = 4, FIND_PEER = 2

Key differences from put/get:

  • lookup / announce is multi-writer — many peers announce under one topic.
  • put / get is single-writer — one value per address.
  • announce stores a small structured record; put stores opaque bytes (up to ~1000 B per record).

The Rendezvous Pattern

An announce “topic” is just a 32-byte address. There’s no constraint that it correspond to a real peer-discoverable resource — it can be:

  • a hash of an application-meaningful key,
  • a deterministic derivation from any shared input (a string, a public key, a secret, a counter, …) via BLAKE2b-256 or any other 32-byte hash,
  • or even a random value, if both writer and reader can agree on it out-of-band.

This makes announce / lookup a generic rendezvous primitive: anyone who can independently arrive at the same 32-byte topic can find every other announcer at it. And because the only structured field the protocol actually requires the announcer to publish is a 32-byte public_key, that pubkey can be treated as a pointer — typically to a mutable_put slot owned by that same ephemeral keypair — rather than as a literal peer identity.

The generic three-step pattern is:

  1. Derive a topic — any agreed-upon function f(...) -> [u8;32]. Writer and reader must arrive at the same value.
  2. Writer — generate an ephemeral keypair k, publish the actual record at mutable_put(k, value, seq), and announce k.public_key on the topic. The relay_addresses field carries no meaning and is typically left empty.
  3. Readerlookup the topic, then mutable_get each returned pubkey to retrieve the actual records in parallel.

This sidesteps mutable_put’s one-record-per-pubkey limitation: a single topic can host many simultaneous writers, each with its own independent mutable_put slot. The TTLs on the rendezvous record and on the payload record are also independent, so a writer can refresh them on different cadences.

Increasing Footprint with Epochs and Buckets

The per-node cap is 20 announcers per topic per node. Two complementary techniques extend that footprint by embedding deterministic salt into the topic derivation:

  • Epochs — incorporate a quantized timestamp (for example, floor(unix_secs / 60)). The topic rotates over time automatically; writer and reader both use the current epoch when deriving the topic, and readers typically scan a small backward window so announcers near a boundary aren’t missed. Epoch rotation also bounds how long an observer can correlate a single topic.
  • Buckets — incorporate a small integer 0..N. Writers hash to one of N possible topics (deterministically or at random); readers scan all N in parallel to find every announcer.

Combining the two yields epoch_window × bucket_count distinct topics for the same logical rendezvous — e.g. a 2-epoch window with 4 buckets gives an effective capacity of 8 × 20 = 160 announcers per node, all discoverable in 8 parallel lookups.

Concrete uses in this workspace

The chat and dd v2 subsystems each lean on this pattern. Their exact topic-derivation rules, epoch/bucket counts, and write/read flows are documented alongside the rest of their wire formats and protocols:

TTL (Time-To-Live)

All stored values are ephemeral — they expire from node storage.

Storage typeTTL (default)
Announcement records (RecordCache)20 minutes (max_record_age)
Mutable / immutable LRU cache20 minutes (max_lru_age)
Router forward entries20 minutes (DEFAULT_FORWARD_TTL)

Clients must periodically re-announce / re-put to keep data alive. The 20-minute default matches the Node.js reference implementation. Both cp and dd issue periodic refreshes during long-running operations for exactly this reason.

Size Budget for mutable_put

The most common protocol-design question is “how many bytes can I put inside one mutable_put value?” Starting from libudx’s MAX_PAYLOAD = 1180 and subtracting the wire overhead for a mutable_put request with the routing token present and seq ≤ 252:

1180  libudx MAX_PAYLOAD
 - 75  outer RPC Request fixed fields (type, flags, tid, to, token, command, target)
 -  3  outer compact-encoding length prefix for put_bytes
 - 32  public_key field
 -  1  seq compact-encoding (1 byte for seq ≤ 252)
 -  3  inner compact-encoding length prefix for value
 - 64  signature
─────
1002  bytes available for the message value payload

In practice the higher-level subsystems reserve a small margin and call this MAX_RECORD_SIZE = 1000 (see chat::wire and deaddrop::v2::wire). Subtract per-record framing — author pubkey, timestamp, content type, signature, length-prefix bytes — to derive the payload budget for your own protocol. The chat subsystem’s Reference and dead drop’s Wire Format chapters carry the exact per-record overhead and the resulting content budgets.

Keys and Identity

Identity in peeroxide is anchored in cryptography. Every peer is identified by an Ed25519 keypair.

Peer Identity

A peer’s Public Key is its stable identity on the network. This key is 32 bytes long and is typically represented as a 64-character hexadecimal string, often prefixed with an @ symbol in the CLI (e.g., @ab12cd34...).

Keypair Types

When running peeroxide commands, you can use different types of keypairs:

  • Ephemeral Keypairs: Generated randomly for a single session. These are used by default when no seed is provided. Once the process exits, the identity is lost.
  • Seeded Keypairs: Derived deterministically from a secret seed string using the --seed flag. The seed is hashed to produce a 32-byte secret, which is then used to generate the Ed25519 keypair. This allows a peer to maintain a stable identity across multiple runs.

Secure Connections

Peeroxide uses the Noise XX handshake protocol to establish authenticated, end-to-end encrypted connections between peers.

  • Authentication: During the handshake, peers exchange and verify their public keys. This ensures that you are communicating with exactly the peer you intended to, and that no man-in-the-middle can impersonate them.
  • Encryption: Once the handshake is complete, all data is sent over a SecretStream, which provides confidentiality and integrity.

For more information on the cryptographic protocols, see the documentation for the ed25519-dalek and noise-protocol crates.

Topics and Discovery

In peeroxide, discovery is organized around topics. A topic is a 32-byte key that serves as a rendezvous point for peers with shared interests.

What is a Topic?

Technically, a topic is always a 32-byte value. However, the CLI allows you to specify topics in two ways:

  1. Raw Hex: A 64-character hexadecimal string is interpreted directly as the 32-byte topic key.
  2. Plaintext Name: Any other string is hashed using BLAKE2b-256 to derive a 32-byte topic key. For example, the topic name "my-application" becomes the hash of those bytes.

This dual approach allows for both human-readable “namespaced” discovery and opaque, randomly generated “private” rendezvous points.

How Discovery Works

The discovery process involves two main actions:

Announcing

When you announce on a topic, you are telling the DHT that your peer identity (public key) is available for connections related to that topic. The swarm automatically handles re-announcing at regular intervals to ensure your record stays fresh on the DHT.

Looking Up

When you lookup a topic, you query the DHT for the public keys and addresses of all peers currently announcing on that topic.

Usage in Tools

Topic-based discovery is the foundation for several peeroxide commands:

  • lookup: Find peers on a topic.
  • announce: Join a topic.
  • cp: Uses a topic as a one-time rendezvous point for a file transfer.
  • ping: Can resolve a topic name to find and ping the associated peers.

By using the same topic name or hex key across different tools and peers, you can easily build decentralized workflows without needing a central coordinator.

Lookup Overview

The lookup command queries the Distributed Hash Table (DHT) to discover peers announcing a specific topic. It provides a way to find connection points (relay addresses and public keys) for any given service or dataset.

Topic Resolution

Topics can be provided as either a plaintext string or a raw 64-character hexadecimal key.

  • Raw Key: If the input is exactly 64 hex characters, it is treated as a raw 32-byte key.
  • Plaintext: Otherwise, the input string is hashed using BLAKE2b-256 via the discovery_key function to derive the target topic key.

Usage

peeroxide lookup <TOPIC> [FLAGS]

Flags

FlagDescription
--with-dataFetch metadata stored on the DHT for each discovered peer.
--jsonOutput results as Newline Delimited JSON (NDJSON) to stdout.

Peer Discovery and Deduplication

The lookup command identifies unique peers by their 32-byte public key. If multiple DHT records are found for the same public key, their relay addresses are merged into a single union set, ensuring no duplicate addresses are displayed for a single peer.

Peers are displayed in the order they were first seen during the lookup process.

Metadata Retrieval

When the --with-data flag is used, peeroxide performs a mutable_get for each discovered peer’s public key (using seq=0). This process runs concurrently with a concurrency limit of 16 to ensure high performance even when many peers are found.

The status of the data retrieval is reported for each peer, indicating whether data was found, missing, or if an error occurred during retrieval.

See Also

Lookup Output Formats

The lookup command supports two output modes: human-readable (default) and NDJSON.

Human-Readable Output

By default, lookup writes diagnostic information and peer records to stderr. The stdout stream remains empty.

The output begins with the resolved topic:

  • LOOKUP <hex> (if raw hex was provided)
  • LOOKUP blake2b("topic") (if plaintext was provided)
  • found <N> peers

Peer Record

For each peer found:

  • @<hex_public_key>
  • relays: host:port, ... (or (direct only) if no relays are registered)

Metadata (with --with-data)

If data is successfully retrieved:

  • data: "string" (escaped UTF-8) or 0x<hex> (if binary)
  • seq: <u64>

Statuses for missing or failed data:

  • data: (not stored)
  • data: (error: <message>)

JSON Output (NDJSON)

When the --json flag is used, lookup emits Newline Delimited JSON to stdout. Diagnostic logs may still appear on stderr.

Peer Schema

Each discovered peer is emitted as a separate JSON object:

{
  "type": "peer",
  "public_key": "<hex>",
  "relay_addresses": ["host:port", ...]
}

Peer Schema (with --with-data)

If metadata is requested, the peer object includes status fields:

  • Success:

    {
      "type": "peer",
      "public_key": "...",
      "relay_addresses": [...],
      "data_status": "ok",
      "data": "<string>",
      "data_encoding": "utf8"|"hex",
      "seq": <u64>
    }
    

    Note: The data field contains raw hexadecimal characters (without a 0x prefix) when the encoding is "hex".

  • Missing:

    {
      "type": "peer",
      "data_status": "none",
      "data": null,
      "seq": null,
      ...
    }
    
  • Error:

    {
      "type": "peer",
      "data_status": "error",
      "data": null,
      "seq": null,
      "error": "<message>",
      ...
    }
    

Summary Schema

A final summary object is emitted after all peers have been processed:

{
  "type": "summary",
  "topic": "<hex>",
  "peers_found": <int>
}

Exit Codes

CodeMeaning
0Success
1Fatal error (e.g., DHT failure, invalid arguments)
130Terminated by SIGINT (Ctrl+C)

Announce Overview

The announce command makes your node discoverable on the DHT for a specific topic. It allows other peers using lookup to find your public key and connection details.

Identity and Keypairs

By default, announce generates a random ephemeral keypair for each session.

  • Ephemeral: A new identity is created on startup and lost when the process exits.
  • Seeded: Using the --seed <string> flag, you can derive a deterministic keypair. The seed is hashed using the discovery_key function to produce the 32-byte secret seed.

Usage

peeroxide announce <TOPIC> [FLAGS]

Flags

FlagDescription
--seed <string>Use a seed to maintain a stable identity across restarts.
--data <string>Store metadata (max 1000 bytes) on the DHT.
--duration <sec>Exit automatically after the specified number of seconds.
--pingEnable the Echo Protocol to accept and respond to connectivity probes.

Metadata

The --data flag allows you to attach a small payload (up to 1000 UTF-8 bytes) to your DHT record. This is useful for sharing service versioning, protocol capabilities, or small state updates.

  • Sequence Numbers: Metadata is stored with a sequence number (seq) based on the current Unix epoch in seconds.
  • Refresh: To ensure your record does not expire, announce automatically refreshes the metadata every 600 seconds (10 minutes) while the process is running.

Output

All output from announce is written to stderr. The stdout stream is always empty.

Startup

  • ANNOUNCE blake2b("topic") as @<pk_hex>
  • announced to closest nodes
  • metadata: "..." (<N> bytes, seq=<u64>) (if applicable)

Shutdown

  • UNANNOUNCE blake2b("topic") (or UNANNOUNCE <hex> if raw hex was provided)
  • done

Exit Codes

CodeMeaning
0Success (including exit via SIGINT or SIGTERM)
1Fatal error (e.g., DHT failure, invalid data size)

See Also

Announce Architecture

The announce command manages a long-running swarm session, coordinating DHT presence and optionally handling incoming connections.

Initialization Flow

  1. Identity Generation: A KeyPair is either generated randomly or derived from a seed.
  2. Swarm Setup: A SwarmConfig is constructed with the identity and DHT configuration. The --public / network.public setting drives bootstrap node selection (see init/overview.md → Global CLI Flags); it does not change firewall semantics.
  3. Joining Topic: The node joins the topic using JoinOpts { client: false }. This instructs the DHT to act as a server for this topic, making the node discoverable to lookup queries.
  4. Flushing: The node waits for the join operation to flush, ensuring at least one announcement has reached the DHT.

Metadata Persistence

If the --data flag is provided, the node performs an initial mutable_put to the DHT.

  • Storage: The data is signed by the node’s private key and stored at the node’s public key address on the DHT.
  • Sequence: The seq field is set to the current Unix epoch in seconds.
  • Lifecycle: A background task triggers every 600 seconds to re-put the data, preventing expiration and keeping the DHT record fresh.

Connection Management

When a peer discovers this node via lookup, they may attempt to open a direct UDX connection.

  • Accepting: The announce loop listens on the conn_rx channel for incoming connections.
  • Modes:
    • Default: Incoming connections are immediately dropped to minimize resource usage.
    • Echo Mode (--ping): Connections are accepted and passed to the Echo Protocol handler.

Shutdown Sequence

The command remains active until it receives a termination signal (SIGINT or SIGTERM) or the --duration timer expires.

  1. Unannounce: The node sends a leave request to the DHT for the active topic.
  2. Cleanup: The background refresh task is aborted, and the swarm handle is destroyed.
  3. Exit: The process exits with code 0.

Echo Protocol

The Echo Protocol is a simple diagnostic service enabled by the --ping flag in the announce command. It allows remote peers to measure latency and verify end-to-end connectivity over the peeroxide swarm.

Protocol Constants

  • PING_MAGIC: 0x50 0x49 0x4E 0x47 (b"PING")
  • PONG_MAGIC: 0x50 0x4F 0x4E 0x47 (b"PONG")
  • MAX_ECHO_SESSIONS: 64 (concurrency limit)
  • HANDSHAKE_TIMEOUT: 5 seconds
  • IDLE_TIMEOUT: 30 seconds
  • ECHO_MSG_LEN: 16 bytes

Echo Probe Frame

Each probe message is exactly 16 bytes long and follows this layout:

OffsetSizeFieldDescription
08sequ64, little-endian sequence number
88timestamp_nanosu64, little-endian Unix timestamp in nanoseconds

Session Lifecycle

  1. Accept: The server accepts an incoming UDX connection.
  2. Semaphore Acquisition: The server attempts to acquire a permit from a 64-slot semaphore. If all slots are full, the connection is dropped.
  3. Handshake:
    • The server waits up to 5 seconds for the client to send the PING_MAGIC (4 bytes).
    • If the magic matches, the server replies with PONG_MAGIC (4 bytes).
  4. Echo Loop:
    • The server enters a loop, waiting for messages from the client with a 30-second idle timeout.
    • Each message must be exactly 16 bytes (ECHO_MSG_LEN).
    • The server echoes the exact 16-byte message back to the client.
  5. Disconnect: The session ends if the timeout is reached, a message of incorrect length is received, or the stream is closed.
  6. Release: The semaphore permit is released, allowing a new session to begin.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Server
    
    Client->>Server: Connect (UDX)
    Note over Server: Acquire Semaphore (max 64)
    
    Client->>Server: "PING" (4 bytes)
    Server->>Client: "PONG" (4 bytes)
    
    loop Echo Loop
        Client->>Server: 16-byte Probe
        Server->>Client: 16-byte Probe (Echo)
    end
    
    Note over Client,Server: Idle Timeout (30s) or Close
    Note over Server: Release Semaphore

Logging

The announce command logs session events to stderr:

  • [connected] @<pk> (echo mode)
  • [disconnected] @<pk> (<N> probes echoed)

Ping Overview

The ping tool is a multi-purpose diagnostic utility for the Peeroxide network. It allows you to verify connectivity to bootstrap nodes, classify your local NAT type, and perform targeted probes of peers discovered on the DHT.

Usage

peeroxide ping [target] [flags]

Targets

The ping tool behaves differently depending on the provided target:

  • No target: Performs a bootstrap check. It probes all configured bootstrap nodes to populate the local routing table, collect reflexive addresses, and classify your NAT type.
  • host:port: Performs a direct UDP probe to a specific network address.
  • @<64-char-hex-pubkey>: Resolves the peer’s relay addresses via find_peer and probes them.
  • <topic>: Resolves up to 20 peers announcing on the given topic (specified as a plain string or 64-char hex) and probes their relay addresses.

Operational Modes

Bootstrap Check

When run without a target, ping sends CMD_FIND_NODE requests to bootstrap nodes. This process:

  1. Verifies reachability to the core network.
  2. Discovers your reflexive (public) IP and port as seen by multiple nodes.
  3. Classifies your NAT Type based on the consistency of these reflexive addresses.
  4. Populates your local DHT routing table with closer nodes.

UDP Probing (Direct, PubKey, Topic)

Standard probes use the DHT CMD_PING RPC. This is a lightweight UDP-based check that verifies the target node is online and responding at the network level.

Connection Probing (--connect)

When the --connect flag is used with a PubKey or Topic target, the tool performs a full Noise XX handshake and establishes a SecretStream. After the secure connection is established, it executes the Echo Protocol to measure end-to-end encrypted latency.

Flags

  • --count <N>: Number of probes to send (default: 1). Set to 0 for infinite probing.
  • --interval <seconds>: Delay between probes (default: 1.0s).
  • --connect: Attempt a full Noise handshake and Echo protocol test.
  • --json: Output results as newline-delimited JSON (NDJSON) for machine consumption.
  • --public: Use the public bootstrap network (shorthand for adding default bootstrap nodes).

Exit Codes

  • 0: All probes succeeded.
  • 1: Partial or total failure (e.g., timeouts, resolution errors).
  • 130: Interrupted by SIGINT (Ctrl+C).

Ping Architecture

The ping tool operates at two distinct layers of the Peeroxide stack: the DHT RPC layer (UDP) and the Secure Stream layer (encrypted TCP-like transport).

Control Flow

The primary logic resides in peeroxide-cli/src/cmd/ping.rs. Depending on the target type, it orchestrates a resolution phase followed by a probing phase.

Targeted Probing with --connect

When using @pubkey or <topic> with the --connect flag, the tool follows this sequence:

  1. Resolution: Queries the DHT (find_peer or lookup) to discover relay addresses for the target peer(s).
  2. Handshake: Initiates a Noise XX handshake via the discovered relay addresses.
  3. SecretStream: Establishes a multiplexed, encrypted stream.
  4. Echo Protocol: Once the stream is ready, it initiates the Echo Protocol.
sequenceDiagram
    participant CLI as ping --connect
    participant DHT as HyperDHT
    participant Peer as Remote Peer

    CLI->>DHT: find_peer(remote_pk)
    DHT-->>CLI: relay_addresses[]
    
    rect rgb(240, 240, 240)
    Note over CLI, Peer: Noise XX + SecretStream
    CLI->>Peer: Noise Handshake (connect_with_nodes)
    Peer-->>CLI: Handshake Complete
    end

    rect rgb(220, 240, 220)
    Note over CLI, Peer: Echo Protocol
    CLI->>Peer: PING (4 bytes)
    Peer-->>CLI: PONG (4 bytes)
    loop Every interval
        CLI->>Peer: Probe [seq, timestamp] (16 bytes)
        Peer-->>CLI: Echo [seq, timestamp] (16 bytes)
        Note right of CLI: Latency = send_time.elapsed()
    end
    end

NAT Classification Logic

During a bootstrap check (no target), the tool collects reflexive addresses (Ipv4Peer) returned by multiple bootstrap nodes in their response to field.

The classification is performed by the NatType enum:

  • Open: The reflexive IP is a local interface address (verified by attempting to bind the IP) AND the reflexive port matches the local bound port.
  • Consistent: All reflexive samples report the same host:port, but the IP is external. This indicates a hole-punchable NAT.
  • Random: The reflexive host is the same across samples, but the ports vary. This typically requires relaying.
  • MultiHomed: Different bootstrap nodes report different reflexive hosts.
  • Unknown: No reflexive address samples were collected.

DHT Interaction

  • Bootstrap mode: Uses dht.ping(host, port) which translates to a CMD_FIND_NODE request on the wire. This is used because bootstrap nodes are expected to return closer nodes to help populate the routing table.
  • Direct/Targeted mode: Uses the same dht.ping mechanism but focuses on the RTT and reachability of the specific target.

Ping Output Formats

The ping tool provides human-readable output on stderr by default and machine-parseable NDJSON on stdout when the --json flag is used.

Human-Readable Output (stderr)

Bootstrap Check

BOOTSTRAP CHECK (3 nodes)
  bootstrap1.example.com:49737  OK  12ms  (20 nodes)  node_id=ab12...
  bootstrap2.example.com:49737  TIMEOUT
--- bootstrap summary ---
3 nodes, 2 reachable, 1 unreachable
50 unique peers discovered via routing tables
public address: 1.2.3.4:5000 (consistent across 2 nodes)
NAT type: consistent (hole-punchable)

Targeted Ping

PING 1.2.3.4:49737 (direct)
  [1] OK  12ms  node_id=ab12...
--- 1.2.3.4:49737 ping statistics ---
1 probes, 1 responded, 0 timed out (0% probe loss)
rtt min/avg/max = 12.0/12.0/12.0 ms

JSON Output (stdout)

JSON output is emitted as newline-delimited objects.

Resolve Events

Emitted before probing starts for PubKey and Topic targets.

{"type":"resolve","method":"find_peer","public_key":"<64-hex>"}
{"type":"resolve","status":"found","addresses":2}

Probe Events

Emitted for each individual probe. UDP probes use rtt_ms, while encrypted echo probes use latency_ms.

// UDP probe (direct/bootstrap)
{"type":"probe","seq":1,"status":"ok","rtt_ms":12.3,"node_id":"<hex>"}

// Echo probe (connect mode)
{"type":"probe","seq":1,"status":"ok","latency_ms":48.3}

Summary Events

Emitted at the end of a session if count > 1 or count == 0.

// Bootstrap summary
{
  "type": "bootstrap_summary",
  "nodes": 3,
  "reachable": 2,
  "unreachable": 1,
  "nat_type": "consistent",
  "closer_nodes_total": 50,
  "public_host": "1.2.3.4",
  "public_port": 5000,
  "port_consistent": true
}

// Targeted summary
{
  "type": "summary",
  "target": "1.2.3.4:49737",
  "probes_sent": 5,
  "probes_responded": 4,
  "probes_timed_out": 1,
  "rtt_min_ms": 11.0,
  "rtt_avg_ms": 20.5,
  "rtt_max_ms": 45.0
}

Overview

The cp command provides secure, point-to-point file transfers between peers over the Hyperswarm network. It allows you to send and receive files using a shared topic, which serves as a rendezvous point on the DHT.

Key Features

  • Direct Transfers: Files are streamed directly between peers using UDX, ensuring high performance and reliable delivery.
  • Topic-Based Discovery: Use human-readable strings or 64-character hex keys to coordinate transfers without needing to know the other peer’s IP address.
  • Piping Support: Full support for stdin (-) as a source and stdout (-) as a destination, making it easy to integrate with other command-line tools.
  • Progress Tracking: Real-time progress bars and transfer statistics provided via stderr.
  • End-to-End Encryption: All transfers are secured using Noise handshakes via SecretStream.

Basic Usage

Sending a File

To send a file, specify the file path and a topic. The command will output the topic (useful if you let it generate a random one) and wait for a receiver.

peeroxide cp send my-file.txt "shared-topic"

Receiving a File

To receive a file, use the same topic. You can specify a destination path or a directory.

peeroxide cp recv "shared-topic" ./downloads/

Streaming with Pipes

You can pipe data directly through cp:

# Sender
cat data.tar.gz | peeroxide cp send - "backup-topic"

# Receiver
peeroxide cp recv "backup-topic" - > restored.tar.gz

Command Options

send Options

  • file: Path to the file to send, or - for stdin.
  • topic: Optional topic name or 64-char hex key.
  • --name: Override the filename sent in the metadata.
  • --keep-alive: Keep the sender running for multiple sequential transfers.
  • --progress: Show a transfer progress bar.

recv Options

  • topic: The shared topic from the sender.
  • dest: Optional destination path or directory, or - for stdout.
  • --yes: Skip the confirmation prompt.
  • --force: Overwrite existing files without asking.
  • --timeout: Seconds to wait for a sender (default: 60).
  • --progress: Show a transfer progress bar.

cp — Protocol

The cp tool is built on top of the peeroxide swarm, leveraging the DHT for peer discovery and UDX for efficient data transport.

Data Flow

The transfer process involves two main stages: discovery and streaming.

1. Discovery

  • Sender: Joins the topic on the DHT as a server (JoinOpts { client: false, .. }). It announces its presence and waits for incoming connections.
  • Receiver: Joins the topic on the DHT as a client (JoinOpts { server: false, .. }). It looks for peers announcing the topic and attempts to connect to them.

2. Protocol Handshake

Once a connection is established, the peers perform a simple JSON-based handshake:

  1. Metadata: The sender transmits a JSON object containing file information:
    • filename: The name of the file being sent.
    • size: The total size in bytes (if known; null when streaming from stdin).
    • version: The protocol version (currently 1).
  2. Acceptance: The receiver validates the metadata and (unless --yes is used) prompts the user for confirmation.

3. Streaming

Data is streamed in chunks (CHUNK_SIZE = 65536 bytes) over the established SecretStream.

sequenceDiagram
    participant S as Sender
    participant DHT as DHT / Bootstrap
    participant R as Receiver

    S->>DHT: Announce Topic
    R->>DHT: Lookup Topic
    DHT-->>R: Peer Address (Sender)
    R->>S: Establish Connection (UDX + Noise XX)
    S->>R: Send Metadata (JSON)
    Note over R: Prompt User (unless --yes)
    loop Data Streaming
        S->>R: Data Chunk (64 KB)
    end
    S->>R: Shutdown Stream
    R-->>S: Connection Closed

Implementation Details

Chunking

While the underlying UDX protocol handles packetization, cp reads and writes data in 64 KB blocks. This balances memory usage with throughput efficiency.

File Handling

  • Temporary Files: During a receive operation, data is written to a hidden temporary file in the destination directory.
  • Atomic Rename: Once the transfer is complete and the received size matches the expected size, the temporary file is renamed to the final destination. This prevents partial or corrupted files if a transfer is interrupted.
  • Sanitization: Filenames provided by the sender are sanitized to prevent path traversal attacks (e.g., stripping .. components and leading slashes).

Network Configuration

The cp command uses the same runtime bootstrap-resolution as every other DHT-using subcommand (build_dht_config(cfg) in peeroxide-cli/src/cmd/mod.rs). Bootstrap node selection is therefore driven by the shared rules documented in init/overview.md → Global CLI Flags: CLI --bootstrap overrides the config file’s network.bootstrap; --public adds default public bootstrap nodes; an empty list auto-fills with the defaults; --no-public removes them. The --public flag does not change the node’s firewall state; NAT traversal for cp always relies on hole-punching via the DHT.

cp — Output Formats

The cp command separates data, status, and progress across output streams.

Standard Output (stdout)

send subcommand

Prints the transfer topic to stdout so it can be captured or piped to the receiver:

a3f2b1...  (64-char hex topic key)

recv subcommand (streaming mode)

When destination is -, file data is written directly to stdout:

peeroxide cp recv <topic> - > received_file.bin

Standard Error (stderr)

All status messages, progress, and connection info go to stderr, keeping stdout clean for data.

Sender output:

CP SEND example.bin (1.2 MB)
topic: a3f2b1...
connected from @deadbeef...
done: 1.2 MB in 2.3s (540 KB/s)

Receiver output:

CP RECV topic: a3f2b1...
looking up sender...
connected to @deadbeef...
Incoming file: example.bin (1.2 MB)
Save to: ./example.bin
done: 1.2 MB in 2.3s (540 KB/s)

Progress Display

When transfer size is known, cp displays a progress bar on stderr showing percentage, bytes transferred, speed, and ETA. When size is unknown (stdin source), a spinner with running byte count is shown instead.

Reliability

cp does not implement retransmission or resumption at the application layer. Reliability is provided by the UDX transport layer (BBR congestion control, retransmission, ordering). If a transfer fails mid-stream:

  • The temporary file is left in place (not renamed to final destination).
  • Re-running cp recv with the same topic will restart the transfer from the beginning.
  • The sender must still be running and on the same topic.

For large files over unreliable networks, consider compressing the payload before transfer to reduce exposure to interruptions.

Dead Drop Overview

The dd command provides an anonymous, asynchronous store-and-forward mechanism using the DHT. It allows a sender to “put” data on the network that a receiver can later “get” using a unique key, without requiring both parties to be online at the same time.

Unlike the cp command, which establishes a direct peer-to-peer connection, dd uses DHT records to store data. This makes it ideal for scenarios where the sender and receiver have intermittent connectivity or want to avoid direct IP discovery.

Key Features

  • Asynchronous Delivery: Data is stored on DHT nodes. The receiver picks it up whenever they’re ready.
  • Protocol Versions: Supports both the original v1 linked-list protocol and the high-performance v2 tree-indexed protocol.
  • Passphrase Support: Pickup keys can be derived from human-readable passphrases.
  • Anonymity: No direct connection is established between the sender and receiver.
  • Acknowledgements: Optional pickup notifications (acks) let the sender know when data was retrieved.
  • Progress Control: Use --no-progress for silent operation or --json for machine-readable event streams.

Protocol Selection

The dd command supports two protocol versions:

VersionCharacteristicsSelection
V1Simple linked-list of mutable records. Limited to 64MB. Sequential fetches.Explicit via --v1 on put. Auto-detected on get.
V2Merkle-tree indexed. Massive capacity. Parallel fetching with need-lists and AIMD congestion control.Default on put. Auto-detected on get.

Dispatch Rules

  • Putting: dd put defaults to v2. Use the --v1 flag to force the legacy protocol.
  • Getting: dd get automatically dispatches based on the first byte of the fetched root record (0x01 for v1, 0x02 for v2).

Quick Start

Putting Data

Put a message using a passphrase (v2 by default):

echo "Hello from the void" | peeroxide dd put - --passphrase "my secret drop"

Put a file using a raw key (generated randomly):

peeroxide dd put my-file.dat

Force v1 for compatibility with older clients:

peeroxide dd put my-file.dat --v1

Getting Data

Retrieve data using a passphrase:

peeroxide dd get --passphrase "my secret drop"

Retrieve data using a 64-character hex pickup key:

peeroxide dd get 7215c9...82a3

Write to a file while suppressing progress bars:

peeroxide dd get 7215c9...82a3 --output saved-file.dat --no-progress

How it Differs from cp

Featurecpdd
ConnectionDirect P2P (UDX)Mediated via DHT storage
Online RequirementBoth must be onlineAsynchronous
DiscoveryTopic-basedKey-based (Public Key)
SpeedHigh (Direct)Moderate (DHT round-trips)
MetadataFilename, sizeSequential or Tree chunks

Dead Drop Architecture

The dd command implements two distinct protocol architectures for storing and retrieving data on the DHT. Both protocols are built on the DHT primitives documented in DHT Primitives (mutable_put / mutable_get / immutable_put / immutable_get / announce).

Protocol V1: Linear Chain

The V1 protocol is a simple linked list of mutable DHT records. Each record contains a portion of the file and the public key of the next chunk in the chain.

V1 Flow

sequenceDiagram
    participant S as Sender
    participant DHT as DHT Nodes
    participant R as Receiver

    Note over S: Chunking + Key Derivation
    S->>DHT: mutable_put(root_pk)
    S->>DHT: mutable_put(chunk_1_pk)
    S->>DHT: ...
    S->>DHT: mutable_put(chunk_N_pk)

    Note over R: Get root_pk
    R->>DHT: mutable_get(root_pk)
    DHT-->>R: root record
    loop Sequential Fetch
        R->>DHT: mutable_get(next_pk)
        DHT-->>R: chunk record
    end

V1 features sequential fetching with exponential retry logic (1s to 30s) per chunk, bounded by the global timeout.

Protocol V2: Merkle Tree

V2 uses a hierarchical tree structure to enable massive file support and parallel fetching.

V2 Flow

sequenceDiagram
    participant S as Sender
    participant DHT as DHT Nodes
    participant R as Receiver

    Note over S: Canonical Tree Build
    S->>DHT: immutable_put(data_chunks)
    S->>DHT: mutable_put(index_chunks)
    S->>DHT: mutable_put(root_pk)

    Note over R: BFS Parallel Fetch
    R->>DHT: mutable_get(root_pk)
    DHT-->>R: root (metadata + top slots)
    
    rect rgb(240, 240, 240)
        Note over R: Parallel BFS Loop
        R->>DHT: mutable_get(index_pk)
        R->>DHT: immutable_get(data_hash)
    end

    Note over R: Need-list Cycle
    R->>DHT: announce(need_topic)
    R->>DHT: mutable_put(need_topic, ranges)
    DHT-->>S: watch(need_topic)
    S->>R: Republish missing chunks

AIMD Congestion Control

V2 employs an Additive Increase / Multiplicative Decrease (AIMD) controller to manage concurrency:

  • EWMA-based: Smoothes sample noise with an alpha of 0.1.
  • Decision interval: 20 samples.
  • Fast-trip: Shrinks immediately if 10 degraded samples occur within a window.
  • Shrink: 0.75x current (minimum 1).
  • Grow: +2 permits.

Robustness Mechanisms

  • Stall Watchdog: Checks every 5s. If no put resolves for 30s, it forces AIMD to a recovery floor.
  • Sliding-window Timeout: get operations abort only if no chunk decodes for --timeout seconds.
  • Graceful Shutdown: First Ctrl-C triggers a sticky cancel signal that enqueues cleanups (like empty need-list sentinels). A second double-press force-exits.
  • Need-list Lifecycle: Receivers publish the encoded missing-range need-list via mutable_put every 20s and announce keepalive on the need topic every 60s. Senders poll the need topic every 5s and prioritize enqueuing the full path (index + data) for any newly-listed chunks.

DHT Wire Monitoring

The dd command monitors raw network overhead by reading atomic counters from the underlying DHT handle.

MethodReturn
wire_stats()(u64, u64) (sent, received)
wire_counters()WireCounters (shared atomic handles)

These counters allow the progress UI to calculate “wire amplification” — the ratio of total bytes sent/received versus actual payload bytes delivered.

Dead Drop Wire Format

The dd command supports two versioned wire formats for DHT records. All multi-byte integers are encoded in little-endian (LE) byte order. The underlying DHT operations (mutable_put / mutable_get / immutable_put / immutable_get) are documented in DHT Primitives.

Version 1 Wire Format

V1 records are limited to 1000 bytes total and form a linear linked list of mutable records.

V1 Constants

  • MAX_CHUNKS: 65,535
  • MAX_PAYLOAD: 1,000 (total record limit)
  • ROOT_HEADER_SIZE: 39
  • NON_ROOT_HEADER_SIZE: 33
  • ROOT_PAYLOAD_MAX: 961
  • NON_ROOT_PAYLOAD_MAX: 967
  • VERSION: 0x01

V1 Layouts

Root Chunk (V1)

[ver: 1][total_chunks: 2 LE][crc32c: 4 LE][next_pk: 32][payload: up to 961]

Non-root Chunk (V1)

[ver: 1][next_pk: 32][payload: up to 967]

Version 2 Wire Format

V2 records use a tree structure. Data chunks are stored in immutable records, while index and root chunks are stored in mutable records.

V2 Constants

  • VERSION: 0x02
  • MAX_CHUNK_SIZE: 1,000
  • DATA_HEADER_SIZE: 2
  • DATA_PAYLOAD_MAX: 998
  • NON_ROOT_INDEX_HEADER_SIZE: 1
  • NON_ROOT_INDEX_SLOT_CAP: 31
  • ROOT_INDEX_HEADER_SIZE: 13
  • ROOT_INDEX_SLOT_CAP: 30
  • NEED_LIST_HEADER_SIZE: 3
  • NEED_ENTRY_SIZE: 8
  • NEED_LIST_ENTRY_CAP: 124

V2 Tree Structure

The tree is constructed bottom-up. Leaf layers pack 31 data hashes per index chunk. Higher layers pack 31 index pubkeys per chunk. The root holds the top-layer keys directly.

DepthMax Data ChunksCapacity (approx)
03029 KB
1930928 KB
228,83028 MB
3893,730891 MB
427,705,63027 GB

Note: The implementation enforces a SOFT_DEPTH_CAP of 4.

V2 Layouts

Data Chunk (V2)

Stored via immutable_put. The salt is reserved for randomization but currently fixed at 0x00.

[0x02][salt: 0x00][payload: up to 998]

Non-root Index Chunk (V2)

Stored via mutable_put. Contains 32-byte slots (either data hashes or child index pubkeys).

[0x02][slots: 31 x 32]

Root Index Chunk (V2)

The entry point. Contains file metadata and top-level slots.

[0x02][file_size: 8 LE][crc32c: 4 LE][slots: 30 x 32]

Need-list Record (V2)

Published by the receiver on the need topic to request missing data.

[0x02][count: 2 LE][entries: count x {start: 4 LE, end: 4 LE}]

Each entry is a half-open range [start, end) of data-chunk indices in the canonical DFS file order (chunk 0 is the first chunk of the file, chunk 1 is the next, etc.). The sender consults the need-list and republishes every data chunk in any listed range, plus the full index-tree path required to make those data chunks reachable.

When the receiver has no missing chunks, it publishes a “receiver done” sentinel: a raw empty byte string at the need topic. The decoder treats a zero-byte value as the sentinel (it is not the same as the encoded need-list with count = 0).

Salt Situation

While the V2 format reserves a byte for a per-deaddrop salt to randomize data chunk addresses, the current implementation enforces salt(...) -> 0x00. All V2 data chunk headers are currently prefixed with [0x02][0x00].

Dead Drop Operations

The dd command supports both human-readable terminal output and machine-readable JSON output for integration with other tools.

Command Line Flags

In addition to the dd-specific flags shown below, both dd put and dd get accept the inherited top-level global flags: --config <FILE>, --no-default-config, --public, --no-public, --bootstrap <ADDR> (repeatable), and -v / --verbose. These control config file loading, DHT bootstrap node selection, and tracing verbosity; see init/overview.md → Global CLI Flags for the bootstrap-resolution algorithm.

dd put Flags

FlagDefaultDescription
<file>requiredInput file path. Use - for stdin.
--max-speed <S>noneLimit transfer speed. Parses k/m suffixes (base-10, case-insensitive).
--refresh-interval <secs>600Seconds between refresh cycles (must be > 0).
--ttl <secs>noneStop refreshing after N seconds (must be > 0).
--max-pickups <N>noneExit after N unique pickup acks (must be > 0).
--passphrase <S>noneDeterministic root seed from discovery_key(passphrase).
--interactive-passphrasenoneTTY prompt for passphrase with hidden input.
--no-progressfalseSuppress progress UI.
--jsonfalseEmit JSON-Lines progress on stdout.
--v1falseForce legacy v1 protocol.

dd get Flags

FlagDefaultDescription
<key>required*64-character hex pickup key or passphrase text.
--passphrase <S>noneDerive pickup key from passphrase.
--interactive-passphrasenoneTTY prompt for passphrase with hidden input.
--no-progressfalseSuppress progress UI.
--output <PATH>stdoutWrite payload to file instead of stdout.
--jsonfalseEmit JSON-Lines progress. Requires --output.
--timeout <secs>1200Sliding no-progress timeout in seconds (must be > 0).
--no-ackfalseSuppress pickup acknowledgement announce.

*Key is required unless a passphrase flag is provided.

Key Derivation and Passphrases

  • Passphrase Derivation: When a passphrase is used, the root seed is derived via discovery_key(passphrase).
  • Interactive Fallback: The --interactive-passphrase flag attempts to open /dev/tty for hidden input, falling back to stdin if unavailable.
  • Key vs Passphrase: If a positional argument is exactly 64 characters of valid hex, it is treated as a raw 32-byte pickup key. Otherwise, it is treated as passphrase text and hashed via discovery_key.

Progress UX

The mode is selected automatically:

  1. --json -> JSON Lines on stdout.
  2. --no-progress -> Progress disabled.
  3. stderr is TTY -> Interactive bars.
  4. else -> Periodic log line (every 2s).

Bar Layouts

  • V1 Put: ↑ filename D(bytes/total) [bar] pct rate ETA
  • V2 Put: ↑ filename I[idx/total] D(bytes/total) [bar] pct rate ETA
  • V2 Get (4-bar multi):
    • index: I[idx/total] rate
    • data: D(bytes/total) [bar] pct rate ETA
    • wire: W ↑ rate ↓ rate (x amplification)
    • overall: filename bytes/total pct

Wire amplification (wire_total / bytes_done) is omitted until the first payload byte is received.

Machine-Readable Output (--json)

The --json flag enables a stream of JSON Lines on stdout. Events use type as a discriminator and RFC3339 timestamps.

Event Schema

TypeDescription
startOperation initiated. Includes version, filename, bytes_total, indexes_total, data_total.
progressPeriodic update. Includes bytes_done, rate_bytes_per_sec, eta_seconds, elapsed_seconds.
resultObjective achieved. put returns pickup_key and chunks. get returns crc and output.
ackSender-only. Emitted when a recipient acknowledges receipt. Includes peer and pickup_number.
doneOperation completed. Includes final counters and elapsed_seconds.

V1 Convention: indexes_total and indexes_done are always 0 in V1 events.

Acknowledgement (Ack) Mechanism

When a get operation completes (unless --no-ack is set), the receiver announces on the ack topic: ack_topic = discovery_key(root_pk || b"ack")

The sender polls this topic every 30s and counts unique announcer public keys.

Future Direction

Both dd v1 and v2 protocols are shipped and fully supported. There is no speculative dd roadmap documented at this time.

Chat Subsystem Overview

Peeroxide chat provides a serverless, end-to-end encrypted messaging environment built on the HyperDHT. It enables real-time communication without centralized accounts, phone numbers, or servers. Every identity is a public key, and every message is a cryptographically signed and encrypted record stored briefly in the distributed hash table.

Why Chat?

Traditional messaging apps rely on central servers to store your messages, manage your identity, and route your traffic. Peeroxide chat removes these intermediaries. It treats the network as a shared space where peers discover each other through topics and exchange data directly.

This design ensures:

  • Censorship Resistance: There is no central point to shut down.
  • Privacy by Default: All messages are encrypted. Metadata is minimized through epoch-based topic rotation.
  • Self-Sovereign Identity: You own your cryptographic keys. Your identity is not tied to a service provider.

Identity Model

Your identity in Peeroxide is an Ed25519 keypair. This keypair is stored in a local profile. When you send a message, it is signed with your private key, allowing anyone with your public key to verify that it came from you.

Profiles allow you to manage multiple identities on one machine. Each profile includes:

  • A permanent secret seed.
  • An optional screen name.
  • An optional biography.
  • A friends list.

Separately, a shared known-users name cache lives at ~/.config/peeroxide/chat/known_users. It is process-wide (not per profile) and acts as a soft directory mapping public keys to the most-recently-seen screen name for each peer you have encountered.

Channels

Peeroxide uses a topic-based discovery system. A “channel” is simply a name that maps to a DHT topic.

Public Channels

Public channels use a well-known derivation for their discovery topic. Anyone who knows the channel name (e.g., general or rust-dev) can join, read history, and post messages.

Private Channels

Private channels add a secret “group salt” to the topic derivation. Only peers who possess the salt can discover the channel topic or decrypt the messages within it. This enables private group conversations on the public DHT without revealing the participants or the content to outsiders.

Direct Messaging (DMs)

Direct messaging allows private, one-to-one communication between two specific public keys.

When you start a DM with another user, Peeroxide derives a unique dm_channel_key using your public key and theirs. Because the derivation is order-independent, both parties arrive at the same key. The communication is further secured using an ephemeral shared secret derived via X25519 Elliptic Curve Diffie-Hellman (ECDH).

The Inbox Concept

Because there is no server to hold messages while you are offline, Peeroxide uses an “Inbox” mechanism to facilitate discovery.

Your inbox is a set of rotating DHT topics derived from your public key. When someone wants to start a DM or invite you to a private channel, they generate a one-shot invite-feed keypair, publish an encrypted InviteRecord at that feed via mutable_put, and then announce that feed on your current inbox topic.

Your client periodically monitors these topics. When a new invite appears, it notifies you and provides the necessary keys to join the conversation. This “nudge” mechanism allows peers to find each other even if they aren’t currently in the same channel.

Profiles and the Nexus

The “Nexus” is your personal landing page on the DHT. It contains your screen name and biography. When you are active, your client publishes your Nexus record directly under your identity public key (via mutable_put on that key, with no extra topic derivation).

Your friends fetch your Nexus by mutable_get-ing your identity public key, picking up name and bio updates. This keeps your identity consistent across different channels and sessions.

For more details on the technical implementation, see Wire Format and Protocol.

Chat User Guide

The Peeroxide chat subsystem provides a set of CLI tools for managing identities, communicating in channels, and sending direct messages.

Global Flags

These flags apply to all peeroxide chat subcommands.

FlagDescription
--debugEnable stderr debug event logs.
--probeEnable internal trace probes (stdin, post, fetch_batch, etc) to stderr.
--line-modeForce line-based I/O even when running on a TTY.

In addition, every chat subcommand inherits the top-level peeroxide global flags documented in init: --config <FILE>, --no-default-config, --public, --no-public, --bootstrap <ADDR> (repeatable), and -v / --verbose. These control config file loading, DHT bootstrap node selection, and tracing verbosity.

Subcommand: join

Join a public or private channel for real-time conversation.

peeroxide chat join <channel> [flags]

Flags

FlagDefaultDescription
--profile <name>defaultUse a specific identity profile.
--group <salt>Set a private channel salt. Conflicts with --keyfile.
--keyfile <path>Read private channel salt from a file. Conflicts with --group.
--no-nexusSkip personal nexus (profile page) refresh and publication.
--no-friendsSkip background friend nexus refresh.
--read-onlyListen only; do not post messages or announce feeds.
--stealthShorthand for --no-nexus --read-only --no-friends.
--feed-lifetime <min>60Rotation lifetime for your feed keypair.
--batch-size <n>16Maximum messages per publish batch. Values below 1 are clamped to 1.
--batch-wait-ms <ms>50Maximum time to wait for a batch to fill before publishing.
--stay-after-eofEnter read-only mode on stdin EOF instead of exiting.
--no-inboxDisable background inbox monitoring.
--inbox-poll-interval <s>15How often to poll the inbox for new invites. Values below 1 are clamped to 1.

Examples

Join a public channel:

peeroxide chat join general

Join a private channel with a secret group name:

peeroxide chat join development --group "super-secret-salt-2026"

Subcommand: dm

Start an encrypted direct message session with another user.

peeroxide chat dm <recipient> [flags]

The recipient can be resolved using several formats (see Recipient Resolution below).

Flags

chat dm supports most of the session flags from join (--profile, --no-nexus, --no-friends, --read-only, --stealth, --feed-lifetime, --batch-size, --batch-wait-ms, --stay-after-eof, --no-inbox, --inbox-poll-interval), plus a DM-only flag:

FlagDescription
--message <text>Initial lure text sent with the inbox invite. Ignored in stealth/read-only mode.

chat dm does not accept --group / --keyfile; the channel key for a DM is derived deterministically from the two participants’ identity public keys via dm_channel_key.

Recipient Resolution

The recipient argument is resolved in the following order:

  1. 64-character hex public key.
  2. @shortkey (e.g., @a1b2c3d4).
  3. name@shortkey (e.g., alice@a1b2c3d4).
  4. 8-character shortkey (e.g., a1b2c3d4).
  5. Friend alias (defined in your friends list).
  6. Screen name from the known_users cache.

Subcommand: inbox

Monitor your inbox for new invites without entering an interactive UI.

peeroxide chat inbox [flags]

Flags

FlagDefaultDescription
--profile <name>defaultUse a specific profile.
--poll-interval <secs>15Interval between inbox scans. Values below 1 are clamped to 1.
--no-nexusAccepted for flag-surface parity with chat join / chat dm, but has no effect on chat inbox (which does not run a nexus publisher).
--no-friendsAccepted for flag-surface parity with chat join / chat dm, but has no effect on chat inbox (which does not run a friend refresh task).

Profile Management: whoami and profiles

whoami

Prints information about your current profile, including your public key, screen name, and nexus topic.

peeroxide chat whoami [--profile <name>]
FlagDefaultDescription
--profile <name>defaultProfile to inspect.

profiles

Manage multiple identities. Subcommands:

peeroxide chat profiles list
peeroxide chat profiles create <name> [--screen-name <name>]
peeroxide chat profiles delete <name>
SubcommandArgs / flagsDescription
listList all available profiles.
create <name>--screen-name <name> (optional)Create a new profile. If --screen-name is omitted, a deterministic vendor name is generated and stored.
delete <name>Delete a profile. The default profile cannot be deleted.

Friend Management: friends

Manage your list of trusted peers.

peeroxide chat friends [subcommand] [flags]

If no subcommand is given, friends list runs.

Subcommands and flags

SubcommandFlagsDescription
list--profile <name> (default default)Show all friends in the profile.
add <key>--alias <name> (optional), --profile <name> (default default)Add a new friend. Key resolution follows the same rules as DM recipients. If --alias is omitted, the alias auto-fills from the known-users cache or a vendor name.
remove <key>--profile <name> (default default)Remove a friend from the profile’s list.
refreshOne-shot DHT update for friends’ profile information. Does not accept a --profile flag — operates on the default profile only.

Personal Page: nexus

Manage your public profile information (Nexus) published on the DHT.

peeroxide chat nexus [flags]

If --lookup is supplied, the command short-circuits to lookup mode. Otherwise, --set-name and --set-bio are written to the profile first (both are applied in one run). After the setters, behavior is:

  • --publish: perform a one-shot Nexus publish and exit.
  • --daemon: enter the background loop (publish every 480 s, refresh all friends every 600 s).
  • No --publish / --daemon, but at least one setter was supplied: exit without publishing.
  • No flags at all (or only --profile): perform a one-shot Nexus publish and exit.

Flags

FlagDefaultDescription
--profile <name>defaultProfile to publish from / inspect.
--set-name <name>Update your screen name (writes the profile’s name file before publishing).
--set-bio <text>Update your biography (writes the profile’s bio file before publishing).
--publishPublish your Nexus record to the DHT once.
--daemonEnter a background loop: publish your Nexus every 480s and refresh all friends every 600s.
--lookup <pubkey>Lookup and print the Nexus information for a specific public key. Short-circuits the rest.

Screen Name and Bio Files

A profile’s screen name and bio live as plain UTF-8 text files inside the profile directory:

~/.config/peeroxide/chat/profiles/<profile>/name
~/.config/peeroxide/chat/profiles/<profile>/bio

Both files are optional. If name is missing, a deterministic vendor name is generated from the profile’s identity public key whenever a screen name is needed. If bio is missing or empty, the published Nexus record carries an empty bio.

You can populate them two ways:

  • peeroxide chat nexus --set-name <text> / --set-bio <text> — writes the file with the supplied text (after trimming leading and trailing whitespace), then optionally publishes if --publish / --daemon is also given. Both setters can be supplied in one command.
  • Edit the file directly with any editor. Multi-line bios are supported; the entire file content (after UTF-8 decoding) becomes the bio. The first line is treated specially by friends’ clients — the friends file caches only the first line of each friend’s bio for the friends list display, but the full bio is shown when a friend explicitly looks the identity up via chat nexus --lookup.

Size Limit

The screen name and bio are serialized together into a single NexusRecord published to the DHT as a mutable_put value. The full record (3 framing bytes + name UTF-8 bytes + bio UTF-8 bytes) must fit within 1000 bytes, which is the MAX_RECORD_SIZE constant for chat records.

In practice: with a typical 10–40 byte screen name, the bio budget is roughly 950–990 UTF-8 bytes (note: bytes, not characters — many non-ASCII characters take 2–4 bytes each).

If the combined size is too large, the publish step fails with:

warning: nexus serialize failed: record too large: N bytes exceeds 1000 byte limit

The bio file is still saved on disk in this case — only the DHT publish is skipped. Shorten the bio (or screen name) and re-run with --publish to recover.

Stealth Mode

The --stealth flag is supported by both chat join and chat dm. It is a shorthand for --no-nexus --read-only --no-friends, but the behavioral and threat-model implications are easier to reason about as a single concept.

What --stealth suppresses

Passing --stealth is equivalent to enabling all three of:

  • --read-only — your publisher is disabled entirely. No feed keypair is created, no message records are written via immutable_put, no FeedRecord is published via mutable_put, and no announce is sent on the channel or DM rendezvous topics. You become a pure observer of the channel.
  • --no-nexus — your profile’s Nexus record (screen name + bio) is not published. Other peers cannot resolve your identity public key to your screen name via the DHT, and you do not consume a mutable_put slot at your identity public key.
  • --no-friends — the background friend-Nexus refresh task does not run. Your DHT does not issue periodic mutable_gets on each friend’s identity public key, which would otherwise be observable to DHT nodes near those keys.

What --stealth does NOT suppress

--stealth stops the publishing side of the protocol. Several other observable activities continue:

  • Channel discovery is still active. Reading any channel requires lookups on its discovery topics, followed by mutable_gets on each announcer’s feed public key. Both operations remain visible to the DHT nodes serving them.
  • Inbox monitoring is independent of --stealth. A stealth session still polls your profile’s inbox topics every --inbox-poll-interval seconds (8 lookups per cycle by default — current + previous epoch, 4 buckets each). The wire-level lookup carries only the derived inbox topic, not your public key, so a passive DHT participant who does not already know your identity cannot recover it from these queries alone. However, an observer who already knows your public key can independently derive the same inbox topics and recognize the polling pattern, which lets them correlate the polling source IP with your identity. If that matches your threat model, also pass --no-inbox.
  • DM under stealth is receive-only. The DM channel key is symmetric between the two parties, so you can decrypt incoming messages. But you never announce your DM feed, never publish a message, and never send the per-epoch nudge. Your DM peer has no way to know you are listening.
  • Network-level metadata is unchanged. Every DHT operation goes out over UDP to peers who see your IP address. The Hyperswarm DHT has no traffic-mixing or onion-routing layer. If IP-level identifiability matters in your threat model — and especially if your public key is already known to an adversary — route peeroxide’s traffic through a transport you trust to provide that property: typically a VPN that gives you a different egress IP, mixes your traffic with other clients, and does not retain per-flow logs.

When --stealth is enough

It is sufficient when your only goal is to read a channel without contributing to its announce set or signaling your presence to other channel participants — for example, when you are using a fresh profile whose public key no observer has associated with you, and you want to listen first before deciding whether to post.

When --stealth is not enough

It is not sufficient when your public key is already known to an adversary and IP-level correlation matters. In that case the chain your public key → derived inbox / Nexus / announce topics → DHT lookups from your IP is exploitable by a sufficiently positioned observer. Combine --stealth --no-inbox with a trustworthy anonymizing transport in front of the binary.

Recipes

  • Lurk on a channel without joining its announce set:

    peeroxide chat join general --stealth
    
  • Same, plus suppress inbox polling:

    peeroxide chat join general --stealth --no-inbox
    
  • Lurk under a burner profile so the activity is not tied to your main identity:

    peeroxide chat profiles create burner
    peeroxide chat join general --stealth --profile burner
    

Interactive Usage

When running in a TTY, join and dm enter an interactive mode with a status bar and slash commands. See Interactive TUI for details.

In line mode (or when stdin is redirected), Peeroxide prints messages to stdout and notices to stderr. This is useful for piping chat into other tools.

Message Display

Messages are formatted as: [TIMESTAMP] [DISPLAY_NAME]: CONTENT

If a message arrives significantly after its timestamp, it is prefixed with [late].

Display names are resolved with the following precedence:

  1. Friend alias (e.g., (Bob)).
  2. Friend’s vendor name + screen name (e.g., (Vendor) <Alice@a1b2c3d4>).
  3. Non-friend with a wire screen_name on the message (e.g., <Alice@a1b2c3d4>).
  4. Non-friend without a wire screen_name but present in the shared known_users cache (e.g., <Cached-Name@a1b2c3d4>).
  5. Vendor fallback (e.g., <Fancy-Tiger@a1b2c3d4>).

A ! suffix on a name indicates the user is currently in a 300-second cooldown period after a name change.

Interactive TUI

Peeroxide chat features a terminal-based interactive interface (TUI) designed for real-time communication.

Mode Selection

The TUI is automatically enabled if:

  1. stdout is a TTY.
  2. stdin is a TTY.
  3. The --line-mode flag is not set.
  4. The PEEROXIDE_LINE_MODE environment variable is unset, empty, or "0" (any other non-empty value forces line mode).

If any of these conditions are not met, the client falls back to line mode. If TUI initialization fails on a TTY, a warning is printed and the client reverts to line mode.

Status Bar Layout

The status bar sits at the bottom of the terminal and provides real-time feedback on network activity and session state.

●  Sending 3  Receiving 12              inbox              Feeds 2  DHT 32  general

Components

  • Activity Indicator (●): Lights up when any DHT operation (put, get, announce, lookup) is in flight.
  • Left Segments:
    • Sending N: Number of messages currently in the publish batching pipeline.
    • Receiving N: Number of messages currently being fetched or ordered.
    • Ready: Indicates the publisher queue is empty and the client is idle.
    • Note: These slots use “sticky width”—once they grow to accommodate a larger number, they remain that size until the terminal is resized.
  • Center Segment:
    • Shows inbox (or i) when there are no unread invites.
    • Shows INBOX (or I) in yellow-on-black when new invites have arrived.
    • The segment is centered. It automatically shrinks or disappears if the terminal width is too narrow to avoid overlapping left or right segments.
  • Right Segments:
    • Feeds N: Total number of active feeds being tracked in the session.
    • DHT N: Current number of connected peers in the DHT routing table.
    • <channel>: The name of the current channel or the recipient’s name.

Keyboard Controls

KeyBehavior
EnterSend the current input buffer.
Ctrl-CIf buffer is non-empty: Clear the buffer. If buffer is empty: Arms a 2-second force-quit window.
Ctrl-DIf buffer is empty: Initiate graceful exit. If non-empty: Forward-delete character.
Ctrl-LFull screen repaint and history replay.
Up/DownMove the cursor up or down within the multi-line input area.

Ctrl-C Force Quit

When the buffer is empty, pressing Ctrl-C once will display a yellow-on-black notice: *** press Ctrl-C again within 2 seconds to force quit — press Ctrl-D for graceful exit

Pressing Ctrl-C a second time within the 2-second window will terminate the process immediately. This remains responsive even if the network publisher is blocked. Any other key disarms the window.

Slash Commands

Commands can be entered directly into the input buffer starting with a /.

CommandAction
/help, /?Display available commands.
/quit, /exitInitiate a graceful shutdown.
/ignore [name]List ignored users, or add a user to the ignore list.
/unignore <name>Remove a user from the ignore list.
/friend [name]List friends, or add a user to your friends list.
/unfriend <name>Remove a user from your friends list.
/inboxDisplay and drain the list of accumulated invites. Resets the INBOX status segment.

Input Features

Multi-line Input

The input area above the status bar supports multi-line text. Use Alt-Enter (or your terminal’s equivalent) to insert a newline without sending.

Bracketed Paste

The TUI supports bracketed paste mode. When you paste large blocks of text, the client treats it as a single input operation, preventing the terminal from interpreting pasted newlines as “Send” commands.

History Replay

The client maintains a bounded in-memory scrollback buffer of the last 500 messages (HISTORY_CAP). When the terminal is resized or repainted (Ctrl-L), the client replays the last min(history_len, terminal_height) entries to restore context.

Terminal Lifecycle

The TerminalGuard ensures the terminal state is correctly managed:

  • Enter: Scrolls existing terminal content into the scrollback, enables raw mode, enables bracketed paste, hides the cursor, and installs a panic hook.
  • Exit/Panic: Resets the scroll region, restores the cursor, disables bracketed paste, restores original colors, and disables raw mode.

EOF and Shutdown

When stdin reaches EOF (e.g., via Ctrl-D or piped input completion):

  • Default: The client begins a graceful drain. It displays *** flushing publish queue (Ctrl-C to abort)… and waits for all pending messages to be published to the DHT. There is no fixed timeout, though Ctrl-C can be used to skip the wait.
  • –stay-after-eof: Instead of exiting, the client enters read-only listener mode, allowing you to continue seeing incoming messages without being able to reply.

Wire Format

Peeroxide chat uses a structured wire format for all data exchanged over the DHT. All records are encrypted within a common frame. The underlying DHT operations (mutable_put / mutable_get / immutable_put / immutable_get / announce / lookup) and their per-record size budget are documented in DHT Primitives.

Encryption Frame

Every record (Message, Feed, Summary, Nexus, Invite) is encapsulated in an XSalsa20-Poly1305 encryption frame.

[nonce: 24 bytes] [tag: 16 bytes] [ciphertext: variable]
  • Cipher: XSalsa20-Poly1305.
  • Nonce: 24-byte random nonce generated per message.
  • Tag: 16-byte authentication tag.
  • No AAD: No additional authenticated data is used in the frame.

Record Types

MessageEnvelope

The MessageEnvelope represents a single chat message.

FieldSizeDescription
id_pubkey32Ed25519 public key of the author.
prev_msg_hash32Blake2b hash of the previous message in this feed’s chain.
timestamp8Unix timestamp in seconds (u64 Little Endian).
content_type10x01 for text.
screen_name_len1Length of the screen name string.
screen_namevarUTF-8 encoded screen name.
content_len2Length of the content (u16 Little Endian).
contentvarUTF-8 encoded message content.
signature64Ed25519 signature over the message body.

Signature Scheme: The signature covers the following bytes: b"peeroxide-chat:msg:v1:" || prev_msg_hash || timestamp || content_type || screen_name_len || screen_name || content

FeedRecord

The FeedRecord is a mutable record stored at a feed’s public key. It acts as an index of recent messages.

FieldSizeDescription
id_pubkey32Author’s permanent public key.
ownership_proof64Proof that id_pubkey owns this feed.
next_feed_pubkey32Pointer to the next feed after rotation (all zeros if none).
summary_hash32Hash of the latest SummaryBlock for this feed.
msg_count1Number of message hashes in this record (max 26).
msg_hashes32 * NArray of message hashes, newest-first.

Ownership Proof: An Ed25519 signature by the id_pubkey over: b"peeroxide-chat:ownership:v1:" || feed_pubkey || channel_key

SummaryBlock

The SummaryBlock is an immutable record used to store history that has been evicted from the FeedRecord.

FieldSizeDescription
id_pubkey32Author’s public key.
prev_summary_hash32Hash of the previous SummaryBlock (all zeros if none).
msg_count1Number of hashes in this block.
msg_hashes32 * NArray of message hashes, oldest-first.
signature64Ed25519 signature.

Signature Scheme: Covers: b"peeroxide-chat:summary:v1:" || prev_summary_hash || msg_hashes...

NexusRecord

The NexusRecord contains profile information published to the author’s personal topic.

FieldSizeDescription
name_len1Length of the screen name.
namevarUTF-8 encoded screen name.
bio_len2Length of the biography (u16 Little Endian).
biovarUTF-8 encoded biography.

InviteRecord

Used for DMs and private channel invites in the Inbox.

FieldSizeDescription
id_pubkey32Author’s public key.
ownership_proof64Ownership proof (same as FeedRecord).
next_feed_pubkey32Next feed pointer.
invite_type10x01 = DM, 0x02 = Private Channel.
payload_len2Length of the payload (u16 Little Endian).
payloadvarEncrypted payload (see below).

DM Payload: Opaque lure text. Private Invite Payload: [name_len: u8][name][salt_len: u16 LE][salt].

Key Derivation

All derivation functions use keyed BLAKE2b-256.

KeyDerivation Formula
channel_key (Public)hash([b"peeroxide-chat:channel:v1:", len4(name), name])
channel_key (Private)hash([b"peeroxide-chat:channel:v1:", len4(name), name, b":salt:", len4(salt), salt])
dm_channel_keyhash([b"peeroxide-chat:dm:v1:", min(pk_a, pk_b), max(pk_a, pk_b)])
msg_keykeyed_blake2b(channel_key, b"peeroxide-chat:msgkey:v1")
dm_msg_key`keyed_blake2b(ecdh_secret, b“peeroxide-chat:dm-msgkey:v1:“
invite_key`keyed_blake2b(ecdh_secret, b“peeroxide-chat:invite-key:v1:“
announce_topic`keyed_blake2b(channel_key, b“peeroxide-chat:announce:v1:“
inbox_topic`keyed_blake2b(hash(pk), b“peeroxide-chat:inbox:v1:“

DM ECDH

For direct messages, Ed25519 keys are converted to X25519:

  • Public Key: Edwards-to-Montgomery conversion.
  • Secret Key: SHA-512(seed)[0..32] with standard clamping.
  • Shared Secret: standard x25519 scalar multiplication.

Epoch and Bucket Math

  • Epoch: unix_time_secs / 60 (60-second intervals).
  • Buckets: 4 buckets per epoch (0, 1, 2, 3).
  • Discovery: A client scans (current_epoch, previous_epoch) × 4 buckets, resulting in 8 lookups per cycle.
  • Randomization: Each session uses a random permutation of the 4 buckets to distribute load.

Operational Protocol

The Peeroxide chat protocol defines how peers discover each other, synchronize message feeds, and maintain a consistent conversation state without a central server.

Feed Lifecycle

A “feed” is a sequence of messages published by a single identity under a temporary Ed25519 keypair.

Rotation

To enhance privacy and limit the impact of key compromise, feed keypairs are rotated periodically.

  1. At session start, a random feed keypair and a lifetime wobble (between 0.5x and 1.5x of --feed-lifetime) are chosen.
  2. A rotation watcher checks the feed age every 30 seconds.
  3. When the lifetime is reached, the publisher generates a new feed keypair.
  4. The publisher first announces the new feed.
  5. It then updates the old FeedRecord to include the next_feed_pubkey pointer.
  6. The old feed remains active briefly to ensure peers follow the transition before it is abandoned.

Message Publishing Pipeline

The publisher uses a bounded queue to batch and write messages to the DHT.

  1. Batching: Messages are accumulated in a queue. A batch is processed when it reaches --batch-size or after --batch-wait-ms.
  2. Immutable Put: Each message in the batch is stored as an immutable record on the DHT.
  3. Mutable Put: The FeedRecord for the current feed is updated to include the hashes of the new messages. This operation is retried up to 3 times (at 200ms, 500ms, and 1000ms intervals) to handle DHT congestion.
  4. Announce: The publisher announces the feed’s availability on the channel’s announce_topic.

Reader Discovery Loop

The reader task starts with a one-shot cold-start scan, then settles into a steady-state discovery loop.

Cold-Start Historical Scan

On startup, the reader fires concurrent lookups across the last 20 epochs × 4 buckets = 80 discovery topics (i.e. a 20-minute backwards window, since each epoch is 60 s). This surfaces feeds that announced before the session started so the client has visible history immediately, instead of waiting up to a full epoch rotation for the steady-state loop to reach them.

Steady-State Loop

After the cold-start completes, the continuous loop runs:

  1. Discovery: Every 8 seconds, the reader performs lookups on the 8 discovery topics (current and previous epoch across 4 buckets).
  2. Polling: For every discovered peer, the reader fetches and decrypts their FeedRecord.
  3. Fetching: New message hashes found in the FeedRecord are fetched as immutable records.
  4. Ordering: Messages are passed to the ChainGate for causal ordering.

Ordering and Deduplication

DedupRing

The DedupRing is a FIFO cache with a capacity of 1000 hashes. It ensures that the client never processes or displays the same message twice, even if it is rediscovered through different feeds or topics.

ChainGate

The ChainGate enforces strict ordering based on the prev_msg_hash field in each MessageEnvelope.

  • If a message arrives and its prev_msg_hash matches the last seen message from that sender, it is released to the UI.
  • If it doesn’t match, it is buffered, and the reader triggers a refetch of the missing hash with an exponential backoff.
  • If a gap remains for more than 5 seconds (GAP_TIMEOUT), the ChainGate force-releases the buffered messages, marking them as late.

History and Eviction

The FeedRecord has a limited capacity (max 26 hashes). When the message count reaches SUMMARY_EVICT_TRIGGER (20), the publisher performs an eviction.

  1. The 15 oldest messages (SUMMARY_EVICT_COUNT) are moved into a new SummaryBlock.
  2. The SummaryBlock is stored as an immutable record.
  3. The FeedRecord is updated to point to the new SummaryBlock hash and contains only the remaining 5 newest messages.
  4. On a cold start, a reader can walk back through these SummaryBlock pointers up to a MAX_SUMMARY_DEPTH of 100 blocks.

Inbox and Invites

The inbox monitor handles parallel scanning for new invites.

  1. Snapshot: The monitor takes a snapshot of currently known feed sequences.
  2. Parallel Scan: It fires 8 concurrent DHT lookups for the 8 inbox topics.
  3. Resolution: Peer pubkeys found in the topics are fanned out into parallel mutable_get calls to retrieve InviteRecords.
  4. Verification: Invites are decrypted using the invite_key (derived via ECDH) and verified.
  5. Nudge: In DM sessions, a “nudge” is sent at most once per epoch to signal the sender’s presence. A nudge is an encrypted InviteRecord published via mutable_put on the sender’s invite-feed keypair (with the lure payload truncated to 800 bytes), followed by an announce on the recipient’s current inbox topic. This matches the regular inbox-invite write path.

Graceful Shutdown

Upon exit, the client attempts a clean teardown:

  1. Publisher Drain: It waits for the publish queue to empty.
  2. Invite Retraction: For DM sessions, it attempts to retract the inbox invite by publishing an empty payload to the invite feed with a 1-second timeout.
  3. Terminal Reset: The TUI is disabled and terminal settings are restored.

Chat Reference

Technical reference tables for constants, flags, and filesystem layouts in the Peeroxide chat subsystem.

Constants

ConstantValueDescription
MAX_RECORD_SIZE1000 bytesMaximum size of any single DHT record.
MSG_FIXED_OVERHEAD180 bytesCombined size of envelope fields (excluding name/content).
MAX_SCREEN_NAME_CONTENT820 bytesMax sum of screen name + content lengths.
NONCE_SIZE24 bytesXSalsa20 nonce size.
TAG_SIZE16 bytesPoly1305 tag size.
CONTENT_TYPE_TEXT0x01Record content type for text messages.
INVITE_TYPE_DM0x01Inbox invite type for direct messages.
INVITE_TYPE_PRIVATE0x02Inbox invite type for private channels.
SUMMARY_EVICT_TRIGGER20Messages in FeedRecord before summary eviction.
SUMMARY_EVICT_COUNT15Number of messages moved to summary on eviction.
MUTABLE_PUT_RETRY_MS[200, 500, 1000]Retry intervals for mutable DHT updates.
ROTATION_CHECK_INTERVAL30sHow often the publisher checks for feed rotation.
MAX_SUMMARY_DEPTH100Maximum number of summary blocks to walk back.
FEED_EXPIRY_SECS1200Time (20 min) after which a feed is considered stale.
DISCOVERY_INTERVAL_SECS8Frequency of reader discovery lookups.
HISTORY_CAP500TUI scrollback history limit (in memory).
CTRL_C_ARM_WINDOW2sDouble-press window for force-exit.
DEDUP_RING_CAPACITY1000Max hashes stored in the deduplication set.
GAP_TIMEOUT5sTime before ChainGate force-releases out-of-order msgs.
REFETCH_SCHEDULE_MS[0, 500, 1500, 3000]Backoff intervals for missing hash refetching.

CLI Flags

Global Flags

  • --debug: Enable stderr debug logs.
  • --probe: Enable stderr trace probes.
  • --line-mode: Force line-based I/O.

Subcommand: join

  • --profile <name>: Profile to use (default: default).
  • --group <salt>: Private channel salt (conflicts with --keyfile).
  • --keyfile <path>: Private salt from file (conflicts with --group).
  • --no-nexus: Skip nexus refresh/publish.
  • --no-friends: Skip friend refresh.
  • --read-only: Listen only mode.
  • --stealth: Shorthand for --no-nexus --read-only --no-friends. Note this does not suppress inbox polling; see Stealth Mode in the user guide for the full threat-model breakdown.
  • --feed-lifetime <min>: Feed rotation interval (default: 60).
  • --batch-size <n>: Max messages per batch (default: 16). Values below 1 are clamped to 1.
  • --batch-wait-ms <ms>: Batch window (default: 50).
  • --stay-after-eof: Enter listener mode on EOF.
  • --no-inbox: Disable inbox monitor.
  • --inbox-poll-interval <s>: Inbox scan frequency (default: 15). Values below 1 are clamped to 1.

Subcommand: dm

Same session-flag surface as join, except --group and --keyfile are not accepted (the DM channel key is derived deterministically from the two participants’ identity public keys). DM also adds:

  • --message <text>: Initial inbox-invite lure text. Ignored in stealth/read-only mode.

Subcommand: inbox

  • --profile <name>: Profile to use (default: default).
  • --poll-interval <secs>: Polling interval (default: 15). Values below 1 are clamped to 1.
  • --no-nexus, --no-friends: Accepted for flag-surface parity with chat join / chat dm but are no-ops here (the inbox CLI does not run nexus / friend background tasks).

Subcommand: whoami

  • --profile <name>: Profile to inspect (default: default).

Subcommand: profiles

  • profiles list: no flags.
  • profiles create <name> [--screen-name <name>]: optional initial screen name; otherwise a deterministic vendor name is generated.
  • profiles delete <name>: rejects default.

Subcommand: friends

  • friends list [--profile <name>]: also the implicit default if no subcommand is given.
  • friends add <key> [--alias <name>] [--profile <name>]: alias auto-fills from the known-users cache (or vendor name) when omitted.
  • friends remove <key> [--profile <name>].
  • friends refresh: one-shot DHT refresh; does not accept --profile and operates on the default profile only.

Subcommand: nexus

  • --profile <name>, --set-name <name>, --set-bio <text>, --publish, --lookup <pubkey-hex>, --daemon (publish every 480 s, refresh all friends every 600 s).
  • --lookup short-circuits to lookup mode.
  • When at least one of --set-name / --set-bio is supplied and neither --publish nor --daemon is set, the setters are written to the profile and the command exits without publishing.
  • When no flags (or only --profile) are supplied, the command still performs a single Nexus publish.

Profile Directory Layout

Profiles are stored under ~/.config/peeroxide/chat/profiles/ (the chat subsystem uses the XDG-style ~/.config/peeroxide/chat/ root regardless of the platform-specific config dir used by peeroxide’s top-level config file).

~/.config/peeroxide/chat/profiles/<profile_name>/
├── seed           # 32-byte raw Ed25519 secret seed
├── name           # Optional UTF-8 screen name
├── bio            # Optional UTF-8 biography
└── friends        # Friend list (TSV)

Friends File Schema

The friends file is a Tab-Separated Values (TSV) file: <64-hex-pubkey>\t<alias>\t<cached_name>\t<cached_bio_line>

Shared Known Users

Located at ~/.config/peeroxide/chat/known_users.

  • Format: TSV <64-hex-pubkey>\t<screen_name>
  • Capacity: 1000 entries (FIFO).
  • Reloading: 5s mtime-debounced reload.

Name Resolution Precedence

NameResolver (peeroxide-cli/src/cmd/chat/name_resolver.rs) resolves a peer’s identity public key in the following order:

  1. Friend Alias: the friend’s locally assigned alias, if non-empty.
  2. Known Screen Name: the latest screen name for that pubkey in the shared ~/.config/peeroxide/chat/known_users cache, if non-empty.
  3. Vendor Fallback: a deterministic auto-generated name derived from the pubkey seed.

Note: the friends file’s per-friend cached_name and cached_bio_line columns are populated by the nexus refresh task for display in the friends-list and friend nexus prints, but NameResolver itself does not consult them — it goes straight from friend alias to the shared known_users cache.

The two output formats:

  • bar_label() — compact label used in the status bar:
    • friend alias source → bare alias (e.g. bob).
    • any other source → name@shortkey (e.g. alice@a1b2c3d4), where shortkey is the first 8 hex characters of the pubkey.
  • formal() — uniform fully-qualified label: name (shortkey) (e.g. alice (a1b2c3d4)), regardless of source.

Security Model

This appendix describes the threat model and security properties of the peeroxide-cli tools.

Transport Security

All peer-to-peer connections established by peeroxide-cli use the Noise XX handshake protocol followed by a SecretStream encrypted channel. This provides:

  • Mutual authentication: Both peers authenticate with their Ed25519 public keys during handshake.
  • Forward secrecy: Session keys are ephemeral and derived per-connection.
  • Confidentiality and integrity: All application data is encrypted and authenticated.

The Noise XX pattern is the same used by the Node.js Hyperswarm stack, ensuring interoperability.

Peer Identity

A peer’s identity is its Ed25519 public key (32 bytes). There is no central authority — identity is self-sovereign. Anyone who controls a private key controls that identity.

  • Ephemeral identities (default for announce) are generated fresh on each run and leave no persistent trace.
  • Seeded identities (--seed flag) are deterministic: KeyPair::from_seed(discovery_key(seed.as_bytes())). The seed string is a secret — anyone who knows it can derive the same keypair.

Warning: Treat --seed values like passwords. They are not hashed with a KDF — raw BLAKE2b is fast, making brute-force of short seeds feasible.

DHT Trust Model

The DHT is untrusted infrastructure. Any node can relay packets, and routing table entries are not authenticated. Mitigations:

  • Mutable DHT values are Ed25519-signed by the originating keypair. Verifiers (including lookup --with-data) confirm the signature before using the data. Forging data requires the private key.
  • Immutable DHT values (used by cp) are addressed by the SHA-256 hash of their content. Content is verified on retrieval.
  • Topic keys are not secret — anyone who knows the topic can look up its peer list. Do not treat topic confidentiality as a security property.

dd (Dead Drop) Threat Model

dd uses mutable DHT storage addressed by (public_key, topic). Security properties:

  • Only the holder of the private key can write to a slot (signatures enforced by the DHT).
  • Anyone who knows (public_key, topic) can read the slot — there is no access control on reads.
  • Data is signed but not encrypted at the DHT layer. For sensitive payloads, encrypt the application data before using dd.
  • dd is designed for asynchronous communication where sender and receiver share a topic out-of-band.

cp Threat Model

cp data is immutable and content-addressed. Anyone who knows the cp:// key can retrieve the data. There is no access control. Do not use cp for sensitive data without prior encryption.

Echo Protocol Security

The echo protocol (see Echo Protocol) is intentionally minimal — it echoes arbitrary 16-byte payloads after a PING/PONG handshake. Session concurrency is capped at MAX_ECHO_SESSIONS = 64 to limit resource exhaustion. The handshake uses a 5-second timeout to prevent slowloris-style attacks.

Because announce connections go through the Noise XX handshake, all echo traffic is authenticated and encrypted. An unauthenticated party cannot reach the echo server.

Denial of Service Considerations

  • Bootstrap nodes are configurable. An attacker controlling all bootstrap nodes can eclipse a node from the DHT.
  • The DHT is susceptible to Sybil attacks in the general case — this is a known limitation of permissionless DHTs.
  • announce data refresh runs every 600 seconds. If a node is offline, its announcement expires naturally according to DHT TTL policies.

Limits and Performance

This appendix documents hard limits, configurable bounds, and observed performance characteristics of the peeroxide-cli tools.

Hard Limits

ConstantValueContext
MAX_ECHO_SESSIONS64Concurrent echo sessions per announce process
HANDSHAKE_TIMEOUT5 sEcho protocol handshake timeout
IDLE_TIMEOUT30 sEcho session idle timeout
ECHO_MSG_LEN16 bytesEcho probe frame size (fixed)
ECHO_TIMEOUT (ping)5 sPer-probe timeout in ping --connect mode
MAX_CHUNKS65 535Maximum chunks in a single dd message
MAX_PAYLOAD1 000 bytesMaximum payload per dd chunk
ROOT_HEADER_SIZE39 bytesdd root chunk header size
NON_ROOT_HEADER_SIZE33 bytesdd non-root chunk header size
CHUNK_SIZE (cp)65 536 bytescp file chunk size
--data max (announce)1 000 bytesMaximum --data payload for announce
lookup --with-data concurrency16buffer_unordered(16) for mutable DHT gets
ping topic mode peer cap20Maximum peers probed per topic lookup

Derived Limits

Maximum dd message size:

MAX_CHUNKS × MAX_PAYLOAD = 65 535 × 1 000 = ~65.5 MB

Practical limit is lower due to DHT value size constraints and network latency.

Maximum cp file size:

Limited by available DHT storage and client memory. Each 65 536-byte chunk is stored as a separate immutable DHT value. There is no hard-coded upper bound in the CLI, but very large files will require many round-trips.

Timing

ParameterValueNotes
announce refresh interval600 sBackground mutable put to keep slot alive
announce seqUnix epoch secondsTwo refreshes in the same second produce identical seq — see ISSUES.md
ping --interval default1.0 sConfigurable
ping --count default10 = infinite

Concurrency

  • lookup --with-data fetches peer data in parallel with a concurrency window of 16 (buffer_unordered(16)).
  • announce handles incoming connections concurrently; echo sessions are bounded by MAX_ECHO_SESSIONS = 64.
  • cp uploads/downloads chunks sequentially per file (parallelism may be added in future releases).

Exit Codes

CodeMeaningTools
0Success / clean shutdownall tools
1Fatal errorall tools
130SIGINT receivedlookup, ping
0SIGINT/SIGTERM receivedannounce (intentional — clean shutdown is success)

Note: announce returns 0 on SIGINT/SIGTERM because interactive shutdown is the normal workflow. lookup and ping return 130 to allow callers to distinguish interruption from success.

Chat

ParameterValueDescription
Max record size1000 bytesMaximum size for a single DHT record
Message overhead180 bytesFixed overhead (screen name + content combined ≤ 820 bytes)
EncryptionXSalsa20-Poly1305Security parameters: nonce 24 bytes, tag 16 bytes
Epoch length60 sTime window for message bucketing
Buckets per epoch4Sub-divisions within an epoch for message distribution
DHT lookups per cycle8Checks current and previous epoch across 4 buckets
Discovery interval8 sCadence for looking up new peers
Feed expiry1200 sTime before a peer feed is considered stale
Summary eviction trigger20 messagesNumber of messages before clearing old history
Summary eviction count15 messagesNumber of messages removed during eviction
Mutable put retries3Retries at 200 ms, 500 ms, and 1000 ms intervals
Rotation check interval30 sFrequency of checking for epoch/bucket rotation
Dedup ring capacity1000 hashesNumber of message hashes stored to prevent duplicates
Gap timeout5 sMaximum wait time for out-of-order messages
TUI history cap500 linesScrollback buffer limit in the interactive interface

Chat Performance

The inbox polling mechanism uses parallel lookups and mutable gets. A full inbox cycle typically completes in 2-4 seconds of wall-clock time. This is a significant improvement over earlier nested-serial designs which required 10-20 seconds for the same operation.

Dead Drop (v2)

ParameterValueDescription
Max chunk size1000 bytesTotal size including headers
Data payload998 bytesActual data bytes per non-root chunk
Root index slots30Pointers to child chunks in the root node
Non-root index slots31Pointers to child chunks in intermediate nodes
Need-list entries1248-byte entries published in each DHT record
Parallel fetch cap64Maximum concurrent DHT requests
Soft depth cap4Maximum tree depth (~27 GB capacity)
Per-put timeout30 sMaximum duration for a single chunk upload
Stall watchdog check5 sFrequency of progress monitoring
Stall watchdog trigger30 sTime with no progress before triggering a restart
Need-list publish20 sFrequency of publishing the local need-list
Need-list announce60 sKeepalive interval for the need-list topic
Refresh interval600 sDefault cadence for re-announcing data availability
Initial concurrency128Starting sender concurrency for AIMD
Fetch backoff500 ms to 15 sProgressive delay for failed mutable or immutable gets

Tree Capacity by Depth

The implementation enforces SOFT_DEPTH_CAP = 4. Depths beyond that are theoretical only and are rejected at PUT time.

DepthMax Data ChunksApprox. Capacity
03029 KB
1930928 KB
228,83028 MB
3893,730891 MB
427,705,63027 GB

AIMD Algorithms

v2 (Current):

  • Uses Exponentially Weighted Moving Average (EWMA) with alpha 0.1.
  • Decision interval of 20 samples.
  • Fast-trip threshold of 10.
  • Shrink factor: 0.75×.
  • Growth factor: +2.

v1 (Legacy):

  • Uses a tumbling window of 10 samples.
  • Halves concurrency if degradation exceeds 30%.
  • Increases concurrency by 1 if 0% degradation is detected.