From e24bac2d970868cce67395888ed65e99242226b0 Mon Sep 17 00:00:00 2001 From: LiHaohua Date: Tue, 18 Aug 2026 15:14:10 +0800 Subject: [PATCH 1/2] fix(wizard): run OOBE while the account still has the factory password pi-gen bakes FIRST_USER_PASS=raspberry into every image, so the password-presence check added for bug #227 also fired on factory-fresh devices and skipped the OOBE entirely, leaving Wi-Fi and timezone unconfigured. Verify the stored hash against the baked pi/raspberry default via crypt_r(3) (the stored hash doubles as the setting string, per crypt(5)), and treat a match as an unconfigured account. Imager-provisioned devices store a user-chosen password (or a renamed user), never match, and keep skipping the wizard. crypt failures return a "*" token that can never compare equal, so errors safely count as configured. Links LaunchWizard against libcrypt (libcrypt.so.1, already in the image) and covers the new policy branch in the unit tests. Co-authored-by: Cursor --- projects/LaunchWizard/main/SConstruct | 2 +- .../main/ui/first_boot_policy.cpp | 2 +- .../LaunchWizard/main/ui/first_boot_policy.h | 13 ++++-- .../LaunchWizard/main/ui/wizard_service.cpp | 43 +++++++++++++++++-- .../tests/test_first_boot_policy.cpp | 11 ++++- 5 files changed, 60 insertions(+), 11 deletions(-) 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 326bb69f..02a82129 100644 --- a/projects/LaunchWizard/main/ui/first_boot_policy.cpp +++ b/projects/LaunchWizard/main/ui/first_boot_policy.cpp @@ -7,7 +7,7 @@ bool should_run_wizard(const FirstBootState &state) if (state.rearm_marker) return true; if (state.factory_marker) - return !state.user_has_password; + return !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 af6f951e..377c9a40 100644 --- a/projects/LaunchWizard/main/ui/first_boot_policy.h +++ b/projects/LaunchWizard/main/ui/first_boot_policy.h @@ -14,14 +14,19 @@ struct FirstBootState { // 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 UID 1000 account is still the factory default baked in by pi-gen + // (user "pi" whose hash verifies against the default password), so any + // 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; }; -// A user-requested re-run always shows the wizard. The factory marker only -// shows it while no account has been configured yet: a device provisioned -// through Raspberry Pi Imager already has a password, so first boot must skip -// straight to the launcher. +// A user-requested re-run always shows the wizard. The factory marker shows it +// while the account is unconfigured: either no password at all, or the +// password still verifies as the pi-gen factory default. A device provisioned +// through Raspberry Pi Imager has a user-chosen password (or a renamed user), +// 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 5455c8f6..8c132f8d 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,32 @@ bool first_user_has_password() #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 @@ -1003,11 +1037,14 @@ bool launch_wizard::WizardService::should_run() // once. state.rearm_marker = access(kRearmOobeMarker, F_OK) == 0; // pi-gen bakes the factory marker into every image. It must only trigger - // the OOBE while the account is still unconfigured; a device provisioned - // through Raspberry Pi Imager already has a password and skips straight to - // the launcher (finish_configured_system() then removes the marker). + // the OOBE while the account is still unconfigured: no password at all, or + // the password still verifying as the baked pi/raspberry default. A device + // provisioned through Raspberry Pi Imager has a user-chosen password (or a + // renamed user) and skips straight to the launcher + // (finish_configured_system() then removes the marker). state.factory_marker = access(kFactoryOobeMarker, F_OK) == 0; 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 b43c5000..aa42d7ac 100644 --- a/projects/LaunchWizard/tests/test_first_boot_policy.cpp +++ b/projects/LaunchWizard/tests/test_first_boot_policy.cpp @@ -27,9 +27,16 @@ bool test_first_boot_policy() state.factory_marker = true; passed &= expect_wizard(true, state, "factory unconfigured"); - // Imager-provisioned device: factory marker still present, but the user - // already has a password -> skip straight to the launcher (bug #227). + // 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 (or renamed the user) -> skip straight to the + // launcher (bug #227). + state.factory_credentials = false; passed &= expect_wizard(false, state, "factory imager-provisioned"); // Settings "Run Setup Wizard" re-arm always wins, even when configured. From d106f9472c1bf23103d1fa13090eb7adabfa3b71 Mon Sep 17 00:00:00 2001 From: LiHaohua Date: Tue, 18 Aug 2026 15:16:46 +0800 Subject: [PATCH 2/2] fix(wizard): treat a renamed first user as provisioned A renamed UID 1000 user can only come from Imager/userconf provisioning, so skip the OOBE even when no password was set (e.g. SSH-keys-only customisation). The factory marker now triggers the wizard only in exact factory state: default username with no password or the baked default. Co-authored-by: Cursor --- .../main/ui/first_boot_policy.cpp | 3 ++- .../LaunchWizard/main/ui/first_boot_policy.h | 18 +++++++++------ .../LaunchWizard/main/ui/wizard_service.cpp | 22 +++++++++++++++---- .../tests/test_first_boot_policy.cpp | 16 +++++++++++--- 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/projects/LaunchWizard/main/ui/first_boot_policy.cpp b/projects/LaunchWizard/main/ui/first_boot_policy.cpp index 02a82129..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 !state.user_has_password || state.factory_credentials; + 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 377c9a40..17289534 100644 --- a/projects/LaunchWizard/main/ui/first_boot_policy.h +++ b/projects/LaunchWizard/main/ui/first_boot_policy.h @@ -11,22 +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 UID 1000 account is still the factory default baked in by pi-gen - // (user "pi" whose hash verifies against the default password), so any - // existing password does NOT mean the user configured the device. + // 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; }; // A user-requested re-run always shows the wizard. The factory marker shows it -// while the account is unconfigured: either no password at all, or the -// password still verifies as the pi-gen factory default. A device provisioned -// through Raspberry Pi Imager has a user-chosen password (or a renamed user), -// so first boot skips straight to the launcher. +// 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 8c132f8d..c41229b8 100644 --- a/projects/LaunchWizard/main/ui/wizard_service.cpp +++ b/projects/LaunchWizard/main/ui/wizard_service.cpp @@ -290,6 +290,19 @@ 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 @@ -1037,12 +1050,13 @@ bool launch_wizard::WizardService::should_run() // once. state.rearm_marker = access(kRearmOobeMarker, F_OK) == 0; // pi-gen bakes the factory marker into every image. It must only trigger - // the OOBE while the account is still unconfigured: no password at all, or - // the password still verifying as the baked pi/raspberry default. A device - // provisioned through Raspberry Pi Imager has a user-chosen password (or a - // renamed user) and skips straight to the launcher + // 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 = diff --git a/projects/LaunchWizard/tests/test_first_boot_policy.cpp b/projects/LaunchWizard/tests/test_first_boot_policy.cpp index aa42d7ac..9148dd2f 100644 --- a/projects/LaunchWizard/tests/test_first_boot_policy.cpp +++ b/projects/LaunchWizard/tests/test_first_boot_policy.cpp @@ -23,8 +23,10 @@ bool test_first_boot_policy() bool passed = true; launch_wizard::FirstBootState state; - // Factory first boot: marker present, account unconfigured -> wizard. + // Factory first boot: marker present, default username, no password -> + // wizard. state.factory_marker = true; + state.factory_username = true; passed &= expect_wizard(true, state, "factory unconfigured"); // Factory image with the baked pi/raspberry password: the hash exists but @@ -34,11 +36,19 @@ bool test_first_boot_policy() passed &= expect_wizard(true, state, "factory baked default password"); // Imager-provisioned device: factory marker still present, but the user - // chose their own password (or renamed the user) -> skip straight to the - // launcher (bug #227). + // 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; passed &= expect_wizard(true, state, "re-arm on configured device");