Feat: Added CA-merged with Securid-Principals, Depart, Client-CLI

This commit is contained in:
2026-02-27 21:59:01 +05:45
parent 92fd57447d
commit b2212ab4d6
29 changed files with 3718 additions and 53 deletions
+24 -3
View File
@@ -12,7 +12,7 @@ from gatehouse_app.models import User, AuthenticationMethod
from gatehouse_app.models.authentication_method import OAuthState
from gatehouse_app.models.base import BaseModel
from gatehouse_app.models.oidc_authorization_code import OIDCAuthCode
from gatehouse_app.utils.constants import AuthMethodType
from gatehouse_app.utils.constants import AuthMethodType, AuditAction
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.services.external_auth_service import (
ExternalAuthService,
@@ -139,7 +139,7 @@ class OAuthFlowService:
except ExternalAuthError as e:
# Log failed initiation
AuditService.log_action(
action="external_auth.login.initiated",
action=AuditAction.EXTERNAL_AUTH_LOGIN_FAILED,
organization_id=organization_id,
metadata={
"provider_type": provider_type_str,
@@ -236,7 +236,7 @@ class OAuthFlowService:
except ExternalAuthError as e:
AuditService.log_action(
action="external_auth.register.initiated",
action=AuditAction.EXTERNAL_AUTH_LOGIN_FAILED,
organization_id=organization_id,
metadata={
"provider_type": provider_type_str,
@@ -399,6 +399,27 @@ class OAuthFlowService:
access_token=tokens["access_token"],
)
if not user_info.get("provider_user_id"):
raise OAuthFlowError(
"Provider did not return a user identifier (sub claim). "
"Cannot complete authentication.",
"MISSING_PROVIDER_USER_ID",
400,
)
if not user_info.get("email"):
raise OAuthFlowError(
"Provider did not return an email address. "
"Cannot complete authentication.",
"MISSING_EMAIL",
400,
)
logger.debug(
f"Got user_info from provider: sub={user_info['provider_user_id']}, "
f"email={user_info['email']}, email_verified={user_info.get('email_verified')}"
)
# Look up user by provider_user_id
auth_method = AuthenticationMethod.query.filter_by(
method_type=provider_type,
@@ -0,0 +1,333 @@
"""SSH Certificate Authority signing service.
Handles SSH certificate signing operations, leveraging sshkey-tools library.
This service is a Gatehouse-integrated version of the secuird/ssh_ca.py logic.
"""
import logging
import os
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
from sshkey_tools.cert import SSHCertificate, CertificateFields
from sshkey_tools.keys import PublicKey, PrivateKey
from gatehouse_app.config.ssh_ca_config import get_ssh_ca_config
from gatehouse_app.exceptions import SSHCAError, ValidationError
from gatehouse_app.utils.crypto import compute_ssh_fingerprint
logger = logging.getLogger(__name__)
class SSHCASigningError(Exception):
"""SSH CA signing operation error."""
pass
class SSHCertificateSigningRequest:
"""Represents an SSH certificate signing request."""
def __init__(
self,
ssh_public_key: str,
principals: List[str],
key_id: str,
cert_type: str = "user",
expiry_hours: Optional[int] = None,
critical_options: Optional[Dict[str, str]] = None,
extensions: Optional[List[str]] = None,
):
"""Initialize signing request.
Args:
ssh_public_key: Public key in OpenSSH format (e.g., "ssh-ed25519 AAAA...")
principals: List of principals (e.g., ["prod-servers", "staging"])
key_id: Key identifier (usually user email)
cert_type: Certificate type - "user" or "host" (default: user)
expiry_hours: Certificate validity in hours
critical_options: Critical options dict
extensions: List of extensions (e.g., ["permit-pty", "permit-agent-forwarding"])
"""
self.ssh_public_key = ssh_public_key
self.principals = principals or []
self.key_id = key_id
self.cert_type = cert_type
self.expiry_hours = expiry_hours
self.critical_options = critical_options or {}
self.extensions = extensions or []
def validate(self) -> List[str]:
"""Validate the signing request.
Returns:
List of validation errors (empty if valid)
"""
errors = []
config = get_ssh_ca_config()
# Validate cert type
if self.cert_type not in ("user", "host"):
errors.append(f"Invalid cert_type: {self.cert_type}. Must be 'user' or 'host'")
# Validate SSH public key
if not self.ssh_public_key or len(self.ssh_public_key) < 16:
errors.append("SSH public key is missing or invalid")
else:
try:
PublicKey.from_string(self.ssh_public_key)
except Exception as e:
errors.append(f"SSH public key is not valid: {str(e)}")
# Validate principals
if not self.principals or len(self.principals) == 0:
errors.append("At least one principal is required")
else:
max_principals = config.get_int('max_principals_per_cert')
if len(self.principals) > max_principals:
errors.append(
f"Too many principals ({len(self.principals)}). "
f"Maximum is {max_principals}"
)
# Validate key_id
if not self.key_id or len(self.key_id) < 5:
errors.append("key_id is missing or too short (minimum 5 characters)")
else:
max_id_len = config.get_int('max_key_id_length')
if len(self.key_id) > max_id_len:
errors.append(f"key_id exceeds maximum length of {max_id_len}")
# Validate expiry_hours
if self.expiry_hours is not None:
if not isinstance(self.expiry_hours, int) or self.expiry_hours <= 0:
errors.append("expiry_hours must be a positive integer")
else:
max_validity = config.get_int('max_cert_validity_hours')
if self.expiry_hours > max_validity:
errors.append(
f"Requested expiry ({self.expiry_hours}h) exceeds "
f"maximum allowed ({max_validity}h)"
)
return errors
class SSHCertificateSigningResponse:
"""Represents a signed SSH certificate response."""
def __init__(
self,
certificate: str,
serial: str,
valid_after: datetime,
valid_before: datetime,
principals: Optional[List[str]] = None,
):
"""Initialize signing response.
Args:
certificate: Full certificate in OpenSSH format
serial: Certificate serial number
valid_after: Validity start datetime
valid_before: Validity end datetime
principals: List of principals the cert was issued for
"""
self.certificate = certificate
self.serial = serial
self.valid_after = valid_after
self.valid_before = valid_before
self.principals = principals or []
def to_dict(self) -> Dict[str, Any]:
"""Convert response to dictionary."""
return {
'certificate': self.certificate,
'serial': self.serial,
'valid_after': self.valid_after.isoformat(),
'valid_before': self.valid_before.isoformat(),
}
class SSHCASigningService:
"""Service for signing SSH certificates.
This service handles all SSH certificate signing operations.
It uses configuration from ssh_ca_config to apply rules and limits.
"""
def __init__(self):
"""Initialize the SSH CA signing service."""
self.config = get_ssh_ca_config()
self.logger = logger
def _load_ca_key_from_config(self) -> str:
"""Load CA private key from config (local file or env var).
Returns:
CA private key in PEM/OpenSSH format as string
Raises:
SSHCASigningError: If key cannot be loaded
"""
# Check env var first
key_content = os.environ.get('SSH_CA_PRIVATE_KEY')
if key_content:
return key_content
# Load from file path
key_path = self.config.get_str('ca_key_path', '').strip()
if not key_path:
raise SSHCASigningError(
"CA private key not configured. Set SSH_CA_PRIVATE_KEY env var "
"or ca_key_path in etc/ssh_ca.conf"
)
key_path = os.path.expandvars(os.path.expanduser(key_path))
if not os.path.exists(key_path):
raise SSHCASigningError(f"CA private key file not found: {key_path}")
with open(key_path, 'r') as f:
return f.read()
def sign_certificate(
self,
signing_request: SSHCertificateSigningRequest,
ca_private_key: Optional[str] = None,
) -> SSHCertificateSigningResponse:
"""Sign an SSH certificate.
Args:
signing_request: SSHCertificateSigningRequest instance
ca_private_key: CA private key in PEM format. If not provided,
loaded from config (ca_key_path or SSH_CA_PRIVATE_KEY env var)
Returns:
SSHCertificateSigningResponse with signed certificate
Raises:
SSHCASigningError: If signing fails
ValidationError: If request is invalid
"""
# Validate request
errors = signing_request.validate()
if errors:
error_msg = "; ".join(errors)
self.logger.error(f"Certificate signing validation failed: {error_msg}")
raise ValidationError(f"Certificate signing validation failed: {error_msg}")
# Load CA key if not provided
if ca_private_key is None:
ca_private_key = self._load_ca_key_from_config()
try:
# Parse CA private key
try:
ca_key = PrivateKey.from_string(ca_private_key)
except Exception as e:
self.logger.error(f"Failed to load CA private key: {str(e)}")
raise SSHCASigningError(f"Invalid CA private key: {str(e)}")
# Parse user's public key
try:
user_pub_key = PublicKey.from_string(signing_request.ssh_public_key)
except Exception as e:
self.logger.error(f"Failed to parse user public key: {str(e)}")
raise SSHCASigningError(f"Invalid user public key: {str(e)}")
# Create certificate
certificate = SSHCertificate.create(
subject_pubkey=user_pub_key,
ca_privkey=ca_key,
)
# Set validity period
now = datetime.utcnow()
expiry_hours = signing_request.expiry_hours or self.config.get_int('cert_validity_hours')
valid_before = now + timedelta(hours=expiry_hours)
# Set certificate fields
cert_type = 1 if signing_request.cert_type == "user" else 0
certificate.fields.cert_type = cert_type
certificate.fields.key_id = signing_request.key_id
certificate.fields.principals = signing_request.principals
certificate.fields.valid_after = now
certificate.fields.valid_before = valid_before
# Set extensions
extensions = signing_request.extensions
if not extensions and self.config.get_bool('extensions_enabled'):
extensions = self.config.get_list('extensions')
certificate.fields.extensions = extensions or []
certificate.fields.critical_options = signing_request.critical_options or {}
# Validate certificate before signing
if not certificate.can_sign():
raise SSHCASigningError("Certificate cannot be signed")
# Sign the certificate
certificate.sign()
# Verify the certificate
try:
certificate.verify(ca_key.public_key, raise_on_error=True)
except Exception as e:
self.logger.error(f"Certificate verification failed: {str(e)}")
raise SSHCASigningError(f"Certificate verification failed: {str(e)}")
# Extract serial from certificate
serial = str(certificate.fields.serial).split(":")[-1].strip() if hasattr(certificate.fields.serial, '__str__') else str(certificate.fields.serial)
# Build response
cert_string = certificate.to_string()
self.logger.info(
f"Successfully signed certificate: serial={serial}, "
f"key_id={signing_request.key_id}, principals={signing_request.principals}"
)
return SSHCertificateSigningResponse(
certificate=cert_string,
serial=serial,
valid_after=now,
valid_before=valid_before,
principals=signing_request.principals,
)
except (SSHCASigningError, ValidationError):
raise
except Exception as e:
self.logger.error(f"Unexpected error during certificate signing: {str(e)}", exc_info=True)
raise SSHCASigningError(f"Error signing certificate: {str(e)}")
def verify_ca_key(self, ca_private_key: str) -> Dict[str, Any]:
"""Verify a CA private key is valid and extract metadata.
Args:
ca_private_key: CA private key in PEM format
Returns:
Dictionary with key metadata (fingerprint, key_type, etc.)
Raises:
SSHCASigningError: If key is invalid
"""
try:
ca_key = PrivateKey.from_string(ca_private_key)
pub_key = ca_key.public_key
# Compute fingerprint
fingerprint = compute_ssh_fingerprint(pub_key.to_string())
# Get key type
key_type = pub_key.keytype if hasattr(pub_key, 'keytype') else 'unknown'
return {
'fingerprint': fingerprint,
'key_type': key_type,
'public_key': pub_key.to_string(),
'valid': True,
}
except Exception as e:
self.logger.error(f"CA key verification failed: {str(e)}")
raise SSHCASigningError(f"Invalid CA key: {str(e)}")
+373
View File
@@ -0,0 +1,373 @@
"""SSH Key management service."""
import base64
import logging
import os
import secrets
import subprocess
import tempfile
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from gatehouse_app.extensions import db
from gatehouse_app.models import SSHKey, User
from gatehouse_app.exceptions import (
SSHKeyError,
SSHKeyNotFoundError,
SSHKeyAlreadyExistsError,
SSHKeyNotVerifiedError,
ValidationError,
UserNotFoundError,
)
from gatehouse_app.utils.crypto import (
compute_ssh_fingerprint,
verify_ssh_key_format,
extract_ssh_key_type,
extract_ssh_key_comment,
)
from gatehouse_app.config.ssh_ca_config import get_ssh_ca_config
logger = logging.getLogger(__name__)
class SSHKeyService:
"""Service for managing SSH keys."""
def __init__(self):
"""Initialize SSH key service."""
self.config = get_ssh_ca_config()
def add_ssh_key(
self,
user_id: str,
public_key: str,
description: Optional[str] = None,
) -> SSHKey:
"""Add an SSH public key for a user.
Args:
user_id: ID of the user
public_key: SSH public key in OpenSSH format
description: Optional description of the key
Returns:
Created SSHKey instance
Raises:
UserNotFoundError: If user doesn't exist
SSHKeyError: If key format is invalid
SSHKeyAlreadyExistsError: If key already exists
"""
# Verify user exists
user = User.query.get(user_id)
if not user:
raise UserNotFoundError(f"User {user_id} not found")
# Validate key format
if not verify_ssh_key_format(public_key):
raise SSHKeyError("Invalid SSH public key format")
# Compute fingerprint
try:
fingerprint = compute_ssh_fingerprint(public_key)
except Exception as e:
logger.error(f"Failed to compute fingerprint: {str(e)}")
raise SSHKeyError(f"Failed to compute key fingerprint: {str(e)}")
# Check for duplicate (including soft-deleted records — fingerprint is unique in DB)
existing = SSHKey.query.filter_by(fingerprint=fingerprint).first()
if existing:
if existing.deleted_at is not None:
# Restore the soft-deleted key: clear deleted_at and update fields
existing.deleted_at = None
existing.user_id = user_id
existing.description = description or existing.description
existing.verified = False
existing.verified_at = None
existing.verify_text = None
existing.verify_text_created_at = None
db.session.commit()
logger.info(
f"Restored soft-deleted SSH key for user {user_id}: "
f"fingerprint={fingerprint}"
)
return existing
raise SSHKeyAlreadyExistsError(
f"SSH key with fingerprint {fingerprint} already exists"
)
# Extract metadata
key_type = extract_ssh_key_type(public_key)
key_comment = extract_ssh_key_comment(public_key)
# Create SSH key record
ssh_key = SSHKey(
user_id=user_id,
payload=public_key,
fingerprint=fingerprint,
description=description,
key_type=key_type,
key_comment=key_comment,
verified=False,
)
ssh_key.save()
logger.info(
f"SSH key added for user {user_id}: "
f"fingerprint={fingerprint}, type={key_type}"
)
return ssh_key
def get_ssh_key(self, key_id: str) -> SSHKey:
"""Get an SSH key by ID.
Args:
key_id: SSH key ID
Returns:
SSHKey instance
Raises:
SSHKeyNotFoundError: If key not found
"""
key = SSHKey.query.filter_by(id=key_id, deleted_at=None).first()
if not key:
raise SSHKeyNotFoundError(f"SSH key {key_id} not found")
return key
def get_user_ssh_keys(self, user_id: str) -> List[SSHKey]:
"""Get all SSH keys for a user.
Args:
user_id: User ID
Returns:
List of SSHKey instances
"""
return SSHKey.query.filter_by(user_id=user_id, deleted_at=None).all()
def get_user_verified_ssh_keys(self, user_id: str) -> List[SSHKey]:
"""Get all verified SSH keys for a user.
Args:
user_id: User ID
Returns:
List of verified SSHKey instances
"""
return SSHKey.query.filter_by(
user_id=user_id,
verified=True,
deleted_at=None,
).all()
def delete_ssh_key(self, key_id: str) -> None:
"""Soft-delete an SSH key.
Args:
key_id: SSH key ID
Raises:
SSHKeyNotFoundError: If key not found
"""
key = self.get_ssh_key(key_id)
key.delete()
logger.info(f"SSH key deleted: {key_id}")
def generate_verification_challenge(self, key_id: str) -> str:
"""Generate a verification challenge for an SSH key.
The user must sign this challenge text with their private key
to prove key ownership.
Args:
key_id: SSH key ID
Returns:
Verification challenge text
Raises:
SSHKeyNotFoundError: If key not found
"""
key = self.get_ssh_key(key_id)
# Generate random challenge
challenge = secrets.token_hex(32)
challenge_text = f"Please sign this to verify SSH key ownership: {challenge}"
# Store challenge
key.verify_text = challenge_text
key.verify_text_created_at = datetime.utcnow()
key.save()
logger.info(f"Generated verification challenge for SSH key {key_id}")
return challenge_text
def verify_ssh_key_ownership(
self,
key_id: str,
signature: str,
) -> bool:
"""Verify SSH key ownership via signature.
The user must sign the verification challenge with their private key.
We verify the signature using the public key.
Args:
key_id: SSH key ID
signature: Base64-encoded signature of the challenge
Returns:
True if signature is valid
Raises:
SSHKeyNotFoundError: If key not found
SSHKeyNotVerifiedError: If challenge is stale or missing
SSHKeyError: If verification fails
"""
key = self.get_ssh_key(key_id)
# Check if challenge exists and is not stale
if not key.verify_text or not key.verify_text_created_at:
raise SSHKeyNotVerifiedError("No verification challenge generated")
max_age = self.config.get_int('verification_challenge_max_age')
age = datetime.utcnow() - key.verify_text_created_at
if age.total_seconds() > (max_age * 3600):
raise SSHKeyNotVerifiedError("Verification challenge has expired")
try:
# Verify the SSH signature using ssh-keygen -Y verify.
# The CLI signs the challenge with: ssh-keygen -Y sign -f <key> -n file <challenge>
# We verify with: ssh-keygen -Y verify -f <allowed_signers> -I <identity> -n file -s <sig> < <message>
#
# allowed_signers format: "<identity> <keytype> <pubkey>"
# We use the key fingerprint as the identity.
sig_bytes = base64.b64decode(signature)
challenge_text = key.verify_text + "\n"
with tempfile.TemporaryDirectory() as tmpdir:
allowed_signers_path = os.path.join(tmpdir, "allowed_signers")
sig_path = os.path.join(tmpdir, "message.sig")
message_path = os.path.join(tmpdir, "message.txt")
identity = key.fingerprint
# Write the allowed_signers file
with open(allowed_signers_path, "w") as f:
f.write(f"{identity} {key.payload}\n")
# Write the signature file
with open(sig_path, "wb") as f:
f.write(sig_bytes)
# Write the challenge message
with open(message_path, "w") as f:
f.write(challenge_text)
result = subprocess.run(
[
"ssh-keygen", "-Y", "verify",
"-f", allowed_signers_path,
"-I", identity,
"-n", "file",
"-s", sig_path,
],
stdin=open(message_path, "rb"),
capture_output=True,
timeout=10,
)
if result.returncode != 0:
stderr = result.stderr.decode(errors="replace").strip()
logger.warning(f"SSH signature verification failed for key {key_id}: {stderr}")
raise SSHKeyError(f"Signature verification failed: {stderr}")
key.mark_verified()
logger.info(f"SSH key verified: {key_id}")
return True
except Exception as e:
logger.error(f"SSH key verification failed: {str(e)}")
raise SSHKeyError(f"Signature verification failed: {str(e)}")
def get_key_fingerprint(self, key_id: str) -> str:
"""Get the fingerprint of an SSH key.
Args:
key_id: SSH key ID
Returns:
Fingerprint string
Raises:
SSHKeyNotFoundError: If key not found
"""
key = self.get_ssh_key(key_id)
return key.fingerprint
def update_ssh_key_description(self, key_id: str, description: str) -> SSHKey:
"""Update the description of an SSH key.
Args:
key_id: SSH key ID
description: New description
Returns:
Updated SSHKey instance
Raises:
SSHKeyNotFoundError: If key not found
"""
key = self.get_ssh_key(key_id)
key.description = description
key.save()
return key
def cleanup_expired_challenges(self) -> int:
"""Clean up expired verification challenges.
Returns:
Number of challenges cleaned
"""
max_age = self.config.get_int('verification_challenge_max_age')
threshold = datetime.utcnow() - timedelta(hours=max_age)
expired = SSHKey.query.filter(
SSHKey.verify_text_created_at < threshold,
SSHKey.verify_text_created_at.isnot(None),
SSHKey.deleted_at.is_(None),
).update({"verify_text": None, "verify_text_created_at": None})
db.session.commit()
logger.info(f"Cleaned up {expired} expired verification challenges")
return expired
def cleanup_unverified_keys(self) -> int:
"""Delete unverified SSH keys older than configured days.
Returns:
Number of keys deleted
"""
days = self.config.get_int('auto_delete_unverified_days')
threshold = datetime.utcnow() - timedelta(days=days)
old_unverified = SSHKey.query.filter(
SSHKey.verified == False,
SSHKey.created_at < threshold,
SSHKey.deleted_at.is_(None),
).all()
count = 0
for key in old_unverified:
key.delete()
count += 1
logger.info(f"Deleted {count} unverified SSH keys older than {days} days")
return count