From 9fd689eac5b1a22b39a80acc97f839bca1d7cdf2 Mon Sep 17 00:00:00 2001 From: boburning Date: Sun, 30 Aug 2026 02:00:50 -0400 Subject: [PATCH 1/2] feat(network): derive safe Wi-Fi profile handles --- docs/1.0_TEST_MATRIX.md | 2 +- docs/ARCHITECTURE.md | 8 ++ docs/SECURITY.md | 7 ++ docs/program/BloomOS_Work_Program_v3.json | 6 +- src/bloomShell/bloom_shell_wifi_ui.c | 108 ++++++++++++++++++++++ src/bloomShell/bloom_shell_wifi_ui.h | 3 + test/test_bloom_shell_wifi_ui.cpp | 63 +++++++++++++ 7 files changed, 194 insertions(+), 3 deletions(-) diff --git a/docs/1.0_TEST_MATRIX.md b/docs/1.0_TEST_MATRIX.md index 05f5d2ecb..74422a878 100644 --- a/docs/1.0_TEST_MATRIX.md +++ b/docs/1.0_TEST_MATRIX.md @@ -50,7 +50,7 @@ shoulder button. | Network services | Desired/runtime separation; carrier without IPv4 remains acquiring/not-ready; canonical DHCP restart and bounded post-address listener reconciliation; SSH shell and SFTP independent key-only states; canonical transfer roots and modes; Saves read-only; private roots denied; traversal/symlink/path bounds; same-filesystem atomic publication; authorized-key import; quiesce/health; stale FTP/Telnet/FileBrowser/inherited-Samba/WPS/hotspot flags cannot activate listeners; process-aware listener audit; Samba hidden until a materially different source-build candidate passes feasibility | | Wi-Fi profile store | JFFS2 private root; stdin-only bounded replacement and `bloomctl network profile upsert ID`; requested/document ID binding; explicit `network forget ID`; inactive-ID deletion with active-profile refusal; active forget only after a separately confirmed fallback connection; schema/unknown-field/count/size enforcement; WPA2-PSK, explicit open, and manual hidden WPA2; WEP and hidden open denial; unique IDs/SSIDs; redacted status; explicit local credential-free UI list with one active marker; mode-0600 atomic single-profile supplicant projection; symlink, occupied-slot, live-lock, invalid-profile, and prior-byte preservation | | Wi-Fi scan API | Asynchronous start; redacted status; explicit ID-scoped local results; cancel and terminal discard; one active worker; five-second rate limit; 15-second runtime; 64-KiB raw ceiling; 32 unique printable SSIDs; eight-operation history; open/WPA2-PSK/unsupported classification; duplicate signal reconciliation; backend failure, timeout, oversize, unsafe-path, stale/live-lock, and unsupported-hardware handling | -| Wi-Fi UI | Original Mini hidden; Settings, Shell Quick Settings, and GameSwitcher Quick Settings converge on `--settings-wifi`; direct radio toggle removed; bounded fixed-argv profile-list load outside render; connection state and saved-network detail; selectable Scan/Cancel row and read-only discovered networks; active-only 100-ms SDL wake; deferred GameSwitcher until cancellation discard; single-flight nonblocking fixed-argv scan client; 16-KiB output and five-second deadline; child process-group cleanup; controller-owned start/status/results/discard and cancel-during-start sequencing; 250-ms status opportunities; exact profile/scan/ack schemas; 16 profiles and 32 networks; unique bounded IDs/SSIDs; one active profile; fixed scan phases/errors; terminal history discarded; host-only open/WPA2 profile form with shared keyboard, masked secret, fixed profile-upsert argv, escaped stdin JSON, strict ID/SSID/PSK validation, explicit secret clearing, nonblocking submit/poll, 4-KiB acknowledgement cap, exact upsert response, and timeout process-group cleanup; credential and unknown-field rejection; passwords absent from arguments, logs, screenshots, durable Shell state, and evidence | +| Wi-Fi UI | Original Mini hidden; Settings, Shell Quick Settings, and GameSwitcher Quick Settings converge on `--settings-wifi`; direct radio toggle removed; bounded fixed-argv profile-list load outside render; connection state and saved-network detail; selectable Scan/Cancel row and read-only discovered networks; active-only 100-ms SDL wake; deferred GameSwitcher until cancellation discard; single-flight nonblocking fixed-argv scan client; 16-KiB output and five-second deadline; child process-group cleanup; controller-owned start/status/results/discard and cancel-during-start sequencing; 250-ms status opportunities; exact profile/scan/ack schemas; 16 profiles and 32 networks; unique bounded IDs/SSIDs; one active profile; fixed scan phases/errors; terminal history discarded; host-only open/WPA2 profile form with shared keyboard, masked secret, fixed profile-upsert argv, escaped stdin JSON, strict ID/SSID/PSK validation, explicit secret clearing, nonblocking submit/poll, 4-KiB acknowledgement cap, exact upsert response, timeout process-group cleanup, exact saved-ID reuse, 104-bit no-follow random new handles, and collision/security/full-store rejection; credential and unknown-field rejection; passwords absent from arguments, logs, screenshots, durable Shell state, and evidence | | GameSwitcher | MENU open/toggle; Left/Right select; L1/R1 page; A resume; X actions; START Quick Settings; B Home; no inherited brightness/view/delete behavior | | Apps/Tweaks | Every app disposition recorded; normal configuration requires no Tweaks; supervised launch remains bounded | | Persistent state | Versioned atomic publication; exact restoration across Details/game/GameSwitcher/Quick Settings; malformed/oversized/symlink input defaults without Safe Mode | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0858bfacc..d1a286feb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -589,6 +589,14 @@ accepts one exact schema-1 upsert acknowledgement; malformed, oversized, non-zero, or five-second-timeout responses kill and reap the exact process group. This request is still not reachable from the render or input loop. +Mapping a discovered network to that form first requires a current validated +profile snapshot. An exact saved SSID reuses its existing non-secret ID only +when the security class also matches. A new SSID receives a 104-bit high-entropy +`wifi-`-prefixed handle from the no-follow system random device; collision is +retried within a fixed bound. Exhausted collision retries, a full profile store, +unsupported security class, missing snapshot, or saved/scan security mismatch +fails closed before form state is created. + Profile association reuses the one guarded network-transaction authority rather than adding a second recovery mechanism. `begin` copies the exact current supplicant file into a mode-0700 JFFS2-private transaction root, records only diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 2ed9d64fc..a3f183c7d 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -170,4 +170,11 @@ requires the exact schema-1 `bloom-wifi-profiles` upsert response and rejects a zero/out-of-range profile count, unknown fields, oversized output, non-zero exit, or timeout before reporting completion. +Derived Wi-Fi profile handles are non-secret but cannot authorize replacement by +themselves. The Shell reuses an existing handle only for the exact same SSID and +security class. New IDs use 104 bits from the no-follow system random device and +retry a collision within a fixed bound. Exhaustion, an unavailable profile +snapshot, and a full store fail closed, so the future scan UI cannot overwrite +an unrelated credential document. + Security-sensitive migrations and repair operations must be bounded, logged, reversible where possible, and validated before activation. diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index 0fd946244..5a3628a1d 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -892,7 +892,7 @@ "risk_tier": 1, "deployment_tier": "hot", "device_gate_required": true, - "last_audited_main": "0ef8e110c0afd197b3c63a400c6e02afd0fec8f9", + "last_audited_main": "49f0a8096bff9a9157e4ce176201d2f532bb74dd", "evidence": [ "issue #544 tracks NET-05 on current main after merged NET-04A-D and UI-05; the audit found that Bloom Shell still treats Wi-Fi as an inline toggle whose A and left/right handlers synchronously call bloom-network enable or disable instead of opening the bounded profile and scan UI", "the first host-safe slice adds an explicit local credential-free projection of at most 16 validated profile IDs, printable SSIDs, security classes, hidden flags, and one active marker; the private service never returns a PSK and the Shell model rejects unknown or credential-bearing fields", @@ -908,7 +908,9 @@ "GitHub PR #549 passed format in 40 seconds, 106 affected shell tests in 50 seconds, AddressSanitizer in 1 minute 10 seconds, the affected Bloom Shell ARM build in 1 minute 22 seconds, native tests in 1 minute 30 seconds, and scoped static analysis in 2 minutes 3 seconds; it merged by squash as 6c277d2300494ba8387accbf4bc28f4459640509", "GitHub PR #550 adds a host-only credential-form boundary that accepts only bounded schema-valid open or WPA2 inputs, masks password copy, invokes fixed bloomctl network profile upsert arguments with escaped JSON only on stdin, and explicitly clears password and temporary document bytes on every path; it has no rendered consumer and cannot scan, associate, restart Wi-Fi, forget a profile, or mutate the device", "GitHub PR #550 passed format in 23 seconds, the complete 58-test affected shell selector in 46 seconds, native tests in 1 minute 11 seconds, AddressSanitizer in 1 minute 17 seconds, the affected Bloom Shell ARM build in 1 minute 23 seconds, and scoped static analysis in 2 minutes 22 seconds; it merged by squash as 0ef8e110c0afd197b3c63a400c6e02afd0fec8f9", - "GitHub PR #551 evolves profile submission into a bounded nonblocking request: start writes stdin and clears the password before returning, poll accepts one exact schema-1 upsert acknowledgement, and malformed, oversized, failed, or timed-out children are reaped through the exact process group; no rendered consumer, association, restart, forget, or device mutation is added" + "GitHub PR #551 evolves profile submission into a bounded nonblocking request: start writes stdin and clears the password before returning, poll accepts one exact schema-1 upsert acknowledgement, and malformed, oversized, failed, or timed-out children are reaped through the exact process group; no rendered consumer, association, restart, forget, or device mutation is added", + "GitHub PR #551 passed format in 14 seconds, the complete 58-test affected shell selector in 50 seconds, AddressSanitizer in 1 minute 15 seconds, native tests in 1 minute 20 seconds, the affected Bloom Shell ARM build in 1 minute 23 seconds, and scoped static analysis in 2 minutes 18 seconds; it merged by squash as 49f0a8096bff9a9157e4ce176201d2f532bb74dd", + "the following host-only selection slice requires a current validated profile snapshot, reuses an exact saved SSID only when its security matches, creates a 104-bit high-entropy no-follow random handle for a new network, and rejects unsupported security, full stores, security mismatch, unavailable state, and bounded collision exhaustion before any form or request can be created" ] }, { diff --git a/src/bloomShell/bloom_shell_wifi_ui.c b/src/bloomShell/bloom_shell_wifi_ui.c index 115d801ce..c5a54056b 100644 --- a/src/bloomShell/bloom_shell_wifi_ui.c +++ b/src/bloomShell/bloom_shell_wifi_ui.c @@ -292,6 +292,114 @@ int bloom_shell_wifi_profile_form_init(BloomShellWifiProfileForm *form, return 0; } +static int profile_snapshot_valid(const BloomShellWifiUi *wifi) +{ + if (wifi == NULL || !wifi->ready || + wifi->profile_count > BLOOM_SHELL_WIFI_PROFILE_LIMIT) + return 0; + int active_count = 0; + for (size_t index = 0; index < wifi->profile_count; ++index) { + const BloomShellWifiProfile *profile = &wifi->profiles[index]; + if (!safe_id_string(profile->id) || !printable_string(profile->ssid, 32) || + (profile->security != BLOOM_SHELL_WIFI_SECURITY_OPEN && + profile->security != BLOOM_SHELL_WIFI_SECURITY_WPA2_PSK) || + (profile->hidden != 0 && profile->hidden != 1) || + (profile->hidden && profile->security == BLOOM_SHELL_WIFI_SECURITY_OPEN) || + (profile->active != 0 && profile->active != 1)) + return 0; + for (size_t prior = 0; prior < index; ++prior) + if (strcmp(wifi->profiles[prior].id, profile->id) == 0 || + strcmp(wifi->profiles[prior].ssid, profile->ssid) == 0) + return 0; + active_count += profile->active; + } + return active_count <= 1; +} + +static int random_profile_id(const BloomShellWifiUi *wifi, char profile_id[33]) +{ + int descriptor = open("/dev/urandom", O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + struct stat metadata; + if (descriptor < 0 || fstat(descriptor, &metadata) != 0 || !S_ISCHR(metadata.st_mode)) { + if (descriptor >= 0) + close(descriptor); + return -1; + } + static const char hex[] = "0123456789abcdef"; + unsigned char entropy[13]; + int result = -1; + for (int attempt = 0; attempt < 4 && result != 0; ++attempt) { + size_t length = 0; + while (length < sizeof(entropy)) { + ssize_t count = read(descriptor, entropy + length, sizeof(entropy) - length); + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) + break; + length += (size_t)count; + } + if (length != sizeof(entropy)) + break; + memcpy(profile_id, "wifi-", 5); + for (size_t index = 0; index < sizeof(entropy); ++index) { + profile_id[5 + index * 2] = hex[entropy[index] >> 4]; + profile_id[6 + index * 2] = hex[entropy[index] & 0x0f]; + } + profile_id[31] = '\0'; + result = 0; + for (size_t index = 0; index < wifi->profile_count; ++index) + if (strcmp(wifi->profiles[index].id, profile_id) == 0) { + result = -1; + break; + } + } + secure_clear(entropy, sizeof(entropy)); + close(descriptor); + return result; +} + +int bloom_shell_wifi_profile_form_from_network(const BloomShellWifiUi *wifi, + size_t network_index, + BloomShellWifiProfileForm *form) +{ + if (form == NULL) + return -1; + if (!profile_snapshot_valid(wifi) || network_index >= wifi->network_count || + wifi->network_count > BLOOM_SHELL_WIFI_NETWORK_LIMIT) { + bloom_shell_wifi_profile_form_clear(form); + return -1; + } + const BloomShellWifiNetwork *network = &wifi->networks[network_index]; + if (!printable_string(network->ssid, 32) || + (network->security != BLOOM_SHELL_WIFI_SECURITY_OPEN && + network->security != BLOOM_SHELL_WIFI_SECURITY_WPA2_PSK)) { + bloom_shell_wifi_profile_form_clear(form); + return -1; + } + for (size_t index = 0; index < wifi->profile_count; ++index) { + const BloomShellWifiProfile *profile = &wifi->profiles[index]; + if (strcmp(profile->ssid, network->ssid) != 0) + continue; + if (profile->security != network->security) { + bloom_shell_wifi_profile_form_clear(form); + return -1; + } + return bloom_shell_wifi_profile_form_init( + form, profile->id, network->ssid, network->security, profile->hidden); + } + if (wifi->profile_count >= BLOOM_SHELL_WIFI_PROFILE_LIMIT) { + bloom_shell_wifi_profile_form_clear(form); + return -1; + } + char profile_id[33]; + if (random_profile_id(wifi, profile_id) != 0) { + bloom_shell_wifi_profile_form_clear(form); + return -1; + } + return bloom_shell_wifi_profile_form_init(form, profile_id, network->ssid, + network->security, 0); +} + int bloom_shell_wifi_profile_form_move(BloomShellWifiProfileForm *form, int horizontal, int vertical) { diff --git a/src/bloomShell/bloom_shell_wifi_ui.h b/src/bloomShell/bloom_shell_wifi_ui.h index 4f9a5bfcf..339ec6742 100644 --- a/src/bloomShell/bloom_shell_wifi_ui.h +++ b/src/bloomShell/bloom_shell_wifi_ui.h @@ -111,6 +111,9 @@ int bloom_shell_wifi_profiles_load(const char *bloomctl_path, BloomShellWifiUi * int bloom_shell_wifi_profile_form_init(BloomShellWifiProfileForm *form, const char *profile_id, const char *ssid, BloomShellWifiSecurity security, int hidden); +int bloom_shell_wifi_profile_form_from_network(const BloomShellWifiUi *wifi, + size_t network_index, + BloomShellWifiProfileForm *form); int bloom_shell_wifi_profile_form_move(BloomShellWifiProfileForm *form, int horizontal, int vertical); void bloom_shell_wifi_profile_form_cycle_mode(BloomShellWifiProfileForm *form); diff --git a/test/test_bloom_shell_wifi_ui.cpp b/test/test_bloom_shell_wifi_ui.cpp index 107bd2b38..e661b64da 100644 --- a/test/test_bloom_shell_wifi_ui.cpp +++ b/test/test_bloom_shell_wifi_ui.cpp @@ -101,6 +101,69 @@ TEST(BloomShellWifiUi, EditsOnlyMaskedBoundedWifiCredentials) bloom_shell_wifi_profile_form_clear(&form); } +TEST(BloomShellWifiUi, MapsScanResultsToCollisionSafeProfileHandles) +{ + BloomShellWifiUi wifi{}; + wifi.ready = 1; + wifi.network_count = 2; + snprintf(wifi.networks[0].ssid, sizeof(wifi.networks[0].ssid), "%s", "Fixture WPA2"); + wifi.networks[0].security = BLOOM_SHELL_WIFI_SECURITY_WPA2_PSK; + snprintf(wifi.networks[1].ssid, sizeof(wifi.networks[1].ssid), "%s", "Saved Home"); + wifi.networks[1].security = BLOOM_SHELL_WIFI_SECURITY_WPA2_PSK; + wifi.profile_count = 1; + snprintf(wifi.profiles[0].id, sizeof(wifi.profiles[0].id), "%s", "saved-home"); + snprintf(wifi.profiles[0].ssid, sizeof(wifi.profiles[0].ssid), "%s", "Saved Home"); + wifi.profiles[0].security = BLOOM_SHELL_WIFI_SECURITY_WPA2_PSK; + wifi.profiles[0].hidden = 1; + + BloomShellWifiProfileForm form{}; + ASSERT_EQ(0, bloom_shell_wifi_profile_form_from_network(&wifi, 0, &form)); + std::string generated_id(form.profile_id); + ASSERT_EQ(31U, generated_id.size()); + EXPECT_EQ(0U, generated_id.find("wifi-")); + EXPECT_EQ(std::string::npos, + generated_id.substr(5).find_first_not_of("0123456789abcdef")); + EXPECT_STREQ("Fixture WPA2", form.ssid); + EXPECT_FALSE(form.hidden); + ASSERT_EQ(0, bloom_shell_wifi_profile_form_from_network(&wifi, 1, &form)); + EXPECT_STREQ("saved-home", form.profile_id); + EXPECT_TRUE(form.hidden); + + wifi.networks[1].security = BLOOM_SHELL_WIFI_SECURITY_OPEN; + EXPECT_NE(0, bloom_shell_wifi_profile_form_from_network(&wifi, 1, &form)); + EXPECT_STREQ("", form.profile_id); + wifi.networks[1].security = BLOOM_SHELL_WIFI_SECURITY_WPA2_PSK; + wifi.profile_count = 2; + snprintf(wifi.profiles[1].id, sizeof(wifi.profiles[1].id), "%s", + "wifi-00000000000000000000000000"); + snprintf(wifi.profiles[1].ssid, sizeof(wifi.profiles[1].ssid), "%s", "Collision"); + wifi.profiles[1].security = BLOOM_SHELL_WIFI_SECURITY_OPEN; + ASSERT_EQ(0, bloom_shell_wifi_profile_form_from_network(&wifi, 0, &form)); + EXPECT_STRNE(wifi.profiles[1].id, form.profile_id); + + wifi.networks[0].security = BLOOM_SHELL_WIFI_SECURITY_UNSUPPORTED; + EXPECT_NE(0, bloom_shell_wifi_profile_form_from_network(&wifi, 0, &form)); + wifi.networks[0].security = BLOOM_SHELL_WIFI_SECURITY_WPA2_PSK; + wifi.profile_count = BLOOM_SHELL_WIFI_PROFILE_LIMIT; + EXPECT_NE(0, bloom_shell_wifi_profile_form_from_network(&wifi, 0, &form)); + wifi.profile_count = 2; + snprintf(wifi.profiles[1].id, sizeof(wifi.profiles[1].id), "%s", "saved-home"); + EXPECT_NE(0, bloom_shell_wifi_profile_form_from_network(&wifi, 0, &form)); + EXPECT_STREQ("", form.profile_id); + snprintf(wifi.profiles[1].id, sizeof(wifi.profiles[1].id), "%s", "other-profile"); + snprintf(wifi.profiles[1].ssid, sizeof(wifi.profiles[1].ssid), "%s", "Saved Home"); + EXPECT_NE(0, bloom_shell_wifi_profile_form_from_network(&wifi, 0, &form)); + snprintf(wifi.profiles[1].ssid, sizeof(wifi.profiles[1].ssid), "%s", "Collision"); + wifi.profiles[0].active = 2; + EXPECT_NE(0, bloom_shell_wifi_profile_form_from_network(&wifi, 0, &form)); + wifi.profiles[0].active = 0; + wifi.profiles[0].security = BLOOM_SHELL_WIFI_SECURITY_OPEN; + EXPECT_NE(0, bloom_shell_wifi_profile_form_from_network(&wifi, 0, &form)); + wifi.profiles[0].security = BLOOM_SHELL_WIFI_SECURITY_WPA2_PSK; + wifi.ready = 0; + EXPECT_NE(0, bloom_shell_wifi_profile_form_from_network(&wifi, 1, &form)); +} + TEST(BloomShellWifiUi, SubmitsFixedProfileArgumentsViaStdinAndClearsSecret) { auto directory = std::filesystem::temp_directory_path() / From 6e211719c5ee8c21bc177a44bce30eeb4c3fbac9 Mon Sep 17 00:00:00 2001 From: boburning Date: Sun, 30 Aug 2026 02:01:52 -0400 Subject: [PATCH 2/2] docs(program): record NET-05 selection PR --- docs/program/BloomOS_Work_Program_v3.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index 5a3628a1d..c0d01a364 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -910,7 +910,7 @@ "GitHub PR #550 passed format in 23 seconds, the complete 58-test affected shell selector in 46 seconds, native tests in 1 minute 11 seconds, AddressSanitizer in 1 minute 17 seconds, the affected Bloom Shell ARM build in 1 minute 23 seconds, and scoped static analysis in 2 minutes 22 seconds; it merged by squash as 0ef8e110c0afd197b3c63a400c6e02afd0fec8f9", "GitHub PR #551 evolves profile submission into a bounded nonblocking request: start writes stdin and clears the password before returning, poll accepts one exact schema-1 upsert acknowledgement, and malformed, oversized, failed, or timed-out children are reaped through the exact process group; no rendered consumer, association, restart, forget, or device mutation is added", "GitHub PR #551 passed format in 14 seconds, the complete 58-test affected shell selector in 50 seconds, AddressSanitizer in 1 minute 15 seconds, native tests in 1 minute 20 seconds, the affected Bloom Shell ARM build in 1 minute 23 seconds, and scoped static analysis in 2 minutes 18 seconds; it merged by squash as 49f0a8096bff9a9157e4ce176201d2f532bb74dd", - "the following host-only selection slice requires a current validated profile snapshot, reuses an exact saved SSID only when its security matches, creates a 104-bit high-entropy no-follow random handle for a new network, and rejects unsupported security, full stores, security mismatch, unavailable state, and bounded collision exhaustion before any form or request can be created" + "GitHub PR #552 adds a host-only selection boundary that requires a current validated profile snapshot, reuses an exact saved SSID only when its security matches, creates a 104-bit high-entropy no-follow random handle for a new network, and rejects malformed state, unsupported security, full stores, security mismatch, unavailable state, and bounded collision exhaustion before any form or request can be created" ] }, {