Initial commit: QSafe Android 1.1.0 and Windows 1.0.4
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Consistent encrypted SQLite backup, verified and atomically published on a separate filesystem."""
|
||||
import datetime
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import sys
|
||||
from contextlib import closing
|
||||
|
||||
os.umask(0o077)
|
||||
source, target = map(pathlib.Path, sys.argv[1:3])
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
|
||||
temp = target / (stamp + ".tmp")
|
||||
final = target / (stamp + ".sqlite3")
|
||||
try:
|
||||
with closing(sqlite3.connect(f"file:{source}?mode=ro", uri=True)) as src, closing(sqlite3.connect(temp)) as dst:
|
||||
src.backup(dst)
|
||||
dst.execute("PRAGMA journal_mode=DELETE")
|
||||
if dst.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
|
||||
raise RuntimeError("Backup failed integrity check")
|
||||
with temp.open("rb") as f:
|
||||
os.fsync(f.fileno())
|
||||
temp.replace(final)
|
||||
descriptor = os.open(target, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
total = 0
|
||||
for index, old in enumerate(sorted(target.glob("*.sqlite3"), reverse=True)):
|
||||
total += old.stat().st_size
|
||||
if index > 0 and (index >= 30 or total > 512 * 1024 * 1024):
|
||||
old.unlink()
|
||||
finally:
|
||||
temp.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,89 @@
|
||||
import concurrent.futures
|
||||
import hashlib
|
||||
import http.client
|
||||
import pathlib
|
||||
import secrets
|
||||
import sqlite3
|
||||
import struct
|
||||
import tempfile
|
||||
import threading
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from server import Server, database
|
||||
|
||||
|
||||
class SyncTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.path = str(pathlib.Path(self.temp.name) / "test.sqlite3")
|
||||
self.token = secrets.token_urlsafe(32)
|
||||
self.server = Server(("127.0.0.1", 0), self.path, hashlib.sha256(self.token.encode()).hexdigest())
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join()
|
||||
self.temp.cleanup()
|
||||
|
||||
def request(self, method, body=None, token=True, **headers):
|
||||
c = http.client.HTTPConnection(*self.server.server_address, timeout=5)
|
||||
if token:
|
||||
headers["Authorization"] = "Bearer " + self.token
|
||||
c.request(method, "/v1/vault", body=body, headers=headers)
|
||||
r = c.getresponse()
|
||||
result = r.status, dict(r.getheaders()), r.read()
|
||||
c.close()
|
||||
return result
|
||||
|
||||
def envelope(self):
|
||||
return b"QSAFE01!" + struct.pack(">I", 600_000) + secrets.token_bytes(80)
|
||||
|
||||
def test_requires_credential_and_preconditions(self):
|
||||
self.assertEqual(self.request("GET", token=False)[0], 401)
|
||||
self.assertEqual(self.request("GET")[0], 404)
|
||||
self.assertEqual(self.request("PUT", self.envelope())[0], 412)
|
||||
self.assertEqual(self.request("PUT", b"plaintext", **{"If-None-Match": "*"})[0], 413)
|
||||
|
||||
def test_roundtrip_and_versions(self):
|
||||
a, b = self.envelope(), self.envelope()
|
||||
status, headers, _ = self.request("PUT", a, **{"If-None-Match": "*"})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(self.request("GET")[2], a)
|
||||
self.assertEqual(self.request("PUT", b, **{"If-Match": '"stale"'})[0], 412)
|
||||
self.assertEqual(self.request("PUT", b, **{"If-Match": headers["ETag"]})[0], 200)
|
||||
self.assertEqual(self.request("GET")[2], b)
|
||||
with database(self.path) as db:
|
||||
self.assertEqual(db.execute("SELECT body FROM history").fetchone()[0], a)
|
||||
self.assertEqual(db.execute("PRAGMA integrity_check").fetchone()[0], "ok")
|
||||
|
||||
def test_competing_writes_only_one_wins(self):
|
||||
_, headers, _ = self.request("PUT", self.envelope(), **{"If-None-Match": "*"})
|
||||
etag = headers["ETag"]
|
||||
with concurrent.futures.ThreadPoolExecutor(2) as pool:
|
||||
jobs = [pool.submit(self.request, "PUT", self.envelope(), **{"If-Match": etag}) for _ in range(2)]
|
||||
self.assertEqual(sorted(j.result()[0] for j in jobs), [200, 412])
|
||||
|
||||
def test_rejects_invalid_envelope(self):
|
||||
body = b"not-a-qsafe-file" + secrets.token_bytes(80)
|
||||
self.assertEqual(self.request("PUT", body, **{"If-None-Match": "*"})[0], 400)
|
||||
|
||||
def test_backup_is_standalone_and_contains_latest_revision(self):
|
||||
a, b = self.envelope(), self.envelope()
|
||||
_, headers, _ = self.request("PUT", a, **{"If-None-Match": "*"})
|
||||
self.assertEqual(self.request("PUT", b, **{"If-Match": headers["ETag"]})[0], 200)
|
||||
target = pathlib.Path(self.temp.name) / "backups"
|
||||
subprocess.run([sys.executable, str(pathlib.Path(__file__).with_name("backup.py")), self.path, str(target)], check=True)
|
||||
backups = list(target.glob("*.sqlite3"))
|
||||
self.assertEqual(len(backups), 1)
|
||||
self.assertEqual(list(target.glob("*.tmp*")), [])
|
||||
with sqlite3.connect(backups[0]) as db:
|
||||
self.assertEqual(db.execute("SELECT body FROM vault").fetchone()[0], b)
|
||||
self.assertEqual(db.execute("SELECT body FROM history").fetchone()[0], a)
|
||||
self.assertEqual(db.execute("PRAGMA integrity_check").fetchone()[0], "ok")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user