lcp-reference-platform 1.0.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.
lcp_platform/auth.py ADDED
@@ -0,0 +1,202 @@
1
+ """Authentication for the reference platform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+
9
+ from .api_keys import hash_api_key, verify_api_key
10
+ from .config import PlatformConfig
11
+ from .secrets import FileSecretProvider
12
+ from .storage import Store
13
+
14
+
15
+ def header(headers: dict[str, str], name: str) -> str | None:
16
+ """Read an HTTP header without relying on dictionary casing."""
17
+ name = name.lower()
18
+ for key, value in headers.items():
19
+ if key.lower() == name:
20
+ return value
21
+ return None
22
+
23
+
24
+ class AuthenticationError(ValueError):
25
+ """Raised when an LCP request cannot be authenticated."""
26
+
27
+ def __init__(self, message: str, code: str = "LCP-002"):
28
+ self.code = code
29
+ super().__init__(message)
30
+
31
+
32
+ def canonical_bytes(timestamp: str, idempotency_key: str | None, body: bytes) -> bytes:
33
+ return f"{timestamp}\n{idempotency_key or ''}\n".encode("utf-8") + body
34
+
35
+
36
+ def signature_for(
37
+ secret: str | bytes | bytearray | memoryview,
38
+ timestamp: str,
39
+ idempotency_key: str | None,
40
+ body: bytes,
41
+ ) -> str:
42
+ secret_bytes = (
43
+ secret
44
+ if isinstance(secret, bytes)
45
+ else secret.tobytes()
46
+ if isinstance(secret, memoryview)
47
+ else bytes(secret)
48
+ if isinstance(secret, bytearray)
49
+ else secret.encode("utf-8")
50
+ )
51
+ return hmac.new(
52
+ secret_bytes,
53
+ canonical_bytes(timestamp, idempotency_key, body),
54
+ hashlib.sha256,
55
+ ).hexdigest()
56
+
57
+
58
+ def _timestamp_is_fresh(timestamp: str, max_skew_seconds: int) -> bool:
59
+ from datetime import datetime, timezone
60
+ try:
61
+ signed_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
62
+ except ValueError:
63
+ return False
64
+ if signed_at.tzinfo is None:
65
+ return False
66
+ now = datetime.now(timezone.utc)
67
+ return abs((now - signed_at.astimezone(timezone.utc)).total_seconds()) <= max_skew_seconds
68
+
69
+
70
+ class Authenticator:
71
+ def __init__(self, store: Store, config: PlatformConfig):
72
+ self.store = store
73
+ self.config = config
74
+ self.secrets = FileSecretProvider(config.secrets_file)
75
+
76
+ def principal(self, sender_id: str) -> dict[str, object] | None:
77
+ row = self.store.get_credential(sender_id)
78
+ principal = dict(row) if row else None
79
+ external = self.secrets.get(sender_id)
80
+ if external:
81
+ principal = principal or {
82
+ "sender_id": sender_id,
83
+ "tenant_id": external.get("tenant_id", "default"),
84
+ "scopes_json": "[]",
85
+ "active": 1,
86
+ }
87
+ for field in ("hmac_secret", "previous_hmac_secret"):
88
+ if field in external:
89
+ principal[field] = external[field]
90
+ if external.get("api_key"):
91
+ principal["api_key_salt"], principal["api_key_hash"] = hash_api_key(
92
+ str(external["api_key"])
93
+ )
94
+ if "scopes" in external:
95
+ principal["scopes_json"] = json.dumps(external["scopes"])
96
+ principal.setdefault("hmac_secret", None)
97
+ principal.setdefault("previous_hmac_secret", None)
98
+ principal.setdefault("api_key_hash", None)
99
+ principal.setdefault("api_key_salt", None)
100
+ return principal
101
+
102
+ def authenticate(
103
+ self,
104
+ *,
105
+ sender_id: str | None,
106
+ headers: dict[str, str],
107
+ body: bytes,
108
+ idempotency_key: str | None = None,
109
+ mutating: bool = False,
110
+ required_scope: str | None = None,
111
+ ) -> str:
112
+ """Authenticate and return the authenticated sender ID."""
113
+ header_sender = header(headers, "X-LCP-Sender-Id")
114
+ if sender_id and header_sender and sender_id != header_sender:
115
+ raise AuthenticationError("Sender header does not match the envelope", "LCP-002")
116
+ sender_id = sender_id or header_sender
117
+ authorization = header(headers, "Authorization") or ""
118
+ if not sender_id and authorization.lower().startswith("bearer "):
119
+ sender_id = self.store.find_sender_for_api_key(authorization[7:].strip())
120
+ if not sender_id:
121
+ raise AuthenticationError("Sender identity is required")
122
+
123
+ if not self.config.require_auth:
124
+ return sender_id
125
+
126
+ credential = self.principal(sender_id)
127
+ if not credential:
128
+ raise AuthenticationError("Unknown sender", "LCP-002")
129
+
130
+ if authorization.lower().startswith("bearer "):
131
+ presented = authorization[7:].strip()
132
+ salt = credential.get("api_key_salt")
133
+ stored = credential.get("api_key_hash")
134
+ if not verify_api_key(
135
+ presented, str(salt) if salt else None, str(stored) if stored else None
136
+ ):
137
+ raise AuthenticationError("Invalid bearer credential", "LCP-012")
138
+ else:
139
+ timestamp = header(headers, "X-LCP-Timestamp")
140
+ signature = header(headers, "X-LCP-Signature")
141
+ header_key = header(headers, "X-LCP-Idempotency-Key")
142
+ if not timestamp or not signature:
143
+ raise AuthenticationError("HMAC headers are required", "LCP-012")
144
+ if not _timestamp_is_fresh(timestamp, self.config.replay_window_seconds):
145
+ raise AuthenticationError("Timestamp is outside the replay window", "LCP-012")
146
+ if mutating and not header_key:
147
+ raise AuthenticationError("Idempotency header is required", "LCP-012")
148
+ if header_key and idempotency_key and header_key != idempotency_key:
149
+ raise AuthenticationError("Header/body idempotency keys differ", "LCP-012")
150
+ candidate_secrets = [
151
+ credential["hmac_secret"],
152
+ credential["previous_hmac_secret"],
153
+ ]
154
+ if not any(
155
+ secret
156
+ and hmac.compare_digest(
157
+ signature.lower(),
158
+ signature_for(secret, timestamp, header_key, body),
159
+ )
160
+ for secret in candidate_secrets
161
+ ):
162
+ raise AuthenticationError("Invalid HMAC signature", "LCP-012")
163
+
164
+ if required_scope and not self.has_scope(sender_id, required_scope):
165
+ raise AuthenticationError("Sender is not authorized for this operation", "LCP-002")
166
+ return sender_id
167
+
168
+ def has_scope(self, sender_id: str, required_scope: str) -> bool:
169
+ scopes = self.scopes_for(sender_id)
170
+ return "*" in scopes or required_scope in scopes or "platform:admin" in scopes
171
+
172
+ def secret_for(self, sender_id: str) -> str | None:
173
+ credential = self.principal(sender_id)
174
+ if not credential or not credential.get("hmac_secret"):
175
+ return None
176
+ value = credential["hmac_secret"]
177
+ if isinstance(value, memoryview):
178
+ return value.tobytes().decode("utf-8")
179
+ if isinstance(value, bytes):
180
+ return value.decode("utf-8")
181
+ return str(value)
182
+
183
+ def tenant_for(self, sender_id: str) -> str | None:
184
+ principal = self.principal(sender_id)
185
+ return str(principal["tenant_id"]) if principal and principal.get("tenant_id") else None
186
+
187
+ def scopes_for(self, sender_id: str) -> set[str]:
188
+ principal = self.principal(sender_id)
189
+ if not principal:
190
+ return set()
191
+ raw_scopes = principal.get("scopes_json", "[]")
192
+ if isinstance(raw_scopes, memoryview):
193
+ raw_scopes = raw_scopes.tobytes().decode("utf-8")
194
+ elif isinstance(raw_scopes, bytes):
195
+ raw_scopes = raw_scopes.decode("utf-8")
196
+ try:
197
+ decoded = json.loads(str(raw_scopes))
198
+ except (TypeError, ValueError):
199
+ return set()
200
+ if not isinstance(decoded, list) or not all(isinstance(scope, str) for scope in decoded):
201
+ return set()
202
+ return set(decoded)
lcp_platform/config.py ADDED
@@ -0,0 +1,157 @@
1
+ """Reference platform configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import os
7
+ from pathlib import Path
8
+
9
+
10
+ def _bool(name: str, default: bool) -> bool:
11
+ value = os.environ.get(name)
12
+ if value is None:
13
+ return default
14
+ return value.lower() in {"1", "true", "yes", "on"}
15
+
16
+
17
+ def _csv(name: str, default: str = "") -> tuple[str, ...]:
18
+ return tuple(value.strip() for value in os.environ.get(name, default).split(",") if value.strip())
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class PlatformConfig:
23
+ database_path: Path
24
+ database_url: str | None = None
25
+ schema_root: Path | None = None
26
+ secrets_file: Path | None = None
27
+ pii_encryption_key: str | None = None
28
+ host: str = "127.0.0.1"
29
+ port: int = 8080
30
+ platform_id: str = "platform_001"
31
+ routing_tenant_id: str = "default"
32
+ require_auth: bool = True
33
+ replay_window_seconds: int = 300
34
+ webhook_timeout_seconds: float = 10.0
35
+ max_delivery_attempts: int = 5
36
+ worker_interval_seconds: float = 1.0
37
+ max_body_bytes: int = 2_000_000
38
+ max_header_bytes: int = 16_384
39
+ max_webhook_response_bytes: int = 262_144
40
+ rate_limit_per_minute: int = 600
41
+ allow_insecure_webhooks: bool = False
42
+ webhook_host_allowlist: tuple[str, ...] = ()
43
+ test_mode: bool = False
44
+ attachment_directory: Path = Path("./data/lcp-attachments")
45
+ max_attachment_bytes: int = 100 * 1024 * 1024
46
+ allowed_attachment_content_types: tuple[str, ...] = (
47
+ "application/pdf",
48
+ "image/jpeg",
49
+ "image/png",
50
+ "image/webp",
51
+ "text/plain",
52
+ "application/msword",
53
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
54
+ )
55
+ # Production object-storage profile. The file backend remains the default
56
+ # for local and sandbox use; the S3-compatible backend requires all of the
57
+ # object/KMS/residency fields below when selected outside test mode.
58
+ attachment_backend: str = "file"
59
+ attachment_scanner: str = "none"
60
+ attachment_scan_required: bool = False
61
+ attachment_residency: str | None = None
62
+ attachment_allowed_residencies: tuple[str, ...] = ()
63
+ attachment_object_bucket: str | None = None
64
+ attachment_object_prefix: str = "lcp/attachments"
65
+ attachment_object_endpoint_url: str | None = None
66
+ attachment_object_region: str | None = None
67
+ attachment_object_kms_key_id: str | None = None
68
+ attachment_object_residency: str | None = None
69
+ attachment_clamav_socket: str | None = None
70
+ attachment_clamav_host: str = "127.0.0.1"
71
+ attachment_clamav_port: int = 3310
72
+
73
+ def __post_init__(self) -> None:
74
+ if self.max_body_bytes <= 0 or self.max_header_bytes <= 0:
75
+ raise ValueError("Request size limits must be positive")
76
+ if self.max_webhook_response_bytes <= 0:
77
+ raise ValueError("Webhook response limit must be positive")
78
+ if self.max_attachment_bytes <= 0:
79
+ raise ValueError("Attachment size limit must be positive")
80
+ if self.rate_limit_per_minute <= 0:
81
+ raise ValueError("Rate limit must be positive")
82
+ if not self.require_auth and not self.test_mode:
83
+ raise ValueError("Authentication may only be disabled in test mode")
84
+ if not self.test_mode and not self.pii_encryption_key:
85
+ raise ValueError("LCP_PII_ENCRYPTION_KEY is required outside test mode")
86
+ if self.allow_insecure_webhooks and not self.test_mode:
87
+ raise ValueError("Insecure webhooks may only be enabled in test mode")
88
+ if self.attachment_backend not in {"file", "s3"}:
89
+ raise ValueError("LCP_ATTACHMENT_BACKEND must be 'file' or 's3'")
90
+ if self.attachment_scanner not in {"none", "clamav"}:
91
+ raise ValueError("LCP_ATTACHMENT_SCANNER must be 'clamav' or 'none'")
92
+ if self.attachment_scan_required and self.attachment_scanner == "none" and not self.test_mode:
93
+ raise ValueError("A production attachment scan is required when LCP_ATTACHMENT_SCAN_REQUIRED is enabled")
94
+ if self.attachment_backend == "s3" and not self.test_mode:
95
+ missing = {
96
+ "LCP_ATTACHMENT_OBJECT_BUCKET": self.attachment_object_bucket,
97
+ "LCP_ATTACHMENT_OBJECT_KMS_KEY_ID": self.attachment_object_kms_key_id,
98
+ "LCP_ATTACHMENT_OBJECT_RESIDENCY": self.attachment_object_residency,
99
+ }
100
+ if any(not value for value in missing.values()):
101
+ raise ValueError(f"S3 attachment backend requires: {', '.join(key for key, value in missing.items() if not value)}")
102
+ if not self.attachment_scan_required:
103
+ raise ValueError("S3 attachment backend requires malware scanning")
104
+ if self.attachment_allowed_residencies and self.attachment_object_residency.upper() not in {
105
+ value.strip().upper() for value in self.attachment_allowed_residencies
106
+ }:
107
+ raise ValueError("Object-store residency is not in the allowed residency policy")
108
+
109
+ @classmethod
110
+ def from_env(cls) -> "PlatformConfig":
111
+ return cls(
112
+ database_path=Path(os.environ.get("LCP_DATABASE_PATH", "./data/lcp.sqlite3")),
113
+ database_url=os.environ.get("LCP_DATABASE_URL"),
114
+ schema_root=Path(os.environ["LCP_SCHEMA_DIR"])
115
+ if os.environ.get("LCP_SCHEMA_DIR")
116
+ else None,
117
+ secrets_file=Path(os.environ["LCP_SECRETS_FILE"])
118
+ if os.environ.get("LCP_SECRETS_FILE")
119
+ else None,
120
+ pii_encryption_key=os.environ.get("LCP_PII_ENCRYPTION_KEY"),
121
+ host=os.environ.get("LCP_HOST", "127.0.0.1"),
122
+ port=int(os.environ.get("LCP_PORT", "8080")),
123
+ platform_id=os.environ.get("LCP_PLATFORM_ID", "platform_001"),
124
+ routing_tenant_id=os.environ.get("LCP_ROUTING_TENANT_ID", "default"),
125
+ require_auth=_bool("LCP_REQUIRE_AUTH", True),
126
+ replay_window_seconds=int(os.environ.get("LCP_REPLAY_WINDOW_SECONDS", "300")),
127
+ webhook_timeout_seconds=float(os.environ.get("LCP_WEBHOOK_TIMEOUT_SECONDS", "10")),
128
+ max_delivery_attempts=int(os.environ.get("LCP_MAX_DELIVERY_ATTEMPTS", "5")),
129
+ worker_interval_seconds=float(os.environ.get("LCP_WORKER_INTERVAL_SECONDS", "1")),
130
+ max_body_bytes=int(os.environ.get("LCP_MAX_BODY_BYTES", "2000000")),
131
+ max_header_bytes=int(os.environ.get("LCP_MAX_HEADER_BYTES", "16384")),
132
+ max_webhook_response_bytes=int(os.environ.get("LCP_MAX_WEBHOOK_RESPONSE_BYTES", "262144")),
133
+ rate_limit_per_minute=int(os.environ.get("LCP_RATE_LIMIT_PER_MINUTE", "600")),
134
+ allow_insecure_webhooks=_bool("LCP_ALLOW_INSECURE_WEBHOOKS", False),
135
+ webhook_host_allowlist=_csv("LCP_WEBHOOK_HOST_ALLOWLIST"),
136
+ test_mode=_bool("LCP_TEST_MODE", False),
137
+ attachment_directory=Path(os.environ.get("LCP_ATTACHMENT_DIRECTORY", "./data/lcp-attachments")),
138
+ max_attachment_bytes=int(os.environ.get("LCP_MAX_ATTACHMENT_BYTES", str(100 * 1024 * 1024))),
139
+ allowed_attachment_content_types=_csv(
140
+ "LCP_ALLOWED_ATTACHMENT_CONTENT_TYPES",
141
+ "application/pdf,image/jpeg,image/png,image/webp,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document",
142
+ ),
143
+ attachment_backend=os.environ.get("LCP_ATTACHMENT_BACKEND", "file").lower(),
144
+ attachment_scanner=os.environ.get("LCP_ATTACHMENT_SCANNER", "clamav").lower(),
145
+ attachment_scan_required=_bool("LCP_ATTACHMENT_SCAN_REQUIRED", True),
146
+ attachment_residency=os.environ.get("LCP_ATTACHMENT_RESIDENCY"),
147
+ attachment_allowed_residencies=_csv("LCP_ATTACHMENT_ALLOWED_RESIDENCIES"),
148
+ attachment_object_bucket=os.environ.get("LCP_ATTACHMENT_OBJECT_BUCKET"),
149
+ attachment_object_prefix=os.environ.get("LCP_ATTACHMENT_OBJECT_PREFIX", "lcp/attachments"),
150
+ attachment_object_endpoint_url=os.environ.get("LCP_ATTACHMENT_OBJECT_ENDPOINT_URL"),
151
+ attachment_object_region=os.environ.get("LCP_ATTACHMENT_OBJECT_REGION"),
152
+ attachment_object_kms_key_id=os.environ.get("LCP_ATTACHMENT_OBJECT_KMS_KEY_ID"),
153
+ attachment_object_residency=os.environ.get("LCP_ATTACHMENT_OBJECT_RESIDENCY"),
154
+ attachment_clamav_socket=os.environ.get("LCP_ATTACHMENT_CLAMAV_SOCKET"),
155
+ attachment_clamav_host=os.environ.get("LCP_ATTACHMENT_CLAMAV_HOST", "127.0.0.1"),
156
+ attachment_clamav_port=int(os.environ.get("LCP_ATTACHMENT_CLAMAV_PORT", "3310")),
157
+ )
lcp_platform/crypto.py ADDED
@@ -0,0 +1,94 @@
1
+ """Application-level encryption for persisted LCP envelopes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import secrets
8
+ from typing import Any
9
+
10
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
11
+
12
+
13
+ class EnvelopeCryptoError(ValueError):
14
+ """Raised when an encrypted envelope cannot be decoded."""
15
+
16
+
17
+ class EnvelopeCipher:
18
+ """Encrypt JSON envelopes while preserving an explicit version marker."""
19
+
20
+ _prefix = "enc:v1:"
21
+ _aad = b"lcp-persisted-envelope:v1"
22
+
23
+ def __init__(self, key: str | bytes | None):
24
+ self._key = self._decode_key(key) if key else None
25
+
26
+ @staticmethod
27
+ def _decode_key(key: str | bytes) -> bytes:
28
+ raw = key.encode("ascii") if isinstance(key, str) else key
29
+ try:
30
+ decoded = base64.b64decode(
31
+ raw + b"=" * (-len(raw) % 4), altchars=b"-_", validate=True
32
+ )
33
+ except (ValueError, UnicodeEncodeError) as exc:
34
+ raise EnvelopeCryptoError(
35
+ "LCP_PII_ENCRYPTION_KEY must be URL-safe base64"
36
+ ) from exc
37
+ if len(decoded) != 32:
38
+ raise EnvelopeCryptoError(
39
+ "LCP_PII_ENCRYPTION_KEY must decode to exactly 32 bytes"
40
+ )
41
+ return decoded
42
+
43
+ @property
44
+ def enabled(self) -> bool:
45
+ return self._key is not None
46
+
47
+ def encode(self, value: dict[str, Any]) -> str:
48
+ plaintext = json.dumps(
49
+ value, separators=(",", ":"), ensure_ascii=False
50
+ ).encode("utf-8")
51
+ if not self._key:
52
+ return plaintext.decode("utf-8")
53
+ nonce = secrets.token_bytes(12)
54
+ ciphertext = AESGCM(self._key).encrypt(nonce, plaintext, self._aad)
55
+ return self._prefix + base64.urlsafe_b64encode(nonce + ciphertext).decode("ascii")
56
+
57
+ def encrypt_bytes(self, value: bytes, *, aad: bytes = b"lcp-persisted-attachment:v1") -> bytes:
58
+ """Encrypt binary PII such as an uploaded contract or call record."""
59
+ if not self._key:
60
+ return value
61
+ nonce = secrets.token_bytes(12)
62
+ return b"encb:v1:" + nonce + AESGCM(self._key).encrypt(nonce, value, aad)
63
+
64
+ def decrypt_bytes(self, value: bytes, *, aad: bytes = b"lcp-persisted-attachment:v1") -> bytes:
65
+ if not value.startswith(b"encb:v1:"):
66
+ if self._key:
67
+ raise EnvelopeCryptoError("Unencrypted attachment encountered while encryption is required")
68
+ return value
69
+ if not self._key:
70
+ raise EnvelopeCryptoError("Encrypted attachment requires LCP_PII_ENCRYPTION_KEY")
71
+ packed = value[len(b"encb:v1:"):]
72
+ try:
73
+ return AESGCM(self._key).decrypt(packed[:12], packed[12:], aad)
74
+ except Exception as exc:
75
+ raise EnvelopeCryptoError("Persisted attachment authentication failed") from exc
76
+
77
+ def decode(self, value: str) -> dict[str, Any]:
78
+ if not value.startswith(self._prefix):
79
+ if self._key:
80
+ raise EnvelopeCryptoError(
81
+ "Unencrypted persisted envelope encountered while encryption is required"
82
+ )
83
+ return json.loads(value)
84
+ if not self._key:
85
+ raise EnvelopeCryptoError("Encrypted envelope requires LCP_PII_ENCRYPTION_KEY")
86
+ try:
87
+ packed = base64.urlsafe_b64decode(value[len(self._prefix) :])
88
+ plaintext = AESGCM(self._key).decrypt(packed[:12], packed[12:], self._aad)
89
+ decoded = json.loads(plaintext)
90
+ except Exception as exc:
91
+ raise EnvelopeCryptoError("Persisted envelope authentication failed") from exc
92
+ if not isinstance(decoded, dict):
93
+ raise EnvelopeCryptoError("Persisted envelope must be a JSON object")
94
+ return decoded
@@ -0,0 +1,18 @@
1
+ """Database backend selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .config import PlatformConfig
8
+ from .storage import Store
9
+
10
+
11
+ def create_store(config: PlatformConfig) -> Any:
12
+ if config.database_url:
13
+ from .postgres import PostgresStore
14
+ return PostgresStore(
15
+ config.database_url,
16
+ pii_encryption_key=config.pii_encryption_key,
17
+ )
18
+ return Store(config.database_path, pii_encryption_key=config.pii_encryption_key)