thephpleague / oauth2-client

Easy integration with OAuth 2.0 service providers.
http://oauth2-client.thephpleague.com
MIT License
3.66k stars 752 forks source link

[Proposal] Add locked-down peer verification for each provider #214

Open SammyK opened 9 years ago

SammyK commented 9 years ago

Got another one for you guys. :)

The Problem

Currently there is no peer verification for any of the providers meaning all HTTPS communication with the providers is more susceptible to man-in-the-middle attacks.

Edited for clarity & correctness:

Currently a default certificate bundle is being used to verify all HTTPS requests meaning all communication with the providers is more susceptible to man-in-the-middle attacks.

The Solution

Each provider should include a custom cert bundle for their specific provider to verify the peer.

The TL;DR on peer verification

1) Find out which certificate authority(ies) (CA) signs the certificates for all the provider's domains. e.g. Facebook uses DigiCert to sign the same cert for all their domains (which makes it easier to just deal with on CA and one cert.) See more about how I found this info out for Facebook. 2) Find the root certificate for the provider's certificate. The CA should have a page dedicated to the root certs. e.g. DigiCert root certificates. 3) Download & ensure the root cert is in PEM (ASCII) format (or convert it to PEM). e.g. DigiCert root certs are in DER (binary) format so you can convert the "DigiCertHighAssuranceEVRootCA.crt" file for example, to PEM from the command line with $ openssl x509 -inform der -in DigiCertHighAssuranceEVRootCA.crt -out DigiCertHighAssuranceEVRootCA.pem -outform PEM 4) Concatenate all the PEM certs into one certificate bundle (Facebook only had one, but I know other providers like Twitter have several) and add it to the provider's repo. 5) Point the Guzzle client to the certificate bundle using the verify option:

$options = [
      // . . . 
      'verify' => __DIR__ . '/certs/DigiCertHighAssuranceEVRootCA.pem',
    ];
$guzzleClient = new Guzzle\Service\Client($options);

We recently locked down the Facebook PHP SDK this way.

Implementation

The current AbstractProvider does not support a verify peer option. So before we add cert bundles, we need to make a way to support them. :)

I propose adding a $verifyPeerBundle property to the AbstractProvider that the extended class would set. On instantiation, if the $verifyPeerBundle property is not set, it will throw an exception thus forcing all providers to use a peer-verification bundle.

abstract class AbstractProvider implements ProviderInterface
{
    public $verifyPeerBundle = '';

    public function __construct($options = [])
    {
        foreach ($options as $option => $value) {
            if (property_exists($this, $option)) {
                $this->{$option} = $value;
            }
        }

        if (empty($this->verifyPeerBundle)) {
                throw PeerVerificationException('No certificate bundle found to verify the peer'); // Or IDPException or whatever
        }
        // Probably want to double-check the $verifyPeerBundle file exists & throw if not.
        // Or we can just let Guzzle throw for us if the bundle can't be found. (probably better)

        $this->setHttpClient(new GuzzleClient(['verify' => $this->verifyPeerBundle]));
    }
}

Cert bundles

Of course this would require a bit of research for each of the providers to figure out all the certs they use and generate a custom cert bundle. But hey - I've got the Facebook one already ready to go! :D

Thoughts? :) If I get a nod on this, I can send a PR.

mtdowling commented 9 years ago

Currently there is no peer verification for any of the providers meaning all HTTPS communication with the providers is more susceptible to man-in-the-middle attacks.

Guzzle enables peer verification by default (using cURL). Guzzle 3 (the library this project seems to use) has a bundled CA cert, which has the potential to become outdated.

If this library upgraded to Guzzle 5, then this would be taken care of automatically for most users via their system CA bundles (when using cURL or PHP stream wrapper+PHP 5.6) or using the path to common CA bundle locations (when using the PHP stream wrapper on versions < PHP 5.6). See https://github.com/guzzle/RingPHP/blob/master/src/Client/ClientUtils.php#L23.

I'm no longer a fan of bundling CA certs in client libraries. There's actually a lot of changes made to CA bundles over time, and trying to keep bundled versions up to date is a big burden on client library authors and requires that users trust client authors to stay up to date. CA bundles should be handled by the distro IMO (e.g., yum update ca-certificates).

SammyK commented 9 years ago

Hey @mtdowling! Thanks for chiming in. :)

Guzzle 3 (the library this project seems to use) has a bundled CA cert, which has the potential to become outdated.

