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
83
tests/test_modes_auth.py
Normal file
83
tests/test_modes_auth.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Peer authentication (CONTRACT.md section 7, MAS-WP-0011)."""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from mas_modes import _load
|
||||
from mas_modes.contract import message, post
|
||||
from mas_modes.email_mode import EmailMode, RoleConfig
|
||||
from mas_modes.harness import ROOT, Harness
|
||||
|
||||
STATUTE = ROOT / "mas-modes-v0.1-spec/conformance/statute.json"
|
||||
|
||||
|
||||
def test_vectors_match_an_independent_hmac():
|
||||
for v in _load("conformance/auth.json")["vectors"]:
|
||||
mac = hmac.new(bytes.fromhex(v["key_hex"]), f"{v['timestamp']}.".encode() + v["body"].encode(),
|
||||
hashlib.sha256).hexdigest()
|
||||
assert v["signature"] == "v1=" + mac
|
||||
|
||||
|
||||
def test_signed_events_required_when_configured():
|
||||
h = Harness(STATUTE, {"ticket": {"role": "master"}}, sign_events=True)
|
||||
try:
|
||||
assert h.mail("ticket", "hello").status == 201 # the gateway signs
|
||||
h.signer = None
|
||||
d = h.mail("ticket", "unsigned")
|
||||
assert d.status == 401 and d.response["error"] == "unauthenticated-peer"
|
||||
h.signer = ("gateway", b"\0" * 32)
|
||||
assert h.mail("ticket", "bad key").status == 401
|
||||
finally:
|
||||
h.close()
|
||||
|
||||
|
||||
def test_unsigned_events_only_without_event_peer():
|
||||
h = Harness(STATUTE, {"ticket": {"role": "master"}})
|
||||
try:
|
||||
assert h.mail("ticket", "loopback, unsigned").status == 201
|
||||
h.signer = ("cms-1", h.keys["cms-1"]) # signed, but not by a gateway
|
||||
assert h.mail("ticket", "x").status == 403
|
||||
finally:
|
||||
h.close()
|
||||
|
||||
|
||||
def test_stubs_accept_only_email():
|
||||
h = Harness(STATUTE, {"case": {"role": "slave"}})
|
||||
try:
|
||||
req = message("handoff.request", {"address": "a@b", "key": "a"}, handoff_id="h", mode="callback",
|
||||
details={}, status_url="http://127.0.0.1:9/x")
|
||||
url = h.email.roles["case"].master_url
|
||||
assert post(url, message("case.intake", intake_ref="i", resource_type="case", event={}))[0] == 401
|
||||
assert post(url, message("case.intake", intake_ref="i", resource_type="case", event={}),
|
||||
("cms-1", h.keys["cms-1"]))[0] == 401 # the master's key is not email's identity
|
||||
mode_url = h.callback_url
|
||||
assert post(mode_url, req)[0] == 401
|
||||
assert post(mode_url, req, ("email", b"\1" * 32))[0] == 401
|
||||
assert post(mode_url, req, ("email", h.keys["callback-1"]))[0] == 202
|
||||
finally:
|
||||
h.close()
|
||||
|
||||
|
||||
def test_email_refuses_config_without_keys():
|
||||
with pytest.raises(ValueError, match="no key"):
|
||||
EmailMode({"case": RoleConfig("slave", master_url="http://x", master_peer="cms-1")}, keys={})
|
||||
with pytest.raises(ValueError, match="no key"):
|
||||
EmailMode({"case": RoleConfig("slave", master_url="http://x", master_peer="cms-1",
|
||||
transfers={"webhook": "http://y", "peer": "nobody"})},
|
||||
keys={"cms-1": b"k" * 32})
|
||||
|
||||
|
||||
def test_status_urls_are_unguessable_and_distinct():
|
||||
h = Harness(STATUTE, {"ticket": {"role": "master", "fields": ("phone", "time", "scenario")}},
|
||||
scenario="silent")
|
||||
try:
|
||||
h.mail("ticket!callback", "Phone: 1\nTime: 2")
|
||||
h.mail("ticket!callback", "Phone: 1\nTime: 2")
|
||||
urls = [m["status_url"] for m in h.callback.received]
|
||||
tokens = [u.rsplit("/", 1)[1] for u in urls]
|
||||
assert len(set(tokens)) == 2 and all(len(t) >= 22 for t in tokens) # >= 128 bits
|
||||
assert json.dumps(h.callback.received).count(tokens[0]) == 1 # not echoed elsewhere
|
||||
finally:
|
||||
h.close()
|
||||
|
|
@ -5,8 +5,7 @@ from mas_modes.contract import message, post
|
|||
from mas_modes.harness import Harness, ROOT
|
||||
|
||||
STATUTE = ROOT / "mas-modes-v0.1-spec/conformance/statute.json"
|
||||
ROLES = {"ticket": {"role": "master", "modes": {"callback": "{callback}"},
|
||||
"fields": ("phone", "time", "scenario"), "timeout": 3600}}
|
||||
ROLES = {"ticket": {"role": "master", "fields": ("phone", "time", "scenario"), "timeout": 3600}}
|
||||
DETAILS = "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
|
||||
|
||||
|
|
@ -94,7 +93,8 @@ def test_mode_not_enabled_by_statute(h):
|
|||
|
||||
|
||||
def test_mode_endpoint_down():
|
||||
roles = {"ticket": {**ROLES["ticket"], "modes": {"callback": "http://127.0.0.1:9/handoffs"}}}
|
||||
roles = {"ticket": {**ROLES["ticket"],
|
||||
"modes": {"callback": {"url": "http://127.0.0.1:9/handoffs", "peer": "callback-1"}}}}
|
||||
h = Harness(STATUTE, roles)
|
||||
try:
|
||||
d = h.mail("ticket!callback", DETAILS)
|
||||
|
|
@ -120,27 +120,33 @@ def test_unauthenticated_sender_gets_no_mail(h):
|
|||
assert h.outbox() == [] # no creation reply, no question: principle 10
|
||||
|
||||
|
||||
def as_mode(h):
|
||||
"""Post as the callback stub does: signed, at the handoff's status URL."""
|
||||
url = h.callback.received[0]["status_url"]
|
||||
return lambda msg: post(url, msg, ("callback-1", h.keys["callback-1"]))
|
||||
|
||||
|
||||
def test_contract_guards(h):
|
||||
tid, hid = start(h, DETAILS + "\nScenario: completed")
|
||||
case = h.callback.run()[0]["case"]
|
||||
url = h.email.base_url + "/mode-status"
|
||||
send = as_mode(h)
|
||||
stale = message("handoff.status", case, handoff_id=hid, seq=1, status="running")
|
||||
assert post(url, stale) == (200, {"ok": True, "ignored": "stale"})
|
||||
code, body = post(url, message("handoff.status", case, handoff_id=hid, seq=99, status="timed-out"))
|
||||
assert send(stale) == (200, {"ok": True, "ignored": "stale"})
|
||||
code, body = send(message("handoff.status", case, handoff_id=hid, seq=99, status="timed-out"))
|
||||
assert code == 409 and body["error"] == "invalid-transition"
|
||||
code, body = post(url, message("handoff.status", case, handoff_id=hid, seq=99, status="negotiating",
|
||||
needs=["phone"]))
|
||||
code, body = send(message("handoff.status", case, handoff_id=hid, seq=99, status="negotiating",
|
||||
needs=["phone"]))
|
||||
assert code == 409 # completed is terminal
|
||||
code, body = post(url, message("handoff.status", case, handoff_id="nope", seq=1, status="running"))
|
||||
code, body = send(message("handoff.status", case, handoff_id="nope", seq=1, status="running"))
|
||||
assert code == 404
|
||||
code, body = post(url, {"type": "handoff.status"})
|
||||
code, body = send({"type": "handoff.status"})
|
||||
assert code == 400 and body["error"] == "schema"
|
||||
|
||||
|
||||
def test_repeated_message_id_is_processed_once(h):
|
||||
tid, hid = start(h, DETAILS + "\nScenario: silent")
|
||||
case = h.callback.received[0]["case"]
|
||||
url = h.email.base_url + "/mode-status"
|
||||
send = as_mode(h)
|
||||
msg = message("handoff.status", case, handoff_id=hid, seq=1, status="scheduled", at="x")
|
||||
assert post(url, msg) == post(url, msg) == (200, {"ok": True})
|
||||
assert send(msg) == send(msg) == (200, {"ok": True})
|
||||
assert h.thread(tid).count("callback: scheduled at=x") == 1
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ from mas_modes.harness import Harness, ROOT
|
|||
|
||||
STATUTE = ROOT / "mas-modes-v0.1-spec/conformance/statute.json"
|
||||
D = "modes.example"
|
||||
WEBHOOK = {"webhook": "{callback}", "peer": "callback-1"}
|
||||
|
||||
|
||||
def harness(**cfg):
|
||||
return Harness(STATUTE, {"case": {"role": "slave", "master_url": "{master}", **cfg}})
|
||||
return Harness(STATUTE, {"case": {"role": "slave", **cfg}})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -80,7 +81,7 @@ def test_principle_10_refuses_master_sends(h, kw, reason):
|
|||
|
||||
|
||||
def test_send_cap_and_unknown_message():
|
||||
h = Harness(STATUTE, {"case": {"role": "slave", "master_url": "{master}"}}, max_sent=0)
|
||||
h = Harness(STATUTE, {"case": {"role": "slave"}}, max_sent=0)
|
||||
try:
|
||||
body = intake(h)
|
||||
assert h.master.send(body["case"], body["message_ref"], "x")[1]["reason"] == "send-cap-reached"
|
||||
|
|
@ -110,7 +111,7 @@ def test_transfer_blocked_by_default(h):
|
|||
|
||||
|
||||
def test_transfer_to_primed_webhook():
|
||||
h = harness(transfers={"webhook": "{callback}"})
|
||||
h = harness(transfers=WEBHOOK)
|
||||
try:
|
||||
body = intake(h, local="case!callback", text="Phone: 123\nTime: now")
|
||||
assert h.master.of_type("handoff.requested")[0]["forwarded"] is True
|
||||
|
|
@ -138,7 +139,7 @@ def test_policy_from_assign_and_later_update(h):
|
|||
|
||||
def test_negotiation_only_when_autonomous():
|
||||
for autonomous, mails in ((False, 0), (True, 2)): # acknowledgement + question
|
||||
h = harness(transfers={"webhook": "{callback}"}, autonomous_send=autonomous)
|
||||
h = harness(transfers=WEBHOOK, autonomous_send=autonomous)
|
||||
try:
|
||||
h.callback.default = "negotiate"
|
||||
intake(h, local="case!callback", text="call me")
|
||||
|
|
@ -150,7 +151,7 @@ def test_negotiation_only_when_autonomous():
|
|||
|
||||
|
||||
def test_master_cancels_on_its_own_timeout():
|
||||
h = harness(transfers={"webhook": "{callback}"}, fields=("phone", "time", "scenario"))
|
||||
h = harness(transfers=WEBHOOK, fields=("phone", "time", "scenario"))
|
||||
try:
|
||||
body = intake(h, local="case!callback", text="Phone: 1\nTime: 2\nScenario: silent")
|
||||
assert h.callback.run() == []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue