Module: Mixlib::Authentication::SignedHeaderAuth

Included in:
SignatureVerification, SigningObject
Defined in:
lib/mixlib/authentication/signedheaderauth.rb

Constant Summary collapse

NULL_ARG =
Object.new
ALGORITHM_FOR_VERSION =
{
  "1.0" => "sha1",
  "1.1" => "sha1",
  "1.3" => "sha256",
}.freeze
SUPPORTED_ALGORITHMS =

Use of SUPPORTED_ALGORITHMS and SUPPORTED_VERSIONS is deprecated. Use ALGORITHM_FOR_VERSION instead

["sha1"].freeze
SUPPORTED_VERSIONS =
["1.0", "1.1"].freeze
DEFAULT_SIGN_ALGORITHM =
"sha1".freeze
DEFAULT_PROTO_VERSION =
"1.0".freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.signing_object(args = {}) ⇒ Object

signing_object

This is the intended interface for signing requests with the Opscode/Chef signed header protocol. This wraps the constructor for a Struct that contains the relevant information about your request.

Signature Parameters:

These parameters are used to generate the canonical representation of the request, which is then hashed and encrypted to generate the request’s signature. These options are all required, with the exception of ‘:body` and `:file`, which are alternate ways to specify the request body (you must specify one of these).

  • ‘:http_method`: HTTP method as a lowercase symbol, e.g., `:get | :put | :post | :delete`

  • ‘:path`: The path part of the URI, e.g., `URI.parse(uri).path`

  • ‘:body`: An object representing the body of the request. Use an empty String for bodiless requests.

  • ‘:timestamp`: A String representing the time in any format understood by `Time.parse`. The server may reject the request if the timestamp is not close to the server’s current time.

  • ‘:user_id`: The user or client name. This is used by the server to lookup the public key necessary to verify the signature.

  • ‘:file`: An IO object (must respond to `:read`) to be used as the request body.

Protocol Versioning Parameters:

  • ‘:proto_version`: The version of the signing protocol to use. Currently defaults to 1.0, but version 1.1 is also available.

Other Parameters:

These parameters are accepted but not used in the computation of the signature.

  • ‘:host`: The host part of the URI



75
76
77
78
79
80
81
82
83
84
85
# File 'lib/mixlib/authentication/signedheaderauth.rb', line 75

def self.signing_object(args = {})
  SigningObject.new(args[:http_method],
    args[:path],
    args[:body],
    args[:host],
    args[:timestamp],
    args[:user_id],
    args[:file],
    args[:proto_version],
    args[:headers])
end

Instance Method Details

#algorithmObject



87
88
89
# File 'lib/mixlib/authentication/signedheaderauth.rb', line 87

def algorithm
  ALGORITHM_FOR_VERSION[proto_version] || DEFAULT_SIGN_ALGORITHM
end

#canonicalize_request(sign_algorithm = algorithm, sign_version = proto_version) ⇒ Object

Takes HTTP request method & headers and creates a canonical form to create the signature

Parameters



212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/mixlib/authentication/signedheaderauth.rb', line 212

def canonicalize_request(sign_algorithm = algorithm, sign_version = proto_version)
  digest = validate_sign_version_digest!(sign_algorithm, sign_version)
  canonical_x_ops_user_id = canonicalize_user_id(user_id, sign_version, digest)
  case sign_version
  when "1.3"
    [
      "Method:#{http_method.to_s.upcase}",
      "Path:#{canonical_path}",
      "X-Ops-Content-Hash:#{hashed_body(digest)}",
      "X-Ops-Sign:version=#{sign_version}",
      "X-Ops-Timestamp:#{canonical_time}",
      "X-Ops-UserId:#{canonical_x_ops_user_id}",
      "X-Ops-Server-API-Version:#{server_api_version}",
    ].join("\n")
  else
    [
      "Method:#{http_method.to_s.upcase}",
      "Hashed Path:#{digester.hash_string(canonical_path, digest)}",
      "X-Ops-Content-Hash:#{hashed_body(digest)}",
      "X-Ops-Timestamp:#{canonical_time}",
      "X-Ops-UserId:#{canonical_x_ops_user_id}",
    ].join("\n")
  end
end

#do_sign(rsa_key, digest, sign_algorithm, sign_version, use_ssh_agent) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Low-level RSA signature implementation used in #sign.

Parameters:

  • rsa_key (OpenSSL::PKey::RSA)

    User’s RSA key. If ‘use_ssh_agent` is true, this must have the public key portion populated. If `use_ssh_agent` is false, this must have the private key portion populated.

  • digest (Class)

    Sublcass of OpenSSL::Digest to use while signing.

  • sign_algorithm (String)

    Hash algorithm to use while signing.

  • sign_version (String)

    Version number of the signing protocol to use.

  • use_ssh_agent (Boolean)

    If true, use ssh-agent for request signing.

