diff --git a/README.md b/README.md index f9e7ef4..65afe4e 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Recommended minimum: ``` ssh joinmarket@rpi4-20220121 ``` - → the password on the first boot is: `joininbox` + → the initial password is unique to each device: it is generated at the first boot (not at image build time) and shown on the local console (see `/etc/issue`); a root-only copy is kept in `/root/joininbox-initial-password`. You will be prompted to change it on the first login. * Use the hostname of the latest SDcard image (`rpi4-20220121`) or to find the IP address to connect to: * scan with the [AngryIP Scanner](https://angryip.org/) * use `sudo arp -a` or @@ -139,7 +139,7 @@ Recommended minimum: or log in with ssh to: `joinmarket@LAN_IP_ADDRESS` -the default password is: `joininbox` - will be prompted to change it on the first start +the initial password is unique to each device - it is generated at the first boot (not at image build time), shown on the local console (see `/etc/issue`), stored in `/root/joininbox-initial-password` (readable only by root) and must be changed on the first login --- diff --git a/build_joininbox.sh b/build_joininbox.sh index dc0505c..c8215e6 100644 --- a/build_joininbox.sh +++ b/build_joininbox.sh @@ -444,18 +444,33 @@ chmod +x /home/joinmarket/*.sh runuser joinmarket -c "cp -r /home/joinmarket/joininbox/scripts/standalone /home/joinmarket/" chmod +x /home/joinmarket/standalone/*.sh -echo "# set the default password 'joininbox' for the users 'pi', \ -'joinmarket' and 'root'" +echo "# set unique first-boot credentials and lock unused accounts" adduser joinmarket sudo # chsh joinmarket -s /bin/bash # configure for usage without password entry for the joinmarket user # https://www.tecmint.com/run-sudo-command-without-password-linux/ echo 'joinmarket ALL=(ALL) NOPASSWD:ALL' | EDITOR='tee -a' visudo -echo "root:joininbox" | chpasswd -echo "joinmarket:joininbox" | chpasswd + +# Security hardening: no shared, known password on the image. +# Lock the root password - root is reached via sudo from joinmarket and +# 'PermitRootLogin no' is set in the Hardening section below. +passwd -l root +# Lock the password of the 'pi' user if present (unused by JoininBox). if [ $(grep -c pi /dev/null 2>&1; then + echo "joininbox-firstboot: user '${TARGET_USER}' not found - skipping" >&2 + exit 0 +fi + +mkdir -p "${MARKER_DIR}" +chmod 700 "${MARKER_DIR}" + +# Generate a random password: 20 alphanumeric chars from /dev/urandom +# (~119 bits of entropy, >= 16 chars as required). +initialPassword=$(tr -dc 'A-Za-z0-9' &2 + exit 1 +fi + +# Set the password (via stdin, never on a command line) and force it to be +# changed at the first login. Debian's default sshd (UsePAM yes) prompts +# for a new password after authentication when the account is expired. +echo "${TARGET_USER}:${initialPassword}" | chpasswd || exit 1 +chage -d 0 "${TARGET_USER}" || exit 1 + +# Root-only backup copy of the initial password. +umask 077 +echo "${initialPassword}" >"${PASSWORD_FILE}" +chmod 600 "${PASSWORD_FILE}" + +# Show the initial password on the local console only (agetty displays +# /etc/issue before the login prompt). Keep a pristine copy of /etc/issue +# so prepare.release.sh can restore it when building a shareable image. +if [ -f "${ISSUE_FILE}" ] && [ ! -f "${ISSUE_ORIG}" ]; then + cp "${ISSUE_FILE}" "${ISSUE_ORIG}" 2>/dev/null || true +fi +{ + echo "" + echo "JoininBox: the unique initial password of the '${TARGET_USER}' user is:" + echo "${initialPassword}" + echo "It must be changed on the first login. A root-only copy is kept in" + echo "${PASSWORD_FILE} (mode 600)." +} >>"${ISSUE_FILE}" + +# Mark as done - the script never runs again on this device, even if the +# unit cannot be disabled below (e.g. read-only systemd state). +touch "${MARKER_FILE}" +chmod 600 "${MARKER_FILE}" + +# Disable the one-shot unit so it is not even evaluated on later boots. +systemctl disable "${UNIT_NAME}" >/dev/null 2>&1 || true +rm -f "/etc/systemd/system/multi-user.target.wants/${UNIT_NAME}" + +unset initialPassword +exit 0 diff --git a/scripts/standalone/joininbox-firstboot.service b/scripts/standalone/joininbox-firstboot.service new file mode 100644 index 0000000..761566f --- /dev/null +++ b/scripts/standalone/joininbox-firstboot.service @@ -0,0 +1,29 @@ +# JoininBox first-boot credential generation (one-shot) +# /etc/systemd/system/joininbox-firstboot.service +# +# Runs ONCE on the device at first boot - before ssh.service and before +# the login consoles - to generate the unique initial password of the +# 'joinmarket' user. Nothing sensitive is generated or logged at image +# build time. The unit disables itself after the first successful run +# (and is guarded by the /var/lib/joininbox/firstboot-done marker). + +[Unit] +Description=JoininBox first-boot credential generation (one-shot) +Documentation=https://github.com/openoms/joininbox +DefaultDependencies=no +After=local-fs.target systemd-sysusers.service +Before=ssh.service ssh.socket getty.target +ConditionPathExists=!/var/lib/joininbox/firstboot-done + +[Service] +Type=oneshot +User=root +Group=root +ExecStart=/usr/local/sbin/joininbox-firstboot.sh +# Never leak script output (and definitely never the password) to the +# journal beyond explicit error messages on stderr. +StandardOutput=null +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/scripts/standalone/prepare.release.sh b/scripts/standalone/prepare.release.sh index f22d749..4a31b13 100755 --- a/scripts/standalone/prepare.release.sh +++ b/scripts/standalone/prepare.release.sh @@ -39,6 +39,25 @@ echo "# Will be recreated when the menu is next run." sudo rm /home/joinmarket/joinin.conf 2>/dev/null echo "# OK" +echo +echo "# Resetting the first-boot credential state ..." +echo "# A new unique password will be generated on the next boot." +# Lock the account BEFORE imaging: without this the operator's active +# password hash would ship inside the shareable image (offline-crackable) +# and SSH would accept it if the first-boot unit failed. With the account +# locked the image fails closed; a successful first-boot run replaces and +# unlocks the password via chpasswd. +sudo passwd -l joinmarket +sudo rm -f /var/lib/joininbox/firstboot-done +sudo rm -f /root/joininbox-initial-password +# restore the pristine console message (remove any shown password) +if [ -f /etc/issue.joininbox-orig ]; then + sudo cp /etc/issue.joininbox-orig /etc/issue +fi +# re-enable the one-shot first-boot unit for the next boot +sudo systemctl enable joininbox-firstboot.service 2>/dev/null +echo "# OK" + echo echo "# Will shutdown now." echo "# Wait until the SBC LEDs show no activity anymore." diff --git a/tests/first.boot.credentials.bats b/tests/first.boot.credentials.bats new file mode 100644 index 0000000..aeb6c78 --- /dev/null +++ b/tests/first.boot.credentials.bats @@ -0,0 +1,80 @@ +#!/usr/bin/env bats + +SCRIPT="$BATS_TEST_DIRNAME/../scripts/standalone/first.boot.credentials.sh" + +setup() { + ROOT="$BATS_TEST_TMPDIR/root" + BIN="$BATS_TEST_TMPDIR/bin" + LOG="$BATS_TEST_TMPDIR/calls.log" + mkdir -p "$ROOT/var/lib/joininbox" "$ROOT/root" "$ROOT/etc/systemd/system/multi-user.target.wants" "$BIN" + printf 'Debian GNU/Linux\n' >"$ROOT/etc/issue" + : >"$LOG" + + cat >"$BIN/id" <<'EOF' +#!/bin/bash +exit 0 +EOF + chmod +x "$BIN/id" + + sed \ + -e "s#MARKER_DIR=\"/var/lib/joininbox\"#MARKER_DIR=\"$ROOT/var/lib/joininbox\"#" \ + -e "s#PASSWORD_FILE=\"/root/joininbox-initial-password\"#PASSWORD_FILE=\"$ROOT/root/joininbox-initial-password\"#" \ + -e "s#ISSUE_FILE=\"/etc/issue\"#ISSUE_FILE=\"$ROOT/etc/issue\"#" \ + -e "s#ISSUE_ORIG=\"/etc/issue.joininbox-orig\"#ISSUE_ORIG=\"$ROOT/etc/issue.joininbox-orig\"#" \ + -e "s#/etc/systemd/system/multi-user.target.wants/#$ROOT/etc/systemd/system/multi-user.target.wants/#" \ + "$SCRIPT" >"$BATS_TEST_TMPDIR/script.sh" + + for command in chpasswd chage systemctl; do + cat >"$BIN/$command" <<'EOF' +#!/bin/bash +printf '%s %s\n' "$(basename "$0")" "$*" >>"$CALL_LOG" +if [ "$(basename "$0")" = chpasswd ]; then cat >>"$CALL_LOG"; fi +EOF + chmod +x "$BIN/$command" + done + export PATH="$BIN:$PATH" CALL_LOG="$LOG" +} + +@test "generates one credential, secures artifacts and disables itself" { + run bash "$BATS_TEST_TMPDIR/script.sh" + [ "$status" -eq 0 ] + [ -f "$ROOT/var/lib/joininbox/firstboot-done" ] + [ "$(stat -c %a "$ROOT/var/lib/joininbox/firstboot-done")" = 600 ] + [ -f "$ROOT/root/joininbox-initial-password" ] + [ "$(stat -c %a "$ROOT/root/joininbox-initial-password")" = 600 ] + password="$(cat "$ROOT/root/joininbox-initial-password")" + [ "${#password}" -eq 20 ] + grep -q "joinmarket:$password" "$LOG" + grep -q '^chage -d 0 joinmarket$' "$LOG" + grep -q '^systemctl disable joininbox-firstboot.service$' "$LOG" + grep -q "$password" "$ROOT/etc/issue" + ! grep -q "$password" <<<"$output" +} + +@test "marker makes subsequent runs idempotent" { + touch "$ROOT/var/lib/joininbox/firstboot-done" + run bash "$BATS_TEST_TMPDIR/script.sh" + [ "$status" -eq 0 ] + [ ! -s "$LOG" ] + [ ! -e "$ROOT/root/joininbox-initial-password" ] +} + +@test "missing target user exits without creating credentials" { + cat >"$BIN/id" <<'EOF' +#!/bin/bash +exit 1 +EOF + chmod +x "$BIN/id" + run bash "$BATS_TEST_TMPDIR/script.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"user 'joinmarket' not found"* ]] + [ ! -e "$ROOT/root/joininbox-initial-password" ] +} + +@test "systemd unit orders credential generation before SSH" { + unit="$BATS_TEST_DIRNAME/../scripts/standalone/joininbox-firstboot.service" + grep -q '^Before=ssh.service' "$unit" + grep -q '^ExecStart=/usr/local/sbin/joininbox-firstboot.sh' "$unit" + grep -q '^ConditionPathExists=!/var/lib/joininbox/firstboot-done' "$unit" + grep -q '^StandardOutput=null' "$unit" +} diff --git a/tests/prepare.release.bats b/tests/prepare.release.bats new file mode 100644 index 0000000..0fc8b2d --- /dev/null +++ b/tests/prepare.release.bats @@ -0,0 +1,7 @@ +#!/usr/bin/env bats + +@test "release preparation locks credentials and resets first-boot state" { + run bash "$BATS_TEST_DIRNAME/test-prepare-release.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"all assertions passed"* ]] +} diff --git a/tests/test-prepare-release.sh b/tests/test-prepare-release.sh new file mode 100755 index 0000000..80e36f1 --- /dev/null +++ b/tests/test-prepare-release.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# test-prepare-release.sh - regression test for prepare.release.sh +# +# Verifies the shareable-image reset path without root or a real system: +# 1. the 'joinmarket' password is LOCKED before imaging (no operator hash +# ships in the image; fail closed if first-boot generation fails) +# 2. first-boot artifacts (marker + initial-password file) are removed +# 3. the one-shot first-boot unit is re-enabled for the next boot +# 4. the script reaches the final shutdown step +# +# All privileged operations are intercepted by a mock 'sudo' that rewrites +# absolute paths into a temporary fakeroot. +# +# Run: bash tests/test-prepare-release.sh + +set -u + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TEST_ROOT="$(mktemp -d)" +MOCK_BIN="${TEST_ROOT}/bin" +MOCK_LOG="${TEST_ROOT}/mock.log" +mkdir -p "${MOCK_BIN}" +touch "${MOCK_LOG}" + +cleanup() { rm -rf "${TEST_ROOT}"; } +trap cleanup EXIT + +# --- fakeroot state ------------------------------------------------------- +mkdir -p \ + "${TEST_ROOT}/root/.ssh" \ + "${TEST_ROOT}/etc/ssh" \ + "${TEST_ROOT}/etc/wpa_supplicant" \ + "${TEST_ROOT}/home/joinmarket" \ + "${TEST_ROOT}/var/lib/joininbox" + +touch "${TEST_ROOT}/root/.ssh/authorized_keys" \ + "${TEST_ROOT}/etc/resolv.conf" \ + "${TEST_ROOT}/home/joinmarket/joinin.conf" \ + "${TEST_ROOT}/var/lib/joininbox/firstboot-done" \ + "${TEST_ROOT}/root/joininbox-initial-password" + +# operator has an ACTIVE password hash after completing first boot +echo 'joinmarket:$6$operatorhash:19000:0:99999:7:::' >"${TEST_ROOT}/etc/shadow" + +# --- mock sudo ------------------------------------------------------------ +cat >"${MOCK_BIN}/sudo" <<'EOF' +#!/bin/bash +# Rewrites absolute path arguments into TEST_ROOT and simulates privileged +# commands. Logs invocations of interest to MOCK_LOG. +cmd="$1"; shift +rewritten=() +for a in "$@"; do + case "$a" in + /*) rewritten+=("${TEST_ROOT}${a}") ;; + *) rewritten+=("$a") ;; + esac +done +case "$cmd" in + passwd) + # only 'passwd -l ' is used by the script + if [ "$1" = "-l" ]; then + sed -i "s/^$2:/$2:!/" "${TEST_ROOT}/etc/shadow" + echo "passwd -l $2" >>"${MOCK_LOG}" + exit 0 + fi + echo "mock sudo: unexpected passwd args: $*" >&2; exit 1 + ;; + systemctl|shutdown) + echo "$cmd $*" >>"${MOCK_LOG}"; exit 0 + ;; + rm|cp|tee) + "$cmd" "${rewritten[@]}" + ;; + *) + echo "mock sudo: unhandled command: $cmd $*" >&2; exit 1 + ;; +esac +EOF +chmod +x "${MOCK_BIN}/sudo" + +# --- run the script under test ------------------------------------------- +PATH="${MOCK_BIN}:$PATH" TEST_ROOT="${TEST_ROOT}" MOCK_LOG="${MOCK_LOG}" \ + bash "${REPO_ROOT}/scripts/standalone/prepare.release.sh" >"${TEST_ROOT}/run.log" 2>&1 + +# --- assertions ----------------------------------------------------------- +failures=0 +check() { + if eval "$2"; then + echo "ok - $1" + else + echo "FAIL - $1" >&2 + failures=$((failures + 1)) + fi +} + +check "joinmarket password is locked in the image" \ + "grep -q '^joinmarket:!' '${TEST_ROOT}/etc/shadow'" + +check "passwd -l joinmarket was invoked" \ + "grep -q '^passwd -l joinmarket$' '${MOCK_LOG}'" + +check "first-boot marker is removed" \ + "[ ! -e '${TEST_ROOT}/var/lib/joininbox/firstboot-done' ]" + +check "initial-password file is removed" \ + "[ ! -e '${TEST_ROOT}/root/joininbox-initial-password' ]" + +check "first-boot unit is re-enabled" \ + "grep -q 'systemctl enable joininbox-firstboot.service' '${MOCK_LOG}'" + +check "script reaches the shutdown step" \ + "grep -q '^shutdown' '${MOCK_LOG}'" + +echo +if [ "${failures}" -gt 0 ]; then + echo "${failures} assertion(s) FAILED" >&2 + echo "--- script output ---" >&2 + cat "${TEST_ROOT}/run.log" >&2 + exit 1 +fi +echo "all assertions passed" +exit 0 diff --git a/typos.toml b/typos.toml index b2ca07e..86cb0a6 100644 --- a/typos.toml +++ b/typos.toml @@ -9,3 +9,4 @@ ba = "ba" ned = "ned" fpr = "fpr" +chage = "chage"