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

@ -0,0 +1,190 @@
{
"description": "Peer authentication (CONTRACT.md section 7). 'vectors': the signature for the given key (hex), timestamp and body (UTF-8, exact octets) MUST equal 'signature'. 'verify': a receiver holding 'keys' (peer -> hex key) whose clock reads 'now' MUST accept the request as 'peer' or refuse it with 'error'.",
"vectors": [
{
"id": "sig-001",
"key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"timestamp": 1792400000,
"body": "{\"mas_modes_version\":\"0.1\",\"type\":\"handoff.cancel\",\"id\":\"a1\",\"case\":{\"address\":\"ticket=gs-1@modes.example\",\"key\":\"ticket=gs-1\"},\"handoff_id\":\"h1\",\"reason\":\"timed-out\"}",
"signature": "v1=67fcf8eea6ab12dd3b863fd262fe115af8842691f562f4a6435516e3402b559f"
},
{
"id": "sig-002",
"key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"timestamp": 1792400000,
"body": "",
"signature": "v1=212ad604a508f09271a48ebcf6310e0379d03194bd404e4f8509900a4ae75531"
},
{
"id": "sig-003",
"key_hex": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"timestamp": 1792400001,
"body": "{\"mas_modes_version\":\"0.1\",\"type\":\"handoff.cancel\",\"id\":\"a1\",\"case\":{\"address\":\"ticket=gs-1@modes.example\",\"key\":\"ticket=gs-1\"},\"handoff_id\":\"h1\",\"reason\":\"timed-out\"}",
"signature": "v1=29bf8df86413caaf51b5e0ff16f3d8e3c6aa9b026d4826a7d51e644fa1b57986"
}
],
"verify": [
{
"id": "auth-001",
"why": "valid",
"keys": {
"callback-1": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
},
"now": 1792400010,
"headers": {
"MAS-Modes-Peer": "callback-1",
"MAS-Modes-Timestamp": "1792400000",
"MAS-Modes-Signature": "v1=67fcf8eea6ab12dd3b863fd262fe115af8842691f562f4a6435516e3402b559f"
},
"body": "{\"mas_modes_version\":\"0.1\",\"type\":\"handoff.cancel\",\"id\":\"a1\",\"case\":{\"address\":\"ticket=gs-1@modes.example\",\"key\":\"ticket=gs-1\"},\"handoff_id\":\"h1\",\"reason\":\"timed-out\"}",
"peer": "callback-1"
},
{
"id": "auth-002",
"why": "header names are case-insensitive",
"keys": {
"callback-1": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
},
"now": 1792400000,
"headers": {
"mas-modes-peer": "callback-1",
"mas-modes-timestamp": "1792400000",
"mas-modes-signature": "v1=67fcf8eea6ab12dd3b863fd262fe115af8842691f562f4a6435516e3402b559f"
},
"body": "{\"mas_modes_version\":\"0.1\",\"type\":\"handoff.cancel\",\"id\":\"a1\",\"case\":{\"address\":\"ticket=gs-1@modes.example\",\"key\":\"ticket=gs-1\"},\"handoff_id\":\"h1\",\"reason\":\"timed-out\"}",
"peer": "callback-1"
},
{
"id": "auth-003",
"why": "unsigned",
"keys": {
"callback-1": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
},
"now": 1792400000,
"headers": {},
"body": "{\"mas_modes_version\":\"0.1\",\"type\":\"handoff.cancel\",\"id\":\"a1\",\"case\":{\"address\":\"ticket=gs-1@modes.example\",\"key\":\"ticket=gs-1\"},\"handoff_id\":\"h1\",\"reason\":\"timed-out\"}",
"error": "unauthenticated-peer"
},
{
"id": "auth-004",
"why": "unknown peer",
"keys": {
"callback-1": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
},
"now": 1792400000,
"headers": {
"MAS-Modes-Peer": "mallory",
"MAS-Modes-Timestamp": "1792400000",
"MAS-Modes-Signature": "v1=67fcf8eea6ab12dd3b863fd262fe115af8842691f562f4a6435516e3402b559f"
},
"body": "{\"mas_modes_version\":\"0.1\",\"type\":\"handoff.cancel\",\"id\":\"a1\",\"case\":{\"address\":\"ticket=gs-1@modes.example\",\"key\":\"ticket=gs-1\"},\"handoff_id\":\"h1\",\"reason\":\"timed-out\"}",
"error": "unauthenticated-peer"
},
{
"id": "auth-005",
"why": "signed with another peer's key",
"keys": {
"callback-1": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"cms-1": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f"
},
"now": 1792400000,
"headers": {
"MAS-Modes-Peer": "cms-1",
"MAS-Modes-Timestamp": "1792400000",
"MAS-Modes-Signature": "v1=67fcf8eea6ab12dd3b863fd262fe115af8842691f562f4a6435516e3402b559f"
},
"body": "{\"mas_modes_version\":\"0.1\",\"type\":\"handoff.cancel\",\"id\":\"a1\",\"case\":{\"address\":\"ticket=gs-1@modes.example\",\"key\":\"ticket=gs-1\"},\"handoff_id\":\"h1\",\"reason\":\"timed-out\"}",
"error": "unauthenticated-peer"
},
{
"id": "auth-006",
"why": "body changed after signing",
"keys": {
"callback-1": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
},
"now": 1792400000,
"headers": {
"MAS-Modes-Peer": "callback-1",
"MAS-Modes-Timestamp": "1792400000",
"MAS-Modes-Signature": "v1=67fcf8eea6ab12dd3b863fd262fe115af8842691f562f4a6435516e3402b559f"
},
"body": "{\"mas_modes_version\":\"0.1\",\"type\":\"handoff.cancel\",\"id\":\"a1\",\"case\":{\"address\":\"ticket=gs-1@modes.example\",\"key\":\"ticket=gs-1\"},\"handoff_id\":\"h1\",\"reason\":\"completed\"}",
"error": "unauthenticated-peer"
},
{
"id": "auth-007",
"why": "timestamp changed after signing",
"keys": {
"callback-1": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
},
"now": 1792400000,
"headers": {
"MAS-Modes-Peer": "callback-1",
"MAS-Modes-Timestamp": "1792400001",
"MAS-Modes-Signature": "v1=67fcf8eea6ab12dd3b863fd262fe115af8842691f562f4a6435516e3402b559f"
},
"body": "{\"mas_modes_version\":\"0.1\",\"type\":\"handoff.cancel\",\"id\":\"a1\",\"case\":{\"address\":\"ticket=gs-1@modes.example\",\"key\":\"ticket=gs-1\"},\"handoff_id\":\"h1\",\"reason\":\"timed-out\"}",
"error": "unauthenticated-peer"
},
{
"id": "auth-008",
"why": "older than the 300 s window",
"keys": {
"callback-1": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
},
"now": 1792400301,
"headers": {
"MAS-Modes-Peer": "callback-1",
"MAS-Modes-Timestamp": "1792400000",
"MAS-Modes-Signature": "v1=67fcf8eea6ab12dd3b863fd262fe115af8842691f562f4a6435516e3402b559f"
},
"body": "{\"mas_modes_version\":\"0.1\",\"type\":\"handoff.cancel\",\"id\":\"a1\",\"case\":{\"address\":\"ticket=gs-1@modes.example\",\"key\":\"ticket=gs-1\"},\"handoff_id\":\"h1\",\"reason\":\"timed-out\"}",
"error": "replayed"
},
{
"id": "auth-009",
"why": "from the future, beyond the window",
"keys": {
"callback-1": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
},
"now": 1792399699,
"headers": {
"MAS-Modes-Peer": "callback-1",
"MAS-Modes-Timestamp": "1792400000",
"MAS-Modes-Signature": "v1=67fcf8eea6ab12dd3b863fd262fe115af8842691f562f4a6435516e3402b559f"
},
"body": "{\"mas_modes_version\":\"0.1\",\"type\":\"handoff.cancel\",\"id\":\"a1\",\"case\":{\"address\":\"ticket=gs-1@modes.example\",\"key\":\"ticket=gs-1\"},\"handoff_id\":\"h1\",\"reason\":\"timed-out\"}",
"error": "replayed"
},
{
"id": "auth-010",
"why": "at the edge of the window",
"keys": {
"callback-1": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
},
"now": 1792400300,
"headers": {
"MAS-Modes-Peer": "callback-1",
"MAS-Modes-Timestamp": "1792400000",
"MAS-Modes-Signature": "v1=67fcf8eea6ab12dd3b863fd262fe115af8842691f562f4a6435516e3402b559f"
},
"body": "{\"mas_modes_version\":\"0.1\",\"type\":\"handoff.cancel\",\"id\":\"a1\",\"case\":{\"address\":\"ticket=gs-1@modes.example\",\"key\":\"ticket=gs-1\"},\"handoff_id\":\"h1\",\"reason\":\"timed-out\"}",
"peer": "callback-1"
},
{
"id": "auth-011",
"why": "non-numeric timestamp",
"keys": {
"callback-1": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
},
"now": 1792400000,
"headers": {
"MAS-Modes-Peer": "callback-1",
"MAS-Modes-Timestamp": "soon",
"MAS-Modes-Signature": "v1=67fcf8eea6ab12dd3b863fd262fe115af8842691f562f4a6435516e3402b559f"
},
"body": "{\"mas_modes_version\":\"0.1\",\"type\":\"handoff.cancel\",\"id\":\"a1\",\"case\":{\"address\":\"ticket=gs-1@modes.example\",\"key\":\"ticket=gs-1\"},\"handoff_id\":\"h1\",\"reason\":\"timed-out\"}",
"error": "unauthenticated-peer"
}
]
}

