Hashi Vault has long been a leader in secret management and has had a Kubernetes Helm Chart now for a year. I've been meaning to get back to Vault for some time and this week I took the time to take a tour of Vault on Azure Kubernetes Service.
Getting Started
First, let's spin up the basics in Azure. We've covered this enough time I'll just abbreviate the steps:
$ az group create --name ijvaultk8srg --location centralus
$ az ad sp create-for-rbac -n ijvaultk8ssp --skip-assignment --output json > my_sp.json
$ export SP_PASS=`cat my_sp.json | jq -r .password`
$ export SP_ID=`cat my_sp.json | jq -r .appId`
$ az aks create --resource-group ijvaultk8srg --name ijvaultaks --location centralus --node-count 3 --enable-cluster-autoscaler --min-count 2 --max-count 4 --generate-ssh-keys --network-plugin azure --network-policy azure --service-principal $SP_ID --client-secret $SP_PASS
Verification
We can verify our config by checking the node pool.
$ az aks get-credentials -n ijvaultaks -g ijvaultk8srg --admin
Merged "ijvaultaks-admin" as current context in /home/builder/.kube/config
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
aks-nodepool1-84770334-vmss000000 Ready agent 4m10s v1.17.11
aks-nodepool1-84770334-vmss000001 Ready agent 4m9s v1.17.11
aks-nodepool1-84770334-vmss000002 Ready agent 4m10s v1.17.11
Installing Hashi Vault
We need to add the Helm repo and install the chart
$ helm repo add hashicorp https://helm.releases.hashicorp.com
"hashicorp" has been added to your repositories
$ helm install vault hashicorp/vault
NAME: vault
LAST DEPLOYED: Thu Oct 1 18:42:19 2020
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
Thank you for installing HashiCorp Vault!
Now that you have deployed Vault, you should look over the docs on using
Vault with Kubernetes available here:
https://www.vaultproject.io/docs/
Your release is named vault. To learn more about the release, try:
$ helm status vault
$ helm get vault
If we get pods, we’ll see vault is up, but not “ready”
$ kubectl get pods
NAMESPACE NAME READY STATUS RESTARTS AGE
default vault-0 0/1 Running 0 5m13s
default vault-agent-injector-bdbf7b844-kqbnp 1/1 Running 0 62s
If we get the vault status, we’ll see it’s presently sealed:
$ kubectl exec vault-0 -- vault status
Key Value
--- -----
Seal Type shamir
Initialized false
Sealed true
Total Shares 0
Threshold 0
Unseal Progress 0/0
Unseal Nonce n/a
Version n/a
HA Enabled false
command terminated with exit code 2
Next we can to go to the UI:
$ kubectl port-forward vault-0 8200:8200
Forwarding from 127.0.0.1:8200 -> 8200
Forwarding from [::1]:8200 -> 8200
Handling connection for 8200
Handling connection for 8200
Handling connection for 8200
Handling connection for 8200
Handling connection for 8200
We will use 3 shares with 2 shares minimum and initialize. You can choose to download the keys at this point:
builder@DESKTOP-2SQ9NQM:/mnt/c/Users/isaac/Downloads$ cat vault-cluster-vault-2020-10-01T23_52_23.801Z.json
{
"keys": [
"c725ad463cfab00fed28284c7bcb60be4ba4889f003da744e6d7d4eceb8a203918",
"4b9082a3039a6a56c73b1f3b91a47c39bdab9d215cdfa7ee42b108dad10b1c1711",
"31c9ec8ad33393dcae71907ab1043fe8dea224d3d1a0a70000eb02f0062938dceb"
],
"keys_base64": [
"xyWtRjz6sA/tKChMe8tgvkukiJ8APadE5tfU7OuKIDkY",
"S5CCowOaalbHOx87kaR8Ob2rnSFc36fuQrEI2tELHBcR",
"McnsitMzk9yucZB6sQQ/6N6iJNPRoKcAAOsC8AYpONzr"
],
"root_token": "s.AghmoNp4pAQekX7ADcM3NID9"
We can also do this on the command line as well:
$ kubectl exec vault-0 -- vault operator init -key-shares=3 -key-threshold=2 -format=json > cluster-keys.json
$ VAULT_UNSEAL_KEY1=$(cat vault-cluster-vault-2020-10-01T23_52_23.801Z.json | jq -r '.keys_base64[0]')
$ VAULT_UNSEAL_KEY2=$(cat vault-cluster-vault-2020-10-01T23_52_23.801Z.json | jq -r '.keys_base64[1]')
$ kubectl exec vault-0 -- vault operator unseal $VAULT_UNSEAL_KEY1
$ kubectl exec vault-0 -- vault operator unseal $VAULT_UNSEAL_KEY2
Once we unseal with two tokens, we can login with the root token:
Let’s login on the pod itself and enable secrets engine:
$ kubectl exec -it vault-0 -- /bin/sh
/ $ vault login
Token (will be hidden):
Success! You are now authenticated. The token information displayed below
is already stored in the token helper. You do NOT need to run "vault login"
again. Future Vault requests will automatically use this token.
Key Value
--- -----
token s.AghmoNp4pAQekX7ADcM3NID9
token_accessor yzz0shnNSMnXIK1mQ7b4JogB
token_duration ∞
token_renewable false
token_policies ["root"]
identity_policies []
policies ["root"]
/ $ vault secrets enable -path=secret kv-v2
Success! Enabled the kv-v2 secrets engine at: secret/
Let’s set a key and then retrieve it:
/ $ vault kv put secret/webapp/config username="myUser" password="myPassword"
Key Value
--- -----
created_time 2020-10-02T00:59:57.502286049Z
deletion_time n/a
destroyed false
version 1
/ $ vault kv get secret/webapp/config
====== Metadata ======
Key Value
--- -----
created_time 2020-10-02T00:59:57.502286049Z
deletion_time n/a
destroyed false
version 1
====== Data ======
Key Value
--- -----
password myPassword
username myUser
We can also enable kubernetes pods to use vault at this time as well
/ $ vault auth enable kubernetes
Success! Enabled kubernetes auth method at: kubernetes/
/ $ echo $KUBERNETES_PORT_443_TCP_ADDR
10.0.0.1
/ $ vault write auth/kubernetes/config \
> token_reviewer_jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \
> kubernetes_host="https://$KUBERNETES_PORT_443_TCP_ADDR:443" \
> kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
Success! Data written to: auth/kubernetes/config
Set a read policy
/ $ vault policy write webapp - <<EOF
> path "secret/data/webapp/config" {
> capabilities = ["read"]
> }
> EOF
Success! Uploaded policy: webapp
Applying that policy to a role:
/ $ vault write auth/kubernetes/role/webapp \
> bound_service_account_names=vault \
> bound_service_account_namespaces=default \
> policies=webapp \
> ttl=24h
Success! Data written to: auth/kubernetes/role/webapp
We can now use a webapp to test. We can use the one out of the Hashi guide
$ cat deployment-01-webapp.yml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: webapp
labels:
app: webapp
spec:
replicas: 1
selector:
matchLabels:
app: webapp
template:
metadata:
labels:
app: webapp
spec:
serviceAccountName: vault
containers:
- name: app
image: burtlo/exampleapp-ruby:k8s
imagePullPolicy: Always
env:
- name: VAULT_ADDR
value: "http://vault:8200"
- name: JWT_PATH
value: "/var/run/secrets/kubernetes.io/serviceaccount/token"
- name: SERVICE_PORT
value: "8080"
$ kubectl apply -f deployment-01-webapp.yml
deployment.apps/webapp created
Let's test
$ kubectl port-forward `kubectl get pod -l app=webapp -o jsonpath="{.items[0].metadata.name}"` 8080:8080
Forwarding from 127.0.0.1:8080 -> 8080
Forwarding from [::1]:8080 -> 8080
Handling connection for 8080
Handling connection for 8080
And if we change it
$ kubectl exec -it vault-0 -- /bin/sh
/ $ vault kv put secret/webapp/config username="static-user" password="changed"
Key Value
--- -----
created_time 2020-10-02T20:09:22.22298837Z
deletion_time n/a
destroyed false
version 2
/ $
we can use curl just as easily to see it reflected
$ curl http://localhost:8080
{"password"=>"changed", "username"=>"static-user"}
And this is a fine solution for a basic test, however if our Vault pod goes down, it will come back up sealed.
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
vault-0 1/1 Running 0 20h
vault-agent-injector-bdbf7b844-kqbnp 1/1 Running 0 20h
webapp-66bd6d455d-6rnfm 1/1 Running 0 7m43s
$ kubectl delete pod vault-0
pod "vault-0" deleted
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
vault-0 0/1 ContainerCreating 0 6s
vault-agent-injector-bdbf7b844-kqbnp 1/1 Running 0 20h
webapp-66bd6d455d-6rnfm 1/1 Running 0 8m7s
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
vault-0 0/1 Running 0 107s
vault-agent-injector-bdbf7b844-kqbnp 1/1 Running 0 20h
webapp-66bd6d455d-6rnfm 1/1 Running 0 9m48s
$ kubectl logs webapp-66bd6d455d-6rnfm
[2020-10-02 20:03:29] INFO WEBrick 1.4.2
[2020-10-02 20:03:29] INFO ruby 2.6.2 (2019-03-13) [x86_64-linux]
[2020-10-02 20:03:29] INFO WEBrick::HTTPServer#start: pid=1 port=8080
2020-10-02 20:13:03 - NoMethodError - undefined method `[]' for nil:NilClass:
/app/lib/service.rb:51:in `block in <class:ExampleApp>'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1635:in `call'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1635:in `block in compile!'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:987:in `block (3 levels) in route!'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1006:in `route_eval'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:987:in `block (2 levels) in route!'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1035:in `block in process_route'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1033:in `catch'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1033:in `process_route'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:985:in `block in route!'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:984:in `each'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:984:in `route!'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1097:in `block in dispatch!'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1071:in `block in invoke'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1071:in `catch'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1071:in `invoke'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1094:in `dispatch!'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:919:in `block in call!'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1071:in `block in invoke'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1071:in `catch'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1071:in `invoke'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:919:in `call!'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:908:in `call'
/usr/local/bundle/gems/rack-protection-2.0.7/lib/rack/protection/xss_header.rb:18:in `call'
/usr/local/bundle/gems/rack-protection-2.0.7/lib/rack/protection/path_traversal.rb:16:in `call'
/usr/local/bundle/gems/rack-protection-2.0.7/lib/rack/protection/json_csrf.rb:26:in `call'
/usr/local/bundle/gems/rack-protection-2.0.7/lib/rack/protection/base.rb:50:in `call'
/usr/local/bundle/gems/rack-protection-2.0.7/lib/rack/protection/base.rb:50:in `call'
/usr/local/bundle/gems/rack-protection-2.0.7/lib/rack/protection/frame_options.rb:31:in `call'
/usr/local/bundle/gems/rack-2.0.7/lib/rack/null_logger.rb:9:in `call'
/usr/local/bundle/gems/rack-2.0.7/lib/rack/head.rb:12:in `call'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:194:in `call'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1950:in `call'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1502:in `block in call'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1729:in `synchronize'
/usr/local/bundle/gems/sinatra-2.0.7/lib/sinatra/base.rb:1502:in `call'
/usr/local/bundle/gems/rack-2.0.7/lib/rack/handler/webrick.rb:86:in `service'
/usr/local/lib/ruby/2.6.0/webrick/httpserver.rb:140:in `service'
/usr/local/lib/ruby/2.6.0/webrick/httpserver.rb:96:in `run'
/usr/local/lib/ruby/2.6.0/webrick/server.rb:307:in `block in start_thread'
If we follow the instructions from above, we can unseal and make the apps usable again:
$ VAULT_UNSEAL_KEY1=$(cat vault-cluster-vault-2020-10-01T23_52_23.801Z.json | jq -r '.keys_base64[0]')
$ VAULT_UNSEAL_KEY2=$(cat vault-cluster-vault-2020-10-01T23_52_23.801Z.json | jq -r '.keys_base64[1]')
$ kubectl exec vault-0 -- vault operator unseal $VAULT_UNSEAL_KEY1
Key Value
--- -----
Seal Type shamir
Initialized true
Sealed true
Total Shares 3
Threshold 2
Unseal Progress 1/2
Unseal Nonce 5aba5b42-dde9-a5b9-022d-3fb753e103ad
Version 1.5.2
HA Enabled false
$ kubectl exec vault-0 -- vault operator unseal $VAULT_UNSEAL_KEY2
Key Value
--- -----
Seal Type shamir
Initialized true
Sealed false
Total Shares 3
Threshold 2
Version 1.5.2
Cluster Name vault-cluster-c20f2dc8
Cluster ID 001a2736-d40e-6bd9-8ca6-b3a60afd4e5c
HA Enabled false
And we can see the pods
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
vault-0 1/1 Running 0 6m58s
vault-agent-injector-bdbf7b844-kqbnp 1/1 Running 0 20h
webapp-66bd6d455d-6rnfm 1/1 Running 0 14m
Setting HA
Let’s create an AKV backend.
First we need an AKV to use
$ az keyvault create -n ijvaultakv -g ijvaultk8srg
{
"id": "/subscriptions/70b42e6a-6faf-4fed-bcec-9f3995b1aca8/resourceGroups/ijvaultk8srg/providers/Microsoft.KeyVault/vaults/ijvaultakv",
"location": "centralus",
"name": "ijvaultakv",
"properties": {
"accessPolicies": [
{
"applicationId": null,
"objectId": "170d4247-9bca-4e26-82f9-919c7ec00101",
"permissions": {
"certificates": [
"get",
"list",
"delete",
"create",
"import",
"update",
"managecontacts",
"getissuers",
"listissuers",
"setissuers",
"deleteissuers",
"manageissuers",
"recover"
],
"keys": [
"get",
"create",
"delete",
"list",
"update",
"import",
"backup",
"restore",
"recover"
],
"secrets": [
"get",
"list",
"set",
"delete",
"backup",
"restore",
"recover"
],
"storage": [
"get",
"list",
"delete",
"set",
"update",
"regeneratekey",
"setsas",
"listsas",
"getsas",
"deletesas"
]
},
"tenantId": "d73a39db-6eda-495d-8000-7579f56d68b7"
}
],
"createMode": null,
"enablePurgeProtection": null,
"enableSoftDelete": true,
"enabledForDeployment": false,
"enabledForDiskEncryption": null,
"enabledForTemplateDeployment": null,
"networkAcls": null,
"privateEndpointConnections": null,
"provisioningState": "Succeeded",
"sku": {
"name": "standard"
},
"tenantId": "d73a39db-6eda-495d-8000-7579f56d68b7",
"vaultUri": "https://ijvaultakv.vault.azure.net/"
},
"resourceGroup": "ijvaultk8srg",
"tags": {},
"type": "Microsoft.KeyVault/vaults"
}
We can now create a “seal” block in the config for auto-unseal:
config: |
ui = true
listener "tcp" {
tls_disable = 1
address = "[::]:8200"
cluster_address = "[::]:8201"
}
storage "file" {
path = "/vault/data"
}
seal "azurekeyvault" {
client_id = "4b3095be-8939-4e49-9a43-81fe1580b21c"
tenant_id = "aabbccdd-eeff-1122-3344-5566778899aa"
client_secret = "aabbccdd-d4aa-4bef-8598-5566778899aa"
vault_name = "ijvaultakv"
key_name = "vaultkey"
}
We then need to make a key… that word is so overloaded.. I kept thinking that we needed to create a “secret” with the shamir key value. But no, what it needs is a real 2048 RSA key in the keyvault made.
Let's check the logs
$ kubectl logs vault-0
==> Vault server configuration:
Azure Environment: AzurePublicCloud
Azure Key Name: vaultkey
Azure Vault Name: ijvaultakv
Api Address: http://10.240.0.19:8200
Cgo: disabled
Cluster Address: https://vault-0.vault-internal:8201
Go Version: go1.14.7
Listener 1: tcp (addr: "[::]:8200", cluster address: "[::]:8201", max_request_duration: "1m30s", max_request_size: "33554432", tls: "disabled")
Log Level: info
Mlock: supported: true, enabled: false
Recovery Mode: false
Storage: file
Version: Vault v1.5.2
Version Sha: 685fdfa60d607bca069c09d2d52b6958a7a2febd
==> Vault server started! Log data will stream in below:
2020-10-02T21:43:50.057Z [INFO] proxy environment: http_proxy= https_proxy= no_proxy=
2020-10-02T21:43:50.349Z [WARN] core: entering seal migration mode; Vault will not automatically unseal even if using an autoseal: from_barrier_type=shamir to_barrier_type=azurekeyvault
Another issue i encountered was that the default key permissions do not include unwrap:
/ $ vault operator unseal -migrate
Unseal Key (will be hidden):
Error unsealing: Error making API request.
URL: PUT http://127.0.0.1:8200/v1/sys/unseal
Code: 500. Errors:
* error setting new recovery key information: failed to encrypt keys for storage: keyvault.BaseClient#WrapKey: Failure responding to request: StatusCode=403 -- Original Error: autorest/azure: Service returned an error. Status=403 Code="Forbidden" Message="The user, group or application 'appid=4b3095be-8939-4e49-9a43-81fe1580b21c;oid=83b5ef3a-f498-491d-8b89-973321cbe480;iss=https://sts.windows.net/aabbccdd-eeff-1122-3344-5566778899aa/' does not have keys wrapKey permission on key vault 'ijvaultakv;location=centralus'. For help resolving this issue, please see https://go.microsoft.com/fwlink/?linkid=2125287" InnerError={"code":"ForbiddenByPolicy"}
So add the Cryptographic Operations
I added the rest of the cryto ops
I then relaunched the migrate
/ $ vault operator unseal -migrate
Unseal Key (will be hidden):
Key Value
--- -----
Recovery Seal Type shamir
Initialized true
Sealed true
Total Recovery Shares 3
Threshold 2
Unseal Progress 1/2
Unseal Nonce 8257a76d-c65e-dd8a-0e1e-7818b53d9cd8
Seal Migration in Progress true
Version 1.5.2
HA Enabled false
/ $ vault operator unseal -migrate
Unseal Key (will be hidden):
Key Value
--- -----
Recovery Seal Type shamir
Initialized true
Sealed false
Total Recovery Shares 3
Threshold 2
Version 1.5.2
Cluster Name vault-cluster-c20f2dc8
Cluster ID 001a2736-d40e-6bd9-8ca6-b3a60afd4e5c
HA Enabled false
We can now see it’s unsealed:
/ $ vault status
Key Value
--- -----
Recovery Seal Type shamir
Initialized true
Sealed false
Total Recovery Shares 3
Threshold 2
Version 1.5.2
Cluster Name vault-cluster-c20f2dc8
Cluster ID 001a2736-d40e-6bd9-8ca6-b3a60afd4e5c
HA Enabled false
Let’s rotate the pod to make sure it works….
$ kubectl delete pod vault-0
pod "vault-0" deleted
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
vault-0 0/1 ContainerCreating 0 3s
vault-agent-injector-bdbf7b844-kqbnp 1/1 Running 0 22h
webapp-66bd6d455d-6rnfm 1/1 Running 0 114m
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
vault-0 0/1 ContainerCreating 0 19s
vault-agent-injector-bdbf7b844-kqbnp 1/1 Running 0 22h
webapp-66bd6d455d-6rnfm 1/1 Running 0 114m
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
vault-0 0/1 ContainerCreating 0 34s
vault-agent-injector-bdbf7b844-kqbnp 1/1 Running 0 22h
webapp-66bd6d455d-6rnfm 1/1 Running 0 114m
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
vault-0 1/1 Running 0 45s
vault-agent-injector-bdbf7b844-kqbnp 1/1 Running 0 22h
webapp-66bd6d455d-6rnfm 1/1 Running 0 115m
And indeed as we see about, it auto unsealed.
Summary
Hashi Vault has always been a great a tool for secrets management but it's native Kubernetes support makes it that much more accessible in a containerized topology. By using Vault as a service within a namespace we can leverage a robust cloud agnostic solution for our secrets. When we add auto-unseal (as we saw above) and HA via Consul we can actually have a solid solution that spans clouds.