From ec4fa433a97757ec7920fbd2bb022b1224fbb688 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:41:34 +0100 Subject: [PATCH] feat(sync): read-only PHP endpoint serving the encrypted snapshot Alternative to WebDAV, and the base for an Android client. The server never decrypts: it stores and serves opaque bytes, so a compromise yields ciphertext plus size and mtime. GET only for now, with a strong ETag and If-None-Match; PUT answers 405 explicitly so a client fails loudly rather than believing it wrote. A bad token answers 404 like a missing snapshot, so an unauthenticated caller learns nothing. HTTPS is required - the bearer token rides a header on every call. --- .gitignore | 4 ++ server-php/README.md | 45 +++++++++++++++ server-php/config.example.php | 17 ++++++ server-php/sync.php | 102 ++++++++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 server-php/README.md create mode 100644 server-php/config.example.php create mode 100644 server-php/sync.php diff --git a/.gitignore b/.gitignore index 420108d..083990c 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,7 @@ delphi-backend/vault - Copie*.db # Delphi build artifacts delphi-backend/*.vrc delphi-backend/*.$manifest + +# PHP sync endpoint — holds the bearer token / the snapshot itself +server-php/config.php +vault-data/ diff --git a/server-php/README.md b/server-php/README.md new file mode 100644 index 0000000..c8af0b8 --- /dev/null +++ b/server-php/README.md @@ -0,0 +1,45 @@ +# Endpoint de synchronisation PHP (lecture seule) + +Alternative au WebDAV pour distribuer l'instantané chiffré du coffre. Le serveur +**ne déchiffre rien** : il sert des octets opaques. + +## Installation + +1. Copier `config.example.php` en `config.php`, y mettre un jeton : + ```bash + php -r "echo bin2hex(random_bytes(32)), PHP_EOL;" + ``` +2. Créer le dossier de données **hors racine web** et y déposer l'instantané + produit par l'application Windows (`vault-sync.json`). +3. Servir `sync.php` en HTTPS. Sans TLS, l'endpoint refuse la requête : le jeton + circule dans un en-tête à chaque appel. + +## Utilisation + +``` +GET /sync.php +Authorization: Bearer +``` + +- **200** — le conteneur chiffré, avec un `ETag` fort. +- **304** — si le client renvoie `If-None-Match` avec l'ETag qu'il détient. +- **404** — pas encore d'instantané, *ou* jeton invalide. Les deux répondent + pareil : un appelant non authentifié n'apprend pas si quelque chose existe ici. +- **405** — toute autre méthode. L'écriture viendra avec le client bidirectionnel. + +## Ce que le serveur voit + +Le conteneur est chiffré côté client (PBKDF2-SHA256 600 k + AES-GCM) avec le +**mot de passe de sync**, qui n'est jamais transmis. Un serveur compromis livre +donc du chiffré, plus ces métadonnées : la taille du fichier, sa date de +modification, et la fréquence des synchronisations. + +Le jeton authentifie l'**appareil**, pas le coffre. Le voler donne accès au +chiffré, pas au contenu. + +## Étape suivante + +L'écriture demandera `PUT` avec `If-Match: ` — c'est ce qui empêche deux +appareils de s'écraser mutuellement. Le client Windows sait déjà le faire : il +capture l'ETag à la lecture et rejoue le cycle complet sur un **412** +(`runSyncNow`, borné à 3 tentatives). diff --git a/server-php/config.example.php b/server-php/config.example.php new file mode 100644 index 0000000..d67bc8e --- /dev/null +++ b/server-php/config.example.php @@ -0,0 +1,17 @@ + 'HTTPS required']); + exit; +} + +header('Content-Type: application/json'); +// This endpoint is for native clients, never a browser page: no CORS, no cache. +header('Cache-Control: no-store'); +header('X-Content-Type-Options: nosniff'); + +// --------------------------------------------------------------------------- +// Authentication +// --------------------------------------------------------------------------- + +/** Bearer token, compared in constant time so a wrong guess leaks no timing. */ +function client_token(): string { + $h = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''; + if (stripos($h, 'Bearer ') === 0) return substr($h, 7); + return ''; +} + +if (!hash_equals(SYNC_TOKEN, client_token())) { + // 404, not 401: an unauthenticated caller learns nothing about what lives + // here — same reasoning as the desktop server's loopback lockdown. + http_response_code(404); + echo json_encode(['error' => 'Not found']); + exit; +} + +// --------------------------------------------------------------------------- +// Routing +// --------------------------------------------------------------------------- + +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + +if ($method === 'HEAD' || $method === 'GET') { + if (!is_file(SYNC_FILE)) { + // 404 = "no snapshot yet". The desktop client already treats this as + // "first sync, remote is empty" rather than an error. + http_response_code(404); + echo json_encode(['error' => 'No snapshot yet']); + exit; + } + + $blob = file_get_contents(SYNC_FILE); + if ($blob === false) { + http_response_code(500); + echo json_encode(['error' => 'Read failed']); + exit; + } + + // Strong ETag over the bytes. The write path will require If-Match against + // it, which is what stops two devices from clobbering each other. + $etag = '"' . hash('sha256', $blob) . '"'; + header('ETag: ' . $etag); + header('Last-Modified: ' . gmdate('D, d M Y H:i:s', (int)filemtime(SYNC_FILE)) . ' GMT'); + + // Save the round trip when the client already holds this version. + if (trim($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') === $etag) { + http_response_code(304); + exit; + } + + header('Content-Length: ' . strlen($blob)); + if ($method === 'HEAD') exit; + echo $blob; + exit; +} + +// Everything else — including PUT — is not implemented yet. +http_response_code(405); +header('Allow: GET, HEAD'); +echo json_encode(['error' => 'Read-only endpoint']);