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 to vi) — only needed if you use vault_edit.

Installation

  1. Place vault-tools.sh somewhere 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.
  2. Source it wherever you want the vault_* functions available — typically ~/.bashrc for every interactive shell, or ~/.bash_profile if you only want it (and the automatic vault load, see below) once per login.
  3. Optionally set VAULT_FILE before sourcing, if you don't want the default secrets.sh.enc in 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, and vault_load paths — decrypted content only ever lives in shell variables, held in memory for the life of the function call. vault_edit is 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/shm isn'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 if vault_edit is in regular use on memory-constrained hosts.
  • Bash can't scrub memory. unset and rm free 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 pipefail to itself via local -, so your interactive shell's own options (or lack of them) are untouched before and after any vault_* 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_edit or 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
}