The Problem
Peer-to-peer systems need a way for peers to find each other. This is the discovery problem. Traditional approaches leak metadata:
- STUN/TURN servers see both peers' IP addresses and can correlate connections
- DHT-based discovery exposes what you are looking for to every node in the hash table
- Central registries become surveillance points; they know who is talking to whom
KEIBIDROP needs discovery while preserving privacy; the relay must help peers find each other without learning anything about them.
The Relay: A Blind Key-Value Store
The KEIBIDROP relay is deliberately simple. It has two endpoints:
POST /registerstores an encrypted blob, indexed by a lookup tokenGET /fetchretrieves an encrypted blob by its lookup token
The relay stores { lookupToken: encryptedBlob } pairs
with a 10-minute TTL; there are no other endpoints. It cannot decrypt the blobs, and it does
not know what the lookup tokens represent.
Room Password Extraction
When peers exchange fingerprints out-of-band (via Signal, Telegram, etc.), they are also implicitly sharing a "room password." The room password is the first 32 bytes of the decoded fingerprint:
// pkg/crypto/asymmetric.go
func ExtractRoomPassword(fingerprint string) ([]byte, error) {
decoded, err := base64.RawURLEncoding.DecodeString(fingerprint)
if err != nil {
return nil, fmt.Errorf("invalid fingerprint encoding: %w", err)
}
if len(decoded) < roomPasswordSize {
return nil, fmt.Errorf("fingerprint too short: need %d bytes, got %d",
roomPasswordSize, len(decoded))
}
return decoded[:roomPasswordSize], nil
}
The fingerprint is a base64url-encoded SHA-512 hash of the concatenated public keys (X25519 + ML-KEM). It is 64 bytes when decoded. The first 32 bytes serve as the room password. Both peers can compute this independently from the exchanged fingerprint.
Dual Key Derivation
From the room password, two separate keys are derived using HKDF-SHA512 with different labels. This is the core of the privacy model:
// pkg/crypto/asymmetric.go
func DeriveRelayKeys(roomPassword []byte) (lookupKey, encryptionKey []byte, err error) {
// Lookup key: used as Bearer token for relay API
lookupKey, err = deriveKeyInternal(sha512.New,
"keibidrop-relay-lookup-v1", KeySize, roomPassword)
if err != nil {
return nil, nil, fmt.Errorf("failed to derive lookup key: %w", err)
}
// Encryption key: used for ChaCha20-Poly1305 encryption of registration data
encryptionKey, err = deriveKeyInternal(sha512.New,
"keibidrop-relay-encrypt-v1", KeySize, roomPassword)
if err != nil {
return nil, nil, fmt.Errorf("failed to derive encryption key: %w", err)
}
return lookupKey, encryptionKey, nil
}
Registration Flow
When Alice registers with the relay, she:
- Extracts the room password from her own fingerprint
- Derives the lookup key and encryption key
- Creates a PeerRegistration struct (fingerprint, IP, port, public keys, timestamp)
- Encrypts it with ChaCha20-Poly1305 using the encryption key
- POSTs the encrypted blob to
/registerwith the lookup key as Bearer token
// pkg/logic/common/utils.go
roomPassword, _ := crypto.ExtractRoomPassword(ownFp)
lookupKey, encryptionKey, _ := crypto.DeriveRelayKeys(roomPassword)
lookupToken := base64.RawURLEncoding.EncodeToString(lookupKey)
peerReg := PeerRegistration{
Fingerprint: ownFp,
Listen: &ConnectionHint{
IPv6: true,
IP: kd.LocalIPv6IP,
Port: kd.inboundPort,
},
PublicKeys: kd.session.OwnKeys.ExportPubKeysAsMap(),
Timestamp: time.Now().UnixNano(),
}
plaintext, _ := json.Marshal(peerReg)
encryptedBlob, _ := crypto.Encrypt(encryptionKey, plaintext)
// POST /register with Authorization: Bearer lookupToken
The relay stores: { lookupToken: encryptedBlob }. It has the lookup token
but not the encryption key. It cannot decrypt the blob.
Lookup Flow
When Bob wants to find Alice, he uses Alice's fingerprint (received out-of-band) to derive the same room password, and from that the same lookup key and encryption key:
- Extract room password from Alice's fingerprint
- Derive the same lookup key and encryption key
- GET
/fetchwith the lookup key as Bearer token - Decrypt the response with the encryption key
- Extract Alice's IP address and public keys
TTL and Keepalive
Relay registrations expire after 10 minutes. A background goroutine re-registers every 8 minutes to keep the entry alive:
// pkg/logic/common/relay_keepalive.go
type RelayKeepalive struct {
Interval time.Duration // 8 minutes (relay TTL is 10 minutes)
}
func (rk *RelayKeepalive) loop() {
ticker := time.NewTicker(rk.Interval)
for {
if !rk.paused.Load() {
rk.refresh() // Re-register with relay
}
}
}
The keepalive is paused during reconnection attempts to avoid registering stale connection hints. Once the connection is re-established, keepalive resumes.
What the Relay Knows
| Data | Relay sees it? |
|---|---|
| Fingerprint | No; only a derived lookup token |
| IP address | No; encrypted inside the blob |
| Public keys | No; encrypted inside the blob |
| File names | No; never sent to relay |
| File contents | No; never sent to relay |
| Who talks to whom | No; lookup token is not the registration token |
| Connection timing | Partially; it sees when tokens are registered/fetched |
Trade-offs
- KEIBIDROP uses direct IPv6 connections, avoiding NAT traversal entirely. This simplifies the relay (no STUN/TURN) but requires both peers to have IPv6 connectivity.
- If the relay is down, peers cannot find each other. But once connected, the relay is not involved in data transfer. The relay is also self-hostable; it is a simple Go HTTP server.
- The relay can observe that a registration and a lookup happened close in time. This is a fundamental limitation of any discovery mechanism. The mitigation is the 10-minute TTL; registrations persist, so lookups do not necessarily correlate with the registration time.
Summary
The relay is a blind key-value store with encrypted values and derived lookup tokens. It facilitates discovery without learning anything useful about the peers. The privacy guarantee comes from separating the lookup key (given to the relay) from the encryption key (never shared with the relay), both derived from the same room password using HKDF with different labels.
The relay cannot decrypt the blobs it stores. Even if it is compromised, seized, or subpoenaed, the attacker gets a collection of opaque tokens and encrypted blobs with a 10-minute TTL.