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.
This commit is contained in:
@@ -66,3 +66,7 @@ delphi-backend/vault - Copie*.db
|
|||||||
# Delphi build artifacts
|
# Delphi build artifacts
|
||||||
delphi-backend/*.vrc
|
delphi-backend/*.vrc
|
||||||
delphi-backend/*.$manifest
|
delphi-backend/*.$manifest
|
||||||
|
|
||||||
|
# PHP sync endpoint — holds the bearer token / the snapshot itself
|
||||||
|
server-php/config.php
|
||||||
|
vault-data/
|
||||||
|
|||||||
@@ -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 <jeton>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **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: <etag>` — 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).
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Copy to config.php and fill in. config.php is gitignored — it holds a secret.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Bearer token the clients send. Generate one, never reuse a password:
|
||||||
|
// php -r "echo bin2hex(random_bytes(32)), PHP_EOL;"
|
||||||
|
// It authenticates the DEVICE, not the vault: it grants access to the
|
||||||
|
// ciphertext only. The sync password (which decrypts it) never leaves the
|
||||||
|
// clients and is never known to this server.
|
||||||
|
const SYNC_TOKEN = 'PUT-A-64-HEX-TOKEN-HERE';
|
||||||
|
|
||||||
|
// Where the snapshot lives. Keep it OUTSIDE the web root: served through this
|
||||||
|
// script only, never fetchable as a static file.
|
||||||
|
const SYNC_FILE = __DIR__ . '/../vault-data/vault-sync.json';
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Read-only sync endpoint for the vault snapshot.
|
||||||
|
*
|
||||||
|
* The snapshot is an OPAQUE encrypted container (pm-encrypted-export-v1):
|
||||||
|
* PBKDF2-SHA256 600k + AES-GCM, produced and read by the clients. This script
|
||||||
|
* never decrypts anything and never needs to — it stores and serves bytes.
|
||||||
|
* A compromised server yields ciphertext plus the metadata below, nothing more.
|
||||||
|
*
|
||||||
|
* Deliberately read-only for now: GET only. The write path (PUT with If-Match)
|
||||||
|
* comes with the two-way client and is refused explicitly rather than silently
|
||||||
|
* ignored, so a client that tries it fails loudly instead of believing it wrote.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/config.php'; // defines SYNC_TOKEN and SYNC_FILE
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Transport
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The token travels in a header on every request; without TLS it is readable by
|
||||||
|
// anyone on the path, and so is the ciphertext. Refuse rather than leak.
|
||||||
|
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||||
|
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')
|
||||||
|
|| in_array($_SERVER['REMOTE_ADDR'] ?? '', ['127.0.0.1', '::1'], true);
|
||||||
|
if (!$https) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => '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']);
|
||||||
Reference in New Issue
Block a user