From 2d9a3ba38f7712919381813ee3b094f997691981 Mon Sep 17 00:00:00 2001 From: Aurel Branzeanu Date: Mon, 15 Apr 2024 14:22:16 +0300 Subject: [PATCH 1/4] Fix "No such file or directory @ rb_file_s_rename" on migrating to '~/.local/state' --- lib/localhost/authority.rb | 110 ++++++++++++++++++------------------- 1 file changed, 54 insertions(+), 56 deletions(-) diff --git a/lib/localhost/authority.rb b/lib/localhost/authority.rb index 7ace655..222009c 100644 --- a/lib/localhost/authority.rb +++ b/lib/localhost/authority.rb @@ -20,88 +20,88 @@ 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 @@ -109,116 +109,116 @@ def 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. @@ -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(File.dirname(path), mode: 0700) unless File.directory?(path) + + # Migrates the legacy dir ~/.localhost/ to the XDG compliant directory + FileUtils.mv(old_root, path) if File.directory?(old_root) end end end From 6169880aea97006f6ec7c6e725ff6bd432e043ce Mon Sep 17 00:00:00 2001 From: Aurel Branzeanu Date: Mon, 15 Apr 2024 16:50:49 +0300 Subject: [PATCH 2/4] Add to the tests before and after hooks to retain the original .localhost --- lib/localhost/authority.rb | 4 +-- test/localhost/authority.rb | 49 +++++++++++++++++++++++++------------ 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/lib/localhost/authority.rb b/lib/localhost/authority.rb index 222009c..78ee823 100644 --- a/lib/localhost/authority.rb +++ b/lib/localhost/authority.rb @@ -228,10 +228,10 @@ def save(path = @root) def ensure_authority_path_exists(path = @root) old_root = File.expand_path("~/.localhost") - FileUtils.mkdir_p(File.dirname(path), mode: 0700) unless File.directory?(path) + 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) if File.directory?(old_root) + FileUtils.mv("#{@old_root}/.", path, force: true) if File.directory?(old_root) end end end diff --git a/test/localhost/authority.rb b/test/localhost/authority.rb index c771f14..b61bd11 100644 --- a/test/localhost/authority.rb +++ b/test/localhost/authority.rb @@ -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 @@ -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) @@ -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 From 61830758d9858ce1ef142e53f719a2acb2a50cef Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 16 Apr 2024 15:39:36 +1200 Subject: [PATCH 3/4] Revert whitespace changes. --- lib/localhost/authority.rb | 98 ++++++++++++++++++------------------- test/localhost/authority.rb | 42 ++++++++-------- 2 files changed, 70 insertions(+), 70 deletions(-) diff --git a/lib/localhost/authority.rb b/lib/localhost/authority.rb index 78ee823..4a6daec 100644 --- a/lib/localhost/authority.rb +++ b/lib/localhost/authority.rb @@ -20,31 +20,31 @@ 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 @@ -54,54 +54,54 @@ def self.fetch(*arguments, **options) 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 @@ -109,116 +109,116 @@ def 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. @@ -227,9 +227,9 @@ def save(path = @root) # will no longer be contain valid certificates. def ensure_authority_path_exists(path = @root) old_root = File.expand_path("~/.localhost") - + 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 diff --git a/test/localhost/authority.rb b/test/localhost/authority.rb index b61bd11..dfe1799 100644 --- a/test/localhost/authority.rb +++ b/test/localhost/authority.rb @@ -41,92 +41,92 @@ def after ENV["XDG_STATE_HOME"] = xdg_dir subject.new } - + with '#certificate' do 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) expect(File).to be(:exist?, authority.key_path) - + expect(authority.key_path).to be == File.join(xdg_dir, "localhost.rb", "localhost.key") expect(authority.certificate_path).to be == File.join(xdg_dir, "localhost.rb", "localhost.crt") end - + it "properly falls back when XDG_STATE_HOME is not set" do ENV.delete("XDG_STATE_HOME") authority = subject.new - + authority.save(authority.class.path) expect(File).to be(:exist?, authority.certificate_path) expect(File).to be(:exist?, authority.key_path) - + expect(authority.key_path).to be == File.join(File.expand_path("~/.local/state/"), "localhost.rb", "localhost.key") expect(authority.certificate_path).to be == File.join(File.expand_path("~/.local/state/"), "localhost.rb", "localhost.crt") end - + with '#store' do it "can verify certificate" do 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 From 51feeb0188011a2b60eea747afe51cc4c93f37be Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 16 Apr 2024 15:42:12 +1200 Subject: [PATCH 4/4] Revert whitespace changes. --- lib/localhost/authority.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/localhost/authority.rb b/lib/localhost/authority.rb index 4a6daec..ddfb3bb 100644 --- a/lib/localhost/authority.rb +++ b/lib/localhost/authority.rb @@ -24,7 +24,7 @@ def self.path # 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") @@ -47,7 +47,7 @@ def self.fetch(*arguments, **options) 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.