11

I've seen a few similar questions that don't quite seem to address my exact use case, and I THINK I've figured out the answer, but I'm a total noob when it comes to security, RSA, and pretty much everything associated with it. I have a basic familiarity with the concepts, but all of the actual implementations I've done up to this point were all about editing someone else's code rather than generating my own. Anyway, here's where I am:

I know that Javascript is an inherently bad place to do encryption. Someone could Man-in-the-Middle your response and mangle the JS so you'll end up sending unencrypted data over the wire. It SHOULD be done via an HTTPS SSL/TLS connection, but that kind of hosting costs money and so do the official signed certificates that should realistically go with the connection.

That being said, I think the way I'm going to do this circumvents the Man-in-the-Middle weakness of JS encryption by virtue of the fact that I'm only ever encrypting one thing (a password hash) for one RESTful service call and then only using that password hash to sign requests from the client in order to authenticate them as coming from the user the requests claim. This means the JS is only responsible for encrypting a password hash once at user account creation and if the server cannot decode that cipher then it knows it's been had.

I'm also going to save some client information, in particular the $_SERVER['REMOTE_ADDR'] to guarantee that someone doesn't M-i-t-M the registration exchange itself.

I'm using PHP's openssl_pkey_ functions to generate an asymmetric key, and the Cryptico library on the client side. My plan is for the user to send a "pre-registration" request to the REST service, which will cause the server to generate a key, store the private key and the client information in a database indexed by the email address, and then respond with the public key.

The client would then encrypt the user's password hash using the public key and send it to the REST service as another request type to complete the registration. The server would decrypt and save the password hash, invalidate the client information and the private key so no further registrations could be conducted using that information, and then respond with a 200 status code.

To login, a user would type in their email address and password, the password would be hashed as during registration, appended to the a request body, and hashed again to sign a request to a login endpoint which would try to append the stored hash to the request body and hash it to validate the signature against the one in the request and so authenticate the user. Further data requests to the service would follow the same authentication process.

Am I missing any glaring holes? Is is possible to spoof the $_SERVER['REMOTE_ADDR'] value to something specific? I don't need the IP address to be accurate or the same as when the user logs in, I just need to know that the same machine that 'pre-registered' and got a public key followed up and completed the registration instead of a hijacker completing the registration for them using a snooped public key. Of course, I guess if they can do that, they've hijacked the account beyond recovery at creation and the legitimate user wouldn't be able to complete the registration with their own password, which is ok too.

Bottom line, can someone still hack my service unless I fork out for a real SSL host? Did I skirt around Javascript's weaknesses as an encryption tool?


As I write and debug my code, I'll post it here if anyone wants to use it. Please let me know if I'm leaving my site open to any kind of attacks.

These are the functions that validate client requests against the hash in the headers, generate the private key, save it to the database, respond with the public key, and decrypt and check the password hash.

        public function validate($requestBody = '',$signature = '',$url = '',$timestamp = '') {
            if (is_array($requestBody)) {
                if (empty($requestBody['signature'])) { return false; }
                if (empty($requestBody['timestamp'])) { return false; }
                if ($requestBody['requestBody'] === null) { return false; }

                $signature = $requestBody['signature'];
                $timestamp = $requestBody['timestamp'];
                $requestBody = $requestBody['requestBody'];
            }

            if (($requestBody === null) || empty($signature) || empty($timestamp)) { return false; }

            $user = $this->get();

            if (count($user) !== 1 || empty($user)) { return false; }
            $user = $user[0];

            if ($signature !== md5("{$user['pwHash']}:{$this->primaryKey}:$requestBody:$url:$timestamp")) { return false; }

            User::$isAuthenticated = $this->primaryKey;
            return $requestBody;
        }

        public function register($emailAddress = '',$cipher = '') {
            if (is_array($emailAddress)) {
                if (empty($emailAddress['cipher'])) { return false; }
                if (empty($emailAddress['email'])) { return false; }

                $cipher = $emailAddress['cipher'];
                $emailAddress = $emailAddress['email'];
            }

            if (empty($emailAddress) || empty($cipher)) { return false; }

            $this->primaryKey = $emailAddress;
            $user = $this->get();

            if (count($user) !== 1 || empty($user)) { return false; }
            $user = $user[0];

            if (!openssl_private_decrypt(base64_decode($cipher),$user['pwHash'],$user['privateKey'])) { return false; }
            if (md5($user['pwHash'].":/api/preRegister") !== $user['session']) { return false; }

            $user['session'] = 0;
            if ($this->put($user) !== 1) { return false; }

            $this->primaryKey = $emailAddress;
            User::$isAuthenticated = $this->primaryKey;
            return $this->getProfile();
        }

        public function preRegister($emailAddress = '',$signature = '') {
            if (is_array($emailAddress)) {
                if (empty($emailAddress['signature'])) { return false; }
                if (empty($emailAddress['email'])) { return false; }

                $signature = $emailAddress['signature'];
                $emailAddress = $emailAddress['email'];
            }

            if (empty($emailAddress) || empty($signature)) { return false; }

            $this->primaryKey = $emailAddress;

            $response = $this->makeUserKey($signature);
            if (empty($response)) { return false; }

            $response['emailAddress'] = $emailAddress;
            return $response;
        }

        private function makeUserKey($signature = '') {
            if (empty($signature)) { return false; }

            $config = array();
            $config['digest_alg'] = 'sha256';
            $config['private_key_bits'] = 1024;
            $config['private_key_type'] = OPENSSL_KEYTYPE_RSA;

            $key = openssl_pkey_new($config);
            if (!openssl_pkey_export($key,$privateKey)) { return false; }
            if (!$keyDetails = openssl_pkey_get_details($key)) { return false; }

            $keyData = array();
            $keyData['publicKey'] = $keyDetails['key'];
            $keyData['privateKey'] = $privateKey;
            $keyData['session'] = $signature;

            if (!$this->post($keyData)) { return false; }

            $publicKey = openssl_get_publickey($keyData['publicKey']);
            $publicKeyHash = md5($keyData['publicKey']);

            if (!openssl_sign($publicKeyHash,$signedKey,$privateKey)) { return false; }
            if (openssl_verify($publicKeyHash,$signedKey,$publicKey) !== 1) { return false; }

            $keyData['signedKey'] = base64_encode($signedKey);
            $keyData['rsa'] = base64_encode($keyDetails['rsa']['n']).'|'.bin2hex($keyDetails['rsa']['e']);
            unset($keyData['privateKey']);
            unset($keyData['session']);

            return $keyData;
        }
