booking-agent-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- booking_agent/__init__.py +6 -0
- booking_agent/authorization.py +450 -0
- booking_agent/backend/__init__.py +217 -0
- booking_agent/backend/live.py +494 -0
- booking_agent/calendar/__init__.py +55 -0
- booking_agent/calendar/filter.py +41 -0
- booking_agent/calendar/google.py +582 -0
- booking_agent/calendar/oauth_listener.py +287 -0
- booking_agent/calendar/scope.py +97 -0
- booking_agent/circuit_breaker.py +116 -0
- booking_agent/config.py +51 -0
- booking_agent/defaults.env +50 -0
- booking_agent/discovery.py +188 -0
- booking_agent/headless.py +463 -0
- booking_agent/http_deadlines.py +43 -0
- booking_agent/http_retry.py +178 -0
- booking_agent/http_trace.py +428 -0
- booking_agent/merchant_pacing.py +73 -0
- booking_agent/models/__init__.py +14 -0
- booking_agent/models/platform.py +200 -0
- booking_agent/models/wire.py +868 -0
- booking_agent/namespace.py +53 -0
- booking_agent/observability.py +143 -0
- booking_agent/platform_profile.py +120 -0
- booking_agent/pop.py +347 -0
- booking_agent/profile_cache.py +130 -0
- booking_agent/registry_url.py +35 -0
- booking_agent/rest_client.py +1493 -0
- booking_agent/secure_file.py +52 -0
- booking_agent/settings.py +110 -0
- booking_agent/signals.py +130 -0
- booking_agent/step_timing.py +168 -0
- booking_agent/ucp_capabilities.py +148 -0
- booking_agent/ucp_checkout.py +1363 -0
- booking_agent/ucp_negotiation.py +184 -0
- booking_agent/ucp_profile.py +410 -0
- booking_agent/usp_methods.py +110 -0
- booking_agent_cli/__init__.py +3 -0
- booking_agent_cli/activity_log.py +106 -0
- booking_agent_cli/booking_poll.py +141 -0
- booking_agent_cli/booking_summary.py +151 -0
- booking_agent_cli/calendar_connection.py +196 -0
- booking_agent_cli/calendar_event.py +611 -0
- booking_agent_cli/calendar_preference.py +176 -0
- booking_agent_cli/catalog.py +864 -0
- booking_agent_cli/checkout_prereq.py +67 -0
- booking_agent_cli/commands/__init__.py +0 -0
- booking_agent_cli/commands/config.py +106 -0
- booking_agent_cli/commands/flow.py +6988 -0
- booking_agent_cli/commands/lowlevel.py +163 -0
- booking_agent_cli/commands/runtime.py +46 -0
- booking_agent_cli/config_loader.py +108 -0
- booking_agent_cli/engine.py +168 -0
- booking_agent_cli/flow_timings.py +34 -0
- booking_agent_cli/geo_eligibility.py +183 -0
- booking_agent_cli/geocode.py +233 -0
- booking_agent_cli/identity.py +104 -0
- booking_agent_cli/link_cli.py +620 -0
- booking_agent_cli/main.py +75 -0
- booking_agent_cli/output.py +199 -0
- booking_agent_cli/payment_helpers.py +884 -0
- booking_agent_cli/payment_token.py +23 -0
- booking_agent_cli/reschedule.py +137 -0
- booking_agent_cli/runtime.py +55 -0
- booking_agent_cli/runtime_prepare.py +129 -0
- booking_agent_cli/search_area.py +111 -0
- booking_agent_cli/session.py +837 -0
- booking_agent_cli/support_bundle_schema.py +557 -0
- booking_agent_cli/support_report.py +1210 -0
- booking_agent_cli/timezone_context.py +314 -0
- booking_agent_cli-0.1.0.dist-info/METADATA +75 -0
- booking_agent_cli-0.1.0.dist-info/RECORD +74 -0
- booking_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- booking_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""USP protocol namespace authority (specification Section 2.5).
|
|
2
|
+
|
|
3
|
+
Capability keys are ``dev.usp-protocol.*``. ``spec`` and ``schema`` URLs for
|
|
4
|
+
those keys MUST use origin ``https://usp-protocol.dev``.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
AUTHORITY_HOST = "usp-protocol.dev"
|
|
12
|
+
AUTHORITY_ORIGIN = f"https://{AUTHORITY_HOST}"
|
|
13
|
+
|
|
14
|
+
CAPABILITY_PREFIX = "dev.usp-protocol."
|
|
15
|
+
SVC_SERVICES = "dev.usp-protocol.services"
|
|
16
|
+
CAP_CATALOG = "dev.usp-protocol.services.catalog"
|
|
17
|
+
CAP_AVAILABILITY = "dev.usp-protocol.services.availability"
|
|
18
|
+
CAP_BOOKINGS = "dev.usp-protocol.services.bookings"
|
|
19
|
+
CAP_PAID_BOOKINGS = "dev.usp-protocol.services.paid_bookings"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NamespaceOriginError(ValueError):
|
|
23
|
+
"""Raised when a ``dev.usp-protocol.*`` key is bound to a non-authority origin."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _origin(url: str) -> str:
|
|
27
|
+
parsed = urlparse(url)
|
|
28
|
+
if parsed.scheme and parsed.netloc:
|
|
29
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
30
|
+
return ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def requires_authority_origin(key: str) -> bool:
|
|
34
|
+
return key == SVC_SERVICES or key.startswith(CAPABILITY_PREFIX)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def check_origin_binding(
|
|
38
|
+
key: str,
|
|
39
|
+
*,
|
|
40
|
+
spec: Optional[str] = None,
|
|
41
|
+
schema: Optional[str] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Enforce Section 2.5 origin binding when *spec* or *schema* is present."""
|
|
44
|
+
if not requires_authority_origin(key):
|
|
45
|
+
return
|
|
46
|
+
for label, url in (("spec", spec), ("schema", schema)):
|
|
47
|
+
if not url:
|
|
48
|
+
continue
|
|
49
|
+
origin = _origin(url)
|
|
50
|
+
if origin != AUTHORITY_ORIGIN:
|
|
51
|
+
raise NamespaceOriginError(
|
|
52
|
+
f"{key} {label} origin must be {AUTHORITY_ORIGIN!r}, got {url!r}"
|
|
53
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Flow correlation id propagation and machine-countable money-path events."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import uuid
|
|
7
|
+
from contextvars import ContextVar, Token
|
|
8
|
+
from typing import Any, Callable, Dict, Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
# Wire name for the flow join key. Optional on the USP HTTP binding (Track S of
|
|
13
|
+
# spec issue #160): send when bound, never require it on responses, never fail a
|
|
14
|
+
# call because a peer omitted or ignored it.
|
|
15
|
+
HEADER = "USP-Correlation-Id"
|
|
16
|
+
|
|
17
|
+
_FORBIDDEN_FIELD_KEYS = frozenset({
|
|
18
|
+
"token",
|
|
19
|
+
"credential",
|
|
20
|
+
"shared_payment_token",
|
|
21
|
+
"client_secret",
|
|
22
|
+
"access_token",
|
|
23
|
+
"refresh_token",
|
|
24
|
+
"email",
|
|
25
|
+
"phone",
|
|
26
|
+
"phone_number",
|
|
27
|
+
"first_name",
|
|
28
|
+
"last_name",
|
|
29
|
+
"name",
|
|
30
|
+
"address",
|
|
31
|
+
"authorization",
|
|
32
|
+
"dpop",
|
|
33
|
+
"spt",
|
|
34
|
+
"pan",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
_correlation_id: ContextVar[Optional[str]] = ContextVar("booking_agent_correlation_id", default=None)
|
|
38
|
+
_event_sink: Callable[[Dict[str, Any]], None] = lambda record: sys.stderr.write(
|
|
39
|
+
json.dumps(record, default=str) + "\n"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def set_event_sink(sink: Callable[[Dict[str, Any]], None]) -> None:
|
|
44
|
+
"""Replace the default stderr JSON event sink (tests)."""
|
|
45
|
+
global _event_sink
|
|
46
|
+
_event_sink = sink
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def reset_event_sink() -> None:
|
|
50
|
+
"""Restore the default stderr event sink."""
|
|
51
|
+
global _event_sink
|
|
52
|
+
_event_sink = lambda record: sys.stderr.write(json.dumps(record, default=str) + "\n")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def new_correlation_id() -> str:
|
|
56
|
+
return str(uuid.uuid4())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def bind(correlation_id: str) -> Token:
|
|
60
|
+
return _correlation_id.set(correlation_id)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def current() -> Optional[str]:
|
|
64
|
+
return _correlation_id.get()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def reset(token: Optional[Token] = None) -> None:
|
|
68
|
+
if token is not None:
|
|
69
|
+
_correlation_id.reset(token)
|
|
70
|
+
else:
|
|
71
|
+
_correlation_id.set(None)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _sanitize_fields(fields: Dict[str, Any]) -> Dict[str, Any]:
|
|
75
|
+
out: Dict[str, Any] = {}
|
|
76
|
+
for key, value in fields.items():
|
|
77
|
+
key_l = str(key).lower()
|
|
78
|
+
if key_l in _FORBIDDEN_FIELD_KEYS or "credential" in key_l or key_l.endswith("_token"):
|
|
79
|
+
continue
|
|
80
|
+
if isinstance(value, str) and ("?" in value and ("http://" in value or "https://" in value)):
|
|
81
|
+
continue
|
|
82
|
+
out[key] = value
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def emit_event(event: str, **fields: Any) -> None:
|
|
87
|
+
"""Emit one machine-countable JSON event line."""
|
|
88
|
+
record: Dict[str, Any] = {"event": event}
|
|
89
|
+
cid = current()
|
|
90
|
+
if cid:
|
|
91
|
+
record["correlation_id"] = cid
|
|
92
|
+
record.update(_sanitize_fields(fields))
|
|
93
|
+
_event_sink(record)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def registry_error_class(response: Optional[httpx.Response]) -> str:
|
|
97
|
+
"""Map registry HTTP failures to #171-style classes."""
|
|
98
|
+
if response is None:
|
|
99
|
+
return "upstream_failure"
|
|
100
|
+
status = response.status_code
|
|
101
|
+
if status in {401, 403}:
|
|
102
|
+
return "auth_rejection"
|
|
103
|
+
if status in {400, 422}:
|
|
104
|
+
return "malformed_request"
|
|
105
|
+
if status >= 500:
|
|
106
|
+
return "upstream_failure"
|
|
107
|
+
try:
|
|
108
|
+
body = response.json()
|
|
109
|
+
except Exception:
|
|
110
|
+
body = None
|
|
111
|
+
if isinstance(body, dict):
|
|
112
|
+
err_type = str(body.get("type") or "").lower()
|
|
113
|
+
if "auth" in err_type or "unauthorized" in err_type:
|
|
114
|
+
return "auth_rejection"
|
|
115
|
+
if "malformed" in err_type or "invalid" in err_type:
|
|
116
|
+
return "malformed_request"
|
|
117
|
+
if status >= 400:
|
|
118
|
+
return "unknown"
|
|
119
|
+
return "unknown"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def payment_decline_reason_class(status: int, body: str = "") -> str:
|
|
123
|
+
"""Classify UCP/processor declines for ``payment_declined`` events."""
|
|
124
|
+
lowered = (body or "").lower()
|
|
125
|
+
if status in {402, 409} or "declin" in lowered:
|
|
126
|
+
return "declined"
|
|
127
|
+
if status >= 500 or "processor" in lowered:
|
|
128
|
+
return "processor_error"
|
|
129
|
+
return "unknown"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def money_path_fields(
|
|
133
|
+
*,
|
|
134
|
+
spend_request_id: Optional[str] = None,
|
|
135
|
+
booking_id: Optional[str] = None,
|
|
136
|
+
) -> Dict[str, Any]:
|
|
137
|
+
"""Bounded join keys for money-path events (log fields, not metric labels)."""
|
|
138
|
+
fields: Dict[str, Any] = {}
|
|
139
|
+
if spend_request_id:
|
|
140
|
+
fields["spend_request_id"] = spend_request_id
|
|
141
|
+
if booking_id:
|
|
142
|
+
fields["booking_id"] = booking_id
|
|
143
|
+
return fields
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Canonical USP/UCP platform profile (embedded SoT for hosting + negotiation)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
# Canonical publish URL: https://profile.booking-agent.dev/platform-profile.json
|
|
7
|
+
DEFAULT_PLATFORM_PROFILE_URL = "https://profile.booking-agent.dev/platform-profile.json"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def platform_profile_url() -> str:
|
|
11
|
+
"""Public identity URL sent in both USP and UCP Agent headers.
|
|
12
|
+
|
|
13
|
+
``USP_AGENT_PROFILE_URL`` is the only override. Development may point it at
|
|
14
|
+
loopback, but that is not conformant with the RFC 6890 and DNS-rebinding
|
|
15
|
+
checks required of production businesses.
|
|
16
|
+
"""
|
|
17
|
+
from . import settings
|
|
18
|
+
|
|
19
|
+
return (settings.get("USP_AGENT_PROFILE_URL") or "").strip() or DEFAULT_PLATFORM_PROFILE_URL
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def platform_profile_doc() -> Dict[str, Any]:
|
|
23
|
+
"""Canonical platform profile document (single source of truth).
|
|
24
|
+
|
|
25
|
+
Generated bytes are published as ``platform-profile.json`` on the ``main``
|
|
26
|
+
branch of ``wix/booking-agent``. Keep this keyless (no ``keys`` / ``signing_keys`` /
|
|
27
|
+
``webhook_url``).
|
|
28
|
+
"""
|
|
29
|
+
return {
|
|
30
|
+
"usp": {
|
|
31
|
+
"version": "2026-08-14",
|
|
32
|
+
"capabilities": {
|
|
33
|
+
"dev.usp-protocol.services.catalog": [
|
|
34
|
+
{
|
|
35
|
+
"version": "2026-08-14",
|
|
36
|
+
"spec": "https://usp-protocol.dev/specification#3-service-catalog",
|
|
37
|
+
"schema": "https://usp-protocol.dev/schemas/services/catalog.json",
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"dev.usp-protocol.services.availability": [
|
|
41
|
+
{
|
|
42
|
+
"version": "2026-08-14",
|
|
43
|
+
"spec": "https://usp-protocol.dev/specification#4-availability",
|
|
44
|
+
"schema": "https://usp-protocol.dev/schemas/services/availability.json",
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"dev.usp-protocol.services.bookings": [
|
|
48
|
+
{
|
|
49
|
+
"version": "2026-08-14",
|
|
50
|
+
"spec": "https://usp-protocol.dev/specification#5-booking-lifecycle",
|
|
51
|
+
"schema": "https://usp-protocol.dev/schemas/services/booking.json",
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
"services": {
|
|
56
|
+
"dev.usp-protocol.services": [{"transport": "rest"}],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
"ucp": {
|
|
60
|
+
"version": "2026-08-25",
|
|
61
|
+
"supported_versions": ["2026-04-08", "2026-08-25"],
|
|
62
|
+
"services": {},
|
|
63
|
+
"capabilities": {
|
|
64
|
+
"dev.ucp.shopping.checkout": [
|
|
65
|
+
{
|
|
66
|
+
"version": "2026-08-25",
|
|
67
|
+
"spec": "https://ucp.dev/latest/specification/shopping/checkout/",
|
|
68
|
+
"schema": "https://ucp.dev/latest/schemas/shopping/checkout.json",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"version": "2026-04-08",
|
|
72
|
+
"spec": "https://ucp.dev/latest/specification/shopping/checkout/",
|
|
73
|
+
"schema": "https://ucp.dev/latest/schemas/shopping/checkout.json",
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
"dev.ucp.shopping.order": [
|
|
77
|
+
{
|
|
78
|
+
"version": "2026-08-25",
|
|
79
|
+
"spec": "https://ucp.dev/latest/specification/shopping/order",
|
|
80
|
+
"schema": "https://ucp.dev/latest/schemas/shopping/order.json",
|
|
81
|
+
}
|
|
82
|
+
],
|
|
83
|
+
"dev.usp-protocol.services.paid_bookings": [
|
|
84
|
+
{
|
|
85
|
+
"version": "2026-08-20",
|
|
86
|
+
"spec": "https://usp-protocol.dev/specification#7-ucp-native-mode",
|
|
87
|
+
"schema": "https://usp-protocol.dev/schemas/services/paid_bookings.json",
|
|
88
|
+
"extends": "dev.ucp.shopping.checkout",
|
|
89
|
+
}
|
|
90
|
+
],
|
|
91
|
+
"dev.ucp.common.payment.terms": [
|
|
92
|
+
{
|
|
93
|
+
"version": "2026-08-25",
|
|
94
|
+
"extends": [
|
|
95
|
+
"dev.ucp.shopping.checkout",
|
|
96
|
+
"dev.ucp.shopping.order",
|
|
97
|
+
],
|
|
98
|
+
"spec": "https://ucp.dev/latest/specification/payment/extensions/terms/",
|
|
99
|
+
"schema": "https://ucp.dev/2026-08-25/schemas/common/payment_terms.json",
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
"payment_handlers": {
|
|
104
|
+
"com.stripe.payments": [
|
|
105
|
+
{
|
|
106
|
+
"id": "stripe_payments",
|
|
107
|
+
"version": "2026-06-25",
|
|
108
|
+
"spec": (
|
|
109
|
+
"https://docs.stripe.com/agentic-commerce/"
|
|
110
|
+
"ucp/stripe-payments-handler"
|
|
111
|
+
),
|
|
112
|
+
"schema": "https://ucp.stripe.com/payments/2026-06-25/schema.json",
|
|
113
|
+
"available_instruments": [
|
|
114
|
+
{"type": "link"},
|
|
115
|
+
],
|
|
116
|
+
}
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
}
|
booking_agent/pop.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""``platform_key_pop`` -- proof-of-possession keys and proofs (USP Section 10.1.6).
|
|
2
|
+
|
|
3
|
+
The agent holds one Ed25519 keypair per business endpoint. The private half is
|
|
4
|
+
never transmitted; the public half travels only inside the JOSE header of each
|
|
5
|
+
proof. The business computes the RFC 7638 thumbprint of that key and binds the
|
|
6
|
+
issued ``booking_scoped_credential`` to it as ``cnf.jkt``, so a credential value
|
|
7
|
+
that leaks -- via a log, a trace, a TLS-terminating proxy, or a database
|
|
8
|
+
compromise -- is useless without the key.
|
|
9
|
+
|
|
10
|
+
What this module is deliberately *not*:
|
|
11
|
+
|
|
12
|
+
* It is not RFC 9421 request signing. No ``Content-Digest``, no signature base,
|
|
13
|
+
no covered components.
|
|
14
|
+
* It publishes nothing. The platform profile stays keyless and shared; there
|
|
15
|
+
are no ``keys``/``signing_keys``, no rotation, and no per-instance profiles.
|
|
16
|
+
* The key is not a platform identity. It is per-endpoint and self-generated, so
|
|
17
|
+
it identifies "the holder of this credential", nothing more.
|
|
18
|
+
|
|
19
|
+
"Ephemeral" here means *never published and never a platform identity* -- not
|
|
20
|
+
short-lived. The key must outlive every credential bound to it, or those
|
|
21
|
+
credentials become unusable; see ``PopKeyStore.release``.
|
|
22
|
+
|
|
23
|
+
Only the REST binding is implemented. The MCP binding (``typ: usp-pop+jwt``
|
|
24
|
+
with ``usp_m``/``aud``/``usp_p`` over JCS-canonicalized params) is specified but
|
|
25
|
+
has no client anywhere -- see issue #199. REST proofs carry the latest
|
|
26
|
+
``DPoP-Nonce`` from any HTTP response; MCP, when built, must use
|
|
27
|
+
``nonce_from_pop_error_body`` from ``authorization``.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import base64
|
|
32
|
+
import hashlib
|
|
33
|
+
import json
|
|
34
|
+
import logging
|
|
35
|
+
import time
|
|
36
|
+
import uuid
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, Dict, Optional
|
|
40
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
41
|
+
|
|
42
|
+
import httpx
|
|
43
|
+
from cryptography.hazmat.primitives import serialization
|
|
44
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
45
|
+
Ed25519PrivateKey,
|
|
46
|
+
Ed25519PublicKey,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
from . import settings
|
|
50
|
+
from .authorization import nonce_from_pop_error_body
|
|
51
|
+
from .secure_file import ensure_private_dir, write_private_text
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
_KEYSTORE_FILE_NAME = "pop_keys.json"
|
|
56
|
+
_STORE_VERSION = 1
|
|
57
|
+
|
|
58
|
+
#: The only algorithm this client emits. USP Section 10.1.6 makes EdDSA (Ed25519) the
|
|
59
|
+
#: floor that any business advertising ``platform_key_pop`` MUST support, chosen
|
|
60
|
+
#: to avoid the ECDSA fixed-width ``r||s`` vs DER interoperability trap.
|
|
61
|
+
ALG = "EdDSA"
|
|
62
|
+
|
|
63
|
+
#: REST proofs are standard RFC 9449 DPoP proofs. The MCP binding uses
|
|
64
|
+
#: ``usp-pop+jwt`` precisely so neither proof can be replayed as the other.
|
|
65
|
+
REST_PROOF_TYP = "dpop+jwt"
|
|
66
|
+
|
|
67
|
+
MECHANISM = "platform_key_pop"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def b64url(raw: bytes) -> str:
|
|
71
|
+
"""base64url without padding, as every JOSE and RFC 7638 value is encoded."""
|
|
72
|
+
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def compute_jkt(public_jwk: Dict[str, str]) -> str:
|
|
76
|
+
"""RFC 7638 JWK SHA-256 thumbprint of an OKP/Ed25519 public key.
|
|
77
|
+
|
|
78
|
+
The hash input is the *required* members only, lexicographically ordered
|
|
79
|
+
(``crv``, ``kty``, ``x``), serialized with no whitespace. Any other member --
|
|
80
|
+
``alg``, ``kid``, ``use`` -- must be excluded or the thumbprint will not
|
|
81
|
+
match what the business computes.
|
|
82
|
+
"""
|
|
83
|
+
required = {
|
|
84
|
+
"crv": public_jwk["crv"],
|
|
85
|
+
"kty": public_jwk["kty"],
|
|
86
|
+
"x": public_jwk["x"],
|
|
87
|
+
}
|
|
88
|
+
canonical = json.dumps(required, separators=(",", ":"), sort_keys=True)
|
|
89
|
+
jkt = b64url(hashlib.sha256(canonical.encode("utf-8")).digest())
|
|
90
|
+
# schemas/usp.json#/$defs/ConfirmationKey pins this to exactly 43 chars.
|
|
91
|
+
if len(jkt) != 43:
|
|
92
|
+
raise ValueError(f"computed jkt has length {len(jkt)}, expected 43")
|
|
93
|
+
return jkt
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def credential_ath(credential_value: str) -> str:
|
|
97
|
+
"""RFC 9449 ``ath``: base64url(SHA-256(credential value)).
|
|
98
|
+
|
|
99
|
+
Binds a proof to the specific credential presented, so a proof captured
|
|
100
|
+
alongside one credential cannot be paired with another.
|
|
101
|
+
"""
|
|
102
|
+
return b64url(hashlib.sha256(credential_value.encode("utf-8")).digest())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def normalize_htu(url: str) -> str:
|
|
106
|
+
"""RFC 9449 ``htu``: the request URI without query or fragment."""
|
|
107
|
+
parts = urlsplit(url)
|
|
108
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class PopKey:
|
|
113
|
+
"""An Ed25519 keypair and its derived public JWK and thumbprint."""
|
|
114
|
+
|
|
115
|
+
private_key: Ed25519PrivateKey
|
|
116
|
+
public_jwk: Dict[str, str]
|
|
117
|
+
jkt: str
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def generate(cls) -> "PopKey":
|
|
121
|
+
return cls.from_private_bytes(Ed25519PrivateKey.generate())
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def from_private_bytes(cls, private_key: Ed25519PrivateKey) -> "PopKey":
|
|
125
|
+
public_jwk = _public_jwk(private_key.public_key())
|
|
126
|
+
return cls(
|
|
127
|
+
private_key=private_key,
|
|
128
|
+
public_jwk=public_jwk,
|
|
129
|
+
jkt=compute_jkt(public_jwk),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_seed_b64(cls, seed_b64: str) -> "PopKey":
|
|
134
|
+
raw = base64.urlsafe_b64decode(seed_b64 + "=" * (-len(seed_b64) % 4))
|
|
135
|
+
return cls.from_private_bytes(Ed25519PrivateKey.from_private_bytes(raw))
|
|
136
|
+
|
|
137
|
+
def seed_b64(self) -> str:
|
|
138
|
+
raw = self.private_key.private_bytes(
|
|
139
|
+
encoding=serialization.Encoding.Raw,
|
|
140
|
+
format=serialization.PrivateFormat.Raw,
|
|
141
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
142
|
+
)
|
|
143
|
+
return b64url(raw)
|
|
144
|
+
|
|
145
|
+
def sign(self, message: bytes) -> bytes:
|
|
146
|
+
return self.private_key.sign(message)
|
|
147
|
+
|
|
148
|
+
def __repr__(self) -> str: # pragma: no cover - defensive
|
|
149
|
+
# Never let the private key reach a log line or a traceback.
|
|
150
|
+
return f"PopKey(jkt={self.jkt!r})"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _public_jwk(public_key: Ed25519PublicKey) -> Dict[str, str]:
|
|
154
|
+
raw = public_key.public_bytes(
|
|
155
|
+
encoding=serialization.Encoding.Raw,
|
|
156
|
+
format=serialization.PublicFormat.Raw,
|
|
157
|
+
)
|
|
158
|
+
return {"kty": "OKP", "crv": "Ed25519", "x": b64url(raw)}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def build_rest_proof(
|
|
162
|
+
key: PopKey,
|
|
163
|
+
*,
|
|
164
|
+
htm: str,
|
|
165
|
+
htu: str,
|
|
166
|
+
ath: Optional[str] = None,
|
|
167
|
+
nonce: Optional[str] = None,
|
|
168
|
+
iat: Optional[int] = None,
|
|
169
|
+
jti: Optional[str] = None,
|
|
170
|
+
) -> str:
|
|
171
|
+
"""Build an RFC 9449 ``dpop+jwt`` proof as a compact JWS.
|
|
172
|
+
|
|
173
|
+
``ath`` is absent at issuance (there is no credential yet) and REQUIRED at
|
|
174
|
+
presentation. ``jti`` is fresh per *attempt*, not per logical call -- a
|
|
175
|
+
retried request must carry a new one or the business's replay cache will
|
|
176
|
+
reject it as ``pop_proof_replayed``.
|
|
177
|
+
|
|
178
|
+
Hand-rolled rather than pulled from a JOSE library: the header is fixed, the
|
|
179
|
+
claim set is six fields, and a library would add a dependency whose
|
|
180
|
+
algorithm-negotiation defaults would need auditing to guarantee we never
|
|
181
|
+
emit ``alg: none``.
|
|
182
|
+
"""
|
|
183
|
+
header = {
|
|
184
|
+
"typ": REST_PROOF_TYP,
|
|
185
|
+
"alg": ALG,
|
|
186
|
+
# The public JWK travels here and nowhere else. It carries no private
|
|
187
|
+
# member ('d'), which a business MUST reject.
|
|
188
|
+
"jwk": key.public_jwk,
|
|
189
|
+
}
|
|
190
|
+
claims: Dict[str, Any] = {
|
|
191
|
+
"htm": htm.upper(),
|
|
192
|
+
"htu": normalize_htu(htu),
|
|
193
|
+
"iat": int(time.time()) if iat is None else iat,
|
|
194
|
+
"jti": jti or str(uuid.uuid4()),
|
|
195
|
+
}
|
|
196
|
+
if ath is not None:
|
|
197
|
+
claims["ath"] = ath
|
|
198
|
+
if nonce is not None:
|
|
199
|
+
claims["nonce"] = nonce
|
|
200
|
+
|
|
201
|
+
signing_input = (
|
|
202
|
+
b64url(json.dumps(header, separators=(",", ":")).encode("utf-8"))
|
|
203
|
+
+ "."
|
|
204
|
+
+ b64url(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
|
|
205
|
+
)
|
|
206
|
+
signature = b64url(key.sign(signing_input.encode("ascii")))
|
|
207
|
+
return f"{signing_input}.{signature}"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def usp_error_payload(response: httpx.Response) -> Dict[str, Any]:
|
|
211
|
+
"""Return the ``error`` object of a USP error body, or the body itself."""
|
|
212
|
+
try:
|
|
213
|
+
body = response.json()
|
|
214
|
+
except Exception:
|
|
215
|
+
return {}
|
|
216
|
+
if not isinstance(body, dict):
|
|
217
|
+
return {}
|
|
218
|
+
error = body.get("error")
|
|
219
|
+
return error if isinstance(error, dict) else body
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def dpop_nonce_from_response(response: httpx.Response) -> Optional[str]:
|
|
223
|
+
"""Return a non-empty ``DPoP-Nonce`` response header, if present.
|
|
224
|
+
|
|
225
|
+
Any response may rotate the nonce; an empty or missing header does not clear
|
|
226
|
+
the value already held.
|
|
227
|
+
"""
|
|
228
|
+
nonce = response.headers.get("DPoP-Nonce")
|
|
229
|
+
return nonce if isinstance(nonce, str) and nonce else None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def nonce_challenge_from_401(response: httpx.Response) -> Optional[str]:
|
|
233
|
+
"""Return the nonce a business is challenging for on HTTP 401.
|
|
234
|
+
|
|
235
|
+
REST (RFC 9449 / USP Section 10.1.6): ``DPoP-Nonce`` header first. JSON
|
|
236
|
+
``proof_nonce_required`` / ``data.nonce`` is the MCP channel and REST
|
|
237
|
+
fallback when the header is absent.
|
|
238
|
+
"""
|
|
239
|
+
header_nonce = dpop_nonce_from_response(response)
|
|
240
|
+
if header_nonce is not None:
|
|
241
|
+
return header_nonce
|
|
242
|
+
return nonce_from_pop_error_body(usp_error_payload(response))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def keystore_path() -> Path:
|
|
246
|
+
base = settings.booking_agent_home()
|
|
247
|
+
ensure_private_dir(base)
|
|
248
|
+
return base / _KEYSTORE_FILE_NAME
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def endpoint_key(endpoint: str) -> str:
|
|
252
|
+
"""Normalize a REST endpoint to the origin used to key the store.
|
|
253
|
+
|
|
254
|
+
Matches ``USPTokenStore._base_url`` so a business is one principal
|
|
255
|
+
regardless of which path the client happened to resolve.
|
|
256
|
+
"""
|
|
257
|
+
parsed = urlsplit(endpoint)
|
|
258
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class PopKeyStore:
|
|
262
|
+
"""Per-business-endpoint Ed25519 keys, persisted 0600.
|
|
263
|
+
|
|
264
|
+
The key is per *endpoint*; credentials are per *resource*. That asymmetry is
|
|
265
|
+
the reason ``release`` is refcounted rather than a plain delete -- see its
|
|
266
|
+
docstring.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
def __init__(self, path: Optional[Path] = None):
|
|
270
|
+
self._path = path or keystore_path()
|
|
271
|
+
self._entries: Dict[str, Dict[str, Any]] = {}
|
|
272
|
+
self._load()
|
|
273
|
+
|
|
274
|
+
def _load(self) -> None:
|
|
275
|
+
if not self._path.is_file():
|
|
276
|
+
return
|
|
277
|
+
try:
|
|
278
|
+
data = json.loads(self._path.read_text())
|
|
279
|
+
except (OSError, json.JSONDecodeError):
|
|
280
|
+
logger.warning("Failed to read PoP keystore at %s", self._path, exc_info=True)
|
|
281
|
+
return
|
|
282
|
+
if not isinstance(data, dict) or data.get("version") != _STORE_VERSION:
|
|
283
|
+
return
|
|
284
|
+
entries = data.get("keys")
|
|
285
|
+
if isinstance(entries, dict):
|
|
286
|
+
self._entries = entries
|
|
287
|
+
|
|
288
|
+
def _save(self) -> None:
|
|
289
|
+
payload = {"version": _STORE_VERSION, "keys": self._entries}
|
|
290
|
+
try:
|
|
291
|
+
write_private_text(self._path, json.dumps(payload, indent=2))
|
|
292
|
+
except OSError:
|
|
293
|
+
logger.warning("Failed to persist PoP keystore to %s", self._path, exc_info=True)
|
|
294
|
+
|
|
295
|
+
def get_or_create(self, endpoint: str) -> PopKey:
|
|
296
|
+
"""Return the key for *endpoint*, generating one on first contact.
|
|
297
|
+
|
|
298
|
+
This is the whole of platform onboarding under ``platform_key_pop``:
|
|
299
|
+
local, with no network call and no registration round trip.
|
|
300
|
+
"""
|
|
301
|
+
slot = endpoint_key(endpoint)
|
|
302
|
+
entry = self._entries.get(slot)
|
|
303
|
+
if entry and entry.get("seed"):
|
|
304
|
+
try:
|
|
305
|
+
return PopKey.from_seed_b64(entry["seed"])
|
|
306
|
+
except Exception:
|
|
307
|
+
logger.warning(
|
|
308
|
+
"Unreadable PoP key for %s; regenerating. Credentials bound to "
|
|
309
|
+
"the old key can no longer be used.", slot, exc_info=True,
|
|
310
|
+
)
|
|
311
|
+
key = PopKey.generate()
|
|
312
|
+
self._entries[slot] = {"seed": key.seed_b64(), "jkt": key.jkt}
|
|
313
|
+
self._save()
|
|
314
|
+
return key
|
|
315
|
+
|
|
316
|
+
def peek(self, endpoint: str) -> Optional[PopKey]:
|
|
317
|
+
"""Return the existing key for *endpoint*, or None. Never generates."""
|
|
318
|
+
entry = self._entries.get(endpoint_key(endpoint))
|
|
319
|
+
if not entry or not entry.get("seed"):
|
|
320
|
+
return None
|
|
321
|
+
try:
|
|
322
|
+
return PopKey.from_seed_b64(entry["seed"])
|
|
323
|
+
except Exception:
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
def release(self, endpoint: str, *, still_in_use: bool) -> bool:
|
|
327
|
+
"""Drop the key for *endpoint* once nothing is bound to it any more.
|
|
328
|
+
|
|
329
|
+
Called when a resource reaches a terminal state. *still_in_use* must be
|
|
330
|
+
True if any other non-expired credential issued by that business remains
|
|
331
|
+
in the session store.
|
|
332
|
+
|
|
333
|
+
Destroying the key unconditionally would be a data-loss bug rather than
|
|
334
|
+
hygiene: with two live bookings at one business, cancelling the first
|
|
335
|
+
would permanently orphan the second, whose credential is bound to a
|
|
336
|
+
``jkt`` whose private half no longer exists. USP defines no re-issuance
|
|
337
|
+
path for that, so the booking could never be cancelled from the agent
|
|
338
|
+
again.
|
|
339
|
+
|
|
340
|
+
Returns True if the key was removed.
|
|
341
|
+
"""
|
|
342
|
+
slot = endpoint_key(endpoint)
|
|
343
|
+
if still_in_use or slot not in self._entries:
|
|
344
|
+
return False
|
|
345
|
+
del self._entries[slot]
|
|
346
|
+
self._save()
|
|
347
|
+
return True
|