opentofu 0.1.0 → 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: 1eeac402d86ae7cc5b5f2e9be3327a60f8f3132453854ea9ee5f477462e58fc7
4
- data.tar.gz: 54f68d912adb5e14a39ddde842ad4acf5ff3664923815138e5a30fcc8ba87881
3
+ metadata.gz: 1af62b16fa0e24d835f5255f5fc81526831f2ebbb06730ca257940057ff5e4b9
4
+ data.tar.gz: 2adbc048e86b177b764cfc9ec874f99142e814eb16de722779207e425cb87452
5
5
  SHA512:
6
- metadata.gz: c74b24eada5091a0d37eb1a97b871838744e2169b36974fed374900c0a64e73acafe019751341da153f6f5e59a0b6e48e2c4aa9b1674fa65aa3573a74be44528
7
- data.tar.gz: 8c00e948a371088cd8e87698d3884fe55d7aa8b84ac65c5a5d64d6b0a53cbf505fed91010cd4d83e53f820a1b2a6dac46a9ad5947733546404e1d992d0301bf5
6
+ metadata.gz: aa68f3a49de9f18d8a3a700757fd974528d1a503f562084d9e28385ac8aaadb4f6398fcd3e271643dc595b9308cdf91520cd9e9cb829113647025d458b68bb28
7
+ data.tar.gz: 12fe5263618be4a044d585b5f573248550ec8a91318e80c444e77158e79c18832390f3439e772cd4cb4ed0bb891128426667688aae1915bf00a7001828848e8d
data/.rubocop.yml CHANGED
@@ -1,16 +1,19 @@
1
1
  AllCops:
2
2
  TargetRubyVersion: 2.6
3
3
 
4
+ Layout/HeredocIndentation:
5
+ Enabled: false
6
+
7
+ Metrics/AbcSize:
8
+ Enabled: true
9
+ Max: 25
10
+
4
11
  Metrics/MethodLength:
5
12
  Enabled: true
6
13
  Max: 18
7
- Exclude:
8
- - "lib/net/tofu/response.rb"
9
14
 
10
- Style/ClassVars:
11
- Enabled: true
12
- Exclude:
13
- - "lib/net/tofu/request.rb"
15
+ Style/Next:
16
+ Enabled: false
14
17
 
15
18
  Style/Semicolon:
16
19
  Enabled: true
@@ -18,9 +21,7 @@ Style/Semicolon:
18
21
  - "lib/net/tofu/response.rb"
19
22
 
20
23
  Style/SingleLineMethods:
21
- Enabled: true
22
- Exclude:
23
- - "lib/net/tofu/response.rb"
24
+ Enabled: false
24
25
 
25
26
  Style/StringLiterals:
26
27
  Enabled: true
@@ -32,3 +33,5 @@ Style/StringLiteralsInInterpolation:
32
33
 
33
34
  Layout/LineLength:
34
35
  Max: 120
36
+ Exclude:
37
+ - "test/test_net_tofu_pub.rb"
data/CHANGELOG.md CHANGED
@@ -5,7 +5,36 @@ 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.1.0] - 2023-07027
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
+
19
+ ## [0.1.1] - 2023-07-28
20
+
21
+ ### Added
22
+
23
+ - Tests.
24
+ - The `simplecov` gem to view testing coverage.
25
+ - A new error, for if the server doesn't send any data.
26
+ - The Linux platform to the bundle platform.
27
+
28
+ ### Fixed
29
+
30
+ - Complexity issues in the Response class from the linter.
31
+
32
+ ### Removed
33
+
34
+ - The `@@current_host` class variable from Request. Clients should handle keeping track of the current host, Tofu should just fetch data.
35
+ - The complicated logic used to parse host names from the Request class, as per the above point.
36
+
37
+ ## [0.1.0] - 2023-07-27
9
38
 
10
39
  First release. Still lots of improvements to be made.
11
40
 
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!
@@ -3,6 +3,9 @@
3
3
  module Net
4
4
  module Tofu
5
5
  class Response
6
+ # Raised when a server doesn't send any data.
7
+ class NoServerResponseError < StandardError; end
8
+
6
9
  # Raised when a server sends an invalid header.
7
10
  class InvalidHeaderError < StandardError; end
8
11
 
