security: v2.0 — AES-GCM crypto, SSRF guards, rate limits, hardening

Breaking wire-format change: dual-read (V1+V2) / single-write (V2) so
existing user secrets, stored messages, and inter-tenant key keep
working. Old clients and relays lose cross-version chat until both
sides are upgraded.

Relay
- Switch clientEncrypt/masterEncrypt to AES-256-GCM with HKDF-derived
  per-ciphertext salts; keep CBC-V1 fallback on decrypt only.
- On-bootstrap migration re-envelopes tenant secret_encrypted and the
  inter-tenant key to GCM-V2 (idempotent).
- CORS: stop reflecting arbitrary Origin; allow own origin + optional
  RELAY_CORS_ORIGINS env allowlist; drop Access-Control-Allow-Credentials.
- Generic error messages replace "no such user: <name>" enumeration.
- Exception messages no longer echoed back; logged server-side.
- Collab-lock max hold capped at 10min so a member can't grief-refresh
  forever.
- Attachment filenames validated against hex.<ext> pattern.
- Passwords min length 10; /api/account/password rate-limited.

App / Electron client
- Shared hyprcrdRelayEncrypt/Decrypt helpers (GCM-V2 write, V1 read).
- Chat + collab-buckets plugins delegate to the shared helpers.
- Poll + push + attachments migrated to the dual-format path.
- /api/relay/settings GET returns has_key boolean; secret never echoed.
- Setup endpoint now CSRF-checked.
- SSRF: CURLOPT_PROTOCOLS restricted to HTTP(S) everywhere; bookmarks
  favicon fetch also blocks private/loopback/link-local.
- Login / password-change rate limiter moved to DB-backed IP keying.
- Passwords min length 10 throughout.
- Zip import enforces per-file (25MB) and total (500MB) caps, extension
  allowlist, no-overwrite.
- Verbose relay-push debug gated behind APP_DEBUG env.

Version bumped to 2.0.0 (protocol-breaking).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-17 17:02:04 +02:00
1 parent d50f9fe789
commit d83de30d6d
27 files changed
+473 -168

No files matched your search

