-
Notifications
You must be signed in to change notification settings - Fork 0
Add SSH key generation for callis↔client and callis↔host pairing #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c5ef132
cc846e7
030609d
029a8f9
8c48908
6fdf7bb
27a4262
fcbada4
8ce1c3b
e122b65
b48c1b2
19ecbb8
7838983
7542c5c
5d5f523
54b1b24
0000860
06ac4aa
153c33a
a51a1d0
ced6d10
6b76dea
3ad5f33
47980f2
53be5ce
b60e5de
c203667
7390aa4
9a2f2d7
0efd866
f696430
9afc404
346b012
fd4264d
bb2bc86
f6301da
9eb35c5
916c570
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| import secrets | ||
| import stat as _stat | ||
| import struct | ||
| import threading | ||
| from collections import OrderedDict | ||
| from datetime import datetime, timedelta, timezone | ||
| from functools import lru_cache | ||
|
|
@@ -16,6 +17,14 @@ | |
| from cryptography.fernet import Fernet | ||
| from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC | ||
| from cryptography.hazmat.primitives import hashes | ||
| from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey | ||
| from cryptography.hazmat.primitives.serialization import ( | ||
| Encoding, | ||
| NoEncryption, | ||
| PrivateFormat, | ||
| PublicFormat, | ||
| load_ssh_private_key, | ||
| ) | ||
| import jwt | ||
| from jwt.exceptions import PyJWTError as JWTError | ||
| import bcrypt | ||
|
|
@@ -487,6 +496,237 @@ | |
| raise ValueError(f"RSA key is {key_bits} bits, minimum {min_bits} required") | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # SSH Keypair Generation | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| def generate_ssh_keypair(comment: str = "") -> tuple[str, str]: | ||
| """Generate an Ed25519 SSH keypair. | ||
|
|
||
| Returns: | ||
| (private_key_openssh: str, public_key_openssh: str) | ||
|
|
||
| The private key is in OpenSSH PEM format. The public key is a single | ||
| authorized_keys line; an optional comment is appended when provided. | ||
| """ | ||
| private_key = Ed25519PrivateKey.generate() | ||
| private_key_text = private_key.private_bytes( | ||
| encoding=Encoding.PEM, | ||
| format=PrivateFormat.OpenSSH, | ||
| encryption_algorithm=NoEncryption(), | ||
| ).decode() | ||
| public_key_text = private_key.public_key().public_bytes( | ||
| encoding=Encoding.OpenSSH, | ||
| format=PublicFormat.OpenSSH, | ||
| ).decode().rstrip() | ||
| if comment: | ||
| # Strip and reject control characters to ensure the public key line | ||
| # remains a single valid authorized_keys entry. | ||
| comment = comment.strip() | ||
| if any(ord(c) < 32 or ord(c) == 127 for c in comment): | ||
| raise ValueError("SSH key comment must not contain control characters") | ||
| if comment: | ||
| public_key_text = f"{public_key_text} {comment}" | ||
| return private_key_text, public_key_text | ||
|
|
||
|
|
||
| _DEPLOY_KEY_PATH = "/data/callis_deploy_key" | ||
| _deploy_public_key_cache: str | None = None | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| # This cache is process-local. In multi-worker deployments, each worker process | ||
| # maintains its own copy. The lock below protects concurrent initialisation when | ||
| # get_server_deploy_public_key() is offloaded to a thread pool. | ||
| _deploy_key_lock = threading.Lock() | ||
|
|
||
|
|
||
| def _derive_public_key_from_private_file(priv_path: str, pub_path: str) -> str | None: | ||
| """Load the deploy private key from *priv_path* and return its OpenSSH public key. | ||
|
|
||
| Writes the derived public key to *pub_path* as a side-effect when possible. | ||
| Returns ``None`` if the file is missing; returns ``None`` and logs a warning | ||
| if the file is unreadable or has insecure permissions that cannot be tightened. | ||
| """ | ||
| try: | ||
| st = os.stat(priv_path) | ||
| mode = _stat.S_IMODE(st.st_mode) | ||
| if mode & 0o077: | ||
| try: | ||
| os.chmod(priv_path, 0o600) | ||
| except OSError as exc: | ||
| logger.warning( | ||
| "Deploy private key at %s has insecure permissions %s and could not be tightened: %s", | ||
| priv_path, | ||
| oct(mode), | ||
| exc, | ||
| ) | ||
| return None | ||
| with open(priv_path, "rb") as f: | ||
| priv_bytes = f.read() | ||
| priv = load_ssh_private_key(priv_bytes, password=None) | ||
| pub_text = priv.public_key().public_bytes( | ||
| encoding=Encoding.OpenSSH, | ||
| format=PublicFormat.OpenSSH, | ||
| ).decode().strip() | ||
| try: | ||
| parse_ssh_public_key(pub_text) | ||
| except (TypeError, ValueError) as exc: | ||
| logger.warning( | ||
| "Derived public key from %s is not a valid SSH public key; " | ||
| "falling back to generating a fresh keypair: %s", | ||
| priv_path, | ||
| exc, | ||
| ) | ||
| return None | ||
| # Validation passed — persist and return the derived public key. | ||
| try: | ||
| with open(pub_path, "w") as fh: | ||
| fh.write(pub_text + "\n") | ||
| except OSError as exc: | ||
| logger.warning("Could not write deploy public key to %s: %s", pub_path, exc) | ||
| return pub_text | ||
| except FileNotFoundError: | ||
| return None | ||
| except Exception as exc: | ||
| logger.warning("Could not load deploy private key at %s: %s", priv_path, exc) | ||
| return None | ||
|
Comment on lines
+541
to
+590
|
||
|
|
||
|
|
||
| def get_server_deploy_public_key() -> str: | ||
| """Return Callis's server deploy public key, generating it if needed. | ||
|
|
||
| The keypair is persisted to /data/callis_deploy_key[.pub]. Returns the | ||
| OpenSSH public key as a single-line string, or an empty string if the key | ||
| cannot be generated (e.g. /data is not writable in dev without Docker). | ||
|
|
||
| **Blocking:** this function performs synchronous disk I/O (stat, open, read, | ||
| chmod, and possibly key generation and file writes). It must not be called | ||
| directly from async request handlers. Call it at application startup (e.g. | ||
| in the FastAPI ``lifespan`` function) or offload it to a thread pool:: | ||
|
|
||
| import anyio | ||
| key = await anyio.to_thread.run_sync(get_server_deploy_public_key) | ||
|
|
||
| The result is cached in memory after the first call so that subsequent | ||
| calls return immediately without any I/O. Persistent failures (e.g. | ||
| /data is not writable) are also cached as an empty string so that callers | ||
| do not repeatedly retry expensive I/O on every request. | ||
| """ | ||
| global _deploy_public_key_cache | ||
| # Lock-free fast path — avoids acquiring the lock on every call once cached. | ||
| if _deploy_public_key_cache is not None: | ||
| return _deploy_public_key_cache | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
|
|
||
| with _deploy_key_lock: | ||
| # Re-check inside the lock to handle concurrent first-call racing. | ||
|
Comment on lines
+600
to
+619
|
||
| if _deploy_public_key_cache is not None: | ||
| return _deploy_public_key_cache | ||
|
|
||
| priv_path = _DEPLOY_KEY_PATH | ||
| pub_path = priv_path + ".pub" | ||
|
|
||
| # Fast path: public key file already exists. | ||
| try: | ||
| with open(pub_path) as f: | ||
| first_line = f.readline().strip() | ||
| if first_line: | ||
| try: | ||
| parse_ssh_public_key(first_line) | ||
| except (TypeError, ValueError): | ||
| logger.warning( | ||
| "Deploy public key file %s is not a valid SSH public key; ignoring.", | ||
| pub_path, | ||
| ) | ||
| else: | ||
| _deploy_public_key_cache = first_line | ||
| return _deploy_public_key_cache | ||
| except FileNotFoundError: | ||
|
Comment on lines
+626
to
+641
|
||
| # Missing public key file is expected on first run; derive or generate it below. | ||
| pass | ||
| except OSError as exc: | ||
| logger.warning( | ||
| "Could not read deploy public key at %s: %s; " | ||
| "falling through to derive/generate.", | ||
| pub_path, exc, | ||
| ) | ||
| # Fall through to deriving from the private key or generating a new keypair. | ||
|
|
||
| # Private key exists but public key file is missing or unreadable — derive it. | ||
| pub_text = _derive_public_key_from_private_file(priv_path, pub_path) | ||
| if pub_text is not None: | ||
| _deploy_public_key_cache = pub_text | ||
Check noticeCode scanning / CodeQL Unused global variable Note
The global variable '_deploy_public_key_cache' is not used.
|
||
| return pub_text | ||
|
Comment on lines
+652
to
+656
|
||
|
|
||
| # Generate a fresh keypair and persist it. | ||
| private_key_text, public_key_text = generate_ssh_keypair(comment="callis@deploy") | ||
| public_key_text = public_key_text.strip() | ||
| try: | ||
| os.makedirs(os.path.dirname(priv_path), exist_ok=True) | ||
| except (OSError, ValueError) as exc: | ||
| logger.warning("Could not create directory for deploy key at %s: %s", priv_path, exc) | ||
| try: | ||
| fd = os.open(priv_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) | ||
| with os.fdopen(fd, "w") as f: | ||
| f.write(private_key_text) | ||
| except FileExistsError: | ||
| # File was created between our check and the open call (e.g. by another | ||
| # process on first startup). Try to read the key they persisted; fall | ||
| # back to deriving it from the private key file. | ||
| try: | ||
| with open(pub_path) as f: | ||
| first_line = f.readline().strip() | ||
| if first_line: | ||
| try: | ||
| parse_ssh_public_key(first_line) | ||
| except (TypeError, ValueError): | ||
| logger.warning( | ||
| "Deploy public key file %s is not a valid SSH public key after concurrent creation; ignoring.", | ||
| pub_path, | ||
| ) | ||
| else: | ||
| _deploy_public_key_cache = first_line | ||
| return _deploy_public_key_cache | ||
| except FileNotFoundError: | ||
|
Comment on lines
+674
to
+687
|
||
| # Public key file was not found after concurrent creation attempt; | ||
| # fall back to deriving it from the private key file below. | ||
| logger.debug( | ||
| "Deploy public key file %s not found after concurrent creation attempt.", | ||
| pub_path, | ||
| ) | ||
| except OSError as exc: | ||
| logger.warning( | ||
| "Could not read deploy public key at %s after concurrent creation: %s", | ||
| pub_path, exc, | ||
| ) | ||
|
|
||
| derived = _derive_public_key_from_private_file(priv_path, pub_path) | ||
| if derived is not None: | ||
| _deploy_public_key_cache = derived | ||
| return _deploy_public_key_cache | ||
|
|
||
| logger.warning( | ||
| "Could not recover deploy public key after concurrent creation at %s; " | ||
| "returning empty string. Cache left unset so the next call can retry.", | ||
| priv_path, | ||
| ) | ||
|
pacnpal marked this conversation as resolved.
|
||
| return "" | ||
|
Comment on lines
+705
to
+710
|
||
| except (PermissionError, OSError) as exc: | ||
| logger.warning( | ||
| "Could not persist server deploy key to %s: %s. " | ||
| "Returning empty string because the generated key is not durable.", | ||
| priv_path, | ||
| exc, | ||
| ) | ||
| _deploy_public_key_cache = "" | ||
Check noticeCode scanning / CodeQL Unused global variable Note
The global variable '_deploy_public_key_cache' is not used.
|
||
| return "" | ||
|
Comment on lines
+711
to
+719
|
||
| try: | ||
| with open(pub_path, "w") as f: | ||
| f.write(public_key_text + "\n") | ||
| except (PermissionError, OSError) as exc: | ||
| logger.warning("Could not write deploy public key to %s: %s", pub_path, exc) | ||
| logger.info("Generated Callis server deploy key and saved to %s", priv_path) | ||
| _deploy_public_key_cache = public_key_text | ||
Check noticeCode scanning / CodeQL Unused global variable Note
The global variable '_deploy_public_key_cache' is not used.
|
||
| return public_key_text | ||
|
Comment on lines
+725
to
+727
|
||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Audit logging (append-only) | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,15 @@ | ||
| import re | ||
| from urllib.parse import urlparse | ||
|
|
||
| import anyio | ||
| from fastapi import APIRouter, Depends, Form, HTTPException, Request | ||
| from fastapi.responses import HTMLResponse, RedirectResponse | ||
| from fastapi.templating import Jinja2Templates | ||
| from sqlalchemy import select | ||
| from sqlalchemy.ext.asyncio import AsyncSession | ||
| from sqlalchemy.orm import selectinload | ||
|
|
||
| from core import get_db, get_runtime_setting, get_settings, register_template_filters, slugify, write_audit_log | ||
| from core import get_db, get_runtime_setting, get_settings, get_server_deploy_public_key, register_template_filters, slugify, write_audit_log | ||
| from dependencies import require_role, require_totp_complete | ||
| from models import AuditAction, Host, User, UserRole | ||
|
|
||
|
|
@@ -35,16 +36,18 @@ async def host_list( | |
|
|
||
| # Load all active users for assignment dropdowns (admin only) | ||
| all_users = [] | ||
| server_deploy_key = "" | ||
| if user.role == UserRole.admin: | ||
| users_result = await db.execute( | ||
| select(User).where(User.is_active == True).order_by(User.username) | ||
| ) | ||
| all_users = users_result.scalars().all() | ||
| server_deploy_key = await anyio.to_thread.run_sync(get_server_deploy_public_key) | ||
|
|
||
|
Comment on lines
+39
to
46
|
||
| return templates.TemplateResponse( | ||
| request, | ||
| "hosts.html", | ||
| context={"hosts": hosts, "user": user, "settings": settings, "ssh_host": ssh_host, "all_users": all_users}, | ||
| context={"hosts": hosts, "user": user, "settings": settings, "ssh_host": ssh_host, "all_users": all_users, "server_deploy_key": server_deploy_key}, | ||
| ) | ||
|
|
||
|
|
||
|
|
@@ -66,13 +69,15 @@ async def _form_error(detail: str): | |
| ) | ||
| all_hosts = result.scalars().all() | ||
| au = [] | ||
| server_deploy_key = "" | ||
| if user.role == UserRole.admin: | ||
| ur = await db.execute(select(User).where(User.is_active == True).order_by(User.username)) | ||
| au = ur.scalars().all() | ||
| server_deploy_key = await anyio.to_thread.run_sync(get_server_deploy_public_key) | ||
| return templates.TemplateResponse( | ||
| request, | ||
| "hosts.html", | ||
| context={"error": detail, "hosts": all_hosts, "user": user, "settings": settings, "ssh_host": ssh_host, "all_users": au}, | ||
| context={"error": detail, "hosts": all_hosts, "user": user, "settings": settings, "ssh_host": ssh_host, "all_users": au, "server_deploy_key": server_deploy_key}, | ||
| status_code=400, | ||
| ) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
generate_ssh_keypair()appends thecommentstring directly onto the public key line. If a future caller passes untrusted input (e.g., containing newlines/control characters), this could produce a multi-line/invalid authorized_keys entry. Consider stripping and rejecting control characters (and/or collapsing whitespace) incommentbefore appending.