net-tofu 0.3.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 326660f0872c1d0c73301704a577a046edf4934cacaf5236acd16d3ebb81d2ad
4
+ data.tar.gz: 47ece363c905ff8ab0df56e8a4ed6ae7a3ee52115baf3def5b6fa7b728ea4c90
5
+ SHA512:
6
+ metadata.gz: ba6fff1abc4d1fed80f41d3f9a42b30b35f427ace5886a8bfceb0e8f1cf011bb2b5c57da4f6a5f0cc599618d5eac9dbb6c23bdeb00d3d10e798a4dd0e1595994
7
+ data.tar.gz: 0b7e90ea5fd91e44cc72a1c6c488cc676d8ddab67f205dd5765e3a7e1f70b1e2501df066456acad6f3e6bb5b461643d96c15fb2e12bc6b444e268cf2514bb311
data/.rubocop.yml ADDED
@@ -0,0 +1,37 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.6
3
+
4
+ Layout/HeredocIndentation:
5
+ Enabled: false
6
+
7
+ Metrics/AbcSize:
8
+ Enabled: true
9
+ Max: 25
10
+
11
+ Metrics/MethodLength:
12
+ Enabled: true
13
+ Max: 18
14
+
15
+ Style/Next:
16
+ Enabled: false
17
+
18
+ Style/Semicolon:
19
+ Enabled: true
20
+ Exclude:
21
+ - "lib/net/tofu/response.rb"
22
+
23
+ Style/SingleLineMethods:
24
+ Enabled: false
25
+
26
+ Style/StringLiterals:
27
+ Enabled: true
28
+ EnforcedStyle: double_quotes
29
+
30
+ Style/StringLiteralsInInterpolation:
31
+ Enabled: true
32
+ EnforcedStyle: double_quotes
33
+
34
+ Layout/LineLength:
35
+ Max: 120
36
+ Exclude:
37
+ - "test/test_net_tofu_pub.rb"
data/CHANGELOG.md ADDED
@@ -0,0 +1,61 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.3.0] - 2023-07-29
9
+
10
+ ### Fixed
11
+
12
+ - Renamed the library to something more appropriate `opentofu -> net-tofu`.
13
+ - Fixed Github workflows.
14
+ - Using my fork of the `gemtext` gem, until my PR is finialized.
15
+
16
+ ## [0.2.0] - 2023-07-29
17
+
18
+ ### Added
19
+
20
+ - Certificate checking and pinning logic.
21
+ - Tests for certificate and pinning logic.
22
+
23
+ ### Fixed
24
+
25
+ - Tests involving fetching data, as they need to trust the host now.
26
+
27
+ ## [0.1.1] - 2023-07-28
28
+
29
+ ### Added
30
+
31
+ - Tests.
32
+ - The `simplecov` gem to view testing coverage.
33
+ - A new error, for if the server doesn't send any data.
34
+ - The Linux platform to the bundle platform.
35
+
36
+ ### Fixed
37
+
38
+ - Complexity issues in the Response class from the linter.
39
+
40
+ ### Removed
41
+
42
+ - The `@@current_host` class variable from Request. Clients should handle keeping track of the current host, Tofu should just fetch data.
43
+ - The complicated logic used to parse host names from the Request class, as per the above point.
44
+
45
+ ## [0.1.0] - 2023-07-27
46
+
47
+ First release. Still lots of improvements to be made.
48
+
49
+ ### Added
50
+
51
+ - Custom error classes for the library.
52
+ - A Request model for handling client requests.
53
+ - Handles the URI parsing logic.
54
+ - Holds a Response type.
55
+ - Does some basic error checking based on the Gemini Protocol specification.
56
+ - A Response model for handling server responses.
57
+ - Holds a Socket type.
58
+ - Does some basic error checking based on the Gemini Protocol specification.
59
+ - A Socket model for handling raw socket connections, as well as TLS security settings.
60
+ - A top-level module for making get and get_response requests.
61
+ - Added a 'trust' parameter to the Net::Tofu.get and Net::Tofu.get_response methods. This will later be used for certificate management, but it isn't in use yet.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Rory Dudley
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # Net::Tofu
2
+
3
+ Net::Tofu is a certificate pinning and client library for Geminispace.
4
+ It is based off of the ['trust on first use'](https://en.wikipedia.org/wiki/Trust_on_first_use) authentication scheme.
5
+
6
+ ## Installation
7
+
8
+ To install the gem standalone:
9
+
10
+ ```sh
11
+ gem install net-tofu
12
+ ```
13
+
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
+
16
+ ```ruby
17
+ gem "net-tofu", "~> 0.2.0"
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```ruby
23
+ require "net/tofu"
24
+ ```
25
+
26
+ ## Credits
27
+
28
+ I'd like to thank Étienne Deparis, author of [ruby-net-text](https://git.umaneti.net/ruby-net-text/) for releasing their code under the MIT license so that some of it can be used in this project. In particular, `lib/ui/gemini.rb` is taken straight from that codebase. The license for it is present in the aformentioned file.
29
+
30
+ ## Development
31
+
32
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
33
+
34
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
35
+
36
+ ## Contributing
37
+
38
+ Bug reports and pull requests are welcome on GitHub at https://github.com/pinecat/net-tofu.
39
+
40
+ ## License
41
+
42
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/test_*.rb"]
10
+ end
11
+
12
+ require "rubocop/rake_task"
13
+
14
+ RuboCop::RakeTask.new
15
+
16
+ task default: %i[test rubocop]
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!
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Tofu
5
+ class Response
6
+ # Raised when a server doesn't send any data.
7
+ class NoServerResponseError < StandardError; end
8
+
9
+ # Raised when a server sends an invalid header.
10
+ class InvalidHeaderError < StandardError; end
11
+
12
+ # Raised when a server sends an invalid status code.
13
+ class InvalidStatusCodeError < StandardError; end
14
+
15
+ # Raised when a server sends an invalid meta field.
16
+ class InvalidMetaError < StandardError; end
17
+
18
+ # Raised when a server sends an invalid redirect link.
19
+ class InvalidRedirectError < StandardError; end
20
+ end
21
+
22
+ class Request
23
+ # Raised when a request contains an invalid scheme.
24
+ class InvalidSchemeError < StandardError; end
25
+
26
+ # Raised when a request contains an invalid URI.
27
+ class InvalidURIError < StandardError; end
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
38
+ end
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
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Tofu
5
+ # Stores a client request to a Gemini server.
6
+ class Request
7
+ MAX_URI_BYTESIZE = 1024
8
+
9
+ # @return [URI] The full URI object of the request.
10
+ attr_reader :uri
11
+
12
+ # @return [String] The request scheme (i.e. gemini://, http://).
13
+ attr_reader :scheme
14
+
15
+ # @return [String] The hostname of the request.
16
+ attr_reader :host
17
+
18
+ # @return [Integer] The server port to connect on.
19
+ attr_reader :port
20
+
21
+ # @return [String] The requested path on the host.
22
+ attr_reader :path
23
+
24
+ # @return [Array] Additional queries to send to the host.
25
+ attr_reader :queries
26
+
27
+ # @return [String] A fragment to request from the host.
28
+ attr_reader :fragment
29
+
30
+ # @return [Response] The response from the server after calling #{gets}.
31
+ attr_reader :resp
32
+
33
+ # Constructor for the request type.
34
+ # @param host [String] A host string, optionally with the gemini:// scheme.
35
+ # @param port [Integer] Optional parameter to specify the server port to connect to.
36
+ def initialize(host, port: nil, trust: false)
37
+ @uri = URI(host)
38
+ @uri.port = port unless port.nil?
39
+
40
+ parse_head
41
+ parse_tail
42
+
43
+ # Make sure the URI isn't too large
44
+ if format.bytesize > MAX_URI_BYTESIZE
45
+ raise InvalidURIError,
46
+ "The URI is too large, should be #{MAX_URI_BYTESIZE} bytes, instead is #{format.bytesize} bytes"
47
+ end
48
+
49
+ # Create a socket
50
+ @sock = Socket.new(@host, @port, trust)
51
+ end
52
+
53
+ # Format the URI for sending over a socket to a Gemini server.
54
+ # @return [String] The URI string appended with a carriage return and linefeed.
55
+ def format
56
+ "#{@uri}\r\n"
57
+ end
58
+
59
+ # Connect to the server and try to fetch data.
60
+ def gets
61
+ @sock.connect
62
+ @resp = @sock.gets(self)
63
+ ensure
64
+ @sock.close
65
+ end
66
+
67
+ private
68
+
69
+ # Parses the scheme, the host, and the port for the request.
70
+ def parse_head
71
+ # Check if a scheme was specified, if not, default to gemini://
72
+ # Also set the port if this happens
73
+ if @uri.scheme.nil? || @uri.scheme.empty?
74
+ @uri.scheme = URI::Gemini::DEFAULT_SCHEME
75
+ @uri.port = URI::Gemini::DEFAULT_PORT if @uri.port.nil?
76
+ end
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
+
81
+ # Set member parts
82
+ @scheme = @uri.scheme
83
+ @host = @uri.host
84
+ @port = @uri.port
85
+
86
+ # Check if a scheme is present that isn't gemini://
87
+ return if @uri.scheme == URI::Gemini::DEFAULT_SCHEME
88
+
89
+ raise InvalidSchemeError,
90
+ "Request uses an invalid scheme (has: #{@uri.scheme}, wants: #{URI::Gemini::DEFAULT_SCHEME}"
91
+ end
92
+
93
+ # Parses the path, the query, and the fragment for the request.
94
+ def parse_tail
95
+ # Set path to '/' if one isn't specified
96
+ @uri.path = "/" if @uri.path.nil? || @uri.path.empty?
97
+
98
+ # Set member parts
99
+ @path = @uri.path
100
+ @queries = @uri.query.split("&") unless @uri.query.nil? || @uri.query.empty?
101
+ @fragment = @uri.fragment
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ class String # :nodoc:
4
+ def numerical?
5
+ to_i.to_s == self
6
+ end
7
+ end
8
+
9
+ module Net
10
+ module Tofu
11
+ # Stores a response from a Gemini server.
12
+ class Response
13
+ # Response types
14
+ INPUT = 1
15
+ SUCCESS = 2
16
+ REDIRECT = 3
17
+ TEMPORARY_FAILURE = 4
18
+ PERMANENT_FAILURE = 5
19
+ REQUEST_CERTIFICATE = 6
20
+
21
+ # Limits
22
+ MAX_META_BYTESIZE = 1024
23
+
24
+ # @return [String] The full response header from the server.
25
+ attr_reader :header
26
+
27
+ # @return [Integer] The 2-digit, server response status.
28
+ attr_reader :status
29
+
30
+ # @return [Integer] The first digit of the server response status.
31
+ attr_reader :status_maj
32
+
33
+ # @return [Integer] The second digit of the server response status.
34
+ attr_reader :status_min
35
+
36
+ # Dependent on the #{status} ->
37
+ # => 1x: (INPUT) A prompt line that should be displayed to the user.
38
+ # => 2x: (SUCCESS) A MIME media type.
39
+ # => 3x: (REDIRECT) A new URL for the requested resource.
40
+ # => 4x: (TEMP FAIL) Additional information regarding the temporary failure.
41
+ # => 5x: (PERM FAIL) Additional information regarding the temporary failure.
42
+ # => 6x: (RQST CERT) Additional information regarding the client certificate requirements.
43
+ #
44
+ # According to the specification for the Gemini Protocol, clients SHOULD close a connection to servers which send
45
+ # a meta over 1024 bytes. This library complies with this specification, although, it is conceivable that meta
46
+ # could be an arbitrarily long string.
47
+ #
48
+ # @return [String] The UTF-8 encoded message from the response header.
49
+ attr_reader :meta
50
+
51
+ # @return [String] The message body.
52
+ attr_reader :body
53
+
54
+ # Constructor for the response type.
55
+ # @param data [String] A raw Gemini server response.
56
+ def initialize(data)
57
+ @data = data
58
+ parse
59
+ end
60
+
61
+ # Get a human readable response type.
62
+ # @return [String] The response type as a human readable string.
63
+ def type
64
+ case @status_maj
65
+ when INPUT then return "Input"
66
+ when SUCCESS then return "Success"
67
+ when REDIRECT then return "Redirect"
68
+ when TEMPORARY_FAILURE then return "Temporary failure"
69
+ when PERMANENT_FAILURE then return "Permanent failure"
70
+ when REQUEST_CERTIFICATE then return "Request certificate"
71
+ end
72
+ "Unknown"
73
+ end
74
+
75
+ def input?; return true if @status_maj == INPUT; false; end
76
+ def success?; return true if @status_maj == SUCCESS; false; end
77
+ def redirect?; return true if @status_maj == REDIRECT; false; end
78
+ def temporary_failure?; return true if @status_maj == TEMPORARY_FAILURE; false; end
79
+ def permanent_failure?; return true if @status_maj == PERMANENT_FAILURE; false; end
80
+ def request_certificate?; return true if @status_maj == REQUEST_CERTIFICATE; false; end
81
+
82
+ private
83
+
84
+ # Splits up the responseinto a header and a body.
85
+ def parse
86
+ raise NoServerResponseError if @data.nil? || @data.empty?
87
+
88
+ # Extract the header and parse it
89
+ a = @data.split("\n")
90
+ @header = a[0].strip
91
+ parse_header
92
+
93
+ # Remove the first element from the array,
94
+ # then populate body with the rest of the data
95
+ a.shift
96
+ @body = a.join("\n") if a.length.positive?
97
+ end
98
+
99
+ # Splits upt the header into a status code and a meta.
100
+ def parse_header
101
+ a = @header.split
102
+
103
+ # Make sure there are only one or two elements in the header
104
+ raise InvalidHeaderError, "The server did not send a header" unless a.length.positive?
105
+
106
+ # Parse the status code
107
+ @status = a[0]
108
+ parse_status
109
+
110
+ # Parse the meta
111
+ @meta = ""
112
+ @meta = a[1..].join(" ") if a.length >= 2
113
+ @meta.strip!
114
+ parse_meta
115
+ end
116
+
117
+ # Splits up the status into a major and minor, checks for invalid status codes.
118
+ def parse_status
119
+ # Make sure the status is numerical
120
+ unless @status.numerical?
121
+ raise InvalidStatusCodeError,
122
+ "The server sent a non-numerical status code: #{@status}"
123
+ end
124
+
125
+ # Allow status code to only be a single digit, or two digits (as the spec says it should be)
126
+ if @status.length == 1
127
+ @status_maj = Integer(@status)
128
+ @status_min = 0
129
+ elsif @status.length == 2
130
+ @status_maj = Integer(@status[0])
131
+ @status_min = Integer(@status[1])
132
+ else
133
+ raise InvalidStatusCodeError, "The server sent a status code that is longer than 2 digits: #{@status}"
134
+ end
135
+
136
+ # Make sure the major status code is between 1 and 6, including 1 and 6
137
+ unless @status_maj >= 1 && @status_maj <= 6
138
+ raise InvalidStatusCodeError,
139
+ "The server sent an invalid, major status code: #{@status_maj}"
140
+ end
141
+
142
+ # Make sure #{status} is an Integer
143
+ @status = Integer(@status)
144
+ end
145
+
146
+ # Checks the meta size, does extra checking depending on #{status} type.
147
+ def parse_meta
148
+ # Make sure the meta isn't too large
149
+ if @meta.bytesize > MAX_META_BYTESIZE
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
154
+ end
155
+
156
+ if @status_maj == TEMPORARY_FAILURE || @status_maj == PERMANENT_FAILURE || @status_maj == REQUEST_CERTIFICATE
157
+ return
158
+ end
159
+
160
+ # Handle extra checks based on the @status type
161
+ handle_type
162
+ end
163
+
164
+ def handle_type
165
+ # Make sure meta exists (i.e. has length)
166
+ # This satisfies the INPUT and SUCCESS
167
+ unless @meta.length.positive?
168
+ raise InvalidMetaError,
169
+ "The server sent an empty meta, should've sent a user prompt"
170
+ end
171
+
172
+ # TODO: Possibly check for valid MIME type for the SUCCESS response
173
+ return if @status_maj == INPUT || @status_maj == SUCCESS
174
+
175
+ # The meta needs a specific URI if @status_maj == REDIRECT
176
+ uri = URI(@meta)
177
+
178
+ # Make sure the URI has a scheme
179
+ raise InvalidRedirectError, "The redirect link does not have a scheme" if uri.scheme.nil? || uri.scheme.empty?
180
+
181
+ # Make sure the URI scheme is 'gemini'
182
+ return if uri.scheme == URI::Gemini::DEFAULT_SCHEME
183
+
184
+ raise InvalidRedirectError,
185
+ "The redirect link has an invalid scheme (has: #{uri.scheme}, wants: #{URI::Gemini::DEFAULT_SCHEME})"
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Tofu
5
+ # Stoes an SSLSocket for making requests and receiving data.
6
+ class Socket
7
+ # Constructor for the socket type.
8
+ # @param host [String] Hostname of the server to connect to.
9
+ # @param port [Integer] Server port to connect to (typically 1965).
10
+ def initialize(host, port, trust)
11
+ @host = host
12
+ @port = port
13
+ @trust = trust
14
+ @sock = OpenSSL::SSL::SSLSocket.open(@host, @port, context: generate_secure_context)
15
+ end
16
+
17
+ # Open a connection to the server.
18
+ def connect
19
+ @sock.hostname = @host
20
+ @sock.connect
21
+ validate_certs
22
+ end
23
+
24
+ # Try and retrieve data from a request.
25
+ def gets(req)
26
+ @sock.puts req.format
27
+
28
+ io = StringIO.new
29
+ while (line = @sock.gets)
30
+ io.puts line
31
+ end
32
+
33
+ Response.new(io.string)
34
+ ensure
35
+ io.close
36
+ end
37
+
38
+ # Close the connection with the server.
39
+ def close
40
+ @sock.close
41
+ end
42
+
43
+ private
44
+
45
+ # Configure the TLS security options to use on the socket.
46
+ def generate_secure_context
47
+ ctx = OpenSSL::SSL::SSLContext.new
48
+ ctx.verify_hostname = true
49
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
50
+ ctx.min_version = OpenSSL::SSL::TLS1_2_VERSION
51
+ ctx.options |= OpenSSL::SSL::OP_IGNORE_UNEXPECTED_EOF
52
+ ctx
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
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Tofu
5
+ VERSION = "0.3.0"
6
+ end
7
+ end
data/lib/net/tofu.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tofu/error"
4
+ require_relative "tofu/pub"
5
+ require_relative "tofu/request"
6
+ require_relative "tofu/response"
7
+ require_relative "tofu/socket"
8
+ require_relative "tofu/version"
9
+
10
+ require "fileutils"
11
+ require "openssl"
12
+ require "stringio"
13
+ require "uri/gemini"
14
+
15
+ module Net
16
+ # Top level module for Geminispace requests.
17
+ module Tofu
18
+ def self.get(uri, trust: false)
19
+ req = Request.new(uri, trust: trust)
20
+ req.gets
21
+
22
+ return req.resp.body if req.resp.success?
23
+
24
+ req.resp.meta
25
+ end
26
+
27
+ def self.get_response(uri, trust: false)
28
+ req = Request.new(uri, trust: trust)
29
+ req.gets
30
+
31
+ req.resp
32
+ end
33
+ end
34
+ end
data/lib/uri/gemini.rb ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # MIT License
4
+ #
5
+ # Copyright (c) 2020 Étienne Deparis
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ # of this software and associated documentation files (the "Software"), to deal
9
+ # in the Software without restriction, including without limitation the rights
10
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ # copies of the Software, and to permit persons to whom the Software is
12
+ # furnished to do so, subject to the following conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be included in all
15
+ # copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ # SOFTWARE.
24
+
25
+ require "uri"
26
+
27
+ module URI # :nodoc:
28
+ #
29
+ # The syntax of Gemini URIs is defined in the Gemini specification,
30
+ # section 1.2.
31
+ #
32
+ # @see https://gemini.circumlunar.space/docs/specification.html
33
+ #
34
+ class Gemini < HTTP
35
+ # A Default port of 1965 for URI::Gemini.
36
+ DEFAULT_PORT = 1965
37
+
38
+ # A Default scheme of gemini for URI::Gemini.
39
+ DEFAULT_SCHEME = "gemini"
40
+
41
+ # An Array of the available components for URI::Gemini.
42
+ COMPONENT = %i[scheme host port
43
+ path query fragment].freeze
44
+ end
45
+
46
+ if respond_to? :register_scheme
47
+ # Introduced somewhere in ruby 3.0.x
48
+ register_scheme "GEMINI", Gemini
49
+ else
50
+ @@schemes["GEMINI"] = Gemini
51
+ end
52
+ end
data/sig/net-tofu.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Net::Tofu
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: net-tofu
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Rory Dudley
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-07-29 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |2
14
+ Net::Tofu is a client and certificate pinning library for Geminispace, derived from
15
+ the 'trust on first use' authentication scheme (https://en.wikipedia.org/wiki/Trust_on_first_use).
16
+ email:
17
+ - rory.dudley@gmail.com
18
+ executables:
19
+ - tofu
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - ".rubocop.yml"
24
+ - CHANGELOG.md
25
+ - LICENSE.txt
26
+ - README.md
27
+ - Rakefile
28
+ - exe/tofu
29
+ - lib/net/tofu.rb
30
+ - lib/net/tofu/error.rb
31
+ - lib/net/tofu/pub.rb
32
+ - lib/net/tofu/request.rb
33
+ - lib/net/tofu/response.rb
34
+ - lib/net/tofu/socket.rb
35
+ - lib/net/tofu/version.rb
36
+ - lib/uri/gemini.rb
37
+ - sig/net-tofu.rbs
38
+ homepage: https://github.com/pinecat/net-tofu
39
+ licenses:
40
+ - MIT
41
+ metadata:
42
+ allowed_push_host: https://rubygems.org
43
+ homepage_uri: https://github.com/pinecat/net-tofu
44
+ source_code_uri: https://github.com/pinecat/net-tofu
45
+ changelog_uri: https://github.com/pinecat/net-tofu/blob/master/CHANGELOG.md
46
+ post_install_message:
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: 2.6.0
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 3.4.17
62
+ signing_key:
63
+ specification_version: 4
64
+ summary: A Gemini client and certificate manager.
65
+ test_files: []