diff --git a/projects/LaunchWizard/main/SConstruct b/projects/LaunchWizard/main/SConstruct index 0c59ccc6..330c3165 100644 --- a/projects/LaunchWizard/main/SConstruct +++ b/projects/LaunchWizard/main/SConstruct @@ -8,7 +8,7 @@ SRCS = Glob("src/*.c*") SRCS += append_srcs_dir(ADir("ui")) INCLUDE = [ADir("."), ADir("ui")] PRIVATE_INCLUDE = [] -REQUIREMENTS = ["cp0_lvgl", "lvgl_component", "pthread", "Sigslot", "eventpp", "Miniaudio", "RadioLib"] +REQUIREMENTS = ["cp0_lvgl", "lvgl_component", "pthread", "crypt", "Sigslot", "eventpp", "Miniaudio", "RadioLib"] STATIC_LIB = [] DYNAMIC_LIB = [] DEFINITIONS = [] diff --git a/projects/LaunchWizard/main/ui/first_boot_policy.cpp b/projects/LaunchWizard/main/ui/first_boot_policy.cpp index 0971c2d4..ba4ba6eb 100644 --- a/projects/LaunchWizard/main/ui/first_boot_policy.cpp +++ b/projects/LaunchWizard/main/ui/first_boot_policy.cpp @@ -7,7 +7,8 @@ bool should_run_wizard(const FirstBootState &state) if (state.rearm_marker) return true; if (state.factory_marker) - return true; + return state.factory_username && + (!state.user_has_password || state.factory_credentials); return state.legacy_piwiz_active; } diff --git a/projects/LaunchWizard/main/ui/first_boot_policy.h b/projects/LaunchWizard/main/ui/first_boot_policy.h index e9eae3fb..17289534 100644 --- a/projects/LaunchWizard/main/ui/first_boot_policy.h +++ b/projects/LaunchWizard/main/ui/first_boot_policy.h @@ -11,14 +11,26 @@ struct FirstBootState { bool rearm_marker = false; // /var/lib/LaunchWizard/run-oobe: baked into every factory image by pi-gen. bool factory_marker = false; + // The UID 1000 user is still named after the factory default ("pi"). A + // rename can only come from provisioning (Imager/userconf), so a changed + // name alone means the device was configured. + bool factory_username = false; + // The UID 1000 user's shadow entry holds a real "$..." hash, meaning the + // account was configured (Raspberry Pi Imager, a finished OOBE, ...). + bool user_has_password = false; + // The stored hash still verifies against the factory default password that + // pi-gen bakes into the image ("raspberry"), so an existing password does + // NOT mean the user configured the device. + bool factory_credentials = false; // Legacy piwiz/lightdm first-boot autologin is still armed. bool legacy_piwiz_active = false; }; -// The wizard runs when either OOBE marker is present (a user-requested re-run -// or a factory first boot), or when the legacy piwiz/lightdm first-boot -// autologin is still armed. LaunchWizard.service is only disabled after the -// wizard finishes configuration (apply_all). +// A user-requested re-run always shows the wizard. The factory marker shows it +// only while the account is exactly in factory state: default username with +// either no password at all or the baked default password. A changed username +// or a user-chosen password both mean the device was provisioned (Raspberry Pi +// Imager), so first boot skips straight to the launcher. bool should_run_wizard(const FirstBootState &state); // The keyboard guide runs exactly once per device, before and independently of diff --git a/projects/LaunchWizard/main/ui/wizard_service.cpp b/projects/LaunchWizard/main/ui/wizard_service.cpp index 0261c7ba..c41229b8 100644 --- a/projects/LaunchWizard/main/ui/wizard_service.cpp +++ b/projects/LaunchWizard/main/ui/wizard_service.cpp @@ -41,10 +41,18 @@ extern char **environ; #endif #endif +#if !LAUNCH_WIZARD_DRY_RUN +#include +#endif + namespace launch_wizard { constexpr uid_t kDefaultUserUid = 1000; constexpr const char *kFirstBootWizardUser = "rpi-first-boot-wizard"; +// Factory account baked into every image by pi-gen (build.yml FIRST_USER_NAME / +// FIRST_USER_PASS). Keep in sync with the pi-gen workflow configuration. +constexpr const char *kFactoryDefaultUser = "pi"; +constexpr const char *kFactoryDefaultPassword = "raspberry"; #if LAUNCH_WIZARD_DRY_RUN constexpr const char *kAccountJournalDir = "/tmp/LaunchWizard-dry-run"; constexpr const char *kAccountJournalPath = @@ -282,6 +290,45 @@ bool first_user_has_password() #endif } +// True while the UID 1000 user is still named after the pi-gen factory +// default. userconf/Imager renames the user in passwd, so a different name +// alone proves the device was provisioned. +bool first_user_has_factory_name() +{ +#if LAUNCH_WIZARD_DRY_RUN + return true; +#else + struct passwd *pw = getpwuid(kDefaultUserUid); + return pw && pw->pw_name && strcmp(pw->pw_name, kFactoryDefaultUser) == 0; +#endif +} + +// True while the UID 1000 account still carries the factory credentials that +// pi-gen bakes into the image (FIRST_USER_NAME=pi / FIRST_USER_PASS=raspberry). +// Verification follows crypt(5): hash the candidate with the stored hash as +// the setting string; a byte-identical result means the password matches. Any +// crypt failure yields NULL or a "*" failure token that never compares equal, +// so errors safely count as "not factory". +bool first_user_has_factory_credentials() +{ +#if LAUNCH_WIZARD_DRY_RUN + return false; +#else + struct passwd *pw = getpwuid(kDefaultUserUid); + if (!pw || !pw->pw_name || strcmp(pw->pw_name, kFactoryDefaultUser) != 0) + return false; + struct spwd *sp = getspnam(pw->pw_name); + if (!sp || !sp->sp_pwdp || sp->sp_pwdp[0] != '$') + return false; + // struct crypt_data is ~32 KiB; keep it off the stack. should_run() is + // called once from the single-threaded startup path. + static struct crypt_data data; + memset(&data, 0, sizeof(data)); + const char *hash = crypt_r(kFactoryDefaultPassword, sp->sp_pwdp, &data); + return hash && hash[0] == '$' && strcmp(hash, sp->sp_pwdp) == 0; +#endif +} + std::vector initial_user_groups() { #if LAUNCH_WIZARD_DRY_RUN @@ -1002,10 +1049,16 @@ bool launch_wizard::WizardService::should_run() // apply_all() clears it on completion, so the wizard still runs exactly // once. state.rearm_marker = access(kRearmOobeMarker, F_OK) == 0; - // pi-gen bakes the factory marker into every image. Any marker means the - // OOBE must run; only apply_all() disables LaunchWizard.service after the - // user completes configuration. + // pi-gen bakes the factory marker into every image. It must only trigger + // the OOBE while the account is exactly in factory state: still named + // "pi", with no password or the baked "raspberry" default. A renamed user + // or a user-chosen password means Raspberry Pi Imager provisioned the + // device, so first boot skips straight to the launcher + // (finish_configured_system() then removes the marker). state.factory_marker = access(kFactoryOobeMarker, F_OK) == 0; + state.factory_username = first_user_has_factory_name(); + state.user_has_password = first_user_has_password(); + state.factory_credentials = first_user_has_factory_credentials(); state.legacy_piwiz_active = lightdm_autologin_user() == kFirstBootWizardUser && piwiz_autostart_enabled(); return should_run_wizard(state); diff --git a/projects/LaunchWizard/tests/test_first_boot_policy.cpp b/projects/LaunchWizard/tests/test_first_boot_policy.cpp index 83dae5d8..9148dd2f 100644 --- a/projects/LaunchWizard/tests/test_first_boot_policy.cpp +++ b/projects/LaunchWizard/tests/test_first_boot_policy.cpp @@ -23,10 +23,31 @@ bool test_first_boot_policy() bool passed = true; launch_wizard::FirstBootState state; - // Factory first boot: marker present -> wizard, regardless of whether the - // account already has a password. Only apply_all() disables the service. + // Factory first boot: marker present, default username, no password -> + // wizard. state.factory_marker = true; - passed &= expect_wizard(true, state, "factory marker"); + state.factory_username = true; + passed &= expect_wizard(true, state, "factory unconfigured"); + + // Factory image with the baked pi/raspberry password: the hash exists but + // still verifies as the factory default, so the wizard must run. + state.user_has_password = true; + state.factory_credentials = true; + passed &= expect_wizard(true, state, "factory baked default password"); + + // Imager-provisioned device: factory marker still present, but the user + // chose their own password -> skip straight to the launcher (bug #227). + state.factory_credentials = false; + passed &= expect_wizard(false, state, "factory imager-provisioned"); + + // A renamed user always means the device was provisioned, even when no + // password was set (e.g. Imager with SSH keys only). + state.factory_username = false; + state.user_has_password = false; + passed &= expect_wizard(false, state, "renamed user without password"); + state.user_has_password = true; + state.factory_credentials = true; + passed &= expect_wizard(false, state, "renamed user keeps factory password"); // Settings "Run Setup Wizard" re-arm always wins, even when configured. state.rearm_marker = true; @@ -37,6 +58,8 @@ bool test_first_boot_policy() passed &= expect_wizard(false, state, "no markers"); state.legacy_piwiz_active = true; passed &= expect_wizard(true, state, "legacy piwiz"); + state.user_has_password = true; + passed &= expect_wizard(true, state, "legacy piwiz ignores password"); // Keyboard guide: needs both the marker and the installed binary; a // missing binary keeps the marker for a later boot.