Platform & Server

Blind Relay Protocol

HTTP/WS APIs, Challenge-AUTH Ed25519 signing, Redis offline queues, abuse protections, and a self-hosting guide.

Overview

The Wiltkey Blind Relay acts as a blind mailbox. It coordinates client routing and handles temporary queueing of offline messages. Cryptographic verification ensures the server learns nothing about the user's relationships, and message payloads are deleted upon successful delivery and client acknowledgment. The relay never stores plaintext, never sees a decryption key, and identifies users only by an opaque SHA-256(pubkey) hash. Under normal conditions, transient messages are stored in Redis; however, large files are stored in object storage (linked in Postgres), and the system can dynamically route all traffic to Postgres under high RAM load.

GATT Challenge-AUTH Flow

Client Device Keys: identityKeyPair Blind Relay websocket.go 1. Connect WebSocket /ws 2. Frame: CHALLENGE (32B random hex) 3. Frame: AUTH (pubkey + challenge_sig) 4. Frame: AUTH_OK (userID)

How it Works

1. Challenge-Response Authentication

When a WebSocket connects to /ws, the server upgrade executes ServeWS. The server generates a random 32-byte hex challenge and writes it back as a CHALLENGE frame. Within 5 seconds, the client must reply with an AUTH frame containing its Ed25519 identity public key and a signature of the challenge hex. The server verifies this signature using VerifySignature. On success, the server derives the userID as the hex-encoded digest of SHA-256(publicKeyBytes) and registers the socket connection.

2. Message Routing & Offline Queues

When routing a message (via WebSocket SEND_MESSAGE or REST POST /api/v1/queue/post):

  • The server queries Redis to verify the recipient's queue is not blocked due to an active nuke command.
  • Premium Subscription Verification: If the payload is 5 MB or larger, the server checks the sender's entitlement status in Redis/Postgres. If no valid Google Play subscription hash exists, the message is dropped.
  • Size-based Storage Routing:
    • Small Payloads (< 500 KB): Normally stored in a Redis sorted set queue with a tiered Time-To-Live (TTL). However, if the server detects high memory pressure (≥ 80% RAM load), it routes small messages directly into Postgres inline storage to protect Redis RAM.
    • Large Payloads (≥ 500 KB): The server uploads the encrypted payload to an S3-compatible object storage bucket, and stores a link to the file along with sender/recipient metadata in Postgres.
  • Tiered Offline Hold (WiltKey Plus): The offline-queue TTL is keyed on the recipient's entitlement — a message addressed to a Plus subscriber is held 72 hours, versus 24 hours for a free/self-hosted recipient (nuke self-destruct envelopes are held 7 days regardless). This is distinct from the ≥5 MB file gate, which is keyed on the sender. A relay with no entitlement store simply holds everything 24 hours.
  • WebSocket Delivery & Acknowledgment:
    • When a client comes online, the server fetches their pending messages from both Redis and Postgres.
    • Inline messages are delivered and immediately deleted.
    • Bucket-backed files are downloaded from object storage and streamed to the client with a message_id. They remain in Postgres/S3 until the client explicitly replies with a FILE_RECEIVED WebSocket frame, preventing message loss on connection drops.

3. Nuke (Remote Wipe) Signalling

When a user wilts a contact, the client sends NUKE_RECIPIENT. The relay blocks the recipient's queue (so no stale messages pile up), queues an encrypted self-destruct envelope, and forwards it if the peer is online. The peer's client acts on it and replies ACK_NUKE, which unblocks the queue. To stop this from being abused as a denial-of-service, nukes are rate-limited per sender and the block is bounded to 24 hours.

4. Abuse Protections & Rate Limits

Because the relay stamps sender_id from the authenticated public key (a client can never forge it) and the app silently ignores messages from unknown senders, outsiders cannot inject readable or spoofed content. The remaining risks are availability/denial-of-service, which the relay guards against directly:

  • Per-connection flood guard: each authenticated socket runs a token bucket (burst up to 200, ~100 msg/s sustained). A socket that floods faster is disconnected.
  • Bounded offline queues: a recipient's queue is capped and auto-purged of expired entries, so a flood cannot grow it without limit or exhaust server memory.
  • Memory Load Fallback: If system RAM load rises above the configured threshold, all offline messages are routed to Postgres to prevent Redis from exhausting server memory.
  • Size Gating: Free users are limited to 5 MB file transfers; Plus subscribers can transfer files up to 50 MB. A payload larger than the 50 MB ceiling is rejected outright for everyone (a storage/DoS guard) — over WebSocket as an ERROR frame, over REST as HTTP 413.
  • Per-IP HTTP rate limits & escalating bans on the REST endpoints, keyed on the real client IP forwarded by the reverse proxy.
  • Proof-of-Work / single-use vouchers gate the HTTP message-post endpoint.
ℹ️ REMOVED FEATURES
Earlier builds experimented with pedometer step claims, geohash beacons, and ephemeral tunnels. These were removed before public release — the tunnel handlers in particular were retired in the July 2026 security pass (they were an unused feature that remained an exploitable attack surface). If you are reading old code or docs referencing CLAIM_STEPS, DROP_BEACON, or TUNNEL_INIT, they no longer exist.

Key Files & Symbols

