Content

Custom Vault Integrations: C++ (Bonus)

Written by Shadow-Soft Team | Feb 10, 2022 10:07:00 AM

Introduction

Hashicorp’s Vault has become the industry standard tool for secrets management. It comes packaged with multiple solutions for engines supporting various authentication methods, secret integrations, and storage. However, not every application has a first-party, third-party, or community secrets engine integration with Vault. This is especially true for internally developed custom applications and software that your company may utilize internally or as customer-facing products. In these situations, you often need to develop a custom integration with Vault.

These articles will guide you in developing a basic custom secrets engine integration with Vault in each of four common languages. It is assumed that you are already familiar with the fundamentals of administrating a Vault cluster, the common Vault engines, and coding in the language(s) of your choice that are demonstrated in each article.

This article will focus on a custom Vault integration developed in C++.

Bindings Installation

Vault has a well documented REST API, and you can certainly interact with it through the REST API bindings in the application language. However, bindings to the Vault API already exist in several languages, and these should be used for their obvious advantages. In this section, we will see how to install those bindings in C++.

First, we retrieve the source code from the git repository. Next, we need to ensure the cURL dependency is installed in the build environment. We also need a version of CMake >= 3.12 to build the library. Finally, we need to ensure a C++17 compatible compiler toolchain exists in the build environment. This includes GCC >= 8 and clang >= 3.8. Once these prerequisites are satisfied, then we are ready to build the Vault C++ bindings library.

We can build the library from the root project directory with the following commands:

mkdir build
cd build
cmake ../
make

Note that the LINK_CURL option for CMake will statically link cURL into the Vault library to remove a runtime environment dependency if desired. You can also install the Vault library as usual with the make install command. Note this command will likely require elevated permissions in your environment.

Your software project’s CMakeLists.txt should at a minimum generally enforce the C++17 standard, find the CURL package, and link your target against the Vault library and then the cURL library.

There is no formal documentation for libvault, so one generally relies upon sources and examples.

Article Environment

Dependencies:

  • libvault: 0.48
  • cURL GNUTLS package: 7.68.0
  • CMake: 3.16
  • GCC: 9.3.0

Header file throughout:

// main.hpp
#pragma once
#include <libvault/VaultClient.h>

Vault::Client configureClient(const bool enableTLS = false);
Vault::Client appRoleLogin(const Vault::RoleId &roleId, const Vault::SecretId &secretId, const bool enableTLS = false);
void enableEngine(const Vault::Client &vaultClient, const std::string mount, const Vault::Parameters engine, const Vault::Parameters options, const Vault::Parameters config);
void writeSecretValue(const Vault::Client &vaultClient, const Vault::Path path, const Vault::Parameters secretKV, const std::string mount = "secret");
std::optional<std::string> readSecretValue(const Vault::Client &vaultClient, const Vault::Path path, const std::string mount = "secret");
std::optional<std::string> generateCert(const Vault::Client &vaultClient, const Vault::Parameters certParams, const Vault::Path path, const std::string mount = "certificates");

Beginning of main code file throughout:

// main.cpp
#include <iostream>
#include <libvault/VaultClient.h>
#include "main.hpp"

int main(void)
{
  ...
}
CMakeLists.txt:

cmake_minimum_required(VERSION 3.12)
project(vault_software VERSION 0.48.0 DESCRIPTION "Software using Vault bindings for C++")

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(CURL)

add_executable(vault_software.xx src/main.cpp)
target_link_libraries(vault_software.xx /usr/local/lib/libvault.so curl)

Client Construction and Authentication

The first step in interacting with the Vault API through the bindings is to construct a client for the interface. After constructing the client, the member method for authentication with a token can then be invoked. This code is actually remarkably similar across the different language bindings.

We begin by defining a function for instantiating a client with a token. The token is read from the environment variable, and we use a pointer to prevent errors around conversion from NULL if undefined (insert your own joke about not de-referencing it). The token is then verified, and we construct a token and token authentication from the token. We construct a Vault configuration with no debug and optional TLS and a couple of error callbacks to assist with debugging. We then construct a Vault client from the config and token with the optional error callback arguments to assist. This client is then returned from the function.

Vault::Client configureClient(const bool enableTLS) {
  auto *vaultTokenEnv = std::getenv("VAULT_TOKEN");

  if (sizeof(vaultTokenEnv) != 26) {
    std::cout << "The Vault token is invalid" << std::endl;
    exit(-1);
  }

  Vault::Token vaultToken{vaultTokenEnv};
  Vault::TokenStrategy auth{vaultToken};
  Vault::Config vaultConfig = Vault::ConfigBuilder().withDebug(false).withTlsEnabled(enableTLS).build();
  Vault::HttpErrorCallback httpErrorCallback = [&](std::string err) {
    std::cout << err << std::endl;
  };
  Vault::ResponseErrorCallback responseCallback = [&](Vault::HttpResponse err) {
    std::cout << err.statusCode << " : " << err.body.value() << std::endl;
  };

  return Vault::Client{vaultConfig, auth, httpErrorCallback, responseCallback};
}

The function return can be used as expected:

auto *vaultClient = configureClient;

Alternate Authentication Engines

You may not want to use the basic Token Authentication engine (and understandably so) for authentication with the Vault client. In that situation, we can utilize a few alternatives.

In the code below, we perform a simple “push” model AppRole authentication strategy. Our function takes a role id and secret id as input arguments with optional TLS enablement. We construct an AppRole authentication from these input id arguments and construct a Vault configuration from the optional TLS enablement. We then construct a Vault client from the config and the AppRole authentication. This client is then returned from the function.

Vault::Client appRoleLogin(const Vault::RoleId &roleId, const Vault::SecretId &secretId, const bool enableTLS) {
  Vault::AppRoleStrategy auth{roleId, secretId};
  Vault::Config vaultConfig = Vault::ConfigBuilder().withTlsEnabled(enableTLS).build();

  return Vault::Client{vaultConfig, auth};
}

To use this function, we begin by initializing a role id and secret id from the environment variables. We use a standard null coalescing ternary (no null coalescing operator as of C++17) to protect against potential errors. We use these id as inputs to the function and assign the return to an initialized Vault client variable.

auto roleId = std::getenv("ROLE_ID") ? std::getenv("ROLE_ID") : "";
auto secretId = std::getenv("SECRET_ID") ? std::getenv("SECRET_ID") : "";
auto vaultClientAppRole = appRoleLogin(Vault::RoleId{roleId}, Vault::SecretId{secretId});

Secrets Retrieval (KV2 Engine)

Once again, we have an authenticated and authorized client ready for interaction with secrets engines. We are ready for the direct integration of the software with the Vault KV2 engine. However, what if we wanted to write a secret first just for fun?

The following function accepts inputs of a Vault client, a path to the secret, Vault parameters defining the key-value pairs in secret, and an optional mount path for the KV2 engine. We first verify the client is authenticated and then construct an interface to the KV2 engine at the specified mount path using the supplied client argument. We then write the key-value pairs to the secret path and verify that the response from the bindings method was successful.

void writeSecretValue(const Vault::Client &vaultClient, const Vault::Path path, const Vault::Parameters secretKV, const std::string mount) {
  if (vaultClient.is_authenticated()) {
    Vault::KeyValue keyValue{vaultClient, Vault::SecretMount{mount}};

    if (auto response = keyValue.create(path, secretKV); response)
      std::cout << "Secrets written successfully at " << path << std::endl;
    else
      std::cout << "Unable to write secrets at " << path << std::endl;
  }
  else
    std::cout << "Unable to authenticate against Vault" << std::endl;
}

Below we see a sample usage for the function. We construct a path for the secret and parameters for the key-value pairs of the secret. We input both of these along with the previously AppRole authenticated client to create three key-value pairs at the secret path /secret/data/mysecret.

Vault::Path secretPath{"mysecret"};
Vault::Parameters secretKV(
  );
writeSecretValue(vaultClientAppRole, secretPath, secretKV);

More importantly, we also need to read the secrets from the KV2 engine. The function accepts input arguments of a Vault client, a path to the secret, and an optional mount point for the KV2 secrets engine. Similar to the function for writing secrets, we verify the client is authenticated and then construct an interface to the KV2 engine at the specified mount path using the supplied client argument. We then read the key-value pairs from the secret path and verify the response from the bindings method was successful. Finally, the key-value pairs are returned as a JSON formatted string.

std::optional<std::string> readSecretValue(const Vault::Client &vaultClient, const Vault::Path path, const std::string mount) {
  if (vaultClient.is_authenticated()) {
    Vault::KeyValue keyValue{vaultClient, Vault::SecretMount{mount}};

    if (auto response = keyValue.read(path); response) {
      std::cout << "Secrets read successfully at " << path << std::endl;
      return response.value();
    }
    else
      std::cout << "Unable to read secrets at " << path << std::endl;
  }
  else
    std::cout << "Unable to authenticate against Vault" << std::endl;

  return "";
}

Assuming we are re-using the previously constructed Vault client and secret path used in the KV2 secrets writing above, then reading the secret is as simple as readSecretValue(vaultClientAppRole, secretPath);.

Engine Enablement

We can also use the C++ bindings to enable Vault engines. We use the below function with input arguments of a Vault client, engine mount path, and parameters to define the engine, options, and config. We verify the client is authenticated and then construct an engine interface for enabling this particular Vault engine. We also constructed a mount path since Vault engines can be mounted at various paths. We then enable the Vault engine from the engine member to ensure authentication and authorization. We use the mount path and the parameters as inputs for enablement. We then verify the engine was enabled correctly based on the return response from the enable bindings method and provide information accordingly to stdout.

void enableEngine(const Vault::Client &vaultClient, const std::string mount, const Vault::Parameters engine, const Vault::Parameters options, const Vault::Parameters config) {
  if (vaultClient.is_authenticated()) {
    Vault::Sys::Mounts engine{vaultClient};
    auto mountPath = Vault::Path{mount};

    engine.enable(mountPath, engine, options, config);

    if (auto response = engine.list(); response) {
      std::cout << response.value() << std::endl;
    }
    else
      std::cout << "Failure enabling engine at " << mount << std::endl;
  }
  else
    std::cout << "Unable to authenticate against Vault" << std::endl;
}

Note we can also similarly disable with engineAdmin.disable(mountPath);.

Below we see an example invocation of this function. It uses the previously authenticated AppRole client to enable a KV2 secrets engine at the mount path of secret with a max lease time to live of fifteen minutes.

enableEngine(
  vaultClientAppRole,
  "secret",
  Vault::Parameters,
  Vault::Parameters,
  Vault::Parameters);

Certificate Generation

Maybe your software also needs to generate certificates by interfacing with Vault’s PKI secrets engine. In that case, we have a fun example for that use case in this section.

The function for generating a certificate accepts inputs of a Vault client, parameters for the certificate, a path to the secret for the PKI engine, and an optional mount point for the PKI secrets engine. We verify the client is authenticated and then construct an interface to the PKI engine at the specified mount path with the authenticated Vault client. We construct parameters for the root certificate for the generated certificate. We generate a root certificate with the pki member method and the root certificate parameters and then verify the response correctly generates a root certificate. We now re-use the URL and role parameters to generate the certificate after generating the corresponding root certificate. We invoke the member method for the PKI interface to issue a certificate and then verify the response for correct certificate generation. We return the response from the engine as a JSON formatted string containing the certificate. This function can also be easily modified to allow the root, url, and role parameters as inputs.

std::optional<std::string> generateCert(const Vault::Client &vaultClient, const Vault::Parameters certParams, const Vault::Path path, const std::string mount) {
  if (vaultClient.is_authenticated()) {
    Vault::Pki pki{vaultClient, Vault::SecretMount{mount}};

    Vault::Parameters rootCertParams({
      {"common_name", "company.com"},
      {"ttl", "8760h"}
    });
    Vault::Parameters urlParams({
      {"issuing_certificates", "http://127.0.0.1:8200/v1/pki/ca"},
      {"crl_distribution_points", "http://127.0.0.1:8200/v1/pki/crl"}
    });
    Vault::Parameters roleParams({
      {"allowed_domains", "company.com"},
      {"allow_subdomains", "false"},
      {"max_ttl", "72h"}
    });

    auto rootCertResponse = pki.generateRoot(Vault::RootCertificateTypes::INTERNAL, rootCertParams);
    if (!rootCertResponse) {
      std::cout << "Could not generate root certificate" << std::endl;
      return "";
    }

    pki.setUrls(urlParams);
    pki.createRole(path, roleParams);

    if (auto certResponse = pki.issue(path, certParams); certResponse)
      return certResponse;
    else
      std::cout << "Could not issue certificate for " << path << std::endl;
  }
  else
    std::cout << "Unable to authenticate against Vault" << std::endl;

  return "";
}

We simply input the AppRole authenticated client, the parameters for the certificate specifying the CN (more options are available), and the path to the certificate secret to the function. This generates a certificate accordingly.

generateCert(vaultClientAppRole, Vault::Parameters , Vault::Path{"company"});

Conclusion

Now we have a custom Vault integration for your application developed in C++. This code can be used as a package within your application or other software so that it can independently connect, authenticate, authorize, and retrieve secrets with an existing Vault cluster in a self-contained procedure. The application is now capable of direct interfacing to Vault, and does not require workarounds using additional tooling. Once you feel comfortable implementing the functionality in this article, you can then expand to support additional client configs, and other authentication and secrets engines.

If your organization is interested in custom Vault integrations for your applications or other software you develop or otherwise utilize, contact Shadow-Soft.