Module: KairosMcp::TlsCertGenerator

Defined in:
lib/kairos_mcp/tls_cert_generator.rb

Overview

TlsCertGenerator: produce a self-signed certificate + key for the single-operator remote-access case.

Design intent (Prop 2): this uses Ruby's OpenSSL stdlib to generate key material — it does not implement any cryptography of its own. It exists so that "kairos-chain --gen-cert" gives a one-shot path to HTTPS for a single operator. For a public, multi-user service, use a CA-issued certificate (e.g. behind a reverse proxy) instead of a self-signed one.

Constant Summary collapse

DEFAULT_HOSTS =

Hostnames/IPs always covered by the generated certificate's SAN so that loopback access verifies without --cert-host. Remote names are added by the caller (system hostname, config host, --cert-host entries).

%w[localhost 127.0.0.1 ::1].freeze

Class Method Summary collapse

Class Method Details

.add_san(entries, host) ⇒ Object



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/kairos_mcp/tls_cert_generator.rb', line 74

def add_san(entries, host)
  return if host.nil? || host.to_s.strip.empty?

  h = host.to_s.strip
  # Reject injection: a raw entry must be a single hostname/IP, not a
  # comma-separated list or a pre-formatted "DNS:/IP:" token — otherwise
  # --cert-host "a,DNS:evil" would silently mint extra SAN names.
  if h.include?(',') || h =~ /\A(DNS|IP):/i
    raise ArgumentError, "invalid SAN host #{h.inspect}: must be a single hostname or IP"
  end

  begin
    IPAddr.new(h)
    entries << "IP:#{h}"
  rescue IPAddr::InvalidAddressError, ArgumentError
    entries << "DNS:#{h}"
  end
end

.generate(cert_path:, key_path:, common_name: 'kairos-chain', hosts: DEFAULT_HOSTS, days: 825, key_size: 2048) ⇒ Hash

Returns { cert_path:, key_path:, not_after:, san: }.

Parameters:

  • hosts (Array<String>) (defaults to: DEFAULT_HOSTS)

    hostnames/IPs to place in the SAN. Each entry is auto-classified as IP: or DNS:. The common_name is always included.

Returns:

  • (Hash)

    { cert_path:, key_path:, not_after:, san: }



28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/kairos_mcp/tls_cert_generator.rb', line 28

def generate(cert_path:, key_path:, common_name: 'kairos-chain',
             hosts: DEFAULT_HOSTS, days: 825, key_size: 2048)
  key = OpenSSL::PKey::RSA.new(key_size)

  name = OpenSSL::X509::Name.parse("/CN=#{common_name}")
  cert = OpenSSL::X509::Certificate.new
  cert.version = 2
  # Positive, non-zero serial. RFC 5280 requires a positive integer;
  # SecureRandom.random_number(1 << 64) can return 0, so add 1.
  cert.serial = OpenSSL::BN.new(SecureRandom.random_number(1 << 64) + 1)
  cert.subject = name
  cert.issuer = name
  cert.public_key = key.public_key

  now = Time.now
  cert.not_before = now - 3600            # tolerate minor clock skew
  cert.not_after  = now + (days * 24 * 3600)

  san = san_value(common_name, hosts)

  ef = OpenSSL::X509::ExtensionFactory.new
  ef.subject_certificate = cert
  ef.issuer_certificate = cert
  # Leaf SERVER certificate — explicitly NOT a CA, scoped to serverAuth so
  # strict clients (macOS Secure Transport, Chromium) accept it.
  cert.add_extension(ef.create_extension('basicConstraints', 'CA:FALSE', true))
  cert.add_extension(ef.create_extension('keyUsage', 'digitalSignature,keyEncipherment', true))
  cert.add_extension(ef.create_extension('extendedKeyUsage', 'serverAuth', false))
  cert.add_extension(ef.create_extension('subjectAltName', san, false))
  cert.sign(key, OpenSSL::Digest.new('SHA256'))

  write_secure(cert_path, cert.to_pem, 0o644)
  write_secure(key_path, key.to_pem, 0o600)

  { cert_path: cert_path, key_path: key_path, not_after: cert.not_after, san: san }
end

.overwrite_refused_noninteractive?(exists:, tty:) ⇒ Boolean

Deterministic half of the --gen-cert overwrite guard: in non-interactive mode, refuse to clobber an existing cert/key (which could break a live deployment). Interactive mode prompts the operator instead. Extracted so the refusal branch is unit-testable.

Returns:

  • (Boolean)


97
98
99
# File 'lib/kairos_mcp/tls_cert_generator.rb', line 97

def overwrite_refused_noninteractive?(exists:, tty:)
  exists && !tty
end

.san_value(common_name, hosts) ⇒ Object

Build a subjectAltName value from a host list, auto-classifying each entry as IP: or DNS:. The common_name is always included as DNS.



67
68
69
70
71
72
# File 'lib/kairos_mcp/tls_cert_generator.rb', line 67

def san_value(common_name, hosts)
  entries = []
  add_san(entries, common_name)
  Array(hosts).each { |h| add_san(entries, h) }
  entries.uniq.join(',')
end

.write_secure(path, content, mode) ⇒ Object



101
102
103
104
105
106
107
108
109
110
111
# File 'lib/kairos_mcp/tls_cert_generator.rb', line 101

def write_secure(path, content, mode)
  FileUtils.mkdir_p(File.dirname(path))
  # Open with target mode (new files) AND chmod the descriptor BEFORE
  # writing, so on OVERWRITE of a pre-existing world-readable file the key
  # bytes are never written while the old permissive mode is still in
  # effect. Closes the umask/overwrite window on both create and overwrite.
  File.open(path, File::WRONLY | File::CREAT | File::TRUNC, mode) do |f|
    f.chmod(mode)
    f.write(content)
  end
end