Class: AceMQ::AMQP::Security
- Inherits:
-
Object
- Object
- AceMQ::AMQP::Security
- Defined in:
- lib/acemq/amqp/security.rb
Overview
How a connection reaches the broker safely: whether it is encrypted, which authority the broker is checked against, and who is logging in.
security = Security.verified(certificate_authority: "certs/ca.pem")
mq = Connection.open("amqps://broker:5671", security: security,
credentials: Credentials.from_env)
An amqps:// URL gets Security.verified without anybody asking, so the ordinary
case needs none of this. The class is what a broker with a private
certificate authority, or one that authenticates clients by certificate,
or one whose password must not be in the URL, is configured with.
There are three modes and they are named rather than being a boolean, because "secure: false" does not say which of two quite different things it means. Security.verified encrypts and checks who answered. Security.without_verifying_the_broker encrypts and does not check. Security.disabled does not encrypt.
This file has no dependency on bunny and does not know a connection exists, so it can be built and tested without a broker in the room. What it knows about bunny is confined to #to_transport_options and #configure, which are the two seams the transport uses.
What this is actually fixing
bunny does not verify the broker's certificate when it is given a URL.
Not "verifies weakly" — does not verify. A URL string is parsed by
AMQ::Settings, which merges in its own defaults, and one of those
defaults is verify: false; bunny then reads that as an explicit
instruction and sets VERIFY_NONE. So Bunny.new("amqps://broker:5671")
encrypts the traffic and accepts any certificate at all, including one
the connecting process just made up, and reports itself as tls? the
whole time. Nothing warns, because from bunny's point of view somebody
asked for this.
Every mode here therefore states verify_peer outright rather than
leaving it unsaid, which is why #to_transport_options always carries it.
Constant Summary collapse
- VERIFIED =
Encrypt, and check that the certificate chains to a trusted authority.
:verified- UNVERIFIED =
Encrypt, and accept whatever certificate turns up. See without_verifying_the_broker before using this.
:unverified- DISABLED =
Do not encrypt.
:disabled- DEVELOPMENT_MARKER =
What every certificate the AceMQ development tooling generates carries in its subject.
A certificate holding it is refused on every path, including UNVERIFIED, unless #allowing_development_certificates says otherwise. The point is that a development certificate reaching a production broker should be an error rather than a thing that quietly works because somebody turned verification off to get past a different problem. The same string in Java, Go, .NET and here.
"ACEMQ DEVELOPMENT ONLY - DO NOT TRUST"- MINIMUM_TLS_VERSION =
The lowest version this library will negotiate.
1.0 and 1.1 have been deprecated since 2021 and RabbitMQ turns them off by default; a broker that still offers them is a broker to fix rather than a broker to reach down to.
OpenSSL::SSL::TLS1_2_VERSION
- MAXIMUM_TLS_VERSION =
The highest version this library will negotiate, when OpenSSL has it.
bunny pins the version by setting the context's minimum and maximum to the same constant, and its default for both is TLS 1.2, so a broker and a client that could have agreed on 1.3 quietly settle for 1.2 instead. #configure lifts the ceiling, which is the only reason that method exists.
if defined?(OpenSSL::SSL::TLS1_3_VERSION) OpenSSL::SSL::TLS1_3_VERSION else MINIMUM_TLS_VERSION end
Instance Attribute Summary collapse
-
#certificate ⇒ Object
readonly
Returns the value of attribute certificate.
-
#certificate_authority ⇒ Object
readonly
Returns the value of attribute certificate_authority.
-
#credentials ⇒ Object
readonly
Returns the value of attribute credentials.
-
#key ⇒ Object
readonly
Returns the value of attribute key.
-
#mode ⇒ Object
readonly
Returns the value of attribute mode.
-
#reason ⇒ Object
readonly
Returns the value of attribute reason.
Class Method Summary collapse
- .complain_about(certificate) ⇒ Object private
-
.development_certificate?(certificate) ⇒ Boolean
Whether a certificate is one the AceMQ development tooling generated.
-
.development_certificate_file?(path) ⇒ Boolean
private
Whether a PEM file holds a certificate carrying the marker.
-
.disabled(credentials: nil) ⇒ Object
Plaintext.
-
.for_connection(url, security: nil, credentials: nil) ⇒ Object
private
The security a connection should use, given what the caller passed.
-
.for_url(url, credentials: nil) ⇒ Object
What a URL asks for on its own.
-
.marker_refusing_callback(verifying) ⇒ Proc
private
The verify callback that refuses the marker and decides nothing else.
-
.verified(certificate_authority: nil, certificate: nil, key: nil, credentials: nil) ⇒ Object
Encrypt, and verify the broker against a certificate authority.
-
.without_verifying_the_broker(because:, certificate: nil, key: nil, credentials: nil) ⇒ Object
Encrypt, and accept any certificate the broker presents.
Instance Method Summary collapse
-
#allowing_development_certificates ⇒ Security
A copy that accepts the certificates the AceMQ development tooling generates.
-
#allowing_development_certificates? ⇒ Boolean
Whether a certificate carrying DEVELOPMENT_MARKER is acceptable.
-
#client_certificate? ⇒ Boolean
Whether a client certificate is presented.
-
#configure(session) ⇒ Object
Settles the TLS versions, and the refusal of development certificates, on a session that has not started yet.
-
#encrypted? ⇒ Boolean
Whether anything is encrypted.
-
#initialize(mode:, certificate_authority: nil, certificate: nil, key: nil, credentials: nil, reason: nil, allow_development_certificates: false) ⇒ Security
constructor
Prefer the three named constructors.
- #inspect ⇒ Object
-
#resolved_credentials ⇒ Credentials?
The login, resolved now.
-
#to_s ⇒ Object
Never the credentials' secret — Credentials#to_s sees to that — and never anything else that would be worth stealing.
-
#to_transport_options ⇒ Hash
The bunny options this describes.
-
#verifying? ⇒ Boolean
Whether the broker's certificate is checked against an authority.
-
#with_credentials(other) ⇒ Object
A copy with a different login.
Constructor Details
#initialize(mode:, certificate_authority: nil, certificate: nil, key: nil, credentials: nil, reason: nil, allow_development_certificates: false) ⇒ Security
Prefer the three named constructors. This is public so a caller holding a mode can build one, and so #with_credentials can copy.
116 117 118 119 120 121 122 123 124 125 126 127 |
# File 'lib/acemq/amqp/security.rb', line 116 def initialize(mode:, certificate_authority: nil, certificate: nil, key: nil, credentials: nil, reason: nil, allow_development_certificates: false) @mode = mode @certificate_authority = readable_files(, "certificate authority") @certificate = readable_file(certificate, "client certificate") @key = readable_file(key, "client private key") @credentials = credentials @reason = reason @allow_development_certificates = allow_development_certificates check_client_certificate_is_a_pair! freeze end |
Instance Attribute Details
#certificate ⇒ Object (readonly)
Returns the value of attribute certificate.
102 103 104 |
# File 'lib/acemq/amqp/security.rb', line 102 def certificate @certificate end |
#certificate_authority ⇒ Object (readonly)
Returns the value of attribute certificate_authority.
102 103 104 |
# File 'lib/acemq/amqp/security.rb', line 102 def @certificate_authority end |
#credentials ⇒ Object (readonly)
Returns the value of attribute credentials.
102 103 104 |
# File 'lib/acemq/amqp/security.rb', line 102 def credentials @credentials end |
#key ⇒ Object (readonly)
Returns the value of attribute key.
102 103 104 |
# File 'lib/acemq/amqp/security.rb', line 102 def key @key end |
#mode ⇒ Object (readonly)
Returns the value of attribute mode.
102 103 104 |
# File 'lib/acemq/amqp/security.rb', line 102 def mode @mode end |
#reason ⇒ Object (readonly)
Returns the value of attribute reason.
102 103 104 |
# File 'lib/acemq/amqp/security.rb', line 102 def reason @reason end |
Class Method Details
.complain_about(certificate) ⇒ Object
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.
416 417 418 419 420 421 422 |
# File 'lib/acemq/amqp/security.rb', line 416 def self.complain_about(certificate) warn("acemq: the broker presented a certificate marked " \ "#{DEVELOPMENT_MARKER.inspect} (subject #{certificate.subject}). It was " \ "generated for development and is not trusted, however this connection is " \ "otherwise configured. If this really is a development broker, say so with " \ "Security#allowing_development_certificates.") end |
.development_certificate?(certificate) ⇒ Boolean
Whether a certificate is one the AceMQ development tooling generated.
The subject and the issuer, because a leaf signed by a marked authority is a development certificate whether or not it says so itself — and in a chain where only the leaf is presented, the issuer is the only place the marker appears.
290 291 292 293 294 295 296 297 298 299 300 |
# File 'lib/acemq/amqp/security.rb', line 290 def self.development_certificate?(certificate) return false if certificate.nil? marker = DEVELOPMENT_MARKER.upcase [certificate.subject, certificate.issuer] .any? { |name| name.to_s.upcase.include?(marker) } rescue StandardError # A certificate that will not describe itself is not evidence of the # marker. Whatever else is wrong with it, the handshake will say so. false end |
.development_certificate_file?(path) ⇒ Boolean
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.
Whether a PEM file holds a certificate carrying the marker.
427 428 429 430 431 432 433 434 435 436 437 |
# File 'lib/acemq/amqp/security.rb', line 427 def self.development_certificate_file?(path) return false if path.nil? File.read(path) .scan(/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m) .any? { |pem| development_certificate?(OpenSSL::X509::Certificate.new(pem)) } rescue StandardError # Unreadable, or not a certificate at all. Neither is the marker, and # both are reported by something better placed to explain them. false end |
.disabled(credentials: nil) ⇒ Object
Plaintext. Everything, including the password used to log in, crosses the network readable.
Reasonable against a broker on the same machine. Against one on the other side of a network you do not entirely control, it is not.
160 161 162 |
# File 'lib/acemq/amqp/security.rb', line 160 def self.disabled(credentials: nil) new(mode: DISABLED, credentials: credentials) end |
.for_connection(url, security: nil, credentials: nil) ⇒ Object
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.
The security a connection should use, given what the caller passed.
Both arguments are optional and independent: security describes the
channel, credentials describe the login, and either can be given
without the other. Passing credentials twice is refused rather than
silently resolved in one direction, because the two answers differ and
neither is obviously the one that was meant.
235 236 237 238 239 240 241 242 243 244 245 246 |
# File 'lib/acemq/amqp/security.rb', line 235 def self.for_connection(url, security: nil, credentials: nil) security ||= for_url(url) return security if credentials.nil? unless security.credentials.nil? raise ConfigurationError, "credentials were passed both to the connection and to its Security; " \ "put them in one place so there is no question which login is used" end security.with_credentials(credentials) end |
.for_url(url, credentials: nil) ⇒ Object
What a URL asks for on its own.
amqps:// means encrypted and verified, because that is what the scheme
promises everywhere else it appears and a client that quietly meant
something weaker is a client nobody can reason about. amqp:// means
plaintext, which is what it says.
218 219 220 221 222 223 224 |
# File 'lib/acemq/amqp/security.rb', line 218 def self.for_url(url, credentials: nil) if url.to_s.strip.downcase.start_with?("amqps://") verified(credentials: credentials) else disabled(credentials: credentials) end end |
.marker_refusing_callback(verifying) ⇒ Proc
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.
The verify callback that refuses the marker and decides nothing else.
In verifying mode OpenSSL's own verdict stands for everything that is not marked; in unverified mode everything that is not marked is accepted, which is what unverified meant before this existed.
It never raises. An exception from inside a verify callback is caught by OpenSSL and turned into a bare "certificate verify failed", so the reason is written to stderr instead, immediately above the failure.
399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 |
# File 'lib/acemq/amqp/security.rb', line 399 def self.marker_refusing_callback() lambda do |trusted, store| certificate = store.current_cert if development_certificate?(certificate) complain_about(certificate) next false end ? trusted : true rescue StandardError # A callback that raises is a callback whose answer OpenSSL cannot # read, and the safe reading of "I do not know" is no. false end end |
.verified(certificate_authority: nil, certificate: nil, key: nil, credentials: nil) ⇒ Object
Encrypt, and verify the broker against a certificate authority.
With no certificate_authority the machine's own trust store is used,
which is what a broker with a certificate from a public authority needs
and nothing else does. Naming a file narrows trust to that authority
alone — the system store is then not consulted at all, which is the
point: a certificate from a public authority is not evidence that the
thing answering is your broker, and the hundreds of authorities a
machine trusts by default are hundreds of ways to be wrong.
certificate and key are the client's own, for a broker that
authenticates clients by certificate rather than by password
(RabbitMQ's EXTERNAL mechanism). They go together: one without the other
is refused here rather than producing a handshake failure later.
149 150 151 152 153 |
# File 'lib/acemq/amqp/security.rb', line 149 def self.verified(certificate_authority: nil, certificate: nil, key: nil, credentials: nil) new(mode: VERIFIED, certificate_authority: , certificate: certificate, key: key, credentials: credentials) end |
.without_verifying_the_broker(because:, certificate: nil, key: nil, credentials: nil) ⇒ Object
Encrypt, and accept any certificate the broker presents.
READ THIS =============================
This turns off the only check that distinguishes your broker from anything else that can answer on that address. The traffic cannot be read by somebody watching the network — and nothing stops that somebody from being the broker. They present any certificate they like, this library accepts it, and the connection then hands over the password in the AMQP login and every message afterwards. It is encrypted the whole way, to them. There is no symptom: the connection succeeds, the messages flow, and the only evidence is on the attacker's disk.
It exists because there is exactly one situation where it is the honest
answer: reaching a development broker whose self-signed certificate you
have not got round to trusting, on a machine where being wrong costs
nothing. In that situation certificate_authority: on verified is
about four seconds more work and is correct, so prefer it even there.
It is deliberately awkward to reach. The name is long and says what it
does, so it cannot be typed by accident and cannot be skimmed past in a
review; because: is required and must say something, so the reason
lands in the code rather than in whoever's memory; and it never becomes
the default for any URL, so no configuration change can arrive at it
without somebody having written this line. If you are reading it in a
diff, the question to ask is not whether it works — it always works,
that is the problem — but what stops this process reaching a production
broker.
195 196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'lib/acemq/amqp/security.rb', line 195 def self.(because:, certificate: nil, key: nil, credentials: nil) reason = because.to_s.strip if reason.empty? raise ConfigurationError, "without_verifying_the_broker needs a reason: pass because: with the " \ "circumstance that makes an unverified broker acceptable here, so the " \ "next person to read this line does not have to guess" end new(mode: UNVERIFIED, certificate: certificate, key: key, credentials: credentials, reason: reason) end |
Instance Method Details
#allowing_development_certificates ⇒ Security
A copy that accepts the certificates the AceMQ development tooling generates.
security = Security.verified(certificate_authority: ".tls/ca.crt")
.allowing_development_certificates
A separate, visible step rather than a keyword on the constructors, and for the same reason without_verifying_the_broker has a long name: it has to be legible in a diff. Turning it on is saying "the broker this process reaches is a development broker", and the whole value of the marker is that saying so is a decision somebody made on purpose.
It does not weaken anything else. Verification stays on, the authority stays whatever it was, and a certificate that does not verify is still refused — this only stops the marker itself being the reason.
277 278 279 |
# File 'lib/acemq/amqp/security.rb', line 277 def allowing_development_certificates copy(allow_development_certificates: true) end |
#allowing_development_certificates? ⇒ Boolean
Whether a certificate carrying DEVELOPMENT_MARKER is acceptable.
258 |
# File 'lib/acemq/amqp/security.rb', line 258 def allowing_development_certificates? = @allow_development_certificates |
#client_certificate? ⇒ Boolean
Whether a client certificate is presented.
255 |
# File 'lib/acemq/amqp/security.rb', line 255 def client_certificate? = !certificate.nil? |
#configure(session) ⇒ Object
Settles the TLS versions, and the refusal of development certificates, on a session that has not started yet.
Everything else this class configures goes through the options hash; this cannot, because bunny builds its SSL context from that hash and pins the minimum and the maximum to the same version, and the only way to say "1.2 or better" rather than "1.2 exactly" is to reach the context afterwards. The session must not have been started: the context is used once, when the socket is wrapped.
Guarded on both sides because it is reaching past bunny's documented surface. A bunny that no longer offers this leaves the connection on bunny's own pin, which is a version this library considers acceptable anyway — so the failure mode is a slightly older protocol, not a connection that will not open.
353 354 355 356 357 358 359 360 361 362 363 364 365 366 |
# File 'lib/acemq/amqp/security.rb', line 353 def configure(session) return unless encrypted? return unless session.respond_to?(:transport) transport = session.transport return unless transport.respond_to?(:configure_tls_context) transport.configure_tls_context do |context| context.min_version = MINIMUM_TLS_VERSION context.max_version = MAXIMUM_TLS_VERSION refuse_development_certificates(context) end nil end |
#encrypted? ⇒ Boolean
Whether anything is encrypted.
249 |
# File 'lib/acemq/amqp/security.rb', line 249 def encrypted? = mode != DISABLED |
#inspect ⇒ Object
382 383 384 |
# File 'lib/acemq/amqp/security.rb', line 382 def inspect "#<AceMQ::AMQP::Security #{self}>" end |
#resolved_credentials ⇒ Credentials?
The login, resolved now.
Resolution happens here rather than in the constructor so a block that re-reads a rotating secret is called at connection time, which is the only time its answer is worth anything.
309 |
# File 'lib/acemq/amqp/security.rb', line 309 def resolved_credentials = Credentials.resolve(credentials) |
#to_s ⇒ Object
Never the credentials' secret — Credentials#to_s sees to that — and never anything else that would be worth stealing. Certificate paths are shown because a connection that trusted the wrong authority is diagnosed by reading which file it trusted.
372 373 374 375 376 377 378 379 380 |
# File 'lib/acemq/amqp/security.rb', line 372 def to_s parts = ["mode=#{mode}"] parts << "authority=#{Array().join(",")}" if parts << "clientCertificate=#{certificate}" if client_certificate? parts << "credentials=(#{credentials_description})" unless credentials.nil? parts << "because=#{reason.inspect}" if reason parts << "developmentCertificates=allowed" if allowing_development_certificates? parts.join(" ") end |
#to_transport_options ⇒ Hash
The bunny options this describes.
verify_peer is stated in every mode, including the ones where it is
what bunny would have done anyway. Leaving it unsaid is what causes the
problem described at the top of this file: bunny reads the absence of an
opinion from a URL as an instruction not to verify.
324 325 326 327 328 329 330 331 332 333 334 |
# File 'lib/acemq/amqp/security.rb', line 324 def check_development_certificates! = { tls: encrypted? } [:verify_peer] = if encrypted? [:tls_ca_certificates] = if if client_certificate? [:tls_cert] = certificate [:tls_key] = key end .merge(resolved_credentials&. || {}) end |
#verifying? ⇒ Boolean
Whether the broker's certificate is checked against an authority.
252 |
# File 'lib/acemq/amqp/security.rb', line 252 def = mode == VERIFIED |
#with_credentials(other) ⇒ Object
A copy with a different login.
312 |
# File 'lib/acemq/amqp/security.rb', line 312 def with_credentials(other) = copy(credentials: other) |