opentofu 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a69c67e3a0d558a57695eafff1a69d111d8ff7ccc6c74de393efe6cb5f0bf276
4
- data.tar.gz: 780851bb7b0166ec9ea945f71735eba1c795cd399a7633606bdd4639762c6b59
3
+ metadata.gz: 1af62b16fa0e24d835f5255f5fc81526831f2ebbb06730ca257940057ff5e4b9
4
+ data.tar.gz: 2adbc048e86b177b764cfc9ec874f99142e814eb16de722779207e425cb87452
5
5
  SHA512:
6
- metadata.gz: c506b5667e6e99e717f0455c94a6e362664a5c299d880f465b031aa684969120c5484175c28606fc5aaabc39bd1fa577c89f6d05374e1df8ea37642dd4bf91e3
7
- data.tar.gz: 5d6f6f2ea96f1ea01a5b442dba6f1f8ef8f83f40f852866b28995fcd879a96a6914030f20303a71d6c9b21e2a56346aba28ad74b9a03a832a71b34e57325a1c2
6
+ metadata.gz: aa68f3a49de9f18d8a3a700757fd974528d1a503f562084d9e28385ac8aaadb4f6398fcd3e271643dc595b9308cdf91520cd9e9cb829113647025d458b68bb28
7
+ data.tar.gz: 12fe5263618be4a044d585b5f573248550ec8a91318e80c444e77158e79c18832390f3439e772cd4cb4ed0bb891128426667688aae1915bf00a7001828848e8d
data/.rubocop.yml CHANGED
@@ -1,6 +1,9 @@
1
1
  AllCops:
2
2
  TargetRubyVersion: 2.6
3
3
 
4
+ Layout/HeredocIndentation:
5
+ Enabled: false
6
+
4
7
  Metrics/AbcSize:
5
8
  Enabled: true
6
9
  Max: 25
@@ -9,6 +12,9 @@ Metrics/MethodLength:
9
12
  Enabled: true
10
13
  Max: 18
11
14
 
15
+ Style/Next:
16
+ Enabled: false
17
+
12
18
  Style/Semicolon:
13
19
  Enabled: true
14
20
  Exclude:
@@ -27,3 +33,5 @@ Style/StringLiteralsInInterpolation:
27
33
 
28
34
  Layout/LineLength:
29
35
  Max: 120
36
+ Exclude:
37
+ - "test/test_net_tofu_pub.rb"
data/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.0] - 2023-07-29
9
+
10
+ ### Added
11
+
12
+ - Certificate checking and pinning logic.
13
+ - Tests for certificate and pinning logic.
14
+
15
+ ### Fixed
16
+
17
+ - Tests involving fetching data, as they need to trust the host now.
18
+
8
19
  ## [0.1.1] - 2023-07-28
9
20
 
10
21
  ### Added
data/README.md CHANGED
@@ -14,7 +14,7 @@ gem install opentofu
14
14
  Or to use it as part of a project, place the following line in your Gemfile, then run `bundle install` in your project directory:
15
15
 
16
16
  ```ruby
17
- gem "opentofu", "~> 0.1.0"
17
+ gem "opentofu", "~> 0.2.0"
18
18
  ```
19
19
 
20
20
  ## Usage
data/exe/tofu ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "net/tofu"
5
+
6
+ OptionParser.new do |p|
7
+ p.banner = "Usage: tofu [options]"
8
+
9
+ p.on("-rHOST", "--remove=HOST", "Host to remove from the ~/.tofu/known_hosts file") do |host|
10
+ Net::Tofu::Pub.db_exist?
11
+
12
+ lines = File.read(Net::Tofu::Pub::DB).split("\n")
13
+
14
+ idx = nil
15
+ lines.each_with_index do |l, i|
16
+ h = l.split[0]
17
+ if host == h
18
+ idx = i
19
+ break
20
+ end
21
+ end
22
+
23
+ lines.delete_at(idx) unless idx.nil?
24
+
25
+ f = File.open(Net::Tofu::Pub::DB, "w")
26
+ f.write lines.join("\n")
27
+ f.write "\n" unless lines.nil? || lines.empty?
28
+
29
+ exit 0
30
+ ensure
31
+ f&.close
32
+ end
33
+
34
+ p.on("-V", "--version", "Print the version then quit") do
35
+ puts "tofu v#{Net::Tofu::VERSION}"
36
+ exit 0
37
+ end
38
+ end.parse!
@@ -26,5 +26,14 @@ module Net
26
26
  # Raised when a request contains an invalid URI.
