Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

---

Expand Down
45 changes: 37 additions & 8 deletions build_joininbox.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 </etc/passwd) -gt 0 ]; then
echo "pi:joininbox" | chpasswd
passwd -l pi
fi
# Do NOT set any joinmarket password at build time and do NOT write any
# credential to /etc/issue, /root or the build output: this script also
# runs in PUBLIC CI image builds, so anything generated here would leak
# into world-readable CI logs and be baked into every published image.
# Instead install a one-shot systemd unit which generates the unique
# initial password ON THE DEVICE at the first boot (before ssh.service).
install -m 700 -o root -g root \
/home/joinmarket/joininbox/scripts/standalone/first.boot.credentials.sh \
/usr/local/sbin/joininbox-firstboot.sh
cp /home/joinmarket/joininbox/scripts/standalone/joininbox-firstboot.service \
/etc/systemd/system/joininbox-firstboot.service
systemctl enable joininbox-firstboot

echo "# create the joinin.conf"
runuser joinmarket -c "touch /home/joinmarket/joinin.conf"
Expand Down Expand Up @@ -674,9 +689,23 @@ echo
echo "To make an SDcard image safe to share use:"
echo "'/home/joinmarket/standalone/prepare.release.sh'"
echo
echo "the ssh login credentials are until the first login:"
echo "user:joinmarket"
echo "password:joininbox"
echo "######################################"
echo "# First-boot credentials (no defaults)"
echo "######################################"
echo
echo "SSH stays enabled (ufw allows port 22), but there is no shared"
echo "default password on this install - and NO password was set at build"
echo "time (nothing sensitive in this build log or in the image)."
echo
echo "On the FIRST BOOT of the device the one-shot"
echo "joininbox-firstboot.service (ordered before ssh.service) generates a"
echo "unique random password for the 'joinmarket' user and shows it on the"
echo "local console (/etc/issue). A root-only copy is kept in"
echo "/root/joininbox-initial-password (mode 600) and the password must be"
echo "changed on the first login (chage -d 0)."
echo
echo "The root and (if present) pi passwords are locked -"
echo "use sudo from the joinmarket user instead."
echo

# remove the build-time noninteractive apt policy so deployed systems
Expand Down
84 changes: 84 additions & 0 deletions scripts/standalone/first.boot.credentials.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/bin/bash

# joininbox-firstboot.sh - one-shot first-boot credential generation
#
# Installed to /usr/local/sbin/joininbox-firstboot.sh by build_joininbox.sh
# and run ONCE on the device by joininbox-firstboot.service (ordered
# Before=ssh.service) at the first boot.
#
# Generates a unique random password for the 'joinmarket' user ON THE
# DEVICE - never at image build time. This keeps the credential out of
# public CI build logs and out of published/shareable images.
#
# The password is NEVER written to stdout, the journal or syslog - only to
# the local console message (/etc/issue) and a root-only backup file.

set -u

TARGET_USER="joinmarket"
MARKER_DIR="/var/lib/joininbox"
MARKER_FILE="${MARKER_DIR}/firstboot-done"
PASSWORD_FILE="/root/joininbox-initial-password"
ISSUE_FILE="/etc/issue"
ISSUE_ORIG="/etc/issue.joininbox-orig"
UNIT_NAME="joininbox-firstboot.service"

# Idempotency: if the marker exists the credentials were already generated
# on this device - do nothing, even if the unit is still enabled.
if [ -f "${MARKER_FILE}" ]; then
exit 0
fi

# Nothing to do if the target user does not exist on this system.
if ! id "${TARGET_USER}" >/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' </dev/urandom | head -c 20)
if [ ${#initialPassword} -lt 16 ]; then
echo "joininbox-firstboot: password generation failed" >&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
29 changes: 29 additions & 0 deletions scripts/standalone/joininbox-firstboot.service
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions scripts/standalone/prepare.release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
80 changes: 80 additions & 0 deletions tests/first.boot.credentials.bats
Original file line number Diff line number Diff line change
@@ -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"
}
7 changes: 7 additions & 0 deletions tests/prepare.release.bats
Original file line number Diff line number Diff line change
@@ -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"* ]]
}
Loading
Loading