View file

@ -1,5 +1,5 @@
{
"description": "Scripted flows (CONTRACT.md section 7). Each runs against a fresh email side with the callback stub and, for 'slave', a stub outside master that assigns EXT-100, EXT-101, ... The statute is statute.json. Placeholders: {case_id}, {message_ref}, {handoff_id} come from the latest response that carried them; {callback} is the stub's handoff URL. Master role config: ticket, modes {callback}, fields phone/time, timeout 3600 s. Slave role config: case, defaults autonomous_send false, transfers block, overridden by 'config'.",
"description": "Scripted flows (CONTRACT.md section 7). Each runs against a fresh email side with the callback stub and, for 'slave', a stub outside master that assigns EXT-100, EXT-101, ... The statute is statute.json. Placeholders: {case_id}, {message_ref}, {handoff_id} come from the latest response that carried them; {callback} is the stub's handoff URL. Peers: email, callback-1 (the stub), cms-1 (the stub master); every request is signed (CONTRACT.md section 7). Master role config: ticket, modes {callback}, fields phone/time, timeout 3600 s. Slave role config: case, defaults autonomous_send false, transfers block, overridden by 'config'.",
"flows": [
{
"id": "m-001",
@ -340,7 +340,8 @@
"title": "transfer to the primed webhook; statuses relayed",
"config": {
"transfers": {
"webhook": "{callback}"
"webhook": "{callback}",
"peer": "callback-1"
}
},
"scenario": "completed",
@ -382,7 +383,8 @@
"title": "the master owns the timeout",
"config": {
"transfers": {
"webhook": "{callback}"
"webhook": "{callback}",
"peer": "callback-1"
}
},
"scenario": "silent",
@ -421,6 +423,281 @@
}
}
]
},
{
"id": "a-001",
"role": "slave",
"title": "unsigned master command refused",
"steps": [
{
"mail": {
"to": "case",
"text": "hi"
}
},
{
"pump": true
},
{
"inject": {
"endpoint": "master",
"sign": "none",
"message": {
"type": "mail.send",
"case": "current",
"in_reply_to": "{message_ref}",
"body": "hello"
}
},
"expect": {
"http_status": 401,
"error": "unauthenticated-peer",
"outbox_count": 0
}
}
]
},
{
"id": "a-002",
"role": "slave",
"title": "a mode cannot assign case ids",
"steps": [
{
"mail": {
"to": "case",
"text": "hi"
},
"expect": {
"response_status": "intake-reported"
}
},
{
"inject": {
"endpoint": "master",
"as": "callback-1",
"message": {
"type": "case.assign",
"intake_ref": "{intake_ref}",
"resource_id": "EVIL-1"
}
},
"expect": {
"http_status": 403,
"error": "wrong-peer"
}
},
{
"pump": true,
"expect": {
"case_address": "case=EXT-100@modes.example"
}
}
]
},
{
"id": "a-003",
"role": "slave",
"title": "webhook to a peer email has no key for",
"steps": [
{
"mail": {
"to": "case",
"text": "hi"
}
},
{
"pump": true
},
{
"inject": {
"endpoint": "master",
"as": "cms-1",
"message": {
"type": "case.policy",
"case": "current",
"transfers": {
"webhook": "http://attacker.example/h",
"peer": "mallory"
}
}
},
"expect": {
"http_status": 400,
"error": "unknown-peer"
}
}
]
},
{
"id": "a-004",
"role": "slave",
"title": "replayed master command",
"steps": [
{
"mail": {
"to": "case",
"text": "hi"
}
},
{
"pump": true
},
{
"inject": {
"endpoint": "master",
"as": "cms-1",
"sign": "stale",
"message": {
"type": "mail.send",
"case": "current",
"in_reply_to": "{message_ref}",
"body": "hello"
}
},
"expect": {
"http_status": 401,
"error": "replayed",
"outbox_count": 0
}
}
]
},
{
"id": "a-005",
"role": "slave",
"title": "master command signed with the wrong key",
"steps": [
{
"mail": {
"to": "case",
"text": "hi"
}
},
{
"pump": true
},
{
"inject": {
"endpoint": "master",
"as": "cms-1",
"sign": "wrong-key",
"message": {
"type": "mail.send",
"case": "current",
"in_reply_to": "{message_ref}",
"body": "hello"
}
},
"expect": {
"http_status": 401,
"error": "unauthenticated-peer",
"outbox_count": 0
}
},
{
"inject": {
"endpoint": "master",
"as": "cms-1",
"message": {
"type": "mail.send",
"case": "current",
"in_reply_to": "{message_ref}",
"body": "hello"
}
},
"expect": {
"http_status": 200,
"outbox_count": 1
}
}
]
},
{
"id": "a-006",
"role": "master",
"title": "statuses only from the handoff's peer, at its URL",
"scenario": "silent",
"steps": [
{
"mail": {
"to": "ticket!callback",
"text": "Phone: +49 30 1234567\nTime: tomorrow 10:00"
},
"expect": {
"outbox_count": 1
}
},
{
"inject": {
"endpoint": "status",
"as": "cms-1",
"message": {
"type": "handoff.status",
"case": "current",
"seq": 1,
"status": "completed"
}
},
"expect": {
"http_status": 403,
"error": "wrong-peer"
}
},
{
"inject": {
"endpoint": "status",
"as": "callback-1",
"token": "bogus",
"message": {
"type": "handoff.status",
"case": "current",
"seq": 1,
"status": "completed"
}
},
"expect": {
"http_status": 404,
"error": "unknown-handoff",
"outbox_count": 1
}
},
{
"inject": {
"endpoint": "status",
"as": "callback-1",
"message": {
"type": "handoff.status",
"case": "current",
"seq": 1,
"status": "scheduled",
"at": "x"
}
},
"expect": {
"http_status": 200
}
}
]
},
{
"id": "a-007",
"role": "master",
"title": "only the gateway posts events",
"steps": [
{
"inject": {
"endpoint": "events",
"as": "callback-1",
"message": {
"type": "x"
}
},
"expect": {
"http_status": 403,
"error": "wrong-peer"
}
}
]
}
]
}

