Commented terminal files

This commit is contained in:
2026-02-03 09:33:49 +00:00
parent f54cc3f09b
commit 962ba27679
4 changed files with 32 additions and 0 deletions

View File

@@ -15,6 +15,7 @@ from .utils import render_system_username
def get_active_ca(created_by=None) -> SSHCertificateAuthority: def get_active_ca(created_by=None) -> SSHCertificateAuthority:
# Reuse the most recent active CA, or lazily create one if missing.
ca = ( ca = (
SSHCertificateAuthority.objects.filter(is_active=True, revoked_at__isnull=True) SSHCertificateAuthority.objects.filter(is_active=True, revoked_at__isnull=True)
.order_by("-created_at") .order_by("-created_at")
@@ -31,9 +32,11 @@ def issue_certificate_for_key(key: SSHKey, created_by=None) -> SSHCertificate:
if not key or not key.user_id: if not key or not key.user_id:
raise ValueError("key must have a user") raise ValueError("key must have a user")
ca = get_active_ca(created_by=created_by) ca = get_active_ca(created_by=created_by)
# Principal must match the system account used for SSH logins.
principal = render_system_username(key.user.username, key.user_id) principal = render_system_username(key.user.username, key.user_id)
now = timezone.now() now = timezone.now()
valid_before = now + timedelta(days=settings.KEYWARDEN_USER_CERT_VALIDITY_DAYS) valid_before = now + timedelta(days=settings.KEYWARDEN_USER_CERT_VALIDITY_DAYS)
# Serial should be unique and non-guessable for audit purposes.
serial = secrets.randbits(63) serial = secrets.randbits(63)
safe_name = _sanitize_label(key.name or "key") safe_name = _sanitize_label(key.name or "key")
identity = f"keywarden-cert-{key.user_id}-{safe_name}-{key.id}" identity = f"keywarden-cert-{key.user_id}-{safe_name}-{key.id}"
@@ -70,6 +73,7 @@ def revoke_certificate_for_key(key: SSHKey) -> None:
cert = key.certificate cert = key.certificate
except SSHCertificate.DoesNotExist: except SSHCertificate.DoesNotExist:
return return
# Mark the cert as revoked but keep the record for audit/history.
cert.revoke() cert.revoke()
cert.save(update_fields=["is_active", "revoked_at"]) cert.save(update_fields=["is_active", "revoked_at"])
@@ -87,6 +91,7 @@ def _sign_public_key(
) -> str: ) -> str:
if not ca_private_key or not ca_public_key: if not ca_private_key or not ca_public_key:
raise RuntimeError("CA material missing") raise RuntimeError("CA material missing")
# Write key material into a temp dir to avoid persisting secrets.
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
ca_path = os.path.join(tmpdir, "user_ca") ca_path = os.path.join(tmpdir, "user_ca")
pubkey_path = os.path.join(tmpdir, "user.pub") pubkey_path = os.path.join(tmpdir, "user.pub")
@@ -94,6 +99,7 @@ def _sign_public_key(
_write_file(ca_path + ".pub", ca_public_key.strip() + "\n", 0o644) _write_file(ca_path + ".pub", ca_public_key.strip() + "\n", 0o644)
pubkey_with_comment = _ensure_comment(public_key, comment) pubkey_with_comment = _ensure_comment(public_key, comment)
_write_file(pubkey_path, pubkey_with_comment + "\n", 0o644) _write_file(pubkey_path, pubkey_with_comment + "\n", 0o644)
# Use ssh-keygen to sign the public key with the CA.
cmd = [ cmd = [
"ssh-keygen", "ssh-keygen",
"-s", "-s",
@@ -114,6 +120,7 @@ def _sign_public_key(
raise RuntimeError("ssh-keygen not available") from exc raise RuntimeError("ssh-keygen not available") from exc
except subprocess.CalledProcessError as exc: except subprocess.CalledProcessError as exc:
raise RuntimeError(f"ssh-keygen failed: {exc.stderr.decode('utf-8', 'ignore')}") from exc raise RuntimeError(f"ssh-keygen failed: {exc.stderr.decode('utf-8', 'ignore')}") from exc
# ssh-keygen writes the cert alongside the input pubkey.
cert_path = pubkey_path cert_path = pubkey_path
if cert_path.endswith(".pub"): if cert_path.endswith(".pub"):
cert_path = cert_path[: -len(".pub")] cert_path = cert_path[: -len(".pub")]
@@ -126,6 +133,7 @@ def _sign_public_key(
def _ensure_comment(public_key: str, comment: str) -> str: def _ensure_comment(public_key: str, comment: str) -> str:
# Preserve the key type and base64 payload; replace/append only the comment.
parts = (public_key or "").strip().split() parts = (public_key or "").strip().split()
if len(parts) < 2: if len(parts) < 2:
return public_key.strip() return public_key.strip()
@@ -136,6 +144,7 @@ def _ensure_comment(public_key: str, comment: str) -> str:
def _sanitize_label(value: str) -> str: def _sanitize_label(value: str) -> str:
# Reduce label to a safe, lowercase token for certificate identity.
cleaned = re.sub(r"[^a-zA-Z0-9_-]+", "-", (value or "").strip()) cleaned = re.sub(r"[^a-zA-Z0-9_-]+", "-", (value or "").strip())
cleaned = cleaned.strip("-_") cleaned = cleaned.strip("-_")
if cleaned: if cleaned:
@@ -146,4 +155,5 @@ def _sanitize_label(value: str) -> str:
def _write_file(path: str, data: str, mode: int) -> None: def _write_file(path: str, data: str, mode: int) -> None:
with open(path, "w", encoding="utf-8") as handle: with open(path, "w", encoding="utf-8") as handle:
handle.write(data) handle.write(data)
# Apply explicit permissions for key material.
os.chmod(path, mode) os.chmod(path, mode)

View File

@@ -9,6 +9,7 @@ _SANITIZE_RE = re.compile(r"[^a-z0-9_-]")
def render_system_username(username: str, user_id: int) -> str: def render_system_username(username: str, user_id: int) -> str:
# Render from template and then sanitize to an OS-safe username.
template = settings.KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE template = settings.KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE
raw = template.replace("{{username}}", username or "") raw = template.replace("{{username}}", username or "")
raw = raw.replace("{{user_id}}", str(user_id)) raw = raw.replace("{{user_id}}", str(user_id))
@@ -17,13 +18,16 @@ def render_system_username(username: str, user_id: int) -> str:
cleaned = cleaned[:MAX_USERNAME_LEN] cleaned = cleaned[:MAX_USERNAME_LEN]
if cleaned: if cleaned:
return cleaned return cleaned
# Fall back to a deterministic, non-empty username.
return f"kw_{user_id}" return f"kw_{user_id}"
def sanitize_username(raw: str) -> str: def sanitize_username(raw: str) -> str:
# Normalize to lowercase and replace disallowed characters.
raw = (raw or "").lower() raw = (raw or "").lower()
raw = _SANITIZE_RE.sub("_", raw) raw = _SANITIZE_RE.sub("_", raw)
raw = raw.strip("-_") raw = raw.strip("-_")
if raw.startswith("-"): if raw.startswith("-"):
# Avoid leading dash, which can be interpreted as a CLI flag.
return "kw" + raw return "kw" + raw
return raw return raw

View File

@@ -35,6 +35,7 @@ class ShellConsumer(AsyncWebsocketConsumer):
self.shell_target = "" self.shell_target = ""
self.server_id: int | None = None self.server_id: int | None = None
# Reject unauthenticated connections before any side effects.
user = self.scope.get("user") user = self.scope.get("user")
if not user or not getattr(user, "is_authenticated", False): if not user or not getattr(user, "is_authenticated", False):
await self.close(code=4401) await self.close(code=4401)
@@ -54,6 +55,7 @@ class ShellConsumer(AsyncWebsocketConsumer):
if not can_shell: if not can_shell:
await self.close(code=4403) await self.close(code=4403)
return return
# Resolve the per-user system account name and the best reachable host.
system_username = await self._get_system_username(user, server) system_username = await self._get_system_username(user, server)
shell_target = server.hostname or server.ipv4 or server.ipv6 shell_target = server.hostname or server.ipv4 or server.ipv6
if not system_username or not shell_target: if not system_username or not shell_target:
@@ -62,6 +64,7 @@ class ShellConsumer(AsyncWebsocketConsumer):
self.system_username = system_username self.system_username = system_username
self.shell_target = shell_target self.shell_target = shell_target
# Only accept the socket after all authn/authz checks have passed.
await self.accept() await self.accept()
# Audit the WebSocket connection as an explicit, opt-in event. # Audit the WebSocket connection as an explicit, opt-in event.
await self._audit_websocket_event(user=user, action="connect", metadata={"server_id": server.id}) await self._audit_websocket_event(user=user, action="connect", metadata={"server_id": server.id})
@@ -96,6 +99,7 @@ class ShellConsumer(AsyncWebsocketConsumer):
async def receive(self, text_data=None, bytes_data=None): async def receive(self, text_data=None, bytes_data=None):
if not self.proc or not self.proc.stdin: if not self.proc or not self.proc.stdin:
return return
# Forward WebSocket payloads directly to the SSH subprocess stdin.
if bytes_data is not None: if bytes_data is not None:
data = bytes_data data = bytes_data
elif text_data is not None: elif text_data is not None:
@@ -109,6 +113,7 @@ class ShellConsumer(AsyncWebsocketConsumer):
async def _start_ssh(self, user): async def _start_ssh(self, user):
# Generate a short-lived keypair + SSH certificate and then # Generate a short-lived keypair + SSH certificate and then
# bridge the WebSocket to an SSH subprocess. # bridge the WebSocket to an SSH subprocess.
# Prefer tmpfs when available so the private key never hits disk.
temp_base = "/dev/shm" if os.path.isdir("/dev/shm") and os.access("/dev/shm", os.W_OK) else None temp_base = "/dev/shm" if os.path.isdir("/dev/shm") and os.access("/dev/shm", os.W_OK) else None
self.tempdir = tempfile.TemporaryDirectory(prefix="keywarden-shell-", dir=temp_base) self.tempdir = tempfile.TemporaryDirectory(prefix="keywarden-shell-", dir=temp_base)
key_path, cert_path = await asyncio.to_thread( key_path, cert_path = await asyncio.to_thread(
@@ -118,6 +123,7 @@ class ShellConsumer(AsyncWebsocketConsumer):
self.system_username, self.system_username,
) )
ssh_host = _format_ssh_host(self.shell_target) ssh_host = _format_ssh_host(self.shell_target)
# Use a locked-down, non-interactive SSH invocation suitable for websockets.
command = [ command = [
"ssh", "ssh",
"-tt", "-tt",
@@ -154,6 +160,7 @@ class ShellConsumer(AsyncWebsocketConsumer):
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT, stderr=asyncio.subprocess.STDOUT,
) )
# Delete key material immediately after the SSH process has it open.
for path in (key_path, cert_path, f"{key_path}.pub"): for path in (key_path, cert_path, f"{key_path}.pub"):
try: try:
os.remove(path) os.remove(path)
@@ -166,6 +173,7 @@ class ShellConsumer(AsyncWebsocketConsumer):
async def _stream_output(self): async def _stream_output(self):
if not self.proc or not self.proc.stdout: if not self.proc or not self.proc.stdout:
return return
# Pump subprocess output until EOF, then close the socket.
while True: while True:
chunk = await self.proc.stdout.read(4096) chunk = await self.proc.stdout.read(4096)
if not chunk: if not chunk:
@@ -228,6 +236,7 @@ class ShellConsumer(AsyncWebsocketConsumer):
metadata=combined_metadata, metadata=combined_metadata,
) )
except Exception: except Exception:
# Auditing is best-effort; never fail the shell session.
return return
@@ -255,6 +264,7 @@ def _generate_session_keypair(tempdir: str, user, principal: str) -> tuple[str,
raise RuntimeError("ssh-keygen not available") from exc raise RuntimeError("ssh-keygen not available") from exc
except subprocess.CalledProcessError as exc: except subprocess.CalledProcessError as exc:
raise RuntimeError(f"ssh-keygen failed: {exc.stderr.decode('utf-8', 'ignore')}") from exc raise RuntimeError(f"ssh-keygen failed: {exc.stderr.decode('utf-8', 'ignore')}") from exc
# Restrict filesystem access to the private key.
os.chmod(key_path, 0o600) os.chmod(key_path, 0o600)
pubkey_path = key_path + ".pub" pubkey_path = key_path + ".pub"
with open(pubkey_path, "r", encoding="utf-8") as handle: with open(pubkey_path, "r", encoding="utf-8") as handle:
@@ -273,6 +283,7 @@ def _generate_session_keypair(tempdir: str, user, principal: str) -> tuple[str,
cert_path = key_path + "-cert.pub" cert_path = key_path + "-cert.pub"
with open(cert_path, "w", encoding="utf-8") as handle: with open(cert_path, "w", encoding="utf-8") as handle:
handle.write(cert_text + "\n") handle.write(cert_text + "\n")
# Public cert is safe to be world-readable.
os.chmod(cert_path, 0o644) os.chmod(cert_path, 0o644)
return key_path, cert_path return key_path, cert_path

View File

@@ -6,6 +6,7 @@ from django.urls import reverse_lazy
from django.templatetags.static import static from django.templatetags.static import static
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
# Load environment overrides early so settings can reference them.
load_dotenv() load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -20,6 +21,7 @@ CSRF_TRUSTED_ORIGINS = [
if origin.strip() if origin.strip()
] ]
# Default to secure cookies and respect TLS termination headers.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CSRF_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
@@ -94,6 +96,7 @@ CACHES = {
} }
} }
# In-memory channel layer keeps local development simple.
CHANNEL_LAYERS = { CHANNEL_LAYERS = {
"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}, "default": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
} }
@@ -101,6 +104,7 @@ CHANNEL_LAYERS = {
SESSION_ENGINE = "django.contrib.sessions.backends.cache" SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default" SESSION_CACHE_ALIAS = "default"
# Certificate validity defaults; can be tightened via env vars.
KEYWARDEN_AGENT_CERT_VALIDITY_DAYS = int(os.getenv("KEYWARDEN_AGENT_CERT_VALIDITY_DAYS", "90")) KEYWARDEN_AGENT_CERT_VALIDITY_DAYS = int(os.getenv("KEYWARDEN_AGENT_CERT_VALIDITY_DAYS", "90"))
KEYWARDEN_USER_CERT_VALIDITY_DAYS = int(os.getenv("KEYWARDEN_USER_CERT_VALIDITY_DAYS", "30")) KEYWARDEN_USER_CERT_VALIDITY_DAYS = int(os.getenv("KEYWARDEN_USER_CERT_VALIDITY_DAYS", "30"))
KEYWARDEN_SHELL_CERT_VALIDITY_MINUTES = int(os.getenv("KEYWARDEN_SHELL_CERT_VALIDITY_MINUTES", "15")) KEYWARDEN_SHELL_CERT_VALIDITY_MINUTES = int(os.getenv("KEYWARDEN_SHELL_CERT_VALIDITY_MINUTES", "15"))
@@ -178,6 +182,7 @@ UNFOLD = {
"ENVIRONMENT": "Keywarden", "ENVIRONMENT": "Keywarden",
"ENVIRONMENT_COLOR": "#7C3AED", "ENVIRONMENT_COLOR": "#7C3AED",
"SHOW_VIEW_ON_SITE": True, "SHOW_VIEW_ON_SITE": True,
# Force a consistent admin theme; disables theme switching.
"THEME": "dark", # Force theme: "dark" or "light". Will disable theme switcher "THEME": "dark", # Force theme: "dark" or "light". Will disable theme switcher
"SIDEBAR": { "SIDEBAR": {
"show_search": True, "show_search": True,
@@ -250,6 +255,7 @@ if AUTH_MODE not in {"native", "oidc", "hybrid"}:
KEYWARDEN_AUTH_MODE = AUTH_MODE KEYWARDEN_AUTH_MODE = AUTH_MODE
if AUTH_MODE == "oidc": if AUTH_MODE == "oidc":
# OIDC-only: enforce identity provider logins.
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
"guardian.backends.ObjectPermissionBackend", "guardian.backends.ObjectPermissionBackend",
@@ -271,4 +277,5 @@ LOGOUT_REDIRECT_URL = "/"
ANONYMOUS_USER_NAME = None ANONYMOUS_USER_NAME = None
def permission_callback(request): def permission_callback(request):
# Guard admin-side model changes behind a single permission check.
return request.user.has_perm("keywarden.change_model") return request.user.has_perm("keywarden.change_model")