Building a Vault Secure Plugin
Vault is an open source tool for managing secrets. Earlier we showcased how Vault provides Encryption as a Service and how New Relic trusts HashiCorp Vault for their platform. This post explores extending Vault even further by writing custom auth plugins that work for both Vault Open Source and Vault Enterprise.
» Vault Plugins
Due to its secure nature, Vault's plugin system takes additional steps to ensure the plugin is fully trusted before use. In the previous post, we explored How to Extend Terraform with Custom Plugins. Vault's plugin system is conceptually similar to other HashiCorp tools like Terraform and Packer, but adds additional precautions and validation steps. Each plugin acts as a server, and Vault makes API calls to that server. This is done over a mutually-authenticated TLS connection, all on the local machine. The process is as follows:
-
A Vault administrator registers a plugin the Vault's Plugin Catalog. The registration includes the path to the plugin binary on disk, the SHA256 checksum of the plugin binary, the name of the binary, and optionally the command to execute (defaults to the name of the plugin).
-
On execution, Vault verifies the plugin is registered (by name) in Vault's Plugin Catalog. Plugins must be registered in the catalog before use.
-
Vault ensures the checksum of the plugin on disk matches the registered checksum in the Plugin Catalog. This ensures the binary has not been tampered with or altered since installation.
-
Vault spawns the plugin, passing it a wrapped token containing TLS certificates and a private key. The wrapped token has exactly one use and a very small TTL.
-
The plugin unwraps the provided wrapped token and makes an API call to Vault to unwrap the provided token. The plugin extracts the unique TLS certificates and private key wrapped by the token. The plugin uses these TLS certificates and private key to start an RPC server encrypted with TLS.
-
Vault and the plugin communicate via RPC over TLS using mutual TLS.
It is important to note that this process is handled automatically by the plugin package. Plugin authors do not need to implement this flow on their own. However, having a basic understanding of the plugin model is helpful in building custom Vault plugins.
» Writing a Vault Plugin
All of the following code samples are available in full on GitHub.
At present, there are three kinds of plugins - audit, auth, and secrets. Audit plugins are responsible for managing audit logs. Auth plugins are responsible for verifying user or machine-supplied information and mapping that identity to policy. Secrets plugins are responsible for generating, storing, or retrieving secrets. This post focuses on building an auth plugin, but the concepts are largely applicable to all three kinds of Vault plugins.
Like most software projects, the first step in writing a Vault Plugin is research. It is important that you have an understanding of the API for which you are building a Vault plugin. Additionally, you should identify any client libraries which manages the authentication and interaction with the API in Golang (the language in which Vault plugins are written). This example post implements a dummy authentication plugin which verifies the user provided the proper shared secret code, so we do not need to learn the complexity of an upstream tool or API.
After you understand the API, some boilerplate code is required to get started. This code configures the binary to be a plugin, manages the TLS handshake describe above, and serves the proper plugin APIs.
func main() {
apiClientMeta := &pluginutil.APIClientMeta{}
flags := apiClientMeta.FlagSet()
flags.Parse(os.Args[1:])
tlsConfig := apiClientMeta.GetTLSConfig()
tlsProviderFunc := pluginutil.VaultPluginTLSProvider(tlsConfig)
if err := plugin.Serve(&plugin.ServeOpts{
BackendFactoryFunc: Factory,
TLSProviderFunc: tlsProviderFunc,
}); err != nil {
log.Fatal(err)
}
}
func Factory(ctx context.Context, c *logical.BackendConfig) (logical.Backend, error) {
// TODO
return nil, nil
}
First, create a main
function. This is the function that is executed when the binary is called. That function calls Vault's built-in plugin.Serve
call, which builds all the required plugin APIs, TLS connections, and RPC server. The BackendFactoryFunc
calls our local Factory
function, which is currently just a stub. The factory is responsible for setting up and configuring the plugin (sometimes called a "backend" internally), returning any errors that occur during setup.
Next, create the actual backend. Vault backends are implemented as interfaces, making it easy to write our own.
type backend struct {
*framework.Backend
}
func Backend(c *logical.BackendConfig) *backend {
var b backend
b.Backend = &framework.Backend{
BackendType: logical.TypeCredential,
AuthRenew: b.pathAuthRenew,
PathsSpecial: &logical.Paths{
Unauthenticated: []string{"login"},
},
Paths: []*framework.Path{
&framework.Path{
Pattern: "login",
Fields: map[string]*framework.FieldSchema{
"password": &framework.FieldSchema{
Type: framework.TypeString,
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.UpdateOperation: b.pathAuthLogin,
},
},
},
}
return &b
}
-
The
backend
struct embeds the standardframework.Backend
. This allows our backend to inherit almost all the required functions and properties without writing more boilerplate code. -
The backend itself declares its type - in this case
logical.TypeCredential
. This tells Vault that the backend (plugin) is a credential (auth) provider. -
Since this is an auth plugin, the
AuthRenew
field is required. This points to a function which will handle requests to renew an auth from this backend. Note that we have not defined thepathAuthRenew
function yet, so this code will compile yet. -
Almost every operation in Vault requires an authentication token. However, since we are building an auth plugin, we need to tell Vault to allow unauthenticated requests to our endpoint. The user will not have a token yet (that is what our auth plugin will be providing). We mark the
login
endpoint as unauthenticated, meaning Vault will not require a Vault token to access this endpoint. -
Lastly we define the path(s) for which this backend will respond. In this example, there is only one path -
login
. Most backends will have multiple paths, each with different functionality. Inside the path, we define the fields (schema). These are the fields that Vault will expect as a JSON payload. In this example, we expect one field -password
as a string. Finally we define the collection of callbacks on this path, which are described more in detail below.
Each path defines a series of "callbacks". A callback is a mapping of a request method (like "create", "read", "update", or "delete") to a path. In the example above, we mapped the logical.UpdateOperation
to the pathAuthLogin
method. This tells Vault to route any PUT/POST requests to the pathAuthLogin
method (which is not yet defined) with the parsed schema fields.
It is important to note that Vault itself handles the request parsing, authentication, and authorization of these paths. Notice that our plugin makes no use of policies or other authorization details. Vault handles this internally as part of the plugin model. Vault also gracefully handles any errors that occur in communication with the plugin. In this way, plugins are relatively unsophisticated in that they just present an API over the RPC interface. Vault itself does most of the heavy lifting.
To allow the code to compile, stub out the two methods discussed above:
func (b *backend) pathAuthLogin(_ context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
// TODO
return nil, nil
}
func (b *backend) pathAuthRenew(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
// TODO
return nil, nil
}
The next section covers implementing the "login" functionality with pathAuthLogin
.
» Implementing Login
The "login" path is unauthenticated. It is the plugin's responsibility to receive the payload from the request, parse, and process that information. If the result is successful, the method returns "ok" with some metadata. If the result is unsuccessful, the method returns an error which translates to "unauthorized" to the user. On success, Vault Core will handle the generation of the Vault Token and policy attachment based on our metadata response.
This auth plugin uses a shared secret value: "super-secret-password". If the user supplies the correct password, they are authenticated. If the user does not provide the correct password, they are denied. Here is some sample code to accomplish this behavior:
func (b *backend) pathAuthLogin(_ context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
password := d.Get("password").(string)
if subtle.ConstantTimeCompare([]byte(password), []byte("super-secret-password")) != 1 {
return nil, logical.ErrPermissionDenied
}
ttl, _, err := b.SanitizeTTLStr("30s", "30s")
if err != nil {
return nil, err
}
// Compose the response
return &logical.Response{
Auth: &logical.Auth{
InternalData: map[string]interface{}{
"secret_value": "abcd1234",
},
Policies: []string{"my-policy", "other-policy"},
Metadata: map[string]string{
"fruit": "banana",
},
LeaseOptions: logical.LeaseOptions{
TTL: ttl,
Renewable: true,
},
},
}, nil
}
-
Extract the "password" from the field schema. This comes from the
Fields
declaration in the path section when we defined the backend above. -
Perform an equality check to see if the user-supplied password matches are hard-coded passphrase. Note: you should not do this in production. This is example code only.
-
If the passwords match, return a
logical.Response
with our metadata in theAuth
field.-
InternalData
is data that only our auth plugin will have access to. Neither other auth backends nor the user will see this data. It is common to store information that will be needed during the renew operation here. For example, if the user provided an API key for authentication, that key may be stored here and retrieved later when renewing the authentication. -
Policies
is the list of policies the resulting token should have attached. In addition to the default policy, tokens created through this auth plugin will have the "my-policy" and "other-policy" policies automatically attached. -
Metadata
is arbitrary key-value metadata that is attached to the token. This information is visible to the user, so do not use it for internal storage. -
LeaseOptions
inform both the user and Vault of the lifetime and the renewability of the resulting token. In this example, the TTL is hardcoded to 30 seconds. In a real plugin, this would likely be a configurable value.
-
The next section details how to compile, register, and run this plugin.
» Testing Implementation
Before going further, verify the plugin is behaving as expected by running it locally.
-
Create a temporary directory to compile the plugin into and to use as the plugin directory for Vault:
$ mkdir -p /tmp/vault-plugins
-
Compile the plugin into the temporary directory:
$ go build -o /tmp/vault-plugins/vault-auth-example
-
Create a configuration file to point Vault at this plugin directory:
$ tee /tmp/vault.hcl <<EOF plugin_directory = "/tmp/vault-plugins" EOF
-
Start a Vault server in development mode with the configuration:
$ vault server -dev -dev-root-token-id="root" -config=/tmp/vault.hcl
-
Leave this running and open a new tab or terminal window. Authenticate to Vault:
$ vault auth root
-
Calculate and register the SHA256 sum of the plugin in Vault's plugin catalog:
SHASUM=$(shasum -a 256 "/tmp/vault-plugins/vault-auth-example" | cut -d " " -f1) vault write sys/plugins/catalog/example-auth-plugin \ sha_256="$SHASUM" \ command="vault-auth-example"
-
Enable the auth plugin:
$ vault auth-enable -path=example -plugin-name=example-auth-plugin plugin
At this point, the plugin is registered and enabled. To test the implementation, submit a login request with an invalid secret:
$ vault write auth/example/login password=nope
The response will be "permission denied":
Error writing data to auth/example/login: Error making API request.
URL: PUT http://127.0.0.1:8200/v1/auth/example/login
Code: 403. Errors:
* permission denied
Now submit a login request with the correct shared secret:
$ vault write auth/example/login password=super-secret-password
The response will be successful and include the token and token metadata:
Key Value
--- -----
token 945fe08d-d9ab-63e9-d77b-7ed8e545d81d
token_accessor 97986a4c-36d0-eff9-eddd-6ae5fd8c317b
token_duration 30s
token_renewable true
token_policies [default my-policy other-policy]
token_meta_fruit "banana"
Notice that the authentication token expires in 30 seconds. If the user does not renew the token within 30 seconds, Vault will automatically revoke it. Again, this functionality is handled by Vault Core. The next section covers implementing the "renew" functionality with pathAuthRenew
.
» Implementing Renew
With the exception of root tokens, all tokens in Vault have a limited lifetime and require renewal. Just like the auth plugin is responsible for managing the authentication, the auth plugin is responsible for managing the renewal. Vault Core will handle the actual extension of the lease - the plugin just needs to return a "yes" or "no" as to whether the token should still be valid.
For example, suppose our auth plugin authenticated users via LDAP or ActiveDirectory. The internally stored data would include the user's username and password. On renew, the auth plugin should verify the user's stored credentials are still valid. It should also verify that no internal state (like policies) have changed. In this fictitious plugin, there is no external service, so it verifies integrity using the internal "secret_value"
.
func (b *backend) pathAuthRenew(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
if req.Auth == nil {
return nil, errors.New("request auth was nil")
}
secretValue := req.Auth.InternalData["secret_value"].(string)
if secretValue != "abcd1234" {
return nil, errors.New("internal data does not match")
}
ttl, maxTTL, err := b.SanitizeTTLStr("30s", "1h")
if err != nil {
return nil, err
}
return framework.LeaseExtend(ttl, maxTTL, b.System())(ctx, req, d)
}
-
If the authentication was not present, the token cannot be renewed.
-
Extract the internal data and verify the data is still the same. If the data is different, this means the user is no longer valid. Again, as mentioned above, this would usually be an external API call to validate stored user credentials.
-
Calculate the TTLs
-
Extend the lease
As you can see, Vault is doing most of the computational work. Vault handles the routing, actual extension of the lease, and much more. The auth plugin simply verifies user or machine-supplied information.
As a final exercise, re-compile and run the plugin. Verify it can renew credentials:
$ vault write auth/example/login password=super-secret-password
Key Value
--- -----
token b62420a6-ee83-22a4-7a15-a908af658c9f
token_accessor 9eff2c4e-e321-3903-413e-a5084abb631e
token_duration 30s
token_renewable true
token_policies [default my-policy other-policy]
token_meta_fruit "banana"
$ vault token-renew b62420a6-ee83-22a4-7a15-a908af658c9f
Key Value
--- -----
token b62420a6-ee83-22a4-7a15-a908af658c9f
token_accessor 9eff2c4e-e321-3903-413e-a5084abb631e
token_duration 30s
token_renewable true
token_policies [default my-policy other-policy]
token_meta_fruit "banana"
» Conclusion
Extending a security tool like Vault may seem intimidating, but hopefully this post has convinced you otherwise. By leveraging Vault's plugin system, we are able to build extensible audit, auth, and secrets plugins to extend Vault's functionality. The source code for this example Vault plugin is available on GitHub, and you can view the source of more complex Vault plugins by looking at Vault's source code on GitHub or the Vault Auth Slack Vault Plugin which inspired this post. We look forward to seeing what custom Vault Plugins you build!
Interested a managed UI, cluster management, HSM integration, or multi-datacenter replication? Be sure to check out Vault Enterprise.
Sign up for the latest HashiCorp news
More blog posts like this one
Vault integrations with MongoDB, Private Machines, and walt.id strengthen customer security
Three new HashiCorp Vault ecosystem integrations extend security use cases for customers.
HashiCorp at re:Invent 2024: Security Lifecycle Management with AWS
A recap of HashiCorp security news and developments on AWS from the past year, for your security management playbook.
HCP Vault Dedicated adds secrets sync, cross-region DR, EST PKI, and more
The newest HCP Vault Dedicated 1.18 upgrade includes a range of new features that include expanding DR region coverage, syncing secrets across providers, and adding PKI EST among other key features.