opentofu 0.1.1 → 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: 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