peach-relay

@june

End to End encrypted relay for Peach Password Manager. Never reads nor stores any vault or user data.

TypeScript

v0.3.24 · 99 saves · 6,507 lines · updated 1 week ago · trusted

99 saves
52 files ⇓ bundle
99 saves
02580f5eb6aeupdate report to reflect v0.3.24 deployed and live-verifieddev1 week ago
c3c3e8c02109rebuild report with clean figures, white cover, and three verified-fixed findingsdev1 week ago
79352b0cf9efport waiter lifetime cap to self-host, evict stale rate-limit buckets, scope per-source throughput to senderdev1 week ago
234c3764eb13add independent service assessment PDF and report source under docs/dev1 week ago
details

Peach Relay

A tiny, private WebSocket relay for Peach password sync. Two devices meet at an unguessable address, the relay joins their sockets, and encrypted frames flow through untouched. The relay cannot read your data — it is just a pipe.

How it keeps your data safe

The relay is built so that it cannot see your vault, by design rather than by promise. Your devices do all the cryptography; the relay only moves bytes.

What the relay can and cannot see

  • It never decrypts, parses, buffers, or stores your frames.
  • Its only certificate is for TLS, so no code path in the relay touches your data.
  • It cannot impersonate a peer, because each device pins the other's public key.
  • A room holds exactly two sockets; a third is rejected, and leaving closes both.
  • It logs nothing per session, only an aggregate health line of uptime, room count, and counters.

The one honest limit: an operator can see that two devices are talking and roughly when, but never what. That timing metadata is the entire attack surface; the payload itself is not.

The encryption your devices add

By the time a byte reaches the relay it has been sealed in three layers, and the relay sits outside all of them:

  • Transport (TLS): stops network eavesdroppers. The relay's own Let's Encrypt certificate wraps the WebSocket, so the network sees only encrypted traffic to your domain.
  • Sync tunnel (Noise): stops the relay operator. X25519 key agreement, ChaCha20-Poly1305 authenticated encryption, and a BLAKE2s handshake hash.
  • Vault envelope (AES-256-GCM + Argon2id + BLAKE3): stops a stolen vault file. The payload is already locked before it enters the tunnel, so the relay forwards ciphertext of ciphertext.

A few details on those layers:

  • The Noise tunnel uses the NK pattern on first pairing, where the dialer already pins the peer's static key, and the KK pattern on reconnects. The handshake runs the canonical Rust snow state machine, compiled to WebAssembly in the browser and to JNI on Android.
  • The vault envelope is stretched with Argon2id at 64 MiB of memory, 3 passes, and 4 lanes, then checked with BLAKE3. Even if the tunnel were somehow stripped, the relay would still only see locked vault bytes.

Meeting points you cannot guess

  • Devices meet at a random single-use token, or at an HKDF-SHA256 token that rotates every hour for reconnects.
  • Public keys are never used as addresses, so rooms cannot be guessed, scanned, or replayed. Mesh-introduced pairs derive their token from the shared vault root and the two sorted X25519 keys.

Want to verify the client half yourself? In the Peach client the handshake lives in noise-js.ts, the vault envelope in peachPayload.ts, and the rendezvous derivation in rendezvous.ts. This relay repo is the deliberately boring, auditable pipe between them.

Self-hosting

What you need

  • Node 24 or newer, or Bun 1.3 or newer.
  • A domain with a DNS A or AAAA record pointing at your server.
  • Ports 80 and 443 reachable for automatic TLS, or an existing reverse proxy for proxy mode.

Install

bun install --frozen-lockfile

Pick a setup

  • Automatic TLS — the relay fetches and renews its own certificate with Let's Encrypt. Use this on a standalone VPS that has nothing else on ports 80 and 443.
  • Behind a reverse proxy — your nginx or Caddy terminates TLS and the relay listens on loopback. Use this when the host already terminates TLS.
  • NixOS — the NixOS module wires the relay on loopback with nginx and ACME in front. Use this on NixOS hosts.

