Add Vault auto-unseal service and 1Password bootstrap script
Add vault-tpm2-unseal.sh/.service to unseal Vault once after boot using the TPM-sealed key, and tpm-bootstrap-1password.sh to (re)seal that key from a 1Password secret reference. Document PCR policy selection, the Vault service, and the host-specific tpm2.env (gitignored, example in README) in README.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
*.env
|
||||||
@@ -126,6 +126,125 @@ This policy binding does not protect the password from a process running with su
|
|||||||
|
|
||||||
Any firmware, bootloader, or Secure Boot configuration change that alters the measured value of the bound PCRs invalidates the existing seal. The password must be resealed against the new PCR values (rerun `tpm-seal-password.sh -f`) before it can be unsealed again.
|
Any firmware, bootloader, or Secure Boot configuration change that alters the measured value of the bound PCRs invalidates the existing seal. The password must be resealed against the new PCR values (rerun `tpm-seal-password.sh -f`) before it can be unsealed again.
|
||||||
|
|
||||||
|
## tpm-bootstrap-1password.sh
|
||||||
|
|
||||||
|
Alternative to `tpm-seal-password.sh` for the sealing pair: reads the password from 1Password via the `op` CLI instead of an interactive prompt, and seals it with the same PCR-policy binding. Requires the 1Password CLI (`op`) in addition to `tpm2-tools`.
|
||||||
|
|
||||||
|
This still requires an operator present: `op read` needs either an active `op signin` session or approval through the 1Password desktop app's biometric unlock integration. It is meant for (re)initializing the seal used by `vault-tpm2-unseal.service` from a canonical copy of the Vault unseal key kept in 1Password, not for unattended use.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
tpm-bootstrap-1password.sh [-c config] [-H handle] [-l pcr-list] [-f]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `-c config` : Environment file providing `OP_SECRET_REFERENCE` and, optionally, `TPM_HANDLE`/`TPM_PCR_LIST`. Default: `/etc/vault.d/tpm2.env`.
|
||||||
|
- `-H handle` : Persistent TPM handle to store the sealed password at. Overrides `TPM_HANDLE` from the config file. Falls back to `0x81010001` if neither is set.
|
||||||
|
- `-l pcr-list` : PCR bank and indices to bind release to. Overrides `TPM_PCR_LIST` from the config file. Falls back to `sha256:7` if neither is set.
|
||||||
|
- `-f` : Evict an existing object at that handle before sealing.
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
1. Checks that `tpm2-tools` and `op` are installed.
|
||||||
|
2. Reads the config file and requires `OP_SECRET_REFERENCE` to be set in it.
|
||||||
|
3. Checks whether an object already exists at the target handle. If one exists and `-f` was not given, the script exits with an error.
|
||||||
|
4. Runs `op read` against `OP_SECRET_REFERENCE` (format `op://vault/item/field`) to retrieve the password.
|
||||||
|
5. Creates a primary key under the TPM owner hierarchy, builds a policy digest from the current values of the given PCRs, and seals the password under that policy, following the same steps as `tpm-seal-password.sh`.
|
||||||
|
6. Persists the sealed object at the target handle and removes all temporary key material.
|
||||||
|
|
||||||
|
### Exit status
|
||||||
|
|
||||||
|
Non-zero if `tpm2-tools` or `op` is missing, if the config file is unreadable or missing `OP_SECRET_REFERENCE`, if a sealed object already exists at the handle without `-f`, or if `op read` returns an empty value.
|
||||||
|
|
||||||
|
## PCR policies
|
||||||
|
|
||||||
|
`tpm-seal-password.sh`/`tpm-unseal-password.sh` default to `-l sha256:7`. PCR 7 records the UEFI Secure Boot policy state: the platform key, key exchange key, signature database, and forbidden signature database in effect, plus whether Secure Boot enforcement is on. Firmware vendors differ in how PCR 7 behaves when Secure Boot is disabled in the UEFI setup: some still extend it with a fixed "disabled" measurement on every boot, others leave it at its unextended reset value, and some populate it inconsistently across boots. On hardware where Secure Boot is off, PCR 7 is not a reliable anchor to seal against, and `tpm-unseal-password.sh` can fail even though nothing relevant to the boot chain changed.
|
||||||
|
|
||||||
|
Check what a given PCR actually reads on the target hardware before choosing a policy:
|
||||||
|
|
||||||
|
```
|
||||||
|
tpm2_pcrread sha256:0,1,2,3,4,5,6,7
|
||||||
|
```
|
||||||
|
|
||||||
|
Run this twice, after two separate reboots, and compare. A PCR whose value changes between otherwise-identical boots is not usable for a policy.
|
||||||
|
|
||||||
|
Other commonly available PCRs, per the TCG PC Client Platform Firmware Profile:
|
||||||
|
|
||||||
|
- PCR 0 : firmware/BIOS code (CRTM, POST code, embedded option ROMs). Changes on a firmware update.
|
||||||
|
- PCR 1 : firmware/BIOS configuration data (UEFI setup settings).
|
||||||
|
- PCR 2 : option ROM code (add-in card firmware, e.g. RAID or NIC controllers).
|
||||||
|
- PCR 3 : option ROM configuration and data.
|
||||||
|
- PCR 4 : boot manager/IPL code, the actual bootloader binary handed control by firmware (e.g. `shimx64.efi`/`grubx64.efi`). Changes whenever the bootloader package is upgraded.
|
||||||
|
- PCR 5 : boot manager/IPL configuration data (partition table).
|
||||||
|
- PCR 6 : platform state transition and wake events.
|
||||||
|
- PCR 7 : Secure Boot policy state, as above.
|
||||||
|
|
||||||
|
PCR 4 is measured by firmware regardless of whether Secure Boot enforcement is on, because it is part of TCG measured boot rather than Secure Boot specifically: firmware records the hash of whatever bootloader binary it loads, whether or not it also verifies a signature on it. This makes PCR 4, alone or combined with PCR 0, a more direct fit than PCR 7 for a machine that does not use Secure Boot: `-l sha256:0,4` binds release to "the same firmware, loading the same bootloader binary" without relying on a Secure Boot certificate database that is not in use.
|
||||||
|
|
||||||
|
The tradeoff is reseal frequency: PCR 0 changes on a firmware/BIOS update, and PCR 4 changes on every bootloader package upgrade (`grub-efi-amd64` on Ubuntu), which happens more often than firmware updates. Each such change requires rerunning `tpm-seal-password.sh -f` with the new PCR values before `tpm-unseal-password.sh` will succeed again, and Vault stays sealed in the meantime unless a fallback (manual unseal) is available.
|
||||||
|
|
||||||
|
## Vault auto-unseal service
|
||||||
|
|
||||||
|
`vault-tpm2-unseal.sh`, `vault-tpm2-unseal.service`, and `/etc/vault.d/tpm2.env` automatically unseal a HashiCorp Vault instance once, right after `vault.service` starts, using a key sealed with `tpm-seal-password.sh`.
|
||||||
|
|
||||||
|
`tpm2.env` is host-specific (it names the local Vault address and the local TPM handle) and is not stored in this repository. Create it on each host from the example below.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
The Vault unseal key must already be sealed into the TPM with `tpm-seal-password.sh`, at the handle and PCR list referenced in `tpm2.env`.
|
||||||
|
|
||||||
|
### Example tpm2.env
|
||||||
|
|
||||||
|
```
|
||||||
|
# Create at /etc/vault.d/tpm2.env, mode 600, owned by root.
|
||||||
|
|
||||||
|
VAULT_ADDR=https://127.0.0.1:8200
|
||||||
|
TPM_UNSEAL_SCRIPT=/usr/local/sbin/tpm-unseal-password.sh
|
||||||
|
TPM_HANDLE=0x81010001
|
||||||
|
TPM_PCR_LIST=sha256:7
|
||||||
|
|
||||||
|
# 1Password secret reference to the Vault unseal key, used only by
|
||||||
|
# tpm-bootstrap-1password.sh, e.g. op://Homelab/Vault Unseal Key/password
|
||||||
|
OP_SECRET_REFERENCE=
|
||||||
|
```
|
||||||
|
|
||||||
|
- `VAULT_ADDR` : Vault API address, e.g. `https://127.0.0.1:8200`.
|
||||||
|
- `TPM_UNSEAL_SCRIPT` : path to the installed `tpm-unseal-password.sh`. Default: `/usr/local/sbin/tpm-unseal-password.sh`.
|
||||||
|
- `TPM_HANDLE` : persistent TPM handle the key was sealed at. Default: `0x81010001`.
|
||||||
|
- `TPM_PCR_LIST` : PCR bank/indices the key was bound to. Default: `sha256:7`.
|
||||||
|
- `OP_SECRET_REFERENCE` : 1Password secret reference to the Vault unseal key, read only by `tpm-bootstrap-1password.sh`. Format: `op://vault/item/field`.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```
|
||||||
|
install -m 0755 -o root -g root tpm-unseal-password.sh /usr/local/sbin/tpm-unseal-password.sh
|
||||||
|
install -m 0755 -o root -g root tpm-bootstrap-1password.sh /usr/local/sbin/tpm-bootstrap-1password.sh
|
||||||
|
install -m 0755 -o root -g root vault-tpm2-unseal.sh /usr/local/sbin/vault-tpm2-unseal.sh
|
||||||
|
install -m 0644 -o root -g root vault-tpm2-unseal.service /etc/systemd/system/vault-tpm2-unseal.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable vault-tpm2-unseal.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `/etc/vault.d/tpm2.env` from the example above, with mode 600 owned by root, and fill in the values for the target host.
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
`vault-tpm2-unseal.service` is a `Type=oneshot` unit ordered `After=`/`Requires=` `vault.service`. Since the packaged `vault.service` is `Type=notify`, systemd starts this unit only once Vault has signaled it is ready (its HTTP listener is up), whether or not it is still sealed. `[Install] WantedBy=vault.service` means that once enabled, starting `vault.service` also starts this unit; it does not need to be in any target's default dependency chain.
|
||||||
|
|
||||||
|
`vault-tpm2-unseal.sh` then:
|
||||||
|
|
||||||
|
1. Checks that the `vault` CLI and the configured `TPM_UNSEAL_SCRIPT` are present.
|
||||||
|
2. Runs `vault status` against `VAULT_ADDR`. If Vault is already unsealed, the script exits successfully without doing anything further, making the service safe to run on every Vault start regardless of how Vault was last sealed.
|
||||||
|
3. If Vault is sealed, runs `tpm-unseal-password.sh` to retrieve the key from the TPM and passes it to `vault operator unseal`.
|
||||||
|
|
||||||
|
### Exit status
|
||||||
|
|
||||||
|
Non-zero if the `vault` CLI or the configured TPM unseal script is missing, if `vault status` fails for a reason other than being sealed, or if `tpm-unseal-password.sh` fails (for example because the current PCR values no longer satisfy the seal policy — see [PCR policies](#pcr-policies)).
|
||||||
|
|
||||||
|
### Security note
|
||||||
|
|
||||||
|
The unseal key is passed to `vault operator unseal` as a command-line argument, since the Vault CLI does not document a way to supply it via stdin. This makes the key briefly visible to anything on the same host inspecting the process list (`ps`) while the command runs. This is a tradeoff inherent to scripting Vault's unseal command, not specific to sourcing the key from the TPM.
|
||||||
|
|
||||||
## Handles
|
## Handles
|
||||||
|
|
||||||
`tpm-nv-store-password.sh`/`tpm-nv-read-password.sh` default to NV index `0x1500016`, in the NV index handle range (`0x01000000`-`0x01FFFFFF`).
|
`tpm-nv-store-password.sh`/`tpm-nv-read-password.sh` default to NV index `0x1500016`, in the NV index handle range (`0x01000000`-`0x01FFFFFF`).
|
||||||
|
|||||||
Executable
+117
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Bootstrap the TPM 2.0 seal used by vault-tpm2-unseal.service: reads the
|
||||||
|
# Vault unseal key from 1Password via the op CLI, using the secret
|
||||||
|
# reference configured in the environment file, and seals it with the same
|
||||||
|
# PCR-policy binding as tpm-seal-password.sh.
|
||||||
|
#
|
||||||
|
# This replaces interactive password entry with an op read, but op read
|
||||||
|
# itself still requires an operator present to approve the request (device
|
||||||
|
# biometric unlock or an active `op signin` session) - this script does not
|
||||||
|
# run unattended.
|
||||||
|
#
|
||||||
|
# Requires: tpm2-tools, the 1Password CLI (op), access to /dev/tpmrm0.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONFIG="/etc/vault.d/tpm2.env"
|
||||||
|
HANDLE=""
|
||||||
|
PCR_LIST=""
|
||||||
|
FORCE=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<-EOF
|
||||||
|
Usage: $(basename "$0") [-c config] [-H handle] [-l pcr-list] [-f]
|
||||||
|
|
||||||
|
-c config Environment file providing OP_SECRET_REFERENCE and,
|
||||||
|
optionally, TPM_HANDLE/TPM_PCR_LIST (default: ${CONFIG})
|
||||||
|
-H handle Persistent TPM handle to store the sealed password at,
|
||||||
|
overrides TPM_HANDLE from the config file
|
||||||
|
-l pcr-list PCR bank and indices to bind release to, overrides
|
||||||
|
TPM_PCR_LIST from the config file
|
||||||
|
-f Evict an existing object at that handle before sealing
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while getopts "c:H:l:fh" opt; do
|
||||||
|
case "$opt" in
|
||||||
|
c) CONFIG="$OPTARG" ;;
|
||||||
|
H) HANDLE="$OPTARG" ;;
|
||||||
|
l) PCR_LIST="$OPTARG" ;;
|
||||||
|
f) FORCE=1 ;;
|
||||||
|
h) usage; exit 0 ;;
|
||||||
|
*) usage; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -r "$CONFIG" ] || {
|
||||||
|
echo "Cannot read configuration file: ${CONFIG}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$CONFIG"
|
||||||
|
|
||||||
|
: "${OP_SECRET_REFERENCE:?OP_SECRET_REFERENCE must be set in ${CONFIG}}"
|
||||||
|
HANDLE="${HANDLE:-${TPM_HANDLE:-0x81010001}}"
|
||||||
|
PCR_LIST="${PCR_LIST:-${TPM_PCR_LIST:-sha256:7}}"
|
||||||
|
|
||||||
|
command -v tpm2_createprimary >/dev/null || {
|
||||||
|
echo "tpm2-tools is required (apt install tpm2-tools)" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
command -v op >/dev/null || {
|
||||||
|
echo "1Password CLI (op) is required" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if tpm2_readpublic -c "$HANDLE" >/dev/null 2>&1; then
|
||||||
|
if [ "$FORCE" -eq 1 ]; then
|
||||||
|
tpm2_evictcontrol -C o -c "$HANDLE" >/dev/null
|
||||||
|
else
|
||||||
|
echo "A sealed object already exists at ${HANDLE}; rerun with -f to replace it" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
WORKDIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$WORKDIR"' EXIT
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
echo "Reading ${OP_SECRET_REFERENCE} from 1Password; approve the request if prompted." >&2
|
||||||
|
|
||||||
|
PASSWORD="$(op read "$OP_SECRET_REFERENCE")"
|
||||||
|
|
||||||
|
[ -n "$PASSWORD" ] || {
|
||||||
|
echo "op read returned an empty value for ${OP_SECRET_REFERENCE}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
tpm2_createprimary -C o -c "$WORKDIR/primary.ctx" -Q
|
||||||
|
|
||||||
|
tpm2_createpolicy -Q \
|
||||||
|
--policy-pcr \
|
||||||
|
-l "$PCR_LIST" \
|
||||||
|
-L "$WORKDIR/policy.digest"
|
||||||
|
|
||||||
|
printf '%s' "$PASSWORD" | tpm2_create \
|
||||||
|
-C "$WORKDIR/primary.ctx" \
|
||||||
|
-L "$WORKDIR/policy.digest" \
|
||||||
|
-i - \
|
||||||
|
-u "$WORKDIR/seal.pub" \
|
||||||
|
-r "$WORKDIR/seal.priv" \
|
||||||
|
-Q
|
||||||
|
|
||||||
|
unset PASSWORD
|
||||||
|
|
||||||
|
tpm2_load \
|
||||||
|
-C "$WORKDIR/primary.ctx" \
|
||||||
|
-u "$WORKDIR/seal.pub" \
|
||||||
|
-r "$WORKDIR/seal.priv" \
|
||||||
|
-c "$WORKDIR/seal.ctx" \
|
||||||
|
-Q
|
||||||
|
|
||||||
|
tpm2_evictcontrol -C o -c "$WORKDIR/seal.ctx" "$HANDLE" >/dev/null
|
||||||
|
|
||||||
|
echo "Password from ${OP_SECRET_REFERENCE} sealed and persisted at handle ${HANDLE}, bound to PCRs ${PCR_LIST}"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Unseal HashiCorp Vault using a TPM 2.0 sealed key
|
||||||
|
Documentation=file:/etc/vault.d/tpm2.env
|
||||||
|
Requires=vault.service
|
||||||
|
After=vault.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
EnvironmentFile=/etc/vault.d/tpm2.env
|
||||||
|
ExecStart=/usr/local/sbin/vault-tpm2-unseal.sh
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=vault.service
|
||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Unseal a HashiCorp Vault instance using a password sealed into the TPM 2.0
|
||||||
|
# with tpm-seal-password.sh. Intended to run once, after vault.service has
|
||||||
|
# started, via vault-tpm2-unseal.service.
|
||||||
|
# Configuration is read from environment variables, normally supplied by
|
||||||
|
# systemd's EnvironmentFile= from /etc/vault.d/tpm2.env:
|
||||||
|
# VAULT_ADDR Vault API address (required)
|
||||||
|
# TPM_UNSEAL_SCRIPT Path to tpm-unseal-password.sh (default: /usr/local/sbin/tpm-unseal-password.sh)
|
||||||
|
# TPM_HANDLE Persistent TPM handle the key was sealed at (default: 0x81010001)
|
||||||
|
# TPM_PCR_LIST PCR bank/indices the key was bound to (default: sha256:7)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${VAULT_ADDR:?VAULT_ADDR must be set}"
|
||||||
|
TPM_UNSEAL_SCRIPT="${TPM_UNSEAL_SCRIPT:-/usr/local/sbin/tpm-unseal-password.sh}"
|
||||||
|
TPM_HANDLE="${TPM_HANDLE:-0x81010001}"
|
||||||
|
TPM_PCR_LIST="${TPM_PCR_LIST:-sha256:7}"
|
||||||
|
|
||||||
|
command -v vault >/dev/null || {
|
||||||
|
echo "vault CLI not found in PATH" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[ -x "$TPM_UNSEAL_SCRIPT" ] || {
|
||||||
|
echo "${TPM_UNSEAL_SCRIPT} not found or not executable" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
set +e
|
||||||
|
vault status -address="$VAULT_ADDR" >/dev/null
|
||||||
|
STATUS=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$STATUS" in
|
||||||
|
0)
|
||||||
|
echo "Vault at ${VAULT_ADDR} is already unsealed"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "vault status against ${VAULT_ADDR} failed (exit ${STATUS})" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
KEY=$("$TPM_UNSEAL_SCRIPT" -H "$TPM_HANDLE" -l "$TPM_PCR_LIST")
|
||||||
|
|
||||||
|
vault operator unseal -address="$VAULT_ADDR" "$KEY" >/dev/null
|
||||||
|
|
||||||
|
unset KEY
|
||||||
|
|
||||||
|
echo "Vault at ${VAULT_ADDR} unsealed via TPM handle ${TPM_HANDLE}"
|
||||||
Reference in New Issue
Block a user