fix: install bws, isolate the private config, keep credentials out of logs

Three defects the private tier could not survive a fresh machine with.

bws was never installed. The private/bws-secrets row promised seven API
keys and shipped no way to fetch them: dotsecrets shells out to `bws
secret get`, and nothing put that binary on the box. Added as a tarball
channel with checksum verification, musl rather than gnu so it does not
pin a glibc newer than an older LTS carries, plus core/unzip as a real
@needs dependency since the release is a .zip and 24.04 minimal has no
unzip. The releases API needs filtering by tag prefix: sdk-sm is a
monorepo and `latest` usually points at a python SDK, not bws.

Both tiers rendered their config to ~/.config/chezmoi/chezmoi.toml, so
re-running the public installer overwrote the private config and took its
seven promptStringOnce answers with it. Silently -- the templates degrade
politely when data is missing, so the symptom was `git commit` not knowing
who you are, days later. The private tier now renders to private.toml and
the cmp alias carries the matching -c.

`run` echoes its argv to stderr, and the private init argv ends in
https://user:TOKEN@host -- into scrollback, any `dotup 2>log`, and any
agent transcript. Traced through redact_url instead, in both the live and
dry-run branches. The clone is also chmod -R go-rwx afterwards: it lands
at the caller umask, and .git/config stores that same credential URL.

