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:
parent
2bc41a3318
commit
bdf200a600
25 changed files with 1603 additions and 151 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue