Skip to content

Use public key authentication as per v3#15

Merged
bhuga merged 24 commits intomasterfrom
v3-auth
May 17, 2017
Merged

Use public key authentication as per v3#15
bhuga merged 24 commits intomasterfrom
v3-auth

Conversation

@bhuga
Copy link
Contributor

@bhuga bhuga commented May 15, 2017

@bhuga
Copy link
Contributor Author

bhuga commented May 15, 2017

Far enough along for real eyes.

@bhuga bhuga requested review from jbarnette and mistydemeo May 15, 2017 22:12
Copy link
Contributor

@jbarnette jbarnette left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Various nitpicks inline. I dig it, let me know when I should port kube-me to V3.

end
end

def invalid_time
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could pass timestamp in here to avoid duplicating the header access.

nonce = request.headers['Chatops-Nonce']
timestamp = request.headers['Chatops-Timestamp']
begin
time = Time.parse(timestamp)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How bout Time.iso8601(timestamp)?

begin
signature_items = signature_header.split(" ", 2)[1].split(",").map { |item| item.split("=", 2) }.to_h
signature = signature_items["signature"]
rescue NoMethodError
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this guarding?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a comment. It's guarding sundry possible nils in the transformation above.

decoded_signature = Base64.decode64(signature)
digest = OpenSSL::Digest::SHA256.new
raise ConfigurationError.new("You need to add a client's public key in .pem format via CHATOPS_AUTH_PUBLIC_KEY") unless ENV["CHATOPS_AUTH_PUBLIC_KEY"].present?
if ENV["CHATOPS_AUTH_PUBLIC_KEY"].present?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will always be true.

digest = OpenSSL::Digest::SHA256.new
raise ConfigurationError.new("You need to add a client's public key in .pem format via CHATOPS_AUTH_PUBLIC_KEY") unless ENV["CHATOPS_AUTH_PUBLIC_KEY"].present?
if ENV["CHATOPS_AUTH_PUBLIC_KEY"].present?
public_key = OpenSSL::PKey::RSA.new(ENV["CHATOPS_AUTH_PUBLIC_KEY"])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How bout a local for ENV["CHATOPS_AUTH_PUBLIC_KEY"]? You're using it in a few places.

authenticated = public_key.verify(digest, decoded_signature, signature_string)
end
if !authenticated && ENV["CHATOPS_AUTH_ALT_PUBLIC_KEY"].present?
public_key = OpenSSL::PKey::RSA.new(ENV["CHATOPS_AUTH_ALT_PUBLIC_KEY"])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same 💭 about a local for these ENV accesses, no big deal though.

expect(response.status).to eq 403
end

it "doesn't allow requests more than 1 minute old" do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth testing the future too?

response.headers['Chatops-SignatureString'] = signature_string
decoded_signature = Base64.decode64(signature)
digest = OpenSSL::Digest::SHA256.new
raise ConfigurationError.new("You need to add a client's public key in .pem format via CHATOPS_AUTH_PUBLIC_KEY") unless ENV["CHATOPS_AUTH_PUBLIC_KEY"].present?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe wrap this into a block like this (and also assign it to a var like John suggests)?

begin
  public_key = ENV.fetch("CHATOPS_AUTH_PUBLIC_KEY")
rescue KeyError
  raise ConfigurationError.new("You need to add a client's public key in .pem format via CHATOPS_AUTH_PUBLIC_KEY")
end


describe ActionController::Base, type: :controller do
controller do
controller do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you mean to tweak the whitespace here?

@bhuga
Copy link
Contributor Author

bhuga commented May 17, 2017

Yalls excellent comments resulted in a huge refactoring to something more rails-reasonable. Ready for 👀 again.

before_action :ensure_valid_chatops_timestamp, if: :should_authenticate_chatops?
before_action :ensure_valid_chatops_signature, if: :should_authenticate_chatops?
before_action :ensure_valid_chatops_nonce, if: :should_authenticate_chatops?
before_action :ensure_chatops_authenticated, if: :should_authenticate_chatops?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛳️

with_options if: :should_authenticate_chatops? do
  before_action :ensure_valid_chatops_url
  before_action :ensure_valid_chatops_timestamp
  before_action :ensure_valid_chatops_signature
  before_action :ensure_valid_chatops_nonce
  before_action :ensure_chatops_authenticated
