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/')) {