vendor human_vault.py (was copied from tea_mcp at build time)
This commit is contained in:
1 parent
c50ff52fcf
commit
99da705b15
2 files changed
+256
-1
No files matched your search
@@ -1,4 +1,3 @@
|
||||
human_vault.py
|
||||
web/index.html
|
||||
web/extension.zip
|
||||
web/version.json
|
||||
|
||||
+256
@@ -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"]),
|
||||
]
|
||||
Reference in new issue
Block a user