27
27
  class InvalidURIError < StandardError; end
28
28
  end
29
+
30
+ # Raised if the host in a get request doesn't have a cached public key.
31
+ class UnknownHostError < StandardError; end
32
+
33
+ # Raised if the host returns an invalid certificate (usually, it means it is out of date).
34
+ class InvalidCertificateError < StandardError; end
35
+
36
+ # Raised if the host returns a different public key.
37
+ class PublicKeyMismatchError < StandardError; end
29
38
  end
30
39
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Tofu
5
+ # Intermediate container for an X509 certificate public key.
6
+ # Manages Geminispace public certificates.
7
+ class Pub
8
+ DIR = File.expand_path("~/.tofu")
9
+ DB = File.expand_path("~/.tofu/known_hosts")
10
+
11
+ # @return [String] The host associated with the certificate public key.
12
+ attr_reader :host
13
+
14
+ # @return [String] The certificate public key associated with the host.
15
+ attr_reader :key
16
+
17
+ # @return [OpenSSL::X509::Certificate] The X509 certificate.
18
+ attr_reader :x509
19
+
20
+ # Constructor for the cert type.
21
+ # @param host [String] Host to associate with the certificate public key.
22
+ # @param data [String] Certificate public key to associate with the host.
23
+ def initialize(host, data)
24
+ @host = host
25
+ @key = data
26
+ @key = format_hosts(data) if data.start_with?("-----BEGIN CERTIFICATE-----")
27
+ @x509 = OpenSSL::X509::Certificate.new(to_x509)
28
+ end
29
+
30
+ # Lookup a host in the known_hosts table.
31
+ # @param host [String] the hostname to lookup
32
+ # @return [Cert] An instance of this class if found, or nil if not found.
33
+ def self.lookup(host)
34
+ db_exist?
35
+
36
+ f = File.read(DB)
37
+ includes = nil
38
+ f.lines.each_with_index do |l, i|
39
+ if l.split[0].include? host
40
+ @line = i
41
+ includes = l.split[1]
42
+ break
43
+ end
44
+ end
45
+ return nil if includes.nil? || includes.empty?
46
+
47
+ new(host, includes)
48
+ end
49
+
50
+ def line
51
+ @line ||= nil
52
+ end
53
+
54
+ # Pin a certificate public key to the known_hosts file
55
+ def pin
56
+ Net::Tofu::Pub.db_exist?
57
+
58
+ f = File.open(DB, "a")
59
+ f.puts self
60
+ ensure
61
+ f&.close
62
+ end
63
+
64
+ # Conver the certificate public key to a properly formatted X509 string.
65
+ def to_x509
66
+ lines = @key.chars.each_slice(64).map(&:join)
67
+ "-----BEGIN CERTIFICATE-----\n#{lines.join("\n")}\n-----END CERTIFICATE-----"
68
+ end
69
+
70
+ # Hosts file format.
71
+ def to_s
72
+ "#{@host} #{@key}"
73
+ end
74
+
75
+ # Compare certificate public keys
76
+ def ==(other)
77
+ @key == other.key
78
+ end
79
+
80
+ # Check if known_hosts file exists, try to create it if it doesn't.
81
+ def self.db_exist?
82
+ return true if File.exist?(DB)
83
+
84
+ FileUtils.mkdir_p(DIR) unless File.directory?(DIR)
85
+ FileUtils.touch(DB)
86
+ true
87
+ end
88
+
89
+ private
90
+
91
+ # Strip header, tail, and newlines from base64 encoded certificate public key.
92
+ def format_hosts(key)
93
+ key.gsub("-----BEGIN CERTIFICATE-----", "").gsub("-----END CERTIFICATE-----", "").gsub("\n", "")
94
+ end
95
+ end
96
+ end
97
+ end
@@ -33,7 +33,7 @@ module Net
33
33
  # Constructor for the request type.
34
34
  # @param host [String] A host string, optionally with the gemini:// scheme.
35
35
  # @param port [Integer] Optional parameter to specify the server port to connect to.
36
- def initialize(host, port: nil)
36
+ def initialize(host, port: nil, trust: false)
37
37
  @uri = URI(host)
38
38
  @uri.port = port unless port.nil?
39
39
 
@@ -47,7 +47,7 @@ module Net
47
47
  end
48
48
 
49
49
  # Create a socket
50
- @sock = Socket.new(@host, @port)
50
+ @sock = Socket.new(@host, @port, trust)
51
51
  end
52
52
 
