Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 54 additions & 56 deletions lib/localhost/authority.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,205 +20,205 @@ class Authority
def self.path
File.expand_path("localhost.rb", ENV.fetch("XDG_STATE_HOME", "~/.local/state"))
end

# List all certificate authorities in the given directory:
def self.list(root = self.path)
return to_enum(:list) unless block_given?

Dir.glob("*.crt", base: root) do |path|
name = File.basename(path, ".crt")

authority = self.new(name, root: root)

if authority.load
yield authority
end
end
end

# Fetch (load or create) a certificate with the given hostname.
# See {#initialize} for the format of the arguments.
def self.fetch(*arguments, **options)
authority = self.new(*arguments, **options)

unless authority.load
authority.save
end

return authority
end

# Create an authority forn the given hostname.
# @parameter hostname [String] The common name to use for the certificate.
# @parameter root [String] The root path for loading and saving the certificate.
def initialize(hostname = "localhost", root: self.class.path)
@root = root
@hostname = hostname

@key = nil
@name = nil
@certificate = nil
@store = nil
end

# The hostname of the certificate authority.
attr :hostname

BITS = 1024*2

def ecdh_key
@ecdh_key ||= OpenSSL::PKey::EC.new "prime256v1"
end

def dh_key
@dh_key ||= OpenSSL::PKey::DH.new(BITS)
end

# The private key path.
def key_path
File.join(@root, "#{@hostname}.key")
end

# The public certificate path.
def certificate_path
File.join(@root, "#{@hostname}.crt")
end

# The private key.
def key
@key ||= OpenSSL::PKey::RSA.new(BITS)
end

def key= key
@key = key
end

# The certificate name.
def name
@name ||= OpenSSL::X509::Name.parse("/O=Development/CN=#{@hostname}")
end

def name= name
@name = name
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.name
# We use the same issuer as the subject, which makes this certificate self-signed:
certificate.issuer = self.name

certificate.public_key = self.key.public_key

certificate.serial = Time.now.to_i
certificate.version = 2

certificate.not_before = Time.now
certificate.not_after = Time.now + (3600 * 24 * 365)

extension_factory = OpenSSL::X509::ExtensionFactory.new
extension_factory.subject_certificate = certificate
extension_factory.issuer_certificate = certificate

certificate.extensions = [
extension_factory.create_extension("basicConstraints", "CA:FALSE", true),
extension_factory.create_extension("subjectKeyIdentifier", "hash"),
]

certificate.add_extension extension_factory.create_extension("authorityKeyIdentifier", "keyid:always,issuer:always")
certificate.add_extension extension_factory.create_extension("subjectAltName", "DNS: #{@hostname}")

certificate.sign self.key, OpenSSL::Digest::SHA256.new
end
end

# The certificate store which is used for validating the server certificate.
def store
@store ||= OpenSSL::X509::Store.new.tap do |store|
store.add_cert(self.certificate)
end
end

SERVER_CIPHERS = "EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5".freeze

# @returns [OpenSSL::SSL::SSLContext] An context suitable for implementing a secure server.
def server_context(*arguments)
OpenSSL::SSL::SSLContext.new(*arguments).tap do |context|
context.key = self.key
context.cert = self.certificate

context.session_id_context = "localhost"

if context.respond_to? :tmp_dh_callback=
context.tmp_dh_callback = proc {self.dh_key}
end

if context.respond_to? :ecdh_curves=
context.ecdh_curves = 'P-256:P-384:P-521'
elsif context.respond_to? :tmp_ecdh_callback=
context.tmp_ecdh_callback = proc {self.ecdh_key}
end

context.set_params(
ciphers: SERVER_CIPHERS,
verify_mode: OpenSSL::SSL::VERIFY_NONE,
)
end
end

# @returns [OpenSSL::SSL::SSLContext] An context suitable for connecting to a secure server using this authority.
def client_context(*args)
OpenSSL::SSL::SSLContext.new(*args).tap do |context|
context.cert_store = self.store

context.set_params(
verify_mode: OpenSSL::SSL::VERIFY_PEER,
)
end
end

def load(path = @root)
ensure_authority_path_exists(path)

certificate_path = File.join(path, "#{@hostname}.crt")
key_path = File.join(path, "#{@hostname}.key")

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))

