Repository navigation
Hold an image shut until its first boot has set a password - #12
Conversation
…t boot Three things the build leaves behind were more permissive than an appliance needs between the end of the build and the end of its first boot. conf/turnkey.d/rootpass, for a build given no ROOT_PASS, wrote a password field that a submitted password can match. It now writes one that no password matches. The value is deliberately not '!': shadow reads a field of exactly '!' as a locked account, and 'passwd --unlock root', which turnkey-sudoadmin runs on every first boot, refuses to unlock it, exits 3 and leaves the rest of that hook undone. Measured in a scratch root with 'unshare -r passwd --root'. The first boot writes over the field with chpasswd as it always did, which replaces it whatever it held. The same script expanded ROOT_PASS against the build directory before handing it to chpasswd, so a password holding a glob character was not the password that was set. conf/turnkey.d/webmin-pam is new and takes 'nullok' out of the PAM stack the webmin package installs. The option lets an account whose password is not set be authenticated without one; no appliance account is meant to be reachable that way and nothing in an appliance asked for it. The script fails rather than report success if the stack is absent or if the option survives. conf/turnkey.d/webmin-enable holds the web interface back until the first boot scripts have run. webmin.service gains a condition on the marker inithooks writes when its boot scripts are done, and a path unit starts it when that marker appears. The conditions are systemd's triggering conditions, an OR, so an image carrying no inithooks at all is not left without a web interface; the path unit triggers a one shot that stays active, so it fires once and does not restart Webmin behind an administrator who stopped it. Tests, 36 bats over the four measured conf scripts, assert behaviour and not configuration. Whether a password gets in is answered by the real crypt(3), the function pam_unix compares with, inside the two rules Linux-PAM applies around it; those rules were confirmed against unix_chkpwd, the helper pam_unix runs, with a scratch shadow file bind mounted over /etc/shadow under 'unshare -r -m'. Whether the web interface would start is answered by 'systemd-analyze condition', and the units it writes are handed to 'systemd-analyze verify'. The shadow tools are stubbed because usermod, chpasswd and passwd chroot into the root they are given and cannot be aimed at a scratch tree by an ordinary user; each stub carries the behaviour it reproduces and the command that measured it. tests/before-firstboot.bats runs the three scripts over one image in the order a build runs them and asks the whole question of the result, because no single one of them owns the answer. tests/coverage.sh measures a list of files rather than one. rootpass moved from /bin/sh to /bin/bash so that kcov can measure it, and uses no bash construct. Closes #11.
751acc1 to
5596a8e
Compare
The core changelog gains a bullet per change: the password field a build with no ROOT_PASS leaves, the glob expansion of a build time ROOT_PASS, the nullok removal and the wait on the first boot scripts. COVERAGE.md records the branch: 100 percent over four measured files, 39 of 39 lines under kcov 43, 36 bats, with what is measured for real and what is stubbed spelled out. The gate stays at 100.
5596a8e to
d9dcbea
Compare
|
Reviewed as a security change. I re-measured every claim rather than reading Reproduced as stated: the HIGH — The instrument that would have caught it is sound; it was just fed wrong. delivers nothing and the helper tests the empty password whatever you piped with a scratch shadow bind-mounted over HIGH — MEDIUM — MEDIUM — MEDIUM — LOW — LOW — LOW — The change does what it sets out to do, and the three parts are the right Warning |
The helpers answered "would this password get in" with a hand written model
of pam_unix: crypt() through perl wrapped in two rules. The model was
missing rules the module has, and reported at least one acceptance the
module does not make: with a blank equivalent field and no nullok it let
the empty password in, and pam_unix refuses it.
tests/pam-authenticate now asks Linux-PAM itself. It loads libpam.so.0
through ctypes, starts the scratch image's own stack with
pam_start_confdir and calls pam_authenticate, in a user and mount
namespace where the scratch shadow and passwd files are bind mounted over
/etc/shadow and /etc/passwd. pam_unix runs in that process as root of the
namespace, which is the path Webmin's miniserv takes; unix_chkpwd, the
helper for unprivileged callers, is not the same path and does not give
the same answers (with nullok and a blank equivalent field it refuses a
non-empty password, and the in process module accepts any password
without asking for one). The fail delay is handed to the application and
ignored, which changes the time and not the verdict.
The verdict is read from the PAM return code the client prints, never
from an exit status, because unshare and mount exit 1 when they fail and
a sandbox that does not work must not read as a refusal. Refutations use
a new 'refuses' helper for the same reason, never '! authenticates'.
Mutation checked: with an unshare that exits 1, every test that asks PAM
fails.
tests/pam-unix.bats pins the facts the other suites rest on, measured on
libpam 1.7.0-5:
field '' or 'U6aMy0wojraho', nullok any password, no prompt
same fields, no nullok every password refused
field starting '*' or '!', either every password refused
One test did not survive the real module, and it was the claim that
removing nullok leaves the empty password to the field. It does not: on
the webmin stack, without nullok, the old placeholder refuses every
password. What the field is for is every stack that keeps nullok, and
Debian's common-auth does; tests/fixtures/pam.d-common-auth is that file
as a built core carries it, and the rewritten test shows the old field
letting anything in through it and '*' refusing everything.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
The previous commit's sandbox needs a user namespace that may mount. The Ubuntu runner the CI uses makes the namespace but its AppArmor policy leaves it without the right to mount, and every test that asks PAM failed there, loudly, which is what the verdict parsing is for. sandbox_mount_ns tries the unprivileged namespace first and falls back to 'sudo --non-interactive unshare --mount' where sudo needs no password, which the runner has. Either way the bind mounts live in a private mount namespace and are never visible outside it, and the module asked is the same one. Where neither is available it says so and the tests fail; they do not skip. Both paths measured locally: 42 of 42, and no shadow mount left behind. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
Webmin's drop-in waited for /run/inithooks-complete, which inithooks writes at the end of the everyboot pass on every boot, on a tmpfs. So the hold did not gate one boot: it gated every boot for the life of the appliance behind inithooks.service reaching that line, and a boot where it did not (a hook blocked on a console nobody is attached to, docs/traps.md entry 1) had no web interface, while 'systemctl start webmin' exited 0 and started nothing, because an unmet condition is a skipped, successful start. The one shot the path unit triggers now records that the interface was opened, in /var/lib/webmin-after-firstboot/opened, before it starts Webmin, and the drop-in accepts that record as a third triggering condition. The first boot is held as before; every boot after one that got to the end of its scripts starts Webmin with the rest of the boot. The build writes no record, and a test says so. The script's comment now says what the marker does not mean (scripts run, not scripts succeeded; rootpass's field is what holds an account shut on a first boot that failed to set a password) and gives the recovery for a first boot that never finishes: 'systemctl start webmin-after-firstboot.service', which opens it for that boot and every one after. Tests, written first and failing on the previous script for the two later boot cases and the recovery: - the unit and its drop-in are handed to systemd together: tests/fixtures/webmin.service is the unit webmin 2.660.turnkey0 installs (read from a built container), 'systemd-analyze verify' reads it with the drop-in in place, and the verdict on the conditions is taken over both files, so a plain Condition in the packaged unit, which would AND with these, is seen; - the one shot's ExecStart lines are run in order, with systemctl stubbed and mkdir and touch real, and the next boot is a boot with an empty /run. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
…assword out of the log conf/samba-rootpass carried both of the defects this branch fixes in conf/turnkey.d/rootpass, and a third of its own: - with no ROOT_PASS it wrote 'U6aMy0wojraho', the crypt() of the empty string, and it runs after rootpass (mk/turnkey/fileserver.mk lists it in COMMON_CONF), so a Samba appliance had rootpass's '*' written back over. Through a stack carrying nullok, Debian's common-auth among them, the real pam_unix lets any password in against that field; - ROOT_PASS was expanded unquoted, against the build directory; - '#!/bin/sh -ex' traced 'echo root:$ROOT_PASS' and the smbpasswd feed into the build log, with the password in them. It now writes '*', quotes the password and does not trace. The shebang is bash, for kcov, as rootpass's is. 'smbpasswd -a -n' is unchanged: a null Samba password is refused unless smb.conf sets 'null passwords = yes', and nothing in this tree does. tests/samba-rootpass.bats, 6 tests, 4 of them failing on the previous script: nothing authenticates through the webmin stack or common-auth, the unlock runs first and the field it leaves is one unlock accepts, a glob is set as written, and the password is in neither output stream. smbpasswd is a stub that records its arguments and what it was fed. The file joins the measured list at 100 percent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
…libpam The CI runner (Ubuntu 24.04, libpam 1.5.3) failed five tests that pass on Debian 13 (libpam 1.7.0-5), all of them about the old placeholder 'U6aMy0wojraho', the crypt() of the empty string. The module is not the same module: - pam_unix runs unix_chkpwd for every shadowed account, as root too (get_account_info returns PAM_UNIX_RUN_HELPER outside the helper). The first commit of this series said the in process path does not; it does, and the helpers and tests/pam-authenticate now say so. - Before asking for a password, _unix_blankpasswd() hands the helper an empty one, with nullok when the stack has it. If the helper accepts, the account is authenticated without being asked. Measured by bind mounting a helper that always refuses over unix_chkpwd in the sandbox: the "any password, no prompt" verdict turns into a prompt and a refusal. - In 1.7, verify_pwd_hash() refuses an empty password without nullok and otherwise compares it; under nullok the empty password matches 'U6aMy0wojraho', so any password gets in. In 1.5 it refuses the empty password under nullok unless the field is empty, and compares it without, so there the same field lets the empty password in when nullok is gone. (Linux-PAM v1.5.3 and v1.7.0, modules/pam_unix/passverify.c.) So on the libpam an appliance runs, removing nullok alone closed the old field, and on 1.5 it did not; '*' is refused by both, through any stack. require_measured_libpam skips a test whose verdict is version dependent, naming both versions, when libpam-modules here is not 1.7. Five tests call it; the facts that hold on both (an empty field, '*' and '!', a real hash) and every test of the scripts themselves do not. Mutation checked by setting the measured version to 1.5: exactly those five skip. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
The changelog said removing nullok stops a wrong password and the field stops the empty one. Asked of the real module, on the libpam an appliance runs, the old field with nullok let any password in, the field '*' closes it through every stack, and removing nullok from Webmin's stack narrows what reaches it. The rootpass entry now says it is the change that holds the account shut, and why; the webmin-pam entry says what it does and that common-auth keeps the option (#13, opened for it, with why it needs a pam-auth-update profile and not an edit). The webmin-enable entry gains the later boots and the recovery, and samba-rootpass gets its own. COVERAGE.md: five files, 48 of 48 lines, 57 bats, and the paragraph on what is real rewritten: libpam through pam_authenticate, the version dependence and the five tests the CI runner skips because of it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
tests/before-firstboot.bats claims to ask a built image the only question worth asking, and asked it through Webmin's stack only. Measured against the previous rootpass with this branch's webmin-pam, on libpam 1.7, all its password tests passed: removing nullok from that one stack is enough to close the old field there. So nothing in the file would notice rootpass going back to 'U6aMy0wojraho'. The new test asks through common-auth as a built core carries it, which keeps nullok (#13), and fails against the previous rootpass. With it the file fails 4 of its 6 tests against the previous rootpass and webmin-enable, and 1 (this one) against the previous rootpass alone. COVERAGE.md counts 58 bats. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
…ning 19.x gained #8 and #10, and tests/coverage.sh in #8's shape: one kcov run per measured file, each with the suite that exercises it. This branch had rewritten it into one run over a list; its files move into #8's shape as targets (samba-rootpass, rootpass, webmin-enable, webmin-pam, each with its own suite, all at 100), and the two suites that measure no file of their own, before-firstboot.bats and pam-unix.bats, run after the loop so they still gate. The changelog keeps both sides' entries; COVERAGE.md keeps this branch's section under 19.x's baseline heading. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SfWQScmDZ94KCS5DMYrfe6
|
Review addressed in 7ba662a..7d4ef76, all new commits. The tests now call |
Hold an image shut until its first boot has set a password
Closes #11.
The problem
A built appliance has accounts before its first boot has set a password on
any of them, and three things in the build made that state more open than it
has to be.
conf/turnkey.d/rootpass, for a build given noROOT_PASS, wrote apassword field that a submitted password can match, rather than one that
matches nothing until a password is set. The PAM stack the
webminpackageinstalls carries
nullok, which lets an account whose password is not setbe authenticated without one. And the web interface starts with the rest of
the boot, before the first boot scripts that configure the appliance have
run at all.
Established before changing anything
Why the placeholder existed, and why it cannot simply become
!./etc/default/inithooksshipsSUDOADMIN=false, sofirstboot.d/29sudoadminruns
turnkey-sudoadmin off --disable-setpasson every first boot, before30rootpass. That path callspasswd --unlock root. Measured againstshadow in a scratch root:
turnkey-sudoadminruns underbash -e, so exit 3 there ends the hookbefore
permitrootlogin_ssh,restart_sshdandset_status, and/etc/sudoadmin/stateis never written. Locking the account is thereforethe fix that breaks the first boot, which
docs/traps.mdputs at the top ofthe list.
*is what the account gets:crypt()returns it for no input,passwd --unlockaccepts it, andbin/setpass.pysets the real passwordwith
chpasswd, which replaces the field whatever it held.What the placeholder was for. Nothing in the first boot authenticates
against it.
sshdshipsPermitRootLogin yesbut OpenSSH defaultsPermitEmptyPasswords no, so it was never an SSH login. The first bootdialog runs on tty1 with
Conflicts=getty@tty1.service, so there is nologin prompt to answer during it either. What it bought was a console login
on an image whose first boot has not run.
Why not
allow=or a loopback bind inminiserv.conf. Both outlive thefirst boot: nothing removes them, so an
allow=written at build time locksout remote administration for good, and a loopback bind has to be rewritten
and the service restarted by something later. Holding the unit back needs no
undoing, so that is what is here instead.
The change
conf/turnkey.d/rootpasswrites*, with the reason!is not used in acomment. The same script expanded
ROOT_PASSagainst the build directorybefore handing it to
chpasswd, so a password holding a glob characterwas not the password that got set; it is quoted now.
conf/turnkey.d/webmin-pam, new, takesnullokout of the stack andfails rather than report success if the stack is absent or the option
survives.
conf/turnkey.d/webmin-enableholds the web interface back.webmin.servicegains a condition on the marker inithooks writes when its boot scripts are
done, and a path unit starts it when the marker appears. The conditions are
systemd triggering conditions, an OR, so an image with no inithooks at all
is not left without a web interface; the path unit triggers a one shot that
stays active, so it fires once and does not restart Webmin behind an
administrator who stopped it.
conf/samba-rootpass, which runs after rootpass on a Samba appliance andwrote the old field back, writes
*too, quotes the password and nolonger traces it into the build log.
What each part does, asked of the real
pam_unixon libpam 1.7.0-5 (theappliance's): the old field let any password in, without a prompt,
through any stack carrying
nullok, which includes Debian'scommon-auth.*refuses everything through every stack, sorootpassis the changethat holds the account shut. Removing
nullokfrom Webmin's stack alsocloses the old field there on 1.7 (not on libpam 1.5, where the empty
password then gets in);
common-authkeeps it, #13. The hold keeps theinterface down until the first boot's scripts have run, and the marker
means they ran, not that they succeeded.
Tests, and what is real in them
58 bats. The question "would this password get in" is put to Linux-PAM
itself:
tests/pam-authenticatecallspam_authenticatethrough libpamover the scratch image's own stack, in a private mount namespace with the
scratch shadow and passwd bind mounted over the real ones, so
pam_unixand its
unix_chkpwdhelper are the installed ones. (The first versionmodelled
pam_unixby hand, and review found the model wrong.) Whatpam_unixmakes of the crypt of the empty string differs between libpam1.5 and 1.7, so the five tests that depend on it run on 1.7 and skip by
name elsewhere; the CI runner has 1.5.3. Whether the interface would start
is answered by
systemd-analyze conditionover the packagedwebmin.serviceand the drop-in together, and both are handed tosystemd-analyze verify.The shadow tools and
smbpasswdare stubbed:usermod,chpasswdandpasswdchroot into the root they are given, so an ordinary user cannotaim them at a scratch tree. Each stub carries the behaviour it reproduces
and the command that measured it.
tests/before-firstboot.batsruns the three scripts over one image in theorder a build runs them and asks the whole question of the result, through
Webmin's stack and through
common-auth. With this branch'swebmin-pamand the previous
rootpassandwebmin-enable, it fails 4 of its 6tests; with only
rootpassreverted, 1.tests/rootpass.batsfails 5 of9 against the previous script,
tests/samba-rootpass.bats4 of 6.Coverage: 100 percent, 48 of 48 lines over the five measured files, kcov 43.
tests/coverage.shmeasures a list rather than one file.rootpassandwebmin-enablemoved from/bin/shto/bin/bashbecause kcov measuresbash and not dash and decision 0003 gives no exemption for a file a change
touches; rootpass uses no bash construct. The gate stays at 100.
What this does not do
Nothing is built, signed or published, and no build lock was taken, so no
appliance has been booted with this. The end to end verdict belongs to an
appliance boot test.
The marker the unit waits for is
/run/inithooks-complete, which today'sinithooks already writes, so this stands on its own and needs no ordering
against Keel-Linux/inithooks#17 or #18. The first boot that reaches it
records that in
/var/lib/webmin-after-firstboot/opened, and every bootafter starts Webmin without waiting. A first boot that never finishes keeps
Webmin shut until
systemctl start webmin-after-firstboot.serviceis runfrom a console.
common-authstill carriesnullok: #13.