vault-tools — Encrypted Credential Storage
Overview
vault-tools.sh is a small, dependency-free (openssl only) Bash toolkit
for storing shared or personal credentials at rest, encrypted, on Linux
systems — no ansible-vault, no enterprise password vault, no external
service required.
It exists to bridge a specific gap: some systems don't have
ansible-vault available, and there's no enterprise secrets vault
(Bitwarden, HashiCorp Vault, etc.) in place yet. Until one is, this fills
that role well enough for day-to-day use, under a few hard constraints
that shaped its design:
- No unencrypted credential may ever be stored in a file — ciphertext at rest is fine; plaintext at rest, even briefly, is not.
- No hardware tokens available — no YubiKeys, and PKI certs can't be bridged from a workstation to these hosts, so nothing here depends on smart cards or hardware-backed keys.
- Must work without
ansible-vault— some hosts in scope don't have Ansible Automation Platform installed.
The result: credentials live in a single openssl-encrypted file
(secrets.sh.enc by default) and get decrypted straight into shell
variables — never into a plaintext file on disk — either at login or on
demand.
This is explicitly an interim solution. It's a reasonable stopgap while the case for a proper enterprise vault gets made to management, not a long-term replacement for one.
Requirements
openssl— a common OS tool, present on most every install with no extra packages needed.- Bash 4+ — Most Linux ship this by default.
- An
$EDITOR(defaults tovi) — only needed if you usevault_edit.
Installation
- Place
vault-tools.shsomewhere readable by whoever needs it — a personal home directory for a personal vault, or a shared path (with appropriate group permissions) for a team vault. - Source it wherever you want the
vault_*functions available — typically~/.bashrcfor every interactive shell, or~/.bash_profileif you only want it (and the automatic vault load, see below) once per login. - Optionally set
VAULT_FILEbefore sourcing, if you don't want the defaultsecrets.sh.encin the current directory:
bash
export VAULT_FILE=/opt/secrets/team.sh.enc
source /opt/secrets/vault-tools.sh
Security model, plainly stated
- Confidentiality, not authenticity. Encryption is AES-256-CBC with
PBKDF2 key derivation. This protects the contents of the vault but
doesn't detect tampering the way an HMAC-based scheme (like
ansible-vault's format) would. If that matters more to you than simplicity, that's a real trade-off to weigh, not an oversight. - Plaintext never touches persistent disk in the
vault_set,vault_unset,vault_list, andvault_loadpaths — decrypted content only ever lives in shell variables, held in memory for the life of the function call.vault_editis the one exception: it decrypts to a file under/dev/shm(tmpfs, RAM-backed) so an interactive editor has something real to save to, then re-encrypts and removes it. /dev/shmisn't disk — as long as the system isn't swapping. If swap is enabled and unencrypted, and the system is under enough memory pressure, that RAM-backed file could theoretically be paged out. Worth knowing ifvault_editis in regular use on memory-constrained hosts.- Bash can't scrub memory.
unsetandrmfree the variable or the inode; neither guarantees the underlying bytes get zeroed. This is a ceiling on what any bash-based approach can guarantee, not a bug here. - Sourcing this file won't change your shell's settings. Every
function scopes
set -euo pipefailto itself vialocal -, so your interactive shell's own options (or lack of them) are untouched before and after anyvault_*call.
Lifecycle
The functions map to a natural sequence: create a vault once, then read and write it as needed.
1. vault_init — create the vault
Run once, per vault. Refuses to run if VAULT_FILE already exists, so it
can't accidentally overwrite a populated vault.
$ vault_init
enter AES-256-CBC encryption password:
Verifying - enter AES-256-CBC encryption password:
Created secrets.sh.enc. Use vault_set to add your first secret.
openssl prompts twice on purpose here — a typo on first-time encryption
would otherwise lock you out of a vault with nothing in it yet worth
losing, so it's caught immediately instead.
2. vault_set — add or update a secret
$ vault_set DB_PASSWORD 'correct-horse-battery-staple'
Updated DB_PASSWORD in secrets.sh.enc.
Adds a new export VAR=value line, or updates it in place if VAR
already exists. Values are shell-escaped automatically, so quotes,
spaces, and $ in a password round-trip correctly. Decrypts, edits, and
re-encrypts to a temp file that's atomically moved into place — the
original vault is never touched unless the whole operation succeeds
(wrong password included: nothing gets written).
3. vault_unset — remove a secret
$ vault_unset DB_PASSWORD
Removed DB_PASSWORD from secrets.sh.enc.
4. vault_edit — free-form editing
For anything vault_set/vault_unset don't cover cleanly — reordering,
touching several variables at once, adding comments:
$ vault_edit
# opens $EDITOR on the decrypted contents (via /dev/shm); re-encrypts on save/exit
5. vault_list — see what's in the vault
$ vault_list
DB_PASSWORD
API_KEY
Prints variable names only — never values — so you can check the vault's contents without a secret value ending up in your terminal scrollback.
6. vault_load — use the secrets
The step that actually gets the credentials into your shell:
$ source <(vault_load)
enter AES-256-CBC decryption password:
$ echo "$DB_PASSWORD"
correct-horse-battery-staple
Typically wired into ~/.bash_profile so it runs automatically at login:
# ~/.bash_profile
source ~/vault-tools.sh
source <(vault_load)
One caveat worth knowing: source <(vault_load) doesn't always fail
loudly if decryption fails — a missing vault produces no output at all,
so source trivially succeeds on empty input and nothing gets loaded; a
wrong password usually (not always) surfaces as a visible syntax error,
since openssl can emit partial garbled output before it detects the
bad padding. vault_load's own error message always prints to stderr
either way, so you'll see something at an interactive login — but for
a guaranteed clean failure instead of relying on that, use:
if vault_secrets="$(vault_load)"; then
source <(printf '%s' "$vault_secrets")
else
echo "vault load failed, nothing was set" >&2
fi
unset vault_secrets
Function reference
| Function | Purpose |
|---|---|
vault_init |
Create a new, empty vault (refuses if one already exists) |
vault_set VAR VALUE |
Add or update one secret |
vault_unset VAR |
Remove one secret |
vault_edit |
Open the decrypted vault in $EDITOR for free-form changes |
vault_list |
List secret names (not values) |
vault_load |
Decrypt to stdout — pair with source <(vault_load) |
Use case 1: personal vault
Scenario: an individual engineer wants credentials for their own lab or test systems available at login, without retyping them each session or leaving them sitting in a plaintext dotfile.
# one-time setup
export VAULT_FILE=~/.secrets/personal.sh.enc
mkdir -p ~/.secrets
source ~/vault-tools.sh
vault_init
vault_set LAB_ROOT_PW 'whatever-it-is'
vault_set TEST_DB_URI 'postgres://user:pw@testhost/db'
# wire into ~/.bash_profile
cat >> ~/.bash_profile <<'EOF'
export VAULT_FILE=~/.secrets/personal.sh.enc
source ~/vault-tools.sh
source <(vault_load)
EOF
From here, every login prompts once for the vault password and the credentials are just environment variables for the rest of the session. Since it's single-user, there's no sharing problem to solve — the password is whatever the individual chooses, and nobody else needs it.
Use case 2: shared team vault
Scenario: a team shares a small set of service-account or system credentials across several engineers on a jump host or shared automation account.
# one person sets it up, on a path the whole team can read
sudo mkdir -p /opt/team-secrets
sudo chown :devsecops-team /opt/team-secrets
sudo chmod 750 /opt/team-secrets
export VAULT_FILE=/opt/team-secrets/team.sh.enc
source /opt/team-secrets/vault-tools.sh
vault_init
vault_set SVC_ACCOUNT_PW 'whatever-it-is'
vault_set GITLAB_RUNNER_TOKEN 'glrt-...'
# each team member adds to their own ~/.bash_profile
cat >> ~/.bash_profile <<'EOF'
export VAULT_FILE=/opt/team-secrets/team.sh.enc
source /opt/team-secrets/vault-tools.sh
source <(vault_load)
EOF
Whoever needs to add or change a secret uses vault_set/vault_unset/
vault_edit against the same shared path; everyone else picks up the
change the next time they log in and vault_load runs.
A team vault has one property worth calling out explicitly: this is symmetric encryption — one shared password protects everything in the vault, for everyone. There's no per-person key to revoke when someone leaves the team, the way there would be with GPG or a PKI-backed approach. When someone leaves:
- Rotate the vault's own password (re-encrypt with
vault_editor by recreating it), and - Rotate every credential actually stored inside it.
Both matter — changing just the vault password doesn't erase what a departing member already saw in plaintext while they had access.
Known limitations (summary)
- Confidentiality only — no built-in tamper/authentication check.
/dev/shm-based editing depends on swap being off or encrypted to truly never touch physical disk.- Bash can't guarantee decrypted values are scrubbed from memory.
source <(vault_load)can fail silently in one specific case (missing vault); use the capture-then-source form above if that's a concern.- This is an interim tool. It exists to close the gap responsibly until an enterprise vault is in place — not to replace the case for one.
Code
This is the code - I need to convince MKDocs to include it dynamically.
Link: https://www.linder.org/blog/2026/09/07/vault-tools.sh
#!/usr/bin/env bash
# vault-tools.sh — helpers for reading and updating an openssl-encrypted
# secrets file (secrets.sh.enc) without ever writing decrypted plaintext
# to persistent disk storage.
#
# Source this file (e.g. from ~/.bashrc) to get the vault_set, vault_unset,
# vault_edit, and vault_list functions in your interactive shell.
#
# Security notes (read before relying on this):
# - Decrypted content only ever lives in shell variables, or, for
# vault_edit, a file under /dev/shm (tmpfs/RAM-backed). Neither
# guarantees the plaintext is scrubbed from RAM — `unset` and `rm`
# free the memory/inode but don't zero the underlying bytes. That's
# an inherent limit of doing this in bash, not something these
# functions can fully close.
# - /dev/shm is "never touches disk" only as long as the system isn't
# under enough memory pressure to swap. If swap is enabled and
# unencrypted, that's a real (if edge-case) gap for vault_edit.
# - This gives confidentiality via AES-256-CBC, not authenticated
# encryption — same trade-off discussed earlier, unchanged here.
#
# Each function runs under `set -euo pipefail`, scoped to itself via
# `local -` so it doesn't change shell options in whatever interactive
# shell you sourced this into (a bare `set -e` at the top of a *sourced*
# file would do that, silently, for the rest of your session).
#
# Typical login setup (e.g. in ~/.bash_profile):
# source ~/vault-tools.sh
# source <(vault_load)
# This prompts once for the vault password and exports everything in
# the vault into your login shell. Caveat: if decryption fails, this
# exact form doesn't always fail loudly — a missing vault produces no
# output, so `source` trivially "succeeds" on empty input and nothing
# gets loaded; a wrong password usually (not always) surfaces as a
# visible syntax error, since openssl can emit partial garbled output
# before it detects the bad padding. The stderr message from vault_load
# itself always prints either way, so you'll see *something*, but if
# you want a guaranteed clean failure instead of relying on that:
# if vault_secrets="$(vault_load)"; then
# source <(printf '%s' "$vault_secrets")
# else
# echo "vault load failed, nothing was set" >&2
# fi
# unset vault_secrets
# To point at a non-default vault path, export VAULT_FILE before sourcing
# this file, e.g.: export VAULT_FILE=/opt/secrets/team.sh.enc
VAULT_FILE="${VAULT_FILE:-secrets.sh.enc}"
VAULT_CIPHER_OPTS=(-aes-256-cbc -pbkdf2)
# vault_load — decrypt to stdout, meant to be used as
# `source <(vault_load)` to pull every vaulted variable into your current
# shell. Kept as its own function so the cipher settings live in one
# place (VAULT_CIPHER_OPTS) instead of being duplicated in .bash_profile.
vault_load() {
local -
set -euo pipefail
if [[ ! -e "$VAULT_FILE" ]]; then
echo "${VAULT_FILE} doesn't exist yet — run vault_init first." >&2
return 1
fi
openssl enc "${VAULT_CIPHER_OPTS[@]}" -d -in "$VAULT_FILE"
}
# vault_init — create a brand-new vault. Refuses to run if VAULT_FILE
# already exists, so this can't be used to accidentally clobber one —
# use vault_edit or vault_set to change an existing vault.
vault_init() {
local -
set -euo pipefail
if [[ -e "$VAULT_FILE" ]]; then
echo "${VAULT_FILE} already exists — refusing to overwrite it. Use vault_edit or vault_set instead." >&2
return 1
fi
local tmp_out seed
seed=$'# managed by vault-tools.sh — use vault_set/vault_unset/vault_edit\n# only lines of the form "export VAR=value" are recognized by vault_list/vault_set\n'
tmp_out="$(mktemp)"
# No -pass here on purpose: openssl prompts twice (enter + verify) when
# none is given, which is exactly what you want on first-time
# encryption — a typo here with nothing else to fall back on would
# otherwise lock the vault before it holds anything worth losing.
if printf '%s' "$seed" | openssl enc "${VAULT_CIPHER_OPTS[@]}" -salt -out "$tmp_out"; then
mv -f "$tmp_out" "$VAULT_FILE"
chmod 600 "$VAULT_FILE"
echo "Created ${VAULT_FILE}. Use vault_set to add your first secret."
else
echo "Failed to create ${VAULT_FILE}." >&2
rm -f "$tmp_out"
return 1
fi
}
# vault_set VAR VALUE — add or update a single exported variable.
vault_set() {
local - # scope any 'set' changes below to this function only
set -euo pipefail # safe here — every risky command is if/&&-guarded below
local var_name="${1:-}" var_value="${2:-}"
if [[ -z "$var_name" || -z "$var_value" ]]; then
echo "usage: vault_set VAR_NAME VALUE" >&2
return 1
fi
if [[ ! "$var_name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
echo "Invalid variable name: ${var_name}" >&2
return 1
fi
local plaintext new_content="" found=0
if [[ ! -e "$VAULT_FILE" ]]; then
echo "${VAULT_FILE} doesn't exist yet — run vault_init first." >&2
return 1
fi
if ! plaintext="$(openssl enc "${VAULT_CIPHER_OPTS[@]}" -d -in "$VAULT_FILE")"; then
echo "Failed to decrypt ${VAULT_FILE} — check your password. Aborting." >&2
return 1
fi
trap 'unset plaintext new_content' RETURN
local new_line="export ${var_name}=$(printf '%q' "$var_value")"
while IFS= read -r line; do
if [[ "$line" == "export ${var_name}="* ]]; then
new_content+="${new_line}"$'\n'
found=1
else
new_content+="${line}"$'\n'
fi
done <<< "$plaintext"
[[ "$found" -eq 0 ]] && new_content+="${new_line}"$'\n'
local tmp_out
tmp_out="$(mktemp)"
if printf '%s' "$new_content" | openssl enc "${VAULT_CIPHER_OPTS[@]}" -salt -out "$tmp_out" \
&& mv -f "$tmp_out" "$VAULT_FILE"; then
echo "Updated ${var_name} in ${VAULT_FILE}."
else
echo "Failed to write updated vault — ${VAULT_FILE} left unchanged." >&2
rm -f "$tmp_out"
return 1
fi
}
# vault_unset VAR — remove a variable entirely.
vault_unset() {
local -
set -euo pipefail
local var_name="${1:-}"
if [[ -z "$var_name" ]]; then
echo "usage: vault_unset VAR_NAME" >&2
return 1
fi
local plaintext new_content=""
if [[ ! -e "$VAULT_FILE" ]]; then
echo "${VAULT_FILE} doesn't exist yet — run vault_init first." >&2
return 1
fi
if ! plaintext="$(openssl enc "${VAULT_CIPHER_OPTS[@]}" -d -in "$VAULT_FILE")"; then
echo "Failed to decrypt ${VAULT_FILE} — check your password. Aborting." >&2
return 1
fi
trap 'unset plaintext new_content' RETURN
while IFS= read -r line; do
[[ "$line" == "export ${var_name}="* ]] && continue
new_content+="${line}"$'\n'
done <<< "$plaintext"
local tmp_out
tmp_out="$(mktemp)"
if printf '%s' "$new_content" | openssl enc "${VAULT_CIPHER_OPTS[@]}" -salt -out "$tmp_out" \
&& mv -f "$tmp_out" "$VAULT_FILE"; then
echo "Removed ${var_name} from ${VAULT_FILE}."
else
echo "Failed to write updated vault — ${VAULT_FILE} left unchanged." >&2
rm -f "$tmp_out"
return 1
fi
}
# vault_list — show which variable names exist, without printing values.
vault_list() {
local -
set -euo pipefail
local plaintext
if [[ ! -e "$VAULT_FILE" ]]; then
echo "${VAULT_FILE} doesn't exist yet — run vault_init first." >&2
return 1
fi
if ! plaintext="$(openssl enc "${VAULT_CIPHER_OPTS[@]}" -d -in "$VAULT_FILE")"; then
echo "Failed to decrypt ${VAULT_FILE} — check your password." >&2
return 1
fi
# `|| true`: an empty vault makes grep report "no match" (exit 1), which
# is correct output, not a real failure — don't let pipefail+errexit
# treat that as an error.
grep '^export ' <<< "$plaintext" | sed -E 's/^export ([A-Za-z_][A-Za-z0-9_]*)=.*/\1/' || true
unset plaintext
}
# vault_edit — open the decrypted vault in $EDITOR for free-form changes.
# Uses /dev/shm (tmpfs/RAM) instead of a regular temp file, so the
# plaintext copy never touches a real disk-backed filesystem (see the
# swap caveat above). This is the fallback for edits vault_set/vault_unset
# don't cover cleanly (reordering, multi-variable changes, comments, etc).
vault_edit() {
local -
set -euo pipefail
local tmp
if [[ ! -e "$VAULT_FILE" ]]; then
echo "${VAULT_FILE} doesn't exist yet — run vault_init first." >&2
return 1
fi
tmp="$(mktemp --tmpdir=/dev/shm vault.XXXXXX)"
trap 'shred -u "$tmp" 2>/dev/null || rm -f "$tmp"' RETURN
if ! openssl enc "${VAULT_CIPHER_OPTS[@]}" -d -in "$VAULT_FILE" -out "$tmp"; then
echo "Failed to decrypt ${VAULT_FILE} — check your password. Aborting." >&2
return 1
fi
"${EDITOR:-vi}" "$tmp"
if ! openssl enc "${VAULT_CIPHER_OPTS[@]}" -salt -in "$tmp" -out "$VAULT_FILE"; then
echo "Re-encryption failed — ${VAULT_FILE} was NOT updated." >&2
return 1
fi
}