From fff45a98356e1fe6115523aab879a2c010cface7 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 14:53:30 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat(users):=20@root=20seeds=20the=20admin'?= =?UTF-8?q?s=20keys=20from=20root's=20own=20=E2=80=94=20the=20one=20source?= =?UTF-8?q?=20that=20cannot=20lock=20you=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headline of #17: rig can verify a lot locally, but never that the operator HOLDS the admin's private key. Seeding authorized_keys from root's current /root/.ssh/authorized_keys turns that unprovable claim into a proven one — the operator is connected as root right now using one of those keys. The users file gains the literal key-field token '@root', shape-validated in the parse pass (exit 2, pre-root-check, testable non-root); apply resolves it once after the root check, dies with the repair when root has no keys to seed, copies key lines verbatim (options included — rig will not silently widen what a key can do), and writes seeded keys first with literal lines appended, so the cmp-guard keeps re-runs convergent to root's then-current keys plus the literals. Co-Authored-By: Claude Fable 5 --- commands/lib/users-config.sh | 24 ++++++++++++++--- commands/users-apply.sh | 51 +++++++++++++++++++++++++++++++++--- test/cli.sh | 30 +++++++++++++++++++++ 3 files changed, 98 insertions(+), 7 deletions(-) diff --git a/commands/lib/users-config.sh b/commands/lib/users-config.sh index 69251ee..d5512d4 100644 --- a/commands/lib/users-config.sh +++ b/commands/lib/users-config.sh @@ -13,6 +13,16 @@ # Repeated username lines are additional authorized keys; the roles field must # be IDENTICAL on each — a repeated line means "another key", never a quiet # role edit hiding mid-file. '#' comments and blank lines are skipped. +# +# The key field may also be the literal token '@root' (#17): "this user's +# authorized_keys becomes root's CURRENT /root/.ssh/authorized_keys at apply +# time". The operator provably holds a root private key — they SSHed in with +# it to run apply at all — so seeding it is the one key source that cannot +# lock them out; any pasted literal can be a key they do not hold. '@root' +# mixes with literal key lines: seeded keys come first, literal keys are +# APPENDED after them, and re-runs converge to root's then-current keys plus +# the literals. The parser only owns the token's shape — reading root's file +# needs root and is apply's business. # parse_users_file # @@ -24,9 +34,11 @@ # Refusals: unknown role (the valid set is named), differing roles across one # user's lines, root as username (root's keys are class policy's business, not # this file's), malformed line (fewer than 3 fields, or a key field that does -# not start with an SSH key type), invalid username (the charset below — -# '|' would corrupt this parser's own delimited stream, a leading '-' reads -# as a useradd flag), duplicate identical key line. +# not start with an SSH key type and is not exactly '@root'), '@root' with +# trailing material (the token IS the whole field), invalid username (the +# charset below — '|' would corrupt this parser's own delimited stream, a +# leading '-' reads as a useradd flag), duplicate identical key line (a +# second '@root' for one user counts — the seen[] map catches it for free). parse_users_file() { local path="$1" local -a errs=() out=() rlist=() @@ -41,9 +53,13 @@ parse_users_file() { continue fi case "$k" in + @root) ;; # seed token — apply reads root's authorized_keys (#17) + @root*) + errs+=("line $n: '@root' is the whole key field — it names root's authorized_keys as this user's key source and takes no trailing material") + continue ;; ssh-*|ecdsa-*|sk-ssh-*|sk-ecdsa-*) ;; *) - errs+=("line $n: malformed — key field must start with an SSH key type (ssh-..., ecdsa-...)") + errs+=("line $n: malformed — key field must start with an SSH key type (ssh-..., ecdsa-...) or be the literal '@root'") continue ;; esac # The username feeds this parser's own '|'-delimited stream and then diff --git a/commands/users-apply.sh b/commands/users-apply.sh index 39021bd..97f0539 100755 --- a/commands/users-apply.sh +++ b/commands/users-apply.sh @@ -32,6 +32,17 @@ keys; the roles must be identical on every line of one user. dan admin,box ssh-ed25519 AAAA... dan@laptop maria rig,box ssh-ed25519 AAAA... maria@mac +The key field may also be the literal token '@root': this user's +authorized_keys is seeded from root's CURRENT /root/.ssh/authorized_keys. +You provably hold a root private key — you SSHed in with it to run apply at +all — so the seeded key is the one key that cannot lock you out. '@root' +mixes with literal key lines: seeded keys land first, literal keys are +appended after them, and every re-run re-seeds from root's then-current file +(convergent to it — a seeded key you remove from the admin by hand returns; +switch the line to literal keys to pin them). Root's key lines are copied +verbatim, options included — a from= or command= restriction on a root key +follows it to the user. Apply dies if root has no authorized_keys to seed. + roles: admin group rig-admin — full NOPASSWD sudo rig group rig — NOPASSWD sudo for /usr/local/bin/rig only @@ -78,11 +89,12 @@ fi PARSED="$(parse_users_file "$FILE")" \ || die "invalid users file: $FILE — every error is listed above; nothing was changed" 2 -declare -A USER_ROLES=() USER_KEYS=() +declare -A USER_ROLES=() USER_KEYS=() USER_SEED=() USERS=() BOX_USERS=() NEED_SUDO=0 NEED_INCUS=0 +NEED_SEED=0 while IFS='|' read -r u r k; do [ -n "$u" ] || continue if [ -z "${USER_ROLES[$u]:-}" ]; then @@ -90,7 +102,14 @@ while IFS='|' read -r u r k; do USER_ROLES[$u]="$r" case ",$r," in *,box,*) BOX_USERS+=("$u") ;; esac fi - USER_KEYS[$u]="${USER_KEYS[$u]:-}$k"$'\n' + # '@root' is a key SOURCE, not a key: remember who seeds and resolve the + # actual lines after the root check — /root/.ssh is unreadable before it. + if [ "$k" = "@root" ]; then + USER_SEED[$u]=1 + NEED_SEED=1 + else + USER_KEYS[$u]="${USER_KEYS[$u]:-}$k"$'\n' + fi case ",$r," in *,admin,*|*,rig,*) NEED_SUDO=1 ;; esac case ",$r," in *,box,*) NEED_INCUS=1 ;; esac done <<< "$PARSED" @@ -108,6 +127,24 @@ if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] \ die "the users family changes who holds root — only rig-admin members (or root itself) may run it; role rig grants operational rig use, not identity management (invoker: $SUDO_USER)" fi +# --- @root seed source (#17) ------------------------------------------------- +# The lockout-avoidance move: the operator SSHed in as root to run this at +# all, so root's CURRENT authorized_keys provably contains a key they hold — +# the one claim no local check can make about a pasted literal. Resolved ONCE +# here (post-root-check: /root/.ssh needs root) and copied verbatim, options +# included: a from=/command= restriction on a root key line follows it to the +# user, which is honest — rig will not silently widen what a key can do. +# Comments and blanks are dropped so the seeded block is exactly key lines; +# an empty result is a hard stop, because seeding nothing would converge the +# admin's authorized_keys to empty and close-root would then refuse — better +# to name the real problem now. +ROOT_SEED_KEYS="" +if [ "$NEED_SEED" -eq 1 ]; then + ROOT_SEED_KEYS="$(grep -Ev '^[[:space:]]*(#|$)' /root/.ssh/authorized_keys 2>/dev/null || true)" + [ -n "$ROOT_SEED_KEYS" ] \ + || die "a user's keys seed from @root but root has no authorized_keys (/root/.ssh/authorized_keys missing or without key lines) — @root's whole point is copying a key you provably hold; list a literal key instead" +fi + # Class is a note, never a refusal: #26's call is that operators belong on # EVERY class — what differs is root SSH's fate once they exist. case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in @@ -199,8 +236,16 @@ for u in "${USERS[@]}"; do home="$(getent passwd "$u" | cut -d: -f6)" ugroup="$(id -gn "$u")" mkdir -p "$home/.ssh" + # Seeded (@root) keys land FIRST, literal lines append after — fixed order + # so the cmp-guard sees identical bytes on identical state and re-runs + # converge to root's then-current keys plus the literals (#17). A literal + # that duplicates a seeded key writes twice; sshd does not mind and the + # bytes stay deterministic. AK_TMP="$(mktemp)" - printf '%s' "${USER_KEYS[$u]}" > "$AK_TMP" + { + if [ -n "${USER_SEED[$u]:-}" ]; then printf '%s\n' "$ROOT_SEED_KEYS"; fi + printf '%s' "${USER_KEYS[$u]:-}" + } > "$AK_TMP" if ! cmp -s "$AK_TMP" "$home/.ssh/authorized_keys" 2>/dev/null; then install -m 0600 -o "$u" -g "$ugroup" "$AK_TMP" "$home/.ssh/authorized_keys" log "authorized_keys for $u: $(grep -c . "$AK_TMP") key(s)" diff --git a/test/cli.sh b/test/cli.sh index ef069e2..884bd0b 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -425,10 +425,40 @@ check "users parser: one run reports the root line" 0 "" grep -q "not a rig-man check "users parser: same run reports the bad role" 0 "" grep -q "unknown role" "$MULTI_ERRS" rm -f "$MULTI_ERRS" +# --- '@root': seed the admin's keys from root's own authorized_keys (#17) ---- +# The operator provably holds a root private key — they SSHed in with it to +# run apply at all — so seeding root's CURRENT authorized_keys is the one key +# source that cannot lock them out. The parser owns only the token's SHAPE +# (reading /root/.ssh needs root and is apply's business), so the shape is +# proven here: the exact token parses, trailing material is refused, literal +# key lines mix (append semantics), a second '@root' is a duplicate, and root +# cannot seed itself. +printf '%s\n' 'dan admin @root' > "$FIX_BAD" +check "users parser: '@root' is a valid key field" 0 "dan|admin|@root" parse "$FIX_BAD" +printf '%s\n' 'dan admin @root ssh-ed25519 AAAA x' > "$FIX_BAD" +check "users parser: '@root' takes no trailing material" 1 "whole key field" parse "$FIX_BAD" +printf '%s\n' 'dan admin @root' 'dan admin ssh-ed25519 AAAAC3lit dan@desk' > "$FIX_BAD" +check "users parser: '@root' mixes with literal key lines" \ + 0 "dan|admin|ssh-ed25519 AAAAC3lit dan@desk" parse "$FIX_BAD" +printf '%s\n' 'dan admin @root' 'dan admin @root' > "$FIX_BAD" +check "users parser: a second '@root' line is a duplicate" 1 "duplicate key line" parse "$FIX_BAD" +printf '%s\n' 'root admin @root' > "$FIX_BAD" +check "users parser: root cannot seed from itself" 1 "not a rig-managed user" parse "$FIX_BAD" +# The empty-seed refusal (root has no authorized_keys) sits behind the root +# check — /root/.ssh is unreadable before it — so grep the die message, the +# same way every root-only refusal in this harness is pinned. +check "users apply: '@root' with a keyless root dies naming the repair" 0 "" \ + grep -q "root has no authorized_keys" "$ROOT/commands/users-apply.sh" + if [ "$(id -u)" -ne 0 ]; then # A VALID fixture proves the whole file-validation pass sits before the # root check — a parse failure here would exit 2, not 1. check "users apply: refuses non-root" 1 "must run as root" "$ROOT/commands/users-apply.sh" --file "$FIX_OK" + # An '@root' fixture reaching the root check proves the token is parse-pass + # validation, not a runtime surprise. + printf '%s\n' 'dan admin @root' > "$FIX_BAD" + check "users apply: '@root' fixture parses, refuses non-root" 1 "must run as root" \ + "$ROOT/commands/users-apply.sh" --file "$FIX_BAD" check "users status: refuses non-root" 1 "must run as root" "$ROOT/commands/users-status.sh" else echo "skip: users non-root refusals (running as root)" From d1b6fec5f8700cfb1e47eee92a33aaf7dd7211e2 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 14:53:50 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat(users):=20close-root=20proves=20the=20?= =?UTF-8?q?door=20opens,=20not=20that=20it=20should=20=E2=80=94=20sudo=20-?= =?UTF-8?q?n=20and=20per-user=20sshd=20-T=20join=20the=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The StrictModes-shaped gate reads files, and files can all look right while the door stays shut: a sudoers drop-in that never landed, an AllowUsers or Match block elsewhere in sshd's config. #17 names the two checks that interrogate behavior instead, and they now run per candidate, additively, before the drop-in installs: 'runuser -u -- sudo -n true' (NOPASSWD sudo answers or it does not — -n never prompts; a missing runuser skips the proof with a loud warning rather than blocking the door on a missing prover), and 'sshd -T -C user=,host=...,addr=...' (the per-user EFFECTIVE config — pubkeyauthentication yes, no literal DenyUsers hit, AllowUsers if set must name them; Allow/Deny patterns match literally, fail closed). The one thing no local check can prove remains possession of the private key — the separate-session advisory stays load-bearing. Co-Authored-By: Claude Fable 5 --- commands/users-close-root.sh | 72 +++++++++++++++++++++++++++++++----- test/cli.sh | 28 ++++++++++++++ 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/commands/users-close-root.sh b/commands/users-close-root.sh index 26d8d74..09846d4 100755 --- a/commands/users-close-root.sh +++ b/commands/users-close-root.sh @@ -12,6 +12,7 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" . "$HERE/lib/users-config.sh" log() { printf 'rig-users: %s\n' "$*"; } +warn() { printf 'rig-users: WARNING: %s\n' "$*" >&2; } die() { printf 'rig-users: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } usage() { @@ -26,10 +27,14 @@ Human class ONLY. On class=server, root SSH is the control plane's (Coolify's) automation identity — closing it severs fleet management — so close-root refuses there, with no --force. It also refuses without a role marker (re-run rig bootstrap; never shut the root door blind) and refuses while no rig-admin -member holds a login sshd would plausibly accept — authorized_keys present -and non-empty, home/.ssh/keys owned by the user and not group/world-writable -(sshd's StrictModes rejects the key otherwise), a real login shell, account -not expired. The refusal names which check failed, per candidate. Run rig +member holds a login this box would actually honor. Per candidate, in order: +the StrictModes shape (authorized_keys present and non-empty, home/.ssh/keys +owned by the user and not group/world-writable, a real login shell, account +not expired), then two reachability proofs (#17) — `sudo -n true` under +runuser must answer (NOPASSWD sudo is effective, not merely written), and +`sshd -T -C user=...` must resolve a per-user effective config that accepts +the login (pubkeyauthentication yes, no DenyUsers hit, AllowUsers — if set — +names them). The refusal names which check failed, per candidate. Run rig users apply first; never close the only door. Before running, verify your admin login in a SEPARATE session — `ssh @@ -70,16 +75,33 @@ if ! WHY="$(assert_marker_human "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then fi # Admin-door gate — never close the only door. Root SSH goes away below, so -# at least one rig-admin member must hold a login sshd would plausibly ACCEPT -# — a non-empty authorized_keys alone proves a file exists, not a door: +# at least one rig-admin member must hold a login this box would actually +# HONOR — a non-empty authorized_keys alone proves a file exists, not a door: # StrictModes rejects keys behind wrongly-owned or group/world-writable # paths, a nologin shell never logs in, and an expired account fails PAM # before the key is read. So every candidate is checked for the StrictModes -# shape, and the refusal names, per candidate, WHICH check failed — an -# operator staring at a refusal must see the repair. Honestly: this proves -# the door SHOULD open per StrictModes, not that it does — the -# verify-in-a-separate-session advisory in --help stays load-bearing. +# shape, and then for REACHABILITY (#17): the shape checks prove the door +# SHOULD open, these prove what can be proven from inside — that NOPASSWD +# sudo actually answers (`sudo -n true` under runuser; a sudoers drop-in +# that never landed is a shape the file checks cannot see), and that sshd's +# per-user EFFECTIVE config would accept the login (`sshd -T -C user=...` — +# an AllowUsers or Match block elsewhere can quietly exclude the admin while +# every file looks right). The refusal names, per candidate, WHICH check +# failed — an operator staring at a refusal must see the repair. Honestly: +# the one thing no local check can prove is that the operator HOLDS the +# private key — the verify-in-a-separate-session advisory in --help stays +# load-bearing. today=$(( $(date +%s) / 86400 )) +# runuser ships in util-linux on Debian — rig's target — but the gate must +# not die on a box without it: skip the live sudo proof with a loud warning +# rather than block close-root on a missing prover. Warned once, not per +# candidate. +HAVE_RUNUSER=0 +if command -v runuser >/dev/null 2>&1; then + HAVE_RUNUSER=1 +else + warn "runuser not found; skipping the live NOPASSWD-sudo proof — verify 'sudo -n true' as your admin by hand before trusting the closed door" +fi # path_strict