90ac75525a
Apache commonly strips Authorization before PHP sees it, so every request looked unauthenticated on a stock XAMPP and answered 404. If-None-Match is now compared on the bare hash: a validator that lost its quotes, or a weak W/ tag, would never match and each request would silently re-download the whole snapshot - expensive on mobile data and invisible in the logs, a 200 looking perfectly normal.
120 lines
4.7 KiB
PHP
120 lines
4.7 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.
|
|
*
|
|
* Three sources on purpose: Apache commonly strips Authorization before PHP
|
|
* sees it (it is only forwarded with CGIPassAuth on, or through a rewrite), so
|
|
* $_SERVER alone answers empty on a stock XAMPP and every request looks
|
|
* unauthenticated. getallheaders() reads what the client actually sent.
|
|
*/
|
|
function client_token(): string {
|
|
$h = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
|
|
if ($h === '' && function_exists('getallheaders')) {
|
|
foreach (getallheaders() as $name => $value) {
|
|
if (strcasecmp($name, 'Authorization') === 0) { $h = $value; break; }
|
|
}
|
|
}
|
|
if (stripos($h, 'Bearer ') === 0) return trim(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.
|
|
// Compared on the bare hash: a validator that loses its quotes or arrives
|
|
// as a weak tag (W/"…") would otherwise never match, and every request
|
|
// would silently download the whole snapshot again — costly on mobile data
|
|
// and invisible in the logs, since a 200 looks perfectly normal.
|
|
$sent = trim($_SERVER['HTTP_IF_NONE_MATCH'] ?? '');
|
|
if ($sent !== '' && trim($sent, 'W/" ') === trim($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']);
|