net-tofu 0.3.0

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