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
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue