Class: Faraday::Middlewares::BuildService::SshSigner

Inherits:
Object
  • Object
show all
Includes:
HttpHelpers
Defined in:
lib/faraday/middlewares/build_service/ssh_signer.rb

Instance Method Summary collapse

Methods included from HttpHelpers

#pairs_to_payload, #pairs_to_quoted_string, #parse_authorization_header, #parse_list_header, #unquote

Constructor Details

#initialize(challenge_params, headers, credentials: {}, challenge_context: {}) ⇒ SshSigner

Initalizes the signer

Parameters:

  • challenge_params (Hash)

    WWW-Authenticate header parameters.

  • headers (Hash)

    Request headers

  • credentials (Hash) (defaults to: {})

    Credentials to sign the request Is expected to have :username & :ssh_key.

  • challenge_context (String) (defaults to: {})

    Additional context headers for the challenge.

    challenge_params keys are:

    :realm => The realm to perform authentication on.
    :headers => a string list of headers to be used.
    


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
# File 'lib/faraday/middlewares/build_service/ssh_signer.rb', line 20

def initialize(challenge_params, headers, credentials: {}, challenge_context: {})
  @headers = headers
  @credentials = credentials
  # The challenge is formatted according to RFC7235 section 4.1
  # https://datatracker.ietf.org/doc/html/rfc7235#section-4.1
  # www-authenticate: 'Signature realm="Use your developer account",headers="(created)"'
  #
  # This signer does only process Signature challenge
  @challenge_params = challenge_params

  created = Time.now.to_i
  # funny tip: you can bucket requests into 5 min blocks and the signature
  # will still be constant & valid.
  # created = created - (created % 300)
  @challenge_context = {
    created: created
  }.update(challenge_context)

  # As per RFC Signing HTTP Messages draft-cavage-http-signatures-12 section
  # 2.3. At the very least `(created)` header (and `(request-target)` but Build
  # Service implementation doesn't include it) should be provided to the
  # signature string.

  # (created) represents a unix timestamp that must be provided into the
  # final signature for the server to validate.
end

Instance Method Details

#generateObject

Generate Signature

as per datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12#section-2.1

a ‘signing_string` is created based on the headers that the challenge demands the format for such signing_string (also referred as payload) is:

(calculated header): %value%n host: your.host

the n is to show that a newline is needed there. Then this full string is passed to the signing algorithm, in the build service case is:

ssh-keygen -Y sign -f “%path-to-privk%” -n “%realm%” -q <<< “signing string”

the result of that signature is in OpenSSH Signature format. Delimiters are removed & newlines stripped.

This final result along with the parameters used to generate the signature are concatenated in an HTTP List format and returned to the caller to use.



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
# File 'lib/faraday/middlewares/build_service/ssh_signer.rb', line 67

def generate
  # first listify the headers in the challenge. As the spec
  @challenge_params[:headers] ||= "(created)"

  headers = @challenge_params[:headers].split

  # Iterate through it to build the singing string
  payload_pairs = headers.map do |header|
    value = if header.include?("(")
      # if it's a calculated header (surrounded by parenthesis), look for it
      # in the challenge_context
      @challenge_context[header.tr("()", "").to_sym]
    else
      # else look for it in the given headers
      @headers[header]
    end

    [header, value]
  end

  # Build the signing string
  signing_string = pairs_to_payload(payload_pairs)

  # remove parenthesis from the headers to append to the signature parameters
  payload_pairs = payload_pairs.map do |key, value|
    key = key.tr("()", "")
    [key, value]
  end

  # sign with the provided realm (or empty if not present)
  signature = ssh_sign(signing_string, @challenge_params[:realm] || "", @credentials[:ssh_key])

  # Build all signature parameters
  signature_parameters = [
    ["keyId", @credentials[:username]],
    ["algorithm", "ssh"],
    ["signature", signature],
    ["headers", @challenge_params[:headers]],
    *payload_pairs
  ]

  # build the HTTP quoted list & return
  pairs_to_quoted_string(signature_parameters)
end

#ssh_sign(payload, realm, ssh_key) ⇒ Object

Sign a given payload pertaining to a specific realm

The SSH Privatekey will briefly be materialized as a file in the filesystem for SSH to be able to pick it up and sign the payload.

Then it’ll be removed

Parameters:

  • payload (String)

    Payload to sign

  • realm (String)

    Realm or Purpose (see man ssh-keygen section -Y sign)

  • ssh_key (Hash)

    SSH Private key to use in the singing



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/faraday/middlewares/build_service/ssh_signer.rb', line 122

def ssh_sign(payload, realm, ssh_key)
  Tempfile.create("ssh-key", Rails.root.join("tmp/")) do |file|
    file.write(ssh_key)
    file.flush # force IO flush

    cmd = ["ssh-keygen", "-Y", "sign", "-f", file.path.to_s, "-q", "-n", realm]
    stdout, stderr, process_status = Open3.capture3({}, *cmd, stdin_data: payload)
    raise "cannot sign: #{stderr}" unless process_status.to_i.zero?

    # remove the surrounding --- blocks & join in one single line
    signature = stdout.split("\n").slice(1..-2).join.presence

    # this should never happen, ssh-keygen ALWAYS returns an armored format or fail
    unless signature
      raise "cannot sign: bad output"
    end

    signature
  end
end