mail-address-semantics/mas_modes/contract.py
tegwick bdf200a600 MAS-WP-0011 finished: peer authentication in mode contract v0.1
CONTRACT.md section 7 (founder-reviewed): pairwise HMAC-SHA256 over
TIMESTAMP.BODY in MAS-Modes-* headers, 300 s window; master commands only
from the configured master; statuses only at the handoff's tokenized URL
from its peer; primed webhooks must name a known peer. mas_modes.signing,
signed contract.post, authenticating json_server, EmailMode/stubs/gateway
signing. conformance/auth.json vectors + 7 authorization flows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5787@bnt-lap001
Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
2026-10-19 11:25:24 +02:00

146 lines
5.4 KiB
Python

"""Contract building blocks shared by the email side and the stubs."""
from __future__ import annotations
import ipaddress
import json
import threading
import urllib.error
import urllib.request
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Callable, Mapping, Optional
from . import CONTRACT_VERSION, transitions
from .signing import AuthError, sign, verify
_T = transitions()
TERMINAL = frozenset(_T["terminal"])
def allowed(old: Optional[str], new: str) -> bool:
"""CONTRACT.md section 4. `old` None means a new handoff."""
return new in _T["transitions"].get(old or "(new)", [])
def message(type_: str, case: Optional[dict] = None, **fields) -> dict:
msg = {"mas_modes_version": CONTRACT_VERSION, "type": type_, "id": uuid.uuid4().hex}
if case is not None:
msg["case"] = case
msg.update({k: v for k, v in fields.items() if v is not None})
return msg
Signer = tuple[str, bytes] # (own peer id, key shared with the receiver)
def post(url: str, msg: dict, signer: Optional[Signer] = None,
timeout: float = 10.0) -> tuple[Optional[int], object]:
"""POST a contract message, signed when `signer` is given (CONTRACT.md 7.2).
Returns (None, error) when the peer is unreachable."""
body = json.dumps(msg).encode()
headers = {"Content-Type": "application/json"}
if signer is not None:
headers.update(sign(body, *signer))
req = urllib.request.Request(url, data=body, method="POST", headers=headers)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, json.loads(r.read().decode() or "null")
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read().decode() or "null")
except (UnicodeDecodeError, json.JSONDecodeError):
return e.code, None
except (OSError, urllib.error.URLError) as e:
return None, str(e)
def refuse(error: str, detail: str = "", status: int = 409, **extra) -> tuple[int, dict]:
return status, {"ok": False, "error": error, "detail": detail, **extra}
class Idempotent:
"""Remember responses by message id (CONTRACT.md section 2)."""
def __init__(self):
self.seen: dict[str, tuple[int, dict]] = {}
self.lock = threading.Lock()
def __call__(self, msg: dict, handler: Callable[[dict], tuple[int, dict]]) -> tuple[int, dict]:
mid = msg.get("id")
with self.lock:
if mid in self.seen:
return self.seen[mid]
out = handler(msg)
if mid and out[0] < 500:
with self.lock:
self.seen[mid] = out
return out
Route = Callable[[dict, Optional[str], str], tuple[int, dict]]
def _route(routes, path):
if path in routes:
return routes[path], path, ""
for key in sorted(routes, key=len, reverse=True):
if key.endswith("/") and path.startswith(key) and len(path) > len(key):
return routes[key], key, path[len(key):]
return None, None, ""
def _loopback(host: str) -> bool:
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def json_server(routes: dict[str, Route], keys: Optional[Mapping[str, bytes]] = None,
unsigned: frozenset = frozenset(), host="127.0.0.1", port=0) -> ThreadingHTTPServer:
"""JSON over HTTP: POST path -> handler(body, peer, rest) -> (status, body).
With `keys`, every request is authenticated before its body is parsed
(CONTRACT.md 7.2); `peer` is the verified peer id. Routes in `unsigned`
also accept requests without signature headers, from loopback only, with
peer None. A key ending in "/" matches by prefix and passes the rest of
the path (a per-handoff token) as `rest`."""
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
fn, key, rest = _route(routes, self.path)
if fn is None:
return self._send(404, {"ok": False, "error": "not-found"})
peer = None
if keys is not None:
signed = any(h.lower().startswith("mas-modes-") for h in self.headers.keys())
if signed or key not in unsigned or not _loopback(self.client_address[0]):
try:
peer = verify(self.headers, body, keys)
except AuthError as e:
return self._send(e.status, {"ok": False, "error": e.code})
try:
data = json.loads(body.decode())
except (UnicodeDecodeError, json.JSONDecodeError):
return self._send(400, {"ok": False, "error": "invalid-json"})
self._send(*fn(data, peer, rest))
def _send(self, status, obj):
out = json.dumps(obj).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(out)))
self.end_headers()
self.wfile.write(out)
def log_message(self, *a):
pass
return ThreadingHTTPServer((host, port), Handler)
def serve(server: ThreadingHTTPServer) -> str:
"""Run in a daemon thread; returns the base URL."""
threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True).start()
return f"http://{server.server_address[0]}:{server.server_address[1]}"