@@ -23,5 +26,14 @@ module Net
23
26
  # Raised when a request contains an invalid URI.
24
27
  class InvalidURIError < StandardError; end
25
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
26
38
  end
27
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
@@ -4,8 +4,6 @@ module Net
4
4
  module Tofu
5
5
  # Stores a client request to a Gemini server.
6
6
  class Request
7
- SCHEME = "gemini"
8
-
9
7
  MAX_URI_BYTESIZE = 1024
10
8
 
11
9
  # @return [URI] The full URI object of the request.
@@ -35,18 +33,13 @@ module Net
35
33
  # Constructor for the request type.
36
34
  # @param host [String] A host string, optionally with the gemini:// scheme.
37
35
  # @param port [Integer] Optional parameter to specify the server port to connect to.
38
- def initialize(host, port: nil)
39
- # Keeps track of the current host for links with only paths.
40
- @@current_host ||= ""
36
+ def initialize(host, port: nil, trust: false)
37
+ @uri = URI(host)
38
+ @uri.port = port unless port.nil?
41
39
 
42
- @host = host
43
- @port = port unless port.nil?
44
- determine_host
45
40
  parse_head
46
41
  parse_tail
47
42
 
48
- puts format
49
-
50
43
  # Make sure the URI isn't too large
51
44
  if format.bytesize > MAX_URI_BYTESIZE
52
45
  raise InvalidURIError,
@@ -54,7 +47,7 @@ module Net
54
47
  end
55
48
 
56
49
  # Create a socket
57
- @sock = Socket.new(@host, @port)
50
+ @sock = Socket.new(@host, @port, trust)
58
51
  end
59
52
 
60
53
  # Format the URI for sending over a socket to a Gemini server.
@@ -73,63 +66,28 @@ module Net
73
66
 
74
67
  private
75
68
 
76
- # Parses the host and the path, and sets the current_host.
77
- def determine_host
78
- puts "current: #{@@current_host}"
79
- @uri = URI(@host)
80
-
81
- unless @uri.host.nil? || @uri.host.empty?
82
- @host = @uri.host
83
- @@current_host = @host
84
- return
85
- end
86
-
87
- return if @uri.path.nil? || @uri.path.empty?
88
- return unless @uri.host.nil? || @uri.host.empty?
89
-
90
- if @uri.path.start_with?("/")
91
- raise InvalidURIError, "No host specified" if @@current_host.nil? || @@current_host.empty?
92
-
93
- unless @@current_host.nil? || @@current_host.empty?
94
- @uri.host = @@current_host
95
- @host = @uri.host
96
- @@current_host = @host
97
- end
98
-
99
- return
100
- end
101
-
102
- paths = @uri.path.split("/")
103
- puts paths
104
-
105
- @uri.host = paths[0]
106
- @host = @uri.host
107
- @@current_host = @host
108
- @uri.path = nil if paths.length == 1
109
- return unless paths.length > 1
110
-
111
- @uri.path = paths[1..].join("/")
112
- @uri.path = "/#{uri.path}"
113
- end
114
-
115
69
  # Parses the scheme, the host, and the port for the request.
116
70
  def parse_head
117
71
  # Check if a scheme was specified, if not, default to gemini://
118
72
  # Also set the port if this happens
119
73
  if @uri.scheme.nil? || @uri.scheme.empty?
120
- @uri.scheme = SCHEME
121
- @uri.port = URI::Gemini::DEFAULT_PORT
74
+ @uri.scheme = URI::Gemini::DEFAULT_SCHEME
75
+ @uri.port = URI::Gemini::DEFAULT_PORT if @uri.port.nil?
122
76
  end
123
77
 
78
+ # Check if a host was specified.
79
+ raise InvalidURIError, "Request does not contain a host" if @uri.host.nil? || @uri.host.empty?
80
+
124
81
  # Set member parts
125
82
  @scheme = @uri.scheme
83
+ @host = @uri.host
126
84
  @port = @uri.port
127
85
 
128
86
  # Check if a scheme is present that isn't gemini://
129
- return if @uri.scheme == SCHEME
87
+ return if @uri.scheme == URI::Gemini::DEFAULT_SCHEME
130
88
 
131
89
  raise InvalidSchemeError,