File Path Symbol Name Description
wiltkey_server/websocket.go ServeWS() Handles HTTP WebSocket upgrades and runs challenge signature authentication. Also retrieves pending offline messages from Postgres/Redis.
wiltkey_server/handlers.go handleSendMessage(), handleFileReceived() Routes messages based on RAM and payload size. Processes client acknowledgments to safely delete bucket storage objects.
wiltkey_server/redis.go AddMessageToQueue(), StoreEntitlement() Caches offline envelopes, rate limits, nonces, and user entitlements (subscriptions) in Redis.
wiltkey_server/db.go PostgresClient Initializes Postgres, runs DB migrations, and stores offline messages and premium entitlements.
wiltkey_server/storage.go ObjectStorage Uploads, downloads, and deletes large payloads from an S3-compatible bucket (falls back to local disk storage).
wiltkey_server/main.go clientIP(), handlePostEntitlement() Resolves real client IP and implements subscription validation endpoints. Spins up background pruner workers.
wiltkey_server/auth.go VerifySignature() Verifies Ed25519 signatures of challenge payloads.

Gotchas & Edge Cases

⚠️ DRIFT TIME & IP BANNING POLICY
HTTP API requests (such as /api/v1/queue/status) verify signature timestamps. If the timestamp drift exceeds 30 seconds compared to the server clock, the request fails. The server records validation failures in Redis: the 1st failure bans the IP for 5 minutes, and subsequent failures ban the IP for 30 minutes, blocking all endpoints.
🚨 THE RELAY MUST SEE THE REAL CLIENT IP
Rate limits and bans are keyed on the client IP. When the relay sits behind a reverse proxy, every request appears to come from the proxy (127.0.0.1) unless the proxy forwards the real IP. Your proxy must set X-Real-IP / X-Forwarded-For, and the relay only trusts those headers when the direct peer is loopback. Get this wrong and all users share one rate-limit bucket — one abuser throttles everyone, and a single ban locks out your whole userbase.

Self-Hosting Your Own Relay

The relay is a single self-contained Go binary. Because Wiltkey is decentralized, anyone can run one — on a VPS, a home server, a Raspberry Pi, or a laptop acting as an offline Wi-Fi hotspot. Clients on different relays are bridged automatically.

What you need

  • Go (to build) — or a pre-built binary for your platform.
  • Redis — for offline queues, nuke blocks, rate-limit counters, and pairing state. If Redis is unreachable the relay falls back to an in-memory store (fine for a quick LAN test, but state is lost on restart and it doesn't scale).
  • A reverse proxy with TLS (nginx, Caddy, …) for any internet-facing deployment — to terminate HTTPS/WSS and forward the real client IP.

Build & run

git clone https://github.com/ArtFacility/WiltKey
cd wiltkey_server
go build -o wiltkey-relay .

# Environment variables (all optional; sensible defaults shown)
PORT=8090 \
REDIS_ADDR=localhost:6379 \
REDIS_DB=1 \
POSTGRES_URL=postgres://user:pass@localhost:5432/db?sslmode=disable \
BUCKET_ENDPOINT=localhost:9000 \
BUCKET_ACCESS_KEY=accesskey \
BUCKET_SECRET_KEY=secretkey \
BUCKET_NAME=wiltkey-files \
BUCKET_USE_SSL=false \
HIGH_RAM_THRESHOLD_PERCENT=80.0 \
./wiltkey-relay

PORT is the port the relay listens on, REDIS_ADDR/REDIS_DB configures Redis. POSTGRES_URL sets the Postgres persistence connection string. BUCKET_ENDPOINT, BUCKET_ACCESS_KEY, BUCKET_SECRET_KEY, and BUCKET_NAME configure S3-compatible Object Storage for payloads ≥ 500 KB (the relay falls back to local disk storage if these are omitted). HIGH_RAM_THRESHOLD_PERCENT determines when to switch small messages to Postgres to protect Redis memory (defaults to 80.0%).

Reverse proxy (nginx example)

The relay speaks plain HTTP/WS; your proxy adds TLS and forwards the real IP. The critical pieces are the WebSocket upgrade headers, the forwarded-IP headers, and generous timeouts for long-lived /ws sockets:

location / {
    proxy_pass http://127.0.0.1:8090;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    # WebSocket upgrade for /ws
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
}

Point your app's relay URL at your domain (e.g. https://relay.example.org); the client upgrades to wss://…/ws automatically.

Before You Host — Security Checklist

Running a relay is low-maintenance, but a handful of things genuinely matter. Go through these before exposing one to the internet:

🚨 Keep Redis and the relay port off the public internet.
Bind Redis to localhost and firewall it. It has no authentication in the default setup, and it holds every offline queue and pairing record. Likewise, only expose 80/443 (your proxy) — the relay's own port should be reachable only from the proxy on localhost. Use a firewall (e.g. ufw) with a default-deny inbound policy.
  • Forward the real client IP (see the reverse-proxy caution above) — otherwise your rate limits and bans are inert.
  • Always terminate TLS. The relay carries end-to-end-encrypted payloads, but without HTTPS/WSS the metadata (who is talking to your relay, when, and from where) is exposed and connections can be tampered with. Use Let's Encrypt / Certbot or Caddy's automatic TLS.
  • Give the relay its own Redis logical DB (REDIS_DB) if you share Redis with other services, so a FLUSHDB elsewhere can't wipe your queues.
  • Run it under a supervisor (systemd, pm2, Docker restart policy) so it comes back after a crash or reboot. The relay is designed to be safely restarted — Redis state survives.
  • Consider connection limits at the proxy. Because mobile carriers put many users behind one IP (CGNAT), aggressive per-IP limits belong at the proxy layer (nginx limit_conn/limit_req), tuned conservatively, rather than in the relay.
  • Keep the binary current. Security fixes ship as relay updates; rebuild and redeploy periodically.
ℹ️ What you can't leak.
Even a fully-compromised relay never holds plaintext, keys, contact graphs, or readable metadata about relationships — it only ever sees opaque hashes and ciphertext that it deletes on delivery. The hosting risks above are about availability (keeping your relay up and abuse-free), not about reading your users' messages.