Ah yes, I should have clarified that it's using the default peer verification CA bundle. But that's what I wanted to bring up is that these really huge CA bundles in general seem to go unmaintained - especially down to the system bundle. Heck, we can't even get people to upgrade from PHP 5.2! Lol.

I'm no longer a fan of bundling CA certs in client libraries. ... CA bundles should be handled by the distro IMO

Correct me if I'm wrong but isn't that just passing the security buck down to the ops guys? :) What I'm pushing for in this proposal isn't a big CA bundle to maintain but to include only the root certificates of the specific provider's CA. It tightens down the peer verification scope like a laser beam.

Facebook's PHP SDK CA bundle went from this (253KB) to this (1.4KB). And peer verification is strict as heck now. :)

What do you think?

ramsey commented 9 years ago

The 1.0 branch is already running Guzzle 5. :-)

mtdowling commented 9 years ago

But that's what I wanted to bring up is that these really huge CA bundles in general seem to go unmaintained

I think that CA bundles bundled with client libraries go unmaintained as well. Let me provide a good example of how relying on the system bundle makes it easier to deploy client libraries to different servers. Amazon provides Amazon Linux which has it's own bundled CA certs. We can deploy the PHP SDK (v3) to these servers and use Amazon's custom CA bundle that's provided as the system default. We don't ever have to worry about out of date CA bundles, nor do we have to make a commit to our repo if a one of the root certs are updated. More importantly: if Amazon ever created other private clouds that require custom CA bundles, then the SDK would work just fine without needing a custom build for that specific cloud.

isn't that just passing the security buck down to the ops guys?

It totally is. But if you're doing anything from the command line (like using curl, wget, git, etc.), then you need a working CA bundle somewhere on the system.

Facebook's PHP SDK CA bundle went from this (253KB) to this (1.4KB). And peer verification is strict as heck now. :)

It can be even smaller: 0KB! :)

The 1.0 branch is already running Guzzle 5. :-)

Sweet!

SammyK commented 9 years ago

relying on the system bundle makes it easier to deploy client libraries

The point I was proposing was related to security, not ease which unfortunately don't seem to go hand-in-hand often. :-/

if Amazon ever created other private clouds that require custom CA bundles, then the SDK would work just fine without needing a custom build for that specific cloud

I hear what you're saying that that certainly sounds like the best solution for that specific context. But this issue relates to peer verification to one specific domain or set of domains for each provider. So the idea is that we won't want to trust any other cert other than the very specific ones we know that one provider has. So the Facebook provider will only ever expect to see a DigiCert High Assurance CA-3 certificate for example. Otherwise the connection will fail.

Since there is talk of moving all the providers out of this repo into their own repo with their own maintainers (#213), it would be up to the provider maintainer to update the small CA bundle if the provider ever changed their CA (a rare case).

Inconvenient? Yes? More secure? Hella yes! :)

It can be even smaller: 0KB! :)

Now you're just being all:

Hehe. :D

mtdowling commented 9 years ago

I'm not sure that what you're proposing is more secure. I think it becomes theoretically less secure because your application would now implicitly have to trust that the certs provided by the client library are up to date and not nefarious (I'm not saying the library authors would ever do something nefarious, but when it comes to security, you shouldn't have to make this assumption).

The point is: certificates are already handled by other parts of the user's system-- parts that your application already trusts. When cURL is compiled, it finds the most appropriate system CA bundle on disk. The same thing happens when PHP 5.6+ is compiled. Having client libraries manage certificates is unnecessary because it's already handled by PHP, cURL, and ultimately, the system CA bundle. Guzzle will fail if a CA bundle cannot be used when sending requests, and the exception it throws describes exactly how to address the problem (e.g., this would be common on Windows).

I don't have any say in what this library does, nor am I a maintainer. I just wanted to share my thoughts.

SammyK commented 9 years ago

I think it becomes theoretically less secure because your application would now implicitly have to trust that the certs provided by the client library are up to date and not nefarious

It'd probably be good to call out a few security nerds on twitter to confirm or deny if this proposal is worth it then. About to tweet... :)

certificates are already handled by other parts of the user's system-- parts that your application already trusts

So if I understand you're saying we'd be moving the trust from the system to the library. I'm guessing there are pros and cons to both methods but I'd really like to know which one is "officially" more secure though! :)

I just wanted to share my thoughts.

Thanks again for chiming in! :)

mtdowling commented 9 years ago

It'd probably be good to call out a few security nerds on twitter to confirm or deny if this proposal is worth it then. About to tweet... :)

Good idea! I'm definitely no security expert. Maybe we can find one to chime in.

SammyK commented 9 years ago

Yay community. (Thanks for the RT too) :D

ramsey commented 9 years ago

Ping @enygma and @ircmaxell. We'd like your security advice, please. See above thread. :-)

rdlowrey commented 9 years ago

While whittling down the CA bundle to only those root CAs that sign the certs of your oauth providers is a good idea in theory I'm with @mtdowling on this: In practice you probably don't want to be responsible for maintaining and keeping these specific CA certs up-to-date yourself. But let's just say for argument's sake you do want to do this. The hard part is actually procuring the certs in a secure manner ...

What People Usually Suggest

The usual advice people give in this area is "just use the curl certs or the mozilla certs." Note that curl actually extracts its certs from the mozilla source tree making these two the exact same thing for our purposes. We can just cherry-pick the root CA certs we need from the curl bundle, right? We'll retrieve them via https or ssh from github ... no reason to worry because we're pulling down the ca-bundle.crt from github over a secure protocol.

The problem in this case is curl is not even retrieving the certs from mozilla in a secure manner. If you take a look at the mk-ca-bundle.pl curl uses to procure its cert bundle from mozilla you'll see that this transaction is done in clear plaintext over http with no encryption. Given the number of tools and projects that rely on the curl certificate bundles this is somewhat terrifying. If I went to work for the NSA tomorrow the first thing I'd do is get in between curl and mozilla and start feeding them CA certs in this transfer that were under my control (surely this has already happened, right?). I can't adequately explain how much of a security WTF this is.

Of course, you can access hg.mozilla.com via https, so it is technically possible to retrieve these certs securely though we still have to trust a third party somewhere along the line to verify that the mozilla certificate is valid and we aren't being MitM'd on that connection. Unfortunately this mozilla server is currently using a certificate signed with a sha-1 hash which, according to one estimate would only cost ~$700k in computing power to forge in 2015. Could a sophisticated attacker with deep pockets do this? Probably.

In summary what I'm trying to get at is this: even the first step in the process of providing your own certs is non-trivial. It's difficult even to feel good about the validity of the CA certs you have. And after that it's now your responsibility to keep them up-to-date.

The Better Alternative

Curl installations are generally configured at compile time to point to the operating system's certificate store to find its CA certs. This is the same as the stream functionality in PHP 5.6+. By simply using one of these tools the problem is completely handled with no effort from you. It will "just work."

In general, I feel better about trusting CA certs delivered to me by my OS than trying to go out and retrieve a CA bundle myself. That said, there are still a couple of problems here:

  1. Let's be honest: few people will actually keep up their OS updates to renew these certs.
  2. I mostly trust nix distros. I don't mean to cast aspersions on Microsoft --but nothing about its history suggests MS wouldn't sell me down the river by installing trusted CA certs compromised by government agencies. Is this a problem for an Oauth use case? Maybe not. But if you want a real expectation of privacy from state actors you probably can't completely trust CAs or large corporate entities as governments probably have the legal means to force them to cooperate.

So in reality the CA peer verification route still suffers even if you "do it right" by trusting OS-provided certs. The real issue here should be clear: the CA model forces us to always implicitly trust a third-party in the transaction. This paradigm has turned out to be a real nightmare in practice.

Are There Better Options?

Well, yes. The idea of "certificate pinning" has been gaining traction as people have become increasingly more aware of the difficulties of a PKI that necessitates trusting some vague third-party CA out in the aether. So instead we have this concept of pinning that revolves around storing identifying certificate/public-key information for a given peer.

Pinning can easily be accomplished at the HTTP client layer by capturing and storing either the entire client certificate or by extracting and retaining only the public key from the certificate. The logic here is that you only need to procure this information one time when not under a MitM attack and after that you can simply compare your stored results to those presented by the peer at each transaction. Though I'm unaware of any way to accomplish this using curl, this is trivial to do with userland streams right now. For example ...

$context = stream_context_create(["ssl" => ["capture_peer_cert" => true]]);
file_get_contents("https://www.google.com/", false, $context);
$peerCertResource = stream_context_get_options($context)['ssl']['peer_certificate'];

// This stores the peer certificate in a string -- ugly by-ref param :(
openssl_x509_export($peerCertResource, $peerCertString);

// If you want to extract the public key:
$publicKeyResource = openssl_pkey_get_public($peerCertResource);
$keyDetails = openssl_pkey_get_details($publicKeyResource);
$publicKeyString = $keyDetails['key'];

A client application could store either/or/both of those and keep them on hand to compare against the cert/public-key in future connections. In particular for something like Oauth where you have a limited number of potential hosts you need to connect to this is a good approach because it's not that difficult to keep them up to date. Also, storing public keys may be more desirable than storing certs because they can be reused (though don't have to be) even when certificates are renewed.

By initially retrieving and comparing the certificates from a couple of different network locations you can feel fairly confident that if they match you weren't MitM'd while retrieving them. The only drawback here is that with current PHP is it's not immediately apparent how to abort the connection prior to the transaction in the event of a cert or key mismatch. Unless you're doing all of the protocol work yourself in something like a non-blocking client this isn't easy to verify until after the transaction has already taken place. I do this in my own personal HTTP client but I haven't migrated those features upstream to the public open source version yet.

Is There An Easier Way to Get the Benefits of Pinning?

Funny you should ask. In fact, there is. It's a simple matter of hashing the contents of the certificate and comparing the hash to what you expect. While I don't think it's something you can do through the curl interface it's trivial to do this in 5.6+ with streams using some of the new functionality available there. All you need to do is pass the new "peer_fingerprint" SSL context option with the expected certificate hash and PHP will verify that it matches the peer certificate before conducting the transaction.

All you need to do is obtain the fingerprint hash for the host to which you need to connect and pass it into the relevant context option like so:

$googleFingerprint = "‎eafadd392a2333f7791e548b689d5e26c2f49f0f";
$context = stream_context_create(["ssl" => [
    "peer_fingerprint" => $googleFingerprint
]]);

// That's it. If the fingerprint fails it won't matter if CA peer verification
// succeeds -- the connection will still be aborted.
file_get_contents("https://www.google.com/", false, $context);

You can obtain cert fingerprint hashes in a few different ways:

$context = stream_context_create(["ssl" => ["capture_peer_cert" => true]]);
file_get_contents("https://www.google.com/", false, $context);
$peerCertResource = stream_context_get_options($context)['ssl']['peer_certificate'];
$sha1Fingerprint = openssl_x509_fingerprint($peerCertResource);

cert

Basically, you just need to pull the hash from a couple of different places or call up a friend and have them pull the hashes for the hosts to which you plan to connect and compare results. As long as they match you can be fairly confident that everyone at every machine from which you loaded the sites wasn't being MitM'd at the time.

These hashes have the same validity period as certificates so you will need to update them any time a certificate changes. Note that this is something that's fairly easy to automate with the excellent tools at your disposal in userland PHP. Also, I suspect your users would start complaining pretty quickly if their connection stopped working. Certs are generally valid for about a year (though some large organizations such as google rotate their certificates about every three months). The kind of automated checks I'm talking about can also easily glean the expiration date from individual certificates so you'd have a heads-up to when you'd need to start looking for a new cert to appear.

Now, remember that currently certificates signed by CAs using sha-1 hashes may be vulnerable to forgery but this is steadily being remedied as CAs are now moving to sha-2 largely with the help of recent nudging from Google :+1:

So In Summary ...

I wouldn't bother trying to curate your own list of individual trusted CAs. You can do it, but in a world with PHP 5.6 I don't see a ton of benefit with this approach. Instead, trust the tools we created to make this exact use case easier to get right :)

If it were me I would use the built-in CA verification tools and retrieve fingerprint hashes for the specific Oauth providers I wanted to connect to and distribute those as part of the application in a config file or something. That gives you a double-barreled verification strategy that's very difficult to circumvent. The real problem here is subsidizing users who insist on using older versions of PHP. Anyone who's serious about the security of their data transfers ought to be using 5.6 at this point. </soapbox>

Addendum

I didn't cover checking for certificate revocation here because I don't believe either curl or PHP offer automatic support at this time. You could pull the location of CRLs and OCSP responders from the certificate resource captured in the stream context and manually verify this at the client-level yourself, though. I'm planning to add support for OCSP-stapling in client streams for PHP7 as well.

SammyK commented 9 years ago

Wow @rdlowrey! Thanks for such an amazing response! :D

I would use the built-in CA verification tools and retrieve fingerprint hashes for the specific Oauth providers

That sounds like an excellent idea and it addresses the original security issue I was trying to point out. I'd like to close this issue and create a new one that suggests we use public key fingerprinting to validate the connection to the provider, but I have some implementation questions.

@ramsey What do you think about what @rdlowrey said here:

These hashes have the same validity period as certificates so you will need to update them any time a certificate changes. Note that this is something that's fairly easy to automate with the excellent tools at your disposal in userland PHP. Also, I suspect your users would start complaining pretty quickly if their connection stopped working.

That sounds like the biggest hurdle to this. We could set up some automation for this to make it easy for provider maintainers to keep the fingerprints up-to-date, but if someone installs the oauth2 package and never updates it, it's guaranteed to stop working for them once the provider's certificate expires (if I understood @rdlowrey correctly).

...but the added security this would give us would be stellar. Dose anyone have any recommendations?

rdlowrey commented 9 years ago

FYI the automation to check cert expiration dates is pretty straightforward:

<?php

$context = stream_context_create(["ssl" => ["capture_peer_cert" => true]]);
file_get_contents("https://www.google.com/", false, $context);
$peerCertResource = stream_context_get_options($context)['ssl']['peer_certificate'];
$parsedCertArrray = openssl_x509_parse($peerCertResource);

date_default_timezone_set('UTC'); // F U Derick
$expiresOn = date('Y-m-d', $parsedCertArray['validTo_time_t']);
$fingerprint = openssl_x509_fingerprint($peerCertResource);

So you'd just need to pull out that expiry timestamp and use it however you like.

SammyK commented 9 years ago

Thanks @rdlowrey!

Haha: date_default_timezone_set('UTC'); // F U Derick ...@derickr gets no love! :)

derickr commented 9 years ago

You're not supposed to set defaults in a script... but rather in php.ini.

rdlowrey commented 9 years ago

I have no doubt that @derickr is a lovely person, I just hate the timezone warning with the burning fire of a thousand Jovian red spots ;)

You're not supposed to set defaults in a script... but rather in php.ini.

Which is my problem with the warning in the first place -- I don't personally like to be forced to use the php.ini (registry anti-pattern, BTW). So when I run without a php.ini (which is always) I'm forced to add the hack in my script.

I understand the reasoning behind the warning, I just disagree with it.

philsturgeon commented 9 years ago

Said with all love for the two of you, this is not the time or the place for this discussion. 

ramsey commented 9 years ago

@SammyK, can you give me the TLDR of this thread? Should it remain open? Is there work to be done?

SammyK commented 9 years ago

TL;DR: This is a really hard problem to solve and would probably get the OAuth Client versioned as vDNF if we tried to figure it out before a 1.0 alpha.

However, I do think we should keep the conversation going on this issue. Possibly even widening the audience since this relates to any TLS connection with a specific known provider. Just not sure where this should be discussed. Ideas anyone?

I believe the main starting point to locking down a provider's connection is to take @rdlowrey's suggestion:

If it were me I would use the built-in CA verification tools and retrieve fingerprint hashes for the specific Oauth providers I wanted to connect to and distribute those as part of the application in a config file or something. That gives you a double-barreled verification strategy that's very difficult to circumvent. The real problem here is subsidizing users who insist on using older versions of PHP. Anyone who's serious about the security of their data transfers ought to be using 5.6 at this point.

Anyone else have thoughts on this? :)

ramsey commented 9 years ago

Thanks. Removing the v1.0 milestone, but I'll keep it open.

shadowhand commented 8 years ago

Personally, I think this kind of implementation would be better served as separate package. One that deals explicitly with the various openssl_* functions for certificate verification and fingerprinting, etc that could be used in conjunction with oauth2-client.

As it stands right now, I don't feel there is a strong argument to have this functionality within the client itself. The vast majority of the PHP ecosystem depends on OS level certificates which are (to my knowledge) secure enough for general purpose use.

enygma commented 8 years ago

@shadowhand Would it make sense to have this kind of handling as a sort of "middware" or plugin that could be used during the connection processes? I could see a more plug-and-play scenario being beneficial. Keeps the validation logic split but makes integration easier. I'm not 100% sure if this would work with the current architecture but just a thought.

ramsey commented 8 years ago

@SammyK @shadowhand @enygma Thoughts on putting peer verification into a separate package that interested providers could require to add the functionality to their implementations?

SammyK commented 8 years ago

I'm certainly open to that idea. This could work assuming we:

Sounds like this could actually be a SaaS. :)

Even if we implement an automated solution like @rdlowrey suggested, we still have the issue of deploying an updated fingerprint to everyone using the package. And this also essentially guarantees some downtime between when a cert is updated and a patch is released and dowloaded. Anyone have any good solution for this? Perhaps a "grace period" based on the cert expiration?

I think Facebook's Graph API cert is renewed every 3 months or so, but we'd need to account for the possibility of the cert being changed for other reasons (e.g. CA change, renew early, etc).