Files
Password-Manager/server-php/sync.php
T
r-zakarya ec4fa433a9 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.
2026-08-14 17:41:34 +01:00

103 lines
3.8 KiB
PHP

<?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']);