add ability to run web server for signature verification
This commit is contained in:
parent
3fbe5f1555
commit
192313ac71
282
aputils/__main__.py
Normal file
282
aputils/__main__.py
Normal file
|
@ -0,0 +1,282 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import aputils
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
|
||||||
|
from functools import cached_property, lru_cache
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
class ClientError(Exception):
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class Response(aputils.JsonBase):
|
||||||
|
def __init__(self,
|
||||||
|
status: int,
|
||||||
|
message: str,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
address: str,
|
||||||
|
valid: bool,
|
||||||
|
headers: dict[str, Any]) -> None:
|
||||||
|
|
||||||
|
aputils.JsonBase.__init__(self,
|
||||||
|
status = status, # type: ignore
|
||||||
|
message = message, # type: ignore
|
||||||
|
method = method, # type: ignore
|
||||||
|
path = path.split("?", 1)[0], # type: ignore
|
||||||
|
address = address, # type: ignore
|
||||||
|
valid = valid, # type: ignore
|
||||||
|
headers = headers # type: ignore
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestHandler(BaseHTTPRequestHandler):
|
||||||
|
default_request_version = "HTTP/1.1"
|
||||||
|
signature: aputils.Signature
|
||||||
|
actor: aputils.Message
|
||||||
|
signer: aputils.Signer
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content_length(self) -> int:
|
||||||
|
try:
|
||||||
|
return int(self.headers.get("Content-Length", 0))
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
raise ClientError(f"Actor is larger than {args.size_limit} bytes") from None
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def method(self) -> str:
|
||||||
|
return self.command.upper()
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remote(self) -> str:
|
||||||
|
return self.headers.get("X-Real-Ip", self.headers.get("X-Forwarded-For", self.address_string()))
|
||||||
|
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def parsed_headers(self) -> dict[str, str]:
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
for key, value in self.headers.items():
|
||||||
|
key = key.title()
|
||||||
|
|
||||||
|
if key.startswith(("X-Forwarded", "X-Real")):
|
||||||
|
continue
|
||||||
|
|
||||||
|
headers[key] = value
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def body(self) -> bytes:
|
||||||
|
return self.rfile.read(self.content_length or -1)
|
||||||
|
|
||||||
|
|
||||||
|
def log_request(self, status: int, length: int = 0) -> None: # type: ignore
|
||||||
|
date = self.date_time_string()
|
||||||
|
path = self.path.split("?", 1)[0]
|
||||||
|
agent = self.headers.get("User-Agent", "n/a")
|
||||||
|
|
||||||
|
message = f"[{date}] {self.remote} \"{self.method} {path}\" {status} {length} {agent}"
|
||||||
|
print(message, flush = True)
|
||||||
|
|
||||||
|
|
||||||
|
def send(self,
|
||||||
|
status: int,
|
||||||
|
message: aputils.JsonBase,
|
||||||
|
headers: dict[str, str] | None = None) -> None:
|
||||||
|
|
||||||
|
data = message.to_json().encode("utf-8")
|
||||||
|
|
||||||
|
if headers is None:
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
if "Content-Type" not in headers:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
self.log_request(status, len(data))
|
||||||
|
|
||||||
|
self.send_response_only(status, None)
|
||||||
|
self.send_header("Server", self.version_string())
|
||||||
|
self.send_header("Date", self.date_time_string())
|
||||||
|
|
||||||
|
for key, value in headers.items():
|
||||||
|
self.send_header(key, value)
|
||||||
|
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
self.wfile.write(data)
|
||||||
|
self.wfile.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def send_error(self, status: int, message: str) -> None: # type: ignore
|
||||||
|
response = Response(
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
self.method,
|
||||||
|
self.path,
|
||||||
|
self.remote,
|
||||||
|
False,
|
||||||
|
self.parsed_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
self.send(status, response, {"Content-Type": "application/json"})
|
||||||
|
|
||||||
|
|
||||||
|
def handle_actor(self) -> None:
|
||||||
|
bio = "Verifies any incoming signatures. Just send any kind of HTTP request to any path."
|
||||||
|
actor = aputils.JsonBase({
|
||||||
|
"@context": [
|
||||||
|
"https://www.w3.org/ns/activitystreams"
|
||||||
|
],
|
||||||
|
"type": "Application",
|
||||||
|
"id": f"{URL}/actor",
|
||||||
|
"preferredUsername": "relay",
|
||||||
|
"name": "Signature Verifier",
|
||||||
|
"summary": bio,
|
||||||
|
"manuallyApprovesFollowers": True,
|
||||||
|
"inbox": f"{URL}/inbox",
|
||||||
|
"url": f"{URL}/",
|
||||||
|
"endpoints": {
|
||||||
|
"sharedInbox": f"{URL}/inbox"
|
||||||
|
},
|
||||||
|
"publicKey": {
|
||||||
|
"id": keyid,
|
||||||
|
"owner": f"{URL}/actor",
|
||||||
|
"publicKeyPem": signer.pubkey
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
self.send(200, actor, {"Content-Type": "application/activity+json"})
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize = 1024, typed = True)
|
||||||
|
def fetch_actor(self, url: str) -> aputils.Message:
|
||||||
|
try:
|
||||||
|
url, _ = url.split("#", 1)
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
request = signer.sign_request(Request(
|
||||||
|
url,
|
||||||
|
method = "GET", headers = {
|
||||||
|
"User-Agent": f"ApUtils Signature Verifier ({URL})"
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urlopen(request) as response:
|
||||||
|
if (length := int(response.headers.get("Content-Length", 0))) <= 0:
|
||||||
|
raise ClientError("Actor body length is 0")
|
||||||
|
|
||||||
|
if length > args.size_limit:
|
||||||
|
raise ClientError(f"Actor is larger than {args.size_limit} bytes")
|
||||||
|
|
||||||
|
return aputils.Message.parse(response.read())
|
||||||
|
|
||||||
|
except HTTPError as error:
|
||||||
|
msg = f"Failed to fetch actor: Status={error.code} Message='{str(error.read())}'"
|
||||||
|
raise ClientError(msg) from None
|
||||||
|
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
raise ClientError(f"Failed to parse actor: {str(error)}") from None
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
raise ClientError("Content-Length header is not an integer") from None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_request(self) -> bool:
|
||||||
|
if not BaseHTTPRequestHandler.parse_request(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
path = self.path.split("?", 1)[0]
|
||||||
|
|
||||||
|
if self.method == "GET" and path == "/actor":
|
||||||
|
self.handle_actor()
|
||||||
|
|
||||||
|
if self.content_length > args.size_limit:
|
||||||
|
self.send_error(400, f"Incoming message is larger than {args.size_limit} bytes")
|
||||||
|
|
||||||
|
if self.method == "GET" and self.content_length:
|
||||||
|
self.send_error(400, "GET messages should not have a body")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.method in {"POST", "PUT"} and self.content_length <= 0:
|
||||||
|
self.send_error(400, f"{self.method} messages should have a body")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.signature = aputils.Signature.new_from_headers(self.parsed_headers)
|
||||||
|
|
||||||
|
except KeyError:
|
||||||
|
self.send_error(400, "Missing signature header")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.actor = self.fetch_actor(self.signature.keyid)
|
||||||
|
|
||||||
|
except ClientError as error:
|
||||||
|
self.send_error(400, str(error))
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.signer = aputils.Signer.new_from_actor(self.actor)
|
||||||
|
|
||||||
|
if self.command.upper() == "GET" and self.content_length:
|
||||||
|
self.send_error(400, "'GET' messages should not have a body")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.signer.validate_signature(self.method, path, self.parsed_headers)
|
||||||
|
|
||||||
|
except aputils.SignatureFailureError as error:
|
||||||
|
self.send_error(401, str(error))
|
||||||
|
return False
|
||||||
|
|
||||||
|
response = Response(
|
||||||
|
200,
|
||||||
|
"HTTP signature is valid :3",
|
||||||
|
self.method,
|
||||||
|
self.path,
|
||||||
|
self.remote,
|
||||||
|
True,
|
||||||
|
self.parsed_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
self.send(200, response)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog = "aputils",
|
||||||
|
description = "Starts a server for validating HTTP signatures"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument("hostname")
|
||||||
|
parser.add_argument("--addr", "-a", default = "0.0.0.0")
|
||||||
|
parser.add_argument("--port", "-p", default = 8080, type = int)
|
||||||
|
parser.add_argument("--size-limit", "-s", default = 1024 * 1024, type = int)
|
||||||
|
parser.add_argument("--protocol", "-r", default = "https", choices = ["https", "http"])
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
URL = f"{args.protocol}://{args.hostname}"
|
||||||
|
keyid = URL + "/actor#main-key"
|
||||||
|
|
||||||
|
if not (key := Path("privkey.pem")).exists():
|
||||||
|
signer = aputils.Signer.new(keyid)
|
||||||
|
signer.export(key)
|
||||||
|
|
||||||
|
else:
|
||||||
|
signer = aputils.Signer(key, keyid)
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer((args.addr, args.port), RequestHandler)
|
||||||
|
server.serve_forever()
|
Loading…
Reference in a new issue