Skip to content

Commit f8ec6c6

Browse files
feat: add internal CA for mutual TLS client certificate management
Implement project-scoped internal Certificate Authority that generates self-signed root certificates and issues X.509 client certificates for mTLS authentication. Includes certificate revocation with CRL generation, encrypted key storage, KDL config integration, and bundle distribution.
1 parent 46c93a2 commit f8ec6c6

21 files changed

Lines changed: 2829 additions & 22 deletions

lib/sentinel_cp/bundles/compile_worker.ex

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ defmodule SentinelCp.Bundles.CompileWorker do
1111

1212
alias SentinelCp.Bundles
1313
alias SentinelCp.Bundles.{Compiler, Risk, Signing, Storage}
14-
alias SentinelCp.Audit
14+
alias SentinelCp.{Audit, Services}
1515

1616
@impl Oban.Worker
1717
def perform(%Oban.Job{args: %{"bundle_id" => bundle_id}}) do
@@ -91,8 +91,10 @@ defmodule SentinelCp.Bundles.CompileWorker do
9191
end
9292

9393
defp compile_bundle(bundle) do
94+
extra_files = build_extra_files(bundle.project_id)
95+
9496
with {:ok, compiler_output} <- Compiler.validate(bundle.config_source),
95-
{:ok, assembly} <- Compiler.assemble(bundle.id, bundle.config_source),
97+
{:ok, assembly} <- Compiler.assemble(bundle.id, bundle.config_source, extra_files),
9698
storage_key <- Storage.storage_key(bundle.project_id, bundle.id),
9799
:ok <- Storage.upload(storage_key, assembly.archive) do
98100
{:ok,
@@ -106,4 +108,17 @@ defmodule SentinelCp.Bundles.CompileWorker do
106108
}}
107109
end
108110
end
111+
112+
defp build_extra_files(project_id) do
113+
case Services.get_internal_ca(project_id) do
114+
nil ->
115+
[]
116+
117+
ca ->
118+
[
119+
{"internal-ca/ca.pem", ca.ca_cert_pem},
120+
{"internal-ca/crl.pem", ca.crl_pem || ""}
121+
]
122+
end
123+
end
109124
end

lib/sentinel_cp/bundles/compiler.ex

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ defmodule SentinelCp.Bundles.Compiler do
4040
Returns {:ok, %{archive: binary, checksum: string, size: integer, manifest: map}}
4141
or {:error, reason}.
4242
"""
43-
def assemble(bundle_id, config_source) when is_binary(config_source) do
43+
def assemble(bundle_id, config_source, extra_files \\ []) when is_binary(config_source) do
4444
tmpdir = Path.join(System.tmp_dir!(), "sentinel-bundle-#{bundle_id}")
4545
File.mkdir_p!(tmpdir)
4646

@@ -49,27 +49,46 @@ defmodule SentinelCp.Bundles.Compiler do
4949
config_path = Path.join(tmpdir, "sentinel.kdl")
5050
File.write!(config_path, config_source)
5151

52+
# Write extra files
53+
extra_file_entries =
54+
Enum.map(extra_files, fn {relative_path, content} ->
55+
full_path = Path.join(tmpdir, relative_path)
56+
File.mkdir_p!(Path.dirname(full_path))
57+
File.write!(full_path, content || "")
58+
59+
%{
60+
"path" => relative_path,
61+
"checksum" => checksum(content || ""),
62+
"size" => byte_size(content || "")
63+
}
64+
end)
65+
5266
# Create manifest
5367
config_checksum = checksum(config_source)
5468

5569
manifest = %{
5670
"bundle_id" => bundle_id,
5771
"assembled_at" => DateTime.utc_now() |> DateTime.to_iso8601(),
58-
"files" => [
59-
%{
60-
"path" => "sentinel.kdl",
61-
"checksum" => config_checksum,
62-
"size" => byte_size(config_source)
63-
}
64-
]
72+
"files" =>
73+
[
74+
%{
75+
"path" => "sentinel.kdl",
76+
"checksum" => config_checksum,
77+
"size" => byte_size(config_source)
78+
}
79+
] ++ extra_file_entries
6580
}
6681

6782
manifest_json = Jason.encode!(manifest, pretty: true)
6883
manifest_path = Path.join(tmpdir, "manifest.json")
6984
File.write!(manifest_path, manifest_json)
7085

86+
# Build file list for archive
87+
extra_paths = Enum.map(extra_files, fn {path, _} -> path end)
88+
all_files = ["sentinel.kdl"] ++ extra_paths ++ ["manifest.json"]
89+
7190
# Create tar.zst archive
72-
archive = create_archive(tmpdir, ["sentinel.kdl", "manifest.json"])
91+
archive = create_archive(tmpdir, all_files)
7392
archive_checksum = checksum(archive)
7493

7594
{:ok,

lib/sentinel_cp/services.ex

Lines changed: 191 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)