Returns:

  • (String)


277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'lib/mixlib/authentication/signedheaderauth.rb', line 277

def do_sign(rsa_key, digest, sign_algorithm, sign_version, use_ssh_agent)
  string_to_sign = canonicalize_request(sign_algorithm, sign_version)
  Mixlib::Authentication.logger.trace "String to sign: '#{string_to_sign}'"
  case sign_version
  when "1.3"
    if use_ssh_agent
      do_sign_ssh_agent(rsa_key, string_to_sign)
    else
      raise AuthenticationError, "RSA private key is required to sign requests, but a public key was provided" unless rsa_key.private?

      rsa_key.sign(digest.new, string_to_sign)
    end
  else
    raise AuthenticationError, "Agent signing mode requires signing protocol version 1.3 or newer" if use_ssh_agent
    raise AuthenticationError, "RSA private key is required to sign requests, but a public key was provided" unless rsa_key.private?

    rsa_key.private_encrypt(string_to_sign)
  end
end

#do_sign_ssh_agent(rsa_key, string_to_sign) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Low-level signing logic for using ssh-agent. This requires the user has already set up ssh-agent and used ssh-add to load in a (possibly encrypted) RSA private key. ssh-agent supports keys other than RSA, however they are not supported as Chef’s protocol explicitly requires RSA keys/sigs.

Parameters:

  • rsa_key (OpenSSL::PKey::RSA)

    User’s RSA public key.

  • string_to_sign (String)

    String data to sign with the requested key.

Returns:

  • (String)


306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
# File 'lib/mixlib/authentication/signedheaderauth.rb', line 306

def do_sign_ssh_agent(rsa_key, string_to_sign)
  # First try loading net-ssh as it is an optional dependency.
  begin
    require "net/ssh" unless defined?(Net::SSH)
  rescue LoadError => e
    # ???: Since agent mode is explicitly enabled, should we even catch
    # this in the first place? Might be cleaner to let the LoadError bubble.
    raise AuthenticationError, "net-ssh gem is not available, unable to use ssh-agent signing: #{e.message}"
  end

  # Try to connect to ssh-agent.
  begin
    agent = Net::SSH::Authentication::Agent.connect
  rescue Net::SSH::Authentication::AgentNotAvailable => e
    raise AuthenticationError, "Could not connect to ssh-agent. Make sure the SSH_AUTH_SOCK environment variable is set and ssh-agent is running: #{e.message}"
  end

  begin
    ssh2_signature = agent.sign(rsa_key.public_key, string_to_sign, Net::SSH::Authentication::Agent::SSH_AGENT_RSA_SHA2_256)
  rescue Net::SSH::Authentication::AgentError => e
    raise AuthenticationError, "Unable to sign request with ssh-agent. Make sure your key is loaded with ssh-add: #{e.class.name} #{e.message})"
  end

  # extract signature from SSH Agent response => skip first 20 bytes for RSA keys
  # "\x00\x00\x00\frsa-sha2-256\x00\x00\x01\x00"
  # (see http://api.libssh.org/rfc/PROTOCOL.agent for details)
  ssh2_signature[20..-1]
end

#hashed_body(digest = OpenSSL::Digest::SHA1) ⇒ Object



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/mixlib/authentication/signedheaderauth.rb', line 181

