MAS-WP-0011 finished: peer authentication in mode contract v0.1

CONTRACT.md section 7 (founder-reviewed): pairwise HMAC-SHA256 over
TIMESTAMP.BODY in MAS-Modes-* headers, 300 s window; master commands only
from the configured master; statuses only at the handoff's tokenized URL
from its peer; primed webhooks must name a known peer. mas_modes.signing,
signed contract.post, authenticating json_server, EmailMode/stubs/gateway
signing. conformance/auth.json vectors + 7 authorization flows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5787@bnt-lap001
Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
This commit is contained in:
tegwick 2026-10-19 11:25:24 +02:00
parent 2bc41a3318
commit bdf200a600
25 changed files with 1603 additions and 151 deletions

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

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

View file

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

View file

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

View file

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

View file

@ -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]

View file

@ -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

View file

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

View file

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

View file

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

View file

@ -2,15 +2,17 @@
from __future__ import annotations
import ipaddress
import json
import threading
import urllib.error
import urllib.request
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Callable, Optional
from typing import Callable, Mapping, Optional
from . import CONTRACT_VERSION, transitions
from .signing import AuthError, sign, verify
_T = transitions()
TERMINAL = frozenset(_T["terminal"])
@ -29,10 +31,18 @@ def message(type_: str, case: Optional[dict] = None, **fields) -> dict:
return msg
def post(url: str, msg: dict, timeout: float = 10.0) -> tuple[Optional[int], object]:
"""POST a contract message. (None, error) when the peer is unreachable."""
req = urllib.request.Request(url, data=json.dumps(msg).encode(), method="POST",
headers={"Content-Type": "application/json"})
Signer = tuple[str, bytes] # (own peer id, key shared with the receiver)
def post(url: str, msg: dict, signer: Optional[Signer] = None,
timeout: float = 10.0) -> tuple[Optional[int], object]:
"""POST a contract message, signed when `signer` is given (CONTRACT.md 7.2).
Returns (None, error) when the peer is unreachable."""
body = json.dumps(msg).encode()
headers = {"Content-Type": "application/json"}
if signer is not None:
headers.update(sign(body, *signer))
req = urllib.request.Request(url, data=body, method="POST", headers=headers)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, json.loads(r.read().decode() or "null")
@ -68,20 +78,53 @@ class Idempotent:
return out
def json_server(routes: dict[str, Callable[[dict], tuple[int, dict]]], host="127.0.0.1",
port=0) -> ThreadingHTTPServer:
"""A loopback JSON-over-HTTP server: POST path -> handler(body) -> (status, body)."""
Route = Callable[[dict, Optional[str], str], tuple[int, dict]]
def _route(routes, path):
if path in routes:
return routes[path], path, ""
for key in sorted(routes, key=len, reverse=True):
if key.endswith("/") and path.startswith(key) and len(path) > len(key):
return routes[key], key, path[len(key):]
return None, None, ""
def _loopback(host: str) -> bool:
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def json_server(routes: dict[str, Route], keys: Optional[Mapping[str, bytes]] = None,
unsigned: frozenset = frozenset(), host="127.0.0.1", port=0) -> ThreadingHTTPServer:
"""JSON over HTTP: POST path -> handler(body, peer, rest) -> (status, body).
With `keys`, every request is authenticated before its body is parsed
(CONTRACT.md 7.2); `peer` is the verified peer id. Routes in `unsigned`
also accept requests without signature headers, from loopback only, with
peer None. A key ending in "/" matches by prefix and passes the rest of
the path (a per-handoff token) as `rest`."""
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
fn = routes.get(self.path)
fn, key, rest = _route(routes, self.path)
if fn is None:
return self._send(404, {"ok": False, "error": "not-found"})
peer = None
if keys is not None:
signed = any(h.lower().startswith("mas-modes-") for h in self.headers.keys())
if signed or key not in unsigned or not _loopback(self.client_address[0]):
try:
peer = verify(self.headers, body, keys)
except AuthError as e:
return self._send(e.status, {"ok": False, "error": e.code})
try:
data = json.loads(body.decode())
except (UnicodeDecodeError, json.JSONDecodeError):
return self._send(400, {"ok": False, "error": "invalid-json"})
self._send(*fn(data))
self._send(*fn(data, peer, rest))
def _send(self, status, obj):
out = json.dumps(obj).encode()

