1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 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 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152
|
# frozen_string_literal: true
# Released under the MIT License.
# Copyright, 2025, by Samuel Williams.
require "openssl"
require "fileutils"
require_relative "state"
module Localhost
# Represents a local Root Certificate Authority used to sign development certificates.
class Issuer
# The default number of bits for the private key. 4096 bits.
BITS = 4096
# The default validity period for the certificate. 10 years in seconds.
VALIDITY = 10 * 365 * 24 * 60 * 60
# Fetch (load or create) a certificate issuer with the given name.
# See {#initialize} for the format of the arguments.
def self.fetch(*arguments, **options)
issuer = self.new(*arguments, **options)
unless issuer.load
issuer.save
end
return issuer
end
# The default certificate issuer name.
NAME = "development"
# Initialize the issuer with the given name.
#
# @parameter name [String] The common name to use for the certificate.
# @parameter path [String] The path path for loading and saving the certificate.
def initialize(name = nil, path: State.path)
@name = name || NAME
@path = path
@subject = nil
@key = nil
@certificate = nil
end
# @returns [String] The path to the private key.
def key_path
File.join(@path, "#{@name}.key")
end
# @returns [String] The path to the public certificate.
def certificate_path
File.join(@path, "#{@name}.crt")
end
# @returns [OpenSSL::X509::Name] The subject name for the certificate.
def subject
@subject ||= OpenSSL::X509::Name.parse("/O=localhost.rb/CN=#{@name}")
end
# Set the subject name for the certificate.
#
# @parameter subject [OpenSSL::X509::Name] The subject name for the certificate.
def subject= subject
@subject = subject
end
# @returns [OpenSSL::PKey::RSA] The private key.
def key
@key ||= OpenSSL::PKey::RSA.new(BITS)
end
# The public certificate.
#
# @returns [OpenSSL::X509::Certificate] A self-signed certificate.
def certificate
@certificate ||= OpenSSL::X509::Certificate.new.tap do |certificate|
certificate.subject = self.subject
# We use the same issuer as the subject, which makes this certificate self-signed:
certificate.issuer = self.subject
certificate.public_key = self.key.public_key
certificate.serial = Time.now.to_i
certificate.version = 2
certificate.not_before = Time.now - 10
certificate.not_after = Time.now + VALIDITY
extension_factory = ::OpenSSL::X509::ExtensionFactory.new
extension_factory.subject_certificate = certificate
extension_factory.issuer_certificate = certificate
certificate.add_extension extension_factory.create_extension("basicConstraints", "CA:TRUE", true)
certificate.add_extension extension_factory.create_extension("keyUsage", "keyCertSign, cRLSign", true)
certificate.add_extension extension_factory.create_extension("subjectKeyIdentifier", "hash")
certificate.add_extension extension_factory.create_extension("authorityKeyIdentifier", "keyid:always", false)
certificate.sign self.key, OpenSSL::Digest::SHA256.new
end
end
# Load the certificate and key from the given path.
#
# @parameter path [String] The path to load the certificate and key.
# @returns [Boolean] True if the certificate and key were loaded successfully.
def load(path = @root)
certificate_path = self.certificate_path
key_path = self.key_path
return false unless File.exist?(certificate_path) and File.exist?(key_path)
certificate = OpenSSL::X509::Certificate.new(File.read(certificate_path))
key = OpenSSL::PKey::RSA.new(File.read(key_path))
@certificate = certificate
@key = key
return true
end
# @returns [String] The path to the lockfile.
def lockfile_path
File.join(@path, "#{@name}.lock")
end
# Save the certificate and key to the given path.
#
# @parameter path [String] The path to save the certificate and key.
def save(path = @root)
lockfile_path = self.lockfile_path
File.open(lockfile_path, File::RDWR|File::CREAT, 0644) do |lockfile|
lockfile.flock(File::LOCK_EX)
File.write(
self.certificate_path,
self.certificate.to_pem
)
File.write(
self.key_path,
self.key.to_pem
)
end
return true
end
end
end
|