def hashed_body(digest = OpenSSL::Digest::SHA1)
  # This is weird. sign() is called with the digest type and signing
  # version. These are also expected to be properties of the object.
  # Hence, we're going to assume the one that is passed to sign is
  # the correct one and needs to passed through all the functions
  # that do any sort of digest.
  @hashed_body_digest = nil unless defined?(@hashed_body_digest)
  if !@hashed_body_digest.nil? && @hashed_body_digest != digest
    raise "hashed_body must always be called with the same digest"
  else
    @hashed_body_digest = digest
  end

  # Hash the file object if it was passed in, otherwise hash based on
  # the body.
  # TODO: tim 2009-12-28: It'd be nice to just remove this special case,
  # always sign the entire request body, using the expanded multipart
  # body in the case of a file being include.
  @hashed_body ||= if file&.respond_to?(:read)
                     digester.hash_file(file, digest)
                   else
                     digester.hash_string(body, digest)
                   end
end

#proto_versionObject



91
92
93
# File 'lib/mixlib/authentication/signedheaderauth.rb', line 91

def proto_version
  DEFAULT_PROTO_VERSION
end

#sign(rsa_key, sign_algorithm = algorithm, sign_version = proto_version, **opts) ⇒ Object

Build the canonicalized request based on the method, other headers, etc. compute the signature from the request, using the looked-up user secret

Parameters:

  • rsa_key (OpenSSL::PKey::RSA)

    User’s RSA key. If ‘use_ssh_agent` is true, this must have the public key portion populated. If `use_ssh_agent` is false, this must have the private key portion populated.

  • use_ssh_agent (Boolean)

    If true, use ssh-agent for request signing.



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
# File 'lib/mixlib/authentication/signedheaderauth.rb', line 102

def sign(rsa_key, sign_algorithm = algorithm, sign_version = proto_version, **opts)
  # Backwards compat stuff.
  if sign_algorithm.is_a?(Hash)
    # Was called like sign(key, sign_algorithm: 'foo', other: 'bar')
    opts.update(sign_algorithm)
    opts[:sign_algorithm] ||= algorithm
    opts[:sign_version] ||= sign_version
  else
    # Was called like sign(key, 'foo', '1.3', other: 'bar')
    Mixlib::Authentication.logger.warn("Using deprecated positional arguments for sign(), please update to keyword arguments (from #{caller[1][/^(.*:\d+):in /, 1]})") unless sign_algorithm == algorithm
    opts[:sign_algorithm] ||= sign_algorithm
    opts[:sign_version] ||= sign_version
  end
  sign_algorithm = opts[:sign_algorithm]
  sign_version = opts[:sign_version]
  use_ssh_agent = opts[:use_ssh_agent]

  digest = validate_sign_version_digest!(sign_algorithm, sign_version)
  # Our multiline hash for authorization will be encoded in multiple header
  # lines - X-Ops-Authorization-1, ... (starts at 1, not 0!)
  header_hash = {
    "X-Ops-Sign" => "algorithm=#{sign_algorithm};version=#{sign_version};",
    "X-Ops-Userid" => user_id,
    "X-Ops-Timestamp" => canonical_time,
    "X-Ops-Content-Hash" => hashed_body(digest),
  }

  signature = Base64.encode64(do_sign(rsa_key, digest, sign_algorithm, sign_version, use_ssh_agent)).chomp
  signature_lines = signature.split("\n")
  signature_lines.each_index do |idx|
    key = "X-Ops-Authorization-#{idx + 1}"
    header_hash[key] = signature_lines[idx]
  end

  Mixlib::Authentication.logger.trace "Header hash: #{header_hash.inspect}"

  header_hash
end

#validate_sign_version_digest!(sign_algorithm, sign_version) ⇒ Object



141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/mixlib/authentication/signedheaderauth.rb', line 141

def validate_sign_version_digest!(sign_algorithm, sign_version)
  if ALGORITHM_FOR_VERSION[sign_version].nil?
    raise AuthenticationError,
      "Unsupported version '#{sign_version}'"
  end

  if ALGORITHM_FOR_VERSION[sign_version] != sign_algorithm
    raise AuthenticationError,
      "Unsupported algorithm #{sign_algorithm} for version '#{sign_version}'"
  end

  case sign_algorithm
  when "sha1"
    OpenSSL::Digest::SHA1
  when "sha256"
    OpenSSL::Digest::SHA256
  else
    # This case should never happen
    raise "Unknown algorithm #{sign_algorithm}"
  end
end