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)"