eigent/backend/app/workspace_bundle/secrets.py

345 lines
12 KiB
Python

"""Capability-authenticated client for the Electron local secret broker."""
from __future__ import annotations
import json
import os
import re
from collections.abc import MutableMapping, Sequence
from dataclasses import dataclass, field
from http.client import HTTPConnection, HTTPException
from typing import Literal
from urllib.parse import urlsplit
def _capture_broker_environment(
environment: MutableMapping[str, str],
) -> tuple[str, str]:
"""Take broker authority out of the environment inherited by agents."""
endpoint = environment.pop("EIGENT_WORKSPACE_SECRET_BROKER_ENDPOINT", "")
capability = environment.pop(
"EIGENT_WORKSPACE_SECRET_BROKER_CAPABILITY", ""
)
# Strip any obsolete or vendor-specific broker authority as well. Only the
# Workspace keys above are accepted as the active authority, but no broker
# credential may leak into an agent child process.
for key in tuple(environment):
if re.fullmatch(
r"EIGENT_[A-Z0-9_]+_SECRET_BROKER_(?:ENDPOINT|CAPABILITY)",
key.strip().upper(),
):
environment.pop(key, None)
return endpoint, capability
_BROKER_ENDPOINT, _BROKER_CAPABILITY = _capture_broker_environment(os.environ)
class WorkspaceSecretBrokerError(RuntimeError):
"""Raised when a local-only Bundle binding cannot be verified."""
@dataclass(frozen=True)
class WorkspaceSecretIdentity:
secret_ref: str
account_scope_digest: str
space_id: str
revision_id: str
slot_id: str
@dataclass(frozen=True)
class WorkspaceSecretVerification:
identity: WorkspaceSecretIdentity
state: Literal["available", "missing", "needs_rebind"]
@dataclass(frozen=True)
class WorkspaceSecretResolution:
"""One in-memory secret value resolved for its exact binding identity."""
identity: WorkspaceSecretIdentity
value: str = field(repr=False)
class WorkspaceSecretBroker:
"""Verify or resolve exact bindings through Electron's private channel."""
MAX_BATCH_BINDINGS = 100
MAX_REQUEST_BYTES = 16 * 1024
MAX_RESPONSE_BYTES = 64 * 1024
MAX_RESOLVE_RESPONSE_BYTES = 8 * 1024 * 1024
def __init__(
self,
*,
endpoint: str,
capability: str,
timeout_seconds: float = 3.0,
) -> None:
try:
parsed = urlsplit(endpoint)
except ValueError as exc:
raise WorkspaceSecretBrokerError(
"Workspace secret broker endpoint is invalid"
) from exc
if (
parsed.scheme != "http"
or parsed.hostname != "127.0.0.1"
or parsed.username is not None
or parsed.password is not None
or parsed.path not in {"", "/"}
or parsed.query
or parsed.fragment
):
raise WorkspaceSecretBrokerError(
"Workspace secret broker must use a loopback endpoint"
)
try:
port = parsed.port
except ValueError as exc:
raise WorkspaceSecretBrokerError(
"Workspace secret broker endpoint is invalid"
) from exc
if (
port is None
or not 1 <= port <= 65535
or re.fullmatch(r"[A-Za-z0-9_-]{32,256}", capability) is None
or not 0 < timeout_seconds <= 30
):
raise WorkspaceSecretBrokerError(
"Workspace secret broker configuration is invalid"
)
self._port = port
self._capability = capability
self._timeout_seconds = timeout_seconds
@classmethod
def from_environment(cls) -> WorkspaceSecretBroker:
if not _BROKER_ENDPOINT or not _BROKER_CAPABILITY:
raise WorkspaceSecretBrokerError(
"Workspace secret broker is unavailable"
)
return cls(
endpoint=_BROKER_ENDPOINT,
capability=_BROKER_CAPABILITY,
)
def verify(self, identity: WorkspaceSecretIdentity) -> None:
verification = self.verify_many((identity,))[0]
if verification.state != "available":
raise WorkspaceSecretBrokerError("Workspace secret is unavailable")
def verify_many(
self,
identities: Sequence[WorkspaceSecretIdentity],
) -> tuple[WorkspaceSecretVerification, ...]:
requested = tuple(identities)
if not requested:
return ()
if len(requested) > self.MAX_BATCH_BINDINGS:
raise WorkspaceSecretBrokerError(
"Workspace secret verification batch is too large"
)
response = self._request(
"/v1/workspace-secrets/verify-batch",
{
"bindings": [
self._identity_payload(identity) for identity in requested
]
},
)
if set(response) != {"statuses"}:
raise WorkspaceSecretBrokerError(
"Workspace secret broker returned an invalid verification"
)
statuses = response.get("statuses")
if not isinstance(statuses, list) or len(statuses) != len(requested):
raise WorkspaceSecretBrokerError(
"Workspace secret broker returned an invalid verification"
)
allowed_status_keys = {
"secret_ref",
"account_scope_digest",
"space_id",
"revision_id",
"slot_id",
"state",
"created_at",
"updated_at",
}
result: list[WorkspaceSecretVerification] = []
for identity, status in zip(requested, statuses, strict=True):
expected = self._identity_payload(identity)
if (
not isinstance(status, dict)
or not set(status).issubset(allowed_status_keys)
or any(
status.get(key) != value for key, value in expected.items()
)
or status.get("state")
not in {"available", "missing", "needs_rebind"}
):
raise WorkspaceSecretBrokerError(
"Workspace secret broker returned an invalid verification"
)
result.append(
WorkspaceSecretVerification(
identity=identity,
state=status["state"],
)
)
return tuple(result)
def resolve_many(
self,
identities: Sequence[WorkspaceSecretIdentity],
) -> tuple[WorkspaceSecretResolution, ...]:
"""Resolve values in memory without persisting or logging them.
Electron binds every value to the complete secret reference and
account/Space/revision/slot tuple. Any missing, stale, or mismatched
binding fails the entire batch closed.
"""
requested = tuple(identities)
if not requested:
return ()
if len(requested) > self.MAX_BATCH_BINDINGS:
raise WorkspaceSecretBrokerError(
"Workspace secret resolution batch is too large"
)
response = self._request(
"/v1/workspace-secrets/resolve-batch",
{
"bindings": [
self._identity_payload(identity) for identity in requested
]
},
max_response_bytes=self.MAX_RESOLVE_RESPONSE_BYTES,
)
if set(response) != {"resolutions"}:
raise WorkspaceSecretBrokerError(
"Workspace secret broker returned an invalid resolution"
)
resolutions = response.get("resolutions")
if not isinstance(resolutions, list) or len(resolutions) != len(
requested
):
raise WorkspaceSecretBrokerError(
"Workspace secret broker returned an invalid resolution"
)
allowed_keys = {
"secret_ref",
"account_scope_digest",
"space_id",
"revision_id",
"slot_id",
"value",
}
result: list[WorkspaceSecretResolution] = []
for identity, resolution in zip(requested, resolutions, strict=True):
expected = self._identity_payload(identity)
if (
not isinstance(resolution, dict)
or set(resolution) != allowed_keys
or any(
resolution.get(key) != value
for key, value in expected.items()
)
or not isinstance(resolution.get("value"), str)
or not resolution["value"]
):
raise WorkspaceSecretBrokerError(
"Workspace secret broker returned an invalid resolution"
)
result.append(
WorkspaceSecretResolution(
identity=identity,
value=resolution["value"],
)
)
return tuple(result)
@staticmethod
def _identity_payload(identity: WorkspaceSecretIdentity) -> dict[str, str]:
return {
"secret_ref": identity.secret_ref,
"account_scope_digest": identity.account_scope_digest,
"space_id": identity.space_id,
"revision_id": identity.revision_id,
"slot_id": identity.slot_id,
}
def _request(
self,
path: str,
request: dict,
*,
max_response_bytes: int | None = None,
) -> dict:
response_limit = max_response_bytes or self.MAX_RESPONSE_BYTES
encoded = json.dumps(
request, separators=(",", ":"), sort_keys=True
).encode("utf-8")
if len(encoded) > self.MAX_REQUEST_BYTES:
raise WorkspaceSecretBrokerError(
"Workspace secret broker request is too large"
)
connection = HTTPConnection(
"127.0.0.1",
self._port,
timeout=self._timeout_seconds,
)
try:
connection.request(
"POST",
path,
body=encoded,
headers={
"Authorization": f"Bearer {self._capability}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
http_response = connection.getresponse()
content_length = http_response.getheader("Content-Length")
if content_length is not None:
try:
declared_length = int(content_length)
except ValueError as exc:
raise WorkspaceSecretBrokerError(
"Workspace secret broker returned invalid metadata"
) from exc
if declared_length > response_limit:
raise WorkspaceSecretBrokerError(
"Workspace secret broker response is too large"
)
raw = http_response.read(response_limit + 1)
if len(raw) > response_limit:
raise WorkspaceSecretBrokerError(
"Workspace secret broker response is too large"
)
if http_response.status != 200:
raise WorkspaceSecretBrokerError(
"Workspace secret is unavailable "
f"(broker_status_{http_response.status})"
)
except (OSError, TimeoutError, HTTPException) as exc:
raise WorkspaceSecretBrokerError(
"Workspace secret broker is unavailable"
) from exc
finally:
connection.close()
try:
response = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise WorkspaceSecretBrokerError(
"Workspace secret broker returned an invalid response"
) from exc
if not isinstance(response, dict):
raise WorkspaceSecretBrokerError(
"Workspace secret broker returned an invalid response"
)
return response