citizenslave
  • 1,408
  • 13
  • 25
  • I just saw [this one](http://stackoverflow.com/questions/5724650/ssl-alternative-encrypt-password-with-javascript-submit-to-php-to-decrypt?rq=1) in my related feed, and the difference here is that the request with an encrypted password can only be sent once per user, just so the server knows what value is being used to salt future requests' hashes. If someone managed to spoof their IP to match the real user, they'd be able to claim the username, but never hijack an account that's in use after registration has completed...I think... – citizenslave Feb 26 '14 at 10:21
  • 2
    I suppose the two aspects I'm uncomfortable with is; 1. No, we can't trust $_SERVER['REMOTE_ADDR'], in a reverse firewall at a company all users could have the same IP (outwardly) depending on its configuration. 2. In a non-SSL scenario you can't trust that that the wire isn't being listened to (particularly now we know an 'attack' could be someone within their firewalled network). That's not to say I've got the complete attack scenario yet. – Oli B Feb 26 '14 at 10:28
  • Thanks for the quick response. Shared IPs on a private network are a legit hole in using `$_SERVER['REMOTE_ADDR']`, but it still seems to me that the only attack would be to snoop the public key out of the pre-registration response, use it to encrypt your own password instead of the user's, and spoof the final registration request. Then you'd have control of the account, but the user never would have. I'm not thrilled leaving that avenue open but I can live with it, especially if the only way to fake the remote address is to be on the same private network. Not perfect, but acceptable. – citizenslave Feb 26 '14 at 14:08
  • Even if the attacker did pull all of that off, the account would be of limited use as they would not be able to verify their email address for higher privilege levels. I can definitely live with that small of a hole in the security for those particular users, as long as that's the only gap. – citizenslave Feb 26 '14 at 14:11
  • 4
    Ultimately everything is in the plain, so whatever it is you're trying to protect is 'visible' on the network. A rogue user on their network could simply listen to/repeat the same requests the legit user made and pass the same hashed values - or have I missed a step? – Oli B Feb 26 '14 at 14:41
  • That's a legitimate gap too, but I think I can solve that one by hashing in the UTC timestamp in ms and then including it in the authenticated requests' headers as a request counter that I save to the user table. Old requests being replayed will have the same or lower request counter, whereas legitimate requests from the client will use a higher counter with a regenerated hash. That solve it? – citizenslave Feb 26 '14 at 16:32
  • I really appreciate you working through this with me. It's for a personal project so I wouldn't feel right bugging my coworkers about it. – citizenslave Feb 26 '14 at 16:33
  • 1
    Timestamp counter is simple and helps. The user has to authenticate in every request? do they type their username/password or does the browser remember it in local storage perhaps? Assuming you've got the hashing right; a hacker couldn't perform any requests they wanted yet could still 'see' everything, depends on the application how useful that'd be, although once you've established trust and a shared 'key' you could start encrypting content. Hmm.. nice project. – Oli B Feb 26 '14 at 17:16
  • Yeah, at this point I'm less worried about them reading the content. As long as I'm using Javascript to do the encoding, I'm pretty sure a M-i-t-M could mangle my JS so that it wouldn't encrypt the requests anyway, even if the server ignored the unencrypted request, the content is already compromised. My client is a SPA, so I can easily save the hashed password "key" on the client side after they've logged in. That way, even though the requests are public, the server can KNOW they came from an authenticated user and have not been tampered with in transit. – citizenslave Feb 26 '14 at 17:40
  • I think that hole in the `$_SERVER['REMOTE_ADDR']` value puts any effort to reset the password at risk. I'm pretty sure I have to essentially rerun the registration flow to reset a password, and hijacking the process between requests at this point would grant the hacker control of a valid account that has been used. – citizenslave Feb 26 '14 at 18:04
  • TLDR; - what is the question? Can you please bold it? – jww Feb 26 '14 at 19:55
  • "Can you hack my website?" If I end up finishing this project before anyone pokes enough holes in my security that I start over, I'm going to post my code here for posterity too. – citizenslave Feb 26 '14 at 20:38
  • 4
    Why not just create a self-signed certificate and do it at the http level? Then just program your client to accept your certificate, even if it isn't signed by a CA. – Matthew Herbst Feb 26 '14 at 21:57
  • A MitM could intercept the response that loads the JS client and rewrite it such that it doesn't encrypt the requests at all. If they try to do that for this one request, the account registration will fail. – citizenslave Feb 26 '14 at 22:02
  • 2
    A MitM could act as a proxy for all requests, telling the client not to hash whilst passing the hashed value to the server - so account registration succeeds. No-one would know it had been compromised. As it is I think we 'trust' the client but the client can't trust the server is real. If we keep going we'll re-invent SSL. In JavaScript. – Oli B Feb 27 '14 at 07:59
  • So, the attacker proxies the client, replacing it with one that doesn't encrypt the pw hash, intercepts the unencrypted hash in the registration request, encrypts the hash itself using the public key, relays the request to the server, account creation succeeds, password hash is compromised and the attacker can sign their own requests as if they were the user. I guess that's checkmate. I can't think of a way around that that isn't susceptible to the same kind of attack. Well done. Thanks. – citizenslave Feb 27 '14 at 14:05
  • Oli B, if you want to post that as an answer, I'll give you the rep for it. – citizenslave Feb 27 '14 at 15:57
  • Rather than using the `$_SERVER['REMOTE_ADDR']` property, I'm using the password hash as a salt to hash a constant value known to the client and server as a session variable between the pre-registration and registration requests. This prevents new accounts from being hijacked between the requests. The weakness to a MitM proxying the client and intercepting unencrypted pw hashes still exists, but I'm just going to move forward anyway on the assumption that I'll be able to afford a legit SSL connection before it's a problem. – citizenslave Mar 01 '14 at 21:46
  • I know you've specifically asked for a way to do this in JS. What I don't know/have missed is why you're not using PHP (or another server-side language) and curl instead. Surely that would be a better approach? – Agi Hammerthief Mar 04 '14 at 11:42
  • I am using PHP, but I'm not sure I understand the approach you're suggesting. The issue I can expect right off the bat is that if I have to get a user's password or a hash of it up from the client without compromising it, it has to be encrypted on the client side. – citizenslave Mar 04 '14 at 15:48
  • 1
    @citizenslave the problem with doing it in JS is that the JS has to come from somewhere too, and there's no way for the client or server to ensure that the JS hasn't been tampered with along the way. – Nico Mar 10 '14 at 01:11

2 Answers2

4

What you are trying to do is to replace the need for SSL certificates signed by a Certificate Authority with custom JavaScript. I'm not a security expert, but as far as I know the simple answer is that this is not possible.

The basic fact is that on the public internet, the server can't trust what a client says, and a client can't trust what the server says, exactly because of man in the middle attacks. The reason why certificate authorities are necessary to begin with is to establish some kind of impartial trust base. CA's are carefully vetted by the browser vendors, and it's the only trust currently available on the public internet, although it's certainly not perfect.

Nico
  • 2,645
  • 19
  • 25
-1

I am curious to know why a relatively inexpensive SSL certificate (like the 1-year from Digicert at $175 USD) is out of the question. Especially if this is for a business, $175/yr is a reasonable expense (it works out to about $12.60 USD/month).

Mark Leighton Fisher
  • 5,609
  • 2
  • 18
  • 29
  • It is not for a business, I'm writing it for a broke-ass political party. It's volunteer work for a non-profit, and a learning experience for me, frankly. I concede that an SSL cert is preferable, but $175/yr buys us a lot of literature and booth space. We're also not really doing anything that REQUIRES security, like eCommerce or plotting the overthrow of Miley Cyrus, so it's more a question of discouraging malicious script kiddies than stopping determined cyber criminals, and the budget is beyond tight. – citizenslave Mar 08 '14 at 03:51
  • Also, I'm pretty sure the flow I've designed here is only vulnerable at account registration to a fairly specific type of attack, and most of the features that security would be even close to truly important for will be protected behind additional forms of authentication such as email and person-to-person code verifications such that those features will compensate even for the registration vulnerability. Without spending a dime extra, much less $12.60/mo. Which is a domain name to point at a free Blogger website for a candidate, if we're talking opportunity costs. – citizenslave Mar 08 '14 at 03:54
  • This doesn't answer the quesiton, should be in a comment – Áxel Costas Pena Jul 01 '14 at 18:17