diff --git a/.gitignore b/.gitignore index d2132b1..63a845a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -human_vault.py web/index.html web/extension.zip web/version.json diff --git a/human_vault.py b/human_vault.py new file mode 100644 index 0000000..4b3b3b0 --- /dev/null +++ b/human_vault.py @@ -0,0 +1,256 @@ +"""Browser-only opaque storage. Deliberately has no crypto or MCP registration.""" +from __future__ import annotations + +import base64 +import binascii +import json +import os +import re +import secrets +import hashlib +import time +import sqlite3 +from urllib.parse import urlsplit +from contextlib import closing + +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route + +EXTENSION_ORIGIN = "chrome-extension://kiolgjoohhgdohcafgelhikpgkbmckbg" +PREFIX = "/chat/persimmons/human-vault/items" +PAGE_INDEX = "/chat/persimmons/human-vault/page-index" +MAX_BODY = 24 * 1024 +ITEM_ID = re.compile(r"[0-9a-f]{32}") +CAPABILITY = re.compile(r"[A-Za-z0-9_-]{40,128}") +CAPABILITY_TTL = 30 * 24 * 60 * 60 +DEFAULT_DB = "/data/human-vault.sqlite" + + +def storage_path(): + # Tea MCP has one authoritative /data volume. Keep an explicit override + # for tests/recovery, but make the normal deployment work without a secret + # environment toggle. + return os.environ.get("PERSIMMONS_HUMAN_VAULT_DB", DEFAULT_DB) + + +def validate_item(item): + if not isinstance(item, dict) or set(item) not in ({"v", "id", "origin", "iv", "ciphertext"}, {"v", "id", "origin", "username", "iv", "ciphertext"}): + raise ValueError() + if type(item["v"]) is not int or item["v"] != 1: + raise ValueError() + if not isinstance(item["id"], str) or not ITEM_ID.fullmatch(item["id"]): + raise ValueError() + if not isinstance(item["origin"], str): + raise ValueError() + try: + parsed = urlsplit(item["origin"]) + _ = parsed.port + except ValueError: + raise ValueError() + if (parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or + parsed.path or parsed.query or parsed.fragment or parsed.geturl() != item["origin"]): + raise ValueError() + username = item.get("username") + if username is not None and (not isinstance(username, str) or not username or len(username) > 512 or any(ord(c) < 32 for c in username)): + raise ValueError() + for name, low, high in (("iv", 12, 12), ("ciphertext", 17, 16384)): + value = item[name] + if not isinstance(value, str): + raise ValueError() + raw = base64.b64decode(value, validate=True) + if not low <= len(raw) <= high or base64.b64encode(raw).decode() != value: + raise ValueError() + return item + + +class Store: + def __init__(self, path): + self.path = path + + def _cap_table(self, db): + db.execute("CREATE TABLE IF NOT EXISTS human_capabilities (owner TEXT NOT NULL, token_hash TEXT NOT NULL PRIMARY KEY, expires_at INTEGER NOT NULL)") + db.execute("DELETE FROM human_capabilities WHERE expires_at < ?", (int(time.time()),)) + + def mint_capability(self, owner): + token = secrets.token_urlsafe(32) + digest = hashlib.sha256(token.encode()).hexdigest() + expires_at = int(time.time()) + CAPABILITY_TTL + with closing(sqlite3.connect(self.path, timeout=5)) as db, db: + self._cap_table(db) + db.execute("DELETE FROM human_capabilities WHERE owner=?", (owner,)) + db.execute("INSERT INTO human_capabilities (owner,token_hash,expires_at) VALUES (?,?,?)", (owner, digest, expires_at)) + return token, expires_at + + def capability_owner(self, token): + if not isinstance(token, str) or not CAPABILITY.fullmatch(token): + return None + digest = hashlib.sha256(token.encode()).hexdigest() + with closing(sqlite3.connect(self.path, timeout=5)) as db, db: + self._cap_table(db) + row = db.execute("SELECT owner FROM human_capabilities WHERE token_hash=? AND expires_at>=?", (digest, int(time.time()))).fetchone() + return row[0] if row else None + + def operate(self, owner, method, item_id=None, item=None): + with closing(sqlite3.connect(self.path, timeout=5)) as db, db: + db.execute("CREATE TABLE IF NOT EXISTS human_items (owner TEXT NOT NULL, id TEXT NOT NULL, origin TEXT NOT NULL, iv TEXT NOT NULL, ciphertext TEXT NOT NULL, username TEXT, PRIMARY KEY(owner,id))") + columns = {row[1] for row in db.execute("PRAGMA table_info(human_items)")} + if "username" not in columns: + db.execute("ALTER TABLE human_items ADD COLUMN username TEXT") + if method == "POST": + if db.execute("SELECT count(*) FROM human_items WHERE owner=?", (owner,)).fetchone()[0] >= 200: + return {"error": "item limit reached"}, 409 + try: + db.execute("INSERT INTO human_items (owner,id,origin,iv,ciphertext,username) VALUES (?,?,?,?,?,?)", (owner, item["id"], item["origin"], item["iv"], item["ciphertext"], item.get("username"))) + except sqlite3.IntegrityError: + return {"error": "item already exists"}, 409 + elif method == "PUT": + changed = db.execute("UPDATE human_items SET origin=?,iv=?,ciphertext=?,username=COALESCE(?,username) WHERE owner=? AND id=?", (item["origin"], item["iv"], item["ciphertext"], item.get("username"), owner, item_id)) + if not changed.rowcount: + return {"error": "not found"}, 404 + elif method == "DELETE": + changed = db.execute("DELETE FROM human_items WHERE owner=? AND id=?", (owner, item_id)) + if not changed.rowcount: + return {"error": "not found"}, 404 + return {"owner": owner, "ok": True}, 200 + elif item_id is None: + rows = db.execute("SELECT id,origin,username FROM human_items WHERE owner=? ORDER BY origin,username,id LIMIT 200", (owner,)).fetchall() + return {"owner": owner, "items": [{"v": 1, "id": row[0], "origin": row[1], **({"username": row[2]} if row[2] else {})} for row in rows]}, 200 + else: + row = db.execute("SELECT origin,iv,ciphertext,username FROM human_items WHERE owner=? AND id=?", (owner, item_id)).fetchone() + if row is None: + return {"error": "not found"}, 404 + item = dict(v=1, id=item_id, origin=row[0], iv=row[1], ciphertext=row[2], **({"username": row[3]} if row[3] else {})) + return {"owner": owner, "item": item}, 201 if method == "POST" else 200 + + +def routes(resolve_actor): + async def endpoint(request): + headers = {"Cache-Control": "no-store", "Vary": "Origin"} + + def reply(payload, code): + return JSONResponse(payload, status_code=code, headers=headers) + + # Do not trust forwarded headers here; the ASGI server's trusted proxy + # configuration owns scheme handling. No bearer/PAT fallback. + if request.url.scheme != "https" or request.headers.getlist("origin") != [EXTENSION_ORIGIN]: + return reply({"error": "forbidden"}, 403) + headers.update({"Access-Control-Allow-Origin": EXTENSION_ORIGIN, "Access-Control-Allow-Credentials": "true"}) + if request.url.query: + return reply({"error": "query forbidden"}, 400) + if request.method == "OPTIONS": + requested = {h.strip().lower() for h in request.headers.get("access-control-request-headers", "").split(",") if h.strip()} + if request.headers.get("access-control-request-method") not in {"GET", "POST", "PUT", "DELETE"} or not requested <= {"content-type", "x-vault-owner", "x-vault-capability"}: + return reply({"error": "forbidden"}, 403) + return Response(status_code=204, headers={**headers, "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE", "Access-Control-Allow-Headers": "Content-Type, X-Vault-Owner, X-Vault-Capability"}) + path = storage_path() + if not path: + return reply({"error": "vault not configured"}, 503) + owner = Store(path).capability_owner(request.headers.get("x-vault-capability")) + if not owner: + if not request.headers.get("cookie") or request.headers.get("authorization"): + return reply({"error": "unauthorized"}, 401) + try: + actor = await resolve_actor(request) + except Exception: + return reply({"error": "session unavailable"}, 503) + if not actor: + return reply({"error": "unauthorized"}, 401) + owner = actor.lower() + item_id = request.path_params.get("item_id") + if item_id is not None and not ITEM_ID.fullmatch(item_id): + return reply({"error": "not found"}, 404) + if (item_id is not None or request.method != "GET") and request.headers.get("x-vault-owner") != owner: + return reply({"error": "session changed"}, 409) + item = None + if request.method in {"POST", "PUT"}: + if request.headers.get("content-type", "").split(";")[0] != "application/json": + return reply({"error": "JSON required"}, 415) + try: + raw = bytearray() + async for chunk in request.stream(): + raw.extend(chunk) + if len(raw) > MAX_BODY: + raise ValueError() + item = validate_item(json.loads(raw)) + if item_id is not None and item["id"] != item_id: + raise ValueError() + except (ValueError, TypeError, binascii.Error): + return reply({"error": "invalid encrypted item"}, 400) + try: + payload, code = Store(path).operate(owner, request.method, item_id, item) + except (sqlite3.Error, OSError): + return reply({"error": "vault unavailable"}, 503) + return reply(payload, code) + + async def read_endpoint(request): + # Chromium omits Origin on permitted extension GETs. The extension uses + # an empty POST to /read so its genuine Origin survives to this pinned + # caller check; adapt only that empty read back into the accepted GET. + if request.method == "OPTIONS": + return await endpoint(request) + async for chunk in request.stream(): + if chunk: + return JSONResponse({"error": "read body must be empty"}, status_code=400) + read_request = Request({**request.scope, "method": "GET"}, receive=request.receive) + return await endpoint(read_request) + + async def link_endpoint(request): + headers = {"Cache-Control": "no-store"} + if request.url.scheme != "https" or request.headers.get("origin") != "https://persimmons.app": + return JSONResponse({"error": "forbidden"}, status_code=403, headers=headers) + if request.method != "POST" or request.headers.get("authorization") or not request.headers.get("cookie") or request.headers.get("x-requested-with") != "Persimmons": + return JSONResponse({"error": "unauthorized"}, status_code=401, headers=headers) + try: + actor = await resolve_actor(request) + except Exception: + return JSONResponse({"error": "session unavailable"}, status_code=503, headers=headers) + if not actor: + return JSONResponse({"error": "unauthorized"}, status_code=401, headers=headers) + path = storage_path() + if not path: + return JSONResponse({"error": "vault not configured"}, status_code=503, headers=headers) + try: + token, expires_at = Store(path).mint_capability(actor.lower()) + except (sqlite3.Error, OSError): + return JSONResponse({"error": "vault unavailable"}, status_code=503, headers=headers) + return JSONResponse({"owner": actor.lower(), "capability": token, "expires_at": expires_at}, headers=headers) + + async def page_endpoint(request): + headers = {"Cache-Control": "no-store"} + if request.url.scheme != "https" or request.headers.get("origin") != "https://persimmons.app": + return JSONResponse({"error": "forbidden"}, status_code=403, headers=headers) + if request.headers.get("authorization") or not request.headers.get("cookie"): + return JSONResponse({"error": "unauthorized"}, status_code=401, headers=headers) + try: + actor = await resolve_actor(request) + except Exception: + return JSONResponse({"error": "session unavailable"}, status_code=503, headers=headers) + if not actor: + return JSONResponse({"error": "unauthorized"}, status_code=401, headers=headers) + owner = actor.lower() + item_id = request.path_params.get("item_id") + if item_id is not None and not ITEM_ID.fullmatch(item_id): + return JSONResponse({"error": "not found"}, status_code=404, headers=headers) + path = storage_path() + if not path: + return JSONResponse({"error": "vault not configured"}, status_code=503, headers=headers) + if request.method == "DELETE": + if request.headers.get("x-requested-with") != "Persimmons": + return JSONResponse({"error": "forbidden"}, status_code=403, headers=headers) + payload, code = Store(path).operate(owner, "DELETE", item_id) + else: + payload, code = Store(path).operate(owner, "GET") + return JSONResponse(payload, status_code=code, headers=headers) + + return [ + # Page index first, at the path nginx targets (/api/vault-index -> /human-vault/page-index). Under /items it + # was both unreachable and shadowed by /items/{item_id} (#51). + Route(PAGE_INDEX, page_endpoint, methods=["GET"]), + Route(PAGE_INDEX + "/{item_id}", page_endpoint, methods=["DELETE"]), + Route(PREFIX, endpoint, methods=["GET", "POST", "OPTIONS"]), + Route(PREFIX + "/{item_id}", endpoint, methods=["GET", "PUT", "DELETE", "OPTIONS"]), + Route(PREFIX + "/read", read_endpoint, methods=["POST", "OPTIONS"]), + Route(PREFIX + "/{item_id}/read", read_endpoint, methods=["POST", "OPTIONS"]), + Route("/chat/persimmons/human-vault/link", link_endpoint, methods=["POST"]), + ]