Custom Vault Integrations: Rust

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 the 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 is demonstrated in each article.

Since we already covered Go, Javascript, Python, Ruby, and C++ one year ago, it is now time for the obvious next language: this article will focus on a custom Vault integration developed in Rust.

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 these bindings in Rust.

The cargo.toml snippet is simple as expected:

[dependencies]
hashicorp_vault = "2.1.1"

Subsequent cargo commands will retrieve and install the bindings as per usual.

Preparation

First we should examine some definitions that provide code cohesiveness for later. This would be similar to first discussing the header, beginning of the main code file, and CMakeLists in the C++17 article. Similar to the Go article, we first establish a VaultConfig struct. Note also the two libraries and the hashicorp_vault crate at the beginning since it is convenient to place that code here also.

use std::env;
use std::error;
use hashicorp_vault as vault;

#[derive(Debug)]
struct VaultConfig {
    host: String,
    token: String,
    role_id: String,
    secret_id: String,
}

impl VaultConfig {
    // return an authenticated vault client
    fn vault_client(&self) -> vault::client::VaultClient<vault::client::TokenData> {
        vault::Client::new(&self.host, &self.token).unwrap()
    }

    // return an approle authenticated client
    fn approle_vault_client(&self) -> vault::client::VaultClient<()> {
        // verify role_id and secret_id exist
        assert!(
            !&self.role_id.is_empty() && !&self.secret_id.is_empty(),
            "The role_id and secret_id must both be set with environment variables for approle authentication"
        );

        vault::Client::new_app_role(&self.host, &self.role_id, Some(&self.secret_id)).expect("The role and/or secret ID are invalid.")
    }

    // read host
    fn host(&self) -> &String {
        &self.host
    }
}

fn main() -> Result<(), Box<dyn error::Error>> {
  ...
  Ok(())
}

The code is rather self-explanatory, but essentially we organize a Vault configuration struct for authenticating a client similar to the procedure in Go. In C++ the struct is conveniently provided for us. Unlike in Go where struct associated functions can be scattered throughout the code, we can make use of impl to clearly define the associated methods for the struct. We have a method for returning a token authenticated client, another for returning an approle authenticated client, and another for returning the host associated with the Vault server cluster (“getter”).

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 the struct for the Vault client configuration:

// provide vault client configuration
let client_config = VaultConfig {
    host: match env::var("VAULT_ADDR") {
        Ok(val) => String::from(val),
        Err(_) => String::from("http://127.0.0.1:8200"),
    },
    token: match env::var("VAULT_TOKEN") {
        Ok(val) => String::from(val),
        Err(_) => panic!("No Vault token (VAULT_TOKEN) provided for authentication"),
    },
    role_id: match env::var("ROLE_ID") {
        Ok(val) => String::from(val),
        Err(_) => String::from(""),
    },
    secret_id: match env::var("SECRET_ID") {
        Ok(val) => String::from(val),
        Err(_) => String::from(""),
    },
};

All four values are retrieved from standardized environment variables. The host will default to the normal local configuration if not provided. The code will trigger a fatal error if the token is not provided (this is not actually necessary for many situations, but serves as a pedantic example). The role_id and secret_id default to empty strings if not provided.

We can then construct a token authenticated Vault client as expected:

// initialize client
println!("The Vault server is hosted at {:?}", client_config.host());
let client = client_config.vault_client();

The struct method uses the stored members of the Vault address and token to authenticate and return a valid client object.

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.

The following example utilizes the member method in the VaultConfig struct to return an approle authenticated Vault client. It is fairly straightforward as the inputs are referenced as members of the self. The struct method uses the stored members of the Role and Secret ID to authenticate and return a valid client object.

// initialize approle client
let approle_client = client_config.approle_vault_client();

The bindings method usage is essentially similar to all the other discussed bindings besides C++17 (which is more robust), and the direct parameter usage is similar to Python, Ruby, and Javascript (recall C++ and Go utilize structs). There is one interesting difference here: the type for the secret id in the bindings is a std::option, whereas in C++ the type is not a std::optional.

Token Information Lookup

We can also leverage the Vault Rust bindings to perform simple token information lookups. The following code demonstrates how to assign the return of the token information for a given client to a variable, and then to access specific information for the token (e.g. number of attached policies). It then finishes by using a client with a token authorized for renewal to renew the approle client’s token.

// lookup token info
let client_info = client.lookup()?;
println!("Number of policies for current Vault authentication: {:?}", client_info.data.unwrap().policies.len());
let approle_client_info = approle_client.lookup()?;
println!("Number of policies for approle Vault authentication: {:?}", approle_client_info.data.unwrap().policies.len());

// renew approle client token
client.renew_token(approle_client.token, None)?;

Secrets Retrieval (KV2 Engine)

Now that we have a connected, authenticated, and authorized client, we are able to begin interfacing with the KV2 secrets engine and retrieve secrets. There is a convenient single method for retrieving the KV2 secret values similar to the Pyhon HVAC bindings. However, this does come at a cost of robustness compared to the C++17 bindings.

Similar to the C++17 bindings, the Rust bindings directly retrieve secret values without parsing the data and accessing the value at the data key. This opinionated code is convenient at essentially zero drawbacks to the interface. This is in contrast to the Go, Ruby, Python, and Javascript bindings. Furthermore, the Go and Ruby bindings also require a Read from the Logical of the client (essentially almost a shim on the API),

This KV2 lifecycle function begins by setting a single secret value at a path with a generic key (recall the earlier comment about the limited robustness in the Rust method). We then demonstrate returning the list of secrets at a given path in the secrets engine. Finally, we retrieve the secret we set earlier.

Now for fun we can also demonstrate how to retrieve the secret wrapped. We retrieve the secret wrapped with a specific TTL. We then access the token associated with the wrapped secret. We then initialize a new client authenticated with the token associated with the wrapped secret. We use this new client to read a cubbyhole response, and then access the value of the response data to read the unwrapped secret.

I feel it is important here to distinguish two potential ambiguities in the preceding code explanation. First, in case it is not clear, the unwrap invocations are Rust unwrap on the Result, and not Vault token unwraps. Second, the data access on the cubbyhole response is for the response structure, and not for the data from the KV2 bindings, nor the data key in the KV2 path. Note this is also implicit with how we parse the value of the response data structure.

To “wrap” up this section (apologies), we begin by deleting the secret we set at the beginning of the function. We then assert that the directly retrieved secret is equal to the unwrapped retrieved wrapped secret. We then print out the original secret, the directly retrieved secret, and the unwrapped secret to transparently demonstrate all three are the same.

// kv2 lifecycle for single kv pair secret
fn kv2_lifecycle(client: &vault::client::VaultClient<vault::client::TokenData>, path: &str, secret: &str) -> Result<(), Box<dyn error::Error>> {
    // set kv2 secret
    client.set_secret(path, secret)?;

    // retrieve and assign list of top-level secrets
    let secrets: Vec<String> = client.list_secrets("")?;
    assert!(secrets.len() > 0, "No top level KV2 secrets found at default mount path");

    // retrieve secret
    let direct_secret = client.get_secret(path)?;

    // alternatively: wrap secret's value with one minute ttl
    let wrapped_secret = client.get_secret_wrapped(path, "1m")?;
    // parse wrapped token for authentication
    let wrapping_token = wrapped_secret.wrap_info.unwrap().token;
    // instantiate a new client with the wrapping token
    let client_new = &vault::Client::new_no_lookup(&client.host, wrapping_token)?;
    // read the cubbyhole response
    let cubbyhole_response = &client_new.get_unwrapped_response()?;
    // assigned unwrapped secret value
    let unwrapped_secret = &cubbyhole_response.data.as_ref().unwrap()["value"];

    // delete secret
    client.delete_secret(path)?;

    // output secret value
    assert_eq!(
        &direct_secret, unwrapped_secret,
        "The directly retrieved secret '{direct_secret}' is not equal to the unwrapped secret '{unwrapped_secret}'"
    );
    println!("My secret value is {secret}");
    println!("My directly accessed secret value is {direct_secret}");
    println!("My unwrapped secret value is also {unwrapped_secret}");

    Ok(())
}

Using this function to manage a KV2 lifecycle is as intuitive as expected. We merely pass an authenticated and authorized client, a secret path, and a secret value.

// execute basic kv2 lifecycle
kv2_lifecycle(&client, "my_secret", "my_credential")?;

Transit Secrets Engine

The transit secrets engine is especially useful for AES-256-GCM cipher encryption of strings. We can develop a Rust function for a full transit secrets engine lifecycle.

We begin by converting to bytes the desired string to encrypt. We then use a transit engine key to encrypt the bytes. Note that transit_encrypt actually also implicitly creates the key if it does not already exist. We then demonstrate decrypting the encrypted bytes with the same key (symmetrical cipher algorithm). Since we now want to prove that the original string is equal to the string processed through the transit secrets engine, we must first convert the decrypted bytes to a string slice. We then compare the input string with the string slice converted to a string, and then validate they are equal.

// transit lifecycle for encryption and decryption of text
fn transit_lifecycle(client: &vault::client::VaultClient<vault::client::TokenData>, key_id: &str, text: &str) -> Result<(), Box<dyn error::Error>> {
    // convert string slice to byte string slice
    let text_bytes = text.as_bytes();
    // encrypt the string with a key
    let encrypted = client.transit_encrypt(None, key_id, text_bytes)?;
    // decrypt the encrypted string with the same key
    let decrypted = client.transit_decrypt(None, key_id, encrypted)?;
    // demonstrate the text before encryption and post-descryption are equal
    let decrypted_slice = decrypted.as_slice();
    assert_eq!(
        text_bytes, decrypted_slice,
        "The original text '{:?}' is not equal to the decrypted text '{:?}'",
        text, String::from_utf8_lossy(decrypted_slice)
    );
    println!("The input text to encrypt should have been {:?}", String::from_utf8_lossy(decrypted_slice));

    Ok(())
}

Invoking this transit engine lifecycle function is fairly straightforward and merely requires an authenticated and authorized client, a name for a transit secrets engine key, and a string to encrypt.

// execute transit lifecycle
transit_lifecycle(&client, "transit-key", "my string")?;

Conclusion

Now we have a custom Vault integration for your application developed in Rust. 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 below.

  • This field is for validation purposes and should be left unchanged.