147 lines
7.7 KiB
Python
147 lines
7.7 KiB
Python
"""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 sqlite3
|
||
|
|
from urllib.parse import urlsplit
|
||
|
|
from contextlib import closing
|
||
|
|
|
||
|
|
from starlette.responses import JSONResponse, Response
|
||
|
|
from starlette.routing import Route
|
||
|
|
|
||
|
|
EXTENSION_ORIGIN = "chrome-extension://kiolgjoohhgdohcafgelhikpgkbmckbg"
|
||
|
|
PREFIX = "/chat/persimmons/human-vault/items"
|
||
|
|
MAX_BODY = 24 * 1024
|
||
|
|
ITEM_ID = re.compile(r"[0-9a-f]{32}")
|
||
|
|
|
||
|
|
|
||
|
|
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 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"}:
|
||
|
|
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"})
|
||
|
|
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)
|
||
|
|
path = os.environ.get("PERSIMMONS_HUMAN_VAULT_DB")
|
||
|
|
if not path:
|
||
|
|
return reply({"error": "vault not configured"}, 503)
|
||
|
|
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)
|
||
|
|
|
||
|
|
return [Route(PREFIX, endpoint, methods=["GET", "POST", "OPTIONS"]),
|
||
|
|
Route(PREFIX + "/{item_id}", endpoint, methods=["GET", "PUT", "DELETE", "OPTIONS"])]
|