peach-relay

@june

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

TypeScriptNix

v0.2.2 · 7 saves · 2,143 lines · updated 5 hours ago · trusted

7 saves
25 files ⇓ bundle
7 saves
bed645205e59fix: Reclaim stale relay rooms (v0.2.2) - Ignore closing Durable Object sockets during admission - Reclaim abandoned two-party splices after two minutes idle - Migrate legacy timestamp-free room attachments safelydev5 hours ago
faf5504e07bflower the connection ceiling to 20 per minute and enforce it at the Worker edge before any rendezvous lookup (v0.2.1)devyesterday
b53da65ba862feat: add self-host relay runtimes (v0.2.0)devyesterday
85dd11d3400cchore: publish hosted relay domain (v0.1.1)devyesterday
details

Peach Relay

Peach Relay is a deliberately small, opaque WebSocket pipe for Peach sync. Two devices connect to the same unguessable rendezvous path, the relay joins those two sockets, and every WebSocket frame is forwarded verbatim. The frames are already Noise-encrypted by the clients.

The relay does not authenticate devices, parse frames, buffer messages, expose presence, or store session data. A hostile relay can refuse service and observe coarse traffic timing/volume, but it cannot read vault data or impersonate the peer whose key the client already pins.

Hosted Worker

The hosted adapter uses one SQLite-backed Cloudflare Durable Object per rendezvous identifier. SQLite is a namespace requirement only; the code never calls the storage API. Accepted sockets use the Durable Object WebSocket Hibernation API and retain only a waiting, left, or right role attachment. The Worker also rejects a source after 20 WebSocket attempts per minute at each Cloudflare location. The address exists only as a short-lived platform rate counter key and is never logged or written to a Durable Object.

Requirements:

  • Bun 1.3 or newer
  • A Cloudflare account with Workers enabled
  • A scoped API token supplied to Wrangler through the environment or its normal

local credential store; never put tokens in this repository

Deploy:

bun install --frozen-lockfile
bun run check
bun run deploy

Connect clients to:

wss://relay.peachpasswords.com/r/<opaque-rendezvous-id>

The deployment also retains its generated workers.dev hostname as an operator recovery path; clients use the custom domain above so the endpoint can move without a client release.

Rendezvous IDs must be one base64url-style segment between 22 and 128 characters. Clients generate a random single-use token for first pairing or an HKDF-derived rotating token for reconnects; raw public keys are never valid rendezvous identifiers.

Reconnect derivation is a client contract, not relay logic: both peers store a 32-byte secret derived from the completed first-pair Noise handshake, then use HKDF-SHA256 with UTF8("peach-rendezvous-v1") || uint64be(hourIndex). Waiting peers register the current and previous hour; dialers try current, previous, then next. The relay only receives the resulting 43-character capability.

Self-hosting

The Worker, Node, and Bun adapters all use the same SpliceRoom and RelayRegistry; only WebSocket acceptance differs. The in-process registry forgets a rendezvous as soon as either peer disconnects, after two minutes without a second peer, or after ten minutes of splice inactivity. It also bounds the process at 4,096 live rooms and 20 connection attempts per source address per minute by default.

Node with automatic TLS

Point DNS at the host, make the machine hostname the public FQDN, and run:

npm install
npm start

On first start, the Node adapter obtains a Let's Encrypt certificate with HTTP-01, stores the account/certificate keys with mode 0600, serves secure WebSockets on 443, keeps port 80 for challenges and HTTPS redirects, and checks renewal twice daily. If the machine hostname is not the relay domain, set the single optional input PEACH_RELAY_DOMAIN=relay.example.com. Ports 80 and 443 must reach this process.

The ACME adapter uses the MIT-licensed acme-client; the dependency check rejects AGPL, SSPL, BUSL, and Commons Clause dependencies so the repository's GPL-3.0-only boundary cannot drift silently.

Behind an existing reverse proxy

Both runtimes expose the same loopback-only proxy listener on 127.0.0.1:65432 by default:

PEACH_RELAY_TLS_MODE=proxy npm start
# or
PEACH_RELAY_TLS_MODE=proxy bun run start:bun

65432 is in IANA's dynamic/private range rather than a registered service port, and is always replaceable through port or PEACH_RELAY_PORT.

The listener accepts WebSocket upgrades only when the trusted loopback proxy supplies X-Forwarded-Proto: https. It rejects public plaintext-style upgrades, and it uses X-Forwarded-For only for the transient in-memory rate limit. A proxy access format must omit the request URI, source address, host, headers, and user agent because those fields can expose a rendezvous capability or user-facing deployment information.

Node's default auto mode checks whether ports 80 and 443 are available. When they are already occupied by a TLS proxy, it leaves automatic ACME to that proxy and keeps only the secure loopback adapter. Bun intentionally uses this proxy path: Bun provides native WebSocket/TLS serving but does not provide ACME certificate issuance.

Optional configuration

No configuration file is required. A peach-relay.json in the working directory may override the small safe set below:

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

Unsafe settings are not expressible: the proxy listener must remain on loopback, direct plaintext TLS mode does not exist, and every numeric option has a conservative range. Environment variables exist for service managers: PEACH_RELAY_CONFIG, PEACH_RELAY_DOMAIN, PEACH_RELAY_ACME_EMAIL, PEACH_RELAY_TLS_MODE, PEACH_RELAY_PROXY_HOST, PEACH_RELAY_PORT, and PEACH_RELAY_STATE_DIR. The NixOS module exposes the same backend port as services.peach-relay.port.

NixOS

The flake ships both a package and a hardened NixOS module. Install the CLI directly with nix profile add <published-flake-url>. A NixOS service needs only the public domain and ACME contact:

{
  imports = [ inputs.peach-relay.nixosModules.default ];

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

The module runs the Bun adapter as a dynamic user, binds it to loopback, uses nginx for WebSocket TLS + ACME, and opens ports 80/443. Its dedicated nginx access log contains only timestamp, response status, upstream status, and request/upstream duration. Request-bearing nginx error output is disabled for this virtual host; the service's structured health events remain available in the system journal.

Operational privacy

wrangler.toml explicitly disables Workers observability and invocation logs, because request URLs contain the transient rendezvous capability. The application has no per-session logging calls and no persistence bindings beyond the required Durable Object namespace.

Self-hosted adapters emit one fixed-field JSON health event at startup, every 60 seconds, and shutdown. Those events report only runtime mode, listening port, uptime, active-room count, and aggregate counters for connections, splices, rejections, forwarded frames, oversize/send failures, timeouts, and disconnects. They never contain rendezvous IDs, paths, IP addresses, domains, keys, headers, frame sizes or contents, device data, or vault data. TLS renewal and startup failures use equally fixed status-only events.

The first socket waits for at most two minutes. A second socket creates the splice; a third is rejected. Closing either peer closes the other. A single frame over 24 MiB closes both peers with WebSocket code 1009, leaving margin below Cloudflare's platform limit.

Development

bun run typecheck
bun run test
bun run test:bun-adapter
bun run check:licenses
bunx wrangler deploy --dry-run
bunx wrangler dev --local

The transport-independent behavior lives in src/core.ts. Runtime adapters must use that core so hosted and self-hosted deployments preserve the same two-party, no-buffering contract.

Runtime/API choices were checked on 2026-07-26 against the Bun WebSocket documentation, the ws server API, and the Let's Encrypt challenge guide (last updated 2026-02-12). The NixOS module follows the stable 26.05 module and systemd-service guidance.