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.

⚡ CODE DEEP DIVE AVAILABLE
For function-to-function code analysis of wiltkey_server (Go), interactive system RAM load & S3 payload routing simulators, Ed25519 challenge-AUTH step-throughs, and token-bucket flood guards, read the full Go Blind Relay Deep Dive Page →

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 over the challenge. The server verifies the signature, derives the userID as the hex-encoded digest of SHA-256(publicKeyBytes), and then completes the device-token handshake:

  • Returning device (has token): the AUTH frame carries the device token and the signature covers challenge||token, binding token possession to the private key on this connection. The relay verifies it against Postgres — where only a hash of the token is stored — and rotates tokens that approach expiry (the old token keeps working briefly for overlap). An unknown/expired token is rejected with AUTH_REJECTED token_invalid; the client simply re-issues through the gate below. No IP ban here — punishing it would brick legitimate users behind shared NATs.
  • New device (no token): the relay answers TOKEN_CHALLENGE with a proof-of-work request. The client must find a nonce such that SHA-256(challenge + nonce + pubkey) starts with WK_ISSUANCE_DIFFICULTY leading hex zeros (default 5, ≈ a second on a phone), bound to the device's own pubkey. On success — and within the per-IP daily mint cap (WK_ISSUANCE_DAILY_IP) — the relay mints a random 256-bit token and returns it in AUTH_OK.
  • Human verification (optional): with WK_CHALLENGE_MODE set (adaptive = every issuance after a device's first of the day; always = every issuance), the relay additionally requires a human-verification puzzle between the proof-of-work and the mint. The relay sends AUTH_CHALLENGE_REQUIRED carrying only a random seed; the client renders a pixel sprite sliced into 5 strips, rotated by an amount derived from that seed, and the user slides the strips back until the picture is symmetrical. The submitted rotation is a single integer the relay verifies against the same seed-derived value. Wrong answers burn strikes (3 per hour → a 30-minute cooldown, no ban). The relay never renders anything — it only checks one number — and the puzzle exists to make scripted bulk identity farming awkward, not to stop a determined reverse-engineer.
  • Rate-limit handling: hitting the issuance cap, a cooldown, or a bad proof-of-work returns a distinct AUTH_REJECTED reason; clients back off (minutes to an hour) instead of hammering the gate.

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.
  • Group Message Single-Upload Distribution: Starting in v1.1.0, group messages (including images) are uploaded once by the sender to the blind relay. The relay handles delivery of the single payload to every group member's socket/queue. This drastically speeds up group messaging and reduces sender mobile data usage while preserving total relay blindness — the relay routes opaque ciphertexts without possessing decryption keys or reading payload contents.
  • 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.
  • On-Demand File Downloads & Token-Gated HTTP Streaming:
    • Inline messages (< 500 KB) are delivered immediately on WebSocket connection and deleted from the queue.
    • Bucket-backed files (≥ 500 KB) are NOT auto-streamed as raw envelopes. Instead, the relay sends a lightweight FILE_OFFER frame containing payload metadata (file ID, size, sender, content type).
    • When the user taps to download, the client sends REQUEST_FILE over WebSocket. The relay verifies authorization, issues a short-lived download token, and replies with a FILE_TOKEN frame.
    • The client streams the file via GET /api/v1/file?token=... over HTTP (supporting chunked streaming, progress bars, and Content-Length).
    • The object in S3 and record in Postgres are deleted ONLY after the recipient's device confirms disk write via a FILE_RECEIVED WebSocket frame ACK (or when the hold TTL expires).
    • Un-ACKed offers are re-advertised on every reconnect, and a client can also request a sweep at any time with REQUEST_PENDING_FILES — so a FILE_OFFER missed while the client stayed connected is recovered without waiting for the next reconnect. Re-offers are idempotent (the client de-dupes by message ID).

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.

5. Google Play Integrity Attestation Endpoints

Starting in v1.3.5, the relay includes support for verifying Google Play Integrity verdicts and issuing signed offline attestations:

  • POST /api/v1/integrity/challenge: Issues a cryptographically random, anti-replay nonce bound to the client's public key hash (stored in Redis with a 5-minute TTL).
  • POST /api/v1/integrity/attest: Validates the Google Play Integrity token with Google's API, verifies that the verdict meets MEETS_DEVICE_INTEGRITY and PLAY_RECOGNIZED, signs an Ed25519 attestation token with the relay's private key (WILTKEY_ATTESTATION:<userId>:<clientType>:<issuedAt>:<expiresAt>), and caches the record in PostgreSQL.
  • GET /api/v1/integrity/query?userId=...: Allows clients to fetch cached attestation tokens for contacts, along with the relay's public key for offline Ed25519 signature verification.
ℹ️ Stories over the authenticated connection
Social stories (post, feed, reactions, deletion) ride the same authenticated WebSocket as chat messages — there is no separate unauthenticated story API. Frames are bounded and throttled per connection, and only token-authenticated connections exist at all, so the story surface inherits the same abuse gate as everything else.

Key Files & Symbols

File Path Symbol Name Description
wiltkey_server/websocket.go ServeWS() Handles HTTP WebSocket upgrades and runs the full auth dance: challenge signature, device-token verification, proof-of-work issuance gate, and optional human-verification puzzle. Also retrieves pending offline messages from Postgres/Redis.
wiltkey_server/puzzle.go + wiltkey_server/internal/cryptoops/ runPuzzleChallenge(), PuzzleAnswerFromSeed(), VerifyPoW() The human-verification gate (strip-slide puzzle: seed → expected rotation) and the canonical proof-of-work/puzzle math, shared byte-for-byte with the app's mirror (golden-tested).
wiltkey_server/handlers.go handleSendMessage(), handleRequestFile(), handleFileReceived() Routes messages based on RAM and payload size. Mints short-lived download tokens for FILE_OFFER payloads and processes FILE_RECEIVED ACKs.
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.
wiltkey_server/social_stories.go handlePostStory(), handleGetStoryFeed(), handleGetSocialQuota(), handleWipeSocialData() Zero-knowledge 24h stories, atomic 7-day rolling quota enforcement (10MB/100MB), and background 24h expiration pruning.
wiltkey_server/play_integrity.go handleIntegrityChallenge(), handleIntegrityAttest() Google Play Integrity verdict validation, anti-replay nonce verification, and Ed25519 attestation signing.

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 \
WK_ISSUANCE_DIFFICULTY=5 \
WK_ISSUANCE_DAILY_IP=5 \
WK_CHALLENGE_MODE=off \
./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%). The identity gate: WK_ISSUANCE_DIFFICULTY (proof-of-work leading zeros), WK_ISSUANCE_DAILY_IP (token mints per IP per day), and WK_CHALLENGE_MODE (off, adaptive — challenge repeat issuances, or always — the human-verification puzzle on every mint; default off).

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.