Release cookie-free Persimmons vault link 0.6.0
This commit is contained in:
1 parent
53c4bb50f4
commit
1aa3cffaf2
32 files changed
+1535
-624
No files matched your search
@@ -6,10 +6,14 @@ 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
|
||||
|
||||
@@ -17,6 +21,13 @@ 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):
|
||||
@@ -53,6 +64,29 @@ 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))")
|
||||
@@ -102,18 +136,23 @@ def routes(resolve_actor):
|
||||
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"}:
|
||||
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"})
|
||||
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()
|
||||
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)
|
||||
@@ -134,14 +173,46 @@ def routes(resolve_actor):
|
||||
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"])]
|
||||
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"]),
|
||||
]
|
||||
Reference in new issue
Block a user