Feat: Added CA-merged with Securid-Principals, Depart, Client-CLI
This commit is contained in:
@@ -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)}")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user