Why do we hash user passwords in our databases? So that if bad actors somehow gain access to our database, they will have a hard time stealing our secrets. But suppose we need to securely store other sensitive data in our database? Encrypt it. But how do we go about that? Assymetric, public/private key encryption with PGP would undoubtedly be the best way to go, but in the case at hand, it’s too impractical. Faced with this situation, I reasoned that symmetrical encryption was the next best option: use one key for both encryption and decryption. But how to store the encryption key securely? As a plain text file sitting on the same server as the database username and password? Which, by the way, are also sitting there in plain text, if not in plain sight?
Disclosures, Disclaimers, Excuses
Now that I have your attention, a quick digression. I am not a security expert — I just try to pay attention to those who are. So please, use your own judgment at your own risk yadda yadda yadda. Moreover, I’m not even officially a web application developer. My job title has nothing to do with that. Long story short: I stumbled into coding some 20 years ago because in my workplace we could find no software, commerical or otherwise, that met our needs in managing a busy federal court interpreters office. So we (OK, I) rolled our own, and have been rolling ever since. Now I’m working on the next iteration of our great project, using Zend Framework 3 and Doctrine, and that’s the context in which this security problem arises.
Wandering back to our main topic: we need to store some sensitive data of contract court interpreters in a database, we want to encrypt it symmetrically, and we need to keep the secret encryption key secret.
Hashicorp Vault to the rescue
The very clever people at Hashicorp generously provide, among other things, a secret-management tool aptly named Vault. As their website puts it,
Vault is a tool for securely accessing secrets. A secret is anything that you want to tightly control access to, such as API keys, passwords, certificates, and more. Vault provides a unified interface to any secret, while providing tight access control and recording a detailed audit log.
The use of Vault is a complex subject, and the following discussion is no substitute for the documentation. We should just point out that secrets (yes, that’s the technical term) can be stored in several different types of storage backends, and authentication can happen by way of a number of different authentication backends. These things are identified and accessed by way of paths similar to a filesystem. And that unified interface mentioned above is an HTTP API. You talk to Vault by sending HTTP requests with an authentication token in your request headers, and get JSON responses. Of course, this makes it a snap for applications to communicate with Vault with the language of your choosing.
The challenging part is devising a good security model, and there is a vast — if not to say baffling — array of choices and decisions to be made. I’m still not convinced my current solution is the best, so it’s subject to change. But let’s step through it as it is at the moment. Of course, you’ll first need to install and configure and run Vault, and they make that pretty damn easy. (Though you do have to decide what machine(s) to put it on. There’s replication and high availability and all that sexy stuff for large-scale deployments. Ours happens to be a modest configuration, involving a small set of users behind our organization’s firewall.)
Creating Policies
In Vault, you create authentication tokens that have policies attached to them, determining what the bearer of the token is allowed to do. I want our authenticated application not to be authorized to read our ultimate secret, but to grant an auth token bound to a policy that does allow it. So we need a policy for reading the secret. We also need a policy for creating the token that’s attached to the policy for reading the secret. Sound complicated? Don’t worry: it is! But the underlying principle is a basic one: grant access that is as permissive as necessary, and no more. So I took out my quill and wrote policies, thus:
read-secret.hcl
path "secret/data" {
policy = "read"
}
create-token.hcl
path "auth/token/create/read-secret" {
policy = "write"
}
Incidentally, that .hcl extension you see refers to Hashicorp Configuration Language. It’s a lot like JSON and you’ll pick it up immediately. Then, after logging into Vault with sufficient privileges, we wrote our policies to Vault via its CLI:
vault policy-write read-secret read-secret.hcl
and
vault policy-write create-token create-token.hcl
and finally, the rather more esoteric one:
vault write auth/token/roles/read-secret allowed_policies=read-secret
which magically allows the “role” to bestow access to something that the role itself cannot access.
Authenticating the application
As implied above, I decided Step One should be that not the user but the application authenticates itself against Vault using the TLS backend. To that end, I created my certificates (thank you, https://jamielinux.com/docs/openssl-certificate-authority/ for help with that), stored them in Vault,
vault write auth/cert/certs/web display_name=web policies=create-token \
certificate=@/path/to/your/cert.pem
and tested it out with the Vault cli,
vault auth -method=cert -client-cert=/path/to/your/cert.pem \
-client-key=/path/to/your/key.pem
then with the HTTP API using our good friend curl
. By the way, before thinking too much about integration with ZF, I followed the same procedure with all this token stuff until I was able to go from zero to reading the secret (encryption key) from the command line. I predicted (correctly!) that the heavy lifing was setting up the Vault stuff, and getting it to play well with ZF would be more like fun than work.
Getting a token for getting the secret
After the previous command, now that we’re authenticated via TLS, we can go
vault token-create -role=read-secret
and get a response something like
Key Value
--- -----
token ef2fb0d1-1644-937f-5326-3c6270abc3ba
token_accessor 522c0a9d-7897-a670-e511-650d37ea6d20
token_duration 768h0m0s
token_renewable true
token_policies [default read-cipher]
Getting the secret
And finally, authenticate with the token we just got:
vault auth ef2fb0d1-1644-937f-5326-3c6270abc3ba
and go for the gold
vault read secret/data
resulting in
Key Value
--- -----
refresh_interval 768h0m0s
cipher b862600aaeba7c4ccd74006d2e616083ffb7031a3b088e743080bcf32e90f3b4
and that “cipher” you see is the encryption key for encrypting/decrypting our sensitive database fields, a step we will perform in our PHP application.
In all honesty, it’s a bit more complicated than this because we are going to be using response wrapping. When a client sends the header so indicating, the response is not the thing requested, but another auth token with which you can get the thing requested. And that token is a single-use token: use it once and then it’s revoked. You can also limit the time-to-live on this token, so that whether it gets used or expires, it is not going to be hanging around for long as a valid token. This exemplifies the Vault (and general security) principle of limiting the exposure of a secret.
Plugging it into ZF
The front end
At last, the fun part. (Not to say that Vault isn’t loads of fun — it absolutely is! But after dealing and struggling with something unfamiliar, it’s comforting like a warm bath to return to the familiar, isn’t it?) I decided that this application would have its Vault capabilities in a separate module to make it easy to enable or disable. If it’s disabled, they simply don’t get to work with sensitive data. Let’s work from the front end to the back. The form has a good number of fields, but the ones we’re concerned with look like this:
because there’s no reason to expose this data if they don’t ask. The encrypted values are tucked away as hidden fields. When they click those lock thingies, we display a modal dialog inviting the already-authenticated user to re-authenticate (in case they load the form, wander off to the bathroom, and just then an identity thief comes up to their workstation). A couple of Javascript xhr calls later, if all goes well, the dialog goes away and these fields are re-populated with the decrypted values.
The Controller
And the above-mentioned xhr sends a POST request containing the encrypted values to /vault/decrypt
, a route mapped to this action method in our module/Vault/src/Controller/VaultController.php
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | public function decryptAction() { $params = $this->params()->fromPost(); try { $key = $this->vaultService->getEncryptionKey(); $cipher = new BlockCipher(new Openssl()); $cipher->setKey($key); $decrypted = [ 'ssn' => $cipher->decrypt($params['ssn']), 'dob' => $cipher->decrypt($params['dob']) ]; return new JsonModel($decrypted) ; } catch (VaultException $e) { return new JsonModel(['error'=>$e->getMessage()]); } } |
The part involving Vault gets our encryption key, and it’s a one-liner. The next bit simply makes use of zend-crypt to take care of decryption.
You notice $this->vaultService
. That’s a dependency injected via the constructor, so there’s a factory which pulls it from the container. Boring, so we’ll skip that part. Let’s have a look at the module’s module.config.php
(ooh, exciting!)
Configuration
<?php namespace SDNY\Vault; use Zend\Router\Http\Literal; return [ 'vault' => [ // override these with a local configuration 'vault_address' => 'https://vault.example.org:8200', 'sslcafile' => '/usr/share/ca-certificates/ca-chain.cert.pem', // these settings must match the configuration set in Vault 'ssl_key' => '/path/to/your-private-key.key.pem', 'ssl_cert' => '/path/to/your-cert.pem', 'path_to_secret' => '/path/to/your/secret', // including leading slash // but do not change this adapter 'adapter' => 'Zend\Http\Client\Adapter\Curl', ], 'service_manager' => [ 'factories' => [ Service\Vault::class => Service\Factory\VaultServiceFactory::class, ] ], 'controllers' => [ 'factories' => [ Controller\VaultController::class => Controller\Factory\VaultControllerFactory::class, ] ], ]; |
So the module’s config sets some default/dummy values, with the expectation that they will be overridden by a local configuration file called something like config/autoload/vault.local.php
, consistent with the ZF convention. No reason to put this in a public repository.
The Vault service
We have a VaultServiceFactory
that injects all that config for us when it instantiates our VaultService
, which is what does the talking to Vault.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 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 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 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 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 | <?php /** * module/Vault/src/Service/Vault.php */ namespace SDNY\Vault\Service; use Zend\Http\Client; /** * Extension of Zend\Http\Client for communciating with Hashicorp Vault * * The purpose is enable us to store sensitive data in MySQL using symmetrical * encryption while avoiding having to store the encryption key in plain text * anywhere at any time. All the configuration has to be correctly set before * instantiation. Error-checking is left up to the consumer. * */ use Zend\EventManager\EventManagerAwareInterface; use Zend\EventManager\EventManagerAwareTrait; class Vault extends Client implements EventManagerAwareInterface { use EventManagerAwareTrait; protected $events; /** * mapping of string keys to CURL integer constants * * we need this because if a config array key is an integer * unfortunate things happen when the framework merges the configs * * @var array */ private static $curlopt_keys = [ 'ssl_key' => \CURLOPT_SSLKEY, 'ssl_cert'=> \CURLOPT_SSLCERT, ]; /** * vault authentication token * * @var string */ private $token; // some more instance variables omitted for brevity /** * constructor * * @param array $config */ public function __construct(Array $config) { $this->vault_address = $config['vault_address'] . $this->prefix; $this->path_to_secret = isset($config['path_to_secret']) ? $config['path_to_secret'] : null; $curloptions = []; foreach ($config as $key => $value) { if (key_exists($key, self::$curlopt_keys)) { $curloptions[self::$curlopt_keys[$key]] = $value; } } $config['curloptions'] = $curloptions; parent::__construct(null, $config); $this->getRequest() ->getHeaders() ->addHeaderLine('Accept: application/json'); } // some setters/getters omitted for brevity /** * checks response for errors * @param Array $response * @return boolean true if error */ public function isError(Array $response) { return key_exists('errors',$response); } /** * resets request, response, etc, and restores * request header for JSON responses * * @return \SDNY\Vault\Service\Vault */ public function reset() { parent::reset(); $this->getRequest() ->getHeaders() ->addHeaderLine('Accept: application/json'); return $this; } /** * attempts Vault TLS authentication * * this will attempt to authenticate using TLS certificates, which have to * have been installed and set in our configuration up front. * * @link https://www.vaultproject.io/docs/auth/cert.html * * @return Vault * @throws VaultException */ public function authenticateTLSCert($options = []) { $this->setMethod('POST') ->setUri($this->vault_address .'/auth/cert/login') ->send(); $response = $this->responseToArray($this->getResponse()->getBody()); if ($this->isError($response)) { $this->getEventManager()->trigger(__FUNCTION__, $this, []); throw new VaultException($response['errors'][0]); } $this->token = $response['auth']['client_token']; return $this; } /** * Attempts to acquire access token that is authorized to read the cipher * we use for symmetrical encryption/decryption of sensitive Interpreter * data. * * @return Vault * @throws VaultException */ public function requestCipherAccessToken() { $this->getRequest()->getHeaders() ->addHeaderLine("X-Vault-Token:$this->token") ->addHeaderLine("X-Vault-Wrap-TTL: 10s"); $endpoint = $this->vault_address . '/auth/token/create/read-cipher'; $this->getRequest()->setContent(json_encode( [ // maybe reconsider these settings 'ttl' => '5m', 'num_uses' => 3, ] )); $this->setMethod('POST')->setUri($endpoint)->send(); $response = $this->responseToArray($this->getResponse()->getBody()); if ($this->isError($response)) { $this->getEventManager()->trigger(__FUNCTION__, $this, [ 'message' => 'failed to get token for cipher access' ]); throw new VaultException($response['errors'][0]); } $this->token = $response['wrap_info']['token']; return $this; } /** * unwraps a wrapped response and returns it as an array * * @param string $token * @return array */ public function unwrap() { $this->reset(); $endpoint = $this->vault_address . '/sys/wrapping/unwrap'; $this->setAuthToken($this->token); $this->setMethod('POST')->setUri($endpoint)->send(); $response = $this->responseToArray($this->getResponse()->getBody()); if ($this->isError($response)) { $this->getEventManager()->trigger(__FUNCTION__, $this, [ 'message' => 'failed to unwrap response' ]); throw new VaultException($response['errors'][0]); } if (isset($response['auth'])) { $this->setAuthToken($response['auth']['client_token']); } return $response; } /** * requests response-wrapped encryption key * * @param string $token authentication token * @return Vault * @throws VaultException */ public function requestWrappedEncryptionKey() { if (! $this->path_to_secret) { throw new VaultException('path to secret has to be set before calling '.__FUNCTION__); } $endpoint = $this->vault_address . $this->path_to_secret; $this->getRequest()->getHeaders()->addHeaderLine("X-Vault-Wrap-TTL: 10s"); $this->setMethod('GET')->setUri($endpoint)->send(); $response = $this->responseToArray($this->getResponse()->getBody()); if ($this->isError($response)) { $this->getEventManager()->trigger(__FUNCTION__, $this, [ 'message' => 'failed to get wrapped encryption-key response' ]); throw new VaultException($response['errors'][0]); } $this->setAuthToken($response['wrap_info']['token']); return $this; } /** * gets encryption key. * * convenience method that wraps the several * steps into one. * * @param string $token authentication token * @return string * @throws VaultException */ public function getEncryptionKey() { $this->authenticateTLSCert() ->requestCipherAccessToken() ->unwrap(); $this->requestWrappedEncryptionKey(); $key = $this->unwrap()['data']['cipher']; return $key; } /** * sets Vault authentication token header * and instance variable * * @param string $token * @return \SDNY\Service\Vault */ public function setAuthToken($token) { $this->getRequest() ->getHeaders() ->addHeaderLine("X-Vault-Token:$token"); $this->token = $token; return $this; } /** * converts json to array * * @param string $json * @return Array */ public function responseToArray($json) { return json_decode($json,true); } /** * attempts user/password authentication * * this will attempt to authenticate user against Vault's * userpass auth backend. NOTE: looks like we won't be using this auth * method after all, so this method is not currently used. * @link https://www.vaultproject.io/docs/auth/userpass.html * * @param string $user * @param string $password * @return array Vault response as array */ public function authenticateUser($user,$password) { $uri = $this->vault_address . "/auth/userpass/login/$user"; $this->getRequest()->setContent(json_encode(['password'=>$password])); $this->setUri($uri)->setMethod('POST')->send(); return $this->responseToArray($this->getResponse()->getBody()); } } |
This Vault service extends Zend\Http\Client
because it basically is a specialized http client. The general pattern is that most of these methods attempt to get the token we need, store it in $this->token
, and return $this
so the lazy coder using the class can save some keystrokes.
We make use of the handy Zend\EventManager\EventManagerAwareInterface
and Zend\EventManager\EventManagerAwareTrait
and do a fair amount of $this->getEventManager()->trigger()
. It’s most definitely a work in progress, but by the time this project is done (or pretty close) there are going to be event listeners responsible for logging things and generally paying attention. On the Vault side, I plan to (figure out a way to) monitor the audit log with the ultimate goal of trying to detect a possible breach and hit the panic button.
So we’ve demonstrated decryption, how about encryption? Coming soon! But as you can see, it will be straightforward.
Further thoughts on Vault and PHP Applications
Remember we started out with a reference to the practice of storing database credentials in plain text? Vault provides a database secret backend so you can avoid doing that. Vault also has a very straightforward user/password authentication backend that makes it tempting to consider implementing a Zend\Authentication\Adapter\AdapterInterface
that uses it.
All of this is to say that in terms of security, I think Vault offers us ways to take Zend Framework applications, and PHP applications generally, to the next level.
Conclusion
As noted at the beginning, this solution to my challenge of keeping our encryption key secure may not be the best possible. But it’s a start. The first objective was to avoid leaving our secret lying around in plain text, and this accomplishes that. My holy grail is to make it so that even in a worst case scenario, where an attacker gains root access to the machine where this application is installed, my precious social security numbers and dates of birth would still not be compromised. Like anything, this model is not guaranteed bullet-proof against a breach that severe, but again, it’s a start.
Hopefully this demonstrates that once you have Vault going, it’s quite easy to integrate with your ZF application. As a personal note, I would add that this is actually my debut blogging about Zend Framework, and I do feel a little self-conscious about presuming to address an audience with so many badasses in it. Stage fright notwithstanding, I am truly interested in hearing your comments, so please feel free.
References