+132 -67
View File
@@ -55,6 +55,22 @@ if (str_starts_with($route, '/api/auth')) {
error_reporting(0);
header('Content-Type: application/json');
// IP-keyed rate limiter for auth endpoints — session cookies are trivial
// to rotate, so the in-session counter alone is bypassable.
$hyprcrdClientIp = $_SERVER['REMOTE_ADDR'] ?? '';
$db->exec("DELETE FROM login_attempts WHERE attempted_at < datetime('now', '-10 minutes')");
$hyprcrdIpRateLimited = function (string $endpoint, int $max, int $windowMinutes = 5) use ($db, $hyprcrdClientIp): bool {
if ($hyprcrdClientIp === '') return false;
$q = $db->prepare("SELECT COUNT(*) FROM login_attempts WHERE ip = :ip AND endpoint = :ep AND attempted_at > datetime('now', '-{$windowMinutes} minutes')");
$q->execute(['ip' => $hyprcrdClientIp, 'ep' => $endpoint]);
return (int)$q->fetchColumn() >= $max;
};
$hyprcrdRecordAttempt = function (string $endpoint) use ($db, $hyprcrdClientIp): void {
if ($hyprcrdClientIp === '') return;
$db->prepare('INSERT INTO login_attempts (ip, endpoint) VALUES (:ip, :ep)')
->execute(['ip' => $hyprcrdClientIp, 'ep' => $endpoint]);
};
// GET /api/auth/status
if ($route === '/api/auth/status' && $method === 'GET') {
echo json_encode(['setup' => $isSetup, 'logged_in' => $isLoggedIn, 'csrf' => $_SESSION['csrf_token'], 'version' => APP_VERSION]);
@@ -63,11 +79,20 @@ if (str_starts_with($route, '/api/auth')) {
// POST /api/auth/setup — set initial password
if ($route === '/api/auth/setup' && $method === 'POST' && $isSetup) {
// CSRF: same-origin + same-session guarantee. A drive-by POST from
// another site won't have the csrf cookie-bound token, so it can't
// silently claim a freshly-provisioned instance.
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!hash_equals($_SESSION['csrf_token'] ?? '', $csrfHeader)) {
http_response_code(403);
echo json_encode(['error' => 'invalid csrf token']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
$pw = $input['password'] ?? '';
if (strlen($pw) < 4) {
if (strlen($pw) < 10) {
http_response_code(400);
echo json_encode(['error' => 'password too short (min 4)']);
echo json_encode(['error' => 'password too short (min 10)']);
exit;
}
$hash = password_hash($pw, PASSWORD_BCRYPT);
@@ -79,13 +104,11 @@ if (str_starts_with($route, '/api/auth')) {
exit;
}
// POST /api/auth/login (rate limited)
// POST /api/auth/login (IP-keyed rate limit)
if ($route === '/api/auth/login' && $method === 'POST') {
$attempts = $_SESSION['login_attempts'] ?? 0;
$lastAttempt = $_SESSION['login_last'] ?? 0;
if ($attempts >= 5 && time() - $lastAttempt < 60) {
if ($hyprcrdIpRateLimited('login', 10, 5)) {
http_response_code(429);
echo json_encode(['error' => 'too many attempts, wait 60s']);
echo json_encode(['error' => 'too many attempts, try again later']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
@@ -94,11 +117,9 @@ if (str_starts_with($route, '/api/auth')) {
session_regenerate_id(true);
$_SESSION['hyprcrd_auth'] = true;
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$_SESSION['login_attempts'] = 0;
echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]);
} else {
$_SESSION['login_attempts'] = $attempts + 1;
$_SESSION['login_last'] = time();
$hyprcrdRecordAttempt('login');
usleep(300000); // 300ms delay against timing attacks
http_response_code(401);
echo json_encode(['error' => 'wrong password']);
@@ -159,18 +180,24 @@ if (str_starts_with($route, '/api/auth')) {
// POST /api/auth/password — change password (requires current password)
if ($route === '/api/auth/password' && $method === 'POST') {
if ($hyprcrdIpRateLimited('password', 10, 5)) {
http_response_code(429);
echo json_encode(['error' => 'too many attempts, try again later']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
$current = $input['current'] ?? '';
$newPass = $input['new'] ?? '';
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
if (!password_verify($current, $hash)) {
$hyprcrdRecordAttempt('password');
http_response_code(403);
echo json_encode(['error' => 'wrong password']);
exit;
}
if (strlen($newPass) < 4) {
if (strlen($newPass) < 10) {
http_response_code(400);
echo json_encode(['error' => 'min 4 characters']);
echo json_encode(['error' => 'min 10 characters']);
exit;
}
$newHash = password_hash($newPass, PASSWORD_BCRYPT);
@@ -752,21 +779,37 @@ if (str_starts_with($route, '/api/')) {
}
}
// Extract uploads
// Extract uploads. Guard against zip bombs and malicious payloads:
// per-file 25MB cap, 500MB total, whitelist allowed extensions (same
// set as the live /api/upload endpoint), never overwrite existing
// files, never leak paths via traversal.
$uploadDir = UPLOAD_DIR;
if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true);
$allowedExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'mp3', 'ogg', 'wav', 'm4a', 'pdf'];
$perFileMax = 25 * 1024 * 1024; // 25 MB
$totalMax = 500 * 1024 * 1024; // 500 MB
$totalWritten = 0;
$skipped = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (str_starts_with($name, 'uploads/') && $name !== 'uploads/') {
$content = $zip->getFromIndex($i);
if ($content !== false) {
file_put_contents($uploadDir . '/' . basename($name), $content);
}
}
if (!str_starts_with($name, 'uploads/') || $name === 'uploads/') continue;
$base = basename($name);
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
if (!in_array($ext, $allowedExts, true)) { $skipped[] = $base; continue; }
$stat = $zip->statIndex($i);
if ($stat && isset($stat['size']) && $stat['size'] > $perFileMax) { $skipped[] = $base; continue; }
if ($totalWritten + (int)($stat['size'] ?? 0) > $totalMax) { $skipped[] = $base; continue; }
$dest = $uploadDir . '/' . $base;
if (file_exists($dest)) { $skipped[] = $base; continue; }
$content = $zip->getFromIndex($i);
if ($content === false) continue;
if (strlen($content) > $perFileMax) { $skipped[] = $base; continue; }
file_put_contents($dest, $content);
$totalWritten += strlen($content);
}
$zip->close();
echo json_encode(['ok' => true, 'buckets' => count($data['buckets']), 'cards' => count($data['cards'])]);
echo json_encode(['ok' => true, 'buckets' => count($data['buckets']), 'cards' => count($data['cards']), 'skipped_files' => count($skipped)]);
exit;
}
@@ -793,18 +836,27 @@ if (str_starts_with($route, '/api/')) {
$url = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '';
$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;
// Never echo the plaintext secret to the client — if it leaks via XSS
// or a compromised session, the attacker can decrypt all relay traffic.
// The UI just needs to know whether a secret is set.
echo json_encode(['url' => $url, 'username' => $username, 'has_key' => !empty($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 (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']]);
// The UI no longer receives the stored secret, so empty strings are
// just "field untouched" and we only write when the user types a new
// value. An explicit null clears the secret for users who need it.
if (array_key_exists('key', $input)) {
$newKey = $input['key'];
if ($newKey === null) {
$db->prepare("DELETE FROM settings WHERE key = 'relay_key'")->execute();
} elseif (is_string($newKey) && $newKey !== '') {
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $newKey]);
}
}
echo json_encode(['ok' => true]); exit;
}
@@ -831,6 +883,7 @@ if (str_starts_with($route, '/api/')) {
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
]);
hyprcrdApplySafeCurlOpts($ch);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
@@ -851,13 +904,15 @@ if (str_starts_with($route, '/api/')) {
if (!is_array($messages)) { echo json_encode(['ok' => false, 'error' => 'invalid response']); exit; }
$pulled = 0;
$encKey = substr(hash('sha256', $relayKey), 0, 32);
$decryptField = function($text) use ($encKey) {
if (!str_starts_with($text, 'HYPRCRD-RELAY-V1')) return $text;
$raw = base64_decode(trim(explode("\n", $text, 2)[1] ?? ''));
if (strlen($raw) < 17) return $text;
$plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
return $plain !== false ? $plain : $text;
$decryptField = function($text) use ($relayKey) {
$decoded = hyprcrdRelayDecrypt($text, $relayKey);
// Dual-format decrypt returns '' only when a magic-prefixed
// envelope failed to decrypt — surface the original string in
// that case so the UI still shows something useful.
if ($decoded === '' && (str_starts_with($text, HYPRCRD_RELAY_GCM_MAGIC) || str_starts_with($text, HYPRCRD_RELAY_CBC_MAGIC))) {
return $text;
}
return $decoded;
};
// Load IDs we pushed ourselves
@@ -920,6 +975,7 @@ if (str_starts_with($route, '/api/')) {
],
CURLOPT_TIMEOUT => 5,
]);
hyprcrdApplySafeCurlOpts($bch);
$bRes = curl_exec($bch);
$bStat = curl_getinfo($bch, CURLINFO_HTTP_CODE);
curl_close($bch);
@@ -936,6 +992,7 @@ if (str_starts_with($route, '/api/')) {
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 30,
]);
hyprcrdApplySafeCurlOpts($ach);
$attResponse = curl_exec($ach);
$attStatus = curl_getinfo($ach, CURLINFO_HTTP_CODE);
curl_close($ach);
@@ -949,23 +1006,28 @@ if (str_starts_with($route, '/api/')) {
if (!preg_match('/^[a-f0-9]+\.\w+$/', $fn)) continue;
$dest = $uploadDir . '/' . $fn;
if (file_exists($dest)) continue;
// New format: HYPRCRD-RELAY-V1 envelope (decrypt-reencrypted by relay)
// Old format: raw base64(iv || cipher)
// Dual-format: GCM-V2 envelope (v1.10+) and legacy
// CBC-V1 envelope; plaintext inside is base64(file
// bytes). Very-old pre-envelope format was raw
// base64(iv || cipher) — unlikely at this point but
// cheap to keep as a last-resort fallback.
$payload = $att['data'] ?? '';
if (str_starts_with($payload, 'HYPRCRD-RELAY-V1')) {
$b64 = trim(substr($payload, strlen('HYPRCRD-RELAY-V1') + 1));
$raw = base64_decode($b64);
$decrypted = false;
if (str_starts_with($payload, HYPRCRD_RELAY_GCM_MAGIC) || str_starts_with($payload, HYPRCRD_RELAY_CBC_MAGIC)) {
$plain = hyprcrdRelayDecrypt($payload, $relayKey);
if ($plain !== '') $decrypted = $plain;
} else {
$legacyKey = substr(hash('sha256', $relayKey), 0, 32);
$raw = base64_decode($payload);
}
if (strlen($raw) >= 17) {
$decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
if ($decrypted !== false) {
$fileBytes = base64_decode($decrypted);
if ($fileBytes !== false) $decrypted = $fileBytes;
file_put_contents($dest, $decrypted);
if ($raw !== false && strlen($raw) >= 17) {
$decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $legacyKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
}
}
if ($decrypted !== false) {
$fileBytes = base64_decode($decrypted);
if ($fileBytes !== false) $decrypted = $fileBytes;
file_put_contents($dest, $decrypted);
}
}
}
}
@@ -982,6 +1044,7 @@ if (str_starts_with($route, '/api/')) {
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 5,
]);
hyprcrdApplySafeCurlOpts($sch);
$sResp = curl_exec($sch);
$sStatus = curl_getinfo($sch, CURLINFO_HTTP_CODE);
curl_close($sch);
@@ -1015,6 +1078,7 @@ if (str_starts_with($route, '/api/')) {
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
]);
hyprcrdApplySafeCurlOpts($ch);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
@@ -1054,12 +1118,7 @@ if (str_starts_with($route, '/api/')) {
$db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$relayUid, $cardId]);
}
$encKey = substr(hash('sha256', $relayKey), 0, 32);
$encryptField = function($text) use ($encKey) {
$iv = random_bytes(16);
$cipher = openssl_encrypt($text, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
};
$encryptField = fn($text) => hyprcrdRelayEncrypt((string)$text, $relayKey);
$payloadData = [
'title' => $encryptField($card['title'] ?? ''),
@@ -1072,21 +1131,27 @@ if (str_starts_with($route, '/api/')) {
$payload = json_encode($payloadData);
$ch = curl_init(rtrim($relayUrl, '/') . '/api/messages');
$debugStream = fopen('php://temp', 'w+');
$debugStream = APP_DEBUG ? fopen('php://temp', 'w+') : null;
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
CURLOPT_VERBOSE => true,
CURLOPT_STDERR => $debugStream,
]);
hyprcrdApplySafeCurlOpts($ch);
if ($debugStream) {
curl_setopt($ch, CURLOPT_VERBOSE, true);
curl_setopt($ch, CURLOPT_STDERR, $debugStream);
}
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
rewind($debugStream);
$curlDebug = stream_get_contents($debugStream);
fclose($debugStream);
$curlDebug = '';
if ($debugStream) {
rewind($debugStream);
$curlDebug = stream_get_contents($debugStream);
fclose($debugStream);
}
curl_close($ch);
if ($status === 200 || $status === 201) {
@@ -1121,6 +1186,7 @@ if (str_starts_with($route, '/api/')) {
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 30,
]);
hyprcrdApplySafeCurlOpts($ach);
curl_exec($ach);
curl_close($ach);
}
@@ -1135,16 +1201,15 @@ if (str_starts_with($route, '/api/')) {
} 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<redacted>', $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,
]);
// In production, don't leak upstream URL / raw bodies / curl traces —
// APP_DEBUG=1 re-enables them for local troubleshooting.
$out = ['ok' => false, 'error' => "relay returned HTTP $status$relayMsg", 'sent_username' => $relayUser];
if (APP_DEBUG) {
$out['relay_url'] = rtrim($relayUrl, '/') . '/api/messages';
$out['response_body'] = is_string($response) ? substr($response, 0, 500) : null;
$out['curl_debug'] = preg_replace('/(X-Relay-Key:\s*)[^\r\n]+/i', '$1<redacted>', $curlDebug);
}
echo json_encode($out);
exit;
}
+4 -4
View File
@@ -194,11 +194,11 @@ async function checkAuth() {
if (data.setup) {
let pw = null;
while (!pw || pw.length < 4) {
pw = await showDialog({ title: 'setup', message: 'Welcome to hyprcrd. Set a password:', input: 'password', placeholder: '> password (min. 4 chars)...', okLabel: 'Set', cancelLabel: '' });
if (pw && pw.length < 4) pw = null;
while (!pw || pw.length < 10) {
pw = await showDialog({ title: 'setup', message: 'Welcome to hyprcrd. Set a password:', input: 'password', placeholder: '> password (min. 10 chars)...', okLabel: 'Set', cancelLabel: '' });
if (pw && pw.length < 10) pw = null;
}
const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': data.csrf || '' }, body: JSON.stringify({ password: pw }) });
const d = await r.json();
if (d.error) { location.reload(); return; }
if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); }
+5 -2
View File
@@ -321,8 +321,11 @@ export async function loadRelaySettings() {
if (urlInput) urlInput.value = s.url || '';
if (userInput) userInput.value = s.username || '';
if (keyInput) {
keyInput.value = s.key || '';
keyInput.placeholder = 'shared secret';
// The server no longer echoes the stored secret. Leave the field
// blank; placeholder tells the user whether one is set. An empty
// save is treated as "field untouched" server-side.
keyInput.value = '';
keyInput.placeholder = s.has_key ? '•••••••• (saved — type to change)' : 'shared secret';
}
// Sync-interval slider lives in the Relay panel but drives every
// poller. Snap points span 5s..1h; live label updates on drag,
+1 -1
View File
@@ -411,7 +411,7 @@ $('settings-pw-save')?.addEventListener('click', async () => {
if (!current || !newPw) { if (status) { status.textContent = 'fill all fields'; status.style.color = 'var(--t-danger)'; } return; }
if (newPw !== confirm) { if (status) { status.textContent = 'passwords do not match'; status.style.color = 'var(--t-danger)'; } return; }
if (newPw.length < 4) { if (status) { status.textContent = 'min 4 characters'; status.style.color = 'var(--t-danger)'; } return; }
if (newPw.length < 10) { if (status) { status.textContent = 'min 10 characters'; status.style.color = 'var(--t-danger)'; } return; }
const res = await fetch('api/auth/password', {
method: 'POST',
+21 -6
View File
@@ -20,6 +20,17 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
echo json_encode(['error' => 'valid url required']);
exit;
}
$scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME));
if ($scheme !== 'http' && $scheme !== 'https') {
http_response_code(400);
echo json_encode(['error' => 'only http/https URLs allowed']);
exit;
}
if (!hyprcrdUrlHostIsPublic($url)) {
http_response_code(400);
echo json_encode(['error' => 'URL host is not publicly reachable']);
exit;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
@@ -30,6 +41,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
CURLOPT_SSL_VERIFYPEER => true,
]);
hyprcrdApplySafeCurlOpts($ch);
$html = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$finalUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url;
@@ -63,18 +75,21 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
}
$faviconData = '';
$ch2 = curl_init($faviconUrl);
curl_setopt_array($ch2, [
$faviconScheme = strtolower((string)parse_url($faviconUrl, PHP_URL_SCHEME));
$favCh = ($faviconScheme === 'http' || $faviconScheme === 'https') && hyprcrdUrlHostIsPublic($faviconUrl);
$ch2 = $favCh ? curl_init($faviconUrl) : null;
if ($ch2) curl_setopt_array($ch2, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_TIMEOUT => 5,
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
]);
$favBytes = curl_exec($ch2);
$favStatus = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
$favMime = curl_getinfo($ch2, CURLINFO_CONTENT_TYPE) ?: 'image/x-icon';
curl_close($ch2);
if ($ch2) hyprcrdApplySafeCurlOpts($ch2);
$favBytes = $ch2 ? curl_exec($ch2) : false;
$favStatus = $ch2 ? curl_getinfo($ch2, CURLINFO_HTTP_CODE) : 0;
$favMime = $ch2 ? (curl_getinfo($ch2, CURLINFO_CONTENT_TYPE) ?: 'image/x-icon') : 'image/x-icon';
if ($ch2) curl_close($ch2);
if ($favBytes && $favStatus >= 200 && $favStatus < 400 && strlen($favBytes) <= 32768) {
$mime = preg_replace('/;.*$/', '', $favMime);
+10 -14
View File
@@ -108,24 +108,20 @@ function chatRelaySettings(): array {
];
}
/** Same envelope format as the existing relay sync (HYPRCRD-RELAY-V1). */
/** Dual-format envelope shared with the relay sync path. Writes V2
* (AES-GCM + HKDF), reads V2 + legacy V1 (CBC). Delegates to the central
* helpers so the format stays in lock-step with src/bootstrap.php and
* the relay's crypto.php. */
function chatEncrypt(string $plain, string $relayKey): string {
$encKey = substr(hash('sha256', $relayKey), 0, 32);
$iv = random_bytes(16);
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
return hyprcrdRelayEncrypt($plain, $relayKey);
}
function chatDecrypt(string $envelope, string $relayKey): string {
if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return '';
$b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1));
$raw = base64_decode($b64);
if (!$raw || strlen($raw) < 17) return '';
$encKey = substr(hash('sha256', $relayKey), 0, 32);
$iv = substr($raw, 0, 16);
$cipher = substr($raw, 16);
$result = openssl_decrypt($cipher, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
return $result !== false ? $result : '';
// Old behaviour returned '' for non-envelope strings; preserve that.
if (!str_starts_with($envelope, HYPRCRD_RELAY_GCM_MAGIC) && !str_starts_with($envelope, HYPRCRD_RELAY_CBC_MAGIC)) {
return '';
}
return hyprcrdRelayDecrypt($envelope, $relayKey);
}
function chatGetState(string $key, $default = null) {
+8 -16
View File
@@ -110,32 +110,24 @@
");
})();
/** Encrypt a JSON payload with the local relay_key using the same envelope
* format the rest of hyprcrd uses (HYPRCRD-RELAY-V1). */
/** Encrypt a JSON payload with the local relay_key using the shared
* dual-format envelope (GCM-V2 writes, V1 read-only fallback lives in
* hyprcrdRelayDecrypt). */
function collabEncryptPayload(array $fields): ?string {
$key = collabRelaySettings()['key'] ?? '';
if ($key === '') return null;
$encKey = substr(hash('sha256', $key), 0, 32);
$plain = json_encode($fields);
$iv = random_bytes(16);
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
if ($cipher === false) return null;
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
if ($plain === false) return null;
return hyprcrdRelayEncrypt($plain, $key);
}
/** Decrypt the inverse. Returns an associative array, or null on failure. */
function collabDecryptPayload(string $envelope): ?array {
if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return null;
$b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1));
$raw = base64_decode($b64);
if (!$raw || strlen($raw) < 17) return null;
if (!str_starts_with($envelope, HYPRCRD_RELAY_GCM_MAGIC) && !str_starts_with($envelope, HYPRCRD_RELAY_CBC_MAGIC)) return null;
$key = collabRelaySettings()['key'] ?? '';
if ($key === '') return null;
$encKey = substr(hash('sha256', $key), 0, 32);
$iv = substr($raw, 0, 16);
$cipher = substr($raw, 16);
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
if ($plain === false) return null;
$plain = hyprcrdRelayDecrypt($envelope, $key);
if ($plain === '') return null;
$data = json_decode($plain, true);
return is_array($data) ? $data : null;
}
+6 -4
View File
@@ -3,8 +3,10 @@ import { describe, it, expect, csrf } from './runner.js?v=4';
describe('Relay: Push API', () => {
it('push without config returns error', async () => {
const settings = await fetch('api/relay/settings').then(r => r.json());
// The GET endpoint echoes the secret directly as `key` (single-user local app).
if (settings.url && settings.key) return; // skip if configured
// GET only reports `has_key` (boolean) — the secret itself is never
// echoed. If a relay is configured the real push may succeed or fail
// for other reasons, so skip in that case.
if (settings.url && settings.has_key) return; // skip if configured
const res = await fetch('api/relay/push', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
@@ -16,7 +18,7 @@ describe('Relay: Push API', () => {
it('push without card_id returns error', async () => {
const settings = await fetch('api/relay/settings').then(r => r.json());
if (!settings.url || !settings.key) return; // card_id check is after config check
if (!settings.url || !settings.has_key) return; // card_id check is after config check
const res = await fetch('api/relay/push', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({}),
@@ -28,7 +30,7 @@ describe('Relay: Push API', () => {
describe('Relay: Poll API', () => {
it('poll without config returns 0 pulled', async () => {
const settings = await fetch('api/relay/settings').then(r => r.json());
if (settings.url && settings.key) return;
if (settings.url && settings.has_key) return;
const res = await fetch('api/relay/poll', {
method: 'POST', headers: { 'X-CSRF-Token': csrf() },