Fix: CA host Sign via web
This commit is contained in:
+313
-3
@@ -131,7 +131,10 @@ def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=N
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: UUID of the user
|
user_id: UUID of the user
|
||||||
ssh_key_id: UUID of the SSH key that was signed
|
ssh_key_id: UUID of the SSH key that was signed. May be None for host
|
||||||
|
certificates issued against a raw public key (no pre-registered
|
||||||
|
SSHKey DB record). When None the record is still persisted
|
||||||
|
but ``ssh_key_id`` is left NULL (requires nullable FK migration).
|
||||||
ca: CA model instance (may be None — cert still returned but not persisted)
|
ca: CA model instance (may be None — cert still returned but not persisted)
|
||||||
signing_response: SSHCertificateSigningResponse
|
signing_response: SSHCertificateSigningResponse
|
||||||
request_ip: Client IP address
|
request_ip: Client IP address
|
||||||
@@ -158,10 +161,10 @@ def _persist_certificate(user_id, ssh_key_id, ca, signing_response, request_ip=N
|
|||||||
cert_record = SSHCertificate(
|
cert_record = SSHCertificate(
|
||||||
ca_id=ca.id,
|
ca_id=ca.id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
ssh_key_id=ssh_key_id,
|
ssh_key_id=ssh_key_id, # None is OK for host certs (nullable FK)
|
||||||
certificate=signing_response.certificate,
|
certificate=signing_response.certificate,
|
||||||
serial=signing_response.serial,
|
serial=signing_response.serial,
|
||||||
key_id=cert_identity or str(ssh_key_id),
|
key_id=cert_identity or (str(ssh_key_id) if ssh_key_id else "host-cert"),
|
||||||
cert_type=resolved_cert_type,
|
cert_type=resolved_cert_type,
|
||||||
principals=signing_response.principals,
|
principals=signing_response.principals,
|
||||||
valid_after=signing_response.valid_after,
|
valid_after=signing_response.valid_after,
|
||||||
@@ -744,6 +747,312 @@ def sign_certificate():
|
|||||||
return api_response(data=result, message="Certificate signed successfully", status=201)
|
return api_response(data=result, message="Certificate signed successfully", status=201)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Host certificate issuance (admin-only)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _classify_ssh_key_material(raw: str) -> str:
|
||||||
|
"""Classify a raw SSH key string.
|
||||||
|
|
||||||
|
Returns one of: 'certificate', 'public_key', 'private_key', 'unknown'.
|
||||||
|
This mirrors the frontend ``classifySshKeyMaterial`` helper so that the
|
||||||
|
API produces the same guardrails even when called directly (e.g. via CLI).
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
line = raw.strip().split()[0] if raw.strip() else ""
|
||||||
|
if re.search(r"-cert-v01@openssh\.com$", line):
|
||||||
|
return "certificate"
|
||||||
|
if re.match(
|
||||||
|
r"^(ssh-ed25519|ssh-rsa|ssh-dss|ecdsa-sha2-nistp\d+|sk-ssh-ed25519@openssh\.com)$",
|
||||||
|
line,
|
||||||
|
):
|
||||||
|
return "public_key"
|
||||||
|
if "BEGIN OPENSSH PRIVATE KEY" in raw or "BEGIN RSA PRIVATE KEY" in raw:
|
||||||
|
return "private_key"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@ssh_bp.route('/sign/host', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def sign_host_certificate():
|
||||||
|
"""Issue a host certificate for a server's host public key.
|
||||||
|
|
||||||
|
This endpoint is admin-only. It accepts a raw OpenSSH host public key
|
||||||
|
(the kind found in ``/etc/ssh/ssh_host_ed25519_key.pub``), signs it with
|
||||||
|
the organisation's Host CA, and returns the signed host certificate.
|
||||||
|
|
||||||
|
The certificate should be saved on the server as
|
||||||
|
``/etc/ssh/ssh_host_ed25519_key-cert.pub`` and referenced in
|
||||||
|
``sshd_config`` as ``HostCertificate``.
|
||||||
|
|
||||||
|
Clients trust the host because they have the Host CA *public key* in their
|
||||||
|
``known_hosts`` (via ``@cert-authority``). That key is different from —
|
||||||
|
and must never be confused with — the certificate returned here.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
host_public_key (str, required):
|
||||||
|
Raw OpenSSH host public key, e.g.
|
||||||
|
"ssh-ed25519 AAAA... root@server".
|
||||||
|
Must NOT be a certificate (ssh-*-cert-v01@openssh.com) or a
|
||||||
|
private key.
|
||||||
|
principals (list[str], required):
|
||||||
|
Hostnames / FQDNs the server is known by, e.g.
|
||||||
|
["prod.example.com", "web01.internal"].
|
||||||
|
These must match what SSH clients use in their connection target.
|
||||||
|
validity_hours (int, optional, default=720):
|
||||||
|
Certificate validity in hours. Host certs are typically
|
||||||
|
30 days (720 h) to 1 year (8760 h).
|
||||||
|
ca_id (str, required):
|
||||||
|
UUID of the Host CA to sign with. Must be a ``ca_type=host`` CA
|
||||||
|
belonging to the caller's organisation.
|
||||||
|
|
||||||
|
Returns (201):
|
||||||
|
certificate, serial, principals, valid_after, valid_before
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
400 BAD_REQUEST — pasted material is a cert / private key / unknown
|
||||||
|
403 FORBIDDEN — caller is not an org admin/owner
|
||||||
|
404 CA_NOT_FOUND — ca_id does not exist or is not a host CA
|
||||||
|
422 VALIDATION_ERROR — invalid principals, validity, or public key
|
||||||
|
503 CA_NOT_CONFIGURED
|
||||||
|
"""
|
||||||
|
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||||
|
from gatehouse_app.models.ssh_ca.ca import CA, CaType
|
||||||
|
from gatehouse_app.utils.constants import OrganizationRole
|
||||||
|
from gatehouse_app.utils.ca_key_encryption import decrypt_ca_key
|
||||||
|
|
||||||
|
user = g.current_user
|
||||||
|
user_id = user.id
|
||||||
|
|
||||||
|
# ── Admin-only gate ───────────────────────────────────────────────────────
|
||||||
|
is_admin = OrganizationMember.query.filter(
|
||||||
|
OrganizationMember.user_id == user_id,
|
||||||
|
OrganizationMember.role.in_([OrganizationRole.ADMIN, OrganizationRole.OWNER]),
|
||||||
|
OrganizationMember.deleted_at.is_(None),
|
||||||
|
).first() is not None
|
||||||
|
|
||||||
|
if not is_admin:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Issuing host certificates requires org admin or owner role.",
|
||||||
|
status=403,
|
||||||
|
error_type="FORBIDDEN",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return api_response(success=False, message="No JSON data provided", status=400, error_type="BAD_REQUEST")
|
||||||
|
|
||||||
|
host_public_key = (data.get("host_public_key") or "").strip()
|
||||||
|
principals = data.get("principals") or []
|
||||||
|
validity_hours = data.get("validity_hours", 720)
|
||||||
|
ca_id = (data.get("ca_id") or "").strip()
|
||||||
|
|
||||||
|
# ── Validate host public key material ─────────────────────────────────────
|
||||||
|
if not host_public_key:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="host_public_key is required.",
|
||||||
|
status=400,
|
||||||
|
error_type="BAD_REQUEST",
|
||||||
|
)
|
||||||
|
|
||||||
|
key_kind = _classify_ssh_key_material(host_public_key)
|
||||||
|
if key_kind == "certificate":
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message=(
|
||||||
|
"You submitted a certificate (ssh-…-cert-v01@openssh.com), not a host public key. "
|
||||||
|
"Retrieve the server's host public key with: "
|
||||||
|
"cat /etc/ssh/ssh_host_ed25519_key.pub"
|
||||||
|
),
|
||||||
|
status=400,
|
||||||
|
error_type="WRONG_KEY_MATERIAL",
|
||||||
|
)
|
||||||
|
if key_kind == "private_key":
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Private keys must never be submitted here. Use the .pub file.",
|
||||||
|
status=400,
|
||||||
|
error_type="WRONG_KEY_MATERIAL",
|
||||||
|
)
|
||||||
|
if key_kind == "unknown":
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message=(
|
||||||
|
"Unrecognised key format. "
|
||||||
|
"Expected an OpenSSH public key starting with ssh-ed25519, ssh-rsa, or ecdsa-sha2-*."
|
||||||
|
),
|
||||||
|
status=400,
|
||||||
|
error_type="WRONG_KEY_MATERIAL",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Validate principals ───────────────────────────────────────────────────
|
||||||
|
if not principals or not isinstance(principals, list):
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="principals must be a non-empty list of hostnames.",
|
||||||
|
status=422,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
)
|
||||||
|
principals = [str(p).strip() for p in principals if str(p).strip()]
|
||||||
|
if not principals:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="At least one principal (hostname/FQDN) is required.",
|
||||||
|
status=422,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Validate validity ─────────────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
validity_hours = int(validity_hours)
|
||||||
|
if validity_hours < 1:
|
||||||
|
raise ValueError
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="validity_hours must be a positive integer.",
|
||||||
|
status=422,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Resolve CA ────────────────────────────────────────────────────────────
|
||||||
|
if not ca_id:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="ca_id is required.",
|
||||||
|
status=400,
|
||||||
|
error_type="BAD_REQUEST",
|
||||||
|
)
|
||||||
|
|
||||||
|
org_ids = [
|
||||||
|
m.organization_id
|
||||||
|
for m in OrganizationMember.query.filter_by(user_id=user_id, deleted_at=None).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# First: find the CA by id (ignoring type) so we can give a specific error
|
||||||
|
# if it exists but is the wrong type.
|
||||||
|
any_ca = CA.query.filter(
|
||||||
|
CA.id == ca_id,
|
||||||
|
CA.is_active.is_(True),
|
||||||
|
CA.organization_id.in_(org_ids),
|
||||||
|
CA.deleted_at.is_(None),
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if any_ca and any_ca.ca_type != CaType.HOST:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message=(
|
||||||
|
f"The CA '{any_ca.name}' is a {any_ca.ca_type.value} CA. "
|
||||||
|
"Host certificates must be signed by a ca_type='host' CA."
|
||||||
|
),
|
||||||
|
status=400,
|
||||||
|
error_type="WRONG_CA_TYPE",
|
||||||
|
)
|
||||||
|
|
||||||
|
host_ca = any_ca # already filtered for org + active + not-deleted above
|
||||||
|
|
||||||
|
if not host_ca:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message=(
|
||||||
|
"Host CA not found, inactive, or you do not have permission to use it. "
|
||||||
|
"Ensure the CA exists and ca_type is 'host'."
|
||||||
|
),
|
||||||
|
status=404,
|
||||||
|
error_type="CA_NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Build key_id for the OpenSSH cert Key ID field ────────────────────────
|
||||||
|
# Format: "host:<principal> [signed-by:<user_email>]"
|
||||||
|
primary_principal = principals[0]
|
||||||
|
cert_identity = f"host:{primary_principal} [signed-by:{user.email}]"
|
||||||
|
|
||||||
|
signing_request = SSHCertificateSigningRequest(
|
||||||
|
ssh_public_key=host_public_key,
|
||||||
|
principals=principals,
|
||||||
|
cert_type="host",
|
||||||
|
key_id=cert_identity,
|
||||||
|
expiry_hours=validity_hours,
|
||||||
|
extensions=[], # Host certs carry no extensions (OpenSSH spec)
|
||||||
|
critical_options={},
|
||||||
|
)
|
||||||
|
|
||||||
|
validation_errors = signing_request.validate()
|
||||||
|
if validation_errors:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Invalid signing request: " + "; ".join(validation_errors),
|
||||||
|
status=422,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ca_private_key_pem = decrypt_ca_key(host_ca.private_key)
|
||||||
|
response = ssh_ca_service.sign_certificate(
|
||||||
|
signing_request, ca_private_key=ca_private_key_pem, ca_obj=host_ca
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
AuditLog.log(
|
||||||
|
action=AuditAction.SSH_CERT_FAILED,
|
||||||
|
user_id=user_id,
|
||||||
|
resource_type="SSHCertificate",
|
||||||
|
ip_address=request.remote_addr,
|
||||||
|
success=False,
|
||||||
|
error_message=str(exc),
|
||||||
|
)
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message=f"Host certificate signing failed: {exc}",
|
||||||
|
status=500,
|
||||||
|
error_type="SIGNING_FAILED",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Persist a cert record linked to the issuing admin (no ssh_key_id FK
|
||||||
|
# because this was a raw key, not a registered user key).
|
||||||
|
# We reuse _persist_certificate with ssh_key_id=ca_id as a stable sentinel.
|
||||||
|
cert_record = _persist_certificate(
|
||||||
|
user_id=user_id,
|
||||||
|
ssh_key_id=None, # host certs are not tied to a user SSH key record
|
||||||
|
ca=host_ca,
|
||||||
|
signing_response=response,
|
||||||
|
request_ip=request.remote_addr,
|
||||||
|
cert_type_str="host",
|
||||||
|
cert_identity=cert_identity,
|
||||||
|
)
|
||||||
|
|
||||||
|
AuditLog.log(
|
||||||
|
action=AuditAction.SSH_CERT_ISSUED,
|
||||||
|
user_id=user_id,
|
||||||
|
resource_type="SSHCertificate",
|
||||||
|
resource_id=cert_record.id if cert_record else None,
|
||||||
|
ip_address=request.remote_addr,
|
||||||
|
description=(
|
||||||
|
f"Host certificate serial={response.serial} issued for "
|
||||||
|
f"{primary_principal} by {user.email}"
|
||||||
|
),
|
||||||
|
extra_data={
|
||||||
|
"serial": response.serial,
|
||||||
|
"principals": principals,
|
||||||
|
"ca_id": str(host_ca.id),
|
||||||
|
"cert_type": "host",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"certificate": response.certificate,
|
||||||
|
"serial": response.serial,
|
||||||
|
"principals": response.principals,
|
||||||
|
"valid_after": response.valid_after.isoformat() if response.valid_after else None,
|
||||||
|
"valid_before": response.valid_before.isoformat() if response.valid_before else None,
|
||||||
|
}
|
||||||
|
if cert_record:
|
||||||
|
result["cert_id"] = str(cert_record.id)
|
||||||
|
|
||||||
|
return api_response(data=result, message="Host certificate issued successfully", status=201)
|
||||||
|
|
||||||
|
|
||||||
@ssh_bp.route('/certificates', methods=['GET'])
|
@ssh_bp.route('/certificates', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
def list_certificates():
|
def list_certificates():
|
||||||
@@ -758,6 +1067,7 @@ def list_certificates():
|
|||||||
.order_by(SSHCertificate.created_at.desc())
|
.order_by(SSHCertificate.created_at.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
return api_response(
|
return api_response(
|
||||||
data={
|
data={
|
||||||
'certificates': [c.to_dict() for c in certs],
|
'certificates': [c.to_dict() for c in certs],
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class SSHCertificate(BaseModel):
|
|||||||
ssh_key_id = db.Column(
|
ssh_key_id = db.Column(
|
||||||
db.String(36),
|
db.String(36),
|
||||||
db.ForeignKey("ssh_keys.id"),
|
db.ForeignKey("ssh_keys.id"),
|
||||||
nullable=False,
|
nullable=True, # Nullable: host certs may be issued against a raw public key
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ class SSHCertificateSigningRequest:
|
|||||||
self.cert_type = cert_type
|
self.cert_type = cert_type
|
||||||
self.expiry_hours = expiry_hours
|
self.expiry_hours = expiry_hours
|
||||||
self.critical_options = critical_options or {}
|
self.critical_options = critical_options or {}
|
||||||
self.extensions = extensions or []
|
# Preserve None vs [] distinction: None means "use CA/policy default";
|
||||||
|
# [] means "explicitly no extensions" (correct for host certificates).
|
||||||
|
self.extensions = extensions # type: Optional[List[str]]
|
||||||
|
|
||||||
def validate(self) -> List[str]:
|
def validate(self) -> List[str]:
|
||||||
"""Validate the signing request.
|
"""Validate the signing request.
|
||||||
@@ -273,11 +275,18 @@ class SSHCASigningService:
|
|||||||
)
|
)
|
||||||
# ─────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Set extensions — prefer policy-provided list, fall back to standard set
|
# Set extensions — prefer policy-provided list, fall back to standard set.
|
||||||
|
# Host certificates (cert_type=2) must have NO extensions per the OpenSSH
|
||||||
|
# spec; passing an empty list is correct and intentional for host certs.
|
||||||
|
# Only fall back to STANDARD_EXTENSIONS for user certificates when the
|
||||||
|
# caller did not supply an explicit (possibly empty) extension list.
|
||||||
extensions = signing_request.extensions
|
extensions = signing_request.extensions
|
||||||
if not extensions:
|
if extensions is None:
|
||||||
from gatehouse_app.models.organization.department_cert_policy import STANDARD_EXTENSIONS
|
if signing_request.cert_type == "user":
|
||||||
extensions = list(STANDARD_EXTENSIONS)
|
from gatehouse_app.models.organization.department_cert_policy import STANDARD_EXTENSIONS
|
||||||
|
extensions = list(STANDARD_EXTENSIONS)
|
||||||
|
else:
|
||||||
|
extensions = [] # host certs: no extensions
|
||||||
|
|
||||||
certificate.fields.extensions = extensions
|
certificate.fields.extensions = extensions
|
||||||
certificate.fields.critical_options = signing_request.critical_options or {}
|
certificate.fields.critical_options = signing_request.critical_options or {}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""allow_null_ssh_key_id_for_host_certs
|
||||||
|
|
||||||
|
Make ssh_certificates.ssh_key_id nullable so that host certificates issued
|
||||||
|
against a raw server host public key (i.e. not a pre-registered SSHKey record)
|
||||||
|
can be persisted in the database.
|
||||||
|
|
||||||
|
Revision ID: db15faee1fb8
|
||||||
|
Revises: 018_audit_enum_values
|
||||||
|
Create Date: 2026-03-03 16:55:54.030674
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'db15faee1fb8'
|
||||||
|
down_revision = '018_audit_enum_values'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.alter_column(
|
||||||
|
'ssh_certificates',
|
||||||
|
'ssh_key_id',
|
||||||
|
existing_type=sa.VARCHAR(length=36),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Null out any rows introduced by host-cert issuance before restoring NOT NULL
|
||||||
|
op.execute(
|
||||||
|
"UPDATE ssh_certificates SET ssh_key_id = '00000000-0000-0000-0000-000000000000' "
|
||||||
|
"WHERE ssh_key_id IS NULL"
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
'ssh_certificates',
|
||||||
|
'ssh_key_id',
|
||||||
|
existing_type=sa.VARCHAR(length=36),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
+1
-1
@@ -6,7 +6,7 @@ python_functions = test_*
|
|||||||
addopts =
|
addopts =
|
||||||
-v
|
-v
|
||||||
--strict-markers
|
--strict-markers
|
||||||
--cov=app
|
--cov=gatehouse_app
|
||||||
--cov-report=term-missing
|
--cov-report=term-missing
|
||||||
--cov-report=html
|
--cov-report=html
|
||||||
--cov-branch
|
--cov-branch
|
||||||
|
|||||||
Reference in New Issue
Block a user