MAS-WP-0011 finished: peer authentication in mode contract v0.1
CONTRACT.md section 7 (founder-reviewed): pairwise HMAC-SHA256 over TIMESTAMP.BODY in MAS-Modes-* headers, 300 s window; master commands only from the configured master; statuses only at the handoff's tokenized URL from its peer; primed webhooks must name a known peer. mas_modes.signing, signed contract.post, authenticating json_server, EmailMode/stubs/gateway signing. conformance/auth.json vectors + 7 authorization flows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 5787@bnt-lap001 Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
This commit is contained in:
parent
2bc41a3318
commit
bdf200a600
25 changed files with 1603 additions and 151 deletions
15
SCOPE.md
15
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
190
mas-modes-v0.1-spec/conformance/auth.json
Normal file
190
mas-modes-v0.1-spec/conformance/auth.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -670,12 +670,17 @@
|
|||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"webhook"
|
||||
"webhook",
|
||||
"peer"
|
||||
],
|
||||
"properties": {
|
||||
"webhook": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"peer": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
190
mas_modes/conformance/auth.json
Normal file
190
mas_modes/conformance/auth.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
53
mas_modes/signing.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
83
tests/test_modes_auth.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Peer authentication (CONTRACT.md section 7, MAS-WP-0011)."""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from mas_modes import _load
|
||||
from mas_modes.contract import message, post
|
||||
from mas_modes.email_mode import EmailMode, RoleConfig
|
||||
from mas_modes.harness import ROOT, Harness
|
||||
|
||||
STATUTE = ROOT / "mas-modes-v0.1-spec/conformance/statute.json"
|
||||
|
||||
|
||||
def test_vectors_match_an_independent_hmac():
|
||||
for v in _load("conformance/auth.json")["vectors"]:
|
||||
mac = hmac.new(bytes.fromhex(v["key_hex"]), f"{v['timestamp']}.".encode() + v["body"].encode(),
|
||||
hashlib.sha256).hexdigest()
|
||||
assert v["signature"] == "v1=" + mac
|
||||
|
||||
|
||||
def test_signed_events_required_when_configured():
|
||||
h = Harness(STATUTE, {"ticket": {"role": "master"}}, sign_events=True)
|
||||
try:
|
||||
assert h.mail("ticket", "hello").status == 201 # the gateway signs
|
||||
h.signer = None
|
||||
d = h.mail("ticket", "unsigned")
|
||||
assert d.status == 401 and d.response["error"] == "unauthenticated-peer"
|
||||
h.signer = ("gateway", b"\0" * 32)
|
||||
assert h.mail("ticket", "bad key").status == 401
|
||||
finally:
|
||||
h.close()
|
||||
|
||||
|
||||
def test_unsigned_events_only_without_event_peer():
|
||||
h = Harness(STATUTE, {"ticket": {"role": "master"}})
|
||||
try:
|
||||
assert h.mail("ticket", "loopback, unsigned").status == 201
|
||||
h.signer = ("cms-1", h.keys["cms-1"]) # signed, but not by a gateway
|
||||
assert h.mail("ticket", "x").status == 403
|
||||
finally:
|
||||
h.close()
|
||||
|
||||
|
||||
def test_stubs_accept_only_email():
|
||||
h = Harness(STATUTE, {"case": {"role": "slave"}})
|
||||
try:
|
||||
req = message("handoff.request", {"address": "a@b", "key": "a"}, handoff_id="h", mode="callback",
|
||||
details={}, status_url="http://127.0.0.1:9/x")
|
||||
url = h.email.roles["case"].master_url
|
||||
assert post(url, message("case.intake", intake_ref="i", resource_type="case", event={}))[0] == 401
|
||||
assert post(url, message("case.intake", intake_ref="i", resource_type="case", event={}),
|
||||
("cms-1", h.keys["cms-1"]))[0] == 401 # the master's key is not email's identity
|
||||
mode_url = h.callback_url
|
||||
assert post(mode_url, req)[0] == 401
|
||||
assert post(mode_url, req, ("email", b"\1" * 32))[0] == 401
|
||||
assert post(mode_url, req, ("email", h.keys["callback-1"]))[0] == 202
|
||||
finally:
|
||||
h.close()
|
||||
|
||||
|
||||
def test_email_refuses_config_without_keys():
|
||||
with pytest.raises(ValueError, match="no key"):
|
||||
EmailMode({"case": RoleConfig("slave", master_url="http://x", master_peer="cms-1")}, keys={})
|
||||
with pytest.raises(ValueError, match="no key"):
|
||||
EmailMode({"case": RoleConfig("slave", master_url="http://x", master_peer="cms-1",
|
||||
transfers={"webhook": "http://y", "peer": "nobody"})},
|
||||
keys={"cms-1": b"k" * 32})
|
||||
|
||||
|
||||
def test_status_urls_are_unguessable_and_distinct():
|
||||
h = Harness(STATUTE, {"ticket": {"role": "master", "fields": ("phone", "time", "scenario")}},
|
||||
scenario="silent")
|
||||
try:
|
||||
h.mail("ticket!callback", "Phone: 1\nTime: 2")
|
||||
h.mail("ticket!callback", "Phone: 1\nTime: 2")
|
||||
urls = [m["status_url"] for m in h.callback.received]
|
||||
tokens = [u.rsplit("/", 1)[1] for u in urls]
|
||||
assert len(set(tokens)) == 2 and all(len(t) >= 22 for t in tokens) # >= 128 bits
|
||||
assert json.dumps(h.callback.received).count(tokens[0]) == 1 # not echoed elsewhere
|
||||
finally:
|
||||
h.close()
|
||||
|
|
@ -5,8 +5,7 @@ from mas_modes.contract import message, post
|
|||
from mas_modes.harness import Harness, ROOT
|
||||
|
||||
STATUTE = ROOT / "mas-modes-v0.1-spec/conformance/statute.json"
|
||||
ROLES = {"ticket": {"role": "master", "modes": {"callback": "{callback}"},
|
||||
"fields": ("phone", "time", "scenario"), "timeout": 3600}}
|
||||
ROLES = {"ticket": {"role": "master", "fields": ("phone", "time", "scenario"), "timeout": 3600}}
|
||||
DETAILS = "Phone: +49 30 1234567\nTime: tomorrow 10:00"
|
||||
|
||||
|
||||
|
|
@ -94,7 +93,8 @@ def test_mode_not_enabled_by_statute(h):
|
|||
|
||||
|
||||
def test_mode_endpoint_down():
|
||||
roles = {"ticket": {**ROLES["ticket"], "modes": {"callback": "http://127.0.0.1:9/handoffs"}}}
|
||||
roles = {"ticket": {**ROLES["ticket"],
|
||||
"modes": {"callback": {"url": "http://127.0.0.1:9/handoffs", "peer": "callback-1"}}}}
|
||||
h = Harness(STATUTE, roles)
|
||||
try:
|
||||
d = h.mail("ticket!callback", DETAILS)
|
||||
|
|
@ -120,27 +120,33 @@ def test_unauthenticated_sender_gets_no_mail(h):
|
|||
assert h.outbox() == [] # no creation reply, no question: principle 10
|
||||
|
||||
|
||||
def as_mode(h):
|
||||
"""Post as the callback stub does: signed, at the handoff's status URL."""
|
||||
url = h.callback.received[0]["status_url"]
|
||||
return lambda msg: post(url, msg, ("callback-1", h.keys["callback-1"]))
|
||||
|
||||
|
||||
def test_contract_guards(h):
|
||||
tid, hid = start(h, DETAILS + "\nScenario: completed")
|
||||
case = h.callback.run()[0]["case"]
|
||||
url = h.email.base_url + "/mode-status"
|
||||
send = as_mode(h)
|
||||
stale = message("handoff.status", case, handoff_id=hid, seq=1, status="running")
|
||||
assert post(url, stale) == (200, {"ok": True, "ignored": "stale"})
|
||||
code, body = post(url, message("handoff.status", case, handoff_id=hid, seq=99, status="timed-out"))
|
||||
assert send(stale) == (200, {"ok": True, "ignored": "stale"})
|
||||
code, body = send(message("handoff.status", case, handoff_id=hid, seq=99, status="timed-out"))
|
||||
assert code == 409 and body["error"] == "invalid-transition"
|
||||
code, body = post(url, message("handoff.status", case, handoff_id=hid, seq=99, status="negotiating",
|
||||
needs=["phone"]))
|
||||
code, body = send(message("handoff.status", case, handoff_id=hid, seq=99, status="negotiating",
|
||||
needs=["phone"]))
|
||||
assert code == 409 # completed is terminal
|
||||
code, body = post(url, message("handoff.status", case, handoff_id="nope", seq=1, status="running"))
|
||||
code, body = send(message("handoff.status", case, handoff_id="nope", seq=1, status="running"))
|
||||
assert code == 404
|
||||
code, body = post(url, {"type": "handoff.status"})
|
||||
code, body = send({"type": "handoff.status"})
|
||||
assert code == 400 and body["error"] == "schema"
|
||||
|
||||
|
||||
def test_repeated_message_id_is_processed_once(h):
|
||||
tid, hid = start(h, DETAILS + "\nScenario: silent")
|
||||
case = h.callback.received[0]["case"]
|
||||
url = h.email.base_url + "/mode-status"
|
||||
send = as_mode(h)
|
||||
msg = message("handoff.status", case, handoff_id=hid, seq=1, status="scheduled", at="x")
|
||||
assert post(url, msg) == post(url, msg) == (200, {"ok": True})
|
||||
assert send(msg) == send(msg) == (200, {"ok": True})
|
||||
assert h.thread(tid).count("callback: scheduled at=x") == 1
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ from mas_modes.harness import Harness, ROOT
|
|||
|
||||
STATUTE = ROOT / "mas-modes-v0.1-spec/conformance/statute.json"
|
||||
D = "modes.example"
|
||||
WEBHOOK = {"webhook": "{callback}", "peer": "callback-1"}
|
||||
|
||||
|
||||
def harness(**cfg):
|
||||
return Harness(STATUTE, {"case": {"role": "slave", "master_url": "{master}", **cfg}})
|
||||
return Harness(STATUTE, {"case": {"role": "slave", **cfg}})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -80,7 +81,7 @@ def test_principle_10_refuses_master_sends(h, kw, reason):
|
|||
|
||||
|
||||
def test_send_cap_and_unknown_message():
|
||||
h = Harness(STATUTE, {"case": {"role": "slave", "master_url": "{master}"}}, max_sent=0)
|
||||
h = Harness(STATUTE, {"case": {"role": "slave"}}, max_sent=0)
|
||||
try:
|
||||
body = intake(h)
|
||||
assert h.master.send(body["case"], body["message_ref"], "x")[1]["reason"] == "send-cap-reached"
|
||||
|
|
@ -110,7 +111,7 @@ def test_transfer_blocked_by_default(h):
|
|||
|
||||
|
||||
def test_transfer_to_primed_webhook():
|
||||
h = harness(transfers={"webhook": "{callback}"})
|
||||
h = harness(transfers=WEBHOOK)
|
||||
try:
|
||||
body = intake(h, local="case!callback", text="Phone: 123\nTime: now")
|
||||
assert h.master.of_type("handoff.requested")[0]["forwarded"] is True
|
||||
|
|
@ -138,7 +139,7 @@ def test_policy_from_assign_and_later_update(h):
|
|||
|
||||
def test_negotiation_only_when_autonomous():
|
||||
for autonomous, mails in ((False, 0), (True, 2)): # acknowledgement + question
|
||||
h = harness(transfers={"webhook": "{callback}"}, autonomous_send=autonomous)
|
||||
h = harness(transfers=WEBHOOK, autonomous_send=autonomous)
|
||||
try:
|
||||
h.callback.default = "negotiate"
|
||||
intake(h, local="case!callback", text="call me")
|
||||
|
|
@ -150,7 +151,7 @@ def test_negotiation_only_when_autonomous():
|
|||
|
||||
|
||||
def test_master_cancels_on_its_own_timeout():
|
||||
h = harness(transfers={"webhook": "{callback}"}, fields=("phone", "time", "scenario"))
|
||||
h = harness(transfers=WEBHOOK, fields=("phone", "time", "scenario"))
|
||||
try:
|
||||
body = intake(h, local="case!callback", text="Phone: 1\nTime: 2\nScenario: silent")
|
||||
assert h.callback.run() == []
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue