Introduction
Making the change to proper secrets management in your software can be a daunting task. The associated lift can be enough to make a team postpone the choice indefinitely. This post provides some design ideas that should help ease the burden. We will set the following goals:
- Seamlessly swap out real secret values with references to secrets in another system
- Allow real secrets to continue to be used in the case of secrets management system failure
Having the ability to “break glass in case of emergency” should be considered a design requirement. It’s something that should only be used under dire circumstances, but it’s always important to remember that availability rests at the core of security. The examples in this post can be referenced in their entirety at https://github.com/abedra/secrets_injection_example.
Really, C++?
The language in this example is mostly irrelevant. The following ideas work in any language with a JSON library and a PostgreSQL driver. C++ is used here because I am the author of libvault and typically run through these ideas using this library to ensure it works as intended.
Our Program
We have a small program. It connects to a PostgreSQL database and checks to verify the connection is open. For this example, that’s all it needs to do since we’re focusing on the secrets required to make the connection.
int main(void) {
std::filesystem::path configPath{"config.json"};
try {
DatabaseConfig databaseConfig = getDatabaseConfiguration(configPath);
pqxx::connection databaseConnection{databaseConfig.connectionString()};
if (databaseConnection.is_open()) {
std::cout << "Connected" << std::endl;
} else {
std::cout << "Could not connect" << std::endl;
}
} catch(const std::exception &e) {
std::cout << e.what() << std::endl;
}
}
More could be done here to isolate the different types of exceptions and pull them out of main
, but this is done for the sake of brevity.
Loading Configuration
The program loads a file by the name of config.json
. We can assume it contains all of the relevant information required to make our database connection.
{
"database": {
"host": "dynamic-secrets-postgres",
"port": 5432,
"database": "postgres",
"username": "postgres",
"password": "postgres"
}
}
This example will use nlohmann’s json library to deserialize the configuration. Let’s start with our basic type:
struct DatabaseConfig {
int port;
std::string host;
std::string database;
std::string username;
std::string password;
std::string connectionString() {
std::stringstream ss;
ss << "host=" << host << " "
<< "port=" << port << " "
<< "user=" << username << " "
<< "password=" << password << " "
<< "dbname=" << database;
return ss.str();
}
};
Once constructed, this will furnish a connection string usable by the libpqxx
driver. To construct this we need to load our configuration file and deserialize it:
void from_json(const nlohmann::json &j, DatabaseConfig &databaseConfig) {
j.at("port").get_to(databaseConfig.port);
j.at("host").get_to(databaseConfig.host);
j.at("database").get_to(databaseConfig.database);
j.at("username").get_to(databaseConfig.username);
j.at("password").get_to(databaseConfig.password);
}
DatabaseConfig getDatabaseConfiguration(const std::filesystem::path &path) {
std::ifstream inputStream(path.generic_string());
std::string raw(std::istreambuf_iterator<char>{inputStream}, {});
return nlohmann::json::parse(raw)["database"];
}
This simple custom deserializer is a quick and easy way to get there. We have now hit the point that secrets have entered our configuration. Not everything here should be considered a secret, but we should at the very least conceal the username and password.
Introducing Vault
HashiCorp Vault is a tool for secrets management. While we are using it for our example, this post will skip an in depth explanation. More can be found on HashiCorp’s Product Page. In order to use Vault we need to set it up and add our secrets. We can use a single script to get the vault binary and provide a ready to use environment
#!/usr/bin/env bash
set -e
VAULT_VERSION=1.6.1
if [[ ! -f "bin/vault" ]]; then
mkdir -p bin
pushd bin
curl -O -L https://releases.hashicorp.com/vault/$VAULT_VERSION/vault_"$VAULT_VERSION"_linux_amd64.zip
unzip vault_"$VAULT_VERSION"_linux_amd64.zip
rm vault_"$VAULT_VERSION"_linux_amd64.zip
popd
fi
export VAULT_ADDR=http://127.0.0.1:8200
VAULT=bin/vault
$VAULT login
$VAULT policy write example vault/example.hcl
$VAULT auth enable approle
$VAULT write auth/approle/role/client policies="example"
ROLE_ID=$($VAULT read auth/approle/role/client/role-id | grep role_id | awk '{print $2}')
SECRET_ID=$($VAULT write -f auth/approle/role/client/secret-id | grep -m1 secret_id | awk '{print $2}')
$VAULT kv put secret/database vault:dbuser=postgres vault:dbpass=postgres
rm -f .env
echo "APPROLE_ROLE_ID=$ROLE_ID" >> .env
echo "APPROLE_SECRET_ID=$SECRET_ID" >> .env
This will leave us with a .env
file that we can use in our program to provide the Vault authentication credentials
Adding Vault to our Program
In order lookup our secrets, we need a way to interface with Vault. We will do this using libvault. First, let’s get an instance of Vault::Client
Vault::Client getVaultClient() {
char *roleId = std::getenv("APPROLE_ROLE_ID");
char *secretId = std::getenv("APPROLE_SECRET_ID");
if (!roleId && !secretId) {
std::cout << "APPROLE_ROLE_ID and APPROLE_SECRET_ID environment variables must be set" << std::endl;
exit(-1);
}
Vault::AppRoleStrategy appRoleStrategy{Vault::RoleId{roleId}, Vault::SecretId{secretId}};
Vault::Config config = Vault::ConfigBuilder()
.withHost(Vault::Host{"dynamic-secrets-vault"})
.withTlsEnabled(false)
.build();
return Vault::Client{config, appRoleStrategy};
}
This will pickup the APPROLE_ROLE_ID
and APPROLE_SECRET_ID
environment variables and use them to authenticate to Vault. These are automatically passed in the example using the --env-file
argument to Docker. The secret bootstrapping problem is something that deserves its own detailed discussion.
Introducing Secret References
Now that we have the ability to communicate with Vault, we need to modify our configuration to reference the location in of the secret we wish to consume.
{
"database": {
"host": "dynamic-secrets-postgres",
"port": 5432,
"database": "postgres",
"username": "vault:dbuser",
"password": "vault:dbpass"
}
}
We have swapped out our real secrets with the key used in Vault that identifies the secret. Let’s add a simple replacement mechanism into DatabaseConfig
:
DatabaseConfig withSecrets(const Vault::Client &vaultClient) {
Vault::KeyValue kv{vaultClient};
auto databaseSecrets = kv.read(Vault::Path{"database"});
if (databaseSecrets) {
std::unordered_map<std::string, std::string> secrets =
nlohmann::json::parse(databaseSecrets.value())["data"]["data"];
auto maybeUsername = secrets.find(this->username);
auto maybePassword = secrets.find(this->password);
this->username = maybeUsername == secrets.end()
? this->username
: maybeUsername->second;
this->password = maybePassword == secrets.end()
? this->password
: maybePassword->second;
return *this;
} else {
return *this;
}
}
This will furnish an updated DatabaseConfig
. If there was a value provided by vault, it will be used. If none was provided, it will continue using what was provided in the configuration. This ensures that the program will be able to move on and off of Vault with no program changes in the event of a secrets engine failure.
Putting it All Together
Finally, let’s update main
to account for our changes:
int main(void) {
std::filesystem::path configPath{"config.json"};
Vault::Client vaultClient = getVaultClient();
if (vaultClient.is_authenticated()) {
try {
DatabaseConfig databaseConfig =
getDatabaseConfiguration(configPath).withSecrets(vaultClient);
pqxx::connection databaseConnection{databaseConfig.connectionString()};
if (databaseConnection.is_open()) {
std::cout << "Connected" << std::endl;
} else {
std::cout << "Could not connect" << std::endl;
}
} catch (const std::exception &e) {
std::cout << e.what() << std::endl;
}
} else {
std::cout << "Unable to authenticate to Vault" << std::endl;
}
}
Running the Example
This example is fully dockerized and uses three separate containers. One for PostgreSQL, one for Vault, and one for our program. The following commands will run the example.
In one terminal:
make docker-network
make postgres
In another terminal:
make vault
In a third terminal, copy the root token from the second terminal output after Vault has completed booting:
vault/setup # paste the root token value when prompted
make docker-run
You will see the output Connected
in the terminal if successful.
Wrap-Up
This example is meant to demonstrate that the lift into secrets management can be both simple and low effort. In order to make this more generic a move away from the json library custom deserializers will be necessary. This would allow us to take all values as a map and construct our configuration values using an initial map with all values potentially swapped out with vault supplied values if applicable. We would parse the initial configuration into a map and pass that to all of our various configuration types. It would be a good idea at this point to change the constructor to only offer one type of construction ala std::unordered_map, Vault::Client, Vault::Path
that provides the configuration slice necessary, the Vault client, and the path to the secret mount that holds the values. The constructor can then iterate through its members, swapping out what is provided and constructing a real instance to use.