aboutsummaryrefslogtreecommitdiffstats
path: root/scripts/screenshots/hkpmock.py
blob: d381eac2bc67998934d87ac1d2433dc9881ecd66 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
"""A local stand-in for a key server, for the key server screenshots.

The application is told to reach ``http://keys.example.org`` through an HTTP
proxy; this is that proxy. It answers the HKP requests GpgFrontend makes
(``/pks/lookup`` with ``op=index`` and ``op=get``, ``/pks/add``) from the
demo keys in ``resource/screenshot/keyserver``, and refuses every other host,
so nothing leaves the machine. It listens on the loopback interface only.
"""

from __future__ import annotations

import threading
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, quote, urlsplit

HOST = "keys.example.org"
PROXY_ENV = "GF_SCREENSHOT_KEYSERVER_PROXY"


@dataclass
class DemoKey:
    fingerprint: str
    algo: str
    length: str
    created: str
    expires: str
    uids: list[str] = field(default_factory=list)
    armored: bytes = b""

    def matches(self, search: str) -> bool:
        if search.lower().startswith("0x"):
            handle = search[2:].upper()
            return bool(handle) and self.fingerprint.endswith(handle)
        needle = search.lower()
        return any(needle in uid.lower() for uid in self.uids)


def load_keys(directory: Path) -> list[DemoKey]:
    """The demo keys, from gpg's --with-colons listing and their exports."""
    keys: list[DemoKey] = []
    listing = (directory / "listing.colons").read_text(encoding="utf-8")
    current: DemoKey | None = None
    for line in listing.splitlines():
        parts = line.split(":")
        if parts[0] == "pub":
            current = DemoKey("", parts[3], parts[2], parts[5], parts[6])
            keys.append(current)
        elif parts[0] == "fpr" and current is not None and not current.fingerprint:
            current.fingerprint = parts[9]
        elif parts[0] == "uid" and current is not None:
            current.uids.append(parts[9])
    for key in keys:
        key.armored = (directory / f"{key.fingerprint}.asc").read_bytes()
    return keys


def index_answer(keys: list[DemoKey], search: str) -> bytes:
    """A machine-readable HKP index (draft-shaw-openpgp-hkp, section 5.2)."""
    found = [k for k in keys if k.matches(search)]
    lines = [f"info:1:{len(found)}"]
    for key in found:
        lines.append(f"pub:{key.fingerprint}:{key.algo}:{key.length}:{key.created}:{key.expires}:")
        for uid in key.uids:
            lines.append(f"uid:{quote(uid, safe=' @<>().')}:{key.created}::")
    return ("\n".join(lines) + "\n").encode("utf-8")


class MockKeyServer:
    """The proxy, in a background thread for as long as the renderer runs."""

    def __init__(self, directory: Path) -> None:
        self.keys = load_keys(directory)
        self.requests: list[str] = []
        server = self

        class Handler(BaseHTTPRequestHandler):
            def log_message(self, *args: object) -> None:  # quiet
                pass

            def _target(self) -> tuple[str, str, dict[str, list[str]]]:
                url = urlsplit(self.path)
                host = (url.hostname or self.headers.get("Host", "").split(":")[0]).lower()
                return host, url.path, parse_qs(url.query)

            def _reply(self, code: int, body: bytes, kind: str = "text/plain") -> None:
                self.send_response(code)
                self.send_header("Content-Type", kind)
                self.send_header("Content-Length", str(len(body)))
                self.end_headers()
                self.wfile.write(body)

            def do_GET(self) -> None:
                host, path, query = self._target()
                server.requests.append(f"GET {host}{path}")
                if host != HOST or path != "/pks/lookup":
                    self._reply(403, b"only the demo key server is served here\n")
                    return
                op = query.get("op", [""])[0]
                search = query.get("search", [""])[0]
                if op == "index":
                    self._reply(200, index_answer(server.keys, search))
                    return
                if op == "get":
                    found = [k for k in server.keys if k.matches(search)]
                    if found:
                        self._reply(200, found[0].armored, "application/pgp-keys")
                        return
                self._reply(404, b"no such key\n")

            def do_POST(self) -> None:
                host, path, _ = self._target()
                server.requests.append(f"POST {host}{path}")
                self.rfile.read(int(self.headers.get("Content-Length") or 0))
                if host == HOST and path == "/pks/add":
                    self._reply(200, b"Key added\n")
                    return
                self._reply(403, b"only the demo key server is served here\n")

            def do_CONNECT(self) -> None:
                server.requests.append(f"CONNECT {self.path}")
                self._reply(403, b"no tunnels\n")

        self._httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
        self._httpd.daemon_threads = True
        self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
        self._thread.start()

    @property
    def address(self) -> str:
        host, port = self._httpd.server_address[:2]
        return f"{host}:{port}"

    def close(self) -> None:
        self._httpd.shutdown()
        self._httpd.server_close()