name: secrets-via-bitwarden description: Use whenever a credential (password, API key, login) is needed for a service that doesn't already have a per-service token file. Loads the Bitwarden CLI workflow, the sartor-secret wrapper, the locked-vault handling, the per-machine setup, and the migration recipe for known-leaked passwords. Reference secrets by name, never by value.
secrets-via-bitwarden
The Sartor convention for retrieving credentials. Reference secrets by name, never by value — in chat, memory files, scripts, or git commits. The vault is the only sanctioned location for credential values.
When this applies
Invoke when about to:
- Access a BMC, router, switch, or other web admin that doesn't have an API key
- Use a password "we know" — first stop is the vault, never paste from chat history
- Add a new service that needs a credential — create the vault entry first, then write the code that reads from it
Skip for: services that already have per-service token files (~/.config/vastai/, ~/.ssh/, .git-credentials, etc.). Those follow their own conventions.
The retrieval pattern
# Inline use — preferred, password never lands in argv twice
curl -k -s -u "admin:$(sartor-secret read 'BMC rtxserver')" \
"https://192.168.1.156/redfish/v1/Systems/Self/LogServices/SEL/Entries"
# Short-lived shell var when same secret is reused several times in a script
PASS="$(sartor-secret read 'BMC rtxserver')" || exit $?
The wrapper at Sartor-claude-network/scripts/sartor-secret (Python; .cmd shim on Windows) is the only sanctioned entry point. Don't call bw get directly — the wrapper exists so the locked-vault behavior is uniform.
Wrapper subcommands:
| Subcommand | Purpose | Exit codes |
|---|---|---|
read <NAME> [--field password|username|uri|totp|notes] |
Retrieve one field from one item. Default field is password. Emits the value to stdout with NO trailing newline. |
0 ok / 2 locked / 3 not-found-or-ambiguous / 4 not-logged-in / 5 other |
status |
Vault state, no values. Use to probe whether a follow-up read will succeed. |
0 unlocked / 2 locked / 4 unauthenticated / 5 other |
unlock |
Interactive: prompt for master pwd, cache the session token. | 0 ok / 4 prereq-missing / 5 other |
list [--search SUBSTR] |
NAMES ONLY (no values). Audit/discovery primitive. | 0 ok / 2 locked / 5 other |
There is intentionally no bulk read, no --all-fields, no JSON output. Add only if a real need surfaces; the surface area cost is high.
Vault item naming convention
Predictable names so I can guess without searching. Lowercase-friendly. One canonical name per service.
| Pattern | Example |
|---|---|
BMC <hostname> |
BMC rtxserver |
<Network-device> admin |
Fios admin, UniFi superadmin |
<Service> API key |
Anthropic API key, OpenAI API key |
<Service> <account-purpose> |
Bitwarden master, Chase business |
When uncertain, search the vault by URI rather than guessing repeatedly.
Service inventory
Sartor services that hold credentials, with the canonical vault item name. Update on each migration. Names only — no values. The vault is the source of truth for what value; this table is a map of what name to ask for.
| Vault item name | Service | URL / endpoint | Migration status | Rotation method |
|---|---|---|---|---|
BMC rtxserver |
ASUS WRX90E-SAGE SE BMC, account 4 (admin) |
https://192.168.1.156 (Redfish at /redfish/v1/) |
Migrated 2026-05-03 | Redfish PATCH (see Playbook 1) |
BMC gpuserver1 |
gpuserver1's IPMI/BMC if exposed | TBD — gpuserver1's motherboard may or may not have BMC; verify | Pending | TBD per board |
Fios admin |
Verizon Fios CR1000A router | https://192.168.1.1 |
Not yet held — Alton has never had admin to this router. Sticker default 6GDPD3G3H was tried during takeover, did not work. Need to either: factory-reset CR1000A (loses ISP-side provisioning), call Verizon to recover, or pivot to UCG-Pro and put CR1000A in bridge mode |
Web UI Vue.js SPA via Chrome MCP + clipboard (no API). See Playbook 2 caveat. |
UniFi superadmin |
UniFi local controller (Rocinante-hosted) | https://192.168.1.171:8443 |
Leaked 2026-05-01 during takeover (alton:;lkjpoiu0987 appears in unifi-takeover-2026-05-01.md Step 3 example payload + adoption-pushed AP creds) |
Controller API or MongoDB-direct (Playbook 3) |
UniFi local-admin <ap-hostname> |
Per-AP mgmt.authkey (used for adoption handshake, not interactive login) |
each AP via SSH | n/a — controller-managed, not user-rotatable. Master copy is C:\Users\alto8\backups\unifi\ap-authkeys-2026-05-01.json (file-only, treated like .ssh/, not in vault, not in git). |
Controlled by adoption |
WiFi LGP123 PSK |
The household WiFi network's WPA passphrase | UniFi controller → Settings → WiFi | Live (broadcast in the air), rotation = "all family devices reconnect" event | UniFi controller UI; coordinate with Aneeta |
Meridian dashboard |
MERIDIAN family dashboard login (single-password cookie-auth, no username) | http://127.0.0.1:5055/login (dashboard/family/server.py) |
Imported 2026-05-13 from runtime source .secrets/meridian-password.txt (gitignored). File is still the runtime source — server.py reads it directly; vault entry is the manageable copy. |
To rotate: generate new value, save to vault FIRST (hygiene rule #6), overwrite .secrets/meridian-password.txt, restart uvicorn. No vendor API. |
Anthropic API key |
Anthropic Console | https://console.anthropic.com |
n/a (Claude Code uses OAuth; separate path via Sartor Peer Creds Sync) |
Console regen |
Bitwarden master |
The vault itself | https://vault.bitwarden.com |
n/a — only in Alton's head; recovery via Bitwarden's emergency-access if ever set up | Web UI (vault settings) |
Other things that look like secrets but DON'T live in Bitwarden:
- vast.ai API key — already at
~/.config/vastai/vast_api_keymode 600 per-machine. Generated fresh per machine on host onboarding. - SSH keys + passphrases —
~/.ssh/per machine, key-based auth. Key passphrases (if any) live in OS keychain (Windows Credential Manager /ssh-agent). No rotation needed unless compromise. - AP
mgmt.authkeyper UniFi device — fileap-authkeys-2026-05-01.jsonoutside repo, mode-600, controller-managed. - Anthropic Claude OAuth tokens — refreshed by
Sartor Peer Creds SyncWindows Scheduled Task every 4h. - TLS / Cloudflare API tokens — n/a (Sartor doesn't expose anything via TLS-with-domain-cert today).
- MongoDB on Rocinante (UniFi backing store) — no auth, loopback-only at
mongodb://127.0.0.1:27117/ace. The "credential" is host access to Rocinante itself.
Add new rows when migrating. Don't pre-populate — only after the vault entry exists and works.
When the vault is locked (exit 2 with [BW_LOCKED])
Do NOT:
- Re-prompt for the master password yourself
- Search filesystem / chat history /
.jsonlfiles for the value - Guess "household defaults"
- Bypass the wrapper
DO:
- Halt the operation that needed the secret
- Surface the lock state to Alton in chat
- Ask him to run
sartor-secret unlock(orbw unlock+ cache the token), then retry
Per-machine install (one-time, by Alton)
Rocinante (Windows 10)
# Install bw CLI
npm install -g @bitwarden/cli
# (or) winget install Bitwarden.CLI
# First-time login (interactive — master password prompt)
bw login alton@example.com
# Add Sartor scripts to PATH
$current = [Environment]::GetEnvironmentVariable("Path", "User")
[Environment]::SetEnvironmentVariable("Path", "$current;C:\Users\alto8\Sartor-claude-network\scripts", "User")
# Test
sartor-secret status
Linux peers (rtxserver, gpuserver1)
# Install bw CLI
sudo npm install -g @bitwarden/cli
# First-time login
bw login alton@example.com
# Add scripts to PATH (in ~/.bashrc)
echo 'export PATH="$HOME/Sartor-claude-network/scripts:$PATH"' >> ~/.bashrc
chmod +x ~/Sartor-claude-network/scripts/sartor-secret
# Test
sartor-secret status
After every reboot (per machine)
Vault re-locks on reboot. Alton must unlock once:
sartor-secret unlock # prompts for master password, caches session
Then either re-source the profile (. $PROFILE on Windows, source ~/.bashrc on Linux) OR open a new shell. The cache file is written to ~/.config/sartor/bw-session (Linux) / %LOCALAPPDATA%\Sartor\bw-session (Windows), mode 600.
Auto-source on shell start (recommended)
Add once to ~/.bashrc (Linux) so new shells auto-load the cached session:
if [ -r ~/.config/sartor/bw-session ]; then
export BW_SESSION="$(cat ~/.config/sartor/bw-session)"
fi
Git Bash on Windows — same pattern but Windows path:
# in ~/.bashrc on Git Bash
WIN_CACHE="/c/Users/alto8/AppData/Local/Sartor/bw-session"
if [ -r "$WIN_CACHE" ]; then
export BW_SESSION="$(cat "$WIN_CACHE")"
fi
PowerShell $PROFILE:
$bwCache = "$env:LOCALAPPDATA\Sartor\bw-session"
if (Test-Path $bwCache) {
$env:BW_SESSION = (Get-Content $bwCache -Raw).Trim()
}
Hygiene rules (these are the point of the skill)
- Reference secrets by name in code/chat/memory:
sartor-secret read 'BMC rtxserver'— never the literal value. - No secrets in
sartor/memory/: the wiki holds references, never values. - No secrets in chat: when discussing a credential, name it (
the BMC password for rtxserver); don't paste it. - No secrets in commit messages or PRs.
- Process substitution
$(...)keeps secrets out ofargv(visible tops -ef); prefer it to setting persistent env vars when only one call needs the value. - Vault save FIRST, rotate second. Every time you import an existing-but-leaked password into the vault, save the NEW value to the vault before sending it to the service. If the service rotation fails, the new value still has a home; if vault save fails, you don't strand yourself with a service-only credential. The vault entry replaces the leaked value.
- Never echo a value to stdout in a way that lands in shell history.
bw get password Xthen copying from terminal is acceptable;echo $PASS >> some.logis not. Never useGet-Clipboardto "verify" a value — it prints to stdout. - Clipboard discipline. After Playbook 2's clipboard-paste pattern, immediately overwrite the clipboard with
Set-Clipboard -Value ' '(orxclip -selection clipboard < /dev/nullon Linux). The clipboard is a per-session global accessible to any other window. Treat clipboard residue like a temp file — clean up. - Don't put secrets in env vars of long-running processes. A service-manager-launched daemon that holds
BW_SESSIONor a service password in its env exposes the value to anyone with/proc/<pid>/environread on Linux (root or same-uid). For one-shot scripts: env is fine and short-lived. For systemd services: useEnvironmentFile=pointing at a mode-600 file, or load secrets at use-time, not boot-time. - Shell-special chars in secrets. Bitwarden generates values with
$, backtick,!,\, quotes. Always quote:"$NEW"not$NEW. For JSON bodies: usepython3 -c 'json.dumps(...)'notprintfsubstitution. For SSH remote command: use stdin (<<<"$NEW") not interpolation. Playbook 5 has the patterns. - Process listing is public on Linux. Any user can
ps -ef. Argv is public; env is per-uid-private. Never put a secret as a command-line argument.
Decision tree — given a new service, how do I rotate
Is this a known-leaked household-default password we found in a new place?
├── YES → list every service we know uses it (search memory + chat for the value's neighbors),
│ rotate ALL of them, then rotate the underlying default itself if it lives anywhere
│ (e.g., a sticker, a wiki page). Treat as a multi-service migration; one row per service in
│ the migration log, plus a "supersedes household default" note.
│
└── NO, single service:
Does the service have a REST/JSON API for credential change?
├── YES → use API (curl + JSON body in mode-600 file)
│ ├── Redfish (BMCs: ASUS WRX90E, Supermicro X12/X13, Dell iDRAC, HPE iLO)
│ │ → Playbook 1. Supermicro IPMI 2.0 + Redfish: same Playbook 1 pattern; quirks vary
│ │ by BMC vendor (Supermicro: AccountService schema slightly different — confirm
│ │ `MaxPasswordLength` and `If-Match` behavior at first run).
│ ├── UniFi controller → Playbook 3 (or MongoDB-direct fallback)
│ ├── vast.ai / Anthropic / OpenAI / Claude.ai
│ │ → API-key regen on the vendor console. NOT a Bitwarden migration in v0 because
│ │ the value lives in a per-machine token file (`~/.config/vastai/`, `~/.config/anthropic/`).
│ │ Optional: ALSO save to vault as a backup/audit trail.
│ ├── SaaS with admin portal (Stripe, Zapier, Notion, Linear, GitHub, etc.)
│ │ → Most expose API-key regen via the dashboard, not via API. That's Playbook 2 (web UI).
│ │ If they DO have a credentials API (rare), Playbook 5.
│ └── Other API → Playbook 5 (generic) as template
│
├── NO, web UI only:
│ ├── Browser-friendly admin (Fios, simple routers, ASUS routers)
│ │ → Playbook 2 (Chrome MCP + clipboard pattern)
│ ├── IoT camera / smart-home device (Google Nest, Ring, Wyze, generic ONVIF)
│ │ → Playbook 2 if there's a web admin; many have ONLY a mobile app, in which
│ │ case the rotation requires Alton's phone — not a Claude-automatable path.
│ │ Document the device + app-only constraint and stop.
│ ├── SaaS dashboard (Stripe API-key regen, GitHub PAT regen, etc.)
│ │ → Playbook 2; clipboard pattern still applies to the "show new key once" reveal step.
│ └── Application admin (some appliances, vendor portals)
│ → Playbook 2 still applies; treat as plain web form
│
├── Linux/Unix user account on a peer machine:
│ → Playbook 4. For Sartor peers: skip (NOPASSWD: ALL configured; verified 2026-05-03 on gpuserver1).
│
├── SSH key passphrase:
│ → NOT a sartor-secret target. Use `ssh-keygen -p -f <key>` interactively;
│ store the new passphrase in vault as `SSH key <machine> <username>` if you set one.
│ For Sartor peers, current convention is no-passphrase keys + per-host
│ `authorized_keys` access control — passphrase rotation is moot.
│
└── Hardware-only (BMC at console, OOB serial, sticker-default-only-no-network):
→ out of scope for sartor-secret; document per-device, escalate to Alton.
Migration recipe — generic procedure
For each credential currently in chat history, .jsonl files, sticker photos, etc.:
- Discover the service's password policy first. Don't generate a 24-char password and find out at rotation-time the service caps at 20. For Redfish BMCs:
GET /redfish/v1/AccountServicereturnsMinPasswordLength/MaxPasswordLength. For routers/switches: check the password-change form's HTML constraints or the docs. For everything else: docs. - Generate at the policy-allowed maximum:
bw generate --uppercase --lowercase --number --special --length <max>. Capture into a shell variable; do NOT echo. - Save to vault FIRST, before rotating at the service. If service rotation fails, the new value still has a home; if vault save fails, you don't strand yourself with a service-only credential. Use
bw get template item | python3 -c '...mutate...' | bw encode | bw create item. - Verify vault retrieval matches the generated value. Diverge here = abort, investigate.
- Rotate at the service using the appropriate playbook (below).
- Verify the new value by re-authenticating. Old value should now reject.
- Append to migration log (
sartor/memory/business/secrets-migration-log.md): one row, no values. Update the Service inventory table above. - Don't try to scrub the old value from chat history /
.jsonlfiles — that data is durable. Rotation kills the value; the old leak is now harmless.
Per-service rotation playbooks
Playbook 1 — Redfish PATCH (BMCs running AMI MegaRAC)
Used 2026-05-03 for BMC rtxserver. Path for ASUS WRX90E-SAGE SE and likely any AMI MegaRAC BMC.
# 1. Find the admin account ID (UserName="admin", not "Administrator" / "fwupd")
curl -k -s -u "admin:$OLD" "https://<bmc>/redfish/v1/AccountService/Accounts" \
| python3 -c "import sys,json; [print(m['@odata.id']) for m in json.load(sys.stdin)['Members']]"
# inspect each: look for UserName="admin", RoleId="Administrator", PasswordChangeRequired=false
# 2. Discover length policy
curl -k -s -u "admin:$OLD" "https://<bmc>/redfish/v1/AccountService" \
| python3 -c "import sys,json; j=json.load(sys.stdin); print(j.get('MinPasswordLength'), j.get('MaxPasswordLength'))"
# 3. GET the account to harvest ETag (PATCH requires If-Match)
ETAG=$(curl -k -s -D - -o /dev/null -u "admin:$OLD" "https://<bmc>/redfish/v1/AccountService/Accounts/<id>" \
| grep -i '^etag:' | head -1 | awk '{print $2}' | tr -d '\r\n')
# 4. PATCH (NEW value via mode-600 file so it doesn't land in argv)
curl -k -X PATCH -H "Content-Type: application/json" -H "If-Match: $ETAG" \
-u "admin:$OLD" --data @/tmp/.patch.json \
"https://<bmc>/redfish/v1/AccountService/Accounts/<id>"
# Expected: HTTP 200 or 204 (no content)
AMI MegaRAC quirks worth remembering:
MaxPasswordLength: 20on rtxserver's BMC (not 24, not unlimited). If you generate at 24 first, the BMC will silently truncate or reject — discover the policy at step 1, not at PATCH-time. The 2026-05-03 rotation hit this, regenerated at 20, and saved a second value into the vault before re-PATCHing.If-Matchheader is required for PATCH on Accounts — returns HTTP 428 (Precondition Required) without it. Capture fromETagresponse header on a prior GET. The ETag changes after every PATCH; re-harvest before each retry.Chassis/Self/Power → PowerSupplies[].Status.State == "Absent"for the second PSU on rtxserver's WRX90E-SAGE SE BMC even though the system runs on a single PSU. This is expected for single-PSU builds with redundancy-capable boards; do not treat as alert noise. (Discovered during the same 2026-05-03 forensics window.)bwCLI on Windows is namedbw.cmd(a shim). Python'ssubprocess.run(["bw",...])won't find it withoutshell=Trueor full path. Avoid: dobwcalls in bash, use Python only for stdin-stdout JSON manipulation.BW_SESSIONdoesn't auto-source in Git Bash on Windows (only PowerShell via$PROFILE). Source explicitly:export BW_SESSION="$(cat /c/Users/alto8/AppData/Local/Sartor/bw-session)".Vault item edit (vs create) uses
bw edit item <id>with the FULL updated item JSON, not a partial patch. Pull current viabw get item <id>, mutate, push back. Concrete recipe (used 2026-05-03 to swap a 24-char value to a 20-char value):ID=$(bw get item 'BMC rtxserver' | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") NEW="$(bw generate --uppercase --lowercase --number --special --length 20)" # Mutate JSON in-process (NEW only via env to keep argv clean) NEW="$NEW" bw get item "$ID" \ | NEW="$NEW" python3 -c "import sys,json,os; j=json.load(sys.stdin); j['login']['password']=os.environ['NEW']; sys.stdout.write(json.dumps(j))" \ | bw encode \ | bw edit item "$ID" unset NEW # Verify test "$(sartor-secret read 'BMC rtxserver' | wc -c)" -eq 20 && echo OK
Playbook 2 — Web UI only (Fios CR1000A, generic admin panels)
When there's no API. Last resort. The risk is the new password value landing in form_input value=NEW tool-call args. The clipboard pattern routes around this: PowerShell sets the clipboard from a value never displayed, Chrome MCP pastes via Ctrl+V keystroke, value never appears in any tool argument.
[!warning] Fios CR1000A precondition: Alton has no admin credential today. Per
sartor/memory/reference/federated-memory-map.md(2026-05-02), Verizon retains admin to the CR1000A. Sticker default6GDPD3G3Hwas tried during the 2026-05-01 takeover and did not work. Recovery options: (a) factory-reset CR1000A via the rear pinhole — loses Verizon-side provisioning, requires a phone call to re-provision; (b) call Verizon support for an admin reset; (c) pivot to UCG-Pro and put CR1000A in bridge mode (admin moot). Until one of these happens, Playbook 2 cannot be exercised against Fios. The playbook is still correct for a future Fios reset and for any other web-only admin panel.
The generic clipboard pattern. PowerShell value-in-variable, never displayed; Chrome MCP focuses the field via mcp__claude-in-chrome__computer click, then mcp__claude-in-chrome__shortcuts_execute sends Ctrl+V; after submit, clipboard is cleared.
# Single PowerShell session; the value stays in $pw, never displayed, never logged
$pw = & "C:\Users\alto8\Sartor-claude-network\scripts\sartor-secret.cmd" read 'Fios admin'
Set-Clipboard -Value $pw
# Hand off to Chrome MCP: click the password input field, then send Ctrl+V keystroke
# After form submit, clobber the clipboard:
Set-Clipboard -Value ' '
Remove-Variable pw
[GC]::Collect()
Concrete Chrome MCP invocation pattern (load via ToolSearch first per the chrome-automation skill):
mcp__claude-in-chrome__navigateto the change-password URL (or the admin page, then click through).mcp__claude-in-chrome__findwith a description like "old password input" to locate the field; capture its element id.mcp__claude-in-chrome__computerclick on the located field. Do NOT useform_input value=...— that puts the secret in the tool-call argument, which is the entire failure mode this pattern avoids.mcp__claude-in-chrome__shortcuts_executewithkeys: "Ctrl+V"(or, fallback,mcp__claude-in-chrome__javascript_toolto fire a synthetic paste event).- Repeat for new-password and confirm-new-password fields, with PowerShell
Set-Clipboardre-set between fields if values differ. - Submit via click; verify success by re-navigating and reading page text via
mcp__claude-in-chrome__get_page_text.
Generic flow checklist:
- Generate + save-to-vault as in the migration recipe (steps 2-4 above).
- Drive Chrome MCP to the password-change page.
- Set OLD password into clipboard via PowerShell; paste into OLD field; clear clipboard (
Set-Clipboard -Value ' '). - Set NEW password into clipboard; paste into NEW + confirm fields; clear clipboard.
- Submit.
- Verify: log out, log back in via the same clipboard pattern. If re-auth succeeds, value is correct in both vault and service. If it fails, you're stranded — see hygiene rule #6 (vault save FIRST means at least the vault has it).
- Final clipboard clobber + GC before ending the PowerShell scope.
Verifying the new password without leaving it visible. The Fios CR1000A admin presents a logout link in the top nav; logout drops you back to the login page; pasting the new value via the same clipboard pattern and getting a 200 + admin landing page is sufficient verification. Do not use Get-Clipboard to inspect — that prints the value to PowerShell's stdout, which lands in shell history.
Fios CR1000A specifics (Vue.js SPA):
- Self-signed cert; expect a Chrome interstitial. The chrome-automation profile already trusts it after the first manual through-click.
- Some Verizon firmware revisions require re-authentication on the change-password form itself (current password as an extra third field). Treat this as a "yes" — populate from clipboard with the OLD value, paste, then move on.
- CSRF tokens are embedded in the page DOM as a hidden input; the Vue.js form picks them up automatically when you click-and-paste into the visible inputs. You should NOT need to pull the CSRF token explicitly.
- The admin SPA aggressively redirects on session timeout — keep the session alive by working in a single Chrome MCP run.
Status: sketched, not yet executed. Run will happen the first time we have an admin credential to work with. Update this section after.
Playbook 3 — UniFi controller API (or MongoDB-direct fallback)
UniFi controller exposes /api/... endpoints with cookie-based auth from /api/login. The login JSON shape is verified from the 2026-05-01 takeover work: {"username": "...", "password": "...", "remember": false} (NOT strict:true — that's a different concept; remember is the persistent-cookie flag and false is correct for a one-shot rotation).
# 1. Login (POST credentials, capture session cookie)
USER_VAL="$(sartor-secret read 'UniFi superadmin' --field username)"
PASS_VAL="$(sartor-secret read 'UniFi superadmin')"
USER_VAL="$USER_VAL" PASS_VAL="$PASS_VAL" python3 -c "import os,sys,json; sys.stdout.write(json.dumps({'username': os.environ['USER_VAL'], 'password': os.environ['PASS_VAL'], 'remember': False}))" > /tmp/.unifi-login.json
chmod 600 /tmp/.unifi-login.json
curl -k -s -c /tmp/.unifi-cookie.txt -X POST \
-H "Content-Type: application/json" \
--data @/tmp/.unifi-login.json \
"https://192.168.1.171:8443/api/login"
chmod 600 /tmp/.unifi-cookie.txt
unset USER_VAL PASS_VAL
rm -f /tmp/.unifi-login.json
# 2. Find the admin account ID
curl -k -s -b /tmp/.unifi-cookie.txt "https://192.168.1.171:8443/api/stat/admin" \
| python3 -c "import sys,json; [print(a.get('_id'), a.get('name'), a.get('email')) for a in json.load(sys.stdin)['data']]"
# 3. Change password — verify the right endpoint for your version (see caveat below).
# Best-known shape for v8.x super-admin self-edit:
NEW="$(bw generate --uppercase --lowercase --number --special --length 32)"
NEW="$NEW" python3 -c "import os,sys,json; sys.stdout.write(json.dumps({'x_password': os.environ['NEW']}))" > /tmp/.unifi-patch.json
chmod 600 /tmp/.unifi-patch.json
curl -k -s -b /tmp/.unifi-cookie.txt -X PUT \
-H "Content-Type: application/json" \
--data @/tmp/.unifi-patch.json \
"https://192.168.1.171:8443/api/s/default/admin/<admin_id>"
# Update vault BEFORE the call too — see hygiene rule #6.
# 4. Cleanup
rm -f /tmp/.unifi-cookie.txt /tmp/.unifi-patch.json
unset NEW
[!caution] API endpoint shape NOT verified at the time of this skill's writing. UniFi has shipped at least three different admin-management API shapes across v6/v7/v8. The known-good ones across versions:
PUT /api/s/default/admin/<admin_id>with{"x_password": "..."}— most common in v7-v8.POST /api/cmd/sitemgrwith{"cmd": "update-admin", "admin_id": "...", "x_password": "..."}— also accepted in some v7/v8 builds.PUT /api/users/self— for self-update, sometimes restricted to non-super-admin roles.First-time runners: probe with a no-op
GET /api/stat/admin, eyeball the JSON, then try the v8PUTshape above with verbose logging. If it returns a non-zerometa.rc(i.e. anything but"ok"), fall back to thecmd/sitemgrshape. Capture which one worked here after first execution.
MongoDB-direct fallback (Plan B). The UniFi controller backs onto mongodb://127.0.0.1:27117/ace with no auth (loopback only — host access to Rocinante IS the credential). When the API misbehaves, the admin password lives in the admin collection as a bcrypt hash in the x_shadow field (NOT a reversible password). Set a new bcrypt hash directly and the controller picks it up immediately:
# On Rocinante. NEW value via environment, never argv. Generates the hash
# from the value already in the vault — never types it on the command line.
import os, bcrypt
from pymongo import MongoClient # pymongo<4 because UniFi MongoDB is wire-version 6
NEW = os.environ['NEW'] # set by caller via: NEW="$(sartor-secret read 'UniFi superadmin')" python3 ...
hashed = bcrypt.hashpw(NEW.encode(), bcrypt.gensalt(rounds=10)).decode()
db = MongoClient('mongodb://127.0.0.1:27117').ace
db.admin.update_one({'name': 'alton'}, {'$set': {'x_shadow': hashed}})
del NEW, hashed
The MongoDB path is more reliable than the API across UniFi version bumps because the schema is stable. It's also the only path when the controller's HTTP layer is misbehaving (e.g., during a Spring boot reload).
Status: sketched + MongoDB fallback designed but not yet executed. Update this section after first UniFi rotation runs.
Playbook 4 — Linux user account on a peer
Sartor peers: this playbook is essentially never needed. Verified 2026-05-03 on gpuserver1: alton has (ALL) NOPASSWD: ALL configured. The user password is never prompted for sudo. SSH key auth handles login. Same applies to rtxserver per MACHINES.md.
The only Sartor scenario where you'd actually rotate a Linux user password:
- A peer is breached and the bcrypt hash in
/etc/shadowis suspected leaked. - A locally-physical-access attacker is in the threat model and console-login-without-key matters.
- A Sartor service genuinely requires interactive password (none today).
For non-Sartor servers (third-party VPS, school admin lab, etc.) the recipe is:
# Push the new password to the user via chpasswd, value via env so it doesn't land in argv on the local side.
NEW="$(sartor-secret read 'Linux <hostname> alton')"
ssh -T alton@<peer> "sudo chpasswd" <<<"alton:$NEW"
unset NEW
# Note: the NEW value still transits SSH (encrypted), and lands in chpasswd's stdin (not argv).
# Avoid `passwd` interactive — it expects a tty and the old-pwd-confirm dance.
Skip this playbook for Sartor unless explicitly asked.
Playbook 5 — Generic API with Basic auth or bearer token
Template for new services. Adapt the curl invocation:
NEW=$(sartor-secret read '<vault item>')
OLD=$(... obtain from vault if previously migrated, else from a temp file ...)
# Build patch body in mode-600 file — value never in argv
NEW="$NEW" python3 -c "import os,sys,json; sys.stdout.write(json.dumps({'<password-field-name>': os.environ['NEW']}))" > /tmp/.patch.json
chmod 600 /tmp/.patch.json
# PATCH/PUT/POST as the API requires.
# Use --data-binary @file (NOT --data @file) — --data strips newlines and can corrupt some bodies.
curl <auth-flag> --data-binary @/tmp/.patch.json -H "Content-Type: application/json" "<URL>"
# Verify (re-auth with new value)
# Cleanup
rm -f /tmp/.patch.json
unset NEW OLD
Shell-special characters in secrets. Bitwarden's generator can produce values containing $, backtick, !, \, single-quote, double-quote — all of which have meaning in bash. Two safety patterns:
- Always pass via env, not argv.
NEW="$(sartor-secret read X)"; some-command "$NEW"is fine because"$NEW"is one fully-quoted argv slot.some-command $NEW(unquoted) is wrong and will mis-tokenize on whitespace. - For embedding in JSON: use Python with
json.dumps()(as in the template) — neverprintf '%s'orsedsubstitution. JSON requires escapes for",\, control chars;json.dumpshandles all of them. Equivalent for embedding in URLs:python3 -c "import os,urllib.parse;print(urllib.parse.quote(os.environ['NEW'],safe=''))". - For ssh remote command:
ssh peer "cmd $NEW"mis-quotes (the value gets re-parsed by the remote shell). Use stdin:ssh peer cmd <<<"$NEW"orssh peer "cmd \"\$NEW\""with the variable expanded only once on the remote side. Thechpasswd <<<"user:$NEW"pattern in Playbook 4 is correct.
Document any new service's quirks in this skill after the first successful rotation.
Account topology
| Phase | Setup | When |
|---|---|---|
| v0 (now) | Single Bitwarden Free account (Alton's). I borrow his unlocked session per machine. | Today |
| v1 | Free 2-user Organization. Items in a "Household Infrastructure" Collection visible to Alton + Aneeta. Subscription stays $0. | When Aneeta needs household-shared secret access |
| v2 | Bitwarden Premium ($10/yr) for TOTP storage; or Secrets Manager (separate paid product) for true headless machine accounts | Only if real friction emerges |
Open follow-ups (not blocking v0)
sartor-claude-peer.serviceintegration: peer-Claude on rtxserver/gpuserver1 needsBW_SESSIONavailable at boot. v0: Alton unlocks each peer once after reboot. v0.1 option: extend the existingSartor Peer Creds SyncWindows Scheduled Task (every 4h) to also push fresh BW_SESSION tokens to peers. Trade-off: couples peer secret-access to Rocinante's vault-unlock state. Defer until needed.- Subscription tracker (separate Sartor work item) — Bitwarden free tier means no new subscription, but the broader "what do I pay for monthly" gap still wants closing.
- Fios admin: not currently held. Either factory-reset the CR1000A (loses Verizon-side provisioning), call Verizon for an admin reset, or pivot to UCG-Pro + bridge mode. Tracked in
unifi-takeover-2026-05-01-INDEX.mdUCG-Pro section. - UniFi rotation: API endpoint shape needs first-execution validation. Playbook 3's
PUT /api/s/default/admin/<id>shape is best-known-but-unverified for v8.x. First runner: probe + log the working endpoint here. - BMC gpuserver1 verification. Whether the i9-14900K motherboard in gpuserver1 has a BMC at all. If yes, add to inventory. If no, drop the row.
Auditor notes
Audit pass 2026-05-03 (auditor-Claude): walked all 5 playbooks, decision tree, hygiene rules, wrapper. Verified live: sartor-secret status/read/list/not-found paths all return correct exit codes. Confirmed (ALL) NOPASSWD: ALL for alton on gpuserver1 (Playbook 4 is essentially never needed for Sartor). Confirmed CR1000A admin precondition gap (Alton has no credential). UniFi controller reachable; admin-update endpoint shape NOT live-verified (no vault item to test with — UniFi superadmin migration still pending). Confirmed UniFi MongoDB at 127.0.0.1:27117/ace is the documented Plan B. Wrapper changes: better error pattern matching for bw error strings, new list subcommand returning names only.
Related
Sartor-claude-network/scripts/sartor-secret— the wrapperSartor-claude-network/scripts/sartor-secret.cmd— Windows shimsartor/memory/business/secrets-migration-log.md— created on first migrationpeer-commsskill — peers retrieve secrets the same way once installed
What this skill does NOT cover
- The actual rotation of any specific credential — that's per-service procedure
- SSH key management — already handled by
~/.ssh/conventions - vast.ai API keys — already handled by
~/.config/vastai/vast_api_keyper-service token file - OAuth tokens (Anthropic Claude OAuth) — already handled by
Sartor Peer Creds Synctask