Tests: 90 passing, 9 new covering all three.
This commit is contained in:
bcherb2
2026-08-17 11:15:48 -04:00
parent e1332c67d8
commit 90bca39396
3 changed files with 130 additions and 3 deletions
+32
View File
@@ -102,6 +102,38 @@ is "@spec lines are not packages" "0" \
"$(sh $D render | cut -f2 | grep -c 'spec' || true)"
reset; sh $D toggle p:core/uv >/dev/null
is "dropping uv drops specify-cli" "off" "$(on agents/specify-cli)"
# bws-secrets promised seven API keys and installed nothing to fetch them with.
# The binary ships as a .zip and 24.04 minimal has no unzip, so the dependency
# is real rather than decorative.
reset; sh $D toggle p:private/bws-secrets >/dev/null
is "ticking bws-secrets pulls in unzip" "on" "$(on core/unzip)"
is "bws-secrets has an install channel" "-tarball" \
"$(awk -F'\t' '$1=="private" && $2=="bws-secrets" {print $4}' "$M")"
printf '\n\033[1mcredentials never reach a log\033[0m\n'
# `run` echoes its whole argv to stderr. The private init's argv ends in
# https://user:TOKEN@host, so it must not go through `run`.
is "private init does not use run()" "0" \
"$(grep -c 'run chezmoi init' $D)"
# Two, not one: the dry-run branch traces it too, and a dry run that printed a
# live token would be the worse leak of the pair.
is "both traced URLs are redacted" "2" \
"$(grep -c 'redact_url "\$p_repo"' $D)"
# Exercise the real implementation lifted straight out of dotup. A copy of the
# sed expression here would keep passing after someone edited the original.
rd() { eval "$(sed -n '/^redact_url()/p' $D)"; redact_url "$1"; }
is "redact_url strips userinfo" "https://<redacted>@git.example.com/x.git" \
"$(rd 'https://ben:deadbeefcafe@git.example.com/x.git')"
is "...and leaves a credential-free URL alone" "https://git.example.com/x.git" \
"$(rd 'https://git.example.com/x.git')"
# Two tiers, two configs. Sharing one meant the public installer silently ate
# the private tier's seven promptStringOnce answers.
is "private init carries its own -c" "1" \
"$(grep -c '\-c "\$PRIV_CFG"' $D)"
is "PRIV_CFG is not the default config path" "0" \
"$(grep -c 'PRIV_CFG=.*chezmoi/chezmoi.toml' $D)"
is "the private source is locked down after clone" "1" \
"$(grep -c 'chmod -R go-rwx "\$PRIV_SRC"' $D)"
printf '\n\033[1mrisk model — the invariants that matter\033[0m\n'
reset
+95 -2
View File
@@ -375,6 +375,10 @@ find_tool() {
# empty, so a channel with nothing in it prints no header.
norm() { printf '%s\n' "$*" | tr ' ' '\n' | grep . | tr '\n' ' ' | sed 's/ $//'; }
# Strip userinfo from a URL for tracing: https://user:token@host/p -> https://host/p
# Used wherever a URL that may carry a credential is about to be printed.
redact_url() { printf '%s\n' "$1" | sed 's#://[^/@]*@#://<redacted>@#'; }
run() {
if [ "$DRYRUN" -eq 1 ]; then printf ' + %s\n' "$*"; return 0; fi
printf '%s + %s%s\n' "$DIM" "$*" "$R" >&2
@@ -570,6 +574,59 @@ install_bespoke() {
run_sh "${SUDO:+$SUDO }rm -rf /usr/local/go && ${SUDO:+$SUDO }tar -xzf /tmp/go.tgz -C /usr/local" \
|| { note_fail "$key" "tarball extract failed"; continue; }
run_sh "rm -f /tmp/go.tgz" ;;
private/bws-secrets)
# The Bitwarden Secrets Manager CLI. Without it the private tier
# fetches nothing: `dotsecrets` shells out to `bws secret get`, so
# this row promising seven API keys and not installing the binary
# was a promise it could not keep.
#
# User-level, ~/.local/bin, no sudo. It is a single static binary
# and the private tier is per-user by definition.
if have bws && [ "$DRYRUN" -eq 0 ]; then
say " bws $(bws --version 2>/dev/null | awk '{print $2}') already present — leaving it"
continue
fi
# musl, not gnu: the gnu build pins a glibc newer than some LTS
# images carry, and this has to work on whatever a fresh VM is.
case $(uname -s) in
Darwin) t=macos-universal ;;
*) case $(uname -m) in
x86_64|amd64) t=x86_64-unknown-linux-musl ;;
aarch64|arm64) t=aarch64-unknown-linux-musl ;;
*) note_fail "$key" "no bws build for $(uname -m)"; continue ;;
esac ;;
esac
if [ "$DRYRUN" -eq 1 ]; then v=2.X.Y
else
# Releases are tagged per-component in this monorepo, so the
# `latest` endpoint points at whatever shipped last -- often a
# python SDK, not bws. Filter by tag prefix instead.
v=$(curl -fsSL 'https://api.github.com/repos/bitwarden/sdk-sm/releases?per_page=40' 2>/dev/null \
| sed -n 's/.*"tag_name": *"bws-v\([0-9.]*\)".*/\1/p' | head -1)
fi
[ -n "$v" ] || { note_fail "$key" "could not resolve the current bws version"; continue; }
b=https://github.com/bitwarden/sdk-sm/releases/download/bws-v$v
run_sh "curl -fsSL '$b/bws-$t-$v.zip' -o /tmp/bws.zip" \
|| { note_fail "$key" "download failed"; continue; }
# Bitwarden publishes checksums; a binary that is about to hold the
# key to every other credential is worth verifying.
if [ "$DRYRUN" -eq 0 ] && have sha256sum; then
if curl -fsSL "$b/bws-sha256-checksums-$v.txt" -o /tmp/bws.sums 2>/dev/null; then
want=$(awk -v f="bws-$t-$v.zip" '$2==f || $2=="*"f {print $1}' /tmp/bws.sums | head -1)
got=$(sha256sum /tmp/bws.zip | awk '{print $1}')
if [ -n "$want" ] && [ "$want" != "$got" ]; then
note_fail "$key" "checksum mismatch — refusing to install"
rm -f /tmp/bws.zip /tmp/bws.sums; continue
fi
[ -n "$want" ] && say " checksum verified"
else
warn " bws: checksums unavailable — installing unverified"
fi
rm -f /tmp/bws.sums
fi
run_sh "mkdir -p \"\$HOME/.local/bin\" && unzip -oq /tmp/bws.zip -d /tmp/bws.d && install -m 755 \"\$(find /tmp/bws.d -type f -name bws | head -1)\" \"\$HOME/.local/bin/bws\"" \
|| { note_fail "$key" "extract failed"; rm -rf /tmp/bws.zip /tmp/bws.d; continue; }
run_sh "rm -rf /tmp/bws.zip /tmp/bws.d" ;;
core/chezmoi)
# Circular by nature: dotup arrives *via* chezmoi. Present already
# in every case that matters; here for the one where it is not.
@@ -658,6 +715,22 @@ cmd_install() {
PRIV_SRC=${DOTUP_PRIVATE_SRC:-${XDG_DATA_HOME:-$HOME/.local/share}/dotfiles-private}
BWS_TOKEN=${DOTUP_BWS_TOKEN:-${XDG_CONFIG_HOME:-$HOME/.config}/bitwarden/bws-token}
# The private tier gets its OWN config file, and this is load-bearing.
#
# chezmoi renders .chezmoi.toml.tmpl to the config path, and without -c that is
# ~/.config/chezmoi/chezmoi.toml for BOTH tiers. The private template asks seven
# [data] questions once (promptStringOnce) -- name, email, signing key, four
# gitea addresses. Re-running the PUBLIC installer afterwards rewrites that same
# file, and since the public tier's rendered config carries no [data] block, the
# seven answers are simply gone.
#
# The failure is silent, which is what makes it worth a separate file rather
# than a warning: the private templates degrade politely when their data is
# missing -- config.local emits a comment telling you to re-run init instead of
# failing the apply -- so the first symptom is `git commit` not knowing who you
# are, days later, with nothing connecting it to the install you ran.
PRIV_CFG=${DOTUP_PRIVATE_CFG:-${XDG_CONFIG_HOME:-$HOME/.config}/chezmoi/private.toml}
cmd_private() {
rows=$(selected_private)
[ -n "$rows" ] || return 0
@@ -720,8 +793,28 @@ cmd_private() {
if [ "$want_repo" -eq 1 ]; then
if [ -n "$p_repo" ]; then
run chezmoi init --apply --source "$PRIV_SRC" "$p_repo" \
|| err "private repo init failed"
# NOT `run`. It echoes its whole argv to stderr, and this argv ends
# in https://user:TOKEN@host/... -- which would put a live git
# credential into terminal scrollback, any `dotup 2>log`, and any
# agent or CI transcript capturing the run. Trace a redacted form
# and execute quietly.
mkdir -p "$(dirname "$PRIV_CFG")"
if [ "$DRYRUN" -eq 1 ]; then
printf ' + chezmoi init --apply --source %s -c %s %s\n' \
"$PRIV_SRC" "$PRIV_CFG" "$(redact_url "$p_repo")"
else
printf '%s + chezmoi init --apply --source %s -c %s %s%s\n' \
"$DIM" "$PRIV_SRC" "$PRIV_CFG" "$(redact_url "$p_repo")" "$R" >&2
chezmoi init --apply --source "$PRIV_SRC" -c "$PRIV_CFG" "$p_repo" \
|| err "private repo init failed"
fi
# The clone lands with the caller's umask, which on a stock Ubuntu
# is 022 -- world-readable. This tree holds ssh config, accepted
# keys and machine identity, and its .git/config stores the
# credential-bearing remote URL in plain text. On a shared or
# multi-user box that is readable by anyone with an account.
[ ! -d "$PRIV_SRC" ] || chmod -R go-rwx "$PRIV_SRC" 2>/dev/null || :
else err "the blob carried no PRIVATE_REPO_URL"; fi
fi
unset p_repo
+3 -1
View File
@@ -31,6 +31,7 @@
@needs agents/specify-cli core/uv
@needs core/mermaid-cli core/node
@needs core/neovim core/imagemagick core/mermaid-cli
@needs private/bws-secrets core/unzip
#
# npm names carry scopes that the plugin's short name does not. `npm i -g
# rpiv-btw` installs somebody else's package.
@@ -63,6 +64,7 @@ core mermaid-cli safe -npm -npm mmdc — nvim renders mermaid fences with it
core btop safe btop btop
core htop safe htop htop
core ncdu safe ncdu ncdu
core unzip safe unzip unzip the bws release ships as a .zip, and 24.04 minimal has no unzip
core tree safe tree tree
core cmake safe cmake cmake
core ninja safe ninja-build ninja package name differs from binary
@@ -116,4 +118,4 @@ virt libvirt invasive libvirt-daemon-system - daemon + group membership
virt virt-manager invasive virt-manager -
@private one password, typed after the install finishes
private private-repo private - - ~/.local/share/dotfiles-private — agent config, ssh config
private bws-secrets private - - 7 API keys into ~/.config/zsh/secrets.zsh
private bws-secrets private -tarball -tarball installs bws, then 7 API keys into ~/.config/zsh/secrets.zsh
Can't render this file because it contains an unexpected character in line 17 and column 49.