summaryrefslogtreecommitdiff
path: root/lib/net/ssh/authentication/agent.rb
blob: 6ba1730f3e440bc0ff79a4bb413364b5005b29d7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
require 'net/ssh/buffer'
require 'net/ssh/errors'
require 'net/ssh/loggable'

require 'net/ssh/transport/server_version'
require 'socket'
require 'rubygems'

require 'net/ssh/authentication/pageant' if Gem.win_platform? && RUBY_PLATFORM != "java"

module Net
  module SSH
    module Authentication
      # Class for representing agent-specific errors.
      class AgentError < Net::SSH::Exception; end
      # An exception for indicating that the SSH agent is not available.
      class AgentNotAvailable < AgentError; end

      # This class implements a simple client for the ssh-agent protocol. It
      # does not implement any specific protocol, but instead copies the
      # behavior of the ssh-agent functions in the OpenSSH library (3.8).
      #
      # This means that although it behaves like a SSH1 client, it also has
      # some SSH2 functionality (like signing data).
      class Agent
        include Loggable

        # A simple module for extending keys, to allow comments to be specified
        # for them.
        module Comment
          attr_accessor :comment
        end

        SSH2_AGENT_REQUEST_VERSION       = 1
        SSH2_AGENT_REQUEST_IDENTITIES    = 11
        SSH2_AGENT_IDENTITIES_ANSWER     = 12
        SSH2_AGENT_SIGN_REQUEST          = 13
        SSH2_AGENT_SIGN_RESPONSE         = 14
        SSH2_AGENT_ADD_IDENTITY          = 17
        SSH2_AGENT_REMOVE_IDENTITY       = 18
        SSH2_AGENT_REMOVE_ALL_IDENTITIES = 19
        SSH2_AGENT_ADD_ID_CONSTRAINED    = 25
        SSH2_AGENT_FAILURE               = 30
        SSH2_AGENT_VERSION_RESPONSE      = 103

        SSH_COM_AGENT2_FAILURE = 102

        SSH_AGENT_REQUEST_RSA_IDENTITIES = 1
        SSH_AGENT_RSA_IDENTITIES_ANSWER1 = 2
        SSH_AGENT_RSA_IDENTITIES_ANSWER2 = 5
        SSH_AGENT_FAILURE                = 5
        SSH_AGENT_SUCCESS                = 6

        SSH_AGENT_CONSTRAIN_LIFETIME = 1
        SSH_AGENT_CONSTRAIN_CONFIRM  = 2

        SSH_AGENT_RSA_SHA2_256 = 0x02
        SSH_AGENT_RSA_SHA2_512 = 0x04

        # The underlying socket being used to communicate with the SSH agent.
        attr_reader :socket

        # Instantiates a new agent object, connects to a running SSH agent,
        # negotiates the agent protocol version, and returns the agent object.
        def self.connect(logger=nil, agent_socket_factory = nil)
          agent = new(logger)
          agent.connect!(agent_socket_factory)
          agent.negotiate!
          agent
        end

        # Creates a new Agent object, using the optional logger instance to
        # report status.
        def initialize(logger=nil)
          self.logger = logger
        end

        # Connect to the agent process using the socket factory and socket name
        # given by the attribute writers. If the agent on the other end of the
        # socket reports that it is an SSH2-compatible agent, this will fail
        # (it only supports the ssh-agent distributed by OpenSSH).
        def connect!(agent_socket_factory = nil)
          debug { "connecting to ssh-agent" }
          @socket =
            if agent_socket_factory
              agent_socket_factory.call
            elsif ENV['SSH_AUTH_SOCK'] && unix_socket_class
              unix_socket_class.open(ENV['SSH_AUTH_SOCK'])
            elsif Gem.win_platform? && RUBY_ENGINE != "jruby"
              Pageant::Socket.open
            else
              raise AgentNotAvailable, "Agent not configured"
            end
        rescue StandardError => e
          error { "could not connect to ssh-agent: #{e.message}" }
          raise AgentNotAvailable, $!.message
        end

        # Attempts to negotiate the SSH agent protocol version. Raises an error
        # if the version could not be negotiated successfully.
        def negotiate!
          # determine what type of agent we're communicating with
          type, body = send_and_wait(SSH2_AGENT_REQUEST_VERSION, :string, Transport::ServerVersion::PROTO_VERSION)

          raise AgentNotAvailable, "SSH2 agents are not yet supported" if type == SSH2_AGENT_VERSION_RESPONSE
          if type == SSH2_AGENT_FAILURE
            debug { "Unexpected response type==#{type}, this will be ignored" }
          elsif type != SSH_AGENT_RSA_IDENTITIES_ANSWER1 && type != SSH_AGENT_RSA_IDENTITIES_ANSWER2
            raise AgentNotAvailable, "unknown response from agent: #{type}, #{body.to_s.inspect}"
          end
        end

        # Return an array of all identities (public keys) known to the agent.
        # Each key returned is augmented with a +comment+ property which is set
        # to the comment returned by the agent for that key.
        def identities
          type, body = send_and_wait(SSH2_AGENT_REQUEST_IDENTITIES)
          raise AgentError, "could not get identity count" if agent_failed(type)
          raise AgentError, "bad authentication reply: #{type}" if type != SSH2_AGENT_IDENTITIES_ANSWER

          identities = []
          body.read_long.times do
            key_str = body.read_string
            comment_str = body.read_string
            begin
              key = Buffer.new(key_str).read_key
              key.extend(Comment)
              key.comment = comment_str
              identities.push key
            rescue NotImplementedError => e
              error { "ignoring unimplemented key:#{e.message} #{comment_str}" }
            end
          end

          return identities
        end

        # Closes this socket. This agent reference is no longer able to
        # query the agent.
        def close
          @socket.close
        end

        # Using the agent and the given public key, sign the given data. The
        # signature is returned in SSH2 format.
        def sign(key, data, flags = 0)
          type, reply = send_and_wait(SSH2_AGENT_SIGN_REQUEST, :string, Buffer.from(:key, key), :string, data, :long, flags)

          raise AgentError, "agent could not sign data with requested identity" if agent_failed(type)
          raise AgentError, "bad authentication response #{type}" if type != SSH2_AGENT_SIGN_RESPONSE

          return reply.read_string
        end

        # Adds the private key with comment to the agent.
        # If lifetime is given, the key will automatically be removed after lifetime
        # seconds.
        # If confirm is true, confirmation will be required for each agent signing
        # operation.
        def add_identity(priv_key, comment, lifetime: nil, confirm: false)
          constraints = Buffer.new
          if lifetime
            constraints.write_byte(SSH_AGENT_CONSTRAIN_LIFETIME)
            constraints.write_long(lifetime)
          end
          constraints.write_byte(SSH_AGENT_CONSTRAIN_CONFIRM) if confirm

          req_type = constraints.empty? ? SSH2_AGENT_ADD_IDENTITY : SSH2_AGENT_ADD_ID_CONSTRAINED
          type, = send_and_wait(req_type, :string, priv_key.ssh_type, :raw, blob_for_add(priv_key),
                      :string, comment, :raw, constraints)
          raise AgentError, "could not add identity to agent" if type != SSH_AGENT_SUCCESS
        end

        # Removes key from the agent.
        def remove_identity(key)
          type, = send_and_wait(SSH2_AGENT_REMOVE_IDENTITY, :string, key.to_blob)
          raise AgentError, "could not remove identity from agent" if type != SSH_AGENT_SUCCESS
        end

        # Removes all identities from the agent.
        def remove_all_identities
          type, = send_and_wait(SSH2_AGENT_REMOVE_ALL_IDENTITIES)
          raise AgentError, "could not remove all identity from agent" if type != SSH_AGENT_SUCCESS
        end

        private

        def unix_socket_class
          defined?(UNIXSocket) && UNIXSocket
        end

        # Send a new packet of the given type, with the associated data.
        def send_packet(type, *args)
          buffer = Buffer.from(*args)
          data = [buffer.length + 1, type.to_i, buffer.to_s].pack("NCA*")
          debug { "sending agent request #{type} len #{buffer.length}" }
          @socket.send data, 0
        end

        # Read the next packet from the agent. This will return a two-part
        # tuple consisting of the packet type, and the packet's body (which
        # is returned as a Net::SSH::Buffer).
        def read_packet
          buffer = Net::SSH::Buffer.new(@socket.read(4))
          buffer.append(@socket.read(buffer.read_long))
          type = buffer.read_byte
          debug { "received agent packet #{type} len #{buffer.length - 4}" }
          return type, buffer
        end

        # Send the given packet and return the subsequent reply from the agent.
        # (See #send_packet and #read_packet).
        def send_and_wait(type, *args)
          send_packet(type, *args)
          read_packet
        end

        # Returns +true+ if the parameter indicates a "failure" response from
        # the agent, and +false+ otherwise.
        def agent_failed(type)
          type == SSH_AGENT_FAILURE ||
            type == SSH2_AGENT_FAILURE ||
            type == SSH_COM_AGENT2_FAILURE
        end

        def blob_for_add(priv_key)
          # Ideally we'd have something like `to_private_blob` on the various key types, but the
          # nuances with encoding (e.g. `n` and `e` are reversed for RSA keys) make this impractical.
          case priv_key.ssh_type
          when /^ssh-dss$/
            Net::SSH::Buffer.from(:bignum, priv_key.p, :bignum, priv_key.q, :bignum, priv_key.g,
                        :bignum, priv_key.pub_key, :bignum, priv_key.priv_key).to_s
          when /^ssh-dss-cert-v01@openssh\.com$/
            Net::SSH::Buffer.from(:string, priv_key.to_blob, :bignum, priv_key.key.priv_key).to_s
          when /^ecdsa\-sha2\-(\w*)$/
            curve_name = OpenSSL::PKey::EC::CurveNameAliasInv[priv_key.group.curve_name]
            Net::SSH::Buffer.from(:string, curve_name, :mstring, priv_key.public_key.to_bn.to_s(2),
                        :bignum, priv_key.private_key).to_s
          when /^ecdsa\-sha2\-(\w*)-cert-v01@openssh\.com$/
            Net::SSH::Buffer.from(:string, priv_key.to_blob, :bignum, priv_key.key.private_key).to_s
          when /^ssh-ed25519$/
            Net::SSH::Buffer.from(:string, priv_key.public_key.verify_key.to_bytes,
                        :string, priv_key.sign_key.keypair).to_s
          when /^ssh-ed25519-cert-v01@openssh\.com$/
            # Unlike the other certificate types, the public key is included after the certifiate.
            Net::SSH::Buffer.from(:string, priv_key.to_blob,
                        :string, priv_key.key.public_key.verify_key.to_bytes,
                        :string, priv_key.key.sign_key.keypair).to_s
          when /^ssh-rsa$/
            # `n` and `e` are reversed compared to the ordering in `OpenSSL::PKey::RSA#to_blob`.
            Net::SSH::Buffer.from(:bignum, priv_key.n, :bignum, priv_key.e, :bignum, priv_key.d,
                        :bignum, priv_key.iqmp, :bignum, priv_key.p, :bignum, priv_key.q).to_s
          when /^ssh-rsa-cert-v01@openssh\.com$/
            Net::SSH::Buffer.from(:string, priv_key.to_blob, :bignum, priv_key.key.d,
                        :bignum, priv_key.key.iqmp, :bignum, priv_key.key.p,
                        :bignum, priv_key.key.q).to_s
          end
        end
      end
    end
  end
end