From 5b18c46ac2df06abe2245501d44c8be3d546a93f Mon Sep 17 00:00:00 2001
From: th
Date: Fri, 10 Apr 2026 18:23:07 +0200
Subject: [PATCH] multi-tenant relay und card-push to user
---
README.md | 107 +-
docker-compose.yml | 2 +
public/index.php | 116 +-
public/js/modules/caldav.js | 51 +-
public/js/modules/context-menu.js | 104 +-
relay/.htaccess | 5 +
relay/README.md | 612 ++++
relay/dist.sh | 2 +-
relay/fonts/Phosphor.woff2 | Bin 0 -> 147380 bytes
relay/fonts/phosphor.css | 4627 +++++++++++++++++++++++++++++
relay/inc/auth.php | 66 +
relay/inc/bootstrap.php | 203 ++
relay/inc/crypto.php | 78 +
relay/inc/routes/account.php | 73 +
relay/inc/routes/admin.php | 98 +
relay/inc/routes/messages.php | 160 +
relay/inc/routes/public.php | 110 +
relay/inc/tenants.php | 24 +
relay/index.php | 492 +--
relay/ui/app.js | 449 +++
relay/ui/layout.php | 186 ++
relay/ui/style.css | 407 +++
22 files changed, 7453 insertions(+), 519 deletions(-)
create mode 100644 relay/README.md
create mode 100644 relay/fonts/Phosphor.woff2
create mode 100644 relay/fonts/phosphor.css
create mode 100644 relay/inc/auth.php
create mode 100644 relay/inc/bootstrap.php
create mode 100644 relay/inc/crypto.php
create mode 100644 relay/inc/routes/account.php
create mode 100644 relay/inc/routes/admin.php
create mode 100644 relay/inc/routes/messages.php
create mode 100644 relay/inc/routes/public.php
create mode 100644 relay/inc/tenants.php
create mode 100644 relay/ui/app.js
create mode 100644 relay/ui/layout.php
create mode 100644 relay/ui/style.css
diff --git a/README.md b/README.md
index 8c7216f..7eba899 100644
--- a/README.md
+++ b/README.md
@@ -342,30 +342,37 @@ When running multiple hyprcrd instances that share CalDAV credentials, you need
## Relay
-A minimal web service for mobile note capture and card sharing between hyprcrd instances.
+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 in the future:
+encrypted chat messages) can be sent **between tenants** on the same
+relay.
+
+**Full documentation:** [`relay/README.md`](relay/README.md)
### Architecture
```
-[Phone/Browser] → https://relay.example.com → [Relay Service]
- ↓ poll
- [hyprcrd (local)]
+[hyprcrd alice] ──push──┐ ┌──poll── [hyprcrd alice]
+ ├──► [Relay] ◄────────────┤
+[hyprcrd bob] ──push──┘ tenants: alice, bob └──poll── [hyprcrd bob]
-[hyprcrd A] → push card → [Relay] ← poll ← [hyprcrd B]
+ sender encrypts → relay decrypts with sender's secret →
+ re-encrypts with shared inter-tenant key → stores →
+ on recipient poll: decrypts → re-encrypts with recipient's secret
```
-### Deploy to Webspace
+### 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
```
-Upload the resulting `hyprcrd-relay.zip` to your webspace and extract it. The SQLite database is created automatically on first request. Open the URL and set a password + shared secret.
-
-Requirements: PHP 8.3+ with `pdo_sqlite`, Apache with `mod_rewrite`.
-
-### Deploy with Docker
+**Docker**:
```bash
cd relay
@@ -373,42 +380,76 @@ 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 and contains the
+SQLite DB plus the master encryption key (chmod 600, blocked from web
+access via `.htaccess`).
+
### Development (both together)
```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.
+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`.
-From inside the hyprcrd container, the relay is reachable at `http://relay:80`.
+### Setup workflow
-### Setup
+1. Open the relay URL **from a desktop browser** — the setup wizard
+ is desktop-friendly
+2. Create the first **admin** account: username + password (UI login)
+ + shared secret (client encryption)
+3. As admin, click the **users** tab and create accounts for any
+ additional users you want to give relay access to
+4. Each user enters their credentials in their own hyprcrd:
+ **Settings → Relay** → relay URL + username + shared secret
+5. To send a card from one tenant to another, use the relay UI's
+ compose form (recipient picker) — or push cards from hyprcrd
-1. Open the relay URL, set a **password** (for UI login) and a **shared secret** (for encryption)
-2. In hyprcrd: **Settings > Relay** — enter the relay URL and the same shared secret
-3. Click **save**
+### How it works (high level)
-### How it works
-
-- **Mobile inbox**: type a note on the relay UI, it arrives as a card with `#inbox` tag
-- **Push to relay**: right-click any card → "push to relay" — other instances pick it up on next poll
-- Messages are encrypted client-side (AES-256-CBC) before sending to the relay
-- The relay only stores ciphertext — it cannot read your messages
-- hyprcrd polls the relay every 45 seconds in the background
-- Messages are kept on the relay for 24 hours, then auto-deleted
-- Cards pushed from the same instance are not re-imported
-- Enter to send, Shift+Enter for newline
+- **Per-tenant inboxes**: each user only sees their own messages
+- **Cross-tenant routing**: send a card to another tenant on the same
+ relay; the relay decrypts with the sender's secret, re-encrypts with
+ a shared inter-tenant key for storage, then re-encrypts with the
+ recipient's secret on delivery
+- **At-rest encryption**: every sensitive value in the DB (tenant
+ secrets, the inter-tenant key, message bodies) is encrypted. The
+ master key lives outside the DB in a chmod-600 file
+- **24-hour ephemeral**: messages and attachments auto-delete after a
+ day. Cards extracted in hyprcrd are the long-term store
+- **Future: end-to-end encrypted chat** between tenants is planned and
+ will require per-pair key exchange — see `relay/README.md` for the
+ roadmap
### Relay Security
-- Separate password (UI login) and shared secret (API + encryption)
-- Bcrypt hashing for both credentials
-- Rate-limited login (5 attempts/minute)
-- AES-256-CBC client-side encryption — relay never sees plaintext
+- Per-tenant bcrypt-hashed passwords + secrets
+- Master key encryption for tenant secrets and the inter-tenant key
+ at rest
+- HttpOnly + SameSite=Strict session cookies
+- Rate-limited login (5 attempts/minute per IP)
+- AES-256-CBC for all message encryption
- Security headers (X-Frame-Options, HSTS, X-Content-Type-Options)
-- SQLite database blocked from direct download via `.htaccess`
-- Request size limited to 256KB
+- `data/` directory blocked from direct download via `.htaccess`
+- Request size limited to 256KB (15MB for attachments)
+- Last-admin protection prevents accidental lockout
+
+For the full security model (including what's NOT protected and the
+roadmap to strict E2E for chat), see [`relay/README.md`](relay/README.md).
+
+### Migrating from the old single-tenant relay
+
+When you upgrade an existing relay installation, the new code automatically
+detects the legacy schema and drops the old `messages` and `attachments`
+tables (the data is 24h-ephemeral anyway). The setup wizard appears on
+your next visit. You'll need to:
+1. Run through the desktop setup wizard to create the first admin
+2. Recreate any other tenant accounts that need access
+3. On every connected hyprcrd, update **Settings → Relay** to add the
+ new **username** field and the new shared secret
+
+The old `relay_key` was bcrypt-hashed and cannot be recovered, so all
+client secrets need to be reissued.
## Rebuilding Tailwind CSS
diff --git a/docker-compose.yml b/docker-compose.yml
index 2009a96..239bf8a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -17,6 +17,8 @@ services:
- ./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
volumes:
diff --git a/public/index.php b/public/index.php
index 8017a84..ad2c0c4 100644
--- a/public/index.php
+++ b/public/index.php
@@ -821,33 +821,61 @@ if (str_starts_with($route, '/api/')) {
if ($route === '/api/relay/settings' && $method === 'GET') {
$url = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '';
- $hasKey = !empty($db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn());
- echo json_encode(['url' => $url, 'has_key' => $hasKey]); exit;
+ $username = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '';
+ $key = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '';
+ // Single-user local app — echo the stored secret back so the user can
+ // see and edit it directly without guessing if it's still saved.
+ echo json_encode(['url' => $url, 'username' => $username, 'key' => $key]); exit;
}
if ($route === '/api/relay/settings' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
if (isset($input['url'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_url', :v)")->execute(['v' => $input['url']]);
- if (!empty($input['key'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $input['key']]);
+ if (isset($input['username'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_username', :v)")->execute(['v' => $input['username']]);
+ // Always overwrite key when present (even empty) — single-user local
+ // app, the input is now pre-populated so an empty submit is intentional
+ if (array_key_exists('key', $input)) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $input['key']]);
echo json_encode(['ok' => true]); exit;
}
if ($route === '/api/relay/poll' && $method === 'POST') {
+ $input = json_decode(file_get_contents('php://input'), true) ?: [];
+ $strict = !empty($input['strict']); // test button sets this; background poll doesn't
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
- if (!$relayUrl || !$relayKey) { echo json_encode(['ok' => true, 'pulled' => 0]); exit; }
+ $relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
+ if (!$relayUrl || !$relayKey || !$relayUser) {
+ if ($strict) {
+ $missing = [];
+ if (!$relayUrl) $missing[] = 'url';
+ if (!$relayUser) $missing[] = 'username';
+ if (!$relayKey) $missing[] = 'secret';
+ echo json_encode(['ok' => false, 'error' => 'relay not configured: missing ' . implode(', ', $missing)]); exit;
+ }
+ echo json_encode(['ok' => true, 'pulled' => 0]); exit;
+ }
$ch = curl_init(rtrim($relayUrl, '/') . '/api/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
- CURLOPT_HTTPHEADER => ['X-Relay-Key: ' . $relayKey],
+ CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
- if ($status !== 200) { echo json_encode(['ok' => false, 'error' => "HTTP $status"]); exit; }
+ if ($status !== 200) {
+ $relayMsg = '';
+ $decoded = json_decode($response, true);
+ if (is_array($decoded) && !empty($decoded['error'])) {
+ $relayMsg = ' — ' . $decoded['error'];
+ } elseif (is_string($response) && $response !== '') {
+ $relayMsg = ' — ' . substr($response, 0, 200);
+ }
+ echo json_encode(['ok' => false, 'error' => "HTTP $status$relayMsg", 'sent_username' => $relayUser]);
+ exit;
+ }
$messages = json_decode($response, true);
if (!is_array($messages)) { echo json_encode(['ok' => false, 'error' => 'invalid response']); exit; }
@@ -909,7 +937,7 @@ if (str_starts_with($route, '/api/')) {
$ach = curl_init(rtrim($relayUrl, '/') . '/api/attachments?relay_uid=' . urlencode($relayUid));
curl_setopt_array($ach, [
CURLOPT_RETURNTRANSFER => true,
- CURLOPT_HTTPHEADER => ['X-Relay-Key: ' . $relayKey],
+ CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 30,
]);
$attResponse = curl_exec($ach);
@@ -938,14 +966,45 @@ if (str_starts_with($route, '/api/')) {
echo json_encode(['ok' => true, 'pulled' => $pulled]); exit;
}
+ // List relay tenants (for the recipient picker)
+ if ($route === '/api/relay/tenants' && $method === 'GET') {
+ $relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
+ $relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
+ $relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
+ if (!$relayUrl || !$relayKey || !$relayUser) {
+ echo json_encode(['ok' => false, 'error' => 'relay not configured']); exit;
+ }
+ $ch = curl_init(rtrim($relayUrl, '/') . '/api/tenants');
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
+ CURLOPT_TIMEOUT => 10,
+ ]);
+ $response = curl_exec($ch);
+ $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+ if ($status !== 200) {
+ echo json_encode(['ok' => false, 'error' => "relay returned HTTP $status"]); exit;
+ }
+ $data = json_decode($response, true);
+ echo json_encode([
+ 'ok' => true,
+ 'tenants' => $data['tenants'] ?? [],
+ 'self' => $data['self'] ?? $relayUser,
+ ]);
+ exit;
+ }
+
// Push a card to relay
if ($route === '/api/relay/push' && $method === 'POST') {
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
- if (!$relayUrl || !$relayKey) { echo json_encode(['ok' => false, 'error' => 'relay not configured']); exit; }
+ $relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
+ if (!$relayUrl || !$relayKey || !$relayUser) { echo json_encode(['ok' => false, 'error' => 'relay not configured (need url, username, and secret)']); exit; }
$input = json_decode(file_get_contents('php://input'), true);
$cardId = $input['card_id'] ?? null;
+ $recipient = isset($input['recipient']) ? trim((string)$input['recipient']) : null;
if (!$cardId) { http_response_code(400); echo json_encode(['error' => 'card_id required']); exit; }
$card = $db->prepare('SELECT * FROM cards WHERE id = ?');
@@ -967,24 +1026,32 @@ if (str_starts_with($route, '/api/')) {
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
};
- $payload = json_encode([
+ $payloadData = [
'title' => $encryptField($card['title'] ?? ''),
'body' => $encryptField($card['body'] ?? ''),
'color' => $card['color'] ?? '#38bdf8',
'tags' => $card['tags'] ?? '',
'relay_uid' => $relayUid,
- ]);
+ ];
+ if ($recipient) $payloadData['recipient'] = $recipient;
+ $payload = json_encode($payloadData);
$ch = curl_init(rtrim($relayUrl, '/') . '/api/messages');
+ $debugStream = fopen('php://temp', 'w+');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
- CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-Key: ' . $relayKey],
+ CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
+ CURLOPT_VERBOSE => true,
+ CURLOPT_STDERR => $debugStream,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ rewind($debugStream);
+ $curlDebug = stream_get_contents($debugStream);
+ fclose($debugStream);
curl_close($ch);
if ($status === 200 || $status === 201) {
@@ -1013,7 +1080,7 @@ if (str_starts_with($route, '/api/')) {
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $attPayload,
- CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-Key: ' . $relayKey],
+ CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 30,
]);
curl_exec($ach);
@@ -1022,7 +1089,25 @@ if (str_starts_with($route, '/api/')) {
echo json_encode(['ok' => true]); exit;
}
- echo json_encode(['ok' => false, 'error' => "HTTP $status"]); exit;
+ // Surface the relay's actual error message so the user can see why
+ $relayMsg = '';
+ $decoded = json_decode($response, true);
+ if (is_array($decoded) && !empty($decoded['error'])) {
+ $relayMsg = ' — ' . $decoded['error'];
+ } elseif (is_string($response) && $response !== '') {
+ $relayMsg = ' — ' . substr($response, 0, 200);
+ }
+ // Strip the X-Relay-Key value from the debug log so we don't leak the secret
+ $safeDebug = preg_replace('/(X-Relay-Key:\s*)[^\r\n]+/i', '$1', $curlDebug);
+ echo json_encode([
+ 'ok' => false,
+ 'error' => "relay returned HTTP $status$relayMsg",
+ 'sent_username' => $relayUser,
+ 'relay_url' => rtrim($relayUrl, '/') . '/api/messages',
+ 'response_body' => is_string($response) ? substr($response, 0, 500) : null,
+ 'curl_debug' => $safeDebug,
+ ]);
+ exit;
}
http_response_code(404); echo json_encode(['error' => 'not found']); exit;
@@ -1438,13 +1523,15 @@ if (str_starts_with($route, '/api/')) {
relay
+
The relay is multi-tenant: each user has their own account on the relay server. Ask the relay admin to create a user for you, then enter the credentials below.
+
-
+
@@ -1819,6 +1906,7 @@ if (str_starts_with($route, '/api/')) {
';
+ return;
+ }
+ // Self first, then others alphabetical
+ const ordered = [
+ ...list.filter(t => t.username === self),
+ ...list.filter(t => t.username !== self),
+ ];
+ relayTenants.innerHTML = ordered.map(t => {
+ const isSelf = t.username === self;
+ const icon = isSelf ? 'ph-user' : 'ph-user-circle';
+ const label = isSelf ? `${t.username} (you)` : t.username;
+ return ``;
+ }).join('');
+
+ relayTenants.querySelectorAll('[data-relay-recipient]').forEach(btn => {
+ btn.addEventListener('click', async e => {
+ e.stopPropagation();
+ const recipient = btn.dataset.relayRecipient;
+ hideContextMenu();
+ const res = await api('api/relay/push', { method: 'POST', body: { card_id: card.id, recipient } });
+ relayPushToast(res, recipient);
+ });
+ });
+}
+
+function relayPushToast(res, recipient) {
+ const ok = res?.ok;
+ const msg = ok
+ ? (recipient && recipient !== cachedRelayTenants?.self ? `pushed to ${recipient}` : 'pushed to relay')
+ : (res?.error || 'push failed');
+ const color = ok ? 'var(--t-accent)' : 'var(--t-danger)';
+ if (!ok) {
+ // Dump every diagnostic the backend gave us so we can see the wire-level
+ // truth instead of guessing why the relay said no
+ console.group('%c[relay push failed]', 'color:#fb7185;font-weight:bold');
+ console.log('error:', res?.error);
+ if (res?.relay_url) console.log('url:', res.relay_url);
+ if (res?.sent_username) console.log('sent username:', res.sent_username);
+ if (res?.response_body) console.log('response body:', res.response_body);
+ if (res?.curl_debug) console.log('curl debug:\n' + res.curl_debug);
+ console.groupEnd();
+ }
+ const n = document.createElement('div');
+ n.style.cssText = `position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid ${color};border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:340px;`;
+ const hint = ok ? '' : '
see browser console for wire-level debug
';
+ n.innerHTML = `
${esc(msg)}
${hint}`;
+ document.body.appendChild(n);
+ setTimeout(() => { n.style.transition = 'opacity .3s'; n.style.opacity = '0'; setTimeout(() => n.remove(), 350); }, ok ? 3000 : 6000);
+}
diff --git a/relay/.htaccess b/relay/.htaccess
index 4100336..2584a34 100644
--- a/relay/.htaccess
+++ b/relay/.htaccess
@@ -3,6 +3,11 @@ RewriteEngine On
# Block direct access to data directory
RewriteRule ^data/ - [F,L]
+# Block direct execution of include files (defense in depth — they also
+# check RELAY_BOOTSTRAPPED at the top of each file).
+RewriteRule ^inc/ - [F,L]
+RewriteRule ^ui/.*\.php$ - [F,L]
+
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
diff --git a/relay/README.md b/relay/README.md
new file mode 100644
index 0000000..360594f
--- /dev/null
+++ b/relay/README.md
@@ -0,0 +1,612 @@
+# 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 in the future:
+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. **Strict end-to-end encryption** for the planned
+chat feature is on the roadmap and will require a per-pair key exchange
+(Curve25519/ECDH) — this is documented as future work in `index.php`.
+
+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
+
+(currently in the relay UI; hyprcrd-side recipient picker is coming)
+
+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 currently sends to your own inbox; recipient picker on the hyprcrd
+side is on the TODO.
+
+## 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 |
+
+### 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:
+```
+
+| 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
+```
+
+- **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** (planned for the encrypted chat
+feature):
+- 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
+ 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
+├── 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.
diff --git a/relay/dist.sh b/relay/dist.sh
index 95ac0f2..a081294 100755
--- a/relay/dist.sh
+++ b/relay/dist.sh
@@ -8,7 +8,7 @@ cd "$(dirname "$0")"
DIST="hyprcrd-relay.zip"
rm -f "$DIST"
-zip -r "$DIST" index.php .htaccess fonts/
+zip -r "$DIST" index.php .htaccess fonts/ inc/ ui/ README.md
echo "Done: ${DIST} ($(du -h "$DIST" | cut -f1))"
echo ""
diff --git a/relay/fonts/Phosphor.woff2 b/relay/fonts/Phosphor.woff2
new file mode 100644
index 0000000000000000000000000000000000000000..76ff203b162893f79d094cde45241a54285d6e31
GIT binary patch
literal 147380
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