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/__init__.py +17 -0
- lcp_platform/admin.py +90 -0
- lcp_platform/api_keys.py +50 -0
- lcp_platform/attachments.py +361 -0
- lcp_platform/auth.py +202 -0
- lcp_platform/config.py +157 -0
- lcp_platform/crypto.py +94 -0
- lcp_platform/database.py +18 -0
- lcp_platform/mapping.py +309 -0
- lcp_platform/matching.py +147 -0
- lcp_platform/messages.py +265 -0
- lcp_platform/postgres.py +235 -0
- lcp_platform/router.py +1116 -0
- lcp_platform/secrets.py +70 -0
- lcp_platform/security.py +76 -0
- lcp_platform/server.py +97 -0
- lcp_platform/service.py +147 -0
- lcp_platform/storage.py +1300 -0
- lcp_platform/validation.py +118 -0
- lcp_platform/worker.py +30 -0
- lcp_platform/wsgi.py +50 -0
- lcp_reference_platform-1.0.0.dist-info/METADATA +22 -0
- lcp_reference_platform-1.0.0.dist-info/RECORD +25 -0
- lcp_reference_platform-1.0.0.dist-info/WHEEL +4 -0
- lcp_reference_platform-1.0.0.dist-info/entry_points.txt +4 -0
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
|
lcp_platform/database.py
ADDED
|
@@ -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)
|