View file

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

View file

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

View file

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

View file

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

53
mas_modes/signing.py Normal file
View file

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

View file

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

View file

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

83
tests/test_modes_auth.py Normal file
View file

@ -0,0 +1,83 @@
"""Peer authentication (CONTRACT.md section 7, MAS-WP-0011)."""
import hashlib
import hmac
import json
import pytest
from mas_modes import _load
from mas_modes.contract import message, post
from mas_modes.email_mode import EmailMode, RoleConfig
from mas_modes.harness import ROOT, Harness
STATUTE = ROOT / "mas-modes-v0.1-spec/conformance/statute.json"
def test_vectors_match_an_independent_hmac():
for v in _load("conformance/auth.json")["vectors"]:
mac = hmac.new(bytes.fromhex(v["key_hex"]), f"{v['timestamp']}.".encode() + v["body"].encode(),
hashlib.sha256).hexdigest()
assert v["signature"] == "v1=" + mac
def test_signed_events_required_when_configured():
h = Harness(STATUTE, {"ticket": {"role": "master"}}, sign_events=True)
try:
assert h.mail("ticket", "hello").status == 201 # the gateway signs
h.signer = None
d = h.mail("ticket", "unsigned")
assert d.status == 401 and d.response["error"] == "unauthenticated-peer"
h.signer = ("gateway", b"\0" * 32)
assert h.mail("ticket", "bad key").status == 401
finally:
h.close()
def test_unsigned_events_only_without_event_peer():
h = Harness(STATUTE, {"ticket": {"role": "master"}})
try:
assert h.mail("ticket", "loopback, unsigned").status == 201
h.signer = ("cms-1", h.keys["cms-1"]) # signed, but not by a gateway
assert h.mail("ticket", "x").status == 403
finally:
h.close()
def test_stubs_accept_only_email():
h = Harness(STATUTE, {"case": {"role": "slave"}})
try:
req = message("handoff.request", {"address": "a@b", "key": "a"}, handoff_id="h", mode="callback",
details={}, status_url="http://127.0.0.1:9/x")
url = h.email.roles["case"].master_url
assert post(url, message("case.intake", intake_ref="i", resource_type="case", event={}))[0] == 401
assert post(url, message("case.intake", intake_ref="i", resource_type="case", event={}),
("cms-1", h.keys["cms-1"]))[0] == 401 # the master's key is not email's identity
mode_url = h.callback_url
assert post(mode_url, req)[0] == 401
assert post(mode_url, req, ("email", b"\1" * 32))[0] == 401
assert post(mode_url, req, ("email", h.keys["callback-1"]))[0] == 202
finally:
h.close()
def test_email_refuses_config_without_keys():
with pytest.raises(ValueError, match="no key"):
EmailMode({"case": RoleConfig("slave", master_url="http://x", master_peer="cms-1")}, keys={})
with pytest.raises(ValueError, match="no key"):
EmailMode({"case": RoleConfig("slave", master_url="http://x", master_peer="cms-1",
transfers={"webhook": "http://y", "peer": "nobody"})},
keys={"cms-1": b"k" * 32})
def test_status_urls_are_unguessable_and_distinct():
h = Harness(STATUTE, {"ticket": {"role": "master", "fields": ("phone", "time", "scenario")}},
scenario="silent")
try:
h.mail("ticket!callback", "Phone: 1\nTime: 2")
h.mail("ticket!callback", "Phone: 1\nTime: 2")
urls = [m["status_url"] for m in h.callback.received]
tokens = [u.rsplit("/", 1)[1] for u in urls]
assert len(set(tokens)) == 2 and all(len(t) >= 22 for t in tokens) # >= 128 bits
assert json.dumps(h.callback.received).count(tokens[0]) == 1 # not echoed elsewhere
finally:
h.close()

View file

@ -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

View file

@ -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() == []

View file

@ -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.