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.
@@ -0,0 +1,17 @@
1
+ """Production-oriented reference LCP platform."""
2
+
3
+ from .config import PlatformConfig
4
+ from .mapping import MappingError, MappingRegistry, PublisherNormalizer
5
+ from .router import Platform, RequestError
6
+ from .service import PlatformService
7
+
8
+ __all__ = [
9
+ "MappingError",
10
+ "MappingRegistry",
11
+ "Platform",
12
+ "PlatformConfig",
13
+ "PlatformService",
14
+ "PublisherNormalizer",
15
+ "RequestError",
16
+ ]
17
+ __version__ = "1.0.0"
lcp_platform/admin.py ADDED
@@ -0,0 +1,90 @@
1
+ """Administrative CLI for local/reference deployments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from dataclasses import replace
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+
11
+ from .config import PlatformConfig
12
+ from .router import Platform
13
+
14
+
15
+ def main() -> None:
16
+ parser = argparse.ArgumentParser(description="Manage an LCP reference platform")
17
+ parser.add_argument("--database", default=None, help="SQLite path; defaults to LCP_DATABASE_PATH")
18
+ commands = parser.add_subparsers(dest="command", required=True)
19
+
20
+ commands.add_parser("init", help="Initialize the database")
21
+
22
+ credential = commands.add_parser("credential", help="Manage sender credentials")
23
+ credential_sub = credential.add_subparsers(dest="credential_command", required=True)
24
+ add_credential = credential_sub.add_parser("upsert")
25
+ add_credential.add_argument("--sender-id", required=True)
26
+ add_credential.add_argument("--tenant-id", default="default")
27
+ add_credential.add_argument("--scope", action="append", dest="scopes")
28
+ add_credential.add_argument("--hmac-secret")
29
+ add_credential.add_argument("--previous-hmac-secret")
30
+ add_credential.add_argument("--api-key")
31
+
32
+ privacy = commands.add_parser("privacy", help="Perform controlled privacy operations")
33
+ privacy_sub = privacy.add_subparsers(dest="privacy_command", required=True)
34
+ erase_lead = privacy_sub.add_parser("erase-lead")
35
+ erase_lead.add_argument("--lead-id", required=True)
36
+ erase_lead.add_argument("--actor-id", default="operator")
37
+
38
+ mapping = commands.add_parser("mapping", help="Manage publisher form mappings")
39
+ mapping_sub = mapping.add_subparsers(dest="mapping_command", required=True)
40
+ upsert_mapping = mapping_sub.add_parser("upsert")
41
+ upsert_mapping.add_argument("--file", required=True, type=Path)
42
+
43
+ offer = commands.add_parser("offer", help="Manage buyer offers")
44
+ offer_sub = offer.add_subparsers(dest="offer_command", required=True)
45
+ upsert_offer = offer_sub.add_parser("upsert")
46
+ upsert_offer.add_argument("--file", required=True, type=Path)
47
+
48
+ args = parser.parse_args()
49
+ config = PlatformConfig.from_env()
50
+ if args.database:
51
+ config = replace(config, database_path=Path(args.database))
52
+ platform = Platform(config)
53
+ try:
54
+ if args.command == "init":
55
+ print(f"Initialized {config.database_path}")
56
+ elif args.command == "credential":
57
+ hmac_secret = args.hmac_secret or os.environ.get("LCP_ADMIN_HMAC_SECRET")
58
+ if not hmac_secret and not args.api_key:
59
+ raise SystemExit("Provide --hmac-secret, LCP_ADMIN_HMAC_SECRET, or --api-key")
60
+ platform.upsert_credential(
61
+ args.sender_id,
62
+ tenant_id=args.tenant_id,
63
+ scopes=args.scopes or ["*"],
64
+ hmac_secret=hmac_secret,
65
+ previous_hmac_secret=args.previous_hmac_secret,
66
+ api_key=args.api_key,
67
+ )
68
+ print(f"Upserted credential for {args.sender_id}")
69
+ elif args.command == "privacy":
70
+ if args.privacy_command == "erase-lead":
71
+ platform.erase_lead(args.lead_id, actor_id=args.actor_id)
72
+ print(f"Erased lead {args.lead_id}")
73
+ elif args.command == "mapping":
74
+ with args.file.open(encoding="utf-8") as handle:
75
+ mapping_data = json.load(handle)
76
+ mappings = mapping_data.get("mappings", []) if isinstance(mapping_data, dict) and "mappings" in mapping_data else [mapping_data]
77
+ for item in mappings:
78
+ platform.upsert_mapping(item)
79
+ print(f"Upserted mapping {item['mapping_id']} v{item['version']}")
80
+ elif args.command == "offer":
81
+ with args.file.open(encoding="utf-8") as handle:
82
+ offer_data = json.load(handle)
83
+ platform.upsert_offer(offer_data)
84
+ print(f"Upserted offer {offer_data['offer_id']}")
85
+ finally:
86
+ platform.close()
87
+
88
+
89
+ if __name__ == "__main__":
90
+ main()
@@ -0,0 +1,50 @@
1
+ """API-key hashing for the reference platform.
2
+
3
+ API keys are stored as salted PBKDF2-HMAC-SHA256 digests so that a leaked
4
+ credentials table cannot be brute-forced offline. Every credential receives
5
+ its own random salt (per-credential salting defeats rainbow tables and batch
6
+ cracking), and verification uses constant-time comparison.
7
+
8
+ The API key itself is never stored; only the salt and digest are persisted.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import hmac
15
+ import secrets
16
+
17
+ # OWASP Password Storage Cheat Sheet (2023) minimum for PBKDF2-HMAC-SHA256.
18
+ # The reference platform holds a small credentials table, so the lookup cost
19
+ # of iterating rows during bearer-only authentication stays negligible.
20
+ API_KEY_PBKDF2_ITERATIONS = 600_000
21
+ _SALT_BYTES = 16
22
+
23
+
24
+ def hash_api_key(api_key: str) -> tuple[str, str]:
25
+ """Return ``(salt, digest)`` for a new API key.
26
+
27
+ The salt is random per credential and must be stored alongside the
28
+ digest so the key can be verified later.
29
+ """
30
+ salt = secrets.token_hex(_SALT_BYTES)
31
+ digest = hashlib.pbkdf2_hmac(
32
+ "sha256",
33
+ api_key.encode("utf-8"),
34
+ salt.encode("ascii"),
35
+ API_KEY_PBKDF2_ITERATIONS,
36
+ )
37
+ return salt, digest.hex()
38
+
39
+
40
+ def verify_api_key(api_key: str, salt: str | None, stored_digest: str | None) -> bool:
41
+ """Constant-time verification of an API key against stored salt/digest."""
42
+ if not salt or not stored_digest:
43
+ return False
44
+ digest = hashlib.pbkdf2_hmac(
45
+ "sha256",
46
+ api_key.encode("utf-8"),
47
+ salt.encode("ascii"),
48
+ API_KEY_PBKDF2_ITERATIONS,
49
+ )
50
+ return hmac.compare_digest(digest.hex(), stored_digest)
@@ -0,0 +1,361 @@
1
+ """Secure attachment storage and scanning adapters.
2
+
3
+ The reference platform uses :class:`FileAttachmentStore` for a self-contained
4
+ sandbox. Production deployments can select :class:`S3ObjectStorageAttachmentStore`
5
+ for AWS S3 or another S3-compatible service. The adapter deliberately keeps
6
+ provider-specific URLs and object keys opaque to LCP messages.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from hashlib import sha256
13
+ import json
14
+ import os
15
+ from pathlib import Path
16
+ import re
17
+ import tempfile
18
+ from typing import Any, Protocol
19
+
20
+ from .crypto import EnvelopeCipher
21
+
22
+
23
+ class AttachmentError(ValueError):
24
+ """Raised for invalid, missing, unsafe, or tampered attachment content."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class MalwareScanResult:
29
+ """Safe-to-persist result of an attachment malware scan."""
30
+
31
+ status: str
32
+ engine: str
33
+ scanned_at: str
34
+
35
+
36
+ class MalwareScanner(Protocol):
37
+ def scan(self, content: bytes, *, filename: str, content_type: str) -> MalwareScanResult:
38
+ """Return a clean result or raise :class:`AttachmentError`."""
39
+
40
+
41
+ class NoopMalwareScanner:
42
+ """Test-only scanner; production configuration must use a real scanner."""
43
+
44
+ def scan(self, content: bytes, *, filename: str, content_type: str) -> MalwareScanResult:
45
+ from .storage import now_iso
46
+
47
+ return MalwareScanResult("not_scanned", "disabled-test-scanner", now_iso())
48
+
49
+
50
+ class ClamAVMalwareScanner:
51
+ """Fail-closed ClamAV scanner using a local ``clamd`` daemon."""
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ socket_path: str | None = None,
57
+ host: str = "127.0.0.1",
58
+ port: int = 3310,
59
+ client: Any | None = None,
60
+ ):
61
+ if client is not None:
62
+ self.client = client
63
+ return
64
+ try:
65
+ import clamd
66
+ except ImportError as exc:
67
+ raise AttachmentError(
68
+ "ClamAV scanning requires the reference-platform[object-storage] extra"
69
+ ) from exc
70
+ try:
71
+ self.client = (
72
+ clamd.ClamdUnixSocket(path=socket_path)
73
+ if socket_path
74
+ else clamd.ClamdNetworkSocket(host=host, port=port)
75
+ )
76
+ except Exception as exc:
77
+ raise AttachmentError("Unable to initialize the ClamAV scanner") from exc
78
+
79
+ def scan(self, content: bytes, *, filename: str, content_type: str) -> MalwareScanResult:
80
+ del filename, content_type
81
+ try:
82
+ result = self.client.instream(content)
83
+ except Exception as exc:
84
+ raise AttachmentError("Malware scanner is unavailable") from exc
85
+ if not isinstance(result, dict):
86
+ raise AttachmentError("Malware scanner returned an invalid result")
87
+ stream_result = result.get("stream")
88
+ if not isinstance(stream_result, tuple) or not stream_result:
89
+ raise AttachmentError("Malware scanner returned an invalid result")
90
+ status = str(stream_result[0]).upper()
91
+ if status == "FOUND":
92
+ raise AttachmentError("Attachment failed malware scanning")
93
+ if status != "OK":
94
+ raise AttachmentError("Attachment malware scan did not complete cleanly")
95
+ from .storage import now_iso
96
+
97
+ return MalwareScanResult("clean", "clamav", now_iso())
98
+
99
+
100
+ class ObjectStorageClient(Protocol):
101
+ """Minimal S3-compatible client contract used by the production adapter."""
102
+
103
+ def put_object(self, **kwargs: Any) -> Any: ...
104
+
105
+ def get_object(self, **kwargs: Any) -> Any: ...
106
+
107
+ def head_object(self, **kwargs: Any) -> Any: ...
108
+
109
+ def delete_object(self, **kwargs: Any) -> Any: ...
110
+
111
+
112
+ _ATTACHMENT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
113
+ _FILENAME = re.compile(r"^[^/\\\x00]{1,255}$")
114
+ _CONTENT_TYPE = re.compile(r"^[^;\s]+/[^;\s]+$")
115
+ _RESIDENCY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{1,31}$")
116
+
117
+
118
+ def validate_residency(residency: str) -> str:
119
+ """Normalize and validate a country/region residency policy identifier."""
120
+ normalized = residency.strip().upper()
121
+ if not _RESIDENCY.fullmatch(normalized):
122
+ raise AttachmentError("attachment residency must be a non-empty country or region identifier")
123
+ return normalized
124
+
125
+
126
+ class FileAttachmentStore:
127
+ """Store encrypted attachment bytes with restrictive local permissions."""
128
+
129
+ encryption = "application_encrypted"
130
+
131
+ def __init__(self, root: str | Path, *, cipher: EnvelopeCipher):
132
+ self.root = Path(root)
133
+ self.root.mkdir(parents=True, exist_ok=True)
134
+ if os.name != "nt":
135
+ self.root.chmod(0o700)
136
+ self.cipher = cipher
137
+
138
+ @staticmethod
139
+ def validate_metadata(*, attachment_id: str, filename: str, content_type: str, size_bytes: int) -> None:
140
+ if not _ATTACHMENT_ID.fullmatch(attachment_id):
141
+ raise AttachmentError("attachment_id contains unsafe characters")
142
+ if not _FILENAME.fullmatch(filename):
143
+ raise AttachmentError("filename must be a basename without path separators")
144
+ if not _CONTENT_TYPE.fullmatch(content_type):
145
+ raise AttachmentError("content_type must be a valid media type")
146
+ if size_bytes <= 0:
147
+ raise AttachmentError("attachment must not be empty")
148
+
149
+ def _path(self, attachment_id: str) -> Path:
150
+ self.validate_metadata(attachment_id=attachment_id, filename="x", content_type="x/x", size_bytes=1)
151
+ return self.root / f"{attachment_id}.bin"
152
+
153
+ def put(
154
+ self,
155
+ attachment_id: str,
156
+ content: bytes,
157
+ *,
158
+ sha256_hex: str,
159
+ content_type: str,
160
+ filename: str,
161
+ residency: str,
162
+ ) -> str:
163
+ del content_type, filename
164
+ validate_residency(residency)
165
+ if sha256(content).hexdigest() != sha256_hex:
166
+ raise AttachmentError("attachment sha256 does not match the uploaded bytes")
167
+ path = self._path(attachment_id)
168
+ encrypted = self.cipher.encrypt_bytes(content)
169
+ with tempfile.NamedTemporaryFile(dir=self.root, prefix=f".{attachment_id}.", delete=False) as handle:
170
+ temporary = Path(handle.name)
171
+ handle.write(encrypted)
172
+ handle.flush()
173
+ os.fsync(handle.fileno())
174
+ if os.name != "nt":
175
+ temporary.chmod(0o600)
176
+ os.replace(temporary, path)
177
+ if os.name != "nt":
178
+ path.chmod(0o600)
179
+ return f"lcp://attachments/{attachment_id}"
180
+
181
+ def read(self, storage_ref: str, *, expected_sha256: str) -> bytes:
182
+ prefix = "lcp://attachments/"
183
+ if not storage_ref.startswith(prefix):
184
+ raise AttachmentError("unsupported attachment storage reference")
185
+ path = self._path(storage_ref.removeprefix(prefix))
186
+ try:
187
+ content = self.cipher.decrypt_bytes(path.read_bytes())
188
+ except FileNotFoundError as exc:
189
+ raise AttachmentError("attachment bytes are not available") from exc
190
+ except Exception as exc:
191
+ raise AttachmentError("attachment decryption failed") from exc
192
+ if sha256(content).hexdigest() != expected_sha256:
193
+ raise AttachmentError("attachment integrity check failed")
194
+ return content
195
+
196
+ def delete(self, storage_ref: str) -> None:
197
+ prefix = "lcp://attachments/"
198
+ if not storage_ref.startswith(prefix):
199
+ return
200
+ path = self._path(storage_ref.removeprefix(prefix))
201
+ try:
202
+ path.unlink()
203
+ except FileNotFoundError:
204
+ pass
205
+
206
+
207
+ class S3ObjectStorageAttachmentStore:
208
+ """S3-compatible object storage with mandatory SSE-KMS and residency keys.
209
+
210
+ The adapter is intentionally dependency-injected for tests and for cloud-
211
+ neutral S3-compatible services. When no client is supplied it lazily uses
212
+ ``boto3.client('s3')``. The KMS key and residency are immutable properties
213
+ of an adapter instance, so a process cannot accidentally write one tenant's
214
+ attachment to another configured region.
215
+ """
216
+
217
+ encryption = "provider_encrypted"
218
+ _ref_prefix = "lcp-object://attachments/"
219
+
220
+ def __init__(
221
+ self,
222
+ *,
223
+ bucket: str,
224
+ prefix: str,
225
+ residency: str,
226
+ kms_key_id: str,
227
+ client: ObjectStorageClient | None = None,
228
+ endpoint_url: str | None = None,
229
+ region_name: str | None = None,
230
+ ):
231
+ if not bucket or not re.fullmatch(r"[A-Za-z0-9._-]{3,255}", bucket):
232
+ raise AttachmentError("object storage bucket is invalid")
233
+ if not kms_key_id:
234
+ raise AttachmentError("object storage requires a KMS key identifier")
235
+ self.bucket = bucket
236
+ self.prefix = prefix.strip("/") or "lcp/attachments"
237
+ self.residency = validate_residency(residency)
238
+ self.kms_key_id = kms_key_id
239
+ if client is not None:
240
+ self.client = client
241
+ else:
242
+ try:
243
+ import boto3
244
+ except ImportError as exc:
245
+ raise AttachmentError(
246
+ "S3 object storage requires the reference-platform[object-storage] extra"
247
+ ) from exc
248
+ self.client = boto3.client("s3", endpoint_url=endpoint_url, region_name=region_name)
249
+
250
+ def _key(self, attachment_id: str) -> str:
251
+ FileAttachmentStore.validate_metadata(
252
+ attachment_id=attachment_id,
253
+ filename="x",
254
+ content_type="x/x",
255
+ size_bytes=1,
256
+ )
257
+ return f"{self.prefix}/residency/{self.residency}/{attachment_id}.bin"
258
+
259
+ def _attachment_id(self, storage_ref: str) -> str:
260
+ if not storage_ref.startswith(self._ref_prefix):
261
+ raise AttachmentError("unsupported attachment storage reference")
262
+ attachment_id = storage_ref.removeprefix(self._ref_prefix)
263
+ self._key(attachment_id)
264
+ return attachment_id
265
+
266
+ def put(
267
+ self,
268
+ attachment_id: str,
269
+ content: bytes,
270
+ *,
271
+ sha256_hex: str,
272
+ content_type: str,
273
+ filename: str,
274
+ residency: str,
275
+ ) -> str:
276
+ FileAttachmentStore.validate_metadata(
277
+ attachment_id=attachment_id,
278
+ filename=filename,
279
+ content_type=content_type,
280
+ size_bytes=len(content),
281
+ )
282
+ if validate_residency(residency) != self.residency:
283
+ raise AttachmentError("attachment residency does not match the object-store adapter")
284
+ if sha256(content).hexdigest() != sha256_hex:
285
+ raise AttachmentError("attachment sha256 does not match the uploaded bytes")
286
+ key = self._key(attachment_id)
287
+ kwargs: dict[str, Any] = {
288
+ "Bucket": self.bucket,
289
+ "Key": key,
290
+ "Body": content,
291
+ "ContentType": content_type,
292
+ "Metadata": {
293
+ "lcp-sha256": sha256_hex,
294
+ "lcp-residency": self.residency,
295
+ "lcp-filename": filename,
296
+ "lcp-malware-status": "clean",
297
+ },
298
+ "ServerSideEncryption": "aws:kms",
299
+ "SSEKMSKeyId": self.kms_key_id,
300
+ "SSEKMSEncryptionContext": json.dumps({"lcp_residency": self.residency}, separators=(",", ":")),
301
+ }
302
+ try:
303
+ self.client.put_object(**kwargs)
304
+ except Exception as exc:
305
+ raise AttachmentError("object storage write failed") from exc
306
+ return f"{self._ref_prefix}{attachment_id}"
307
+
308
+ def read(self, storage_ref: str, *, expected_sha256: str) -> bytes:
309
+ attachment_id = self._attachment_id(storage_ref)
310
+ key = self._key(attachment_id)
311
+ try:
312
+ head = self.client.head_object(Bucket=self.bucket, Key=key)
313
+ metadata = head.get("Metadata", {})
314
+ if metadata.get("lcp-residency", "").upper() != self.residency:
315
+ raise AttachmentError("object residency metadata does not match policy")
316
+ if metadata.get("lcp-sha256", "").lower() != expected_sha256.lower():
317
+ raise AttachmentError("object metadata hash does not match the attachment record")
318
+ response = self.client.get_object(Bucket=self.bucket, Key=key)
319
+ body = response["Body"].read()
320
+ except AttachmentError:
321
+ raise
322
+ except Exception as exc:
323
+ raise AttachmentError("object storage read failed") from exc
324
+ if sha256(body).hexdigest() != expected_sha256:
325
+ raise AttachmentError("attachment integrity check failed")
326
+ return body
327
+
328
+ def delete(self, storage_ref: str) -> None:
329
+ attachment_id = self._attachment_id(storage_ref)
330
+ try:
331
+ self.client.delete_object(Bucket=self.bucket, Key=self._key(attachment_id))
332
+ except Exception as exc:
333
+ raise AttachmentError("object storage deletion failed") from exc
334
+
335
+
336
+ def build_attachment_store(config: Any, *, cipher: EnvelopeCipher) -> FileAttachmentStore | S3ObjectStorageAttachmentStore:
337
+ """Build the configured backend; imports cloud SDKs only when selected."""
338
+ if config.attachment_backend == "s3":
339
+ return S3ObjectStorageAttachmentStore(
340
+ bucket=config.attachment_object_bucket or "",
341
+ prefix=config.attachment_object_prefix,
342
+ residency=config.attachment_object_residency or "",
343
+ kms_key_id=config.attachment_object_kms_key_id or "",
344
+ endpoint_url=config.attachment_object_endpoint_url,
345
+ region_name=config.attachment_object_region,
346
+ )
347
+ if config.attachment_backend != "file":
348
+ raise AttachmentError("LCP_ATTACHMENT_BACKEND must be 'file' or 's3'")
349
+ return FileAttachmentStore(config.attachment_directory, cipher=cipher)
350
+
351
+
352
+ def build_malware_scanner(config: Any) -> MalwareScanner:
353
+ if config.attachment_scanner == "none":
354
+ return NoopMalwareScanner()
355
+ if config.attachment_scanner == "clamav":
356
+ return ClamAVMalwareScanner(
357
+ socket_path=config.attachment_clamav_socket,
358
+ host=config.attachment_clamav_host,
359
+ port=config.attachment_clamav_port,
360
+ )
361
+ raise AttachmentError("LCP_ATTACHMENT_SCANNER must be 'clamav' or 'none'")