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

The Problem

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: A Blind Key-Value Store

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.

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
}
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.

Registration Flow

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. 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:

  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.

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?
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

Trade-offs

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.