diff --git a/.tests/test.sh b/.tests/test.sh index 503190c..80df20e 100755 --- a/.tests/test.sh +++ b/.tests/test.sh @@ -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://@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 diff --git a/dot_local/bin/executable_dotup b/dot_local/bin/executable_dotup index 735b49b..cb53ce5 100755 --- a/dot_local/bin/executable_dotup +++ b/dot_local/bin/executable_dotup @@ -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#://[^/@]*@#://@#'; } + 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 diff --git a/dot_local/share/dotup/packages.tsv b/dot_local/share/dotup/packages.tsv index abd622d..4f41fc4 100644 --- a/dot_local/share/dotup/packages.tsv +++ b/dot_local/share/dotup/packages.tsv @@ -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