end

signature_string = [@chatops_url, @chatops_nonce, @chatops_timestamp, body].join("\n")
# We return this just url client debugging.
response.headers['Chatops-SignatureString'] = signature_string
public_key = ENV["CHATOPS_AUTH_PUBLIC_KEY"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"I know, I'll use single quotes on odd-numbered lines and double quotes on even lines!"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"I know, I'll use single quotes on odd-numbered lines and double quotes on even lines!"

It was more like "I know a great way to get @jbarnette riled up!"

response.headers['Chatops-SignatureString'] = signature_string
public_key = ENV["CHATOPS_AUTH_PUBLIC_KEY"]
alt_public_key = ENV["CHATOPS_AUTH_ALT_PUBLIC_KEY"]
raise ConfigurationError.new("You need to add a client's public key in .pem format via CHATOPS_AUTH_PUBLIC_KEY") unless public_key.present?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these are fine inline, but they seem like the kind of thing we'd normally break out to a Chatops.auth_public_key accessor etc.

def ensure_valid_chatops_url
base_url = ENV["CHATOPS_AUTH_BASE_URL"]
raise ConfigurationError.new("You need to set the server's base URL to authenticate chatops RPC via CHATOPS_AUTH_BASE_URL") unless base_url.present?
@chatops_url = base_url + request.path
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any need to care about trailing /?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any need to care about trailing /?

Sorta. I added another linter here.

That sort of thing is why the Chatops-Signature-String to so what we signed; hopefully that will make such errors easier to find.

signature_header = request.headers['Chatops-Signature']

begin
# 'Chatops-Signatre: Signature keyid=foo,signature=abc123' => { "keyid"" => "foo", "signature" => "abc123" }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-Signatre, "keyid""

@chatops_signature = signature_items["signature"]
rescue NoMethodError
# The signature header munging, if something's amiss, can produce a `nil` that raises a
# no method error. We'll just carry on; the nil signature will raise below
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<3

@bhuga
Copy link
Contributor Author

bhuga commented May 17, 2017

All feedback adopted. Feeling kinda ashamed yall had so much to find. Thanks again.

lib/chatops.rb Outdated
@@ -0,0 +1,21 @@
module ChatOps
Copy link
Contributor

@jbarnette jbarnette May 17, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be Chatops. Or the file should be chat_ops.rb. Actually let's talk about class and gem names for a sec since they're inconsistent + this is going to be public soon. If it's gonna stick to RubyGems conventions (underscores separate words, dashes separate namespaces), one of these first two approaches would be good:

gem sample path constant
chatops-controller lib/chatops/controller.rb Chatops::Controller
chat_ops-controller lib/chat_ops/controller.rb ChatOps::Controller

These are not great options:

gem sample path constant
chatops_controller lib/chatops_controller.rb ChatopsController
chat_ops_controller lib/chat_ops_controller.rb ChatopsController

Myself, I prefer the first example, because underscores in project names are kinda ugly and mixing em with dashes doubly so. But you could've guessed that. 😁

@bhuga
Copy link
Contributor Author

bhuga commented May 17, 2017

Actually let's talk about class and gem names for a sec since they're inconsistent + this is going to be public soon.

Good call. I will go ahead and wrap that into this pull since it's version 3.0.

Ben Lavender added 2 commits May 17, 2017 11:26
Without the arg, this seems to work only in some versions of rails
@bhuga
Copy link
Contributor Author

bhuga commented May 17, 2017

Okay, the gem's renamed. One more time, this time with feeling!

@bhuga
Copy link
Contributor Author

bhuga commented May 17, 2017

Awesome awesome thanks again. Looking good. Next PR is the README cleanup this needs before going open-source.

@bhuga bhuga merged commit 5586435 into master May 17, 2017
@bhuga bhuga deleted the v3-auth branch May 17, 2017 19:48
Copy link

@keithduncan keithduncan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is so good and the tests look great too

end

def self.alt_public_key
ENV["CHATOPS_AUTH_ALT_PUBLIC_KEY"]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We actually don’t need the main v alt split under v3 with public key auth, because the key used to sign is self identified with the keyid param 🤔

We can have a relation of keyid => publickey hosting many live keys. Rolling keys with this scheme would be much simpler than the multiple deploys to populate and move keys from alt to main.

I think it would be worth baking that concept into the gem from the beginning. We can have a default backend that looks the key up in the ENV, but otherwise the notion of supporting multiple and even per-user keys is pressed from the beginning.

# We return this just to aid client debugging.
response.headers["Chatops-Signature-String"] = signature_string
raise ConfigurationError.new("You need to add a client's public key in .pem format via #{Chatops.public_key_env_var_name}") unless Chatops.public_key.present?
if signature_valid?(Chatops.public_key, @chatops_signature, signature_string) ||

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above I think the key to verify with should be hinted on the incoming keyid field.

unless authenticated
render :status => :forbidden, :plain => "Not authorized"
if Chatops.auth_base_url[-1] == "/"
raise ConfigurationError.new("Don't include a trailing slash in #{Chatops.auth_base_url_env_var_name}; the rails path will be appended and it must match exactly.")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯 error handling here.

What do you think about verifying this when included so that you get the error on boot rather than on first request?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we can detect that the last character is slash, and we don't want slashes, would it be a better user experience to just remove the trailing slash? That would reduce the back and forth a little bit.

# "Chatops-Signature: Signature keyid=foo,signature=abc123" => { "keyid"" => "foo", "signature" => "abc123" }
signature_items = signature_header.split(" ", 2)[1].split(",").map { |item| item.split("=", 2) }.to_h
@chatops_signature = signature_items["signature"]
rescue NoMethodError

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we mandate Ruby 2.3 and use the safe nil navigation operator?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth surverying the existing CRPC commands to see if anything is < 2.3.

It would also be possible to use try on these since ActiveSupport should be around.

def ensure_valid_chatops_timestamp
@chatops_timestamp = request.headers["Chatops-Timestamp"]
time = Time.iso8601(@chatops_timestamp)
if !(time > 1.minute.ago && time < 1.minute.from_now)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super minor but if ! can be swapped for unless

end

def request_is_chatop?
(chatop_names + [:list]).include?(params[:action].to_sym)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s worth requiring at least Ruby 2.2.1 for the symbols GC if we String#to_sym user data

Copy link
Contributor

@technicalpickles technicalpickles left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I'm late to the review party.

Aside from line-level comments, it looks like the README.md wasn't updated to talk about setting up private/public keys at all.

unless authenticated
render :status => :forbidden, :plain => "Not authorized"
if Chatops.auth_base_url[-1] == "/"
raise ConfigurationError.new("Don't include a trailing slash in #{Chatops.auth_base_url_env_var_name}; the rails path will be appended and it must match exactly.")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we can detect that the last character is slash, and we don't want slashes, would it be a better user experience to just remove the trailing slash? That would reduce the back and forth a little bit.

# "Chatops-Signature: Signature keyid=foo,signature=abc123" => { "keyid"" => "foo", "signature" => "abc123" }
signature_items = signature_header.split(" ", 2)[1].split(",").map { |item| item.split("=", 2) }.to_h
@chatops_signature = signature_items["signature"]
rescue NoMethodError
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth surverying the existing CRPC commands to see if anything is < 2.3.

It would also be possible to use try on these since ActiveSupport should be around.


begin
# "Chatops-Signature: Signature keyid=foo,signature=abc123" => { "keyid"" => "foo", "signature" => "abc123" }
signature_items = signature_header.split(" ", 2)[1].split(",").map { |item| item.split("=", 2) }.to_h
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL split with a limit


begin
# "Chatops-Signature: Signature keyid=foo,signature=abc123" => { "keyid"" => "foo", "signature" => "abc123" }
signature_items = signature_header.split(" ", 2)[1].split(",").map { |item| item.split("=", 2) }.to_h
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an impressive one-liner, but I am wondering if it'd be easier to grok broken out on multiple lines. That might be a lot in the controller, so it could be logical to have it in some utility method.


def ensure_valid_chatops_timestamp
@chatops_timestamp = request.headers["Chatops-Timestamp"]
time = Time.iso8601(@chatops_timestamp)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can also do @chatops_timestamp.to_time

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants