Privacy-Preserving P2P Discovery: How the KEIBIDROP Relay Works

The relay only sees encrypted blobs; it cannot read your files, your metadata, or even your fingerprint.

8 min read | KEIBIDROP Series | May 2026

By Marius-Florin Cristian

Peer discovery normally leaks both peers to the server

Peer-to-peer systems need a way for peers to find each other. This is the discovery problem. Traditional approaches leak metadata:

KEIBIDROP needs discovery while preserving privacy; the relay must help peers find each other without learning anything about them.

The relay is a key-value store that cannot read its own values

The KEIBIDROP relay is deliberately simple. It has two endpoints:

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.

The room password is the first 32 bytes of the fingerprint

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 decodes to 64 bytes, of which the first 32 serve as the room password. Both peers can compute this independently from the exchanged fingerprint.

Two keys come out of it, and the relay only ever sees one

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
}
The lookup key is given to the relay as a Bearer token; the encryption key is never sent to the relay. Since HKDF with different labels produces independent keys, knowing the lookup key does not reveal the encryption key.

Alice sends an encrypted blob under a token she derives herself

When Alice registers with the relay, she:

  1. Extracts the room password from her own fingerprint
  2. Derives the lookup key and encryption key
  3. Creates a PeerRegistration struct (fingerprint, IP, port, public keys, timestamp)
  4. Encrypts it with ChaCha20-Poly1305 using the encryption key
  5. POSTs the encrypted blob to /register with 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, so it cannot decrypt the blob.

Bob derives the same two keys from the same fingerprint

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:

  1. Extract room password from Alice's fingerprint
  2. Derive the same lookup key and encryption key
  3. GET /fetch with the lookup key as Bearer token
  4. Decrypt the response with the encryption key
  5. Extract Alice's IP address and public keys
The relay never sees Alice's fingerprint. It only sees the lookup token (a derived hash) and the encrypted blob (which it cannot decrypt). It cannot correlate who registered with who is looking up, because the registration token and lookup token are derived from different fingerprints.

Entries expire after 10 minutes and re-register every 8

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.

This is everything the relay can see, line by line

Data Relay sees it?
FingerprintNo; only a derived lookup token
IP addressNo; encrypted inside the blob
Public keysNo; encrypted inside the blob
File namesNo; never sent to relay
File contentsNo; never sent to relay
Who talks to whomNo; lookup token is not the registration token
Connection timingPartially; it sees when tokens are registered/fetched

Direct IPv6 keeps the relay simple and needs IPv6 on both ends

The privacy comes from separating the lookup key from the encryption key

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.

In practice: why no inbound port matters on an incident.