#!/bin/sh # dotsecrets -- regenerate ~/.config/zsh/secrets.zsh from Bitwarden Secrets Manager. # # Needs no endpoint, no username and no password. The bootstrap exchange already # happened; what it left behind is ~/.config/bitwarden/bws-token, mode 600, and # that token is the whole input to this command. Run it whenever you rotate a # key in bws. `cmp apply` runs it too, via run_after_50-secrets.sh. # # THE ONE INVARIANT: a failed or partial fetch leaves a working secrets.zsh # exactly as it was. Every value is fetched into a 600-mode temp file first, # and that file is renamed over the real one only after all of them have # arrived. Six keys out of seven is a machine that was fine a moment ago and # now silently cannot reach one provider -- worse than a machine that says the # refresh failed and carries on with yesterday's keys. # # The temp file is created in the SAME DIRECTORY as the destination, not in # /tmp. `mv` across filesystems is copy-then-unlink, which has a window where # the destination is half-written; within one filesystem it is rename(2), which # has none. The atomicity this whole script is built around is a property of # rename(2), not of the word "mv". # # NOTHING IS EVER PRINTED. No value reaches stdout, stderr, argv or a log: # - values move from `bws` into a shell variable and from there into a file # through the `printf` BUILTIN, so they never appear in `ps`; # - bws's own stderr is discarded, because an error message is not worth the # risk of it quoting what it was handed; # - every failure message below names the ENV VAR, never the value. # # Generated by chezmoi from the PRIVATE tier. The env-var -> secret-id map is # .chezmoidata/bws.toml; UUIDs are identifiers, not secrets. set -u PROG=dotsecrets CFG="${XDG_CONFIG_HOME:-$HOME/.config}" TOKEN_FILE="$CFG/bitwarden/bws-token" OUT="$CFG/zsh/secrets.zsh" TMP="" warn() { printf '%s: %s\n' "$PROG" "$*" >&2; } cleanup() { [ -n "$TMP" ] && rm -f "$TMP"; return 0; } trap cleanup EXIT trap 'cleanup; exit 130' INT trap 'cleanup; exit 143' TERM HUP # Bail out without touching OUT. This is the entire point of the command. abort() { warn "$1" if [ -r "$OUT" ]; then warn "keeping the existing $OUT -- it was NOT modified" else warn "$OUT was not written; the shell starts without those keys" fi exit 1 } # POSIX single-quoting using builtins only, so a value never becomes an # argument to an external command and never becomes a line in `ps` output. # API keys do not contain apostrophes, but a quoting routine that is correct # only for the inputs you happen to have is not a quoting routine. shquote() { _sq_s=$1 _sq_o='' while :; do case $_sq_s in *"'"*) ;; *) break ;; esac _sq_o="$_sq_o${_sq_s%%\'*}'\\''" _sq_s=${_sq_s#*\'} done printf "'%s%s'" "$_sq_o" "$_sq_s" } # ------------------------------------------------------------ preconditions --- [ -r "$TOKEN_FILE" ] || abort "no bws token at $TOKEN_FILE (public-only machine?)" command -v bws >/dev/null 2>&1 \ || abort "bws is not installed or not on PATH -- https://bitwarden.com/help/secrets-manager-cli/" BWS_ACCESS_TOKEN="$(cat "$TOKEN_FILE")" [ -n "$BWS_ACCESS_TOKEN" ] || abort "$TOKEN_FILE is empty" export BWS_ACCESS_TOKEN mkdir -p "$CFG/zsh" || abort "cannot create $CFG/zsh" chmod 700 "$CFG/zsh" 2>/dev/null || : # ---------------------------------------------------------------- the map --- # `env var name` `bws secret id`, rendered from .chezmoidata/bws.toml so that a # UUID is written down in exactly one place and it is not this script. SECRET_MAP='{{ range .bws.secrets }} {{ .env }} {{ .id }}{{ end }}' # Second names for a value fetched once. One secret, two exported names: some # tools spell it Z_AI_API_KEY and some spell it ZAI_API_KEY. ALIAS_MAP='{{ range .bws.aliases }} {{ .name }} {{ .from }}{{ end }}' # ------------------------------------------------------------- the fetch --- umask 077 TMP="$(mktemp "$CFG/zsh/.secrets.zsh.XXXXXXXX")" || abort "cannot create a temp file beside $OUT" chmod 600 "$TMP" || abort "cannot chmod the temp file" { printf '# Generated from Bitwarden Secrets Manager. DO NOT EDIT, DO NOT COMMIT.\n' printf '# Regenerate with `dotsecrets`. Mode 600, in no repository.\n' printf '# Last refreshed: %s\n\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" } >"$TMP" || abort "cannot write to the temp file" # `set -f` because the maps are split on IFS by `set --`, and an unglobbed # split would let a stray `*` in the source data expand against the cwd. # The loops run in this shell, not in a pipeline subshell, so `count` and the # remembered values survive them -- a `while read` on the right of a pipe is # the classic way to lose exactly the state this script needs. set -f count=0 NL="$(printf '\n_')" NL=${NL%_} # shellcheck disable=SC2086 set -- $SECRET_MAP while [ "$#" -ge 2 ]; do name=$1 id=$2 shift 2 # The name becomes part of a variable name below. It comes from a file in # this repo rather than from anywhere a stranger can reach, but a shell # variable name is close enough to code that it gets checked anyway. case $name in [A-Za-z_]*) ;; *) abort "invalid env var name in the secret map: $name" ;; esac case $name in *[!A-Za-z0-9_]*) abort "invalid env var name in the secret map: $name" ;; esac # -o env prints `KEY=VALUE`, where KEY is the secret's own name in bws. # Comparing it to the name we asked for is a free integrity check on the # map: an id that points OPENAI_API_KEY at the Groq secret is caught here # rather than six months later as a confusing 401. line="$(bws secret get "$id" -o env 2>/dev/null)" \ || abort "could not fetch $name from bws (no network, or the token is wrong or revoked)" # First line only, trimmed with parameter expansion rather than `head` or # `sed`: keeping the value out of every external process's stdin as well # as its argv costs one case statement. case $line in *"$NL"*) line=${line%%"$NL"*} ;; esac case $line in "$name"=*) ;; *) abort "bws returned a different secret than $name -- check its id in .chezmoidata/bws.toml" ;; esac value=${line#"$name"=} [ -n "$value" ] || abort "bws returned an empty value for $name" printf 'export %s=%s\n' "$name" "$(shquote "$value")" >>"$TMP" \ || abort "cannot write to the temp file" # Remember it for the alias pass. The value is expanded by the assignment, # not by `eval` -- eval only ever parses the variable NAME. eval "_v_$name=\$value" count=$((count + 1)) done [ "$count" -gt 0 ] || abort ".chezmoidata/bws.toml carries no entries -- nothing to fetch" # --------------------------------------------------------------- aliases --- # After the loop, so an alias can only reference a value that has already # arrived intact. alias_count=0 # shellcheck disable=SC2086 set -- $ALIAS_MAP while [ "$#" -ge 2 ]; do alias_name=$1 source_name=$2 shift 2 alias_count=$((alias_count + 1)) eval "aliased=\${_v_$source_name:-}" [ -n "$aliased" ] \ || abort "alias $alias_name names $source_name, which is not in the secret map" printf '\n# same value, second name expected by some tools\n' >>"$TMP" \ || abort "cannot write to the temp file" printf 'export %s=%s\n' "$alias_name" "$(shquote "$aliased")" >>"$TMP" \ || abort "cannot write to the temp file" done set +f # ----------------------------------------------------------------- commit --- # Everything arrived. Only now does the real file change, and it changes in one # rename rather than a truncate followed by a write. chmod 600 "$TMP" || abort "cannot chmod the temp file" mv -f "$TMP" "$OUT" || abort "cannot rename the temp file into place" TMP="" # $count is secrets FETCHED; aliases add further exports without another # fetch. Reporting only the first number against a file with more lines than # that reads like a bug in the generator. Say both. if [ "$alias_count" -gt 0 ]; then warn "wrote $OUT ($count secrets + $alias_count alias(es) = $((count + alias_count)) exports, mode 600)" else warn "wrote $OUT ($count secrets, mode 600)" fi exit 0