@@ -8,7 +8,7 @@ defmodule SentinelCp.Services do
88
99 import Ecto.Query , warn: false
1010 alias SentinelCp.Repo
11- alias SentinelCp.Services . { Service , ServiceTemplate , ProjectConfig , UpstreamGroup , UpstreamTarget , Certificate , TrustStore , AuthPolicy , OpenApiSpec , DiscoverySource , DiscoverySync , Middleware , ServiceMiddleware }
11+ alias SentinelCp.Services . { Service , ServiceTemplate , ProjectConfig , UpstreamGroup , UpstreamTarget , Certificate , TrustStore , AuthPolicy , OpenApiSpec , DiscoverySource , DiscoverySync , Middleware , ServiceMiddleware , InternalCa , IssuedCertificate , CACrypto , CertificateCrypto }
1212 alias SentinelCp.Secrets
1313
1414 ## Services
@@ -405,6 +405,196 @@ defmodule SentinelCp.Services do
405405 Repo . delete ( trust_store )
406406 end
407407
408+ ## Internal CA
409+
410+ @ doc """
411+ Gets the internal CA for a project, if one exists.
412+ """
413+ def get_internal_ca ( project_id ) do
414+ Repo . get_by ( InternalCa , project_id: project_id )
415+ end
416+
417+ @ doc """
418+ Gets the internal CA for a project, raises if not found.
419+ """
420+ def get_internal_ca! ( project_id ) do
421+ Repo . get_by! ( InternalCa , project_id: project_id )
422+ end
423+
424+ @ doc """
425+ Initializes a new internal CA for a project.
426+
427+ Generates a key pair and self-signed CA certificate, encrypts the key,
428+ and inserts the InternalCa record.
429+ """
430+ def initialize_internal_ca ( attrs ) do
431+ algorithm = attrs [ :key_algorithm ] || attrs [ "key_algorithm" ] || "EC-P384"
432+ subject_cn = attrs [ :subject_cn ] || attrs [ "subject_cn" ] || "Internal CA"
433+ validity_years = attrs [ :validity_years ] || attrs [ "validity_years" ] || 10
434+
435+ key = CACrypto . generate_ca_key_pair ( algorithm )
436+ ca_cert_pem = CACrypto . generate_ca_certificate ( key , subject_cn , validity_years )
437+ key_pem = CACrypto . private_key_to_pem ( key )
438+ encrypted_key = CertificateCrypto . encrypt ( key_pem )
439+
440+ { :ok , meta } = Certificate . parse_cert_pem ( ca_cert_pem )
441+
442+ name = attrs [ :name ] || attrs [ "name" ]
443+ project_id = attrs [ :project_id ] || attrs [ "project_id" ]
444+
445+ ca_attrs = % {
446+ name: name ,
447+ project_id: project_id ,
448+ subject_cn: subject_cn ,
449+ key_algorithm: algorithm ,
450+ ca_cert_pem: ca_cert_pem ,
451+ ca_key_encrypted: encrypted_key ,
452+ not_before: meta . not_before ,
453+ not_after: meta . not_after ,
454+ fingerprint_sha256: meta . fingerprint_sha256
455+ }
456+
457+ % InternalCa { }
458+ |> InternalCa . create_changeset ( ca_attrs )
459+ |> Repo . insert ( )
460+ end
461+
462+ @ doc """
463+ Destroys an internal CA (deletes the record and cascades to issued certs).
464+ """
465+ def destroy_internal_ca ( % InternalCa { } = ca ) do
466+ Repo . delete ( ca )
467+ end
468+
469+ ## Issued Certificates
470+
471+ @ doc """
472+ Lists issued certificates for an internal CA, ordered by serial number.
473+ """
474+ def list_issued_certificates ( internal_ca_id ) do
475+ from ( c in IssuedCertificate ,
476+ where: c . internal_ca_id == ^ internal_ca_id ,
477+ order_by: [ asc: c . serial_number ]
478+ )
479+ |> Repo . all ( )
480+ end
481+
482+ @ doc """
483+ Gets a single issued certificate by ID.
484+ """
485+ def get_issued_certificate ( id ) , do: Repo . get ( IssuedCertificate , id )
486+
487+ @ doc """
488+ Gets a single issued certificate by ID, raises if not found.
489+ """
490+ def get_issued_certificate! ( id ) , do: Repo . get! ( IssuedCertificate , id )
491+
492+ @ doc """
493+ Issues a new client certificate from the internal CA.
494+
495+ Uses Ecto.Multi to atomically claim a serial number and insert the cert.
496+ """
497+ def issue_certificate ( % InternalCa { } = ca , attrs ) do
498+ subject_cn = attrs [ :subject_cn ] || attrs [ "subject_cn" ]
499+ subject_ou = attrs [ :subject_ou ] || attrs [ "subject_ou" ]
500+ name = attrs [ :name ] || attrs [ "name" ]
501+ validity_days = attrs [ :validity_days ] || attrs [ "validity_days" ] || 365
502+
503+ Ecto.Multi . new ( )
504+ |> Ecto.Multi . one ( :ca , fn _ ->
505+ from ( c in InternalCa , where: c . id == ^ ca . id )
506+ end )
507+ |> Ecto.Multi . run ( :issue , fn _repo , % { ca: locked_ca } ->
508+ serial = locked_ca . next_serial
509+
510+ # Decrypt CA key
511+ { :ok , ca_key_pem } = CertificateCrypto . decrypt ( locked_ca . ca_key_encrypted )
512+ { :ok , ca_key } = CACrypto . pem_to_private_key ( ca_key_pem )
513+
514+ # Parse CA cert DER
515+ [ { :Certificate , ca_cert_der , _ } ] = :public_key . pem_decode ( locked_ca . ca_cert_pem )
516+
517+ # Issue client cert
518+ { cert_pem , key_pem } =
519+ CACrypto . issue_client_certificate ( ca_key , ca_cert_der , subject_cn , serial ,
520+ subject_ou: subject_ou ,
521+ validity_days: validity_days
522+ )
523+
524+ encrypted_key = CertificateCrypto . encrypt ( key_pem )
525+ { :ok , meta } = Certificate . parse_cert_pem ( cert_pem )
526+
527+ cert_attrs = % {
528+ name: name ,
529+ serial_number: serial ,
530+ subject_cn: subject_cn ,
531+ subject_ou: subject_ou ,
532+ cert_pem: cert_pem ,
533+ key_pem_encrypted: encrypted_key ,
534+ not_before: meta . not_before ,
535+ not_after: meta . not_after ,
536+ fingerprint_sha256: meta . fingerprint_sha256 ,
537+ internal_ca_id: locked_ca . id
538+ }
539+
540+ % IssuedCertificate { }
541+ |> IssuedCertificate . create_changeset ( cert_attrs )
542+ |> Repo . insert ( )
543+ end )
544+ |> Ecto.Multi . update ( :increment_serial , fn % { ca: locked_ca } ->
545+ InternalCa . serial_changeset ( locked_ca , % { next_serial: locked_ca . next_serial + 1 } )
546+ end )
547+ |> Repo . transaction ( )
548+ |> case do
549+ { :ok , % { issue: cert } } -> { :ok , cert }
550+ { :error , _step , changeset , _changes } -> { :error , changeset }
551+ end
552+ end
553+
554+ @ doc """
555+ Revokes an issued certificate and regenerates the CRL.
556+ """
557+ def revoke_issued_certificate ( % IssuedCertificate { } = cert , reason \\ "unspecified" ) do
558+ Repo . transaction ( fn ->
559+ # Revoke the certificate
560+ { :ok , revoked } =
561+ cert
562+ |> IssuedCertificate . revoke_changeset ( % {
563+ revoked_at: DateTime . utc_now ( ) |> DateTime . truncate ( :second ) ,
564+ revoke_reason: reason
565+ } )
566+ |> Repo . update ( )
567+
568+ # Regenerate CRL
569+ ca = Repo . get! ( InternalCa , cert . internal_ca_id )
570+ regenerate_crl ( ca )
571+
572+ revoked
573+ end )
574+ end
575+
576+ defp regenerate_crl ( % InternalCa { } = ca ) do
577+ revoked_certs =
578+ from ( c in IssuedCertificate ,
579+ where: c . internal_ca_id == ^ ca . id and c . status == "revoked" ,
580+ select: { c . serial_number , c . revoked_at , c . revoke_reason }
581+ )
582+ |> Repo . all ( )
583+
584+ { :ok , ca_key_pem } = CertificateCrypto . decrypt ( ca . ca_key_encrypted )
585+ { :ok , ca_key } = CACrypto . pem_to_private_key ( ca_key_pem )
586+ [ { :Certificate , ca_cert_der , _ } ] = :public_key . pem_decode ( ca . ca_cert_pem )
587+
588+ crl_pem = CACrypto . generate_crl ( ca_key , ca_cert_der , revoked_certs )
589+
590+ ca
591+ |> InternalCa . crl_changeset ( % {
592+ crl_pem: crl_pem ,
593+ crl_updated_at: DateTime . utc_now ( ) |> DateTime . truncate ( :second )
594+ } )
595+ |> Repo . update! ( )
596+ end
597+
408598 ## Service Templates
409599
410600 @ doc """
0 commit comments