Files
hyprcrd/relay/README.md
T
thandClaude Opus 4.6 c5bf8b0407 docs: update all READMEs for v1.1.0
Add Electron desktop app section, document all 8 plugins, update
features list (assets, super-buckets, find/replace, arrow nav, PDF
upload), fix relay docs (chat shipped, not future), add missing API
endpoints, correct electron data storage paths and php-bin fetch info.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:00:06 +02:00

616 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# hyprcrd Relay
A self-hosted **multi-tenant** message relay for hyprcrd. Multiple users
share one relay instance; each one has their own login, their own client
encryption secret, and their own inbox. Cards and encrypted chat
messages can be sent **between tenants** on the same relay.
The relay is a single-file PHP app — drop the directory into any PHP
webspace and it works.
## Concepts
| Term | Meaning |
|---|---|
| **Tenant** | A user account on the relay. Has a username + UI password (set by admin) + a self-managed client secret (set by the user themselves). |
| **Admin** | A tenant with `is_admin = 1`. Can create, modify, and delete other tenants. The first tenant created at install time is automatically admin. |
| **Client secret (per-tenant)** | The key your hyprcrd client uses to encrypt outgoing messages. **Set by the user themselves** in the relay UI's "your account" view (not by the admin). Each user picks or generates their own. The relay stores it encrypted with the master key so it can route messages between tenants. |
| **Super-shared secret** | A relay-wide secret **set by the admin during initial setup**. This is the cross-tenant routing key — used by the relay to re-encrypt messages at rest so they can flow between any pair of tenants. Stored as `inter_tenant_key` in the settings table, encrypted with the master key. Set once, can't be changed without re-setup (would invalidate all stored messages). |
| **Master key** | A 32-byte random value generated on first install, stored in `data/master.key` (chmod 600, blocked by `.htaccess`). Encrypts the super-shared secret and per-tenant client secrets at rest, so a DB leak alone doesn't compromise the relay. |
| **Inbox** | Each tenant's incoming messages. 24-hour ephemeral storage. |
### Three secrets, three jobs
1. **UI login password** (set by admin) — opens the relay web UI for that tenant
2. **Client secret** (set by the user themselves on first login) — encrypts hyprcrd ↔ relay traffic
3. **Super-shared secret** (set by admin once at install) — encrypts everything in the DB and is the pivot for cross-tenant routing
The admin only ever touches secrets #1 and #3. The user only ever touches secret #2. They are stored separately and serve different purposes.
## Encryption flow (cross-tenant)
```
┌──────────────┐ ┌─────────────────────┐
│ hyprcrd A │ │ relay │
│ (alice) │ │ │
│ │ │ 1. auth alice │
│ encrypts │ POST /messages │ 2. decrypt body │
│ body with ├─────────────────►│ with alice's │
│ Sa (alice's │ X-Relay-User:A │ secret │
│ secret) │ X-Relay-Key:Sa │ 3. re-encrypt with │
│ │ │ inter-tenant key │
└──────────────┘ │ 4. store with │
│ recipient = bob │
┌──────────────┐ │ │
│ hyprcrd B │ │ 5. on B's poll: │
│ (bob) │ GET /messages │ decrypt with │
│ │ X-Relay-User:B │ inter-tenant key │
│ decrypts │◄─────────────────┤ 6. re-encrypt with │
│ body with │ X-Relay-Key:Sb │ bob's secret │
│ Sb (bob's │ │ 7. return │
│ secret) │ │ │
└──────────────┘ └─────────────────────┘
```
The relay **does** see plaintext briefly during the cross-tenant hop
(steps 2–3 and 5–6). This is the same trust model as email: you trust
the relay operator. Chat between tenants is shipped (via the chat
plugin with delivery + read receipts). **Strict end-to-end encryption**
with per-pair key exchange (Curve25519/ECDH) is on the roadmap.
For at-rest data, all sensitive values are encrypted with the master
key, so a stolen DB without the master key file gives an attacker
nothing.
## Setup
### 1. Deploy
**Webspace** (any PHP host with `mod_rewrite` and `pdo_sqlite`):
```bash
cd relay
./dist.sh
# upload hyprcrd-relay.zip and extract into your webspace
```
**Docker**:
```bash
cd relay
docker build -t hyprcrd-relay .
docker run -d -p 8081:80 -v relay-data:/var/www/html/data hyprcrd-relay
```
The `data/` directory is created on first request, contains the
SQLite DB and the master key file, and is blocked from web access via
`.htaccess`.
### 2. Initial admin setup
Open the relay URL **from a desktop browser** (the setup wizard is
desktop-friendly). On first visit you'll see the setup form.
Fill in:
- **admin username** (2-32 chars, letters/digits/`_`/`-`)
- **admin password** (min 4 chars, used for the relay UI login)
- **super-shared secret** (min 12 chars, the relay-wide cross-tenant
routing key — set once, can't be changed)
Click **create admin + activate relay**. You're logged in and dropped
straight onto the **account** page where you'll set your own client
secret.
### 3. Set your own client secret
Every tenant — including the admin — sets their own client secret on
the **account** page. The admin never knows or sets it for other users.
You can either:
- **Type your own secret** (min 8 chars) and click save
- **Generate a random one** (48-char hex) and click the dice button
The secret is shown in plaintext on the account page so you can copy
it to your hyprcrd's relay settings. You can return to the account
page anytime to view it again.
### 4. Create more users (admin)
Once logged in as admin, click the **users** tab in the header. Use the
form to create additional tenants:
- their own **username**
- a **temporary password** (the user is forced to change it on first login)
- optional admin privileges (checkbox)
The admin does **not** set the new user's client secret — the user
themselves sets it on first login from the account page.
Tell each user their credentials (just username + temporary password)
out-of-band (signal, encrypted email, in person). On their first
login, the relay UI will:
1. Show a **forced password change** screen — they must replace the
admin-issued temp password with one of their own (min 4 chars).
No other view is reachable until they do.
2. Drop them on the **account** page to set their client secret.
3. Then they can copy the secret to their hyprcrd Settings → Relay.
The admin themselves is **never** forced to change password (the
admin set their own password during the initial setup wizard).
### 5. Connect a hyprcrd instance to the relay
In any hyprcrd instance:
1. Open **Settings → Relay**
2. Enter:
- **relay url** (e.g. `https://relay.example.com`)
- **username** (the one the admin gave you)
- **shared secret** (the one **you** set on the account page)
3. Click **save**, then **test** to verify the connection works
After this, hyprcrd polls the relay every 45 seconds and will fetch
incoming messages.
### 6. Send a card to another tenant
(in the relay UI compose tab)
In the relay UI **compose** tab:
- Pick a recipient from the dropdown (defaults to yourself)
- Type a title and body
- Click **send**
The recipient will see it on their next hyprcrd poll, materialized as a
card in their board.
You can also right-click a card in hyprcrd and pick **push to relay** —
this sends to your own inbox (or pick a recipient in the relay UI).
## Data lifetime
- Messages auto-delete after **24 hours** (whether fetched or not)
- Attachments same
- Sessions expire after 24 hours of inactivity
- Rate-limit entries cleaned up every 5 minutes
The relay is **not a long-term archive**. Anything you want to keep
should be extracted as a card in hyprcrd.
## Database schema
```
tenants
id, username, password_hash (bcrypt), secret_hash (bcrypt),
secret_encrypted (master-key encrypted), is_admin, created_at
settings
key='inter_tenant_key', value=master-encrypted blob
messages
id, recipient_id → tenants(id), sender_id → tenants(id),
sender_username, title, body (both encrypted with inter-tenant key),
color, tags, relay_uid, fetched, created_at
attachments
id, recipient_id → tenants(id), relay_uid, filename, data, created_at
sessions
id (random token), tenant_id, created_at, expires_at
rate_limits
ip, endpoint, attempted_at
```
`recipient_id` and `sender_id` cascade-delete when a tenant is removed.
## API reference
All endpoints under `/api/`. JSON in/out.
### Public (no auth)
| Method | Path | Body | Response |
|---|---|---|---|
| GET | `/api/status` | — | `{setup, has_admin, logged_in, username?, is_admin?, inbox_count?}` |
| POST | `/api/setup` | `{username, password, secret}` | `{ok, username, is_admin}` — only works when no tenants exist |
| POST | `/api/login` | `{username, password}` | `{ok, username, is_admin}` — sets session cookie |
| POST | `/api/logout` | — | `{ok}` |
### Session-authenticated (UI cookie)
| Method | Path | Body | Notes |
|---|---|---|---|
| GET | `/api/me` | — | Current tenant info |
| GET | `/api/tenants` | — | List of all usernames (for recipient picker) |
| POST | `/api/messages` | `{title, body, recipient?}` | UI compose path: server encrypts with inter-tenant key |
| GET | `/api/messages/inbox` | — | Read own inbox messages (decrypted for display) |
| DELETE | `/api/messages/{id}` | — | Delete a message from own inbox |
| GET | `/api/chat/sent` | — | Sent chat messages with delivery/read status |
| POST | `/api/chat/seen` | `{ids: [...]}` | Mark chat messages as seen (read receipts) |
### Admin only (session + `is_admin`)
| Method | Path | Body | Notes |
|---|---|---|---|
| GET | `/api/users` | — | List all tenants |
| POST | `/api/users` | `{username, password, secret, is_admin?}` | Create new tenant |
| PATCH | `/api/users/{id}` | `{password?, secret?, is_admin?}` | Update existing tenant |
| DELETE | `/api/users/{id}` | — | Cascade-delete tenant + their messages |
Last-admin protection: you cannot demote or delete the only remaining
admin, and you cannot delete your own account.
### Client API (header-authenticated)
Headers required:
```
X-Relay-User: alice
X-Relay-Key: <alice's shared secret>
```
| Method | Path | Body | Notes |
|---|---|---|---|
| POST | `/api/messages` | `{title, body, recipient?, color?, tags?, relay_uid?}` | title and body must already be client-encrypted with sender's secret. Server decrypts, re-encrypts with inter-tenant key, stores. |
| GET | `/api/messages?since=ISO` | — | Returns messages re-encrypted with the requesting tenant's secret. Marks them as `fetched`. |
| DELETE | `/api/messages/{id}` | — | Only deletes own messages (recipient_id must match) |
| POST | `/api/attachments` | `{relay_uid, filename, data, recipient?}` | data is base64-encoded encrypted blob |
| GET | `/api/attachments?relay_uid=...` | — | Fetches own attachments only |
## Encryption format
Client-side messages use a versioned envelope:
```
HYPRCRD-RELAY-V1\n<base64(IV || ciphertext)>
```
- **Cipher**: AES-256-CBC
- **Key**: `substr(sha256(secret_string), 0, 32)` interpreted as 32 ASCII bytes
- **IV**: 16 random bytes per message, prepended to ciphertext
This format is compatible with the JS encryption in hyprcrd's relay
plugin and the PHP `clientEncrypt`/`clientDecrypt` helpers in the relay.
The **inter-tenant key** uses the exact same scheme — it's just a longer
random secret (64 hex chars = 32 bytes after the `substr` step gives 32
ASCII chars from the hex prefix). All re-encryption hops in the relay
use the same code path.
## Security model
**What's protected:**
- Tenant secrets and the inter-tenant key are encrypted at rest with the
master key
- The master key file lives in `data/`, blocked from web access by
`.htaccess`, with `chmod 600`
- All passwords (UI login + client secrets) are bcrypt-hashed for auth
verification
- Sessions use random 32-byte tokens, HttpOnly + SameSite=Strict cookies
- Rate-limiting: 5 attempts/minute per IP for setup, login, and verify
- Request body limited to 256 KB (15 MB for attachment uploads)
- Security headers: X-Frame-Options, X-Content-Type-Options, HSTS,
Referrer-Policy
- All sensitive endpoints require authentication
- Last-admin protection prevents accidental lockout
**What's NOT protected (by design, current trust model):**
- The relay operator can decrypt all messages — the master key file +
the DB give them access to everything. This is the same trust model
as email.
- Cross-tenant messages are seen in plaintext briefly during the
re-encryption hop in PHP memory. They are not written anywhere
unencrypted.
- A compromised relay machine = compromised messages. Use HTTPS, keep
PHP up to date, restrict shell access.
**Future work for stronger guarantees** (roadmap for strict E2E):
- Per-tenant Curve25519 key pair stored in the DB (public key plus
master-key-encrypted private key, or even private key never on the
server)
- ECDH key exchange between tenant pairs
- Per-conversation symmetric key derived from the shared secret
- Forward secrecy via ratcheting
- This would make the relay a true blind router: it sees only opaque
blobs and metadata.
## Migrations
### v1 (single-tenant) → v3 (multi-tenant, self-set secrets)
The old schema had `relay_password` and `relay_key` in the `settings`
table and messages/attachments tables without `recipient_id`. On first
request after the upgrade, the relay automatically:
1. Detects the legacy schema by checking if `messages.recipient_id`
exists. If not, it `DROP`s the old `messages` and `attachments`
tables. (They contain only 24h-ephemeral data anyway.)
2. Creates the new `tenants`, `sessions`, `messages`, and `attachments`
tables via `CREATE TABLE IF NOT EXISTS`.
3. The `tenants` table starts empty → the **setup wizard** is shown
the next time you load the relay URL in a browser.
### v2 (admin-set secrets) → v3 (self-set secrets)
In v2 the admin set the client secret when creating each user. In v3
this changed: secrets are nullable and each user sets their own.
On first request after the upgrade, the relay automatically:
1. Detects the v2 schema by checking if `tenants.secret_hash` is
`NOT NULL`. If so, the table is recreated with nullable columns
(and the new `password_changed` column).
2. Existing tenant rows (username, password, is_admin) are kept
intact, but their `secret_hash` and `secret_encrypted` are
cleared to NULL.
3. **Non-admin users get `password_changed = 0`** so they're forced
to change their password on the next login (the v2 password was
admin-set, not self-chosen). Admins keep `password_changed = 1`.
4. The next time each user logs in, the relay walks them through:
forced password change → set client secret → start using.
5. The previously generated `inter_tenant_key` is kept as-is so
cross-tenant messages flow through the same key. Admin can rotate
it later (currently requires a manual DB edit; rotate API endpoint
is on the TODO).
### v3.0 → v3.1 (forced password change flag)
A small additive migration. If `tenants.password_changed` is missing,
the relay does:
1. `ALTER TABLE tenants ADD COLUMN password_changed INTEGER NOT NULL DEFAULT 1`
2. `UPDATE tenants SET password_changed = 0 WHERE is_admin = 0` —
forces existing non-admin users to change their password too,
since they were originally admin-set.
Admins keep `password_changed = 1` because they set their own password
during the initial setup wizard.
After running through the setup wizard once on a desktop:
- You're the new admin
- A fresh inter-tenant key is generated and stored encrypted with the
master key
- All previously connected hyprcrd instances need to update their
**Settings → Relay** to add the new `username` field and the new
shared secret you set
There's **no plaintext recovery** of the old `relay_key` because it
was bcrypt-hashed in the old schema — you have to re-create users and
re-enter the secret on every connected hyprcrd. Treat the upgrade as a
fresh install with credential reset.
The legacy `relay_password` and `relay_key` rows in the `settings` table
are left in place (unused) so you can downgrade if needed.
## Development (both relay + hyprcrd together)
From the project root:
```bash
./dev.sh
```
Starts hyprcrd on `http://localhost:8088` and the relay on
`http://localhost:8081` in the same Docker network. Source files are
bind-mounted — changes are live without rebuilding.
From inside the hyprcrd container, the relay is reachable at
`http://relay:80`. This is the URL to put in hyprcrd's relay settings
when developing locally.
### Docker bind-mounts
The relay's `docker-compose.yml` bind-mounts these paths from the host:
```yaml
volumes:
- ./relay/index.php:/var/www/html/index.php
- ./relay/.htaccess:/var/www/html/.htaccess
- ./relay/fonts:/var/www/html/fonts
- ./relay/inc:/var/www/html/inc
- ./relay/ui:/var/www/html/ui
- relay-data:/var/www/html/data
```
If you add new top-level directories to `relay/`, mount them here too —
otherwise the container won't see them and `require` will fail silently
(`error_reporting(0)` swallows the fatal). Symptoms: blank page or 500
on every request.
After updating bind mounts you must `docker compose down && docker compose up -d --build`
— Compose remembers the previous mount config until you tear down.
### Wiping the relay state
Sometimes you want a completely fresh DB (e.g. after schema changes
during development):
```bash
docker compose down -v # -v also removes the relay-data volume
docker compose up -d
```
Without `-v` the volume persists between restarts. Wiping forces the
relay back to setup-wizard mode on the next visit.
## UI
### Responsive layout
The UI is desktop-first but works on mobile. The header collapses to
icon-only buttons on narrow screens. The compose card and inbox stack
naturally.
### Recipient picker auto-hides for solo tenants
When the relay only has one tenant (typical for a fresh install or a
single-user deployment), the **recipient** dropdown in the compose form
is hidden — there's no other tenant to send to. The compose form
becomes just title + body.
As soon as the admin creates a second tenant via the users panel, the
dropdown reappears on the next render of the compose view, defaulting
to "you" with the other tenants listed below.
The default recipient is always "self" if not explicitly set, so the
backend behaves the same whether the picker is visible or not.
### Mobile is compose-only
On mobile (screens narrower than 700px) the relay strips down to a
single-purpose tool: dump content into the inbox. There's no
navigation, no admin, no identity display — just compose + your own
inbox count.
What's hidden on mobile via CSS media query (`@media (max-width: 700px)`):
- The **users** nav button (`#nav-admin`)
- The entire **users management view** (`#view-admin`)
- The **logged-in username + admin badge** in the header (`#nav-user`)
- The **compose nav button** (`#nav-compose`) — since there's nothing
to navigate between, the button is dead weight
The mobile header shrinks to just: pulsing dot + "hyprcrd relay" title
+ inbox count + logout button.
For defense-in-depth, the JS also redirects `showView('admin')` calls
to `compose` when the viewport is mobile, so the admin panel can't be
opened programmatically either.
The backend admin API endpoints (`/api/users`) are still reachable —
they're protected by the session+`is_admin` check, so only the UI is
restricted. The intent is to:
1. Keep mobile users out of admin tasks (form is cramped on small screens)
2. Avoid showing the username in shoulder-surfing range — most relay
use on phone is ad-hoc note capture in public
3. Reduce the mobile UI to its single purpose: capture and send
If you need to administrate from a mobile device, use a desktop
browser's mobile-responsive emulator OR temporarily widen the viewport
(rotate to landscape on a tablet etc.).
### Icons (Phosphor, locally hosted)
The relay uses [Phosphor Icons](https://phosphoricons.com) — the same
icon family hyprcrd uses. They're hosted **locally** (no CDN), so the
relay remains a fully self-contained drop-in app with zero external
dependencies at runtime.
The font + CSS files live in `relay/fonts/`:
- `phosphor.css` (~78 KB) — the `.ph` and `.ph-{name}` rules
- `Phosphor.woff2` (~147 KB) — the actual font file
To add an icon anywhere in the UI:
```html
<i class="ph ph-paper-plane-tilt"></i> send
```
Sizing is controlled in `ui/style.css` per context (nav buttons 11px,
section headers 13px, action buttons 12px). Custom sizes can be set
inline via `style="font-size:20px;"`.
The full icon catalog is at https://phosphoricons.com.
## Files
```
relay/
├── README.md # this file
├── index.php # entry point: bootstraps, dispatches API routes, renders UI
├── inc/
│ ├── bootstrap.php # DB connect, master key, schema migration, helpers
│ ├── crypto.php # masterEncrypt/Decrypt, clientEncrypt/Decrypt, inter-tenant key
│ ├── tenants.php # tenant DB lookup helpers
│ ├── auth.php # rate limit, sessions, header-based client auth
│ └── routes/
│ ├── public.php # /api/status, /setup, /login, /logout, /me
│ ├── admin.php # /api/users CRUD, /api/tenants directory
│ ├── messages.php # /api/messages, /api/attachments, cross-tenant routing
│ ├── chat.php # /api/chat/sent, /api/chat/seen (read receipts)
│ └── collab-buckets.php # /api/collab-buckets CRUD + card sync
├── ui/
│ ├── layout.php # HTML shell + view containers
│ ├── style.css # all CSS (including responsive media queries)
│ └── app.js # all JS
├── fonts/ # all locally-hosted UI assets
│ ├── silkscreen-400.ttf # pixel display font
│ ├── silkscreen-700.ttf
│ ├── jetbrains-mono-400.ttf # monospace body font
│ ├── phosphor.css # Phosphor icon font CSS rules
│ └── Phosphor.woff2 # Phosphor icon font binary
├── .htaccess # rewrite + blocks /data/, /inc/, /ui/*.php
├── Dockerfile
├── dist.sh # builds hyprcrd-relay.zip with all of the above
└── data/ # created on first run, NOT in the dist
├── relay.db # SQLite database
└── master.key # 32-byte master key (chmod 600)
```
Each include file in `inc/` and `ui/` checks `RELAY_BOOTSTRAPPED` at the
top and exits with 404 if accessed directly. The `.htaccess` adds a
defense-in-depth `[F,L]` block on `inc/` and `ui/*.php` so they're never
served by Apache anyway.
The `dist.sh` script zips up `index.php`, `.htaccess`, `inc/`, `ui/`,
`fonts/`, and `README.md` recursively — drop the resulting
`hyprcrd-relay.zip` into any PHP webspace.
## Troubleshooting
**"too many attempts" on login**
Rate limit. Wait one minute.
**"unauthorized" from a hyprcrd instance**
- Verify `relay-username` is set in hyprcrd Settings → Relay
- Verify the username matches a tenant created on the relay
- Verify the shared secret matches what the admin gave you
- Check the relay log if you have access — bcrypt mismatch is the most
common cause
**Can't see new tenants in the dropdown after admin created them**
The recipient picker is loaded once per page session. Refresh the relay
UI tab to repopulate.
**Lost the master key**
You're locked out. There's no recovery: the inter-tenant key and all
stored tenant secrets are unrecoverable. You'll need to wipe `data/`
and re-setup. Back up `data/master.key` to a safe place after install
if you care about durability beyond the 24h message window.
**Lost the admin password**
Open `data/relay.db` directly with `sqlite3` and reset:
```sql
UPDATE tenants SET password_hash = '$2y$10$...' WHERE username = 'admin';
```
Generate the bcrypt hash with `php -r "echo password_hash('newpw', PASSWORD_BCRYPT);"`.
**Blank page / 500 error after upgrade in Docker**
The most common cause is that `docker-compose.yml` is missing bind
mounts for `relay/inc` and `relay/ui`. When the new modular `index.php`
runs `require __DIR__ . '/inc/bootstrap.php'` and the file doesn't exist
in the container, PHP fatally errors. `error_reporting(0)` in the relay
swallows the error → blank page.
Fix: ensure these mounts are present in `docker-compose.yml`:
```yaml
- ./relay/inc:/var/www/html/inc
- ./relay/ui:/var/www/html/ui
```
Then `docker compose down && docker compose up -d --build`.
**Setup wizard doesn't appear after upgrading from v1**
The auto-migration drops the legacy `messages` and `attachments` tables
on first request. If you still don't see the setup wizard:
1. Clear browser cookies + localStorage for the relay domain (the v1
stored credentials in localStorage which is now stale)
2. Check the relay log for `[hyprcrd-relay] migrated:` lines confirming
the legacy tables were dropped
3. As a last resort, wipe the volume:
`docker compose down -v && docker compose up -d`
You lose nothing important — the relay is 24h-ephemeral by design.
**Mobile user wants admin access**
By design, mobile screens (≤700px) hide the entire admin UI. Use a
desktop browser, or in your phone browser's dev tools rotate to
landscape on a tablet, or use the request-desktop-site option to widen
the viewport. The admin API endpoints still work via curl with a valid
session cookie if you really need scripted access.