feat(keystore,api,ui,docker): encrypted secrets store with a write-only admin page and a secret run helper for the model
This commit is contained in:
1 parent
2a47c6771d
commit
7b36cb2156
22 files changed
+1569
-5
No files matched your search
+5
-1
@@ -74,7 +74,7 @@ ENV BEAVER_CLAUDE_CLI=/usr/local/bin/claude-cli
|
|||||||
# `acl` lets the entrypoint grant it write access to the vault sub-mounts
|
# `acl` lets the entrypoint grant it write access to the vault sub-mounts
|
||||||
# without chowning files that Obsidian Sync keeps rewriting as root.
|
# without chowning files that Obsidian Sync keeps rewriting as root.
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends acl \
|
&& apt-get install -y --no-install-recommends acl sudo \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& useradd --system --uid 1001 --create-home --shell /usr/sbin/nologin beaver-runner
|
&& useradd --system --uid 1001 --create-home --shell /usr/sbin/nologin beaver-runner
|
||||||
|
|
||||||
@@ -83,6 +83,10 @@ ENV PATH=/app/.venv/bin:$PATH
|
|||||||
COPY --from=builder /app/.venv /app/.venv
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
COPY --from=builder /app /app
|
COPY --from=builder /app /app
|
||||||
COPY --from=ui /ui/build /app/ui/build
|
COPY --from=ui /ui/build /app/ui/build
|
||||||
|
COPY --chmod=0755 docker/secret /usr/local/bin/secret
|
||||||
|
COPY --chmod=0755 docker/beaver-secret /usr/local/libexec/beaver-secret
|
||||||
|
COPY --chmod=0440 docker/sudoers /etc/sudoers.d/beaver-secret
|
||||||
|
RUN visudo -cf /etc/sudoers.d/beaver-secret
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENTRYPOINT ["python", "-m"]
|
ENTRYPOINT ["python", "-m"]
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ is a full setup built on them.
|
|||||||
aggregated in-process and handed to agents by name.
|
aggregated in-process and handed to agents by name.
|
||||||
- **vault/** - watching a directory of notes for the envelope.
|
- **vault/** - watching a directory of notes for the envelope.
|
||||||
- **security/** - bearer tokens, the audit log, credential redaction.
|
- **security/** - bearer tokens, the audit log, credential redaction.
|
||||||
|
- **keystore/** - secrets the model runs commands with: values and files
|
||||||
|
sealed with `SECRETS_KEY` (AES-256-GCM) in the gateway's database, set
|
||||||
|
write-only from the admin, handed out only through `secret run NAME --
|
||||||
|
cmd` (one `sudo` rule for `beaver-runner`, a root-only socket, files in
|
||||||
|
`/dev/shm`, values masked in the command's output).
|
||||||
- **events/** - the in-process bus every frontend taps, and the stream
|
- **events/** - the in-process bus every frontend taps, and the stream
|
||||||
event protocol backends emit.
|
event protocol backends emit.
|
||||||
- **storage/** - SQLModel tables and the Postgres session store.
|
- **storage/** - SQLModel tables and the Postgres session store.
|
||||||
@@ -40,7 +45,7 @@ is a full setup built on them.
|
|||||||
`config.py` defines `Gateway` and loads a setup; `app.py` builds the runtime
|
`config.py` defines `Gateway` and loads a setup; `app.py` builds the runtime
|
||||||
from it; `cli.py` is the entrypoint. The admin SPA in `ui/` is the browser
|
from it; `cli.py` is the entrypoint. The admin SPA in `ui/` is the browser
|
||||||
window on all of it: conversations by day, the thread with the tree of tool
|
window on all of it: conversations by day, the thread with the tree of tool
|
||||||
calls, the context of the last turn, memory, jobs, usage, tokens and the audit.
|
calls, the context of the last turn, memory, jobs, usage, tokens, secrets and the audit.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
exec /usr/local/bin/python3 -I /app/src/beaver_gateway/keystore/cli.py --root "$@"
|
||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
exec /usr/local/bin/python3 -I /app/src/beaver_gateway/keystore/cli.py "$@"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Defaults:beaver-runner !syslog
|
||||||
|
beaver-runner ALL=(root) NOPASSWD: /usr/local/libexec/beaver-secret
|
||||||
@@ -16,6 +16,7 @@ dependencies = [
|
|||||||
"argon2-cffi>=25.1.0",
|
"argon2-cffi>=25.1.0",
|
||||||
"claude-agent-sdk>=0.2.157",
|
"claude-agent-sdk>=0.2.157",
|
||||||
"croniter>=6.2.4",
|
"croniter>=6.2.4",
|
||||||
|
"cryptography>=48.0.0",
|
||||||
"fastapi>=0.136.1",
|
"fastapi>=0.136.1",
|
||||||
"fastmcp>=3.3.1",
|
"fastmcp>=3.3.1",
|
||||||
"greenlet>=3.5.0",
|
"greenlet>=3.5.0",
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ from beaver_gateway.frontends.base import GatewayRuntime
|
|||||||
from beaver_gateway.frontends.bearer import require_token
|
from beaver_gateway.frontends.bearer import require_token
|
||||||
from beaver_gateway.frontends.root import build_root_app
|
from beaver_gateway.frontends.root import build_root_app
|
||||||
from beaver_gateway.jobs.scheduler import Scheduler
|
from beaver_gateway.jobs.scheduler import Scheduler
|
||||||
|
from beaver_gateway.keystore import SecretBox, SecretStore
|
||||||
|
from beaver_gateway.keystore import serve as serve_secrets
|
||||||
from beaver_gateway.mcp.internal_app import build_internal_app
|
from beaver_gateway.mcp.internal_app import build_internal_app
|
||||||
from beaver_gateway.security.auth import TokenStore, scopes_with
|
from beaver_gateway.security.auth import TokenStore, scopes_with
|
||||||
from beaver_gateway.storage import (
|
from beaver_gateway.storage import (
|
||||||
@@ -44,7 +46,7 @@ from beaver_gateway.storage import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterable, Iterator
|
from collections.abc import Iterable, Iterator, Sequence
|
||||||
|
|
||||||
from claude_agent_sdk import McpSdkServerConfig
|
from claude_agent_sdk import McpSdkServerConfig
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
@@ -120,6 +122,7 @@ async def run(gateway: Gateway, settings: Settings) -> None:
|
|||||||
mcps = McpRegistry(gateway.mcps)
|
mcps = McpRegistry(gateway.mcps)
|
||||||
db = Database(settings.database_url)
|
db = Database(settings.database_url)
|
||||||
await db.create_all()
|
await db.create_all()
|
||||||
|
secrets = SecretStore(db, _secret_box(settings))
|
||||||
token_store = TokenStore(
|
token_store = TokenStore(
|
||||||
db,
|
db,
|
||||||
bootstrap=TokenStore.parse_bootstrap(settings.bootstrap_tokens),
|
bootstrap=TokenStore.parse_bootstrap(settings.bootstrap_tokens),
|
||||||
@@ -197,6 +200,7 @@ async def run(gateway: Gateway, settings: Settings) -> None:
|
|||||||
public_url=gateway.public_url.rstrip("/") if gateway.public_url else None,
|
public_url=gateway.public_url.rstrip("/") if gateway.public_url else None,
|
||||||
scopes=scopes_with(fe.scope for fe in gateway.frontends),
|
scopes=scopes_with(fe.scope for fe in gateway.frontends),
|
||||||
send_file_roots=_roots(settings.send_file_roots),
|
send_file_roots=_roots(settings.send_file_roots),
|
||||||
|
secrets=secrets,
|
||||||
)
|
)
|
||||||
for fe in gateway.frontends:
|
for fe in gateway.frontends:
|
||||||
fe.configure(runtime)
|
fe.configure(runtime)
|
||||||
@@ -224,10 +228,43 @@ async def run(gateway: Gateway, settings: Settings) -> None:
|
|||||||
tg.create_task(_serve_root(gateway, extra={"/hooks": hooks}))
|
tg.create_task(_serve_root(gateway, extra={"/hooks": hooks}))
|
||||||
if gateway.watch is not None:
|
if gateway.watch is not None:
|
||||||
tg.create_task(gateway.watch.run())
|
tg.create_task(gateway.watch.run())
|
||||||
|
if secrets.enabled:
|
||||||
|
tg.create_task(_serve_secrets(secrets, settings=settings, db=db))
|
||||||
for fe in gateway.frontends:
|
for fe in gateway.frontends:
|
||||||
tg.create_task(fe.serve())
|
tg.create_task(fe.serve())
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_box(settings: Settings) -> SecretBox | None:
|
||||||
|
if not settings.secrets_key:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return SecretBox.from_hex(settings.secrets_key)
|
||||||
|
except ValueError as exc:
|
||||||
|
_log.error("secrets are off: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _serve_secrets(
|
||||||
|
store: SecretStore, *, settings: Settings, db: Database
|
||||||
|
) -> None:
|
||||||
|
async def used(names: Sequence[str]) -> None:
|
||||||
|
try:
|
||||||
|
async with db.session() as session:
|
||||||
|
await append_audit(
|
||||||
|
session,
|
||||||
|
actor="runner",
|
||||||
|
kind="secret_use",
|
||||||
|
detail={"names": list(names)},
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_log.exception("secret audit write failed")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await serve_secrets(store, settings.secrets_socket, on_use=used)
|
||||||
|
except OSError:
|
||||||
|
_log.exception("secrets socket %s did not open", settings.secrets_socket)
|
||||||
|
|
||||||
|
|
||||||
async def _authorize_hook(
|
async def _authorize_hook(
|
||||||
request: Request, *, runtime: GatewayRuntime, scope: str
|
request: Request, *, runtime: GatewayRuntime, scope: str
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"""``ApiFrontend`` - ``/api/*``: conversations, SSE, sessions, usage, limits.
|
"""``ApiFrontend`` - ``/api/*``: conversations, SSE, sessions, usage, limits.
|
||||||
|
|
||||||
Bearer scope ``api``; token and audit management need ``admin``. Every
|
Bearer scope ``api``; tokens, secrets and audit need ``admin``. Every
|
||||||
write goes through conversations/service.py; the frontend only shapes JSON.
|
write goes through conversations/service.py; the frontend only shapes JSON.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
@@ -34,6 +36,7 @@ from beaver_gateway.frontends.sse import (
|
|||||||
sse_pack,
|
sse_pack,
|
||||||
)
|
)
|
||||||
from beaver_gateway.frontends.urls import frontend_url
|
from beaver_gateway.frontends.urls import frontend_url
|
||||||
|
from beaver_gateway.keystore import SecretsDisabledError
|
||||||
from beaver_gateway.security import audit
|
from beaver_gateway.security import audit
|
||||||
from beaver_gateway.security.auth import hash_token
|
from beaver_gateway.security.auth import hash_token
|
||||||
from beaver_gateway.storage import (
|
from beaver_gateway.storage import (
|
||||||
@@ -46,6 +49,7 @@ from beaver_gateway.storage.models import (
|
|||||||
Conversation,
|
Conversation,
|
||||||
InjectQueueItem,
|
InjectQueueItem,
|
||||||
RateLimit,
|
RateLimit,
|
||||||
|
Secret,
|
||||||
Token,
|
Token,
|
||||||
Usage,
|
Usage,
|
||||||
)
|
)
|
||||||
@@ -978,6 +982,58 @@ def build_app( # noqa: PLR0915
|
|||||||
)
|
)
|
||||||
return {"id": token_id, "revoked": True}
|
return {"id": token_id, "revoked": True}
|
||||||
|
|
||||||
|
@app.get("/secrets")
|
||||||
|
async def secrets_list(request: Request) -> dict[str, Any]:
|
||||||
|
await require_token(request, runtime, scope=ADMIN_SCOPE)
|
||||||
|
store = runtime.secrets
|
||||||
|
rows = await store.rows() if store is not None else []
|
||||||
|
return {
|
||||||
|
"enabled": store is not None and store.enabled,
|
||||||
|
"secrets": [_secret_public(r) for r in rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.put("/secrets/{name}")
|
||||||
|
async def secret_put(name: str, request: Request) -> dict[str, Any]:
|
||||||
|
actor = await require_token(request, runtime, scope=ADMIN_SCOPE)
|
||||||
|
store = runtime.secrets
|
||||||
|
if store is None or not store.enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_503_SERVICE_UNAVAILABLE, "SECRETS_KEY is not set"
|
||||||
|
)
|
||||||
|
data = await body_of(request)
|
||||||
|
description = data.get("description")
|
||||||
|
payload, filename = _secret_payload(data)
|
||||||
|
try:
|
||||||
|
row = await store.put(
|
||||||
|
name,
|
||||||
|
description=str(description) if description is not None else None,
|
||||||
|
data=payload,
|
||||||
|
filename=filename,
|
||||||
|
)
|
||||||
|
except SecretsDisabledError as exc:
|
||||||
|
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||||
|
await audit.log(
|
||||||
|
runtime,
|
||||||
|
actor=f"token:{actor}",
|
||||||
|
kind="secret_set",
|
||||||
|
name=name,
|
||||||
|
replaced=payload is not None,
|
||||||
|
)
|
||||||
|
return {"secret": _secret_public(row)}
|
||||||
|
|
||||||
|
@app.delete("/secrets/{name}")
|
||||||
|
async def secret_delete(name: str, request: Request) -> dict[str, Any]:
|
||||||
|
actor = await require_token(request, runtime, scope=ADMIN_SCOPE)
|
||||||
|
store = runtime.secrets
|
||||||
|
if store is None or not await store.delete(name):
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, f"no secret {name}")
|
||||||
|
await audit.log(
|
||||||
|
runtime, actor=f"token:{actor}", kind="secret_delete", name=name
|
||||||
|
)
|
||||||
|
return {"name": name, "deleted": True}
|
||||||
|
|
||||||
@app.get("/audit")
|
@app.get("/audit")
|
||||||
async def audit_list(request: Request) -> dict[str, Any]:
|
async def audit_list(request: Request) -> dict[str, Any]:
|
||||||
await require_token(request, runtime, scope=ADMIN_SCOPE)
|
await require_token(request, runtime, scope=ADMIN_SCOPE)
|
||||||
@@ -1254,6 +1310,46 @@ def _token_public(row: Token) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_public(row: Secret) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": row.name,
|
||||||
|
"description": row.description,
|
||||||
|
"kind": "file" if row.filename else "value",
|
||||||
|
"filename": row.filename,
|
||||||
|
"size": row.size,
|
||||||
|
"created_at": _iso(row.created_at),
|
||||||
|
"updated_at": _iso(row.updated_at),
|
||||||
|
"used_at": _iso(row.used_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_payload(data: dict[str, Any]) -> tuple[bytes | None, str | None]:
|
||||||
|
value, upload = data.get("value"), data.get("file")
|
||||||
|
if value is not None and upload is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "send `value` or `file`, not both"
|
||||||
|
)
|
||||||
|
if isinstance(value, str):
|
||||||
|
if not value:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "`value` is empty")
|
||||||
|
return value.encode(), None
|
||||||
|
if isinstance(upload, dict):
|
||||||
|
filename, content = upload.get("name"), upload.get("content")
|
||||||
|
if not isinstance(filename, str) or not isinstance(content, str):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "`file` needs `name` and base64 `content`"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return base64.b64decode(content, validate=True), filename
|
||||||
|
except binascii.Error as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "`file.content` is not base64"
|
||||||
|
) from exc
|
||||||
|
if value is not None or upload is not None:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "`value` must be a string")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def _detail(raw: str) -> Any:
|
def _detail(raw: str) -> Any:
|
||||||
try:
|
try:
|
||||||
return json.loads(raw) if raw else {}
|
return json.loads(raw) if raw else {}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ if TYPE_CHECKING:
|
|||||||
from beaver_gateway.backends.base import Backend
|
from beaver_gateway.backends.base import Backend
|
||||||
from beaver_gateway.conversations.kinds import Kind
|
from beaver_gateway.conversations.kinds import Kind
|
||||||
from beaver_gateway.frontends.turn_record import TurnRecord
|
from beaver_gateway.frontends.turn_record import TurnRecord
|
||||||
|
from beaver_gateway.keystore import SecretStore
|
||||||
from beaver_gateway.security.auth import TokenStore
|
from beaver_gateway.security.auth import TokenStore
|
||||||
from beaver_gateway.storage import Database
|
from beaver_gateway.storage import Database
|
||||||
from beaver_gateway.storage.models import Conversation, ConversationBinding
|
from beaver_gateway.storage.models import Conversation, ConversationBinding
|
||||||
@@ -62,6 +63,8 @@ class GatewayRuntime:
|
|||||||
"""Every token scope this gateway knows: the builtins plus each frontend's."""
|
"""Every token scope this gateway knows: the builtins plus each frontend's."""
|
||||||
send_file_roots: tuple[Path, ...] = ()
|
send_file_roots: tuple[Path, ...] = ()
|
||||||
"""Directories ``send_file`` may read, from ``SEND_FILE_ROOTS``."""
|
"""Directories ``send_file`` may read, from ``SEND_FILE_ROOTS``."""
|
||||||
|
secrets: SecretStore | None = None
|
||||||
|
"""Sealed secrets for ``secret run``; ``None`` in setups without them."""
|
||||||
|
|
||||||
|
|
||||||
class Frontend(ABC):
|
class Frontend(ABC):
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Secrets the model runs commands with: sealed in the database, handed out to root."""
|
||||||
|
|
||||||
|
from beaver_gateway.keystore.server import serve
|
||||||
|
from beaver_gateway.keystore.store import (
|
||||||
|
NAME,
|
||||||
|
SecretBox,
|
||||||
|
SecretsDisabledError,
|
||||||
|
SecretStore,
|
||||||
|
UnknownSecretError,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"NAME",
|
||||||
|
"SecretBox",
|
||||||
|
"SecretStore",
|
||||||
|
"SecretsDisabledError",
|
||||||
|
"UnknownSecretError",
|
||||||
|
"serve",
|
||||||
|
]
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""``secret ls`` and ``secret run NAME... -- command``: stdlib only, run by path.
|
||||||
|
|
||||||
|
The model's uid calls this file; it asks the root half (the same file
|
||||||
|
through ``sudo``) for the values, which reads them from the gateway's
|
||||||
|
root-only socket. The command then runs as the model's uid with each
|
||||||
|
value in its own variable, files in a private tmpfs directory, and the
|
||||||
|
values masked in what it prints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import IO, TYPE_CHECKING, Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from io import BufferedReader
|
||||||
|
|
||||||
|
SOCKET = Path("/run/beaver-secrets/gateway.sock")
|
||||||
|
ROOT_HALF = ("sudo", "-n", "/usr/local/libexec/beaver-secret")
|
||||||
|
SHM = Path("/dev/shm") # noqa: S108
|
||||||
|
PREFIX = "beaver-secret-"
|
||||||
|
NAME = re.compile(r"[A-Z][A-Z0-9_]{0,63}")
|
||||||
|
MIN_MASKED = 6
|
||||||
|
HOLD = 1 << 16
|
||||||
|
CHUNK = 1 << 16
|
||||||
|
DRAIN = 2.0
|
||||||
|
|
||||||
|
USAGE = """usage:
|
||||||
|
secret ls names and descriptions
|
||||||
|
secret run NAME [NAME...] -- CMD run CMD with each secret in $NAME
|
||||||
|
|
||||||
|
A value secret arrives as its text; a file secret as the path to a
|
||||||
|
private copy that is removed when CMD exits. Values are masked in
|
||||||
|
CMD's output as <secret:NAME>.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class SecretError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def request(payload: dict[str, Any], path: Path = SOCKET) -> dict[str, Any]:
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as conn:
|
||||||
|
try:
|
||||||
|
conn.connect(str(path))
|
||||||
|
except OSError as exc:
|
||||||
|
msg = f"the gateway's secrets socket is not reachable ({exc.strerror})"
|
||||||
|
raise SecretError(msg) from exc
|
||||||
|
conn.sendall(json.dumps(payload).encode() + b"\n")
|
||||||
|
chunks = []
|
||||||
|
while chunk := conn.recv(CHUNK):
|
||||||
|
chunks.append(chunk)
|
||||||
|
reply = json.loads(b"".join(chunks) or b"{}")
|
||||||
|
if not reply.get("ok"):
|
||||||
|
raise SecretError(reply.get("error") or "the gateway gave no answer")
|
||||||
|
return reply
|
||||||
|
|
||||||
|
|
||||||
|
def root_half(argv: list[str]) -> int:
|
||||||
|
if argv[:1] == ["ls"] and len(argv) == 1:
|
||||||
|
payload: dict[str, Any] = {"op": "ls"}
|
||||||
|
elif (
|
||||||
|
argv[:1] == ["get"]
|
||||||
|
and len(argv) > 1
|
||||||
|
and all(NAME.fullmatch(a) for a in argv[1:])
|
||||||
|
):
|
||||||
|
payload = {"op": "get", "names": argv[1:]}
|
||||||
|
else:
|
||||||
|
print("beaver-secret: ls | get NAME...", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
reply = request(payload)
|
||||||
|
except SecretError as exc:
|
||||||
|
reply = {"ok": False, "error": str(exc)}
|
||||||
|
sys.stdout.write(json.dumps(reply))
|
||||||
|
return 0 if reply.get("ok") else 1
|
||||||
|
|
||||||
|
|
||||||
|
def ask(argv: list[str]) -> dict[str, Any]:
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
return request(
|
||||||
|
{"op": "ls"} if argv == ["ls"] else {"op": "get", "names": argv[1:]}
|
||||||
|
)
|
||||||
|
proc = subprocess.run( # noqa: S603
|
||||||
|
[*ROOT_HALF, *argv], capture_output=True, check=False
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
reply = json.loads(proc.stdout)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
err = proc.stderr.decode(errors="replace").strip()
|
||||||
|
msg = f"the root helper failed: {err or f'exit {proc.returncode}'}"
|
||||||
|
raise SecretError(msg) from None
|
||||||
|
if not reply.get("ok"):
|
||||||
|
raise SecretError(reply.get("error") or "the root helper gave no answer")
|
||||||
|
return reply
|
||||||
|
|
||||||
|
|
||||||
|
def list_secrets() -> int:
|
||||||
|
rows = ask(["ls"])["secrets"]
|
||||||
|
if not rows:
|
||||||
|
print("no secrets yet; a human adds them in the admin, System -> Secrets")
|
||||||
|
return 0
|
||||||
|
width = max(len(r["name"]) for r in rows)
|
||||||
|
for r in rows:
|
||||||
|
kind = f"file {r['filename']}" if r.get("filename") else "value"
|
||||||
|
line = f"{r['name']:<{width}} {kind}"
|
||||||
|
if r.get("description"):
|
||||||
|
line += f" - {r['description']}"
|
||||||
|
print(line)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class Masker:
|
||||||
|
"""Streams bytes through, replacing every needle; never splits one."""
|
||||||
|
|
||||||
|
def __init__(self, needles: dict[bytes, bytes]) -> None:
|
||||||
|
self._needles = needles
|
||||||
|
self._longest = max((len(n) for n in needles), default=0)
|
||||||
|
ordered = sorted(needles, key=lambda n: -len(n))
|
||||||
|
self._pattern = (
|
||||||
|
re.compile(b"|".join(re.escape(n) for n in ordered)) if ordered else None
|
||||||
|
)
|
||||||
|
self._pending = b""
|
||||||
|
|
||||||
|
def feed(self, data: bytes) -> bytes:
|
||||||
|
if self._pattern is None:
|
||||||
|
return data
|
||||||
|
self._pending += data
|
||||||
|
cut = self._pending.rfind(b"\n") + 1
|
||||||
|
if len(self._pending) - cut > HOLD:
|
||||||
|
cut = max(cut, len(self._pending) - self._longest + 1)
|
||||||
|
for match in self._pattern.finditer(self._pending):
|
||||||
|
if match.start() < cut < match.end():
|
||||||
|
cut = match.end()
|
||||||
|
head, self._pending = self._pending[:cut], self._pending[cut:]
|
||||||
|
return self._mask(head)
|
||||||
|
|
||||||
|
def flush(self) -> bytes:
|
||||||
|
tail, self._pending = self._pending, b""
|
||||||
|
return self._mask(tail)
|
||||||
|
|
||||||
|
def _mask(self, data: bytes) -> bytes:
|
||||||
|
if self._pattern is None or not data:
|
||||||
|
return data
|
||||||
|
return self._pattern.sub(lambda m: self._needles[m[0]], data)
|
||||||
|
|
||||||
|
|
||||||
|
def needles_for(values: dict[str, bytes]) -> dict[bytes, bytes]:
|
||||||
|
out: dict[bytes, bytes] = {}
|
||||||
|
for name, data in values.items():
|
||||||
|
mask = f"<secret:{name}>".encode()
|
||||||
|
try:
|
||||||
|
text = data.decode()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
for part in text.splitlines() or [text]:
|
||||||
|
for form in {part.strip(), quote(part.strip(), safe="")}:
|
||||||
|
if len(form) >= MIN_MASKED:
|
||||||
|
out[form.encode()] = mask
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def sweep(root: Path) -> None:
|
||||||
|
for stale in root.glob(f"{PREFIX}*"):
|
||||||
|
pid = stale.name.removeprefix(PREFIX).split("-", 1)[0]
|
||||||
|
if not pid.isdigit() or _alive(int(pid)):
|
||||||
|
continue
|
||||||
|
shutil.rmtree(stale, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _alive(pid: int) -> bool:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
return True
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _pump(src: BufferedReader, dst: IO[bytes], masker: Masker) -> None:
|
||||||
|
while chunk := src.read1(CHUNK):
|
||||||
|
dst.write(masker.feed(chunk))
|
||||||
|
dst.flush()
|
||||||
|
dst.write(masker.flush())
|
||||||
|
dst.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def run(names: list[str], command: list[str], *, root: Path | None = None) -> int:
|
||||||
|
secrets = ask(["get", *names])["secrets"]
|
||||||
|
base = root or (SHM if SHM.is_dir() else Path(tempfile.gettempdir()))
|
||||||
|
sweep(base)
|
||||||
|
workdir = Path(tempfile.mkdtemp(prefix=f"{PREFIX}{os.getpid()}-", dir=base))
|
||||||
|
env = dict(os.environ)
|
||||||
|
values: dict[str, bytes] = {}
|
||||||
|
try:
|
||||||
|
for name in names:
|
||||||
|
item = secrets[name]
|
||||||
|
data = base64.b64decode(item["data"])
|
||||||
|
if item.get("filename"):
|
||||||
|
path = workdir / name / item["filename"]
|
||||||
|
path.parent.mkdir(mode=0o700)
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
with os.fdopen(fd, "wb") as fh:
|
||||||
|
fh.write(data)
|
||||||
|
env[name] = str(path)
|
||||||
|
else:
|
||||||
|
env[name] = data.decode(errors="surrogateescape")
|
||||||
|
values[name] = data
|
||||||
|
return _spawn(command, env, needles_for(values))
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(workdir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn(command: list[str], env: dict[str, str], needles: dict[bytes, bytes]) -> int:
|
||||||
|
try:
|
||||||
|
child = subprocess.Popen( # noqa: S603
|
||||||
|
command, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
reason = exc.strerror if isinstance(exc, OSError) else str(exc)
|
||||||
|
print(f"secret: cannot run {command[0]}: {reason}", file=sys.stderr)
|
||||||
|
return 127
|
||||||
|
forwarded = (
|
||||||
|
(signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
|
||||||
|
if threading.current_thread() is threading.main_thread()
|
||||||
|
else ()
|
||||||
|
)
|
||||||
|
previous = {
|
||||||
|
sig: signal.signal(sig, lambda s, _f: child.send_signal(s)) for sig in forwarded
|
||||||
|
}
|
||||||
|
pumps = [
|
||||||
|
threading.Thread(target=_pump, args=(src, dst, Masker(needles)), daemon=True)
|
||||||
|
for src, dst in (
|
||||||
|
(child.stdout, sys.stdout.buffer),
|
||||||
|
(child.stderr, sys.stderr.buffer),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for pump in pumps:
|
||||||
|
pump.start()
|
||||||
|
try:
|
||||||
|
code = child.wait()
|
||||||
|
for pump in pumps:
|
||||||
|
pump.join(DRAIN)
|
||||||
|
finally:
|
||||||
|
for sig, handler in previous.items():
|
||||||
|
signal.signal(sig, handler)
|
||||||
|
return 128 - code if code < 0 else code
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = sys.argv[1:] if argv is None else argv
|
||||||
|
if args[:1] == ["--root"]:
|
||||||
|
return root_half(args[1:])
|
||||||
|
try:
|
||||||
|
if args == ["ls"]:
|
||||||
|
return list_secrets()
|
||||||
|
if args[:1] == ["run"] and "--" in args:
|
||||||
|
split = args.index("--")
|
||||||
|
names, command = args[1:split], args[split + 1 :]
|
||||||
|
bad = [n for n in names if not NAME.fullmatch(n)]
|
||||||
|
if names and command and not bad:
|
||||||
|
return run(names, command)
|
||||||
|
if bad:
|
||||||
|
print(f"secret: invalid name {bad[0]!r}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
except SecretError as exc:
|
||||||
|
print(f"secret: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(USAGE, end="", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
with contextlib.suppress(KeyboardInterrupt):
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Root-only unix socket that hands sealed secrets to the ``secret`` helper."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from beaver_gateway.keystore.store import (
|
||||||
|
SecretsDisabledError,
|
||||||
|
UnknownSecretError,
|
||||||
|
check_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Awaitable, Callable, Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from beaver_gateway.keystore.store import SecretStore
|
||||||
|
|
||||||
|
_log = logging.getLogger("beaver_gateway.keystore")
|
||||||
|
|
||||||
|
REQUEST_LIMIT = 1 << 16
|
||||||
|
|
||||||
|
|
||||||
|
async def serve(
|
||||||
|
store: SecretStore,
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
on_use: Callable[[Sequence[str]], Awaitable[None]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Listen on ``path`` (mode 0600 in a 0700 directory) until cancelled."""
|
||||||
|
_clear(path)
|
||||||
|
|
||||||
|
async def handle(
|
||||||
|
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
line = await reader.readline()
|
||||||
|
reply = await answer(store, line, on_use=on_use)
|
||||||
|
writer.write(json.dumps(reply).encode() + b"\n")
|
||||||
|
await writer.drain()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_log.exception("secret request failed")
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await writer.wait_closed()
|
||||||
|
|
||||||
|
server = await asyncio.start_unix_server(
|
||||||
|
handle, path=str(path), limit=REQUEST_LIMIT
|
||||||
|
)
|
||||||
|
_lock(path)
|
||||||
|
_log.info("secrets socket on %s", path)
|
||||||
|
async with server:
|
||||||
|
await server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
async def answer(
|
||||||
|
store: SecretStore,
|
||||||
|
line: bytes,
|
||||||
|
*,
|
||||||
|
on_use: Callable[[Sequence[str]], Awaitable[None]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
request = json.loads(line)
|
||||||
|
op = request.get("op") if isinstance(request, dict) else None
|
||||||
|
if op == "ls":
|
||||||
|
return {"ok": True, "secrets": [_listed(r) for r in await store.rows()]}
|
||||||
|
if op == "get":
|
||||||
|
names = [check_name(str(n)) for n in request.get("names") or ()]
|
||||||
|
if not names:
|
||||||
|
return {"ok": False, "error": "no secret names given"}
|
||||||
|
revealed = await store.reveal(names)
|
||||||
|
if on_use is not None:
|
||||||
|
await on_use(names)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"secrets": {
|
||||||
|
r.name: {
|
||||||
|
"data": base64.b64encode(r.data).decode(),
|
||||||
|
"filename": r.filename,
|
||||||
|
}
|
||||||
|
for r in revealed
|
||||||
|
},
|
||||||
|
}
|
||||||
|
except UnknownSecretError as exc:
|
||||||
|
return {"ok": False, "error": f"unknown secret: {exc}"}
|
||||||
|
except (SecretsDisabledError, ValueError) as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
return {"ok": False, "error": "op must be `ls` or `get`"}
|
||||||
|
|
||||||
|
|
||||||
|
def _clear(path: Path) -> None:
|
||||||
|
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
path.parent.chmod(0o700)
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _lock(path: Path) -> None:
|
||||||
|
path.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def _listed(row: Any) -> dict[str, Any]:
|
||||||
|
return {"name": row.name, "description": row.description, "filename": row.filename}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""Seal, list, replace and reveal secrets; the key never leaves this module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from sqlmodel import col, select
|
||||||
|
|
||||||
|
from beaver_gateway.storage.models import Secret
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable, Sequence
|
||||||
|
|
||||||
|
from beaver_gateway.storage import Database
|
||||||
|
|
||||||
|
NAME = re.compile(r"[A-Z][A-Z0-9_]{0,63}")
|
||||||
|
"""A secret's name is the environment variable a command receives it under."""
|
||||||
|
|
||||||
|
KEY_BYTES = 32
|
||||||
|
NONCE_BYTES = 12
|
||||||
|
MAX_SIZE = 1 << 20
|
||||||
|
"""Largest value or file the store accepts, in bytes."""
|
||||||
|
|
||||||
|
|
||||||
|
class SecretsDisabledError(RuntimeError):
|
||||||
|
"""``SECRETS_KEY`` is unset or unusable, so nothing can be sealed or opened."""
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownSecretError(LookupError):
|
||||||
|
"""A name that is not in the store."""
|
||||||
|
|
||||||
|
|
||||||
|
class SecretBox:
|
||||||
|
"""AES-256-GCM with the secret's name as associated data."""
|
||||||
|
|
||||||
|
__slots__ = ("_aead",)
|
||||||
|
|
||||||
|
def __init__(self, key: bytes) -> None:
|
||||||
|
if len(key) != KEY_BYTES:
|
||||||
|
msg = f"SECRETS_KEY must be {KEY_BYTES} bytes, got {len(key)}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
self._aead = AESGCM(key)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_hex(cls, text: str) -> SecretBox:
|
||||||
|
try:
|
||||||
|
key = bytes.fromhex(text.strip())
|
||||||
|
except ValueError as exc:
|
||||||
|
msg = "SECRETS_KEY must be hex, e.g. `openssl rand -hex 32`"
|
||||||
|
raise ValueError(msg) from exc
|
||||||
|
return cls(key)
|
||||||
|
|
||||||
|
def seal(self, name: str, data: bytes) -> bytes:
|
||||||
|
nonce = os.urandom(NONCE_BYTES)
|
||||||
|
return nonce + self._aead.encrypt(nonce, data, name.encode())
|
||||||
|
|
||||||
|
def open(self, name: str, sealed: bytes) -> bytes:
|
||||||
|
nonce, body = sealed[:NONCE_BYTES], sealed[NONCE_BYTES:]
|
||||||
|
return self._aead.decrypt(nonce, body, name.encode())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Revealed:
|
||||||
|
name: str
|
||||||
|
data: bytes
|
||||||
|
filename: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def check_name(name: str) -> str:
|
||||||
|
if not NAME.fullmatch(name):
|
||||||
|
msg = (
|
||||||
|
f"invalid secret name {name!r}: use A-Z, 0-9 and _, starting with a letter"
|
||||||
|
)
|
||||||
|
raise ValueError(msg)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def clean_filename(filename: str) -> str:
|
||||||
|
base = os.path.basename(filename.replace("\\", "/")).strip() # noqa: PTH119
|
||||||
|
if base in ("", ".", ".."):
|
||||||
|
msg = f"invalid file name {filename!r}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
class SecretStore:
|
||||||
|
"""Rows of :class:`Secret`; ``box`` is ``None`` when no key is configured."""
|
||||||
|
|
||||||
|
__slots__ = ("_box", "_db")
|
||||||
|
|
||||||
|
def __init__(self, db: Database, box: SecretBox | None) -> None:
|
||||||
|
self._db = db
|
||||||
|
self._box = box
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self._box is not None
|
||||||
|
|
||||||
|
def _require_box(self) -> SecretBox:
|
||||||
|
if self._box is None:
|
||||||
|
msg = "SECRETS_KEY is not set"
|
||||||
|
raise SecretsDisabledError(msg)
|
||||||
|
return self._box
|
||||||
|
|
||||||
|
async def rows(self) -> Sequence[Secret]:
|
||||||
|
async with self._db.session() as session:
|
||||||
|
result = await session.exec(select(Secret).order_by(col(Secret.name)))
|
||||||
|
return result.all()
|
||||||
|
|
||||||
|
async def put(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
description: str | None = None,
|
||||||
|
data: bytes | None = None,
|
||||||
|
filename: str | None = None,
|
||||||
|
) -> Secret:
|
||||||
|
"""Create or replace; ``data=None`` keeps the value, sets the description."""
|
||||||
|
check_name(name)
|
||||||
|
if data is not None:
|
||||||
|
if len(data) > MAX_SIZE:
|
||||||
|
msg = f"secret {name} is {len(data)} bytes, the limit is {MAX_SIZE}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
sealed = self._require_box().seal(name, data)
|
||||||
|
async with self._db.session() as session:
|
||||||
|
row = await session.get(Secret, name)
|
||||||
|
if row is None:
|
||||||
|
if data is None:
|
||||||
|
msg = f"secret {name} needs a value or a file"
|
||||||
|
raise ValueError(msg)
|
||||||
|
row = Secret(name=name, sealed=sealed)
|
||||||
|
if data is not None:
|
||||||
|
row.sealed = sealed
|
||||||
|
row.size = len(data)
|
||||||
|
row.filename = clean_filename(filename) if filename else None
|
||||||
|
if description is not None:
|
||||||
|
row.description = description.strip()
|
||||||
|
row.updated_at = _now()
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
async def delete(self, name: str) -> bool:
|
||||||
|
async with self._db.session() as session:
|
||||||
|
row = await session.get(Secret, name)
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
await session.delete(row)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def reveal(self, names: Iterable[str]) -> list[Revealed]:
|
||||||
|
box = self._require_box()
|
||||||
|
wanted = list(dict.fromkeys(names))
|
||||||
|
async with self._db.session() as session:
|
||||||
|
rows = {
|
||||||
|
r.name: r
|
||||||
|
for r in (
|
||||||
|
await session.exec(
|
||||||
|
select(Secret).where(col(Secret.name).in_(wanted))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
missing = [n for n in wanted if n not in rows]
|
||||||
|
if missing:
|
||||||
|
raise UnknownSecretError(", ".join(missing))
|
||||||
|
out = [_open(box, rows[n]) for n in wanted]
|
||||||
|
now = _now()
|
||||||
|
for row in rows.values():
|
||||||
|
row.used_at = now
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _open(box: SecretBox, row: Secret) -> Revealed:
|
||||||
|
try:
|
||||||
|
data = box.open(row.name, row.sealed)
|
||||||
|
except InvalidTag as exc:
|
||||||
|
msg = f"secret {row.name} does not open: SECRETS_KEY changed since it was set"
|
||||||
|
raise SecretsDisabledError(msg) from exc
|
||||||
|
return Revealed(name=row.name, data=data, filename=row.filename)
|
||||||
@@ -28,6 +28,9 @@ KNOWN_KINDS: frozenset[str] = frozenset(
|
|||||||
"logout",
|
"logout",
|
||||||
"token_create",
|
"token_create",
|
||||||
"token_revoke",
|
"token_revoke",
|
||||||
|
"secret_set",
|
||||||
|
"secret_delete",
|
||||||
|
"secret_use",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
"""Kinds the gateway currently emits; ``AuditLog.kind`` stays free-form."""
|
"""Kinds the gateway currently emits; ``AuditLog.kind`` stays free-form."""
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ class Settings(BaseSettings):
|
|||||||
"""Directories the ``send_file`` tool may read, separated by ``:``. A
|
"""Directories the ``send_file`` tool may read, separated by ``:``. A
|
||||||
frontend always adds the inbox its own attachments land in."""
|
frontend always adds the inbox its own attachments land in."""
|
||||||
|
|
||||||
|
secrets_key: str | None = None
|
||||||
|
"""Hex AES-256 key (``openssl rand -hex 32``) sealing the secrets table;
|
||||||
|
unset leaves secrets off. Never reaches the model's environment."""
|
||||||
|
|
||||||
|
secrets_socket: Path = Path("/run/beaver-secrets/gateway.sock")
|
||||||
|
"""Root-only socket the ``secret`` helper reads values from."""
|
||||||
|
|
||||||
bootstrap_tokens: str = ""
|
bootstrap_tokens: str = ""
|
||||||
"""Out-of-band token seed: ``name1:value1,name2:value2``. Layers
|
"""Out-of-band token seed: ``name1:value1,name2:value2``. Layers
|
||||||
alongside DB-issued tokens; used for first-run setup and recovery."""
|
alongside DB-issued tokens; used for first-run setup and recovery."""
|
||||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import JSON, Column, Index, UniqueConstraint, text
|
from sqlalchemy import JSON, Column, Index, LargeBinary, UniqueConstraint, text
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlmodel import Field, SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
@@ -374,6 +374,25 @@ class JobRunRecord(SQLModel, table=True):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Secret(SQLModel, table=True):
|
||||||
|
"""A value or file the model may run a command with, sealed by ``SECRETS_KEY``.
|
||||||
|
|
||||||
|
``sealed`` is nonce + AES-GCM ciphertext bound to ``name``; nothing
|
||||||
|
here decrypts without the key, so a dump of the table stays closed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "secrets"
|
||||||
|
|
||||||
|
name: str = Field(primary_key=True)
|
||||||
|
description: str = Field(default="")
|
||||||
|
filename: str | None = Field(default=None)
|
||||||
|
size: int = Field(default=0)
|
||||||
|
sealed: bytes = Field(sa_column=Column(LargeBinary, nullable=False))
|
||||||
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
used_at: datetime | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
"Conversation",
|
"Conversation",
|
||||||
@@ -383,6 +402,7 @@ __all__ = [
|
|||||||
"InjectQueueItem",
|
"InjectQueueItem",
|
||||||
"JobRunRecord",
|
"JobRunRecord",
|
||||||
"RateLimit",
|
"RateLimit",
|
||||||
|
"Secret",
|
||||||
"TelegramUpdate",
|
"TelegramUpdate",
|
||||||
"Token",
|
"Token",
|
||||||
"TranscriptEntry",
|
"TranscriptEntry",
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from beaver_gateway.app import AgentRegistry, McpRegistry
|
||||||
|
from beaver_gateway.frontends.api.frontend import build_app as build_api
|
||||||
|
from beaver_gateway.frontends.base import GatewayRuntime
|
||||||
|
from beaver_gateway.keystore import (
|
||||||
|
SecretBox,
|
||||||
|
SecretsDisabledError,
|
||||||
|
SecretStore,
|
||||||
|
UnknownSecretError,
|
||||||
|
cli,
|
||||||
|
serve,
|
||||||
|
)
|
||||||
|
from beaver_gateway.security.auth import TokenStore
|
||||||
|
from beaver_gateway.storage import Database
|
||||||
|
from beaver_gateway.storage.models import AuditLog, Secret
|
||||||
|
|
||||||
|
KEY = "11" * 32
|
||||||
|
HEADERS = {"Authorization": "Bearer tok"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db():
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="beaver-keys-"))
|
||||||
|
database = Database(f"sqlite:///{root / 'k.db'}")
|
||||||
|
await database.create_all()
|
||||||
|
yield database
|
||||||
|
await database.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(db: Database) -> SecretStore:
|
||||||
|
return SecretStore(db, SecretBox.from_hex(KEY))
|
||||||
|
|
||||||
|
|
||||||
|
def test_box_binds_the_value_to_its_name() -> None:
|
||||||
|
box = SecretBox.from_hex(KEY)
|
||||||
|
sealed = box.seal("A_KEY", b"hunter22")
|
||||||
|
assert b"hunter22" not in sealed
|
||||||
|
assert box.open("A_KEY", sealed) == b"hunter22"
|
||||||
|
with pytest.raises(InvalidTag):
|
||||||
|
box.open("OTHER", sealed)
|
||||||
|
with pytest.raises(ValueError, match="32 bytes"):
|
||||||
|
SecretBox.from_hex("abcd")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_store_keeps_only_ciphertext_and_reveals_by_name(
|
||||||
|
store: SecretStore, db: Database
|
||||||
|
) -> None:
|
||||||
|
await store.put("NODEREAL_BSC", description="bsc rpc", data=b"nr-abcdef123")
|
||||||
|
await store.put("GCP_SA", data=b'{"k": 1}', filename="../sa.json")
|
||||||
|
async with db.session() as session:
|
||||||
|
rows = (await session.exec(select(Secret))).all()
|
||||||
|
assert all(b"nr-abcdef123" not in r.sealed for r in rows)
|
||||||
|
assert {r.name: r.filename for r in rows} == {
|
||||||
|
"GCP_SA": "sa.json",
|
||||||
|
"NODEREAL_BSC": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
await store.put("NODEREAL_BSC", description=" rpc key ")
|
||||||
|
listed = {r.name: r for r in await store.rows()}
|
||||||
|
assert listed["NODEREAL_BSC"].description == "rpc key"
|
||||||
|
assert listed["NODEREAL_BSC"].used_at is None
|
||||||
|
|
||||||
|
revealed = await store.reveal(["NODEREAL_BSC", "GCP_SA"])
|
||||||
|
assert [(r.name, r.data) for r in revealed] == [
|
||||||
|
("NODEREAL_BSC", b"nr-abcdef123"),
|
||||||
|
("GCP_SA", b'{"k": 1}'),
|
||||||
|
]
|
||||||
|
assert all(r.used_at is not None for r in await store.rows())
|
||||||
|
with pytest.raises(UnknownSecretError):
|
||||||
|
await store.reveal(["NODEREAL_BSC", "MISSING"])
|
||||||
|
with pytest.raises(ValueError, match="invalid secret name"):
|
||||||
|
await store.put("lower", data=b"x")
|
||||||
|
with pytest.raises(ValueError, match="needs a value"):
|
||||||
|
await store.put("EMPTY", description="no value")
|
||||||
|
assert await store.delete("GCP_SA")
|
||||||
|
assert not await store.delete("GCP_SA")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_another_key_or_no_key_opens_nothing(
|
||||||
|
store: SecretStore, db: Database
|
||||||
|
) -> None:
|
||||||
|
await store.put("A_KEY", data=b"value-123")
|
||||||
|
with pytest.raises(SecretsDisabledError, match="SECRETS_KEY changed"):
|
||||||
|
await SecretStore(db, SecretBox.from_hex("22" * 32)).reveal(["A_KEY"])
|
||||||
|
off = SecretStore(db, None)
|
||||||
|
assert not off.enabled
|
||||||
|
assert [r.name for r in await off.rows()] == ["A_KEY"]
|
||||||
|
with pytest.raises(SecretsDisabledError):
|
||||||
|
await off.reveal(["A_KEY"])
|
||||||
|
with pytest.raises(SecretsDisabledError):
|
||||||
|
await off.put("B_KEY", data=b"x")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def socket_path(store: SecretStore):
|
||||||
|
path = Path(tempfile.mkdtemp(prefix="bk-", dir="/tmp")) / "run" / "gw.sock" # noqa: S108
|
||||||
|
used: list[list[str]] = []
|
||||||
|
|
||||||
|
async def on_use(names):
|
||||||
|
used.append(list(names))
|
||||||
|
|
||||||
|
task = asyncio.create_task(serve(store, path, on_use=on_use))
|
||||||
|
for _ in range(100):
|
||||||
|
if path.exists():
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
yield path, used
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_socket_is_root_only_and_answers_ls_and_get(
|
||||||
|
store: SecretStore, socket_path
|
||||||
|
) -> None:
|
||||||
|
path, used = socket_path
|
||||||
|
assert path.stat().st_mode & 0o777 == 0o600
|
||||||
|
assert path.parent.stat().st_mode & 0o777 == 0o700
|
||||||
|
await store.put("API_TOKEN", description="the api", data=b"tok-123456")
|
||||||
|
|
||||||
|
ls = await asyncio.to_thread(cli.request, {"op": "ls"}, path)
|
||||||
|
assert ls["secrets"] == [
|
||||||
|
{"name": "API_TOKEN", "description": "the api", "filename": None}
|
||||||
|
]
|
||||||
|
got = await asyncio.to_thread(
|
||||||
|
cli.request, {"op": "get", "names": ["API_TOKEN"]}, path
|
||||||
|
)
|
||||||
|
assert base64.b64decode(got["secrets"]["API_TOKEN"]["data"]) == b"tok-123456"
|
||||||
|
assert used == [["API_TOKEN"]]
|
||||||
|
with pytest.raises(cli.SecretError, match="unknown secret: NOPE"):
|
||||||
|
await asyncio.to_thread(cli.request, {"op": "get", "names": ["NOPE"]}, path)
|
||||||
|
with pytest.raises(cli.SecretError, match="invalid secret name"):
|
||||||
|
await asyncio.to_thread(cli.request, {"op": "get", "names": ["../x"]}, path)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_hands_values_and_files_and_masks_the_output(
|
||||||
|
store: SecretStore, socket_path, monkeypatch, capfd
|
||||||
|
) -> None:
|
||||||
|
path, _ = socket_path
|
||||||
|
await store.put("API_TOKEN", data=b"tok/123456")
|
||||||
|
await store.put("SA_JSON", data=b'{"private": "pem"}', filename="sa.json")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli, "ask", lambda argv: cli.request({"op": "get", "names": argv[1:]}, path)
|
||||||
|
)
|
||||||
|
shm = Path(tempfile.mkdtemp(prefix="bk-shm-"))
|
||||||
|
stale = shm / f"{cli.PREFIX}999999-old"
|
||||||
|
stale.mkdir()
|
||||||
|
script = (
|
||||||
|
"import os, sys, pathlib;"
|
||||||
|
"p = pathlib.Path(os.environ['SA_JSON']);"
|
||||||
|
"print(p.read_text(), oct(p.stat().st_mode & 0o777), p.parent.parent.name.startswith('beaver-secret-'));"
|
||||||
|
"print('url', 'https://x/?key=' + os.environ['API_TOKEN'].replace('/', '%2F'));"
|
||||||
|
"sys.stderr.write('boom ' + os.environ['API_TOKEN'] + '\\n');"
|
||||||
|
"print(p, flush=True); sys.exit(3)"
|
||||||
|
)
|
||||||
|
code = await asyncio.to_thread(
|
||||||
|
cli.run, ["API_TOKEN", "SA_JSON"], [sys.executable, "-c", script], root=shm
|
||||||
|
)
|
||||||
|
out, err = capfd.readouterr()
|
||||||
|
assert code == 3
|
||||||
|
lines = out.splitlines()
|
||||||
|
assert lines[0] == '{"private": "pem"} 0o600 True'
|
||||||
|
assert lines[1] == "url https://x/?key=<secret:API_TOKEN>"
|
||||||
|
assert "tok/123456" not in out + err
|
||||||
|
assert err == "boom <secret:API_TOKEN>\n"
|
||||||
|
assert not Path(lines[2]).exists()
|
||||||
|
assert list(shm.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_masker_never_splits_a_value_across_chunks() -> None:
|
||||||
|
masker = cli.Masker(cli.needles_for({"K": b"abcdef123"}))
|
||||||
|
out = b"".join(
|
||||||
|
[
|
||||||
|
masker.feed(b"one abc"),
|
||||||
|
masker.feed(b"def1"),
|
||||||
|
masker.feed(b"23 two\nthr"),
|
||||||
|
masker.flush(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert out == b"one <secret:K> two\nthr"
|
||||||
|
assert cli.Masker({}).feed(b"as is") == b"as is"
|
||||||
|
blob = b"x" * (cli.HOLD + 10) + b"abcdef"
|
||||||
|
masker = cli.Masker(cli.needles_for({"K": b"abcdef123"}))
|
||||||
|
head = masker.feed(blob)
|
||||||
|
assert (
|
||||||
|
head + masker.feed(b"123") + masker.flush()
|
||||||
|
== b"x" * (cli.HOLD + 10) + b"<secret:K>"
|
||||||
|
)
|
||||||
|
assert cli.needles_for({"S": b"abc"}) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_rejects_bad_shapes(capsys) -> None:
|
||||||
|
assert cli.main(["run", "A_KEY"]) == 2
|
||||||
|
assert cli.main(["run", "bad-name", "--", "true"]) == 2
|
||||||
|
assert cli.main(["--root", "get", "../etc"]) == 2
|
||||||
|
assert cli.main(["--root", "rm"]) == 2
|
||||||
|
assert "usage" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
class _Runtime:
|
||||||
|
def __init__(self, db: Database, store: SecretStore | None) -> None:
|
||||||
|
self.tokens = TokenStore(db, bootstrap={"t": "tok"})
|
||||||
|
self.tokens.grant("weak", "weak", scope="api")
|
||||||
|
self.runtime = GatewayRuntime(
|
||||||
|
agents=AgentRegistry([]),
|
||||||
|
mcps=McpRegistry([]),
|
||||||
|
backends={},
|
||||||
|
token_store=self.tokens,
|
||||||
|
db=db,
|
||||||
|
conversations=object(),
|
||||||
|
bus=object(),
|
||||||
|
secrets=store,
|
||||||
|
)
|
||||||
|
self.http = AsyncClient(
|
||||||
|
transport=ASGITransport(app=build_api(self.runtime)), base_url="http://api"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_writes_secrets_but_never_shows_a_value(
|
||||||
|
store: SecretStore, db: Database
|
||||||
|
) -> None:
|
||||||
|
api = _Runtime(db, store).http
|
||||||
|
assert (
|
||||||
|
await api.get("/secrets", headers={"Authorization": "Bearer weak"})
|
||||||
|
).status_code == 403
|
||||||
|
put = await api.put(
|
||||||
|
"/secrets/NODEREAL_BSC",
|
||||||
|
json={"description": "bsc", "value": "nr-secret-1"},
|
||||||
|
headers=HEADERS,
|
||||||
|
)
|
||||||
|
assert put.status_code == 200, put.text
|
||||||
|
upload = await api.put(
|
||||||
|
"/secrets/SA_JSON",
|
||||||
|
json={"file": {"name": "sa.json", "content": base64.b64encode(b"{}").decode()}},
|
||||||
|
headers=HEADERS,
|
||||||
|
)
|
||||||
|
assert upload.json()["secret"]["kind"] == "file"
|
||||||
|
rename = await api.put(
|
||||||
|
"/secrets/NODEREAL_BSC", json={"description": "rpc"}, headers=HEADERS
|
||||||
|
)
|
||||||
|
assert rename.json()["secret"]["size"] == len("nr-secret-1")
|
||||||
|
listed = await api.get("/secrets", headers=HEADERS)
|
||||||
|
assert listed.json()["enabled"] is True
|
||||||
|
assert "nr-secret-1" not in listed.text
|
||||||
|
assert [(s["name"], s["description"]) for s in listed.json()["secrets"]] == [
|
||||||
|
("NODEREAL_BSC", "rpc"),
|
||||||
|
("SA_JSON", ""),
|
||||||
|
]
|
||||||
|
assert (await store.reveal(["NODEREAL_BSC"]))[0].data == b"nr-secret-1"
|
||||||
|
bad = await api.put("/secrets/bad name", json={"value": "x"}, headers=HEADERS)
|
||||||
|
assert bad.status_code == 400
|
||||||
|
both = await api.put(
|
||||||
|
"/secrets/X_KEY",
|
||||||
|
json={"value": "x", "file": {"name": "a", "content": ""}},
|
||||||
|
headers=HEADERS,
|
||||||
|
)
|
||||||
|
assert both.status_code == 400
|
||||||
|
assert (await api.delete("/secrets/SA_JSON", headers=HEADERS)).status_code == 200
|
||||||
|
assert (await api.delete("/secrets/SA_JSON", headers=HEADERS)).status_code == 404
|
||||||
|
async with db.session() as session:
|
||||||
|
audit = (await session.exec(select(AuditLog))).all()
|
||||||
|
assert [a.kind for a in audit] == [
|
||||||
|
"secret_set",
|
||||||
|
"secret_set",
|
||||||
|
"secret_set",
|
||||||
|
"secret_delete",
|
||||||
|
]
|
||||||
|
assert all("nr-secret-1" not in a.detail_json for a in audit)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_without_a_key_lists_but_refuses_writes(db: Database) -> None:
|
||||||
|
api = _Runtime(db, SecretStore(db, None)).http
|
||||||
|
assert (await api.get("/secrets", headers=HEADERS)).json() == {
|
||||||
|
"enabled": False,
|
||||||
|
"secrets": [],
|
||||||
|
}
|
||||||
|
put = await api.put("/secrets/A_KEY", json={"value": "x"}, headers=HEADERS)
|
||||||
|
assert put.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_runs_with_isolated_stdlib_python() -> None:
|
||||||
|
source = Path(cli.__file__).read_text(encoding="utf-8")
|
||||||
|
imports = {
|
||||||
|
line.split()[1].split(".")[0]
|
||||||
|
for line in source.splitlines()
|
||||||
|
if line.startswith(("import ", "from "))
|
||||||
|
}
|
||||||
|
assert imports <= set(sys.stdlib_module_names) | {"__future__"}
|
||||||
|
assert os.access(cli.__file__, os.R_OK)
|
||||||
@@ -14,6 +14,9 @@ import type {
|
|||||||
MemoryTree,
|
MemoryTree,
|
||||||
ScopeRow,
|
ScopeRow,
|
||||||
SearchResponse,
|
SearchResponse,
|
||||||
|
SecretRow,
|
||||||
|
SecretsResponse,
|
||||||
|
SecretWrite,
|
||||||
SessionsResponse,
|
SessionsResponse,
|
||||||
TokenRow,
|
TokenRow,
|
||||||
TurnSummary,
|
TurnSummary,
|
||||||
@@ -147,6 +150,14 @@ export class ApiClient {
|
|||||||
return this.request<T>("PATCH", path, body ?? {});
|
return this.request<T>("PATCH", path, body ?? {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
put<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
return this.request<T>("PUT", path, body ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete<T>(path: string): Promise<T> {
|
||||||
|
return this.request<T>("DELETE", path);
|
||||||
|
}
|
||||||
|
|
||||||
async stream(
|
async stream(
|
||||||
path: string,
|
path: string,
|
||||||
onEvent: (event: BusEvent) => void,
|
onEvent: (event: BusEvent) => void,
|
||||||
@@ -392,6 +403,18 @@ export class ApiClient {
|
|||||||
return this.post(`/api/tokens/${id}/revoke`);
|
return this.post(`/api/tokens/${id}/revoke`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
secrets(): Promise<SecretsResponse> {
|
||||||
|
return this.get("/api/secrets");
|
||||||
|
}
|
||||||
|
|
||||||
|
putSecret(name: string, body: SecretWrite): Promise<{ secret: SecretRow }> {
|
||||||
|
return this.put(`/api/secrets/${encodeURIComponent(name)}`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSecret(name: string): Promise<{ name: string; deleted: boolean }> {
|
||||||
|
return this.delete(`/api/secrets/${encodeURIComponent(name)}`);
|
||||||
|
}
|
||||||
|
|
||||||
audit(params?: { before?: number; limit?: number }): Promise<AuditPage> {
|
audit(params?: { before?: number; limit?: number }): Promise<AuditPage> {
|
||||||
return this.get("/api/audit", params);
|
return this.get("/api/audit", params);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,6 +287,28 @@ export interface TokenRow {
|
|||||||
scope: string;
|
scope: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SecretRow {
|
||||||
|
created_at: string;
|
||||||
|
description: string;
|
||||||
|
filename: string | null;
|
||||||
|
kind: "file" | "value";
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
updated_at: string;
|
||||||
|
used_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretsResponse {
|
||||||
|
enabled: boolean;
|
||||||
|
secrets: SecretRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretWrite {
|
||||||
|
description?: string;
|
||||||
|
file?: { content: string; name: string };
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuditRecord {
|
export interface AuditRecord {
|
||||||
actor: string;
|
actor: string;
|
||||||
agent: string | null;
|
agent: string | null;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const SYSTEM_PAGES: NavItem[] = [
|
|||||||
{ href: "/system/agents", key: "", label: "Agents & endpoints" },
|
{ href: "/system/agents", key: "", label: "Agents & endpoints" },
|
||||||
{ href: "/system/jobs", key: "", label: "Jobs" },
|
{ href: "/system/jobs", key: "", label: "Jobs" },
|
||||||
{ href: "/system/tokens", key: "", label: "Tokens" },
|
{ href: "/system/tokens", key: "", label: "Tokens" },
|
||||||
|
{ href: "/system/secrets", key: "", label: "Secrets" },
|
||||||
{ href: "/system/audit", key: "", label: "Audit" },
|
{ href: "/system/audit", key: "", label: "Audit" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FileIcon from "@lucide/svelte/icons/file";
|
||||||
|
import PlusIcon from "@lucide/svelte/icons/plus";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import type { SecretRow, SecretWrite } from "$lib/api/types";
|
||||||
|
import EmptyState from "$lib/components/empty-state.svelte";
|
||||||
|
import ErrorNote from "$lib/components/error-note.svelte";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import * as Tabs from "$lib/components/ui/tabs";
|
||||||
|
import { fmtBytes, fmtDateTime, fmtRelative } from "$lib/format";
|
||||||
|
import { session } from "$lib/session.svelte";
|
||||||
|
|
||||||
|
const NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||||
|
const MAX_SIZE = 1024 * 1024;
|
||||||
|
|
||||||
|
let secrets = $state<SecretRow[] | null>(null);
|
||||||
|
let enabled = $state(true);
|
||||||
|
let failure = $state<string | null>(null);
|
||||||
|
|
||||||
|
let editOpen = $state(false);
|
||||||
|
let editing = $state<SecretRow | null>(null);
|
||||||
|
let secretName = $state("");
|
||||||
|
let description = $state("");
|
||||||
|
let mode = $state<"value" | "file">("value");
|
||||||
|
let value = $state("");
|
||||||
|
let files = $state<FileList | undefined>();
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
let doomed = $state<SecretRow | null>(null);
|
||||||
|
|
||||||
|
const file = $derived(files?.[0]);
|
||||||
|
const nameError = $derived(
|
||||||
|
!editing && secretName && !NAME.test(secretName)
|
||||||
|
? "Capital letters, digits and _, starting with a letter."
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const fileError = $derived(
|
||||||
|
file && file.size > MAX_SIZE ? "Files are capped at 1 MB." : null
|
||||||
|
);
|
||||||
|
const hasPayload = $derived(mode === "value" ? value !== "" : !!file);
|
||||||
|
const canSave = $derived(
|
||||||
|
!(busy || nameError || fileError) &&
|
||||||
|
(editing ? true : NAME.test(secretName) && hasPayload)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const { client } = session;
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
failure = null;
|
||||||
|
try {
|
||||||
|
({ secrets, enabled } = await client.secrets());
|
||||||
|
} catch (cause) {
|
||||||
|
failure = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
|
||||||
|
function openEditor(row: SecretRow | null) {
|
||||||
|
editing = row;
|
||||||
|
secretName = row?.name ?? "";
|
||||||
|
description = row?.description ?? "";
|
||||||
|
mode = row?.kind ?? "value";
|
||||||
|
value = "";
|
||||||
|
files = undefined;
|
||||||
|
editOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64Of(blob: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const url = String(reader.result);
|
||||||
|
resolve(url.slice(url.indexOf(",") + 1));
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const { client } = session;
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const body: SecretWrite = { description };
|
||||||
|
if (mode === "value" && value) {
|
||||||
|
body.value = value;
|
||||||
|
}
|
||||||
|
if (mode === "file" && file) {
|
||||||
|
body.file = { content: await base64Of(file), name: file.name };
|
||||||
|
}
|
||||||
|
const target = editing?.name ?? secretName;
|
||||||
|
await client.putSecret(target, body);
|
||||||
|
toast.success(editing ? `Updated ${target}` : `Saved ${target}`);
|
||||||
|
editOpen = false;
|
||||||
|
value = "";
|
||||||
|
files = undefined;
|
||||||
|
await load();
|
||||||
|
} catch (cause) {
|
||||||
|
toast.error(cause instanceof Error ? cause.message : String(cause));
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(row: SecretRow) {
|
||||||
|
const { client } = session;
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await client.deleteSecret(row.name);
|
||||||
|
toast.success(`Deleted ${row.name}`);
|
||||||
|
doomed = null;
|
||||||
|
await load();
|
||||||
|
} catch (cause) {
|
||||||
|
toast.error(cause instanceof Error ? cause.message : String(cause));
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
|
<h2 class="font-medium text-sm">Secrets</h2>
|
||||||
|
<span class="text-muted-foreground text-xs">
|
||||||
|
write-only · the agent runs
|
||||||
|
<code class="rounded bg-muted px-1 py-0.5">secret run NAME -- cmd</code>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
class="ml-auto"
|
||||||
|
disabled={!enabled}
|
||||||
|
onclick={() => openEditor(null)}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
New secret
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
{#if !enabled}
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-1 rounded-lg border border-warn/40 bg-warn/5 p-3 text-sm"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<p class="font-medium">Secrets are off: SECRETS_KEY is not set.</p>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
Put a key in the gateway's environment (<code
|
||||||
|
class="rounded bg-muted px-1 py-0.5"
|
||||||
|
>openssl rand -hex 32</code
|
||||||
|
>) and recreate the container. Existing names stay listed; values cannot
|
||||||
|
be set or read until then.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if failure}
|
||||||
|
<ErrorNote message={failure} retry={() => load()} />
|
||||||
|
{:else if secrets === null}
|
||||||
|
<p class="text-muted-foreground text-sm">Loading…</p>
|
||||||
|
{:else if secrets.length === 0}
|
||||||
|
<EmptyState
|
||||||
|
hint="Keys and files the agent runs commands with. They are sealed with SECRETS_KEY in the gateway's database, reach a command as environment variables, and are never shown again after saving."
|
||||||
|
title="No secrets"
|
||||||
|
>
|
||||||
|
{#if enabled}
|
||||||
|
<Button onclick={() => openEditor(null)} size="sm" variant="outline">
|
||||||
|
Add the first secret
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</EmptyState>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto rounded-lg border bg-strip">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="text-muted-foreground text-xs">
|
||||||
|
<tr class="border-b text-left">
|
||||||
|
<th class="px-3 py-2 font-medium">name</th>
|
||||||
|
<th class="hidden px-3 py-2 font-medium sm:table-cell">
|
||||||
|
description
|
||||||
|
</th>
|
||||||
|
<th class="hidden px-3 py-2 font-medium sm:table-cell">kind</th>
|
||||||
|
<th class="hidden px-3 py-2 font-medium sm:table-cell">changed</th>
|
||||||
|
<th class="hidden px-3 py-2 font-medium sm:table-cell">
|
||||||
|
last used
|
||||||
|
</th>
|
||||||
|
<th class="px-3 py-2 text-right font-medium"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each secrets as row (row.name)}
|
||||||
|
<tr class="row-hover border-b last:border-b-0">
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
<code class="break-all font-medium">{row.name}</code>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-0.5 pt-0.5 text-muted-foreground text-xs sm:hidden"
|
||||||
|
>
|
||||||
|
{#if row.description}
|
||||||
|
<span class="text-foreground/80">{row.description}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="tabular">
|
||||||
|
{row.filename
|
||||||
|
? `${row.filename} · ${fmtBytes(row.size)}`
|
||||||
|
: "value"}
|
||||||
|
· changed {fmtRelative(row.updated_at)} ·
|
||||||
|
{row.used_at
|
||||||
|
? `used ${fmtRelative(row.used_at)}`
|
||||||
|
: "never used"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="hidden min-w-48 px-3 py-1.5 sm:table-cell">
|
||||||
|
{#if row.description}
|
||||||
|
{row.description}
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted-foreground">–</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="hidden whitespace-nowrap px-3 py-1.5 text-xs sm:table-cell"
|
||||||
|
>
|
||||||
|
{#if row.filename}
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<FileIcon class="size-3.5 text-icon" />
|
||||||
|
{row.filename}
|
||||||
|
<span class="tabular text-muted-foreground"
|
||||||
|
>{fmtBytes(row.size)}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted-foreground">value</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="tabular hidden whitespace-nowrap px-3 py-1.5 text-xs sm:table-cell"
|
||||||
|
title={fmtDateTime(row.updated_at)}
|
||||||
|
>
|
||||||
|
{fmtRelative(row.updated_at)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="tabular hidden whitespace-nowrap px-3 py-1.5 text-xs sm:table-cell"
|
||||||
|
title={row.used_at ? fmtDateTime(row.used_at) : undefined}
|
||||||
|
>
|
||||||
|
{#if row.used_at}
|
||||||
|
{fmtRelative(row.used_at)}
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted-foreground">never</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="flex flex-col items-end px-1 py-1.5 sm:table-cell sm:whitespace-nowrap sm:px-3 sm:text-right"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
disabled={!enabled}
|
||||||
|
onclick={() => openEditor(row)}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
Replace
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
class="text-destructive hover:text-destructive"
|
||||||
|
onclick={() => {
|
||||||
|
doomed = row;
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={editOpen}>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>
|
||||||
|
{editing ? `Replace ${editing.name}` : "New secret"}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
{editing
|
||||||
|
? "Leave the value empty to change only the description."
|
||||||
|
: "Stored encrypted. After saving, the value is not shown anywhere."}
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<form
|
||||||
|
autocomplete="off"
|
||||||
|
class="flex flex-col gap-3"
|
||||||
|
onsubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
save();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if !editing}
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<Label for="secret-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
aria-invalid={nameError ? true : undefined}
|
||||||
|
autocapitalize="characters"
|
||||||
|
class="font-mono"
|
||||||
|
id="secret-name"
|
||||||
|
oninput={() => {
|
||||||
|
secretName = secretName.toUpperCase().replaceAll(/[\s-]/g, "_");
|
||||||
|
}}
|
||||||
|
placeholder="NODEREAL_BSC"
|
||||||
|
required
|
||||||
|
spellcheck={false}
|
||||||
|
bind:value={secretName}
|
||||||
|
/>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{nameError ?? `The command reads it as $${secretName || "NAME"}.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<Label for="secret-description">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="secret-description"
|
||||||
|
placeholder="What it opens and who issued it"
|
||||||
|
bind:value={description}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Tabs.Root bind:value={mode}>
|
||||||
|
<Tabs.List class="w-full">
|
||||||
|
<Tabs.Trigger value="value">Value</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="file">File</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
<Tabs.Content class="flex flex-col gap-1.5 pt-2" value="value">
|
||||||
|
<Label class="sr-only" for="secret-value">Value</Label>
|
||||||
|
<Input
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="font-mono"
|
||||||
|
id="secret-value"
|
||||||
|
placeholder={editing ? "New value" : "Paste the value"}
|
||||||
|
spellcheck={false}
|
||||||
|
type="password"
|
||||||
|
bind:value
|
||||||
|
/>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
One line. Multi-line keys and JSON go in as a file.
|
||||||
|
</p>
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content class="flex flex-col gap-1.5 pt-2" value="file">
|
||||||
|
<Label class="sr-only" for="secret-file">File</Label>
|
||||||
|
<Input
|
||||||
|
aria-invalid={fileError ? true : undefined}
|
||||||
|
id="secret-file"
|
||||||
|
type="file"
|
||||||
|
bind:files
|
||||||
|
/>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{fileError ??
|
||||||
|
"Up to 1 MB. The command gets $NAME as the path to a private copy, removed when it exits."}
|
||||||
|
</p>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button
|
||||||
|
onclick={() => {
|
||||||
|
editOpen = false;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!canSave} type="submit">
|
||||||
|
{editing && !hasPayload ? "Save description" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</form>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<Dialog.Root
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
doomed = null;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
open={doomed !== null}
|
||||||
|
>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Delete {doomed?.name}?</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Commands that ask for it will fail with “unknown secret”. The value
|
||||||
|
cannot be recovered.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button
|
||||||
|
onclick={() => {
|
||||||
|
doomed = null;
|
||||||
|
}}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => doomed && remove(doomed)}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -296,6 +296,7 @@ dependencies = [
|
|||||||
{ name = "argon2-cffi" },
|
{ name = "argon2-cffi" },
|
||||||
{ name = "claude-agent-sdk" },
|
{ name = "claude-agent-sdk" },
|
||||||
{ name = "croniter" },
|
{ name = "croniter" },
|
||||||
|
{ name = "cryptography" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "fastmcp" },
|
{ name = "fastmcp" },
|
||||||
{ name = "greenlet" },
|
{ name = "greenlet" },
|
||||||
@@ -340,6 +341,7 @@ requires-dist = [
|
|||||||
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
||||||
{ name = "claude-agent-sdk", specifier = ">=0.2.157" },
|
{ name = "claude-agent-sdk", specifier = ">=0.2.157" },
|
||||||
{ name = "croniter", specifier = ">=6.2.4" },
|
{ name = "croniter", specifier = ">=6.2.4" },
|
||||||
|
{ name = "cryptography", specifier = ">=48.0.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.136.1" },
|
{ name = "fastapi", specifier = ">=0.136.1" },
|
||||||
{ name = "fastmcp", specifier = ">=3.3.1" },
|
{ name = "fastmcp", specifier = ">=3.3.1" },
|
||||||
{ name = "greenlet", specifier = ">=3.5.0" },
|
{ name = "greenlet", specifier = ">=3.5.0" },
|
||||||
|
|||||||
Reference in new issue
Block a user