# Certificates with old version need to be regenerated.
return false if certificate.version < 2

@certificate = certificate
@key = key

return true
end

def save(path = @root)
ensure_authority_path_exists(path)

lockfile_path = File.join(path, "#{@hostname}.lock")

File.open(lockfile_path, File::RDWR|File::CREAT, 0644) do |lockfile|
lockfile.flock(File::LOCK_EX)

File.write(
File.join(path, "#{@hostname}.crt"),
self.certificate.to_pem
)

File.write(
File.join(path, "#{@hostname}.key"),
self.key.to_pem
)
end
end

# Ensures that the directory to store the certificate exists. If the legacy
# directory (~/.localhost/) exists, it is moved into the new XDG Basedir
# compliant directory.
Expand All @@ -227,13 +227,11 @@ def save(path = @root)
# will no longer be contain valid certificates.
def ensure_authority_path_exists(path = @root)
old_root = File.expand_path("~/.localhost")

if File.directory?(old_root) and not File.directory?(path)
# Migrates the legacy dir ~/.localhost/ to the XDG compliant directory
File.rename(old_root, path)
elsif not File.directory?(path)
FileUtils.makedirs(path, mode: 0700)
end

FileUtils.mkdir_p(path, mode: 0700) unless File.directory?(path)

# Migrates the legacy dir ~/.localhost/ to the XDG compliant directory
FileUtils.mv("#{@old_root}/.", path, force: true) if File.directory?(old_root)
end
end
end
49 changes: 34 additions & 15 deletions test/localhost/authority.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@
require 'tempfile'

describe Localhost::Authority do
def before
@old_root = File.expand_path("~/.localhost")
@old_root_exists = File.directory?(@old_root)

if @old_root_exists
@tmp_folder = File.expand_path("~/.localhost_test")
FileUtils.mkdir_p(@tmp_folder, mode: 0700)
FileUtils.cp_r("#{@old_root}/.", @tmp_folder)
end
end

def after
if @old_root_exists
FileUtils.mkdir_p(@old_root, mode: 0700)
FileUtils.mv("#{@tmp_folder}/.", @old_root, force: true)
FileUtils.rm_r(@tmp_folder)
end
end

let(:xdg_dir) { File.join(Dir.pwd, "state") }
let(:authority) {
ENV["XDG_STATE_HOME"] = xdg_dir
Expand All @@ -27,22 +46,22 @@
it "is not valid for more than 1 year" do
certificate = authority.certificate
validity = certificate.not_after - certificate.not_before

# https://support.apple.com/en-us/102028
expect(validity).to be <= 398 * 24 * 60 * 60
end
end

it "can generate key and certificate" do
Dir.mktmpdir('localhost') do |dir|
authority.save(dir)

expect(File).to be(:exist?, File.expand_path("localhost.lock", dir))
expect(File).to be(:exist?, File.expand_path("localhost.crt", dir))
expect(File).to be(:exist?, File.expand_path("localhost.key", dir))
end
end

it "have correct key and certificate path" do
authority.save(authority.class.path)
expect(File).to be(:exist?, authority.certificate_path)
Expand All @@ -69,45 +88,45 @@
expect(authority.store.verify(authority.certificate)).to be == true
end
end

with '#server_context' do
it "can generate appropriate ssl context" do
expect(authority.server_context).to be_a OpenSSL::SSL::SSLContext
end
end

with 'client/server' do
include Sus::Fixtures::Async::ReactorContext

let(:endpoint) {Async::IO::Endpoint.tcp("localhost", 4040)}
let(:server_endpoint) {Async::IO::SSLEndpoint.new(endpoint, ssl_context: authority.server_context)}
let(:client_endpoint) {Async::IO::SSLEndpoint.new(endpoint, ssl_context: authority.client_context)}

let(:client) {client_endpoint.connect}

def before
@bound_endpoint = Async::IO::SharedEndpoint.bound(server_endpoint)

@server_task = reactor.async do
@bound_endpoint.accept do |peer|
peer.write("Hello World!")
peer.close
end
end

super
end

def after
@server_task&.stop
@bound_endpoint&.close

super
end

it "can verify peer" do
expect(client.read(12)).to be == "Hello World!"

client.close
end
end
Expand Down