Class: As2::Message

Inherits:
Object
  • Object
show all
Defined in:
lib/as2/message.rb

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(message, private_key, public_certificate, mic_algorithm: nil) ⇒ Message

Returns a new instance of Message.



107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/as2/message.rb', line 107

def initialize(message, private_key, public_certificate, mic_algorithm: nil)
  # TODO: might need to use OpenSSL::PKCS7.read_smime rather than .new sometimes
  @pkcs7 = OpenSSL::PKCS7.new(message)
  @private_key = private_key
  @public_certificate = public_certificate
  @verification_error = nil

  @mic_algorithm = mic_algorithm || 'sha256'
  if !As2::DigestSelector.valid?(@mic_algorithm)
    raise ArgumentError, "'#{@mic_algorithm}' is not a valid MIC algorithm."
  end
end

Instance Attribute Details

#verification_errorObject (readonly)

Returns the value of attribute verification_error.



3
4
5
# File 'lib/as2/message.rb', line 3

def verification_error
  @verification_error
end

Class Method Details

.choose_attachment(mail_parts) ⇒ Mail::Part?

given multiple parts of a message, choose the one most likely to be the actual content we care about

Parameters:

  • mail_parts (Mail::PartsList)

Returns:

  • (Mail::Part, nil)


10
11
12
13
14
15
16
17
18
# File 'lib/as2/message.rb', line 10

def self.choose_attachment(mail_parts)
  return nil if mail_parts.nil?

   # if there are multiple content parts, try to prefer the EDI content.
  candidates = mail_parts
               .select { |part| part.content_type.to_s['pkcs7-signature'].nil? } # skip signature
               .sort_by { |part| part.content_type.to_s.match(/^application\/edi/i) ? 0 : 1 }
  candidates[0]
end

.choose_signature(mail_parts) ⇒ Mail::Part?

return the mail part containing a digital signature

Parameters:

  • mail_parts (Mail::PartsList)

Returns:

  • (Mail::Part, nil)


24
25
26
27
28
# File 'lib/as2/message.rb', line 24

def self.choose_signature(mail_parts)
  return nil if mail_parts.nil?

  mail_parts.find { |part| part.content_type.to_s['pkcs7-signature'] }
end

.mic(attachment, mic_algorithm) ⇒ String

calculate the MIC for a given mail part

Parameters:

  • attachment (Mail::Part)
  • mic_algorithm (String)

Returns:

  • (String)

    message integrity check string



35
36
37
38
# File 'lib/as2/message.rb', line 35

def self.mic(attachment, mic_algorithm)
  digest = As2::DigestSelector.for_code(mic_algorithm)
  digest.base64digest(attachment.raw_source.lstrip)
end

.verify(content:, signature_text:, signing_certificate:) ⇒ Hash

Check that the signature is valid.

This confirms 2 things:

1. The `signature_text` is valid for `content`, ie: the `content` has
   not been altered.
2. The `signature_text` was generated by the party who owns `certificate`,
   ie: The same private key generated `signature_text` and `certificate`.

Parameters:

  • content (String)
  • signature_text (String)
  • certificate (OpenSSL::X509::Certificate)

Returns:

  • (Hash)
    • :valid [boolean] was the verification successful or not?

    • :error [String, nil] a verification error message.

      will be empty when `valid` is true.
      


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
# File 'lib/as2/message.rb', line 56

def self.verify(content:, signature_text:, signing_certificate:)
  begin
    signature = OpenSSL::PKCS7.new(signature_text)

    # using an empty CA store. see notes on NOVERIFY flag below.
    store = OpenSSL::X509::Store.new

    # notes on verification process and flags used
    #
    # ## NOINTERN
    #
    # > If PKCS7_NOINTERN is set the certificates in the message itself are
    # > not searched when locating the signer's certificate. This means that
    # > all the signers certificates must be in the certs parameter.
    # >
    # > One application of PKCS7_NOINTERN is to only accept messages signed
    # > by a small number of certificates. The acceptable certificates would
    # > be passed in the certs parameter. In this case if the signer is not
    # > one of the certificates supplied in certs then the verify will fail
    # > because the signer cannot be found.
    #
    # https://www.openssl.org/docs/manmaster/man3/PKCS7_verify.html
    #
    # we want this so we can be sure that the `signing_certificate` we supply
    # was actually used to sign the message. otherwise we could get a positive
    # verification even if `signing_certificate` didn't sign the message
    # we're checking.
    #
    # ## NOVERIFY
    #
    # > If PKCS7_NOVERIFY is set the signer's certificates are not chain verified.
    #
    # ie: we won't attempt to connect signer (in the first param) to a root
    # CA (in `store`, which is empty). alternately, we could instead remove
    # this flag, and add `signing_certificate` to `store`. but what's the point?
    # we'd only be verifying that `signing_certificate` is connected to `signing_certificate`.
    valid = signature.verify([signing_certificate], store, content, OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::NOINTERN)

    # when `signature.verify` fails, signature.error_string will be populated.
    error = signature.error_string
  rescue => e
    valid = false
    error = "#{e.class}: #{e.message}"
  end

  {
    valid: valid,
    error: error
  }
