Compare commits

..
2 Commits
Author SHA1 Message Date
noconnorandClaude Opus 5 0426ebc973 git: authenticate to our Gitea from $GITEA_KEY
git never reads GITEA_KEY on its own. With only `credential.helper
store`, auth depended on whatever was typed at a first prompt and cached
in ~/.git-credentials: it went stale when the token was rotated in the
vault, and a box that never pushed had no credential at all, so pushes
failed even with a key exported.

Add a credential helper scoped to https://git.nic-oconnor.com that hands
git $GITEA_KEY as the password (username from GITEA_USER, else the login
name). An empty helper entry first clears the inherited `store` for that
host, so a rejected token is never cached and a stale one can't win.
Other hosts keep `store`. With GITEA_KEY unset it prints nothing and git
prompts as before. Idempotent: re-runs leave one copy.

Verified: `git credential fill` returns exactly $GITEA_KEY for the host,
other hosts are unaffected, ~/.git-credentials stays absent. A dry-run
push still fails, because the key currently in the vault is rejected by
Gitea (401 on the API too); that is the token, not this wiring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:36:20 +00:00
noconnorandClaude Opus 5 1159197e4b secrets: rename the vault helpers from env* to op*
They act on 1Password, not on the environment, so the prefix now says so:
opr (read), opc (create/update), opd (delete), opl (list), ops (refresh).
No behavior change. Checked the new names don't collide with any command
or oh-my-zsh alias, and verified opr/opl/ops against the real vault
(values compared, not printed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:36:20 +00:00
5 changed files with 56 additions and 35 deletions
+5 -5
View File
@@ -61,11 +61,11 @@ Already have a clone? Just run it directly:
The first run prompts for the service-account token (and vault/item), and The first run prompts for the service-account token (and vault/item), and
keeps them in `~/.config/dotfiles/op.env` (0600, not loaded into shells). keeps them in `~/.config/dotfiles/op.env` (0600, not loaded into shells).
Runs after `bin/` (needs `op` and `jq`); bootstrap loads the result so later Runs after `bin/` (needs `op` and `jq`); bootstrap loads the result so later
modules get the keys without prompting. Day to day: `envr NAME` reads a modules get the keys without prompting. Day to day: `opr NAME` reads a
field, `envc NAME [VALUE]` creates or updates one (prompts, hidden, without field, `opc NAME [VALUE]` creates or updates one (prompts, hidden, without
VALUE), `envd NAME` deletes one; both of those refresh the file and this VALUE), `opd NAME` deletes one; both of those refresh the file and this
shell. `envl [VAULT [ITEM]]` shows every field with its current value, and shell. `opl [VAULT [ITEM]]` shows every field with its current value, and
`envs` refreshes after a change made elsewhere. No sudo. `ops` refreshes after a change made elsewhere. No sudo.
opencode is configured by `mcp-config/`: it points at the covert-gpt opencode is configured by `mcp-config/`: it points at the covert-gpt
LiteLLM gateway (`https://llm.covert-gpt.com/v1`) for models. MCP tools do LiteLLM gateway (`https://llm.covert-gpt.com/v1`) for models. MCP tools do
+22 -1
View File
@@ -30,4 +30,25 @@ say "fetch.prune = true (stale remote-tracking refs cleaned on every fetch)"
git config --global credential.helper store git config --global credential.helper store
say "credential.helper = store (~/.git-credentials, created on first auth)" say "credential.helper = store (~/.git-credentials, created on first auth)"
say "Done. On the next HTTPS push/pull to a new host, git prompts once and caches the credential in ~/.git-credentials." # Our own Gitea authenticates from $GITEA_KEY (exported by secrets/ from the
# vault), not from ~/.git-credentials.
#
# WHY: git never reads GITEA_KEY by itself. With only `store`, the token was
# whatever got typed at the first prompt, copied into ~/.git-credentials, and
# it silently went stale when the vault's key was rotated — or, on a box that
# never pushed, there was no credential at all and the push just failed. Now
# `opc GITEA_KEY` is the only step when the token changes.
#
# The empty helper first clears the inherited `store` for this host, so a
# rejected token is never cached and a stale cached one can never win. Gitea
# accepts the token as the password; the username is informational (GITEA_USER
# overrides it). With GITEA_KEY unset the helper prints nothing and git falls
# back to prompting, as before.
GITEA_URL="https://git.nic-oconnor.com"
git config --global --unset-all "credential.$GITEA_URL.helper" 2>/dev/null || true
git config --global --add "credential.$GITEA_URL.helper" ''
git config --global --add "credential.$GITEA_URL.helper" \
'!f() { test "$1" = get && test -n "${GITEA_KEY:-}" && printf "username=%s\npassword=%s\n" "${GITEA_USER:-$(id -un)}" "$GITEA_KEY"; }; f'
say "credential helper for $GITEA_URL = \$GITEA_KEY (from secrets.env)"
say "Done. $GITEA_URL uses \$GITEA_KEY; other HTTPS hosts prompt once and cache in ~/.git-credentials."
+4 -4
View File
@@ -6,19 +6,19 @@
# field on EVERY new shell. That made each terminal wait on 1Password, broke # field on EVERY new shell. That made each terminal wait on 1Password, broke
# every shell when the network or the service account was down, and meant each # every shell when the network or the service account was down, and meant each
# box carried its own hand-written copy of the loop. Now the vault is read once # box carried its own hand-written copy of the loop. Now the vault is read once
# here (at bootstrap, or on demand with `envs`), and shells read a local file. # here (at bootstrap, or on demand with `ops`), and shells read a local file.
# #
# Layout: one vault per user (default: the login name), holding one item # Layout: one vault per user (default: the login name), holding one item
# (default: also the login name) whose field LABELS are the variable names — # (default: also the login name) whose field LABELS are the variable names —
# GITEA_KEY, LITELLM_KEY, PROXMOX_TOKEN_SECRET, ... Every such field is # GITEA_KEY, LITELLM_KEY, PROXMOX_TOKEN_SECRET, ... Every such field is
# exported. Add a secret by adding a field (`envc NAME`), not by editing # exported. Add a secret by adding a field (`opc NAME`), not by editing
# this repo. # this repo.
# #
# Files, all under ~/.config/dotfiles (0700), none in the repo: # Files, all under ~/.config/dotfiles (0700), none in the repo:
# op.env OP_SERVICE_ACCOUNT_TOKEN, OP_VAULT, OP_ITEM (0600). Kept so a # op.env OP_SERVICE_ACCOUNT_TOKEN, OP_VAULT, OP_ITEM (0600). Kept so a
# refresh needs no prompt. Deliberately NOT sourced by shells: # refresh needs no prompt. Deliberately NOT sourced by shells:
# the token stays out of every process's environment, and only # the token stays out of every process's environment, and only
# the env* helpers in zsh/zshrc load it, in a subshell. # the op* helpers in zsh/zshrc load it, in a subshell.
# secrets.env the exported fields (0600), replaced atomically, so a failed # secrets.env the exported fields (0600), replaced atomically, so a failed
# fetch leaves the previous copy in place. # fetch leaves the previous copy in place.
# #
@@ -115,7 +115,7 @@ _tmp="$(mktemp "$CONF_DIR/secrets.env.XXXXXX")"
trap 'rm -f "$_tmp"' EXIT trap 'rm -f "$_tmp"' EXIT
{ {
printf '# Generated by dotfiles secrets/install.sh from op://%s/%s — do not edit.\n' "$OP_VAULT" "$OP_ITEM" printf '# Generated by dotfiles secrets/install.sh from op://%s/%s — do not edit.\n' "$OP_VAULT" "$OP_ITEM"
printf '# Refresh with: envs (change the vault with: envc NAME / envd NAME)\n' printf '# Refresh with: ops (change the vault with: opc NAME / opd NAME)\n'
printf '%s' "$_json" | jq -r ' printf '%s' "$_json" | jq -r '
.fields[] .fields[]
| select(.purpose != "NOTES") | select(.purpose != "NOTES")
+24 -24
View File
@@ -76,38 +76,38 @@ grmb() {
# Secrets from this user's 1Password vault, written by dotfiles/secrets/install.sh. # Secrets from this user's 1Password vault, written by dotfiles/secrets/install.sh.
# Reading a local 0600 file keeps shell startup off the network: nothing here # Reading a local 0600 file keeps shell startup off the network: nothing here
# calls op. envc/envd refresh the file themselves; after editing the vault # calls op. opc/opd refresh the file themselves; after editing the vault
# anywhere else (the 1Password app, another box), run `envs`. # anywhere else (the 1Password app, another box), run `ops`.
_DOTFILES_CONF="$HOME/.config/dotfiles" _DOTFILES_CONF="$HOME/.config/dotfiles"
[ -f "$_DOTFILES_CONF/secrets.env" ] && source "$_DOTFILES_CONF/secrets.env" [ -f "$_DOTFILES_CONF/secrets.env" ] && source "$_DOTFILES_CONF/secrets.env"
# envr NAME read one field straight from the vault # opr NAME read one field straight from the vault
# envc NAME [VALUE] create or update a field (prompts, hidden, when VALUE is # opc NAME [VALUE] create or update a field (prompts, hidden, when VALUE is
# omitted, which keeps the secret out of shell history), # omitted, which keeps the secret out of shell history),
# then refresh and reload this shell # then refresh and reload this shell
# envd NAME delete a field, refresh, and unset it in this shell # opd NAME delete a field, refresh, and unset it in this shell
# envl [VAULT [ITEM]] show every field and its current value (default: your # opl [VAULT [ITEM]] show every field and its current value (default: your
# vault and item from op.env) # vault and item from op.env)
# envs re-fetch the vault into secrets.env and reload this shell # ops re-fetch the vault into secrets.env and reload this shell
# #
# The service-account token lives in op.env and is loaded only inside these # The service-account token lives in op.env and is loaded only inside these
# subshells, so it never lands in the environment of everything you run. # subshells, so it never lands in the environment of everything you run.
_dotfiles_op() { _dotfiles_op() {
[ -f "$_DOTFILES_CONF/op.env" ] || { print -u2 "no $_DOTFILES_CONF/op.env; run: envs"; return 1; } [ -f "$_DOTFILES_CONF/op.env" ] || { print -u2 "no $_DOTFILES_CONF/op.env; run: ops"; return 1; }
( set -a; source "$_DOTFILES_CONF/op.env"; set +a; "$@" ) ( set -a; source "$_DOTFILES_CONF/op.env"; set +a; "$@" )
} }
_dotfiles_env_name() { _dotfiles_env_name() {
[[ "$1" =~ '^[A-Za-z_][A-Za-z0-9_]*$' ]] && return 0 [[ "$1" =~ '^[A-Za-z_][A-Za-z0-9_]*$' ]] && return 0
print -u2 "$2: '$1' is not a valid variable name"; return 2 print -u2 "$2: '$1' is not a valid variable name"; return 2
} }
envr() { opr() {
_dotfiles_env_name "$1" envr || return _dotfiles_env_name "$1" opr || return
_dotfiles_op sh -c 'op read "op://$OP_VAULT/$OP_ITEM/$1"' envr "$1" _dotfiles_op sh -c 'op read "op://$OP_VAULT/$OP_ITEM/$1"' opr "$1"
} }
envc() { opc() {
emulate -L zsh emulate -L zsh
local name=$1 value local name=$1 value
_dotfiles_env_name "$name" envc || return _dotfiles_env_name "$name" opc || return
if (( $# >= 2 )); then if (( $# >= 2 )); then
value=$2 value=$2
else else
@@ -115,18 +115,18 @@ envc() {
fi fi
# [concealed] makes it a password field; an existing field of that name is updated. # [concealed] makes it a password field; an existing field of that name is updated.
_dotfiles_op sh -c 'op item edit "$OP_ITEM" --vault "$OP_VAULT" "$1[concealed]=$2" >/dev/null' \ _dotfiles_op sh -c 'op item edit "$OP_ITEM" --vault "$OP_VAULT" "$1[concealed]=$2" >/dev/null' \
envc "$name" "$value" && envs opc "$name" "$value" && ops
} }
envd() { opd() {
_dotfiles_env_name "$1" envd || return _dotfiles_env_name "$1" opd || return
_dotfiles_op sh -c 'op item edit "$OP_ITEM" --vault "$OP_VAULT" "$1[delete]" >/dev/null' envd "$1" \ _dotfiles_op sh -c 'op item edit "$OP_ITEM" --vault "$OP_VAULT" "$1[delete]" >/dev/null' opd "$1" \
&& unset "$1" && envs && unset "$1" && ops
} }
envl() { opl() {
_dotfiles_op sh -c ' _dotfiles_op sh -c '
vault=${1:-$OP_VAULT} item=${2:-$OP_ITEM} vault=${1:-$OP_VAULT} item=${2:-$OP_ITEM}
printf "op://%s/%s\n" "$vault" "$item" >&2 printf "op://%s/%s\n" "$vault" "$item" >&2
op item get "$item" --vault "$vault" --reveal --format json' envl "$@" \ op item get "$item" --vault "$vault" --reveal --format json' opl "$@" \
| jq -r ' | jq -r '
[.fields[] | select(.purpose != "NOTES")] as $f [.fields[] | select(.purpose != "NOTES")] as $f
| ([$f[] | .label // "" | length] | max // 0) as $w | ([$f[] | .label // "" | length] | max // 0) as $w
@@ -136,7 +136,7 @@ envl() {
| "\($name) \($lines[0] // "")", | "\($name) \($lines[0] // "")",
($lines[1:][] | (" " * ($w + 2)) + .)' ($lines[1:][] | (" " * ($w + 2)) + .)'
} }
envs() { ops() {
"${DOTFILES_DIR:-$HOME/.dotfiles}/secrets/install.sh" \ "${DOTFILES_DIR:-$HOME/.dotfiles}/secrets/install.sh" \
&& source "$_DOTFILES_CONF/secrets.env" && source "$_DOTFILES_CONF/secrets.env"
} }
+1 -1
View File
@@ -36,7 +36,7 @@
# Nothing to set here. Do NOT loop over `op read` in this file: that ran on # Nothing to set here. Do NOT loop over `op read` in this file: that ran on
# every shell. secrets/install.sh stores the service-account token in # every shell. secrets/install.sh stores the service-account token in
# ~/.config/dotfiles/op.env and writes the vault's fields to secrets.env, which # ~/.config/dotfiles/op.env and writes the vault's fields to secrets.env, which
# the managed ~/.zshrc loads. Use envr / envc / envd (see zsh/zshrc). # the managed ~/.zshrc loads. Use opr / opc / opd (see zsh/zshrc).
# --- Proxmox ------------------------------------------------------------- # --- Proxmox -------------------------------------------------------------
# One pair of credentials, several consumers: the proxmox MCP server # One pair of credentials, several consumers: the proxmox MCP server