Automatic TLS

The relay obtains and renews its own Let's Encrypt certificate. Point DNS at the server, then start it:

PEACH_RELAY_DOMAIN=relay.example.com \
[email protected] \
bun run start

It listens on 443, and on 80 for ACME challenges. If the machine hostname already matches your domain you can leave out PEACH_RELAY_DOMAIN.

Behind a reverse proxy

Run the relay on loopback, which is the default at 127.0.0.1:65432, and let your proxy handle TLS:

PEACH_RELAY_TLS_MODE=proxy bun run start

Then forward WebSocket upgrades to that port. Example configs for nginx and Caddy follow.

nginx

server {
    listen 443 ssl http2;
    server_name relay.example.com;

    ssl_certificate     /etc/ssl/certs/relay.example.com.pem;
    ssl_certificate_key /etc/ssl/private/relay.example.com.key;

    location / {
        proxy_pass http://127.0.0.1:65432;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

Caddy

relay.example.com {
    reverse_proxy 127.0.0.1:65432
}

Caddy handles TLS and WebSocket upgrades on its own.

NixOS

A module ships at nix/module.nix. Import it and set your domain:

{
  imports = [ /path/to/nix/module.nix ];

  services.peach-relay = {
    enable = true;
    domain = "relay.example.com";
    acmeEmail = "[email protected]";
  };
}

This wires the relay on loopback with nginx and ACME in front. Rebuild and you are done.

Configuration

Everything has a safe default, so no config file is required. Override with environment variables, or with a peach-relay.json in the working directory.

  • PEACH_RELAY_TLS_MODE — TLS mode. Default auto.
  • PEACH_RELAY_DOMAIN — public hostname or hostnames. Default: the machine hostname.
  • PEACH_RELAY_ACME_EMAIL — ACME contact email. No default.
  • PEACH_RELAY_PORT — loopback port. Default 65432.
  • PEACH_RELAY_STATE_DIR — where the ACME account and certificate keys live. Default ~/.local/state/peach-relay.

Back up the state directory if you want certificate renewals to survive a rebuild.

Full config file

{
  "port": 65432,
  "tlsMode": "auto",
  "domains": ["relay.example.com"],
  "acmeEmail": "[email protected]",
  "waitingTimeoutMs": 120000,
  "spliceIdleTimeoutMs": 600000,
  "rateLimitWindowMs": 60000,
  "connectionsPerWindow": 240,
  "maxActiveRooms": 4096
}

Unknown fields are rejected, and environment variables override file values.

Point your devices at it

In the Peach app or browser extension, put your relay domain in the relay or server setting — just the hostname, nothing else:

relay.example.com

The app builds the WebSocket path and the rendezvous IDs itself during pairing. You never construct or share those URLs.

Run it as a service

If you are not on NixOS and want the relay managed by systemd:

[Unit]
Description=Peach Relay
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/bin/env bun run start
WorkingDirectory=/opt/peach-relay
Environment=PEACH_RELAY_TLS_MODE=proxy
Restart=on-failure
RestartSec=2s
DynamicUser=true
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
StateDirectory=peach-relay
UMask=0077

[Install]
WantedBy=multi-user.target

Then run systemctl daemon-reload && systemctl enable --now peach-relay.

Cloudflare Worker

If you would rather not run a server at all, the relay also deploys as a Cloudflare Worker backed by Durable Objects:

bun run deploy

This needs a Cloudflare account with Workers enabled and a Wrangler API token in your environment.

Development

To typecheck and run the test suite locally:

bun run typecheck
bun run test
bun run test:bun-adapter
bun run check:licenses

Privacy

The relay logs nothing per session. Self-hosted instances emit one fixed-field health event at startup, every 60 seconds, and at shutdown — uptime, room count, and aggregate counters only. Rendezvous IDs, IP addresses, frame contents, and device info never appear in logs.

License

GPL-3.0-only