peach-relay
End to End encrypted relay for Peach Password Manager. Never reads nor stores any vault or user data.
TIMELINE 99 saves
ATLAS 52 files ⇓ bundle
HISTORY 99 saves
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
NKpattern on first pairing, where the dialer already pins the peer's static key, and theKKpattern on reconnects. The handshake runs the canonical Rustsnowstate machine, compiled to WebAssembly in the browser and to JNI on Android. - The vault envelope is stretched with
Argon2idat 64 MiB of memory, 3 passes, and 4 lanes, then checked withBLAKE3. 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-SHA256token 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
AorAAAArecord 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. Defaultauto.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. Default65432.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