diff --git a/docs/changelog-20260713-git-credential-store.md b/docs/changelog-20260713-git-credential-store.md new file mode 100644 index 0000000..71dc454 --- /dev/null +++ b/docs/changelog-20260713-git-credential-store.md @@ -0,0 +1,65 @@ +# 2026-07-13 -- unattended git push from the jumphost (SEC-005) + +The jumphost clone could not `git push`: every attempt died with +`could not read Username for 'https://git.baldurkeep.com'`. The branch sat **12 commits +ahead of origin for two days** as a result -- a real data-loss exposure, since all of the +D-112 / OPNsense edge work existed only on this box. Fixed by giving git a stored +credential. Operator ruled the posture; recorded as **SEC-005**. + +## What was configured + + git config --global credential.helper store # -> ~/.git-credentials, mode 600 + +The token itself was written by the OPERATOR via a hidden prompt +(`getpass` + `/dev/tty`), URL-encoded, file created 0600 from `os.open` so it is never +briefly world-readable. **The token was never read into agent context** -- verification is +by *using* the credential (push succeeds / file survives), never by printing it. Per the +CLAUDE.md secrets rule. + +Result: `f5fd4f5..425d434` pushed; `git ls-remote` confirms origin HEAD == local HEAD. + +## Two GitBucket behaviours worth knowing (both cost us a cycle) + +**1. The git username is NOT the login you type into the web UI.** +The operator signs in to GitBucket with `jesse.austin@neumatrix.com`. That address is +rejected for git transport. Measured against the real endpoints: + +| username | `/api/v3/user` | `info/refs?service=git-receive-pack` | verdict | +|---|---|---|---| +| `jesse.austin@neumatrix.com` | 401 | 401 | rejected | +| **`jesse.austin`** | 401 | **200** | **CAN PUSH** | +| `JANeumatrix` (the git `user.name`) | 401 | 401 | rejected | + +Note the whole API column is 401: **GitBucket does not accept PAT basic-auth on +`/api/v3/`, but does accept it for git transport.** So an API probe is the WRONG way to +validate this credential -- it produces a false negative. Validate against +`info/refs?service=git-receive-pack`, which is what push actually uses. + +**2. `credential.helper store` DELETES the credential when the server rejects it.** +git calls `credential reject` on a 401, and the store helper erases the matching line. +The first (bad-username) push therefore emptied `~/.git-credentials` to 0 bytes, and the +follow-up diagnostics were all authenticating with an empty string -- which looked like a +corrupt/unparseable file and sent the investigation the wrong way for a beat. If the file +mysteriously empties, the credential was rejected; it did not fail to write. + +Consequence for tooling: **validate before writing.** The helper script now confirms push +rights against GitBucket and only then persists the token, so a bad entry cannot silently +wipe the store. + +## Security posture (SEC-005) + +A long-lived, account-wide PAT now sits in plaintext on the jumphost, readable by anything +running as `jessea123`. This is the accepted cost of unattended push, ruled by the operator. +Mitigations in place: dedicated token (not a reused personal one), mode 600, and a recorded +revoke path. GitBucket has no per-repo token scoping, so least-privilege is not achievable +here -- the control is rotation, not scope. + +**Revoke/rotate:** GitBucket -> Account Settings -> Applications. Do it at v1 close, or +immediately if the jumphost is shared, rebuilt, or its disk leaves the premises. + +## Revert + + git config --global --unset credential.helper + rm -f ~/.git-credentials + +Then revoke the token in GitBucket. Pushes go back to requiring interactive auth. diff --git a/docs/security-ledger.md b/docs/security-ledger.md index efa92a3..09d9ec1 100644 --- a/docs/security-ledger.md +++ b/docs/security-ledger.md @@ -13,3 +13,4 @@ | SEC-002 | 2026-06-17 | juju action params persist in the operation log -- charm authorization must use short-lived child tokens | DOCFIX-011 | operator | STANDING RULE (verify each vault authorize) | | SEC-003 | 2026-07-03 | Vault unseal-key custody is single-operator (bus factor) | D-069 | operator | OPEN -- assign custodians + rehearse second-person unseal (DEFERRED 2026-07-06 by operator; note: keeps d011-06 MANUAL, gates D-011 full close) | | SEC-004 | 2026-05-27 | repo temporarily PUBLIC for v1 web_fetch workflow | project completion list | operator | OPEN -- flip to private at v1 close (deferral reaffirmed 2026-07-06) | +| SEC-005 | 2026-07-13 | long-lived GitBucket PAT stored in PLAINTEXT on the jumphost (`credential.helper store`, mode 600) so the agent can push unattended; GitBucket PATs are account-wide -- no per-repo scoping exists | docs/changelog-20260713-git-credential-store.md | operator | OPEN -- dedicated token (not a reused personal one); revoke + rotate at v1 close, or immediately if the jumphost is shared/rebuilt. Revoke: GitBucket -> Account Settings -> Applications | diff --git a/docs/session-ledger.md b/docs/session-ledger.md index b382ce7..98f8834 100644 --- a/docs/session-ledger.md +++ b/docs/session-ledger.md @@ -1933,6 +1933,16 @@ reasoning in the prior session's close-out; would be D-113 if it needs a formal ruling. NOT raised as a decision; no D-number assigned. +- **Unattended git push now works (SEC-005).** `credential.helper store` + a dedicated + GitBucket PAT at `~/.git-credentials` (mode 600). The branch had been stuck 12 commits + ahead of origin for two days; now pushed (`425d434`, ls-remote confirmed). Two gotchas, + both measured, in `docs/changelog-20260713-git-credential-store.md`: + (a) the git username is **`jesse.austin`** -- NOT the web-UI login email, NOT `JANeumatrix`; + (b) GitBucket 401s PAT basic-auth on `/api/v3/` but ACCEPTS it for git transport, so + validate creds against `info/refs?service=git-receive-pack`, never the API; + (c) `store` DELETES the credential on a 401, so a rejected token silently empties the file. + SEC-005 is OPEN: plaintext account-wide token on the jumphost -- revoke/rotate at v1 close. + ## State facts to remember - beta cluster left at **node_count=2** (deliberate; bonus resize acceptance coverage).