View file

@ -181,7 +181,8 @@
"key": "ticket=gs-1"
},
"transfers": {
"webhook": "http://127.0.0.1:8031/handoffs"
"webhook": "http://127.0.0.1:8031/handoffs",
"peer": "callback-1"
}
}
},
@ -435,6 +436,22 @@
"time": "tomorrow 10:00"
}
}
},
{
"case_id": "x-014",
"why": "webhook without the receiving mode's peer (CONTRACT.md 7.3)",
"message": {
"id": "x-014",
"mas_modes_version": "0.1",
"type": "case.policy",
"case": {
"address": "ticket=gs-1@great-service.example",
"key": "ticket=gs-1"
},
"transfers": {
"webhook": "http://127.0.0.1:8031/handoffs"
}
}
}
]
}

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()

View file

@ -10,8 +10,11 @@ Takes normalized gateway events (POST /mas-events), mode statuses
- slave: an outside master issues case ids, gets every inbound mail as an
event, and decides what email sends and where mode requests go.
Every outbound mail goes through principle 10 (`mailer.refusal`), whoever
asked for it. Outbound contract messages are queued while the lock is
Every peer is authenticated (CONTRACT.md section 7): requests in and out
are signed with pairwise keys, master commands are accepted only from the
master configured for the resource type, and mode statuses only at the
handoff's own tokenized URL, from the peer it was sent to. Every outbound
mail goes through principle 10 (`mailer.refusal`), whoever asked for it. Outbound contract messages are queued while the lock is
held and sent after it is released, so peers may call back at once.
"""
@ -19,6 +22,7 @@ from __future__ import annotations
import json
import re
import secrets
import sqlite3
import threading
import time
@ -41,7 +45,8 @@ SCHEMA = """
CREATE TABLE IF NOT EXISTS handoffs (
id TEXT PRIMARY KEY, role TEXT, resource_type TEXT, case_address TEXT, case_key TEXT,
case_ref TEXT, mode TEXT, status TEXT, seq INTEGER DEFAULT 0, deadline REAL,
needs TEXT, participant TEXT, endpoint TEXT, relay_seq INTEGER DEFAULT 0
needs TEXT, participant TEXT, endpoint TEXT, relay_seq INTEGER DEFAULT 0,
peer TEXT, token TEXT UNIQUE
);
CREATE TABLE IF NOT EXISTS intakes (
ref TEXT PRIMARY KEY, resource_type TEXT, domain TEXT, message_ref TEXT,
@ -69,12 +74,13 @@ class MasterStore(Protocol):
@dataclass
class RoleConfig:
role: str # "master" | "slave"
modes: dict = field(default_factory=dict) # master: mode -> endpoint URL
modes: dict = field(default_factory=dict) # master: mode -> {"url": URL, "peer": ID}
fields: tuple = ("phone", "time") # details read from "Key: value" lines
timeout: float = 3600.0 # master: handoff deadline (seconds)
master_url: Optional[str] = None # slave: outside master's event URL
master_peer: Optional[str] = None # slave: the only peer allowed to command
autonomous_send: bool = False # slave: default policy
transfers: Union[str, dict] = "block" # slave: "block" | {"webhook": URL}
transfers: Union[str, dict] = "block" # slave: "block" | {"webhook": URL, "peer": ID}
def extract_details(text: str, fields) -> dict:
@ -101,12 +107,27 @@ def _resume_text(mode: str, status: str, info: dict) -> str:
class EmailMode:
def __init__(self, roles: dict[str, RoleConfig], store: Optional[MasterStore] = None,
db_path: str = ":memory:", statutes: Optional[dict[str, Statute]] = None,
def __init__(self, roles: dict[str, RoleConfig], keys: dict[str, bytes],
store: Optional[MasterStore] = None, peer_id: str = "email",
event_peer: Optional[str] = None, db_path: str = ":memory:",
statutes: Optional[dict[str, Statute]] = None,
clock: Callable[[], float] = time.time, smtp=None, max_sent=None,
require_dmarc_pass: bool = True):
"""keys: peer id -> key shared with that peer. event_peer: the gateway's
peer id when events must be signed; None accepts unsigned events from
loopback only."""
if any(c.role == "master" for c in roles.values()) and store is None:
raise ValueError("a master role needs a MasterStore")
for t, c in roles.items():
peers = [m["peer"] for m in c.modes.values()] if c.role == "master" else [c.master_peer]
if isinstance(c.transfers, dict):
peers.append(c.transfers.get("peer"))
missing = [p for p in peers if p not in keys]
if missing:
raise ValueError(f"{t}: no key for peer(s) {missing}")
self.keys = keys
self.peer_id = peer_id
self.event_peer = event_peer
self.roles = roles
self.store = store
self.statutes = statutes or {}
@ -116,17 +137,18 @@ class EmailMode:
self.db.executescript(SCHEMA)
self.mailer = Mailer(self.db, smtp, max_sent, require_dmarc_pass)
self.lock = threading.RLock()
self.base_url = "" # set by serve(); status_url = base_url + /mode-status
self.base_url = "" # set by serve(); status URLs are base_url/mode-status/TOKEN
self._queue: list = []
self._once = Idempotent()
# -- HTTP entry points ---------------------------------------------------------
def server(self, host="127.0.0.1", port=0):
return json_server({"/mas-events": self.handle_event, "/mode-status": self.mode_status,
"/master": self.master_command,
"/tick": lambda body: (200, {"ok": True, "timed_out": self.tick()})},
host, port)
routes = {"/mas-events": self.handle_event, "/mode-status/": self.mode_status,
"/master": self.master_command,
"/tick": lambda body, peer, rest: (200, {"ok": True, "timed_out": self.tick()})}
unsigned = {"/tick"} | ({"/mas-events"} if self.event_peer is None else set())
return json_server(routes, self.keys, frozenset(unsigned), host, port)
def _run(self, fn, *args):
with self.lock, self.db:
@ -134,23 +156,25 @@ class EmailMode:
self._flush()
return out
def _send(self, url, msg, on_result=None):
self._queue.append((url, msg, on_result))
def _send(self, url, peer, msg, on_result=None):
self._queue.append((url, (self.peer_id, self.keys[peer]), msg, on_result))
def _flush(self):
while True:
with self.lock:
if not self._queue:
return
url, msg, on_result = self._queue.pop(0)
status, body = post(url, msg)
url, signer, msg, on_result = self._queue.pop(0)
status, body = post(url, msg, signer)
if on_result:
with self.lock, self.db:
on_result(status, body)
# -- inbound mail --------------------------------------------------------------
def handle_event(self, event: dict) -> tuple[int, dict]:
def handle_event(self, event: dict, peer: Optional[str] = None, rest: str = "") -> tuple[int, dict]:
if peer != self.event_peer: # None: unsigned on loopback, when configured so
return refuse("wrong-peer", "events come from the gateway only", 403)
mas = event.get("mas", {})
rtype = mas.get("resource_type") if mas.get("kind") == "resource" else mas.get("intake_for")
cfg = self.roles.get(rtype) if mas.get("valid") and mas.get("namespace_confirmed") else None
@ -185,15 +209,16 @@ class EmailMode:
details = extract_details(msg.get("text"), json.loads(h["needs"] or "[]"))
if details:
self.store.record(ref, "mode-update", f"{h['mode']}: details {sorted(details)}")
self._send(h["endpoint"], message("handoff.update", case,
handoff_id=h["id"], details=details))
self._send(h["endpoint"], h["peer"], message("handoff.update", case,
handoff_id=h["id"], details=details))
return code, {**resp, "handoff_id": h["id"]}
mode = mas.get("interaction")
if not mode:
return code, resp
failures = {f["code"] for f in mas.get("failures", [])}
hid = self._new_handoff("master", rtype, case, ref, mode, msg,
cfg.modes.get(mode), self.clock() + cfg.timeout)
endpoint = cfg.modes.get(mode) or {}
hid = self._new_handoff("master", rtype, case, ref, mode, msg, endpoint.get("url"),
endpoint.get("peer"), self.clock() + cfg.timeout)
if failures & NOT_ENABLED:
# The creation reply already says so; only an existing case gets a notice.
self._status(hid, "refused", {"reason": "mode-not-enabled"},
@ -204,42 +229,46 @@ class EmailMode:
self._request(hid, case, mode, extract_details(msg.get("text"), cfg.fields), msg)
return code, {**resp, "handoff_id": hid}
def _new_handoff(self, role, rtype, case, ref, mode, msg, endpoint, deadline):
def _new_handoff(self, role, rtype, case, ref, mode, msg, endpoint, peer, deadline):
hid = "h-" + uuid.uuid4().hex[:12]
self.db.execute("INSERT INTO handoffs (id, role, resource_type, case_address, case_key,"
" case_ref, mode, status, deadline, participant, endpoint)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?)",
" case_ref, mode, status, deadline, participant, endpoint, peer, token)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
(hid, role, rtype, case["address"], case["key"], ref, mode, None,
deadline, json.dumps(msg), endpoint))
deadline, json.dumps(msg), endpoint, peer, secrets.token_urlsafe(24)))
return hid
def _request(self, hid, case, mode, details, msg):
h = self._handoff(hid)
self._status(hid, "requested", {})
req = message("handoff.request", case, handoff_id=hid, mode=mode, details=details,
status_url=self.base_url + "/mode-status",
status_url=f"{self.base_url}/mode-status/{h['token']}",
participant={"address": msg.get("from")} if msg.get("from") else None)
def on_result(status, body):
if status is None or status >= 400:
self._status(hid, "refused", {"reason": "mode-unavailable",
"detail": f"mode endpoint answered {status}"})
self._send(h["endpoint"], req, on_result)
self._send(h["endpoint"], h["peer"], req, on_result)
def _handoff(self, hid):
return self.db.execute("SELECT * FROM handoffs WHERE id=?", (hid,)).fetchone()
# -- statuses ------------------------------------------------------------------
def mode_status(self, msg: dict) -> tuple[int, dict]:
return self._once(msg, lambda m: self._run(self._mode_status, m))
def mode_status(self, msg: dict, peer: Optional[str] = None, token: str = "") -> tuple[int, dict]:
return self._once(msg, lambda m: self._run(self._mode_status, m, peer, token))
def _mode_status(self, msg):
def _mode_status(self, msg, peer, token):
h = self.db.execute("SELECT * FROM handoffs WHERE token=?", (token,)).fetchone()
if h is None:
return refuse("unknown-handoff", "no handoff at this status URL", 404)
if peer != h["peer"]:
return refuse("wrong-peer", f"handoff {h['id']} was sent to another peer", 403)
errors = message_errors(msg)
if errors or msg.get("type") != "handoff.status":
return refuse("schema", "; ".join(errors) or "expected handoff.status", 400)
h = self._handoff(msg["handoff_id"])
if h is None or h["case_key"] != msg["case"]["key"]:
if msg["handoff_id"] != h["id"] or msg["case"]["key"] != h["case_key"]:
return refuse("unknown-handoff", msg["handoff_id"], 404)
if msg["seq"] <= h["seq"]:
return 200, {"ok": True, "ignored": "stale"}
@ -272,8 +301,8 @@ class EmailMode:
# slave: the master owns the flow; relay, and ask only when allowed to
cfg = self.roles[h["resource_type"]]
if status != "requested":
self._send(cfg.master_url, message("handoff.status", case, handoff_id=hid,
seq=self._relay_seq(hid), status=status, **info))
self._send(cfg.master_url, cfg.master_peer, message(
"handoff.status", case, handoff_id=hid, seq=self._relay_seq(hid), status=status, **info))
if status == "negotiating" and self._policy(h["case_key"])["autonomous_send"]:
self._ask(h, participant, info["needs"], lambda subj, body: self.mailer.reply(
h["case_key"], h["case_address"], participant, subj, body) is None)
@ -300,7 +329,7 @@ class EmailMode:
continue
self._status(h["id"], "timed-out", {})
if h["endpoint"]:
self._send(h["endpoint"], message(
self._send(h["endpoint"], h["peer"], message(
"handoff.cancel", {"address": h["case_address"], "key": h["case_key"]},
handoff_id=h["id"], reason="timed-out"))
out.append(h["id"])
@ -322,8 +351,9 @@ class EmailMode:
mref = self._store_message(None, msg, "intake")
self.db.execute("INSERT INTO intakes VALUES (?,?,?,?,?,NULL)",
(iref, rtype, mas["domain"], mref, mas.get("interaction")))
self._send(cfg.master_url, message("case.intake", intake_ref=iref, resource_type=rtype,
event=event, requested_mode=mas.get("interaction")))
self._send(cfg.master_url, cfg.master_peer, message(
"case.intake", intake_ref=iref, resource_type=rtype, event=event,
requested_mode=mas.get("interaction")))
return 202, {"status": "intake-reported", "intake_ref": iref}
row = self.db.execute("SELECT * FROM cases WHERE key=?", (mas["equivalence_key"],)).fetchone()
if row is None:
@ -331,14 +361,15 @@ class EmailMode:
return 202, {"status": "unknown-resource", "action": "quarantined, no reply"}
case = {"address": row["address"], "key": row["key"]}
mref = self._store_message(row["key"], msg, "attached")
self._send(cfg.master_url, message("case.message", case, event=event, message_ref=mref))
self._send(cfg.master_url, cfg.master_peer,
message("case.message", case, event=event, message_ref=mref))
out = {"status": "reported", "case": case, "message_ref": mref}
h = self._open_handoff(row["key"])
if h is not None and h["status"] == "negotiating" and h["endpoint"]:
details = extract_details(msg.get("text"), json.loads(h["needs"] or "[]"))
if details:
self._send(h["endpoint"], message("handoff.update", case, handoff_id=h["id"],
details=details))
self._send(h["endpoint"], h["peer"], message("handoff.update", case,
handoff_id=h["id"], details=details))
elif h is None and mas.get("interaction"):
out["handoff_id"] = self._transfer(rtype, cfg, case, mas["interaction"], msg)
return 200, out
@ -350,31 +381,54 @@ class EmailMode:
def _transfer(self, rtype, cfg, case, mode, msg):
"""A !MODE request in the slave role: blocked, or sent to the primed webhook."""
transfers = self._policy(case["key"])["transfers"]
webhook = transfers.get("webhook") if isinstance(transfers, dict) else None
hid = self._new_handoff("slave", rtype, case, case["key"], mode, msg, webhook, None)
self._send(cfg.master_url, message("handoff.requested", case, handoff_id=hid, mode=mode,
forwarded=webhook is not None))
if webhook is None:
webhook = transfers if isinstance(transfers, dict) else {}
hid = self._new_handoff("slave", rtype, case, case["key"], mode, msg, webhook.get("webhook"),
webhook.get("peer"), None)
self._send(cfg.master_url, cfg.master_peer, message(
"handoff.requested", case, handoff_id=hid, mode=mode, forwarded=bool(webhook)))
if not webhook:
self._status(hid, "requested", {})
self._status(hid, "refused", {"reason": "blocked"})
else:
self._request(hid, case, mode, extract_details(msg.get("text"), cfg.fields), msg)
return hid
def master_command(self, msg: dict) -> tuple[int, dict]:
return self._once(msg, lambda m: self._run(self._master_command, m))
def master_command(self, msg: dict, peer: Optional[str] = None, rest: str = "") -> tuple[int, dict]:
return self._once(msg, lambda m: self._run(self._master_command, m, peer))
def _master_command(self, msg):
def _authorized(self, rtype, peer):
cfg = self.roles.get(rtype)
return cfg is not None and cfg.role == "slave" and peer is not None and peer == cfg.master_peer
def _unknown_webhook_peer(self, msg):
pol = msg.get("policy", msg)
transfers = pol.get("transfers")
if isinstance(transfers, dict) and transfers["peer"] not in self.keys:
return refuse("unknown-peer", transfers["peer"], 400)
return None
def _master_command(self, msg, peer):
if not any(self._authorized(t, peer) for t in self.roles):
return refuse("wrong-peer", f"{peer} is not a master here", 403)
errors = message_errors(msg)
if errors:
return refuse("schema", "; ".join(errors), 400)
t = msg["type"]
if t == "case.assign":
return self._assign(msg)
it = self.db.execute("SELECT resource_type FROM intakes WHERE ref=?",
(msg["intake_ref"],)).fetchone()
if it is not None and not self._authorized(it["resource_type"], peer):
return refuse("wrong-peer", f"{peer} is not the master for {it['resource_type']}", 403)
return self._unknown_webhook_peer(msg) or self._assign(msg)
row = self.db.execute("SELECT * FROM cases WHERE key=?", (msg["case"]["key"],)).fetchone()
if row is None:
return refuse("unknown-case", msg["case"]["key"], 404)
if not self._authorized(row["resource_type"], peer):
return refuse("wrong-peer", f"{peer} is not the master for {row['resource_type']}", 403)
if t == "case.policy":
bad = self._unknown_webhook_peer(msg)
if bad:
return bad
pol = {**self._policy(row["key"]),
**{k: msg[k] for k in ("autonomous_send", "transfers") if k in msg}}
self.db.execute("UPDATE cases SET policy=? WHERE key=?", (json.dumps(pol), row["key"]))
@ -394,8 +448,8 @@ class EmailMode:
if h is None or h["case_key"] != row["key"]:
return refuse("unknown-handoff", msg["handoff_id"], 404)
if h["endpoint"] and h["status"] not in TERMINAL:
self._send(h["endpoint"], message("handoff.cancel", msg["case"], handoff_id=h["id"],
reason=msg.get("reason")))
self._send(h["endpoint"], h["peer"], message(
"handoff.cancel", msg["case"], handoff_id=h["id"], reason=msg.get("reason")))
return 200, {"ok": True}
return refuse("unsupported", t, 400)

View file

@ -3,11 +3,14 @@
Used by the tests and by the contract flow runner. Mail enters through the
real gateway (`build_event` from raw RFC 822, with trusted
Authentication-Results), so the whole path is exercised. Time is a fake
clock that only moves on `advance()`.
clock that only moves on `advance()`. Every peer gets its own pairwise key
with email: `callback-1` (the stub) and `cms-1` (the stub master), plus
`gateway` when events are signed.
"""
from __future__ import annotations
import secrets
import sys
from pathlib import Path
from typing import Optional
@ -42,17 +45,21 @@ class FakeClock:
class Harness:
"""roles: resource type -> {"role": "master"|"slave", ...RoleConfig fields}.
The strings "{callback}" and "{master}" in them are replaced by the stub URLs."""
The strings "{callback}" and "{master}" in them are replaced by the stub URLs.
Without explicit settings, master roles hand callbacks to the stub and
slave roles take commands from the stub master."""
def __init__(self, statute_file, roles: dict, scenario: str = "completed",
auto_assign: bool = True, max_sent: Optional[int] = None, ticket_ids=None):
auto_assign: bool = True, max_sent: Optional[int] = None, ticket_ids=None,
sign_events: bool = False):
self.resolver = Resolver.with_statute_files([statute_file])
[self.domain] = self.resolver.pinned
self.clock = FakeClock()
self.callback = CallbackStub(default=scenario)
self.keys = {peer: secrets.token_bytes(32) for peer in ("callback-1", "cms-1", "gateway")}
self.callback = CallbackStub({"email": self.keys["callback-1"]}, default=scenario)
self._servers = []
cb_url = self._serve(self.callback.server()) + "/handoffs"
self.master = StubMaster(auto_assign=auto_assign)
cb_url = self.callback_url = self._serve(self.callback.server()) + "/handoffs"
self.master = StubMaster({"email": self.keys["cms-1"]}, auto_assign=auto_assign)
master_url = self._serve(self.master.server()) + "/events"
def fill(v):
@ -61,13 +68,18 @@ class Harness:
if isinstance(v, dict):
return {k: fill(x) for k, x in v.items()}
return v
configs = {t: RoleConfig(**fill(c)) for t, c in roles.items()}
defaults = {"master": {"modes": {"callback": {"url": "{callback}", "peer": "callback-1"}}},
"slave": {"master_url": "{master}", "master_peer": "cms-1"}}
configs = {t: RoleConfig(**fill({**defaults[c["role"]], **c})) for t, c in roles.items()}
self.tickets = None
if any(c.role == "master" for c in configs.values()):
kw = {"id_factory": (lambda it=iter(ticket_ids): next(it))} if ticket_ids else {}
self.tickets = reference_ticket_service()(require_dmarc_pass=True, max_sent=max_sent, **kw)
self.email = EmailMode(configs, self.tickets, statutes=dict(self.resolver.pinned),
clock=self.clock, max_sent=max_sent)
self.signer = ("gateway", self.keys["gateway"]) if sign_events else None
self.email = EmailMode(configs, dict(self.keys), self.tickets,
event_peer="gateway" if sign_events else None,
statutes=dict(self.resolver.pinned), clock=self.clock,
max_sent=max_sent)
self.email.base_url = self._serve(self.email.server())
self.master.email_url = self.email.base_url + "/master"
self.app_url = self.email.base_url + "/mas-events"
@ -91,7 +103,7 @@ class Harness:
f"Message-ID: <{abs(hash((to, subject, text, self.clock.t)))}@example.net>\r\n"
f"{headers}\r\n{text}\r\n").encode()
meta = message_meta_from_rfc822(raw, trusted_authserv_id=self.domain)
[d] = gateway([to], meta, self.resolver, self.app_url)
[d] = gateway([to], meta, self.resolver, self.app_url, self.signer)
return d
def advance(self, seconds: float):

View file

@ -9,17 +9,21 @@ and pytest.
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
import uuid
from collections import Counter
from dataclasses import dataclass, field
from importlib import resources
from . import flow_fixtures, message_errors, message_fixtures, transitions
from . import _load, flow_fixtures, message_errors, message_fixtures, transitions
from .contract import allowed
from .harness import Harness
from .signing import AuthError, sign, signature, verify
MASTER = {"role": "master", "modes": {"callback": "{callback}"},
"fields": ("phone", "time", "scenario"), "timeout": 3600}
SLAVE = {"role": "slave", "master_url": "{master}"}
MASTER = {"role": "master", "fields": ("phone", "time", "scenario"), "timeout": 3600}
SLAVE = {"role": "slave"}
@dataclass
@ -56,6 +60,53 @@ def _ok(report):
report.passed += 1
def check_auth(report: Report):
"""Signing vectors and verification cases (CONTRACT.md section 7)."""
fx = _load("conformance/auth.json")
for v in fx["vectors"]:
got = signature(bytes.fromhex(v["key_hex"]), v["timestamp"], v["body"].encode())
(report.failures.append((v["id"], f"signature {got}")) if got != v["signature"] else _ok(report))
for c in fx["verify"]:
keys = {p: bytes.fromhex(k) for p, k in c["keys"].items()}
try:
got = verify(c["headers"], c["body"].encode(), keys, now=c["now"])
except AuthError as e:
got = "error:" + e.code
want = c["peer"] if "peer" in c else "error:" + c["error"]
(report.failures.append((c["id"], f"got {got}, expected {want}")) if got != want else _ok(report))
def _inject(h, spec, ctx):
"""Send a crafted request straight to an email endpoint (authorization flows)."""
if spec["endpoint"] == "status":
reqs = [m for m in h.callback.received if m["type"] == "handoff.request"]
url = reqs[-1]["status_url"]
if spec.get("token") == "bogus":
url = url.rsplit("/", 1)[0] + "/not-a-token"
else:
url = h.email.base_url + {"master": "/master", "events": "/mas-events"}[spec["endpoint"]]
msg = {"mas_modes_version": "0.1", "id": uuid.uuid4().hex, **_fill(spec["message"], ctx)}
if msg.get("case") == "current": # the case (and handoff) the flow is working on
if spec["endpoint"] == "status":
msg["case"], msg["handoff_id"] = reqs[-1]["case"], reqs[-1]["handoff_id"]
else:
msg["case"] = {"address": ctx["case_address"], "key": ctx["case_key"]}
body = json.dumps(msg).encode()
headers = {"Content-Type": "application/json"}
mode = spec.get("sign", "valid")
if mode != "none":
peer = spec["as"]
key = h.keys.get(peer, b"k" * 32) if mode != "wrong-key" else b"x" * 32
ts = int(time.time()) - (400 if mode == "stale" else 0)
headers.update(sign(body, peer, key, ts))
req = urllib.request.Request(url, data=body, method="POST", headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, json.loads(r.read().decode() or "null")
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read().decode() or "null")
def _fill(value, ctx):
if isinstance(value, str):
for k, v in ctx.items():
@ -95,6 +146,8 @@ def run_flow(flow: dict) -> list[str]:
elif "pump" in step:
results = h.master.pump()
resp = results[-1][1] if results else {}
elif "inject" in step:
last_command = _inject(h, step["inject"], ctx)
elif "master" in step:
cmd = _fill(step["master"], {**ctx, "callback": ""})
case = {"address": ctx["case_address"], "key": ctx["case_key"]}
@ -108,7 +161,7 @@ def run_flow(flow: dict) -> list[str]:
if isinstance(resp.get("case"), dict):
ctx["case_id"] = resp["case"]["address"].split("=", 1)[1].split("@")[0]
ctx["case_address"], ctx["case_key"] = resp["case"]["address"], resp["case"]["key"]
for k in ("message_ref", "handoff_id"):
for k in ("message_ref", "handoff_id", "intake_ref"):
if resp.get(k):
ctx[k] = resp[k]
for key, want in step.get("expect", {}).items():
@ -150,12 +203,17 @@ def _observe(h, key, resp, last_command, timed_out):
return (last_command[1] or {}).get("ok")
if key == "command_reason":
return (last_command[1] or {}).get("reason")
if key == "http_status":
return last_command[0]
if key == "error":
return (last_command[1] or {}).get("error")
raise KeyError(f"unknown expectation {key!r}")
def run_mode_corpus() -> Report:
report = Report()
check_messages(report)
check_auth(report)
for flow in flow_fixtures():
problems = run_flow(flow)
if problems:

View file

@ -670,12 +670,17 @@
{
"type": "object",
"required": [
"webhook"
"webhook",
"peer"
],
"properties": {
"webhook": {
"type": "string",
"format": "uri"
},
"peer": {
"type": "string",
"minLength": 1
}
},
"additionalProperties": false

53
mas_modes/signing.py Normal file
View file

@ -0,0 +1,53 @@
"""Peer authentication for the mode contract (CONTRACT.md section 7).
HMAC-SHA256 over TIMESTAMP + "." + BODY with a pairwise key, sent in three
headers. Standard library only, so the gateway can use it without the rest
of `mas_modes`.
"""
from __future__ import annotations
import hashlib
import hmac
import time
from typing import Mapping, Optional
PEER = "MAS-Modes-Peer"
TIMESTAMP = "MAS-Modes-Timestamp"
SIGNATURE = "MAS-Modes-Signature"
WINDOW = 300 # seconds
def signature(key: bytes, timestamp: int, body: bytes) -> str:
return "v1=" + hmac.new(key, str(timestamp).encode() + b"." + body, hashlib.sha256).hexdigest()
def sign(body: bytes, peer: str, key: bytes, timestamp: Optional[int] = None) -> dict:
"""The headers that authenticate `body` as sent by `peer`."""
ts = int(time.time()) if timestamp is None else timestamp
return {PEER: peer, TIMESTAMP: str(ts), SIGNATURE: signature(key, ts, body)}
class AuthError(Exception):
def __init__(self, code: str, status: int = 401):
super().__init__(code)
self.code, self.status = code, status
def verify(headers: Mapping[str, str], body: bytes, keys: Mapping[str, bytes],
now: Optional[float] = None) -> str:
"""Return the authenticated peer id, or raise AuthError."""
get = {k.lower(): v for k, v in headers.items()}.get
peer, ts, sig = get(PEER.lower()), get(TIMESTAMP.lower()), get(SIGNATURE.lower())
key = keys.get(peer) if peer else None
if key is None or not ts or not sig:
raise AuthError("unauthenticated-peer")
try:
ts_i = int(ts)
except ValueError:
raise AuthError("unauthenticated-peer") from None
if not hmac.compare_digest(signature(key, ts_i, body), sig):
raise AuthError("unauthenticated-peer")
if abs((time.time() if now is None else now) - ts_i) > WINDOW:
raise AuthError("replayed")
return peer

View file

@ -5,8 +5,11 @@ calls anyone. Scenarios are scripted and deterministic: nothing happens
until `step()` (or POST /step) emits the next status. A scenario is chosen
by the request's `details.scenario`, or else by the stub's default.
stub = CallbackStub(default="no-answer-retry")
base = serve(stub.server()) # POST /handoffs, /step
stub = CallbackStub({"email": key}, default="no-answer-retry")
base = serve(stub.server()) # POST /handoffs (signed), /step (loopback)
Requests are accepted only when signed by email's peer id, and statuses
are signed with the key shared with email (CONTRACT.md section 7).
"""
from __future__ import annotations
@ -55,9 +58,11 @@ class Handoff:
class CallbackStub:
def __init__(self, default: str = "completed"):
def __init__(self, keys: dict[str, bytes], default: str = "completed",
peer_id: str = "callback-1", email_peer: str = "email"):
if default not in SCENARIOS:
raise ValueError(f"unknown scenario {default!r}")
self.keys, self.peer_id, self.email_peer = keys, peer_id, email_peer
self.default = default
self.handoffs: dict[str, Handoff] = {}
self.received: list[dict] = []
@ -66,7 +71,9 @@ class CallbackStub:
# -- contract endpoint ---------------------------------------------------------
def receive(self, msg: dict) -> tuple[int, dict]:
def receive(self, msg: dict, peer: Optional[str] = None, rest: str = "") -> tuple[int, dict]:
if peer != self.email_peer:
return refuse("wrong-peer", "only email may hand off", 403)
return self._once(msg, self._receive)
def _receive(self, msg):
@ -127,7 +134,7 @@ class CallbackStub:
seq=h.seq, status=status, **extra)
h.sent.append(msg)
url = h.request["status_url"]
post(url, msg)
post(url, msg, (self.peer_id, self.keys[self.email_peer]))
return msg
def run(self, handoff_id: Optional[str] = None, limit: int = 20) -> list[dict]:
@ -149,6 +156,6 @@ class CallbackStub:
return None
def server(self, host="127.0.0.1", port=0):
return json_server({"/handoffs": self.receive,
"/step": lambda body: (200, {"ok": True, "sent": self.step(body.get("handoff_id"))})},
host, port)
step = lambda body, peer, rest: (200, {"ok": True, "sent": self.step(body.get("handoff_id"))}) # noqa: E731
return json_server({"/handoffs": self.receive, "/step": step}, self.keys,
frozenset({"/step"}), host, port)

View file

@ -5,8 +5,8 @@ drives email through the contract: assign, policy, mail.send, cancel.
Nothing happens on its own: `pump()` performs queued auto-assignments, so
tests stay deterministic and no handler calls back into email.
master = StubMaster(email_url="http://127.0.0.1:PORT/master", auto_assign=True)
base = serve(master.server()) # POST /events
master = StubMaster({"email": key}, email_url="http://127.0.0.1:PORT/master", auto_assign=True)
base = serve(master.server()) # POST /events (signed by email)
"""
from __future__ import annotations
@ -20,8 +20,10 @@ from ..contract import Idempotent, json_server, message, post, refuse
class StubMaster:
def __init__(self, email_url: Optional[str] = None, auto_assign: bool = False,
id_prefix: str = "EXT-"):
def __init__(self, keys: dict[str, bytes], email_url: Optional[str] = None,
auto_assign: bool = False, id_prefix: str = "EXT-", peer_id: str = "cms-1",
email_peer: str = "email"):
self.keys, self.peer_id, self.email_peer = keys, peer_id, email_peer
self.email_url = email_url
self.auto_assign = auto_assign
self.ids = (f"{id_prefix}{n}" for n in itertools.count(100))
@ -30,7 +32,9 @@ class StubMaster:
self.lock = threading.Lock()
self._once = Idempotent()
def receive(self, msg: dict) -> tuple[int, dict]:
def receive(self, msg: dict, peer: Optional[str] = None, rest: str = "") -> tuple[int, dict]:
if peer != self.email_peer:
return refuse("wrong-peer", "events come from email only", 403)
return self._once(msg, self._receive)
def _receive(self, msg):
@ -49,7 +53,8 @@ class StubMaster:
# -- commands to email -------------------------------------------------------
def command(self, type_: str, case: Optional[dict] = None, **fields) -> tuple[Optional[int], object]:
return post(self.email_url, message(type_, case, **fields))
return post(self.email_url, message(type_, case, **fields),
(self.peer_id, self.keys[self.email_peer]))
def pump(self) -> list[tuple]:
"""Assign case ids to queued intakes (when auto_assign is on)."""
@ -76,4 +81,4 @@ class StubMaster:
return self.command("handoff.cancel", case, handoff_id=handoff_id, reason=reason)
def server(self, host="127.0.0.1", port=0):
return json_server({"/events": self.receive}, host, port)
return json_server({"/events": self.receive}, self.keys, host=host, port=port)