opentofu 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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: