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
This commit is contained in:
tegwick 2026-10-19 11:25:24 +02:00
parent 2bc41a3318
commit bdf200a600
25 changed files with 1603 additions and 151 deletions

View file

@ -2,15 +2,17 @@
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, Optional
from typing import Callable, Mapping, Optional
from . import CONTRACT_VERSION, transitions
from .signing import AuthError, sign, verify
_T = transitions()
TERMINAL = frozenset(_T["terminal"])
@ -29,10 +31,18 @@ def message(type_: str, case: Optional[dict] = None, **fields) -> dict:
return msg
def post(url: str, msg: dict, timeout: float = 10.0) -> tuple[Optional[int], object]:
"""POST a contract message. (None, error) when the peer is unreachable."""
req = urllib.request.Request(url, data=json.dumps(msg).encode(), method="POST",
headers={"Content-Type": "application/json"})
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")
@ -68,20 +78,53 @@ class Idempotent:
return out
def json_server(routes: dict[str, Callable[[dict], tuple[int, dict]]], host="127.0.0.1",
port=0) -> ThreadingHTTPServer:
"""A loopback JSON-over-HTTP server: POST path -> handler(body) -> (status, body)."""
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 = routes.get(self.path)
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))
self._send(*fn(data, peer, rest))
def _send(self, status, obj):
out = json.dumps(obj).encode()