end

Instance Method Details

#attachmentMail::Part

TODO:

maybe rename this to ‘payload`. ’attachment’ sounds very email.

Return the attached file, use .filename and .body on the return value This is the content the sender is sending to us.

Returns:

  • (Mail::Part)


219
220
221
# File 'lib/as2/message.rb', line 219

def attachment
  @attachment ||= self.class.choose_attachment(parts)
end

#decrypted_messageObject



120
121
122
# File 'lib/as2/message.rb', line 120

def decrypted_message
  @decrypted_message ||= @pkcs7.decrypt @private_key, @public_certificate
end

#micObject



206
207
208
# File 'lib/as2/message.rb', line 206

def mic
  self.class.mic(attachment, mic_algorithm)
end

#mic_algorithmObject



210
211
212
# File 'lib/as2/message.rb', line 210

def mic_algorithm
  @mic_algorithm
end

#partsObject

TODO: deprecate this, or make it private



232
233
234
# File 'lib/as2/message.rb', line 232

def parts
  mail&.parts
end

#signatureMail::Part

Return the digital signature which is part of the incoming message. Will return ‘nil` for unsigned messages

Returns:

  • (Mail::Part)


227
228
229
# File 'lib/as2/message.rb', line 227

def signature
  @signature ||= self.class.choose_signature(parts)
end

#valid_signature?(partner_signing_certificate) ⇒ Boolean

Returns:

  • (Boolean)


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
# File 'lib/as2/message.rb', line 124

def valid_signature?(partner_signing_certificate)
  content_type = mail.header_fields.find { |h| h.name == 'Content-Type' }.content_type
  # TODO: substantial overlap between this code & the fallback/rescue code in
  # As2::Client#verify_mdn_signature
  if content_type == "multipart/signed"
    # for a "multipart/signed" message, we will do 'detatched' signature
    # verification, where we supply the data to be verified as the 3rd parameter
    # to OpenSSL::PKCS7#verify. this is in keeping with how this content type
    # is described in the S/MIME RFC.
    #
    # > The multipart/signed MIME type has two parts.  The first part contains
    # > the MIME entity that is signed; the second part contains the "detached signature"
    # > CMS SignedData object in which the encapContentInfo eContent field is absent.
    #
    # https://datatracker.ietf.org/doc/html/rfc3851#section-3.4.3
    #
    # see also https://datatracker.ietf.org/doc/html/rfc1847#section-2.1

    content = attachment.raw_source
    # remove any leading \r\n characters (between headers & body i think).
    content = content.gsub(/\A\s+/, '')

    # TODO: why is signature.body.to_s different from signature.body.raw_source?
    signature_text = signature.body.to_s

    result = self.class.verify(
                                content: content,
                                signature_text: signature_text,
                                signing_certificate: partner_signing_certificate
                              )

    output = result[:valid]
    @verification_error = result[:error]

    # HACK until https://github.com/mikel/mail/pull/1511 is available
    #
    # due to a bug in the mail gem (fixed in PR above), when using
    # 'Content-Transfer-Encoding: binary', the body given by `attachment.raw_source`
    # will have all "\n" replaced by "\r\n". This causes a signature verification
    # failure.
    #
    # here, we try reversing this behavior (changing "\r\n" in the body back
    # to "\n") and re-attempt verification.
    #
    # this entire block can should removed once the bugfix in mail gem is
    # released & integrated into as2.
    #
    # we don't really know that verification failed due to line-ending mismatch.
    # it's only a guess.
    if !output && attachment.content_transfer_encoding == 'binary'
      # TODO: log when this happens.
      # include attachment.content_transfer_encoding, the results of the initial verification
      # and the results of the re-attempted verification

      body_delimiter = "\r\n\r\n"
      # split on first occurrence of `body_delimiter`
      # any trailing occurrences of `body_delimiter` are preserved as part of `body`
      headers, _, body = content.partition(body_delimiter)

      body.gsub!("\r\n", "\n") # cross fingers...
      content = headers + body_delimiter + body

      retry_output = self.class.verify(
                                        content: content,
                                        signature_text: signature_text,
                                        signing_certificate: partner_signing_certificate
                                      )

      if retry_output[:valid]
        @attachment = Mail::Part.new(content)
        @verification_error = retry_output[:error]
        output = retry_output[:valid]
      end
    end

    output
  else
    # TODO: how to log this?
    false
  end
end