53
53
  # Format the URI for sending over a socket to a Gemini server.
@@ -7,9 +7,10 @@ module Net
7
7
  # Constructor for the socket type.
8
8
  # @param host [String] Hostname of the server to connect to.
9
9
  # @param port [Integer] Server port to connect to (typically 1965).
10
- def initialize(host, port)
10
+ def initialize(host, port, trust)
11
11
  @host = host
12
12
  @port = port
13
+ @trust = trust
13
14
  @sock = OpenSSL::SSL::SSLSocket.open(@host, @port, context: generate_secure_context)
14
15
  end
15
16
 
@@ -17,6 +18,7 @@ module Net
17
18
  def connect
18
19
  @sock.hostname = @host
19
20
  @sock.connect
21
+ validate_certs
20
22
  end
21
23
 
22
24
  # Try and retrieve data from a request.
@@ -49,6 +51,37 @@ module Net
49
51
  ctx.options |= OpenSSL::SSL::OP_IGNORE_UNEXPECTED_EOF
50
52
  ctx
51
53
  end
54
+
55
+ # Validate a remote certificate with a local one
56
+ def validate_certs
57
+ remote = Pub.new(@host, @sock.peer_cert.to_pem)
58
+ local = Pub.lookup(@host)
59
+
60
+ raise UnknownHostError if local.nil? && !@trust
61
+
62
+ remote.pin if local.nil? && @trust
63
+ local = Pub.lookup(@host)
64
+
65
+ unless local == remote
66
+ raise PublicKeyMismatchError, <<-TXT
67
+ The remote host has a different certificate than what is cached locally.
68
+ This could be because a new certificate was issued, or because of a MITM attack.
69
+ Please verify the new public certificate, then you may run:
70
+
71
+ tofu -r #{@host}
72
+
73
+ Once you remove the old certificate public key, you will be able to add the new one.
74
+ TXT
75
+ end
76
+
77
+ validate_timestamp(remote)
78
+ end
79
+
80
+ # Validate certificate timestamps
81
+ def validate_timestamp(remote)
82
+ raise InvalidCertificateError, "Certificate is not valid yet" if remote.x509.not_before > Time.now.utc
83
+ raise InvalidCertificateError, "Certificate is no longer valid" if remote.x509.not_after < Time.now.utc
84
+ end
52
85
  end
53
86
  end
54
87
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Net
4
4
  module Tofu
5
- VERSION = "0.1.1"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/net/tofu.rb CHANGED
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "tofu/error"
4
+ require_relative "tofu/pub"
4
5
  require_relative "tofu/request"
5
6
  require_relative "tofu/response"
6
7
  require_relative "tofu/socket"
7
8
  require_relative "tofu/version"
8
9
 
10
+ require "fileutils"
9
11
  require "openssl"
10
12
  require "stringio"
11
13
  require "uri/gemini"
@@ -13,8 +15,8 @@ require "uri/gemini"
13
15
  module Net
14
16
  # Top level module for Geminispace requests.
15
17
  module Tofu
16
- def self.get(uri, trust: true)
17
- req = Request.new(uri)
18
+ def self.get(uri, trust: false)
19
+ req = Request.new(uri, trust: trust)
18
20
  req.gets
19
21
 
20
22
  return req.resp.body if req.resp.success?
@@ -22,8 +24,8 @@ module Net
22
24
  req.resp.meta
23
25
  end
24
26
 
25
- def self.get_response(uri, trust: true)
26
- req = Request.new(uri)
27
+ def self.get_response(uri, trust: false)
28
+ req = Request.new(uri, trust: trust)
27
29
  req.gets
28
30
 
29
31
  req.resp
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: opentofu
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rory Dudley
@@ -15,7 +15,8 @@ description: |2
15
15
  the 'trust on first use' authentication scheme (https://en.wikipedia.org/wiki/Trust_on_first_use).
16
16
  email:
17
17
  - rory.dudley@gmail.com
18
- executables: []
18
+ executables:
19
+ - tofu
19
20
  extensions: []
20
21
  extra_rdoc_files: []
21
22
  files:
@@ -24,8 +25,10 @@ files:
24
25
  - LICENSE.txt
25
26
  - README.md
26
27
  - Rakefile
28
+ - exe/tofu
27
29
  - lib/net/tofu.rb
28
30
  - lib/net/tofu/error.rb
31
+ - lib/net/tofu/pub.rb
29
32
  - lib/net/tofu/request.rb
30
33
  - lib/net/tofu/response.rb
31
34
  - lib/net/tofu/socket.rb