From bdf200a600124ef40efbf5c2fccee288283e7023 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 19 Oct 2026 11:25:24 +0200 Subject: [PATCH] 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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 5787@bnt-lap001 Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd --- SCOPE.md | 15 +- docs/reuse-surface.md | 25 +- mas-modes-v0.1-spec/CONTRACT.md | 93 +++++- mas-modes-v0.1-spec/README.md | 1 + mas-modes-v0.1-spec/conformance/auth.json | 190 ++++++++++++ mas-modes-v0.1-spec/conformance/flows.json | 283 +++++++++++++++++- mas-modes-v0.1-spec/conformance/messages.json | 19 +- .../schema/mas-modes-v0.1.schema.json | 7 +- mas_gateway/core.py | 18 +- mas_gateway/main.py | 23 +- mas_modes/conformance/auth.json | 190 ++++++++++++ mas_modes/conformance/flows.json | 283 +++++++++++++++++- mas_modes/conformance/messages.json | 19 +- mas_modes/contract.py | 63 +++- mas_modes/email_mode.py | 156 ++++++---- mas_modes/harness.py | 32 +- mas_modes/runner.py | 68 ++++- mas_modes/schema/mas-modes-v0.1.schema.json | 7 +- mas_modes/signing.py | 53 ++++ mas_modes/stubs/callback.py | 23 +- mas_modes/stubs/master.py | 19 +- tests/test_modes_auth.py | 83 +++++ tests/test_modes_master.py | 30 +- tests/test_modes_slave.py | 11 +- ...P-0011-mode-contract-transport-security.md | 43 ++- 25 files changed, 1603 insertions(+), 151 deletions(-) create mode 100644 mas-modes-v0.1-spec/conformance/auth.json create mode 100644 mas_modes/conformance/auth.json create mode 100644 mas_modes/signing.py create mode 100644 tests/test_modes_auth.py diff --git a/SCOPE.md b/SCOPE.md index fa41fc9..2a8eb9a 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -81,12 +81,13 @@ delivery stays external. - Status: active. The reference experiment (MAS-WP-0002) passed its decision gate on 2026-10-18; the findings are in `docs/experiment-review.md`. -- Finished: MAS-WP-0001 to 0006, 0008 and 0010. Postfix profile, v0.1.1 +- Finished: MAS-WP-0001 to 0006, 0008, 0010 and 0011 (peer + authentication in the mode contract). Postfix profile, v0.1.1 errata, v0.2 draft, address composer, v0.2 conformance cases, and the mode contract with its reference implementation. - Open: MAS-WP-0007 (retire the live MX; backlog, kept by decision), MAS-WP-0009 (declare v0.2 final; waits for a second implementation), - and MAS-WP-0011 to 0013 (proposed). + and MAS-WP-0012 and 0013 (proposed). - v0.2 stays a draft (founder, 2026-10-19). - P2 is decided: `!MODE` requests a handoff (founder, 2026-10-19; SCP-8, v0.2 §7.1). The mode contract v0.1 draft and the reference @@ -94,12 +95,12 @@ delivery stays external. corpus. - The live MX runs image `mas-live:v0.2-a95f848` (redeployed 2026-10-19) and is kept up by founder decision (MAS-WP-0007 stays in backlog). - Open follow-ups: MAS-WP-0011 (contract transport security), 0012 (one - outbound path and cap), 0013 (harness-independent flows), 0009 - (second implementation, now covering both drafts). -- Tests: `.venv/bin/pytest` runs 242 tests, offline apart from loopback + Open follow-ups: MAS-WP-0012 (one outbound path and cap), 0013 + (harness-independent flows), 0009 (second implementation, now covering + both drafts). +- Tests: `.venv/bin/pytest` runs 249 tests, offline apart from loopback fixtures and the Docker probe. `mas test` runs four corpora: parser - 62, statute 16, v0.2 statute-aware 29, mode contract 172. + 62, statute 16, v0.2 statute-aware 29, mode contract 194. - Assessments: `history/` (latest: `2026-10-19T090725Z-scope-vs-intent.md`). ## Getting Oriented diff --git a/docs/reuse-surface.md b/docs/reuse-surface.md index 249fa8f..228c4fc 100644 --- a/docs/reuse-surface.md +++ b/docs/reuse-surface.md @@ -19,23 +19,29 @@ how stable each part is. ## Integrating as an outside master -Configure the resource type as `slave` (`mas_modes.email_mode.RoleConfig`) -and point `master_url` at your system. You then: +Configure the resource type as `slave` (`mas_modes.email_mode.RoleConfig`), +point `master_url` at your system, set `master_peer` to your peer id, and +exchange a key with the email operator. Every request in both directions is +signed (CONTRACT.md section 7; `mas_modes.signing`, standard library only). +You then: - receive `case.intake` and `case.message`, each carrying a gateway event; - answer `case.assign` with your own case id; email adopts it as `TYPE=ID@domain`; - send replies with `mail.send` (`in_reply_to` a recorded message); principle 10 can refuse them; -- decide per case on `autonomous_send` and `transfers` (`block`, or your - mode webhook); +- decide per case on `autonomous_send` and `transfers`: `block`, or a + mode webhook with that mode's `peer`. The peer must be one the email + operator has a key for; - own every timeout; end a handoff with `handoff.cancel`. ## Integrating a mode Implement the mode endpoint in CONTRACT.md: accept `handoff.request`, -`handoff.update` and `handoff.cancel`, and post `handoff.status` to -`status_url` with an increasing `seq`. Never send `timed-out`. +`handoff.update` and `handoff.cancel` only when signed by email, and post +`handoff.status`, signed with your peer id, to exactly the `status_url` of +that handoff (it carries a per-handoff token), with an increasing `seq`. +Never send `timed-out`. `mas_modes/stubs/callback.py` is a complete example. ## Examples only, not products @@ -44,6 +50,7 @@ Implement the mode endpoint in CONTRACT.md: accept `handoff.request`, store for email-mastered cases. - `mas_modes/stubs/`: the callback stub and the stub outside master. They never contact a person or a service. -- `mas_modes/email_mode.py`: the reference email side. It is - single-process, with SQLite and no authentication on its HTTP endpoints, - so it is fit for loopback and tests. The contract is the interface. +- `mas_modes/email_mode.py`: the reference email side. It authenticates + every peer (section 7), but it is single-process with SQLite, has no key + rotation, and serves plain HTTP. Put it behind TLS before exposing it + beyond loopback. The contract is the interface. diff --git a/mas-modes-v0.1-spec/CONTRACT.md b/mas-modes-v0.1-spec/CONTRACT.md index 10d6749..20cde98 100644 --- a/mas-modes-v0.1-spec/CONTRACT.md +++ b/mas-modes-v0.1-spec/CONTRACT.md @@ -89,7 +89,7 @@ The outside master owns the control flow, case identifiers and timeouts. Policy fields the master does not set take the email side's defaults for the resource type. 4. **Transfers.** The case policy sets `transfers` to `"block"` or to - `{"webhook": URL}`. With `block`, a `!MODE` request is reported as + `{"webhook": URL, "peer": ID}` (the peer must be known to email, §7.3). With `block`, a `!MODE` request is reported as `handoff.requested` with `"forwarded": false`, and nothing else happens. With a webhook, email sends `handoff.request` to that URL, relays every `handoff.status` to the master, and does the negotiation @@ -138,11 +138,11 @@ A receiver MUST refuse a transition that the table does not allow | `case.intake` | email → master | `intake_ref`, `resource_type`, `event` (gateway event), `requested_mode` | | `case.assign` | master → email | `intake_ref`, `resource_id`, optional `policy` (`{autonomous_send, transfers}`) | | `case.message` | email → master | `event`, `message_ref` | -| `case.policy` | master → email | `autonomous_send` (bool), `transfers` (`"block"` or `{"webhook": URL}`) | +| `case.policy` | master → email | `autonomous_send` (bool), `transfers` (`"block"` or `{"webhook": URL, "peer": ID}`) | | `handoff.requested` | email → master | `handoff_id`, `mode`, `forwarded` (bool) | | `mail.send` | master → email | `in_reply_to` (a `message_ref`), `subject`, `body` | -`event` is the normalized gateway event (`schema/mas-event-v0.1.schema.json`). +`status_url` contains the per-handoff token (§7.3). `event` is the normalized gateway event (`schema/mas-event-v0.1.schema.json`). `message_ref` is the email side's id for a recorded inbound message. `mail.send` responses: `{"ok": true, "sent": true}`, or `{"ok": false, @@ -158,10 +158,93 @@ sender. It MUST NOT send once its send cap is reached. These rules hold whoever asks for the send: email itself as master, the case's autonomous policy, or a master's `mail.send`. -## 7. Conformance +## 7. Peer authentication and transport security + +Contract messages steer real mail (`mail.send`, `case.assign`, +`case.policy`) and change what customers are told (`handoff.status`). +Every party MUST therefore know who sent a message before acting on it. + +### 7.1 Peers and keys + +Each party has a **peer id** (`email`, `cms-1`, `callback-1`, ...). +Every pair of parties that exchange messages shares a secret key of at +least 32 random octets. Keys are pairwise: email holds one key per master, +mode endpoint and gateway it talks to, and the other party holds the same +key for email. Keys are configuration, never part of a message. + +### 7.2 Signed requests (MUST) + +Every contract request MUST carry three HTTP headers. Gateway events +(gateway → email) MUST carry them when the gateway and the email side are +on different hosts. A deployment MAY leave them off for events on +loopback; the email side is then configured to accept unsigned events, +and only from loopback. + +| Header | Value | +|---|---| +| `MAS-Modes-Peer` | the sender's peer id | +| `MAS-Modes-Timestamp` | the time of sending, as integer Unix seconds | +| `MAS-Modes-Signature` | `v1=` followed by the lower-case hex HMAC-SHA256, keyed with the pair's key, of `TIMESTAMP + "." + BODY`, where BODY is the exact request body octets | + +The receiver MUST: + +1. look up the key for `MAS-Modes-Peer`. If the peer is unknown, refuse + with `401 unauthenticated-peer`; +2. recompute the signature over the received octets and compare it in + constant time. On a mismatch or missing header, refuse with + `401 unauthenticated-peer`; +3. refuse a timestamp more than 300 seconds from its own clock with + `401 replayed`; +4. only then parse and act on the body. A repeated message `id` inside + the window gets the original response (§2), so a captured request + cannot cause a second effect. + +Responses are not signed. They travel on the connection the signer opened. +Mutual TLS MAY be used in addition, but it does not replace signing: the +peer id and the authorization in §7.3 come from the signed headers. + +### 7.3 Authorization + +Authentication says who sent a message. Email then also checks that this +peer may send it: + +- **Master commands** (`case.assign`, `case.policy`, `mail.send`, + `handoff.cancel` to email) are accepted only from the peer configured + as master for the case's resource type (for `case.assign`, the intake's + resource type). Anyone else gets `403 wrong-peer`. +- **Mode statuses.** Each handoff is bound to the peer it was sent to. + `handoff.request` carries a `status_url` that contains an unguessable + per-handoff token (at least 128 bits). Email accepts a `handoff.status` + only at that URL, and only from that peer (`403 wrong-peer`). A + `handoff_id` alone is not enough. +- **Primed webhooks** (slave role). `transfers.webhook` MUST name the + receiving mode's `peer`, which MUST be a peer email already has a key + for. Otherwise `case.policy` and `case.assign` are refused with + `400 unknown-peer`. A master can therefore direct handoffs only to + modes the email operator has provisioned. It cannot make email sign + requests to an arbitrary URL with a key it chose. +- **Mode endpoints** accept `handoff.request`, `handoff.update` and + `handoff.cancel` only from email's peer id. +- **Masters** accept `case.*`, `handoff.requested` and relayed + `handoff.status` only from email's peer id. + +### 7.4 Errors + +| Code | HTTP | Meaning | +|---|---|---| +| `unauthenticated-peer` | 401 | unknown peer, missing or bad signature | +| `replayed` | 401 | timestamp outside the window | +| `wrong-peer` | 403 | authenticated, but not allowed to send this | +| `unknown-peer` | 400 | a webhook names a peer email has no key for | + +A sender MUST NOT retry `401` or `403` unchanged. + +## 8. Conformance `conformance/` holds valid and invalid messages, the transition table as data, and scripted flows. Each flow is a sequence of inbound mail, contract messages and clock ticks, with the expected email-side reaction. -An email-side implementation claims `MAS-MODES-EMAIL-MASTER` +`conformance/auth.json` holds signing vectors and verification cases for +section 7; every party MUST pass them. Flows `a-*` check authorization at +the email side. An email-side implementation claims `MAS-MODES-EMAIL-MASTER` or `MAS-MODES-EMAIL-SLAVE` by passing the flows for that role. diff --git a/mas-modes-v0.1-spec/README.md b/mas-modes-v0.1-spec/README.md index 77e26c9..9ab6f67 100644 --- a/mas-modes-v0.1-spec/README.md +++ b/mas-modes-v0.1-spec/README.md @@ -16,6 +16,7 @@ separately from the MAS address specification (`mas-v0.2-spec/`, §7). - `conformance/messages.json`: valid and invalid messages - `conformance/flows.json`: scripted flows for `MAS-MODES-EMAIL-MASTER` and `MAS-MODES-EMAIL-SLAVE` +- `conformance/auth.json`: signing vectors and verification cases (§7) - `conformance/statute.json`: the statute the flows run under The reference email side, the callback stub and the stub outside master are diff --git a/mas-modes-v0.1-spec/conformance/auth.json b/mas-modes-v0.1-spec/conformance/auth.json new file mode 100644 index 0000000..cd88520 --- /dev/null +++ b/mas-modes-v0.1-spec/conformance/auth.json @@ -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" + } + ] +} diff --git a/mas-modes-v0.1-spec/conformance/flows.json b/mas-modes-v0.1-spec/conformance/flows.json index 7473659..9c0a83a 100644 --- a/mas-modes-v0.1-spec/conformance/flows.json +++ b/mas-modes-v0.1-spec/conformance/flows.json @@ -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" + } + } + ] } ] } diff --git a/mas-modes-v0.1-spec/conformance/messages.json b/mas-modes-v0.1-spec/conformance/messages.json index c6d0334..05d6527 100644 --- a/mas-modes-v0.1-spec/conformance/messages.json +++ b/mas-modes-v0.1-spec/conformance/messages.json @@ -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" + } + } } ] } diff --git a/mas-modes-v0.1-spec/schema/mas-modes-v0.1.schema.json b/mas-modes-v0.1-spec/schema/mas-modes-v0.1.schema.json index b2c1de8..7a39e8a 100644 --- a/mas-modes-v0.1-spec/schema/mas-modes-v0.1.schema.json +++ b/mas-modes-v0.1-spec/schema/mas-modes-v0.1.schema.json @@ -670,12 +670,17 @@ { "type": "object", "required": [ - "webhook" + "webhook", + "peer" ], "properties": { "webhook": { "type": "string", "format": "uri" + }, + "peer": { + "type": "string", + "minLength": 1 } }, "additionalProperties": false diff --git a/mas_gateway/core.py b/mas_gateway/core.py index 732ac37..62130b7 100644 --- a/mas_gateway/core.py +++ b/mas_gateway/core.py @@ -150,9 +150,16 @@ class Delivery: response: Optional[object] = None -def post_event(event: dict, app_url: str, timeout: float = 10.0) -> Delivery: - req = urllib.request.Request(app_url, data=json.dumps(event).encode(), method="POST", - headers={"Content-Type": "application/json"}) +def post_event(event: dict, app_url: str, timeout: float = 10.0, + signer: Optional[tuple[str, bytes]] = None) -> Delivery: + """POST one event. `signer` (peer id, key) signs it as the mode contract + requires when the application is on another host (CONTRACT.md 7.2).""" + body = json.dumps(event).encode() + headers = {"Content-Type": "application/json"} + if signer is not None: + from mas_modes.signing import sign # only needed when signing + headers.update(sign(body, *signer)) + req = urllib.request.Request(app_url, data=body, method="POST", headers=headers) try: with urllib.request.urlopen(req, timeout=timeout) as resp: return Delivery(event["recipient"], resp.status, "delivered", _json(resp.read())) @@ -170,5 +177,6 @@ def _json(b: bytes): return b.decode(errors="replace") -def gateway(recipients: list[str], message: dict, resolver: Resolver, app_url: str) -> list[Delivery]: - return [post_event(build_event(r, message, resolver), app_url) for r in recipients] +def gateway(recipients: list[str], message: dict, resolver: Resolver, app_url: str, + signer: Optional[tuple[str, bytes]] = None) -> list[Delivery]: + return [post_event(build_event(r, message, resolver), app_url, signer=signer) for r in recipients] diff --git a/mas_gateway/main.py b/mas_gateway/main.py index d1e6a06..7a44c45 100644 --- a/mas_gateway/main.py +++ b/mas_gateway/main.py @@ -35,7 +35,7 @@ MAX_BODY = 25 * 1024 * 1024 def cmd_pipe(args, resolver) -> int: raw = sys.stdin.buffer.read() meta = message_meta_from_rfc822(raw, args.trusted_authserv_id) - deliveries = gateway(args.recipient, meta, resolver, args.app_url) + deliveries = gateway(args.recipient, meta, resolver, args.app_url, _signer(args)) for d in deliveries: print(json.dumps(d.__dict__), file=sys.stderr) return EX_TEMPFAIL if any(d.outcome == "tempfail" for d in deliveries) else 0 @@ -57,7 +57,8 @@ def webhook_message(body: bytes, ctype: str, query: dict, def make_webhook_server(listen: str, app_url: str, resolver: Resolver, - trusted_authserv_id: Optional[str] = None) -> ThreadingHTTPServer: + trusted_authserv_id: Optional[str] = None, + signer: Optional[tuple[str, bytes]] = None) -> ThreadingHTTPServer: host, port = listen.rsplit(":", 1) class Handler(BaseHTTPRequestHandler): @@ -74,7 +75,7 @@ def make_webhook_server(listen: str, app_url: str, resolver: Resolver, return self._reply(400, {"error": f"bad inbound payload: {e}"}) if not recipients: return self._reply(400, {"error": "no recipient"}) - deliveries = gateway(recipients, meta, resolver, app_url) + deliveries = gateway(recipients, meta, resolver, app_url, signer) status = 503 if any(d.outcome == "tempfail" for d in deliveries) else 200 self._reply(status, {"deliveries": [d.__dict__ for d in deliveries]}) @@ -92,6 +93,13 @@ def make_webhook_server(listen: str, app_url: str, resolver: Resolver, return ThreadingHTTPServer((host, int(port)), Handler) +def _signer(args): + if not args.sign_peer: + return None + with open(args.sign_key_file, "rb") as f: + return args.sign_peer, f.read().strip() + + def main(argv=None) -> int: ap = argparse.ArgumentParser(prog="mas-gateway", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) @@ -101,6 +109,10 @@ def main(argv=None) -> int: ap.add_argument("--trusted-authserv-id", metavar="ID", help="read sender authentication only from Authentication-Results with this id " "(our own MTA); without it, no verdict is reported") + ap.add_argument("--sign-peer", metavar="ID", + help="sign events as this peer (mode contract section 7); needed when the " + "application is not on loopback") + ap.add_argument("--sign-key-file", metavar="FILE", help="key shared with the application") sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("pipe") p.add_argument("--recipient", action="append", required=True) @@ -109,11 +121,14 @@ def main(argv=None) -> int: w.add_argument("--listen", default="127.0.0.1:8026") w.add_argument("--app-url", required=True) args = ap.parse_args(argv) + if bool(args.sign_peer) != bool(args.sign_key_file): + ap.error("--sign-peer and --sign-key-file go together") registry = json.load(open(args.registry)) if args.registry else None resolver = Resolver.with_statute_files(args.statute, local_registry=registry) if args.cmd == "pipe": return cmd_pipe(args, resolver) - srv = make_webhook_server(args.listen, args.app_url, resolver, args.trusted_authserv_id) + srv = make_webhook_server(args.listen, args.app_url, resolver, args.trusted_authserv_id, + _signer(args)) print(f"mas-gateway webhook on http://{args.listen} -> {args.app_url}", file=sys.stderr) srv.serve_forever() return 0 diff --git a/mas_modes/conformance/auth.json b/mas_modes/conformance/auth.json new file mode 100644 index 0000000..cd88520 --- /dev/null +++ b/mas_modes/conformance/auth.json @@ -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" + } + ] +} diff --git a/mas_modes/conformance/flows.json b/mas_modes/conformance/flows.json index 7473659..9c0a83a 100644 --- a/mas_modes/conformance/flows.json +++ b/mas_modes/conformance/flows.json @@ -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" + } + } + ] } ] } diff --git a/mas_modes/conformance/messages.json b/mas_modes/conformance/messages.json index c6d0334..05d6527 100644 --- a/mas_modes/conformance/messages.json +++ b/mas_modes/conformance/messages.json @@ -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" + } + } } ] } diff --git a/mas_modes/contract.py b/mas_modes/contract.py index 08d74fb..5c60610 100644 --- a/mas_modes/contract.py +++ b/mas_modes/contract.py @@ -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() diff --git a/mas_modes/email_mode.py b/mas_modes/email_mode.py index 240d97c..26c0c85 100644 --- a/mas_modes/email_mode.py +++ b/mas_modes/email_mode.py @@ -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) diff --git a/mas_modes/harness.py b/mas_modes/harness.py index 977d159..f2acab0 100644 --- a/mas_modes/harness.py +++ b/mas_modes/harness.py @@ -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): diff --git a/mas_modes/runner.py b/mas_modes/runner.py index d0bb866..3f48fe8 100644 --- a/mas_modes/runner.py +++ b/mas_modes/runner.py @@ -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: diff --git a/mas_modes/schema/mas-modes-v0.1.schema.json b/mas_modes/schema/mas-modes-v0.1.schema.json index b2c1de8..7a39e8a 100644 --- a/mas_modes/schema/mas-modes-v0.1.schema.json +++ b/mas_modes/schema/mas-modes-v0.1.schema.json @@ -670,12 +670,17 @@ { "type": "object", "required": [ - "webhook" + "webhook", + "peer" ], "properties": { "webhook": { "type": "string", "format": "uri" + }, + "peer": { + "type": "string", + "minLength": 1 } }, "additionalProperties": false diff --git a/mas_modes/signing.py b/mas_modes/signing.py new file mode 100644 index 0000000..5a82ce9 --- /dev/null +++ b/mas_modes/signing.py @@ -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 diff --git a/mas_modes/stubs/callback.py b/mas_modes/stubs/callback.py index 421af54..726d01e 100644 --- a/mas_modes/stubs/callback.py +++ b/mas_modes/stubs/callback.py @@ -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) diff --git a/mas_modes/stubs/master.py b/mas_modes/stubs/master.py index 5c92809..3347e42 100644 --- a/mas_modes/stubs/master.py +++ b/mas_modes/stubs/master.py @@ -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) diff --git a/tests/test_modes_auth.py b/tests/test_modes_auth.py new file mode 100644 index 0000000..7f78a61 --- /dev/null +++ b/tests/test_modes_auth.py @@ -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() diff --git a/tests/test_modes_master.py b/tests/test_modes_master.py index ef0f125..d19bd92 100644 --- a/tests/test_modes_master.py +++ b/tests/test_modes_master.py @@ -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 diff --git a/tests/test_modes_slave.py b/tests/test_modes_slave.py index cb67738..2745b54 100644 --- a/tests/test_modes_slave.py +++ b/tests/test_modes_slave.py @@ -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() == [] diff --git a/workplans/MAS-WP-0011-mode-contract-transport-security.md b/workplans/MAS-WP-0011-mode-contract-transport-security.md index 6f9f443..7f0058d 100644 --- a/workplans/MAS-WP-0011-mode-contract-transport-security.md +++ b/workplans/MAS-WP-0011-mode-contract-transport-security.md @@ -4,7 +4,7 @@ type: workplan title: "Mode contract: peer authentication and transport security" domain: communication repo: mail-address-semantics -status: proposed +status: finished owner: claude topic_slug: coulomb-social created: "2026-10-19" @@ -27,7 +27,7 @@ unauthenticated. This must be fixed before anyone integrates. ```task id: MAS-WP-0011-T01 -status: todo +status: done priority: high state_hub_task_id: "b6b866e0-eb9d-511b-85da-7423bc36f7eb" ``` @@ -48,7 +48,7 @@ Review with the founder before implementing. ```task id: MAS-WP-0011-T02 -status: todo +status: done priority: high state_hub_task_id: "57f7cb5b-7e32-54a7-925b-3938b4f98572" ``` @@ -58,3 +58,40 @@ Put the chosen scheme into `mas_modes.contract` (sign and verify), into (signed and unsigned messages, a replayed message, a wrong peer) and flows. Update `docs/reuse-surface.md`, which still says the endpoints are loopback-only. + +## Outcome + +- **Design** (CONTRACT.md §7). Reviewed by the founder on 2026-10-19: + HMAC-SHA256 is the MUST and mTLS is optional on top; the authorization + rules are as drafted; gateway events are signed when not on loopback + (optional per deployment). + - Pairwise keys. Three headers (`MAS-Modes-Peer`, `-Timestamp`, + `-Signature: v1=`) over `TIMESTAMP.BODY`, with a 300 s window. + - Errors `unauthenticated-peer` / `replayed` (401), `wrong-peer` (403), + `unknown-peer` (400). + - Master commands are accepted only from the master configured for the + resource type. + - Statuses are accepted only at the handoff's tokenized `status_url` + (192-bit token) and only from the peer the handoff was sent to. + - A primed webhook must name a peer email has a key for (schema: + `transfers.webhook` requires `peer`). +- **Code.** + - `mas_modes/signing.py` (standard library only). + - `contract.post` signs. `contract.json_server` authenticates before + parsing, with prefix routes for tokens and unsigned routes only from + loopback. + - `EmailMode` has keys, `event_peer` and `master_peer`, and checks that + every configured peer has a key at startup. + - Both stubs accept only email and sign their own requests. + - `mas-gateway --sign-peer/--sign-key-file`. +- **Fixtures and tests.** + - `conformance/auth.json`: 3 signing vectors, cross-checked against an + independent HMAC, and 11 verification cases. + - 7 authorization flows `a-001` to `a-007`, with a mutation check that + disabling the master check, the replay window or the webhook peer + check fails them. + - `tests/test_modes_auth.py` (6). `mas test`: mode corpus 194/194. +- **Live MX unaffected:** it runs the ticket service directly, and the + gateway posts unsigned on loopback. +- **Not covered:** key rotation and storage (deployment configuration), and + TLS for the reference email side. The reuse surface states both.