132
- "Request uses an invalid scheme (has: #{@uri.scheme}, wants: #{SCHEME}"
90
+ "Request uses an invalid scheme (has: #{@uri.scheme}, wants: #{URI::Gemini::DEFAULT_SCHEME}"
133
91
  end
134
92
 
135
93
  # Parses the path, the query, and the fragment for the request.
@@ -83,6 +83,8 @@ module Net
83
83
 
84
84
  # Splits up the responseinto a header and a body.
85
85
  def parse
86
+ raise NoServerResponseError if @data.nil? || @data.empty?
87
+
86
88
  # Extract the header and parse it
87
89
  a = @data.split("\n")
88
90
  @header = a[0].strip
@@ -108,6 +110,7 @@ module Net
108
110
  # Parse the meta
109
111
  @meta = ""
110
112
  @meta = a[1..].join(" ") if a.length >= 2
113
+ @meta.strip!
111
114
  parse_meta
112
115
  end
113
116
 
@@ -144,14 +147,21 @@ module Net
144
147
  def parse_meta
145
148
  # Make sure the meta isn't too large
146
149
  if @meta.bytesize > MAX_META_BYTESIZE
147
- raise InvalidMetaError,
148
- "The server sent a meta that was too large, should be #{MAX_META_BYTESIZE} bytes, instead is #{@meta.bytesize} bytes"
150
+ raise InvalidMetaError, <<-TXT
151
+ The server sent a meta that was too large, should be #{MAX_META_BYTESIZE} bytes,
152
+ instead is #{@meta.bytesize} bytes
153
+ TXT
149
154
  end
150
155
 
151
156
  if @status_maj == TEMPORARY_FAILURE || @status_maj == PERMANENT_FAILURE || @status_maj == REQUEST_CERTIFICATE
152
157
  return
153
158
  end
154
159
 
160
+ # Handle extra checks based on the @status type
161
+ handle_type
162
+ end
163
+
164
+ def handle_type
155
165
  # Make sure meta exists (i.e. has length)
156
166
  # This satisfies the INPUT and SUCCESS
157
167
  unless @meta.length.positive?
@@ -169,10 +179,10 @@ module Net
169
179
  raise InvalidRedirectError, "The redirect link does not have a scheme" if uri.scheme.nil? || uri.scheme.empty?
170
180
 
171
181
  # Make sure the URI scheme is 'gemini'
172
- return if uri.scheme == "gemini"
182
+ return if uri.scheme == URI::Gemini::DEFAULT_SCHEME
173
183
 
174
184
  raise InvalidRedirectError,
175
- "The redirect link has an invalid scheme (has: #{uri.scheme}, wants: gemini)"
185
+ "The redirect link has an invalid scheme (has: #{uri.scheme}, wants: #{URI::Gemini::DEFAULT_SCHEME})"
176
186
  end
177
187
  end
178
188
  end
@@ -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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Tofu
4
- VERSION = "0.1.0"
3
+ module Net
4
+ module Tofu
5
+ VERSION = "0.2.0"
6
+ end
5
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
data/lib/uri/gemini.rb CHANGED
@@ -35,6 +35,9 @@ module URI # :nodoc:
35
35
  # A Default port of 1965 for URI::Gemini.
36
36
  DEFAULT_PORT = 1965
37
37
 
38
+ # A Default scheme of gemini for URI::Gemini.
39
+ DEFAULT_SCHEME = "gemini"
40
+
38
41
  # An Array of the available components for URI::Gemini.
39
42
  COMPONENT = %i[scheme host port
40
43
  path query fragment].freeze
metadata CHANGED
@@ -1,21 +1,22 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: opentofu
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rory Dudley
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-07-27 00:00:00.000000000 Z
11
+ date: 2023-07-29 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |2
14
14
  OpenTOFU is a client and certificate pinning library for Geminispace, derived from
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
@@ -39,7 +42,7 @@ metadata:
39
42
  allowed_push_host: https://rubygems.org
40
43
  homepage_uri: https://github.com/pinecat/opentofu
41
44
  source_code_uri: https://github.com/pinecat/opentofu
42
- changelog_uri: https://github.com/pinecat/opentofu
45
+ changelog_uri: https://github.com/pinecat/opentofu/blob/master/CHANGELOG.md
43
46
  post_install_message:
44
47
  rdoc_options: []
45
48
  require_paths: