Skip to main content

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:

  1. 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).

  2. On execution, Vault verifies the plugin is registered (by name) in Vault's Plugin Catalog. Plugins must be registered in the catalog before use.

  3. 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.

  4. 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.

  5. 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.

  6. Vault and the plugin communicate via RPC over TLS using mutual TLS.

Vault Plugin Flow

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 standard framework.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 the pathAuthRenew 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 the Auth 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.

  1. Create a temporary directory to compile the plugin into and to use as the plugin directory for Vault:

    $ mkdir -p /tmp/vault-plugins
    
  2. Compile the plugin into the temporary directory:

    $ go build -o /tmp/vault-plugins/vault-auth-example
    
  3. Create a configuration file to point Vault at this plugin directory:

    $ tee /tmp/vault.hcl <<EOF
    plugin_directory = "/tmp/vault-plugins"
    EOF
    
  4. Start a Vault server in development mode with the configuration:

    $ vault server -dev -dev-root-token-id="root" -config=/tmp/vault.hcl
    
  5. Leave this running and open a new tab or terminal window. Authenticate to Vault:

    $ vault auth root
    
  6. 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"
    
  7. 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

By submitting this form, you acknowledge and agree that HashiCorp will process your personal information in accordance with the Privacy Policy.