From 0cd59efa6db8d964eb642c384bb639c4dbaadfdc Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Thu, 25 Jun 2026 05:54:13 +0200 Subject: [PATCH 1/6] feat(firstboot): force root + operator password on first boot (console gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds clawdie_firstboot_rootpw, an rc.d gate ordered BEFORE sddm and colibri_daemon. On the text console (operator present at first boot) it runs a 15s countdown to engage; if engaged it forces a root AND operator (clawdie) password, echo-off, applied via 'pw usermod -h 0' over stdin (secret never in argv/ps, never near the agent). Idempotent via a persistent success marker /var/db/colibri/.secured (/var persists: varmfs=NO). Skipping leaves the node open and re-prompts next boot — never bricks an unattended/headless boot. Running before the daemon means the security decision is always made before any agent can autospawn/node_register, so no cross-component interlock is needed (rc ordering replaces it). The .secured marker is also the signal a future colibri change can read to label an unsecured node to mother. Tests: tests/firstboot-rootpw-test.sh proves marker skip, password validation, and that the secret is delivered on stdin and NEVER appears in argv (10/10). Console interactivity (read -t countdown, stty echo-off on /dev/console) must be verified by booting on osa/bhyve before merge. Co-Authored-By: Claude Opus 4.8 --- build.sh | 3 + .../operator-session/clawdie-firstboot-rootpw | 141 ++++++++++++++++++ tests/firstboot-rootpw-test.sh | 64 ++++++++ 3 files changed, 208 insertions(+) create mode 100644 live/operator-session/clawdie-firstboot-rootpw create mode 100755 tests/firstboot-rootpw-test.sh diff --git a/build.sh b/build.sh index afd5bd6..c407f06 100755 --- a/build.sh +++ b/build.sh @@ -1452,6 +1452,8 @@ EOF "${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_live_wifi" install -m 0755 "${LIVE_SESSION_DIR}/clawdie-live-seed" \ "${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_live_seed" + install -m 0755 "${LIVE_SESSION_DIR}/clawdie-firstboot-rootpw" \ + "${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_firstboot_rootpw" install -m 0755 "${LIVE_SESSION_DIR}/clawdie-live-resolver" \ "${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_live_resolver" install -m 0755 "${LIVE_SESSION_DIR}/clawdie-live-audio" \ @@ -1878,6 +1880,7 @@ EOF set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_gpu_nvidia_branch=""' set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_wifi_enable="YES"' set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_seed_enable="YES"' + set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_firstboot_rootpw_enable="YES"' set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_resolver_enable="YES"' set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_audio_enable="YES"' set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_power_enable="YES"' diff --git a/live/operator-session/clawdie-firstboot-rootpw b/live/operator-session/clawdie-firstboot-rootpw new file mode 100644 index 0000000..0b5a6ab --- /dev/null +++ b/live/operator-session/clawdie-firstboot-rootpw @@ -0,0 +1,141 @@ +#!/bin/sh +# Clawdie first-boot password gate. +# +# Forces the operator to set a root + operator (clawdie) password on the FIRST +# boot, on the text console, BEFORE the GUI (sddm) and BEFORE the colibri daemon +# autospawns an agent. Running before the daemon means the security decision is +# always made before any agent can act — no cross-component interlock needed. +# +# Design: +# - Idempotent via a persistent success marker (/var is persistent on this +# image: varmfs="NO"). Marker present -> silent exit. +# - A countdown to ENGAGE (read -t). If the operator does not engage, boot +# continues with passwords UNSET and the gate re-prompts next boot (never +# bricks an unattended/headless boot). Once set, never prompts again. +# - Passwords are read with echo off (stty) and applied via `pw usermod -h 0`, +# which reads the new password from STDIN — never in argv/ps, never near the +# agent/LLM. +# - On success, writes /var/db/colibri/.secured so the colibri daemon can +# reflect security state to mother (label the node "unsecured" while absent). +# The daemon-side consumption of this marker is a separate (colibri) change. +# +# Functions below are sourced and unit-tested on a non-FreeBSD host with +# CLAWDIE_ROOTPW_TEST=1 (which skips the rc.subr handoff). The interactive +# countdown lives only in _start and is not exercised by the logic test. + +# PROVIDE: clawdie_firstboot_rootpw +# REQUIRE: clawdie_live_gpu FILESYSTEMS +# BEFORE: sddm colibri_daemon +# KEYWORD: nojail + +if [ -r /etc/rc.subr ]; then + . /etc/rc.subr +fi + +name="clawdie_firstboot_rootpw" +rcvar="${name}_enable" +start_cmd="${name}_start" +stop_cmd=":" + +: "${clawdie_firstboot_rootpw_enable:=YES}" + +# Overridable for tests. +SECURED_MARKER="${SECURED_MARKER:-/var/db/colibri/.secured}" +PW_BIN="${PW_BIN:-/usr/sbin/pw}" +ROOTPW_CONSOLE="${ROOTPW_CONSOLE:-/dev/console}" +ROOTPW_COUNTDOWN="${ROOTPW_COUNTDOWN:-15}" +ROOTPW_MIN_LEN="${ROOTPW_MIN_LEN:-8}" + +# --- pure logic (unit-tested) ----------------------------------------------- + +# Already secured? (marker present) +_rootpw_secured() { + [ -e "${SECURED_MARKER}" ] +} + +# Validate a password pair. Echoes a reason and returns 1 on failure. +_rootpw_valid() { + _p1="$1"; _p2="$2" + if [ -z "${_p1}" ]; then echo "empty password"; return 1; fi + if [ "${_p1}" != "${_p2}" ]; then echo "passwords do not match"; return 1; fi + if [ "${#_p1}" -lt "${ROOTPW_MIN_LEN}" ]; then + echo "too short (minimum ${ROOTPW_MIN_LEN} characters)"; return 1 + fi + return 0 +} + +# Apply a password to a user. Reads the password from STDIN (pw usermod -h 0). +_rootpw_apply() { + "${PW_BIN}" usermod "$1" -h 0 +} + +# Record success so the daemon sees a secured node and the gate stops prompting. +_rootpw_mark_secured() { + mkdir -p "$(dirname "${SECURED_MARKER}")" 2>/dev/null || true + # mirror the daemon's ownership intent; best-effort (no-op under test). + chown colibri:colibri "$(dirname "${SECURED_MARKER}")" 2>/dev/null || true + chmod 0750 "$(dirname "${SECURED_MARKER}")" 2>/dev/null || true + : > "${SECURED_MARKER}" + chmod 0644 "${SECURED_MARKER}" 2>/dev/null || true +} + +# --- interactive (console only; not unit-tested) ---------------------------- + +# Prompt for one account, echo off, loop until a valid pair is applied. +_rootpw_prompt_and_set() { + _user="$1"; _label="$2" + while :; do + stty -echo 2>/dev/null + printf ' %s password: ' "${_label}"; IFS= read -r _p1; printf '\n' + printf ' confirm %s: ' "${_label}"; IFS= read -r _p2; printf '\n' + stty echo 2>/dev/null + if _why="$(_rootpw_valid "${_p1}" "${_p2}")"; then + printf '%s\n' "${_p1}" | _rootpw_apply "${_user}" + _p1=; _p2=; _why= + printf ' -> %s password set.\n\n' "${_label}" + return 0 + fi + _p1=; _p2= + printf ' ! %s — try again.\n' "${_why}" + done +} + +clawdie_firstboot_rootpw_start() { + _rootpw_secured && return 0 + + # Talk to the operator on the system console. + exec < "${ROOTPW_CONSOLE}" > "${ROOTPW_CONSOLE}" 2>&1 + + # vt(4)/framebuffer may not have flushed right after the GPU rc script; + # settle and clear (terminfo-free) so the prompt is actually visible. + sleep 1 + printf '\033[H\033[2J' + + printf '================ FIRST BOOT — SECURE THIS NODE ================\n\n' + printf ' This stick boots with NO root password. Set one now.\n' + printf ' WRITE BOTH PASSWORDS ON PAPER — there is no recovery.\n\n' + printf ' Press ENTER within %ss to set passwords' "${ROOTPW_COUNTDOWN}" + printf ' (otherwise skipped) ... ' + + if IFS= read -r -t "${ROOTPW_COUNTDOWN}" _ans; then + printf '\n\n' + _rootpw_prompt_and_set root "ROOT (admin)" + _rootpw_prompt_and_set clawdie "OPERATOR (clawdie)" + _rootpw_mark_secured + printf ' Node secured. Continuing boot...\n' + sleep 2 + else + printf '\n\n [skipped] passwords NOT set — this node remains OPEN.\n' + printf ' You will be prompted again on the next boot.\n' + sleep 3 + fi + return 0 +} + +# On FreeBSD, hand off to rc.subr. Under test, skip so functions can be sourced. +if [ -n "${CLAWDIE_ROOTPW_TEST:-}" ]; then + : +elif command -v run_rc_command >/dev/null 2>&1; then + load_rc_config "${name}" + run_rc_command "$1" +fi diff --git a/tests/firstboot-rootpw-test.sh b/tests/firstboot-rootpw-test.sh new file mode 100755 index 0000000..016a309 --- /dev/null +++ b/tests/firstboot-rootpw-test.sh @@ -0,0 +1,64 @@ +#!/bin/sh +# Logic test for the first-boot password gate (clawdie-firstboot-rootpw). +# +# Exercises the testable security logic on any POSIX host: marker skip, +# password validation, the secured-marker write, and — critically — that the +# password is applied to `pw usermod -h 0` via STDIN (never argv). +# The interactive countdown (_start) needs a real console and is verified by +# booting on osa, not here. +set -u + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +GATE="${SCRIPT_DIR}/../live/operator-session/clawdie-firstboot-rootpw" +[ -r "${GATE}" ] || { echo "FATAL: gate not found at ${GATE}" >&2; exit 2; } + +WORK=$(mktemp -d "${TMPDIR:-/tmp}/firstboot-rootpw.XXXXXX") || exit 2 +trap 'rm -rf "${WORK}"' EXIT INT TERM + +# Fake `pw`: records argv and the password read from stdin, so we can prove the +# secret arrives on stdin and never on the command line. +FAKEPW="${WORK}/fake-pw" +cat >"${FAKEPW}" <> "${WORK}/pw.calls" +IFS= read -r _stdin_pw +printf '%s\n' "STDIN: \${_stdin_pw}" >> "${WORK}/pw.calls" +EOF +chmod +x "${FAKEPW}" + +export CLAWDIE_ROOTPW_TEST=1 +export SECURED_MARKER="${WORK}/secured" +export PW_BIN="${FAKEPW}" +export ROOTPW_MIN_LEN=8 + +# shellcheck disable=SC1090 +. "${GATE}" + +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; } +chk() { [ "$1" -eq 0 ] && ok "$2" || bad "$2"; } + +echo "== marker / idempotency ==" +_rootpw_secured; chk "$([ $? -ne 0 ] && echo 0 || echo 1)" "not secured when marker absent" +_rootpw_mark_secured +[ -f "${SECURED_MARKER}" ]; chk $? "mark_secured creates the marker" +_rootpw_secured; chk $? "secured when marker present" + +echo "== password validation ==" +_rootpw_valid "goodpassword" "goodpassword" >/dev/null; chk $? "accepts matching >=8 char password" +{ ! _rootpw_valid "" "" >/dev/null; }; chk $? "rejects empty" +{ ! _rootpw_valid "abc" "abc" >/dev/null; }; chk $? "rejects too short" +{ ! _rootpw_valid "password1" "password2" >/dev/null; }; chk $? "rejects mismatch" + +echo "== apply via stdin (secret never in argv) ==" +: > "${WORK}/pw.calls" +printf '%s\n' "s3cret-on-stdin" | _rootpw_apply root +grep -q 'ARGV: usermod root -h 0' "${WORK}/pw.calls"; chk $? "pw called: usermod root -h 0" +grep -q 'STDIN: s3cret-on-stdin' "${WORK}/pw.calls"; chk $? "password delivered on STDIN" +{ ! grep -q 'ARGV:.*s3cret-on-stdin' "${WORK}/pw.calls"; }; chk $? "password NEVER appears in argv" + +echo +echo "RESULT: ${PASS} passed, ${FAIL} failed" +[ "${FAIL}" -eq 0 ] || exit 1 +exit 0 From 70731cdddad4e3a609aee2eaf23c34a2149d8b30 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Thu, 25 Jun 2026 05:59:05 +0200 Subject: [PATCH 2/6] fix(firstboot): run the password gate before the GPU/KMS switch Reorder the gate to REQUIRE: FILESYSTEMS devfs / BEFORE: clawdie_live_gpu LOGIN so it runs on the plain early boot text console, before clawdie_live_gpu does its KMS/framebuffer mode-switch. That removes the console-flush race entirely, so the sleep 1 + screen-clear workaround is gone. Still before LOGIN, hence before sddm and colibri_daemon (race-free property preserved). Co-Authored-By: Claude Opus 4.8 --- .../operator-session/clawdie-firstboot-rootpw | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/live/operator-session/clawdie-firstboot-rootpw b/live/operator-session/clawdie-firstboot-rootpw index 0b5a6ab..3ac395e 100644 --- a/live/operator-session/clawdie-firstboot-rootpw +++ b/live/operator-session/clawdie-firstboot-rootpw @@ -24,9 +24,14 @@ # countdown lives only in _start and is not exercised by the logic test. # PROVIDE: clawdie_firstboot_rootpw -# REQUIRE: clawdie_live_gpu FILESYSTEMS -# BEFORE: sddm colibri_daemon +# REQUIRE: FILESYSTEMS devfs +# BEFORE: clawdie_live_gpu LOGIN # KEYWORD: nojail +# +# Ordering: runs on the plain early boot text console, BEFORE clawdie_live_gpu +# does its KMS/framebuffer mode-switch (so there is no console-flush race) and +# BEFORE LOGIN (so before sddm and before colibri_daemon, which REQUIRE LOGIN). +# Needs only FILESYSTEMS + devfs (console, /etc/master.passwd, /var marker, pw). if [ -r /etc/rc.subr ]; then . /etc/rc.subr @@ -103,15 +108,12 @@ _rootpw_prompt_and_set() { clawdie_firstboot_rootpw_start() { _rootpw_secured && return 0 - # Talk to the operator on the system console. + # Talk to the operator on the system console. We run before the GPU/KMS + # mode-switch, so this is the stable early text console — no settle/clear + # workaround needed. exec < "${ROOTPW_CONSOLE}" > "${ROOTPW_CONSOLE}" 2>&1 - # vt(4)/framebuffer may not have flushed right after the GPU rc script; - # settle and clear (terminfo-free) so the prompt is actually visible. - sleep 1 - printf '\033[H\033[2J' - - printf '================ FIRST BOOT — SECURE THIS NODE ================\n\n' + printf '\n================ FIRST BOOT — SECURE THIS NODE ================\n\n' printf ' This stick boots with NO root password. Set one now.\n' printf ' WRITE BOTH PASSWORDS ON PAPER — there is no recovery.\n\n' printf ' Press ENTER within %ss to set passwords' "${ROOTPW_COUNTDOWN}" From f428bc7fcb064dc0bb9b473027c38b6081313dc8 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Thu, 25 Jun 2026 06:02:01 +0200 Subject: [PATCH 3/6] feat(firstboot): visible 'Continuing in 3s 2s 1s' countdown before boot resumes Replace the silent trailing sleeps with a counting-down message so the operator sees the result (secured / skipped) and a clear cue before clawdie_live_gpu repaints the screen. Same ~3s pause, now visible. Co-Authored-By: Claude Opus 4.8 --- .../operator-session/clawdie-firstboot-rootpw | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/live/operator-session/clawdie-firstboot-rootpw b/live/operator-session/clawdie-firstboot-rootpw index 3ac395e..e95bd73 100644 --- a/live/operator-session/clawdie-firstboot-rootpw +++ b/live/operator-session/clawdie-firstboot-rootpw @@ -105,6 +105,19 @@ _rootpw_prompt_and_set() { done } +# Visible "Continuing in 3s 2s 1s" countdown before boot resumes, so the +# operator can read the result before clawdie_live_gpu repaints the screen. +_rootpw_continue_countdown() { + _n="${1:-3}" + printf ' Continuing in ' + while [ "${_n}" -gt 0 ]; do + printf '%ss ' "${_n}" + sleep 1 + _n=$((_n - 1)) + done + printf '... proceeding.\n' +} + clawdie_firstboot_rootpw_start() { _rootpw_secured && return 0 @@ -124,12 +137,12 @@ clawdie_firstboot_rootpw_start() { _rootpw_prompt_and_set root "ROOT (admin)" _rootpw_prompt_and_set clawdie "OPERATOR (clawdie)" _rootpw_mark_secured - printf ' Node secured. Continuing boot...\n' - sleep 2 + printf ' Node secured.\n' + _rootpw_continue_countdown 3 else printf '\n\n [skipped] passwords NOT set — this node remains OPEN.\n' printf ' You will be prompted again on the next boot.\n' - sleep 3 + _rootpw_continue_countdown 3 fi return 0 } From e135c305a41ec353fa6f0baf5cda7e6e057b9610 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Thu, 25 Jun 2026 06:08:14 +0200 Subject: [PATCH 4/6] docs(firstboot): make the skip message honest about unsecured state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The '.secured' marker is written but not yet consumed by colibri, so the gate must not imply colibri/zot are blocked. Reword the skip message to state the node is UNSECURED and the agent SHOULD NOT register/run while unsecured — true as a policy statement, without claiming enforcement we haven't built. Upgrade to 'will not' once the colibri .secured interlock lands. Co-Authored-By: Claude Opus 4.8 --- live/operator-session/clawdie-firstboot-rootpw | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/live/operator-session/clawdie-firstboot-rootpw b/live/operator-session/clawdie-firstboot-rootpw index e95bd73..ed4379e 100644 --- a/live/operator-session/clawdie-firstboot-rootpw +++ b/live/operator-session/clawdie-firstboot-rootpw @@ -140,7 +140,9 @@ clawdie_firstboot_rootpw_start() { printf ' Node secured.\n' _rootpw_continue_countdown 3 else - printf '\n\n [skipped] passwords NOT set — this node remains OPEN.\n' + printf '\n\n [skipped] root/operator passwords NOT set — node is UNSECURED.\n' + printf ' Treat this node as untrusted until a password is set. The colibri\n' + printf ' agent should not register with mother or run tasks while unsecured.\n' printf ' You will be prompted again on the next boot.\n' _rootpw_continue_countdown 3 fi From 73b603d995047f570f434c121567236d1254b618 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Thu, 25 Jun 2026 06:16:34 +0200 Subject: [PATCH 5/6] feat(firstboot): opt-in require-secured knob + 'will not' skip message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clawdie-iso half of the .secured interlock: - build.sh writes colibri_daemon_require_secured="YES" to the operator image's rc.conf. Opt-in so DEPLOYED colibri hosts (shared colibri_daemon.in via the FreeBSD port, no firstboot gate) are unaffected — they never set this knob. - gate skip message upgraded to 'agent will NOT start or register until secured'. Depends on the colibri-side consumer (colibri_daemon.in prestart): when colibri_daemon_require_secured is YES and /var/db/colibri/.secured is absent, export COLIBRI_AUTOSPAWN=NO (after the provider.env source block). Tracked as the colibri follow-up; both must ship in the same 0.12 image for the message to hold. Co-Authored-By: Claude Opus 4.8 --- build.sh | 5 +++++ live/operator-session/clawdie-firstboot-rootpw | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/build.sh b/build.sh index c407f06..5c0dc77 100755 --- a/build.sh +++ b/build.sh @@ -993,6 +993,11 @@ install_colibri_service() { fi set_config_line "${MOUNT_POINT}/etc/rc.conf" "colibri_daemon_enable=\"${COLIBRI_DAEMON_ENABLE:-YES}\"" + # Operator-image only: require the first-boot password gate to have run + # (it writes /var/db/colibri/.secured) before the daemon autospawns an + # agent. Opt-in so deployed colibri hosts (no firstboot gate) are unaffected. + # Consumed by colibri_daemon.in prestart (colibri repo). + set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_require_secured="YES"' set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_user="colibri"' set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_group="colibri"' set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_data_dir="/var/db/colibri"' diff --git a/live/operator-session/clawdie-firstboot-rootpw b/live/operator-session/clawdie-firstboot-rootpw index ed4379e..16e603a 100644 --- a/live/operator-session/clawdie-firstboot-rootpw +++ b/live/operator-session/clawdie-firstboot-rootpw @@ -141,9 +141,9 @@ clawdie_firstboot_rootpw_start() { _rootpw_continue_countdown 3 else printf '\n\n [skipped] root/operator passwords NOT set — node is UNSECURED.\n' - printf ' Treat this node as untrusted until a password is set. The colibri\n' - printf ' agent should not register with mother or run tasks while unsecured.\n' - printf ' You will be prompted again on the next boot.\n' + printf ' The colibri agent will NOT start or register with mother until a\n' + printf ' password is set (colibri_daemon_require_secured). Set one to activate\n' + printf ' this node. You will be prompted again on the next boot.\n' _rootpw_continue_countdown 3 fi return 0 From 2fa7825f12b91cc062cd638a3b05632679257590 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Thu, 25 Jun 2026 07:05:16 +0200 Subject: [PATCH 6/6] fix(iso): bind colibri_daemon_require_secured=YES to complete the interlock The rc.conf.sample on the live USB now sets require_secured=YES. Together with the paired colibri change, this ensures the daemon disables autospawn until the console gate writes .secured. --- scripts/stage-colibri-iso.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/stage-colibri-iso.sh b/scripts/stage-colibri-iso.sh index 10b73e3..517f259 100755 --- a/scripts/stage-colibri-iso.sh +++ b/scripts/stage-colibri-iso.sh @@ -105,6 +105,7 @@ colibri_daemon_logfile="/var/log/colibri/daemon.log" colibri_daemon_provider_env="/usr/local/etc/colibri/provider.env" colibri_daemon_host="\$(/bin/hostname)" colibri_daemon_cost_mode="${COLIBRI_COST_MODE}" +colibri_daemon_require_secured="YES" EOF cat > "${ETC_DIR}/provider.env" <<'EOF'