159 lines
6.0 KiB
Python
159 lines
6.0 KiB
Python
#!/usr/bin/env python3
|
|
"""QSafe blind single-vault sync server. Standard library only; TLS is provided by Caddy."""
|
|
import argparse
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import secrets
|
|
import sqlite3
|
|
import struct
|
|
import time
|
|
from contextlib import contextmanager
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
MAX_BYTES = 32 * 1024 * 1024
|
|
|
|
|
|
@contextmanager
|
|
def database(path):
|
|
db = sqlite3.connect(path, timeout=20)
|
|
db.execute("PRAGMA journal_mode=WAL")
|
|
db.execute("PRAGMA synchronous=FULL")
|
|
db.execute("CREATE TABLE IF NOT EXISTS vault (id INTEGER PRIMARY KEY CHECK(id=1), etag TEXT NOT NULL, body BLOB NOT NULL, updated INTEGER NOT NULL)")
|
|
db.execute("CREATE TABLE IF NOT EXISTS history (id INTEGER PRIMARY KEY, etag TEXT NOT NULL, body BLOB NOT NULL, updated INTEGER NOT NULL)")
|
|
try:
|
|
with db:
|
|
yield db
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
class Server(ThreadingHTTPServer):
|
|
daemon_threads = True
|
|
def __init__(self, address, db_path, token_hash):
|
|
super().__init__(address, Handler)
|
|
self.db_path, self.token_hash = db_path, token_hash
|
|
with database(db_path) as db:
|
|
if db.execute("PRAGMA quick_check").fetchone()[0] != "ok":
|
|
raise RuntimeError("Database integrity check failed")
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = "QSafe"
|
|
sys_version = ""
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def setup(self):
|
|
super().setup()
|
|
self.connection.settimeout(30)
|
|
|
|
def log_message(self, *_args):
|
|
pass # Do not log credentials, payloads, URLs, or card data.
|
|
|
|
def reply(self, code, body=b"", content_type="application/json", etag=None):
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
self.send_header("Connection", "close")
|
|
self.close_connection = True
|
|
if etag:
|
|
self.send_header("ETag", '"' + etag + '"')
|
|
self.end_headers()
|
|
if self.command != "HEAD":
|
|
self.wfile.write(body)
|
|
|
|
def authenticated(self):
|
|
value = self.headers.get("Authorization", "")
|
|
if not value.startswith("Bearer ") or len(value) > 256:
|
|
self.reply(401, b'{"error":"unauthorized"}')
|
|
return False
|
|
candidate = hashlib.sha256(value[7:].encode()).hexdigest()
|
|
if not hmac.compare_digest(candidate, self.server.token_hash):
|
|
self.reply(401, b'{"error":"unauthorized"}')
|
|
return False
|
|
return True
|
|
|
|
def do_GET(self):
|
|
if self.path == "/health":
|
|
self.reply(200, b'{"service":"qsafe","protocol":1}')
|
|
return
|
|
if self.path != "/v1/vault":
|
|
self.reply(404)
|
|
return
|
|
if not self.authenticated():
|
|
return
|
|
with database(self.server.db_path) as db:
|
|
row = db.execute("SELECT etag,body FROM vault WHERE id=1").fetchone()
|
|
if row is None:
|
|
self.reply(404, b'{"error":"empty"}')
|
|
else:
|
|
self.reply(200, row[1], "application/octet-stream", row[0])
|
|
|
|
def do_PUT(self):
|
|
if self.path != "/v1/vault":
|
|
self.reply(404)
|
|
return
|
|
if not self.authenticated():
|
|
return
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "-1"))
|
|
except ValueError:
|
|
length = -1
|
|
if self.headers.get("Transfer-Encoding") or not 56 <= length <= MAX_BYTES:
|
|
self.reply(413, b'{"error":"size"}')
|
|
return
|
|
body = self.rfile.read(length)
|
|
if len(body) != length or body[:8] != b"QSAFE01!" or not 600_000 <= struct.unpack(">I", body[8:12])[0] <= 2_000_000:
|
|
self.reply(400, b'{"error":"envelope"}')
|
|
return
|
|
etag = hashlib.sha256(body).hexdigest()
|
|
now = int(time.time())
|
|
with database(self.server.db_path) as db:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
previous = db.execute("SELECT etag,body,updated FROM vault WHERE id=1").fetchone()
|
|
match = self.headers.get("If-Match", "")
|
|
if previous is None:
|
|
valid = self.headers.get("If-None-Match") == "*" and not match
|
|
else:
|
|
valid = match == '"' + previous[0] + '"' and not self.headers.get("If-None-Match")
|
|
if not valid:
|
|
db.rollback()
|
|
self.reply(412, b'{"error":"version_conflict"}')
|
|
return
|
|
if previous and previous[0] != etag:
|
|
db.execute("INSERT INTO history(etag,body,updated) VALUES(?,?,?)", previous)
|
|
db.execute("INSERT OR REPLACE INTO vault VALUES(1,?,?,?)", (etag, body, now))
|
|
# Keep the latest 100 encrypted revisions, independent of client clocks.
|
|
db.execute("DELETE FROM history WHERE id NOT IN (SELECT id FROM history ORDER BY id DESC LIMIT 100)")
|
|
rows = db.execute("SELECT id,length(body) FROM history ORDER BY id DESC").fetchall()
|
|
total = 0
|
|
for version, size in rows:
|
|
total += size
|
|
if total > 256 * 1024 * 1024:
|
|
db.execute("DELETE FROM history WHERE id=?", (version,))
|
|
self.reply(200, b'{"ok":true}', etag=etag)
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser()
|
|
p.add_argument("--bind", default="127.0.0.1")
|
|
p.add_argument("--port", type=int, default=18792)
|
|
p.add_argument("--data", required=True)
|
|
p.add_argument("--token-hash-file", required=True)
|
|
args = p.parse_args()
|
|
os.umask(0o077)
|
|
data = pathlib.Path(args.data)
|
|
data.mkdir(parents=True, exist_ok=True)
|
|
token_hash = pathlib.Path(args.token_hash_file).read_text().strip()
|
|
if len(token_hash) != 64 or any(c not in "0123456789abcdef" for c in token_hash):
|
|
raise ValueError("Invalid credential hash")
|
|
Server((args.bind, args.port), str(data / "vault.sqlite3"), token_hash).serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|