diff options
| author | Bitflash <admin@bitflash.network> | 2026-08-10 11:21:39 -0300 |
|---|---|---|
| committer | Bitflash <admin@bitflash.network> | 2026-08-10 11:21:39 -0300 |
| commit | 64317cc5a3416d77c9171351c367ab8c24c4dff1 (patch) | |
| tree | 1cc7b3cfd9253bcab08a88778df2b8798dc5350d /docs | |
| parent | dfab2226330ccf5b6529a0e7355e1f574526de94 (diff) | |
docs: specify the .btf rendezvous protocol
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/rendezvous.md | 288 |
1 files changed, 288 insertions, 0 deletions
diff --git a/docs/rendezvous.md b/docs/rendezvous.md new file mode 100644 index 0000000..ac8e598 --- /dev/null +++ b/docs/rendezvous.md | |||
| @@ -0,0 +1,288 @@ | |||
| 1 | # Bitflash `.btf` Rendezvous Protocol | ||
| 2 | |||
| 3 | This is the specification of how Bitflash nodes reach each other, and how miners | ||
| 4 | reach pools, without revealing an IP address and without any central directory. | ||
| 5 | It documents the protocol as implemented in `src/btfaddr.cpp`, `src/btfchan.cpp`, | ||
| 6 | `src/btfrv.cpp`, `src/btftunnel.cpp` and `src/nostr.cpp`. | ||
| 7 | |||
| 8 | The design is ported from the Itzal hidden service design. A `.btf` address names | ||
| 9 | a public key, a node proves ownership of that key by signing a service | ||
| 10 | descriptor, other nodes find the descriptor over Nostr, and the two sides then | ||
| 11 | meet through an untrusted relay over an end to end encrypted tunnel. The relay | ||
| 12 | carries ciphertext only and never learns the plaintext. | ||
| 13 | |||
| 14 | ## 1. Overview | ||
| 15 | |||
| 16 | A connection to a `.btf` service has three layers: | ||
| 17 | |||
| 18 | 1. **Discovery (Nostr).** The service publishes a signed descriptor as a Nostr | ||
| 19 | event. A caller fetches it by the service public key and verifies the | ||
| 20 | signature. The descriptor says which rendezvous relay the service is waiting | ||
| 21 | at and which encryption key to use. | ||
| 22 | 2. **Rendezvous (TCP).** Caller and service both connect out to the same relay | ||
| 23 | and are paired by public key. Neither needs an inbound port or a public IP. | ||
| 24 | After pairing the relay is a blind byte pipe. | ||
| 25 | 3. **Encrypted channel (X25519 + XChaCha20-Poly1305).** Over the paired pipe the | ||
| 26 | caller and service run an ephemeral to static handshake and then exchange | ||
| 27 | authenticated frames. The relay sees only ciphertext. | ||
| 28 | |||
| 29 | The caller ends up holding an ordinary local socket that behaves like a direct | ||
| 30 | TCP connection to the peer. The node treats it exactly like any other peer | ||
| 31 | socket. | ||
| 32 | |||
| 33 | ## 2. Identities and keys | ||
| 34 | |||
| 35 | A node holds two independent keypairs. | ||
| 36 | |||
| 37 | - **Signing / identity key**: a secp256k1 x-only key (BIP340 Schnorr). This is | ||
| 38 | the `.btf` identity and also the node's Nostr identity. Persisted in | ||
| 39 | `nostr.key`. | ||
| 40 | - **Encryption key**: an X25519 (Curve25519) keypair. Persisted in | ||
| 41 | `btf_enc.key`. Its public half is published in the descriptor as `enc` and is | ||
| 42 | used for the tunnel key agreement. | ||
| 43 | |||
| 44 | The identity key names the service and authenticates the descriptor. The | ||
| 45 | encryption key secures the tunnel. They are separate so the long term identity | ||
| 46 | never performs Diffie-Hellman and the encryption key can be rotated in a new | ||
| 47 | signed descriptor. | ||
| 48 | |||
| 49 | ## 3. The `.btf` address | ||
| 50 | |||
| 51 | An address is the identity public key plus a short checksum, Base32 encoded, with | ||
| 52 | the suffix `.btf`. | ||
| 53 | |||
| 54 | ``` | ||
| 55 | label = Base32( pubkey[32] || checksum[2] ) | ||
| 56 | address = label + ".btf" | ||
| 57 | ``` | ||
| 58 | |||
| 59 | - `pubkey` is the 32 byte secp256k1 x-only public key. | ||
| 60 | - `checksum` is the first two bytes of `SHA256(".btf-checksum-v1" || pubkey)`. | ||
| 61 | - Base32 uses the lowercase alphabet `abcdefghijklmnopqrstuvwxyz234567`, no | ||
| 62 | padding. The 34 byte payload encodes to 55 characters. | ||
| 63 | - Parsing is case insensitive, trims surrounding whitespace, and strips a | ||
| 64 | trailing dot. A wrong checksum is rejected. | ||
| 65 | |||
| 66 | The address is self certifying: it *is* the public key, so no registry has to be | ||
| 67 | trusted to map a name to a key. | ||
| 68 | |||
| 69 | ## 4. Discovery over Nostr | ||
| 70 | |||
| 71 | Nodes publish and read three kinds of parameterized replaceable Nostr events. | ||
| 72 | Parameterized replaceable means the newest event for a given author and `d` tag | ||
| 73 | supersedes the older one, so a descriptor is always the latest state. | ||
| 74 | |||
| 75 | | Purpose | kind | `d` tag | | ||
| 76 | |---|---|---| | ||
| 77 | | Service descriptor | 38501 | `btf-descriptor-2` | | ||
| 78 | | Rendezvous relay announcement | 38502 | `btf-relay-2` | | ||
| 79 | | Pool announcement | 38503 | `btf-pool-2` | | ||
| 80 | |||
| 81 | The Nostr event is signed with the node's identity key, so the event author | ||
| 82 | equals the `.btf` public key. That binds the descriptor to the address at the | ||
| 83 | Nostr layer, and the descriptor is *also* signed inside its own content (see | ||
| 84 | below), so a relay operator cannot forge or swap a descriptor for an address it | ||
| 85 | does not control. | ||
| 86 | |||
| 87 | **Resolving an address.** To reach `X.btf`, a caller parses the address to the 32 | ||
| 88 | byte key and sends a Nostr `REQ` filtered by `authors = [X]`, `kinds = [38501]`, | ||
| 89 | `#d = ["btf-descriptor-2"]`, `limit = 1`. It verifies the returned descriptor and | ||
| 90 | uses its `meeting_node` and `enc`. | ||
| 91 | |||
| 92 | **Relay auto discovery.** A relay operator runs a rendezvous relay and publishes a | ||
| 93 | kind 38502 event carrying `host:port`. Every node that reads it adds the relay to | ||
| 94 | the set it can register at and dial through. No seed list edit or maintainer | ||
| 95 | approval is needed. A small curated seed list is still shipped for bootstrap: | ||
| 96 | |||
| 97 | ``` | ||
| 98 | 92.246.128.180:8434 | ||
| 99 | 31.44.4.249:8434 | ||
| 100 | 90.156.222.107:8434 | ||
| 101 | ``` | ||
| 102 | |||
| 103 | The default rendezvous port is `8434`. | ||
| 104 | |||
| 105 | ## 5. The service descriptor | ||
| 106 | |||
| 107 | The descriptor is a JSON object, signed with the identity key over a domain | ||
| 108 | separated digest of its own fields. | ||
| 109 | |||
| 110 | ```json | ||
| 111 | { | ||
| 112 | "v": 2, | ||
| 113 | "pubkey": "<hex 32-byte x-only identity key>", | ||
| 114 | "enc": "<hex 32-byte X25519 public key>", | ||
| 115 | "meeting_node": "host:port", | ||
| 116 | "created": <unix seconds>, | ||
| 117 | "sig": "<hex 64-byte BIP340 Schnorr signature>" | ||
| 118 | } | ||
| 119 | ``` | ||
| 120 | |||
| 121 | The signed digest is: | ||
| 122 | |||
| 123 | ``` | ||
| 124 | digest = SHA256( ".btf-descriptor-v2" | ||
| 125 | || pubkey[32] | ||
| 126 | || enc (raw bytes of the string) | ||
| 127 | || meeting_node (raw bytes of the string) | ||
| 128 | || created (8 bytes, big endian) ) | ||
| 129 | ``` | ||
| 130 | |||
| 131 | The signature is a deterministic BIP340 Schnorr signature (no auxiliary | ||
| 132 | randomness) over `digest` by the identity key. | ||
| 133 | |||
| 134 | **Verification** (untrusted input, must never throw): parse the JSON, require all | ||
| 135 | fields with the right types, confirm `pubkey` equals the address being resolved, | ||
| 136 | recompute the digest, and verify the Schnorr signature. Any failure rejects the | ||
| 137 | descriptor. | ||
| 138 | |||
| 139 | `created` lets a reader prefer the freshest descriptor when more than one is | ||
| 140 | seen. A node only publishes a descriptor once it has actually registered at a | ||
| 141 | `meeting_node`, so it never advertises a relay it cannot be reached at. | ||
| 142 | |||
| 143 | ## 6. Rendezvous transport | ||
| 144 | |||
| 145 | The relay is a small TCP server. Every connection opens with a fixed 33 byte | ||
| 146 | header: | ||
| 147 | |||
| 148 | ``` | ||
| 149 | byte 0 role: 'S' (service) or 'C' (client) | ||
| 150 | bytes 1..32 target public key (the .btf identity, 32 bytes) | ||
| 151 | ``` | ||
| 152 | |||
| 153 | **Service registration (`S`).** The service connects out to the relay and sends | ||
| 154 | `'S' || own_pubkey`. The relay stores the connection in a table keyed by the | ||
| 155 | public key and leaves it open, waiting. There is one slot per key: a new | ||
| 156 | registration for the same key replaces and closes the old one. The service does | ||
| 157 | not block waiting to be paired at registration time; it is considered registered | ||
| 158 | the moment the relay reads the header, which is what lets it publish a descriptor | ||
| 159 | pointing at this relay. | ||
| 160 | |||
| 161 | **Client dial (`C`).** The caller connects out to the same relay and sends | ||
| 162 | `'C' || target_pubkey`. The relay looks up the waiting service: | ||
| 163 | |||
| 164 | - If present, it removes it from the table, writes a single `0x01` byte to both | ||
| 165 | sockets, and then forwards raw bytes in both directions until either side | ||
| 166 | closes. From this point the relay is a blind pipe. | ||
| 167 | - If absent, it writes `0x00` to the caller and closes. | ||
| 168 | |||
| 169 | Both sides connect *outbound* to the relay, so neither needs a public IP or an | ||
| 170 | open inbound port. The relay never sees the identity of the caller (the caller | ||
| 171 | sends no key of its own, only the target it wants). | ||
| 172 | |||
| 173 | **Liveness.** A registration socket is idle by design and can outlive the network | ||
| 174 | path that created it (a NAT drops the idle mapping, the relay restarts, a route | ||
| 175 | changes) with no FIN and nothing readable. TCP keepalive was measured and does | ||
| 176 | not reliably detect this. The service therefore imposes its own timeout on the | ||
| 177 | wait for the pairing byte and re-registers on expiry. Expiring is not a failure; | ||
| 178 | it costs one reconnect per interval and returns the loop to a known state. | ||
| 179 | |||
| 180 | ## 7. The encrypted channel | ||
| 181 | |||
| 182 | Once paired through the relay, the caller and service run an end to end handshake | ||
| 183 | and then exchange authenticated frames. The primitive is libsodium | ||
| 184 | `crypto_box_curve25519xchacha20poly1305` (X25519 key agreement, XChaCha20-Poly1305 | ||
| 185 | AEAD), the same construction as Rust's `crypto_box` ChaChaBox. | ||
| 186 | |||
| 187 | **Handshake (ephemeral to static).** | ||
| 188 | |||
| 189 | 1. The caller generates a fresh ephemeral X25519 keypair, computes | ||
| 190 | `shared = X25519(eph_sk, service_enc_pub)` reduced to a box key | ||
| 191 | (`crypto_box_beforenm`), and sends the 32 byte ephemeral public key over the | ||
| 192 | paired pipe. | ||
| 193 | 2. The service reads the ephemeral public key and computes the same | ||
| 194 | `shared = X25519(enc_sk, eph_pub)`. | ||
| 195 | |||
| 196 | Because the caller uses a new ephemeral key per connection, each session has an | ||
| 197 | independent key and forward secrecy against later compromise of the service | ||
| 198 | encryption key. | ||
| 199 | |||
| 200 | **Framing.** Every message is length prefixed: | ||
| 201 | |||
| 202 | ``` | ||
| 203 | frame = len[4] (big endian, length of the body that follows) | ||
| 204 | body = nonce[24] || ciphertext || mac[16] | ||
| 205 | ``` | ||
| 206 | |||
| 207 | The nonce is 24 random bytes per frame (XChaCha20), the MAC is 16 bytes | ||
| 208 | (Poly1305). A body shorter than `nonce + mac` or larger than 1 MiB is rejected. | ||
| 209 | Decryption that fails authentication drops the frame and tears the tunnel down. | ||
| 210 | |||
| 211 | The tunnel is transparent: the application is handed a local socket pair end and | ||
| 212 | reads and writes plaintext. Two pump threads move data between that socket and | ||
| 213 | the relay socket, encrypting outbound and decrypting inbound. | ||
| 214 | |||
| 215 | ## 8. End to end connection flow | ||
| 216 | |||
| 217 | To dial `X.btf`: | ||
| 218 | |||
| 219 | 1. Parse `X.btf` to the 32 byte identity key. | ||
| 220 | 2. Fetch and verify the descriptor over Nostr (kind 38501). Obtain | ||
| 221 | `meeting_node` and `enc`. | ||
| 222 | 3. Connect to `meeting_node`, send `'C' || X`, and wait for `0x01`. | ||
| 223 | 4. Generate an ephemeral X25519 key, derive the shared key against `enc`, send | ||
| 224 | the ephemeral public key. | ||
| 225 | 5. Exchange XChaCha20-Poly1305 frames. Hand the application a local socket that | ||
| 226 | speaks the ordinary peer protocol. | ||
| 227 | |||
| 228 | A service, symmetrically: registers at a relay (`'S' || own_pubkey`), publishes a | ||
| 229 | descriptor pointing at that relay, waits for `0x01`, reads the caller ephemeral | ||
| 230 | public key, derives the shared key with its encryption secret, and serves the | ||
| 231 | tunnel. | ||
| 232 | |||
| 233 | ## 9. Security properties | ||
| 234 | |||
| 235 | - **Address unforgeability.** The address is the identity key and the descriptor | ||
| 236 | is Schnorr signed by it, and the Nostr event is signed by it. A relay or Nostr | ||
| 237 | operator cannot publish a working descriptor for an address it does not own. | ||
| 238 | - **Relay confidentiality.** The relay forwards ciphertext and never holds the | ||
| 239 | channel key. It cannot read or modify traffic undetected; a tampered frame | ||
| 240 | fails the Poly1305 tag and the tunnel closes. | ||
| 241 | - **Caller anonymity to the service and relay.** The caller presents no identity | ||
| 242 | key, only the target it wants to reach. It connects outbound, so its address is | ||
| 243 | not published anywhere. | ||
| 244 | - **Forward secrecy.** The caller's per connection ephemeral key means a later | ||
| 245 | compromise of the service encryption key does not decrypt past sessions. | ||
| 246 | - **IP privacy.** Neither side needs a public IP or an inbound port. The observable | ||
| 247 | network fact is that each side has a TCP connection to a relay. | ||
| 248 | |||
| 249 | ## 10. Threat model and limitations | ||
| 250 | |||
| 251 | - **The relay is trusted for availability, not for confidentiality.** It can | ||
| 252 | refuse to pair, drop a connection, or go offline. This is why relays are | ||
| 253 | plural, auto discovered, and re-registration is periodic. It cannot read | ||
| 254 | traffic. | ||
| 255 | - **Traffic analysis is not addressed.** A network observer, or the relay, can | ||
| 256 | see connection timing and byte volumes. There is no padding or mixing. | ||
| 257 | - **Single slot registration causes churn.** One waiting slot per key per relay | ||
| 258 | means a race or a stale registration can briefly make a service unreachable at | ||
| 259 | that relay. Multiple relays and the wait timeout bound the damage, but this is | ||
| 260 | a known source of connection churn. | ||
| 261 | - **Descriptor freshness is best effort.** `created` is a self reported | ||
| 262 | timestamp; readers prefer the newest descriptor they have seen but there is no | ||
| 263 | global clock. | ||
| 264 | - **Discovery depends on public Nostr relays.** If the relays a node uses are all | ||
| 265 | unreachable, discovery stalls until one is reachable again. Relay TLS is | ||
| 266 | verified, which closes a man in the middle path on discovery, but availability | ||
| 267 | still rests on the relay set. | ||
| 268 | |||
| 269 | ## 11. Wire reference | ||
| 270 | |||
| 271 | | Item | Value | | ||
| 272 | |---|---| | ||
| 273 | | `.btf` Base32 alphabet | `abcdefghijklmnopqrstuvwxyz234567`, no padding | | ||
| 274 | | Address payload | `pubkey[32] || checksum[2]`, 55 Base32 chars | | ||
| 275 | | Address checksum | `SHA256(".btf-checksum-v1" || pubkey)[0..2]` | | ||
| 276 | | Descriptor domain sep | `.btf-descriptor-v2` | | ||
| 277 | | Descriptor version | `2` | | ||
| 278 | | Descriptor signature | BIP340 Schnorr, deterministic (no aux rand) | | ||
| 279 | | Nostr descriptor event | kind `38501`, `d = btf-descriptor-2` | | ||
| 280 | | Nostr relay event | kind `38502`, `d = btf-relay-2` | | ||
| 281 | | Nostr pool event | kind `38503`, `d = btf-pool-2` | | ||
| 282 | | Rendezvous port (default) | `8434` | | ||
| 283 | | Rendezvous header | `role[1] ('S'/'C') || pubkey[32]`, 33 bytes | | ||
| 284 | | Rendezvous pairing byte | `0x01` paired, `0x00` no service | | ||
| 285 | | Channel primitive | `crypto_box_curve25519xchacha20poly1305` | | ||
| 286 | | Nonce / MAC / key sizes | 24 / 16 / 32 bytes | | ||
| 287 | | Frame | `len[4 big endian] || nonce[24] || ct || mac[16]` | | ||
| 288 | | Max frame body | 1 MiB | | ||