blun-king-cli 9.1.597 → 9.1.600
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.
- package/CHANGELOG.md +39 -0
- package/README.md +17 -1
- package/agent-spine-plugin/CHANGELOG.md +1 -1
- package/agent-spine-plugin/docs/host-integration.md +1 -15
- package/agent-spine-plugin/docs/preflight-recall.md +1 -1
- package/agent-spine-plugin/scripts/check-install.js +1 -6
- package/agent-spine-plugin/scripts/hermetic-process.js +8 -0
- package/agent-spine-plugin/scripts/release-check.js +2 -3
- package/agent-spine-plugin/scripts/run-tests-hermetic.js +8 -11
- package/agent-spine-plugin/src/hook.js +40 -40
- package/agent-spine-plugin/src/lib/briefing.js +6 -3
- package/agent-spine-plugin/src/lib/hook-context.js +63 -64
- package/agent-spine-plugin/src/lib/hook-output.js +115 -94
- package/agent-spine-plugin/src/lib/hook-timeline.js +20 -11
- package/agent-spine-plugin/src/lib/host-instruction-budget.js +8 -14
- package/agent-spine-plugin/src/lib/mcp-timeline-tools.js +0 -7
- package/agent-spine-plugin/src/lib/pre-answer-timeline-recall.js +104 -0
- package/agent-spine-plugin/src/lib/session-timeline-codex.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-enrollment-source.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline-enrollment-storage.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-enrollment-transport.js +0 -2
- package/agent-spine-plugin/src/lib/session-timeline-enrollment.js +0 -22
- package/agent-spine-plugin/src/lib/session-timeline-host-origin.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-prior.js +42 -50
- package/agent-spine-plugin/src/lib/session-timeline-provider.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline-query.js +0 -1
- package/agent-spine-plugin/src/lib/session-timeline-sid-acl.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-source.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline-transport.js +0 -8
- package/agent-spine-plugin/src/lib/session-timeline-windows-acl.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline.js +370 -408
- package/agent-spine-plugin/src/lib/source-roots.js +26 -16
- package/agent-spine-plugin/src/lib/timeline-user-feedback.js +0 -1
- package/bin/core-bootstrap.js +18 -10
- package/bin/launcher-mode.js +5 -0
- package/bin/launcher-runtime.js +27 -0
- package/bin/verify-bundled-agent-sources.cjs +28 -2
- package/blun.mjs +798 -143
- package/bundled-agent-sources.json +192 -155
- package/package.json +1 -1
- package/standard-skills/translate-native/README.md +1334 -1
- package/standard-skills/translate-native/SKILL.md +1 -1
- package/standard-skills/translate-native/VERSION +1 -1
- package/standard-skills/translate-native/docs/PREMORTEM.md +901 -0
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION.md +208 -41
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_API.md +1319 -77
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_EVIDENCE_HTTP.md +66 -11
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_HTTP_PROVIDER.md +19 -1
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_RECEIPT_VERIFIER_HTTP.md +24 -6
- package/standard-skills/translate-native/integrations/commercial_localization_profile.py +23 -0
- package/standard-skills/translate-native/integrations/website_localization.py +234 -10
- package/standard-skills/translate-native/integrations/website_localization_benchmark.py +39 -3
- package/standard-skills/translate-native/integrations/website_localization_cms.py +512 -10
- package/standard-skills/translate-native/integrations/website_localization_cms_client.py +116 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver.py +101 -11
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver_runtime.py +12 -1
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver_store.py +186 -24
- package/standard-skills/translate-native/integrations/website_localization_cms_source_client.py +64 -1
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery.py +528 -5
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_auth.py +918 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_auth_runtime.py +609 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_client.py +885 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_http.py +1244 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_runtime.py +220 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_sidecar_adapter.py +331 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_capabilities_http.py +444 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_client.py +673 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch.py +1317 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_client.py +826 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_http.py +1152 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_openapi.py +896 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_runtime.py +621 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_http.py +1180 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_runtime.py +1479 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_http.py +133 -17
- package/standard-skills/translate-native/integrations/website_localization_cms_source_runtime.py +319 -4
- package/standard-skills/translate-native/integrations/website_localization_cms_source_service.py +72 -2
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_http.py +13 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver.py +56 -13
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver_openapi.py +335 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver_runtime.py +66 -1
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_processing_monitor.py +73 -7
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_receiver_client.py +93 -9
- package/standard-skills/translate-native/integrations/website_localization_evidence_http.py +221 -11
- package/standard-skills/translate-native/integrations/website_localization_health.py +6 -0
- package/standard-skills/translate-native/integrations/website_localization_http_provider.py +1 -1
- package/standard-skills/translate-native/integrations/website_localization_quality_profiles.py +350 -0
- package/standard-skills/translate-native/integrations/website_localization_queue.py +126 -56
- package/standard-skills/translate-native/integrations/website_localization_receipt_verifier_http.py +166 -13
- package/standard-skills/translate-native/integrations/website_localization_release.py +423 -17
- package/standard-skills/translate-native/integrations/website_localization_release_coordinator.py +168 -13
- package/standard-skills/translate-native/integrations/website_localization_runner.py +10 -0
- package/standard-skills/translate-native/integrations/website_localization_worker.py +213 -9
- package/standard-skills/translate-native/provenance.json +2 -2
- package/standard-skills/translate-native/references/commercial-localization.md +96 -17
- package/standard-skills/translate-native/scripts/blun_language_guard.py +3 -1
- package/standard-skills/translate-native/scripts/check_commercial_review.py +69 -9
- package/standard-skills/translate-native/scripts/commercial_localization_profile.py +829 -31
- package/standard-tools/language-guard/VERSION +1 -1
- package/standard-tools/language-guard/blun_language_guard.py +3 -1
- package/standard-tools/language-guard/check_commercial_review.py +69 -9
- package/standard-tools/language-guard/commercial_localization_profile.py +829 -31
- package/standard-tools/language-guard/provenance.json +2 -2
- package/standard-tools/manifest.json +1 -1
- package/worker-host.mjs +215 -82
- package/agent-spine-plugin/scripts/check-install-king.js +0 -37
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Rotatable HMAC authentication for the source-delivery sidecar.
|
|
3
|
+
|
|
4
|
+
The client signer and server verifier bind the exact request, tenant identity,
|
|
5
|
+
idempotency fields, and both deployed capability contracts. A durable nonce
|
|
6
|
+
store rejects replay before the sidecar parses or persists website content.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import importlib.util
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import secrets
|
|
19
|
+
import sqlite3
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import unicodedata
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Callable, Iterable, Mapping
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
PROOF_SCHEMA = "blun.cms-source-delivery-sidecar-hmac-proof.v1"
|
|
30
|
+
REPLAY_SCHEMA_VERSION = 1
|
|
31
|
+
ALGORITHM = "hmac-sha256"
|
|
32
|
+
MAX_SECRET_BYTES = 4_096
|
|
33
|
+
MAX_CLOCK_SKEW_SECONDS = 300
|
|
34
|
+
TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
|
|
35
|
+
NONCE = re.compile(r"^[A-Za-z0-9_-]{32,128}$")
|
|
36
|
+
SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
|
37
|
+
SIGNATURE = re.compile(r"^[a-f0-9]{64}$")
|
|
38
|
+
|
|
39
|
+
HEADER_CREDENTIAL_ID = "X-Localization-Auth-Credential-Id"
|
|
40
|
+
HEADER_CREDENTIAL_VERSION = "X-Localization-Auth-Credential-Version"
|
|
41
|
+
HEADER_TIMESTAMP = "X-Localization-Auth-Timestamp"
|
|
42
|
+
HEADER_NONCE = "X-Localization-Auth-Nonce"
|
|
43
|
+
HEADER_SIGNATURE = "X-Localization-Auth-Signature"
|
|
44
|
+
HEADER_SITE_ID = "X-Localization-Auth-Site-Id"
|
|
45
|
+
HEADER_EVENT_ID = "X-Localization-Auth-Event-Id"
|
|
46
|
+
HEADER_REQUEST_ID = "X-Localization-Auth-Request-Id"
|
|
47
|
+
HEADER_PAYLOAD_SHA256 = "X-Localization-Auth-Payload-Sha256"
|
|
48
|
+
|
|
49
|
+
_COMMON_HEADERS = {
|
|
50
|
+
HEADER_CREDENTIAL_ID.lower(),
|
|
51
|
+
HEADER_CREDENTIAL_VERSION.lower(),
|
|
52
|
+
HEADER_TIMESTAMP.lower(),
|
|
53
|
+
HEADER_NONCE.lower(),
|
|
54
|
+
HEADER_SIGNATURE.lower(),
|
|
55
|
+
}
|
|
56
|
+
_TENANT_HEADERS = {
|
|
57
|
+
HEADER_SITE_ID.lower(),
|
|
58
|
+
HEADER_EVENT_ID.lower(),
|
|
59
|
+
HEADER_REQUEST_ID.lower(),
|
|
60
|
+
HEADER_PAYLOAD_SHA256.lower(),
|
|
61
|
+
}
|
|
62
|
+
_REPLAY_META_COLUMNS = ("singleton", "schema_version")
|
|
63
|
+
_REPLAY_COLUMNS = (
|
|
64
|
+
"credential_id", "credential_version", "nonce", "proof_sha256",
|
|
65
|
+
"issued_at", "expires_at",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _load_module(name: str, path: Path):
|
|
70
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
71
|
+
if spec is None or spec.loader is None:
|
|
72
|
+
raise RuntimeError(f"cannot load HMAC authentication dependency: {path.name}")
|
|
73
|
+
module = importlib.util.module_from_spec(spec)
|
|
74
|
+
sys.modules[spec.name] = module
|
|
75
|
+
spec.loader.exec_module(module)
|
|
76
|
+
return module
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_ROOT = Path(__file__).resolve().parents[1]
|
|
80
|
+
_HTTP = _load_module(
|
|
81
|
+
"blun_website_localization_cms_source_delivery_auth_http",
|
|
82
|
+
_ROOT / "integrations" / "website_localization_cms_source_delivery_http.py",
|
|
83
|
+
)
|
|
84
|
+
_CLIENT = _load_module(
|
|
85
|
+
"blun_website_localization_cms_source_delivery_auth_client",
|
|
86
|
+
_ROOT / "integrations" / "website_localization_cms_source_delivery_client.py",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SourceDeliveryHMACAuthenticationUnavailable(RuntimeError):
|
|
91
|
+
"""Stable content-free authentication infrastructure failure."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, code: str):
|
|
94
|
+
super().__init__(code)
|
|
95
|
+
self.code = code
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _unavailable(code: str) -> SourceDeliveryHMACAuthenticationUnavailable:
|
|
99
|
+
return SourceDeliveryHMACAuthenticationUnavailable(
|
|
100
|
+
"source_delivery_hmac." + code,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _token(value: Any) -> bool:
|
|
105
|
+
return (
|
|
106
|
+
isinstance(value, str)
|
|
107
|
+
and TOKEN.fullmatch(value) is not None
|
|
108
|
+
and unicodedata.is_normalized("NFC", value)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _sha256(value: Any) -> bool:
|
|
113
|
+
return isinstance(value, str) and SHA256.fullmatch(value) is not None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _timestamp(value: Any) -> float:
|
|
117
|
+
if (
|
|
118
|
+
isinstance(value, bool)
|
|
119
|
+
or not isinstance(value, (int, float))
|
|
120
|
+
or not math.isfinite(float(value))
|
|
121
|
+
or float(value) < 0
|
|
122
|
+
):
|
|
123
|
+
raise ValueError("timestamp is invalid")
|
|
124
|
+
return float(value)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _canonical(value: Mapping[str, Any]) -> bytes:
|
|
128
|
+
try:
|
|
129
|
+
return json.dumps(
|
|
130
|
+
dict(value), ensure_ascii=False, allow_nan=False, sort_keys=True,
|
|
131
|
+
separators=(",", ":"),
|
|
132
|
+
).encode("utf-8")
|
|
133
|
+
except (TypeError, ValueError, RecursionError):
|
|
134
|
+
raise ValueError("proof is invalid") from None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True, repr=False)
|
|
138
|
+
class HMACCredential:
|
|
139
|
+
"""One explicit credential generation; the secret is never represented."""
|
|
140
|
+
|
|
141
|
+
principal_id: str
|
|
142
|
+
credential_id: str
|
|
143
|
+
credential_version: str
|
|
144
|
+
secret: bytes = field(repr=False, compare=False)
|
|
145
|
+
scopes: tuple[str, ...]
|
|
146
|
+
site_id: str | None = None
|
|
147
|
+
|
|
148
|
+
def __post_init__(self) -> None:
|
|
149
|
+
if not all(_token(value) for value in (
|
|
150
|
+
self.principal_id, self.credential_id, self.credential_version,
|
|
151
|
+
)):
|
|
152
|
+
raise ValueError("credential identity is invalid")
|
|
153
|
+
if (
|
|
154
|
+
not isinstance(self.secret, bytes)
|
|
155
|
+
or not 32 <= len(self.secret) <= MAX_SECRET_BYTES
|
|
156
|
+
):
|
|
157
|
+
raise ValueError("credential secret must contain 32 to 4096 bytes")
|
|
158
|
+
if (
|
|
159
|
+
not isinstance(self.scopes, tuple)
|
|
160
|
+
or not self.scopes
|
|
161
|
+
or tuple(sorted(set(self.scopes))) != self.scopes
|
|
162
|
+
or any(scope not in _HTTP.SCOPES.values() for scope in self.scopes)
|
|
163
|
+
):
|
|
164
|
+
raise ValueError("credential scopes are invalid")
|
|
165
|
+
tenant_scopes = {
|
|
166
|
+
_HTTP.SCOPES[path] for path in _HTTP.TENANT_PATHS
|
|
167
|
+
}
|
|
168
|
+
if self.site_id is not None and not _token(self.site_id):
|
|
169
|
+
raise ValueError("credential tenant is invalid")
|
|
170
|
+
if tenant_scopes.intersection(self.scopes) and self.site_id is None:
|
|
171
|
+
raise ValueError("tenant scopes require one site")
|
|
172
|
+
|
|
173
|
+
def __repr__(self) -> str:
|
|
174
|
+
return (
|
|
175
|
+
"HMACCredential(principal_id={!r}, credential_id={!r}, "
|
|
176
|
+
"credential_version={!r}, scopes={!r}, site_id={!r}, "
|
|
177
|
+
"secret=<redacted>)"
|
|
178
|
+
).format(
|
|
179
|
+
self.principal_id, self.credential_id, self.credential_version,
|
|
180
|
+
self.scopes, self.site_id,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class DurableHMACReplayStore:
|
|
185
|
+
"""Atomically consume proof nonces in caller-owned durable SQLite."""
|
|
186
|
+
|
|
187
|
+
def __init__(self, connection: sqlite3.Connection):
|
|
188
|
+
if not isinstance(connection, sqlite3.Connection):
|
|
189
|
+
raise TypeError("connection must be sqlite3.Connection")
|
|
190
|
+
self.connection = connection
|
|
191
|
+
self.connection.row_factory = sqlite3.Row
|
|
192
|
+
self._lock = threading.RLock()
|
|
193
|
+
self._initialize()
|
|
194
|
+
|
|
195
|
+
def _initialize(self) -> None:
|
|
196
|
+
with self._lock:
|
|
197
|
+
try:
|
|
198
|
+
self.connection.execute("""
|
|
199
|
+
CREATE TABLE IF NOT EXISTS cms_source_delivery_hmac_meta (
|
|
200
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
201
|
+
schema_version INTEGER NOT NULL CHECK (schema_version = 1)
|
|
202
|
+
)
|
|
203
|
+
""")
|
|
204
|
+
self.connection.execute("""
|
|
205
|
+
INSERT OR IGNORE INTO cms_source_delivery_hmac_meta
|
|
206
|
+
VALUES (1, 1)
|
|
207
|
+
""")
|
|
208
|
+
self.connection.execute("""
|
|
209
|
+
CREATE TABLE IF NOT EXISTS cms_source_delivery_hmac_nonces (
|
|
210
|
+
credential_id TEXT NOT NULL,
|
|
211
|
+
credential_version TEXT NOT NULL,
|
|
212
|
+
nonce TEXT NOT NULL,
|
|
213
|
+
proof_sha256 TEXT NOT NULL,
|
|
214
|
+
issued_at REAL NOT NULL,
|
|
215
|
+
expires_at REAL NOT NULL,
|
|
216
|
+
PRIMARY KEY (credential_id, credential_version, nonce)
|
|
217
|
+
)
|
|
218
|
+
""")
|
|
219
|
+
self.connection.commit()
|
|
220
|
+
self._validate()
|
|
221
|
+
except SourceDeliveryHMACAuthenticationUnavailable:
|
|
222
|
+
raise
|
|
223
|
+
except sqlite3.Error:
|
|
224
|
+
if self.connection.in_transaction:
|
|
225
|
+
self.connection.rollback()
|
|
226
|
+
raise _unavailable("replay_store_unavailable") from None
|
|
227
|
+
|
|
228
|
+
def _validate(self) -> None:
|
|
229
|
+
meta_columns = tuple(
|
|
230
|
+
row["name"] for row in self.connection.execute(
|
|
231
|
+
"PRAGMA table_info(cms_source_delivery_hmac_meta)"
|
|
232
|
+
).fetchall()
|
|
233
|
+
)
|
|
234
|
+
nonce_columns = tuple(
|
|
235
|
+
row["name"] for row in self.connection.execute(
|
|
236
|
+
"PRAGMA table_info(cms_source_delivery_hmac_nonces)"
|
|
237
|
+
).fetchall()
|
|
238
|
+
)
|
|
239
|
+
meta = self.connection.execute(
|
|
240
|
+
"SELECT singleton, schema_version FROM cms_source_delivery_hmac_meta"
|
|
241
|
+
).fetchall()
|
|
242
|
+
if (
|
|
243
|
+
meta_columns != _REPLAY_META_COLUMNS
|
|
244
|
+
or nonce_columns != _REPLAY_COLUMNS
|
|
245
|
+
or len(meta) != 1
|
|
246
|
+
or tuple(meta[0]) != (1, REPLAY_SCHEMA_VERSION)
|
|
247
|
+
):
|
|
248
|
+
raise _unavailable("replay_store_integrity")
|
|
249
|
+
for row in self.connection.execute(
|
|
250
|
+
"SELECT * FROM cms_source_delivery_hmac_nonces"
|
|
251
|
+
).fetchall():
|
|
252
|
+
try:
|
|
253
|
+
valid = (
|
|
254
|
+
_token(row["credential_id"])
|
|
255
|
+
and _token(row["credential_version"])
|
|
256
|
+
and isinstance(row["nonce"], str)
|
|
257
|
+
and NONCE.fullmatch(row["nonce"]) is not None
|
|
258
|
+
and _sha256(row["proof_sha256"])
|
|
259
|
+
and _timestamp(row["issued_at"])
|
|
260
|
+
<= _timestamp(row["expires_at"])
|
|
261
|
+
)
|
|
262
|
+
except (TypeError, ValueError):
|
|
263
|
+
valid = False
|
|
264
|
+
if not valid:
|
|
265
|
+
raise _unavailable("replay_store_integrity")
|
|
266
|
+
|
|
267
|
+
def consume(
|
|
268
|
+
self,
|
|
269
|
+
credential_id: str,
|
|
270
|
+
credential_version: str,
|
|
271
|
+
nonce: str,
|
|
272
|
+
proof_sha256: str,
|
|
273
|
+
*,
|
|
274
|
+
issued_at: float,
|
|
275
|
+
expires_at: float,
|
|
276
|
+
now: float,
|
|
277
|
+
) -> bool:
|
|
278
|
+
"""Return true exactly once for one valid proof identity."""
|
|
279
|
+
|
|
280
|
+
if (
|
|
281
|
+
not _token(credential_id)
|
|
282
|
+
or not _token(credential_version)
|
|
283
|
+
or not isinstance(nonce, str)
|
|
284
|
+
or NONCE.fullmatch(nonce) is None
|
|
285
|
+
or not _sha256(proof_sha256)
|
|
286
|
+
):
|
|
287
|
+
raise _unavailable("replay_store_request_invalid")
|
|
288
|
+
try:
|
|
289
|
+
issued = _timestamp(issued_at)
|
|
290
|
+
expiry = _timestamp(expires_at)
|
|
291
|
+
current = _timestamp(now)
|
|
292
|
+
except ValueError:
|
|
293
|
+
raise _unavailable("replay_store_request_invalid") from None
|
|
294
|
+
if issued > expiry or current > expiry:
|
|
295
|
+
raise _unavailable("replay_store_request_invalid")
|
|
296
|
+
with self._lock:
|
|
297
|
+
if self.connection.in_transaction:
|
|
298
|
+
raise _unavailable("replay_store_transaction_nested")
|
|
299
|
+
try:
|
|
300
|
+
self.connection.execute("BEGIN IMMEDIATE")
|
|
301
|
+
self._validate()
|
|
302
|
+
self.connection.execute(
|
|
303
|
+
"DELETE FROM cms_source_delivery_hmac_nonces "
|
|
304
|
+
"WHERE expires_at < ?",
|
|
305
|
+
(current,),
|
|
306
|
+
)
|
|
307
|
+
inserted = self.connection.execute("""
|
|
308
|
+
INSERT OR IGNORE INTO cms_source_delivery_hmac_nonces (
|
|
309
|
+
credential_id, credential_version, nonce, proof_sha256,
|
|
310
|
+
issued_at, expires_at
|
|
311
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
312
|
+
""", (
|
|
313
|
+
credential_id, credential_version, nonce, proof_sha256,
|
|
314
|
+
issued, expiry,
|
|
315
|
+
)).rowcount == 1
|
|
316
|
+
self.connection.commit()
|
|
317
|
+
return inserted
|
|
318
|
+
except SourceDeliveryHMACAuthenticationUnavailable:
|
|
319
|
+
if self.connection.in_transaction:
|
|
320
|
+
self.connection.rollback()
|
|
321
|
+
raise
|
|
322
|
+
except sqlite3.Error:
|
|
323
|
+
if self.connection.in_transaction:
|
|
324
|
+
self.connection.rollback()
|
|
325
|
+
raise _unavailable("replay_store_unavailable") from None
|
|
326
|
+
|
|
327
|
+
def health(self) -> Mapping[str, Any]:
|
|
328
|
+
with self._lock:
|
|
329
|
+
try:
|
|
330
|
+
self._validate()
|
|
331
|
+
count = self.connection.execute(
|
|
332
|
+
"SELECT COUNT(*) FROM cms_source_delivery_hmac_nonces"
|
|
333
|
+
).fetchone()[0]
|
|
334
|
+
except SourceDeliveryHMACAuthenticationUnavailable:
|
|
335
|
+
raise
|
|
336
|
+
except sqlite3.Error:
|
|
337
|
+
raise _unavailable("replay_store_unavailable") from None
|
|
338
|
+
return {
|
|
339
|
+
"schema": "blun.cms-source-delivery-hmac-replay-health.v1",
|
|
340
|
+
"status": "ok",
|
|
341
|
+
"consumed_nonces": int(count),
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _clock(clock: Callable[[], float | int]) -> float:
|
|
346
|
+
try:
|
|
347
|
+
return _timestamp(clock())
|
|
348
|
+
except Exception:
|
|
349
|
+
raise _unavailable("clock_unavailable") from None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _nonce(factory: Callable[[], str]) -> str:
|
|
353
|
+
try:
|
|
354
|
+
value = factory()
|
|
355
|
+
except Exception:
|
|
356
|
+
raise _unavailable("nonce_unavailable") from None
|
|
357
|
+
if not isinstance(value, str) or NONCE.fullmatch(value) is None:
|
|
358
|
+
raise _unavailable("nonce_unavailable")
|
|
359
|
+
return value
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _binding(
|
|
363
|
+
credential: HMACCredential,
|
|
364
|
+
*,
|
|
365
|
+
origin: str,
|
|
366
|
+
sidecar_capabilities_sha256: str,
|
|
367
|
+
remote_capabilities_sha256: str,
|
|
368
|
+
method: str,
|
|
369
|
+
path: str,
|
|
370
|
+
scope: str,
|
|
371
|
+
body_sha256: str,
|
|
372
|
+
issued_at: int,
|
|
373
|
+
nonce: str,
|
|
374
|
+
site_id: str | None,
|
|
375
|
+
event_id: str | None,
|
|
376
|
+
request_id: str | None,
|
|
377
|
+
payload_sha256: str | None,
|
|
378
|
+
idempotency_key: str | None,
|
|
379
|
+
source_payload_sha256: str | None,
|
|
380
|
+
) -> dict[str, Any]:
|
|
381
|
+
return {
|
|
382
|
+
"schema": PROOF_SCHEMA,
|
|
383
|
+
"algorithm": ALGORITHM,
|
|
384
|
+
"origin": origin,
|
|
385
|
+
"sidecar_capabilities_sha256": sidecar_capabilities_sha256,
|
|
386
|
+
"remote_capabilities_sha256": remote_capabilities_sha256,
|
|
387
|
+
"method": method,
|
|
388
|
+
"path": path,
|
|
389
|
+
"scope": scope,
|
|
390
|
+
"body_sha256": body_sha256,
|
|
391
|
+
"principal_id": credential.principal_id,
|
|
392
|
+
"credential_id": credential.credential_id,
|
|
393
|
+
"credential_version": credential.credential_version,
|
|
394
|
+
"issued_at": issued_at,
|
|
395
|
+
"nonce": nonce,
|
|
396
|
+
"site_id": site_id,
|
|
397
|
+
"event_id": event_id,
|
|
398
|
+
"request_id": request_id,
|
|
399
|
+
"payload_sha256": payload_sha256,
|
|
400
|
+
"idempotency_key": idempotency_key,
|
|
401
|
+
"source_payload_sha256": source_payload_sha256,
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class SourceDeliveryHMACSigner:
|
|
406
|
+
"""Produce request-bound authentication headers for the reference client."""
|
|
407
|
+
|
|
408
|
+
def __init__(
|
|
409
|
+
self,
|
|
410
|
+
credential: HMACCredential,
|
|
411
|
+
*,
|
|
412
|
+
origin: str,
|
|
413
|
+
sidecar_capabilities_sha256: str,
|
|
414
|
+
remote_capabilities_sha256: str,
|
|
415
|
+
clock: Callable[[], float | int] = time.time,
|
|
416
|
+
nonce_factory: Callable[[], str] = lambda: secrets.token_urlsafe(24),
|
|
417
|
+
allow_loopback_http: bool = False,
|
|
418
|
+
):
|
|
419
|
+
if not isinstance(credential, HMACCredential):
|
|
420
|
+
raise TypeError("credential must be HMACCredential")
|
|
421
|
+
if not callable(clock) or not callable(nonce_factory):
|
|
422
|
+
raise TypeError("clock and nonce factory must be callable")
|
|
423
|
+
self.credential = credential
|
|
424
|
+
self.origin = _CLIENT._endpoint(origin, allow_loopback_http)
|
|
425
|
+
if not _sha256(sidecar_capabilities_sha256):
|
|
426
|
+
raise ValueError("sidecar capability digest is invalid")
|
|
427
|
+
if not _sha256(remote_capabilities_sha256):
|
|
428
|
+
raise ValueError("remote capability digest is invalid")
|
|
429
|
+
self.sidecar_capabilities_sha256 = sidecar_capabilities_sha256
|
|
430
|
+
self.remote_capabilities_sha256 = remote_capabilities_sha256
|
|
431
|
+
self.clock = clock
|
|
432
|
+
self.nonce_factory = nonce_factory
|
|
433
|
+
|
|
434
|
+
def __repr__(self) -> str:
|
|
435
|
+
return "SourceDeliveryHMACSigner(configured=True, secret=<redacted>)"
|
|
436
|
+
|
|
437
|
+
def __call__(self, context: Mapping[str, Any]) -> Mapping[str, str]:
|
|
438
|
+
base_fields = {
|
|
439
|
+
"schema", "method", "origin", "path", "scope", "body_sha256",
|
|
440
|
+
}
|
|
441
|
+
if not isinstance(context, Mapping):
|
|
442
|
+
raise _unavailable("signing_context_invalid")
|
|
443
|
+
path = context.get("path")
|
|
444
|
+
tenant = path in _HTTP.TENANT_PATHS
|
|
445
|
+
expected_fields = base_fields | (
|
|
446
|
+
{"site_id", "event_id", "request_id", "payload_sha256"}
|
|
447
|
+
if tenant else set()
|
|
448
|
+
)
|
|
449
|
+
if set(context) != expected_fields:
|
|
450
|
+
raise _unavailable("signing_context_invalid")
|
|
451
|
+
try:
|
|
452
|
+
method = _HTTP.METHODS[path]
|
|
453
|
+
scope = _HTTP.SCOPES[path]
|
|
454
|
+
except (KeyError, TypeError):
|
|
455
|
+
raise _unavailable("signing_context_invalid") from None
|
|
456
|
+
if (
|
|
457
|
+
context.get("schema") != _CLIENT.AUTH_CONTEXT_SCHEMA
|
|
458
|
+
or context.get("method") != method
|
|
459
|
+
or context.get("origin") != self.origin
|
|
460
|
+
or context.get("scope") != scope
|
|
461
|
+
or not _sha256(context.get("body_sha256"))
|
|
462
|
+
or scope not in self.credential.scopes
|
|
463
|
+
):
|
|
464
|
+
raise _unavailable("signing_context_invalid")
|
|
465
|
+
site_id = event_id = request_id = payload_sha256 = None
|
|
466
|
+
if tenant:
|
|
467
|
+
site_id = context["site_id"]
|
|
468
|
+
event_id = context["event_id"]
|
|
469
|
+
request_id = context["request_id"]
|
|
470
|
+
payload_sha256 = context["payload_sha256"]
|
|
471
|
+
if (
|
|
472
|
+
not all(_token(value) for value in (
|
|
473
|
+
site_id, event_id, request_id,
|
|
474
|
+
))
|
|
475
|
+
or not _sha256(payload_sha256)
|
|
476
|
+
or site_id != self.credential.site_id
|
|
477
|
+
):
|
|
478
|
+
raise _unavailable("signing_context_invalid")
|
|
479
|
+
issued_at = int(_clock(self.clock))
|
|
480
|
+
nonce = _nonce(self.nonce_factory)
|
|
481
|
+
write = path in {_HTTP.CHANGE_PATH, _HTTP.REMOVAL_PATH}
|
|
482
|
+
binding = _binding(
|
|
483
|
+
self.credential,
|
|
484
|
+
origin=self.origin,
|
|
485
|
+
sidecar_capabilities_sha256=self.sidecar_capabilities_sha256,
|
|
486
|
+
remote_capabilities_sha256=self.remote_capabilities_sha256,
|
|
487
|
+
method=method,
|
|
488
|
+
path=path,
|
|
489
|
+
scope=scope,
|
|
490
|
+
body_sha256=context["body_sha256"],
|
|
491
|
+
issued_at=issued_at,
|
|
492
|
+
nonce=nonce,
|
|
493
|
+
site_id=site_id,
|
|
494
|
+
event_id=event_id,
|
|
495
|
+
request_id=request_id,
|
|
496
|
+
payload_sha256=payload_sha256,
|
|
497
|
+
idempotency_key=request_id if write else None,
|
|
498
|
+
source_payload_sha256=payload_sha256 if write else None,
|
|
499
|
+
)
|
|
500
|
+
signature = hmac.new(
|
|
501
|
+
self.credential.secret, _canonical(binding), hashlib.sha256,
|
|
502
|
+
).hexdigest()
|
|
503
|
+
headers = {
|
|
504
|
+
HEADER_CREDENTIAL_ID: self.credential.credential_id,
|
|
505
|
+
HEADER_CREDENTIAL_VERSION: self.credential.credential_version,
|
|
506
|
+
HEADER_TIMESTAMP: str(issued_at),
|
|
507
|
+
HEADER_NONCE: nonce,
|
|
508
|
+
HEADER_SIGNATURE: signature,
|
|
509
|
+
}
|
|
510
|
+
if tenant:
|
|
511
|
+
headers.update({
|
|
512
|
+
HEADER_SITE_ID: site_id,
|
|
513
|
+
HEADER_EVENT_ID: event_id,
|
|
514
|
+
HEADER_REQUEST_ID: request_id,
|
|
515
|
+
HEADER_PAYLOAD_SHA256: payload_sha256,
|
|
516
|
+
})
|
|
517
|
+
return headers
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class RotatingSourceDeliveryHMACSigner:
|
|
521
|
+
"""Atomically replace one client credential under a fixed proof contract."""
|
|
522
|
+
|
|
523
|
+
def __init__(
|
|
524
|
+
self,
|
|
525
|
+
credential: HMACCredential,
|
|
526
|
+
*,
|
|
527
|
+
origin: str,
|
|
528
|
+
sidecar_capabilities_sha256: str,
|
|
529
|
+
remote_capabilities_sha256: str,
|
|
530
|
+
clock: Callable[[], float | int] = time.time,
|
|
531
|
+
nonce_factory: Callable[[], str] = lambda: secrets.token_urlsafe(24),
|
|
532
|
+
allow_loopback_http: bool = False,
|
|
533
|
+
):
|
|
534
|
+
self._configuration = {
|
|
535
|
+
"origin": origin,
|
|
536
|
+
"sidecar_capabilities_sha256": sidecar_capabilities_sha256,
|
|
537
|
+
"remote_capabilities_sha256": remote_capabilities_sha256,
|
|
538
|
+
"clock": clock,
|
|
539
|
+
"nonce_factory": nonce_factory,
|
|
540
|
+
"allow_loopback_http": allow_loopback_http,
|
|
541
|
+
}
|
|
542
|
+
try:
|
|
543
|
+
self._signer = SourceDeliveryHMACSigner(
|
|
544
|
+
credential, **self._configuration,
|
|
545
|
+
)
|
|
546
|
+
except Exception:
|
|
547
|
+
raise _unavailable("signer_configuration_invalid") from None
|
|
548
|
+
self._lock = threading.RLock()
|
|
549
|
+
self._owner_pid = os.getpid()
|
|
550
|
+
|
|
551
|
+
def __repr__(self) -> str:
|
|
552
|
+
return "RotatingSourceDeliveryHMACSigner(configured=True, secret=<redacted>)"
|
|
553
|
+
|
|
554
|
+
def _assert_owner(self) -> None:
|
|
555
|
+
if os.getpid() != self._owner_pid:
|
|
556
|
+
raise _unavailable("signer_foreign_process")
|
|
557
|
+
|
|
558
|
+
def __call__(self, context: Mapping[str, Any]) -> Mapping[str, str]:
|
|
559
|
+
self._assert_owner()
|
|
560
|
+
with self._lock:
|
|
561
|
+
return self._signer(context)
|
|
562
|
+
|
|
563
|
+
def replace_credential(self, credential: HMACCredential) -> None:
|
|
564
|
+
"""Replace the signer only after the complete new credential validates."""
|
|
565
|
+
|
|
566
|
+
self._assert_owner()
|
|
567
|
+
with self._lock:
|
|
568
|
+
try:
|
|
569
|
+
replacement = SourceDeliveryHMACSigner(
|
|
570
|
+
credential, **self._configuration,
|
|
571
|
+
)
|
|
572
|
+
except Exception:
|
|
573
|
+
raise _unavailable("signer_configuration_invalid") from None
|
|
574
|
+
self._signer = replacement
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
class RotatingHMACCMSSourceDeliveryClient:
|
|
578
|
+
"""Bind one rotatable signer and HTTP client to the same fixed contract."""
|
|
579
|
+
|
|
580
|
+
def __init__(
|
|
581
|
+
self,
|
|
582
|
+
credential: HMACCredential,
|
|
583
|
+
*,
|
|
584
|
+
origin: str,
|
|
585
|
+
sidecar_capabilities_sha256: str,
|
|
586
|
+
remote_capabilities_sha256: str,
|
|
587
|
+
clock: Callable[[], float | int] = time.time,
|
|
588
|
+
nonce_factory: Callable[[], str] = lambda: secrets.token_urlsafe(24),
|
|
589
|
+
transport: Any = None,
|
|
590
|
+
timeout: float | int = 30,
|
|
591
|
+
allow_loopback_http: bool = False,
|
|
592
|
+
):
|
|
593
|
+
self._owner_pid = os.getpid()
|
|
594
|
+
try:
|
|
595
|
+
signer = RotatingSourceDeliveryHMACSigner(
|
|
596
|
+
credential,
|
|
597
|
+
origin=origin,
|
|
598
|
+
sidecar_capabilities_sha256=sidecar_capabilities_sha256,
|
|
599
|
+
remote_capabilities_sha256=remote_capabilities_sha256,
|
|
600
|
+
clock=clock,
|
|
601
|
+
nonce_factory=nonce_factory,
|
|
602
|
+
allow_loopback_http=allow_loopback_http,
|
|
603
|
+
)
|
|
604
|
+
client_options = {
|
|
605
|
+
"timeout": timeout,
|
|
606
|
+
"allow_loopback_http": allow_loopback_http,
|
|
607
|
+
}
|
|
608
|
+
if transport is not None:
|
|
609
|
+
client_options["transport"] = transport
|
|
610
|
+
client = _CLIENT.CMSSourceDeliverySidecarHTTPClient(
|
|
611
|
+
origin,
|
|
612
|
+
sidecar_capabilities_sha256,
|
|
613
|
+
remote_capabilities_sha256,
|
|
614
|
+
signer,
|
|
615
|
+
**client_options,
|
|
616
|
+
)
|
|
617
|
+
except Exception:
|
|
618
|
+
raise _unavailable("client_configuration_invalid") from None
|
|
619
|
+
self._signer = signer
|
|
620
|
+
self._client = client
|
|
621
|
+
# Preserve the immutable public contract required by durable client
|
|
622
|
+
# adapters without exposing the signer or credential.
|
|
623
|
+
self.origin = client.origin
|
|
624
|
+
self.expected_capabilities_sha256 = (
|
|
625
|
+
client.expected_capabilities_sha256
|
|
626
|
+
)
|
|
627
|
+
self.expected_remote_capabilities_sha256 = (
|
|
628
|
+
client.expected_remote_capabilities_sha256
|
|
629
|
+
)
|
|
630
|
+
self.timeout = client.timeout
|
|
631
|
+
|
|
632
|
+
def __repr__(self) -> str:
|
|
633
|
+
return f"RotatingHMACCMSSourceDeliveryClient(state={self.state!r})"
|
|
634
|
+
|
|
635
|
+
def _assert_owner(self) -> None:
|
|
636
|
+
if os.getpid() != self._owner_pid:
|
|
637
|
+
raise _unavailable("client_foreign_process")
|
|
638
|
+
|
|
639
|
+
def _call(self, name: str, *args: Any, **kwargs: Any) -> Mapping[str, Any]:
|
|
640
|
+
self._assert_owner()
|
|
641
|
+
return getattr(self._client, name)(*args, **kwargs)
|
|
642
|
+
|
|
643
|
+
def replace_credential(self, credential: HMACCredential) -> None:
|
|
644
|
+
self._assert_owner()
|
|
645
|
+
self._signer.replace_credential(credential)
|
|
646
|
+
|
|
647
|
+
def capabilities(self) -> Mapping[str, Any]:
|
|
648
|
+
return self._call("capabilities")
|
|
649
|
+
|
|
650
|
+
def submit_change(
|
|
651
|
+
self,
|
|
652
|
+
change: Mapping[str, Any],
|
|
653
|
+
*,
|
|
654
|
+
source_max_attempts: int = 5,
|
|
655
|
+
delivery_max_attempts: int = 5,
|
|
656
|
+
) -> Mapping[str, Any]:
|
|
657
|
+
return self._call(
|
|
658
|
+
"submit_change",
|
|
659
|
+
change,
|
|
660
|
+
source_max_attempts=source_max_attempts,
|
|
661
|
+
delivery_max_attempts=delivery_max_attempts,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
def submit_removal(
|
|
665
|
+
self,
|
|
666
|
+
removal: Mapping[str, Any],
|
|
667
|
+
*,
|
|
668
|
+
source_max_attempts: int = 5,
|
|
669
|
+
delivery_max_attempts: int = 5,
|
|
670
|
+
) -> Mapping[str, Any]:
|
|
671
|
+
return self._call(
|
|
672
|
+
"submit_removal",
|
|
673
|
+
removal,
|
|
674
|
+
source_max_attempts=source_max_attempts,
|
|
675
|
+
delivery_max_attempts=delivery_max_attempts,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
def status(
|
|
679
|
+
self,
|
|
680
|
+
operation: str,
|
|
681
|
+
request_id: str,
|
|
682
|
+
event_id: str,
|
|
683
|
+
site_id: str,
|
|
684
|
+
payload_sha256: str,
|
|
685
|
+
) -> Mapping[str, Any]:
|
|
686
|
+
return self._call(
|
|
687
|
+
"status", operation, request_id, event_id, site_id, payload_sha256,
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
def source_status(
|
|
691
|
+
self,
|
|
692
|
+
event_id: str,
|
|
693
|
+
site_id: str,
|
|
694
|
+
payload_sha256: str,
|
|
695
|
+
) -> Mapping[str, Any]:
|
|
696
|
+
return self._call(
|
|
697
|
+
"source_status", event_id, site_id, payload_sha256,
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
def source_readiness(self) -> Mapping[str, Any]:
|
|
701
|
+
return self._call("source_readiness")
|
|
702
|
+
|
|
703
|
+
def source_health(self) -> Mapping[str, Any]:
|
|
704
|
+
return self._call("source_health")
|
|
705
|
+
|
|
706
|
+
def health(self) -> Mapping[str, Any]:
|
|
707
|
+
return self._call("health")
|
|
708
|
+
|
|
709
|
+
def readiness(self) -> Mapping[str, Any]:
|
|
710
|
+
return self._call("readiness")
|
|
711
|
+
|
|
712
|
+
@property
|
|
713
|
+
def state(self) -> str:
|
|
714
|
+
if os.getpid() != self._owner_pid:
|
|
715
|
+
return "foreign-process"
|
|
716
|
+
return "open"
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
class SourceDeliveryHMACVerifier:
|
|
720
|
+
"""Authenticate exact sidecar requests and consume each proof once."""
|
|
721
|
+
|
|
722
|
+
def __init__(
|
|
723
|
+
self,
|
|
724
|
+
credentials: Iterable[HMACCredential],
|
|
725
|
+
replay_store: DurableHMACReplayStore,
|
|
726
|
+
*,
|
|
727
|
+
origin: str,
|
|
728
|
+
sidecar_capabilities_sha256: str,
|
|
729
|
+
remote_capabilities_sha256: str,
|
|
730
|
+
clock: Callable[[], float | int] = time.time,
|
|
731
|
+
max_age_seconds: float | int = 60,
|
|
732
|
+
future_skew_seconds: float | int = 5,
|
|
733
|
+
allow_loopback_http: bool = False,
|
|
734
|
+
):
|
|
735
|
+
try:
|
|
736
|
+
values = tuple(credentials)
|
|
737
|
+
except TypeError:
|
|
738
|
+
raise TypeError("credentials must be iterable") from None
|
|
739
|
+
if not values or any(not isinstance(item, HMACCredential) for item in values):
|
|
740
|
+
raise TypeError("credentials must contain HMACCredential values")
|
|
741
|
+
keyed = {
|
|
742
|
+
(item.credential_id, item.credential_version): item
|
|
743
|
+
for item in values
|
|
744
|
+
}
|
|
745
|
+
if len(keyed) != len(values):
|
|
746
|
+
raise ValueError("credential generation is duplicated")
|
|
747
|
+
if not isinstance(replay_store, DurableHMACReplayStore):
|
|
748
|
+
raise TypeError("replay_store must be DurableHMACReplayStore")
|
|
749
|
+
if not callable(clock):
|
|
750
|
+
raise TypeError("clock must be callable")
|
|
751
|
+
self.origin = _CLIENT._endpoint(origin, allow_loopback_http)
|
|
752
|
+
if not _sha256(sidecar_capabilities_sha256):
|
|
753
|
+
raise ValueError("sidecar capability digest is invalid")
|
|
754
|
+
if not _sha256(remote_capabilities_sha256):
|
|
755
|
+
raise ValueError("remote capability digest is invalid")
|
|
756
|
+
try:
|
|
757
|
+
age = _timestamp(max_age_seconds)
|
|
758
|
+
skew = _timestamp(future_skew_seconds)
|
|
759
|
+
except ValueError:
|
|
760
|
+
raise ValueError("authentication time window is invalid") from None
|
|
761
|
+
if not 0 < age <= MAX_CLOCK_SKEW_SECONDS or skew > age:
|
|
762
|
+
raise ValueError("authentication time window is invalid")
|
|
763
|
+
self.credentials = keyed
|
|
764
|
+
self.replay_store = replay_store
|
|
765
|
+
self.sidecar_capabilities_sha256 = sidecar_capabilities_sha256
|
|
766
|
+
self.remote_capabilities_sha256 = remote_capabilities_sha256
|
|
767
|
+
self.clock = clock
|
|
768
|
+
self.max_age_seconds = age
|
|
769
|
+
self.future_skew_seconds = skew
|
|
770
|
+
|
|
771
|
+
def __repr__(self) -> str:
|
|
772
|
+
return "SourceDeliveryHMACVerifier(configured=True, secrets=<redacted>)"
|
|
773
|
+
|
|
774
|
+
@staticmethod
|
|
775
|
+
def _headers(request: Mapping[str, Any]) -> dict[str, str] | None:
|
|
776
|
+
items = request.get("headers")
|
|
777
|
+
if not isinstance(items, list):
|
|
778
|
+
return None
|
|
779
|
+
result: dict[str, str] = {}
|
|
780
|
+
for item in items:
|
|
781
|
+
if (
|
|
782
|
+
not isinstance(item, list)
|
|
783
|
+
or len(item) != 2
|
|
784
|
+
or not all(isinstance(part, str) for part in item)
|
|
785
|
+
):
|
|
786
|
+
return None
|
|
787
|
+
name, value = item[0].lower(), item[1]
|
|
788
|
+
if name in result:
|
|
789
|
+
return None
|
|
790
|
+
result[name] = value
|
|
791
|
+
return result
|
|
792
|
+
|
|
793
|
+
def __call__(self, request: Mapping[str, Any]) -> Mapping[str, str] | None:
|
|
794
|
+
if (
|
|
795
|
+
not isinstance(request, Mapping)
|
|
796
|
+
or set(request) != {
|
|
797
|
+
"schema", "method", "path", "headers", "body_sha256",
|
|
798
|
+
}
|
|
799
|
+
or request.get("schema") != _HTTP.AUTH_REQUEST_SCHEMA
|
|
800
|
+
):
|
|
801
|
+
return None
|
|
802
|
+
path = request.get("path")
|
|
803
|
+
try:
|
|
804
|
+
method = _HTTP.METHODS[path]
|
|
805
|
+
scope = _HTTP.SCOPES[path]
|
|
806
|
+
except (KeyError, TypeError):
|
|
807
|
+
return None
|
|
808
|
+
if request.get("method") != method or not _sha256(request.get("body_sha256")):
|
|
809
|
+
return None
|
|
810
|
+
headers = self._headers(request)
|
|
811
|
+
if headers is None or not _COMMON_HEADERS.issubset(headers):
|
|
812
|
+
return None
|
|
813
|
+
tenant = path in _HTTP.TENANT_PATHS
|
|
814
|
+
if tenant != _TENANT_HEADERS.issubset(headers):
|
|
815
|
+
return None
|
|
816
|
+
if not tenant and _TENANT_HEADERS.intersection(headers):
|
|
817
|
+
return None
|
|
818
|
+
credential = self.credentials.get((
|
|
819
|
+
headers[HEADER_CREDENTIAL_ID.lower()],
|
|
820
|
+
headers[HEADER_CREDENTIAL_VERSION.lower()],
|
|
821
|
+
))
|
|
822
|
+
if credential is None or scope not in credential.scopes:
|
|
823
|
+
return None
|
|
824
|
+
try:
|
|
825
|
+
issued_text = headers[HEADER_TIMESTAMP.lower()]
|
|
826
|
+
if not issued_text.isascii() or not issued_text.isdecimal():
|
|
827
|
+
return None
|
|
828
|
+
issued_at = int(issued_text)
|
|
829
|
+
nonce = headers[HEADER_NONCE.lower()]
|
|
830
|
+
signature = headers[HEADER_SIGNATURE.lower()]
|
|
831
|
+
if (
|
|
832
|
+
NONCE.fullmatch(nonce) is None
|
|
833
|
+
or SIGNATURE.fullmatch(signature) is None
|
|
834
|
+
):
|
|
835
|
+
return None
|
|
836
|
+
except (KeyError, TypeError):
|
|
837
|
+
return None
|
|
838
|
+
current = _clock(self.clock)
|
|
839
|
+
if (
|
|
840
|
+
issued_at < current - self.max_age_seconds
|
|
841
|
+
or issued_at > current + self.future_skew_seconds
|
|
842
|
+
):
|
|
843
|
+
return None
|
|
844
|
+
site_id = event_id = request_id = payload_sha256 = None
|
|
845
|
+
if tenant:
|
|
846
|
+
site_id = headers[HEADER_SITE_ID.lower()]
|
|
847
|
+
event_id = headers[HEADER_EVENT_ID.lower()]
|
|
848
|
+
request_id = headers[HEADER_REQUEST_ID.lower()]
|
|
849
|
+
payload_sha256 = headers[HEADER_PAYLOAD_SHA256.lower()]
|
|
850
|
+
if (
|
|
851
|
+
not all(_token(value) for value in (
|
|
852
|
+
site_id, event_id, request_id,
|
|
853
|
+
))
|
|
854
|
+
or not _sha256(payload_sha256)
|
|
855
|
+
or site_id != credential.site_id
|
|
856
|
+
):
|
|
857
|
+
return None
|
|
858
|
+
write = path in {_HTTP.CHANGE_PATH, _HTTP.REMOVAL_PATH}
|
|
859
|
+
idempotency_key = headers.get("idempotency-key")
|
|
860
|
+
source_payload_sha256 = headers.get(
|
|
861
|
+
"x-localization-source-payload-sha256"
|
|
862
|
+
)
|
|
863
|
+
if write:
|
|
864
|
+
if (
|
|
865
|
+
idempotency_key != request_id
|
|
866
|
+
or source_payload_sha256 != payload_sha256
|
|
867
|
+
):
|
|
868
|
+
return None
|
|
869
|
+
elif idempotency_key is not None or source_payload_sha256 is not None:
|
|
870
|
+
return None
|
|
871
|
+
binding = _binding(
|
|
872
|
+
credential,
|
|
873
|
+
origin=self.origin,
|
|
874
|
+
sidecar_capabilities_sha256=self.sidecar_capabilities_sha256,
|
|
875
|
+
remote_capabilities_sha256=self.remote_capabilities_sha256,
|
|
876
|
+
method=method,
|
|
877
|
+
path=path,
|
|
878
|
+
scope=scope,
|
|
879
|
+
body_sha256=request["body_sha256"],
|
|
880
|
+
issued_at=issued_at,
|
|
881
|
+
nonce=nonce,
|
|
882
|
+
site_id=site_id,
|
|
883
|
+
event_id=event_id,
|
|
884
|
+
request_id=request_id,
|
|
885
|
+
payload_sha256=payload_sha256,
|
|
886
|
+
idempotency_key=idempotency_key,
|
|
887
|
+
source_payload_sha256=source_payload_sha256,
|
|
888
|
+
)
|
|
889
|
+
proof = _canonical(binding)
|
|
890
|
+
expected = hmac.new(
|
|
891
|
+
credential.secret, proof, hashlib.sha256,
|
|
892
|
+
).hexdigest()
|
|
893
|
+
if not hmac.compare_digest(signature, expected):
|
|
894
|
+
return None
|
|
895
|
+
proof_sha256 = hashlib.sha256(proof).hexdigest()
|
|
896
|
+
if not self.replay_store.consume(
|
|
897
|
+
credential.credential_id,
|
|
898
|
+
credential.credential_version,
|
|
899
|
+
nonce,
|
|
900
|
+
proof_sha256,
|
|
901
|
+
issued_at=float(issued_at),
|
|
902
|
+
expires_at=float(issued_at) + self.max_age_seconds,
|
|
903
|
+
now=current,
|
|
904
|
+
):
|
|
905
|
+
return None
|
|
906
|
+
principal = {
|
|
907
|
+
"schema": (
|
|
908
|
+
_HTTP.TENANT_PRINCIPAL_SCHEMA
|
|
909
|
+
if tenant else _HTTP.PRINCIPAL_SCHEMA
|
|
910
|
+
),
|
|
911
|
+
"principal_id": credential.principal_id,
|
|
912
|
+
"credential_id": credential.credential_id,
|
|
913
|
+
"credential_version": credential.credential_version,
|
|
914
|
+
"scope": scope,
|
|
915
|
+
}
|
|
916
|
+
if tenant:
|
|
917
|
+
principal["site_id"] = site_id
|
|
918
|
+
return principal
|