Files
app/backend/shared/human_vault.py
T

219 lines
11 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 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"
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():
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):
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)
return [
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"]),
]