diff --git a/build/dependencies.lock b/build/dependencies.lock index aaafa1598..a1e7632c5 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -184,10 +184,10 @@ legacy_channels = ["development"] [legacy_inventory] manifest = "build/legacy-manifest.json" schema = 1 -component_count = 330 -file_count = 2129 -byte_count = 314637632 -source_build_components = 226 +component_count = 331 +file_count = 2130 +byte_count = 314646675 +source_build_components = 227 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index f7d72a69c..45b03a183 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -990,6 +990,19 @@ "build_recipe": "Makefile", "resolution": "source-build" }, + { + "id": "runtime-tmp-update-bin-bloom-wifi-profiles", + "kind": "runtime", + "path": "static/build/.tmp_update/bin/bloom-wifi-profiles", + "file_count": 1, + "byte_count": 9043, + "tree_sha256": "269e3600718c2d1c88c3083c9a751ad572d2de9e5e12d50aa1883a333be74c13", + "source": "https://github.com/boburning/BloomOS", + "source_revision": "release-commit", + "license": "GPL-3.0-only", + "build_recipe": "Makefile", + "resolution": "source-build" + }, { "id": "runtime-tmp-update-bin-bloomctl", "kind": "runtime", diff --git a/build/wifi-profile-store.schema.json b/build/wifi-profile-store.schema.json new file mode 100644 index 000000000..11438fd25 --- /dev/null +++ b/build/wifi-profile-store.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bloomos.local/schemas/wifi-profile-store-v1.json", + "title": "BloomOS Wi-Fi profile store", + "type": "object", + "additionalProperties": false, + "required": ["schema", "profiles"], + "properties": { + "schema": {"const": 1}, + "profiles": { + "type": "array", + "maxItems": 16, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["id", "ssid", "security", "hidden", "psk"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,31}$"}, + "ssid": {"type": "string", "pattern": "^[ -~]{1,32}$"}, + "security": {"const": "wpa2-psk"}, + "hidden": {"type": "boolean"}, + "psk": { + "type": "string", + "anyOf": [ + {"pattern": "^[ -~]{8,63}$"}, + {"pattern": "^[0-9A-Fa-f]{64}$"} + ] + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "ssid", "security", "hidden"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,31}$"}, + "ssid": {"type": "string", "pattern": "^[ -~]{1,32}$"}, + "security": {"const": "open"}, + "hidden": {"const": false} + } + } + ] + } + } + } +} diff --git a/docs/1.0_TEST_MATRIX.md b/docs/1.0_TEST_MATRIX.md index 751869207..a516f7175 100644 --- a/docs/1.0_TEST_MATRIX.md +++ b/docs/1.0_TEST_MATRIX.md @@ -47,6 +47,7 @@ shoulder button. | Settings | One sectioned surface; inline values; capability filtering; one-layer details; hidden Developer section; optional overlapping L1/R1 list paging | | Quick Settings | START from core surfaces; immediate brightness/volume/mute; capability-gated Wi-Fi; readable battery; B/START close | | 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; schema/unknown-field/count/size enforcement; WPA2-PSK, explicit open, and manual hidden WPA2; WEP and hidden open denial; unique IDs/SSIDs; redacted status; mode-0600 atomic single-profile supplicant projection; symlink, occupied-slot, live-lock, invalid-profile, and prior-byte preservation | | 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 387d6c87e..9abdcd88b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -273,6 +273,25 @@ read, a serialized compatibility import preserves the existing v1 marker and never consults the markers again while canonical fields are valid. The old reader remains only as that fail-closed migration path. +`bloom-wifi-profiles` is the separate private Wi-Fi-secret authority. Its +schema-1 store lives below the internal JFFS2 `/appconfigs` boundary, is +limited to 16 profiles and 16 KiB, and accepts a complete replacement only on +stdin. Profile IDs are non-secret stable handles; status reports only counts +and managed/unmanaged state. SSIDs and credentials are never returned. The +initial security vocabulary is WPA2-PSK plus an explicitly created open +profile. A hidden profile must be WPA2-PSK. WEP, unknown security modes, +duplicate IDs or SSIDs, control characters, links, extra fields, and unsafe +paths fail closed. + +Projection is an explicit second operation: a caller names one non-secret +profile ID, the service validates the private store again, writes one +`update_config=0` supplicant network to a mode-0600 same-directory candidate, +synchronizes it, and atomically replaces `/appconfigs/wpa_supplicant.conf`. +The previous runtime configuration remains authoritative until that rename. +The service never scans, associates, changes the Wi-Fi preference, or restarts +the radio; those behaviors remain with later NET-04 API work and the existing +`bloom-network`/`bloom-wifi` owners. + Listener reconciliation is event-driven at existing fixed boundaries: boot, Wi-Fi reconciliation, Wi-Fi preference changes, remote-key publication, and each listener preference request. A request publishes its desired boolean and diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 664390061..2fbf1e8d2 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -263,6 +263,35 @@ host also refuses to confirm `ssh-disable`; that sole-route operation must be explicitly rolled back or allowed to expire under the watchdog. Transaction state cannot create either developer marker or alter signed update identity. +## Wi-Fi profile-store fixtures + +NET-03 separates private station profiles from non-secret Bloom settings. Use +only synthetic fixtures during host development. A complete schema-1 store is +received on stdin; credentials are never command arguments: + +```sh +bloom-wifi-profiles replace < synthetic-profiles.json +bloom-wifi-profiles status --json +bloom-wifi-profiles project fixture-profile-id +``` + +`replace` is bounded to 16 profiles and 16 KiB. `project` accepts only the +non-secret stable ID, emits no SSID or credential, and writes a single managed +supplicant configuration through a mode-0600 same-directory candidate and +atomic rename. Neither operation starts/stops Wi-Fi or associates to a +network. Run the focused gate with the pinned shell-test image: + +```sh +./tools/run-shell-tests.sh "$PWD" "$PINNED_SHELL_IMAGE" 1 \ + test/shell/wifi_profiles.bats +``` + +Do not copy a live `/appconfigs/wpa_supplicant.conf` into the repository, +fixture output, shell history, logs, screenshots, or evidence. Device preflight +may inspect only filesystem type, mode, byte count, structural counts, and +redacted health. A future NET-04 connect/forget surface must retain stdin or an +equivalent fixed private input boundary and the existing rollback rules. + An activated update is not considered successful merely because the installer or MainUI started. The runtime records each unconfirmed boot in durable `.bloom/update` state. After device checks pass, developers can promote the diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 79c9fc42f..c7c88a648 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -84,4 +84,16 @@ offline indexes may remain on the SD card. Credential bootstrap accepts secrets only through hidden local prompts and pinned encrypted transport, never through process arguments. +Wi-Fi profiles follow that same boundary. `bloom-wifi-profiles replace` reads +one bounded schema-1 document from stdin and publishes it mode 0600 below a +mode-0700 `/appconfigs/bloom-network` root. It exposes no export or raw-list +command. Public status contains only the profile count and whether the current +supplicant file carries Bloom's managed marker. Projection accepts only a +non-secret profile ID, revalidates the store, and atomically publishes a +single-network mode-0600 configuration with `update_config=0`, preventing +`wpa_supplicant` from becoming a shadow writer. WEP, implicit open-network +selection, hidden open profiles, unsafe links, duplicate identities, control +characters, oversized stores, occupied atomic slots, and unknown fields are +rejected without replacing the prior store or runtime configuration. + 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 732525bff..9e7e6aaec 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -804,16 +804,24 @@ "id": "NET-03", "track": "Network", "title": "Bloom Wi-Fi secret profile store and runtime projection", - "status": "planned", + "status": "merge_ready", "dependencies": [ "NET-01" ], - "github_prs": [], + "github_prs": [ + 485 + ], "risk_tier": 3, "deployment_tier": "warm", "device_gate_required": true, - "last_audited_main": "3a7e78290285806e1ce3eb0c126887644ff665f2", - "evidence": [] + "last_audited_main": "5852e08bb31b9d539999fec08992dfac19b0d143", + "evidence": [ + "issue #484 tracks the main-based NET-03 slice after NET-02 merged as 5852e08bb31b9d539999fec08992dfac19b0d143; current main has no canonical Wi-Fi profile owner and still consumes the inherited single /appconfigs/wpa_supplicant.conf directly", + "Mini Plus read-only preflight confirmed /appconfigs is writable JFFS2, /mnt/SDCARD is VFAT, the current supplicant configuration is a regular non-symlink file, and only structural counts and required headers were inspected without reading or exporting SSIDs or credentials", + "PR #485 publishes one bounded schema-1 JFFS2 profile authority with stdin-only replacement, redacted status, WPA2-PSK, explicit open, manual hidden WPA2, WEP and hidden-open denial, unique IDs and SSIDs, mode-0600 atomic single-profile projection, and no scan, association, preference, radio, or listener mutation; focused adversarial validation passed 13/13 after manifest, provenance, transfer-policy, and exact 314646675-byte dependency preflight", + "exact head bf2280480d44087f368805ce946a72f902269e53 passed risk-scoped shell workflow 33170790616 in 1m33s after the focused legacy manifest and provenance suite passed 13/13 locally in 271 seconds; the prior failed run 33170145418 exposed only two stale exact inventory assertions, corrected on the same PR branch before any device fixture", + "the exact 9043-byte helper with SHA-256 b812644ae75139dd92df6e24b31904b67433a58d9db2d8d07f06210be159de2f was transferred through strict key-only SSH and exercised only under /tmp/bloom-net03-fixture-bf228048 with synthetic profiles; the root was mode 0700, store and projected config were mode 0600, exactly one managed network was emitted, live /appconfigs/wpa_supplicant.conf retained SHA-256 d7fbadd510e76c423638513337620e17944d51b4505ff314354971d97114bdef, temporary state was removed, and aggregate device health remained green without a radio, listener, update, reboot, or network-transaction mutation" + ] }, { "id": "NET-04", diff --git a/static/build/.tmp_update/bin/bloom-wifi-profiles b/static/build/.tmp_update/bin/bloom-wifi-profiles new file mode 100755 index 000000000..b3a6bc618 --- /dev/null +++ b/static/build/.tmp_update/bin/bloom-wifi-profiles @@ -0,0 +1,210 @@ +#!/bin/sh +set -eu + +ROOT=${BLOOM_WIFI_PROFILE_STORE_ROOT:-/appconfigs/bloom-network} +STORE="$ROOT/profiles.json" +LOCK="$ROOT/.lock" +CONFIG=${BLOOM_WIFI_PROFILE_CONFIG:-/appconfigs/wpa_supplicant.conf} +JQ=${BLOOM_WIFI_PROFILE_JQ_BIN:-/mnt/SDCARD/.tmp_update/bin/jq} +DD=${BLOOM_WIFI_PROFILE_DD_BIN:-dd} +SYNC=${BLOOM_WIFI_PROFILE_SYNC_BIN:-/bin/sync} +MAX_STORE_BYTES=16384 +MAX_PROFILES=16 +LOCKED=0 +TEMP_STORE= +TEMP_CONFIG= + +fail() { printf 'bloom-wifi-profiles: %s\n' "$*" >&2; exit 1; } + +cleanup() { + [ -z "$TEMP_STORE" ] || rm -f "$TEMP_STORE" 2>/dev/null || true + [ -z "$TEMP_CONFIG" ] || rm -f "$TEMP_CONFIG" 2>/dev/null || true + if [ "$LOCKED" -eq 1 ]; then + rm -f "$LOCK/owner" 2>/dev/null || true + rmdir "$LOCK" 2>/dev/null || true + LOCKED=0 + fi +} +trap cleanup EXIT +trap 'exit 1' HUP INT TERM + +safe_profile_id() { + case "$1" in + ''|*[!a-z0-9._-]*|.*|-*) return 1 ;; + esac + [ "${#1}" -le 32 ] +} + +ensure_root() { + parent=${ROOT%/*} + [ -n "$parent" ] || parent=/ + [ -d "$parent" ] && [ ! -L "$parent" ] || fail "profile-store parent is unsafe" + if [ -e "$ROOT" ] || [ -L "$ROOT" ]; then + [ -d "$ROOT" ] && [ ! -L "$ROOT" ] || fail "profile-store root is unsafe" + else + mkdir "$ROOT" || fail "cannot create profile-store root" + fi + chmod 700 "$ROOT" || fail "cannot protect profile-store root" +} + +acquire_lock() { + if ! mkdir "$LOCK" 2>/dev/null; then + [ -d "$LOCK" ] && [ ! -L "$LOCK" ] || fail "profile store is busy" + owner=$(cat "$LOCK/owner" 2>/dev/null || true) + case "$owner" in + ''|*[!0-9]*) owner_live=false ;; + *) if kill -0 "$owner" 2>/dev/null; then owner_live=true; else owner_live=false; fi ;; + esac + [ "$owner_live" = false ] || fail "profile store is busy" + rm -f "$LOCK/owner" 2>/dev/null || fail "cannot recover stale profile-store lock" + rmdir "$LOCK" 2>/dev/null || fail "cannot recover stale profile-store lock" + mkdir "$LOCK" 2>/dev/null || fail "profile store is busy" + fi + [ ! -L "$LOCK" ] || fail "profile store is busy" + LOCKED=1 + (umask 077; printf '%s\n' "$$" >"$LOCK/owner") || fail "cannot record profile-store lock" +} + +validate_store() { + candidate=$1 + [ -f "$candidate" ] && [ ! -L "$candidate" ] || return 1 + "$JQ" -e --argjson max_profiles "$MAX_PROFILES" ' + type == "object" and + (keys | sort) == ["profiles", "schema"] and + .schema == 1 and + (.profiles | type == "array") and + (.profiles | length) <= $max_profiles and + (all(.profiles[]; + type == "object" and + (.id | type == "string" and test("^[a-z0-9][a-z0-9._-]{0,31}$")) and + (.ssid | type == "string" and test("^[ -~]{1,32}$")) and + (.security == "wpa2-psk" or .security == "open") and + (.hidden | type == "boolean") and + (if .security == "wpa2-psk" then + (keys | sort) == ["hidden", "id", "psk", "security", "ssid"] and + (.psk | type == "string" and + (test("^[ -~]{8,63}$") or test("^[0-9A-Fa-f]{64}$"))) + else + (keys | sort) == ["hidden", "id", "security", "ssid"] and + .hidden == false + end) + )) and + ([.profiles[].id] | unique | length) == (.profiles | length) and + ([.profiles[].ssid] | unique | length) == (.profiles | length) + ' "$candidate" >/dev/null 2>&1 +} + +sync_filesystem() { + [ -x "$SYNC" ] || fail "durability helper is unavailable" + "$SYNC" || fail "cannot synchronize profile state" +} + +replace_store() { + ensure_root + acquire_lock + if [ -e "$STORE" ] || [ -L "$STORE" ]; then + [ -f "$STORE" ] && [ ! -L "$STORE" ] || fail "profile store is unsafe" + fi + candidate_store="$STORE.incoming" + [ ! -e "$candidate_store" ] && [ ! -L "$candidate_store" ] || fail "profile-store replacement slot is occupied" + TEMP_STORE="$candidate_store" + (umask 077; "$DD" of="$TEMP_STORE" bs=$((MAX_STORE_BYTES + 1)) count=1 2>/dev/null) || fail "cannot receive profile store" + bytes=$(wc -c <"$TEMP_STORE" | tr -d ' ') + case "$bytes" in ''|*[!0-9]*) fail "profile-store size is invalid" ;; esac + [ "$bytes" -gt 0 ] && [ "$bytes" -le "$MAX_STORE_BYTES" ] || fail "profile store exceeds the bounded size" + validate_store "$TEMP_STORE" || fail "profile store is invalid" + chmod 600 "$TEMP_STORE" || fail "cannot protect profile store" + sync_filesystem + mv -f "$TEMP_STORE" "$STORE" || fail "cannot atomically publish profile store" + TEMP_STORE= + sync_filesystem + count=$("$JQ" -r '.profiles | length' "$STORE") || fail "cannot read published profile store" + printf '{"schema":1,"service":"bloom-wifi-profiles","operation":"replace","published":true,"profile_count":%s}\n' "$count" +} + +store_status() { + if [ ! -e "$ROOT" ] && [ ! -L "$ROOT" ]; then + printf '{"schema":1,"service":"bloom-wifi-profiles","initialized":false,"profile_count":0,"projection_managed":false}\n' + return 0 + fi + [ -d "$ROOT" ] && [ ! -L "$ROOT" ] || fail "profile-store root is unsafe" + if [ ! -e "$STORE" ] && [ ! -L "$STORE" ]; then + printf '{"schema":1,"service":"bloom-wifi-profiles","initialized":false,"profile_count":0,"projection_managed":false}\n' + return 0 + fi + validate_store "$STORE" || fail "profile store is invalid" + count=$("$JQ" -r '.profiles | length' "$STORE") || fail "cannot read profile store" + managed=false + if [ -f "$CONFIG" ] && [ ! -L "$CONFIG" ] && grep -Fqx '# managed-by=BloomOS wifi-profile-store-v1' "$CONFIG"; then + managed=true + fi + printf '{"schema":1,"service":"bloom-wifi-profiles","initialized":true,"profile_count":%s,"projection_managed":%s}\n' "$count" "$managed" +} + +project_profile() { + profile_id=$1 + safe_profile_id "$profile_id" || fail "profile id is invalid" + ensure_root + acquire_lock + validate_store "$STORE" || fail "profile store is invalid" + matches=$("$JQ" -r --arg id "$profile_id" '[.profiles[] | select(.id == $id)] | length' "$STORE") || fail "cannot read profile store" + [ "$matches" = 1 ] || fail "profile is unavailable" + + config_parent=${CONFIG%/*} + [ -n "$config_parent" ] || config_parent=/ + [ -d "$config_parent" ] && [ ! -L "$config_parent" ] || fail "supplicant parent is unsafe" + if [ -e "$CONFIG" ] || [ -L "$CONFIG" ]; then + [ -f "$CONFIG" ] && [ ! -L "$CONFIG" ] || fail "supplicant configuration is unsafe" + fi + candidate_config="$CONFIG.bloom-incoming" + [ ! -e "$candidate_config" ] && [ ! -L "$candidate_config" ] || fail "supplicant replacement slot is occupied" + TEMP_CONFIG="$candidate_config" + + security=$("$JQ" -r --arg id "$profile_id" '.profiles[] | select(.id == $id) | .security' "$STORE") || fail "cannot read profile security" + hidden=$("$JQ" -r --arg id "$profile_id" '.profiles[] | select(.id == $id) | .hidden' "$STORE") || fail "cannot read hidden-network state" + ssid_quoted=$("$JQ" -r --arg id "$profile_id" '.profiles[] | select(.id == $id) | .ssid | @json' "$STORE") || fail "cannot read profile SSID" + + umask 077 + { + printf '%s\n' 'ctrl_interface=/var/run/wpa_supplicant' + printf '%s\n' 'update_config=0' + printf '%s\n' '# managed-by=BloomOS wifi-profile-store-v1' + printf '%s\n' 'network={' + printf '\tssid=%s\n' "$ssid_quoted" + [ "$hidden" = true ] && printf '\tscan_ssid=1\n' + case "$security" in + wpa2-psk) + psk=$("$JQ" -r --arg id "$profile_id" '.profiles[] | select(.id == $id) | .psk' "$STORE") || fail "cannot read profile credential" + case "$psk" in + *[!0-9A-Fa-f]*) raw_psk=false ;; + *) [ "${#psk}" -eq 64 ] && raw_psk=true || raw_psk=false ;; + esac + printf '\tkey_mgmt=WPA-PSK\n' + if [ "$raw_psk" = true ]; then + printf '\tpsk=%s\n' "$psk" + else + psk_quoted=$("$JQ" -r --arg id "$profile_id" '.profiles[] | select(.id == $id) | .psk | @json' "$STORE") || fail "cannot read profile credential" + printf '\tpsk=%s\n' "$psk_quoted" + fi + ;; + open) + printf '\tkey_mgmt=NONE\n' + ;; + *) fail "profile security is unsupported" ;; + esac + printf '%s\n' '}' + } >"$TEMP_CONFIG" || fail "cannot write supplicant candidate" + chmod 600 "$TEMP_CONFIG" || fail "cannot protect supplicant candidate" + sync_filesystem + mv -f "$TEMP_CONFIG" "$CONFIG" || fail "cannot atomically publish supplicant configuration" + TEMP_CONFIG= + sync_filesystem + printf '{"schema":1,"service":"bloom-wifi-profiles","operation":"project","projected":true,"security":"%s","hidden":%s}\n' "$security" "$hidden" +} + +case "$#:${1:-}" in + 1:replace) replace_store ;; + 2:status) [ "$2" = --json ] || fail "invalid arguments"; store_status ;; + 2:project) project_profile "$2" ;; + *) fail "invalid arguments" ;; +esac diff --git a/test/shell/legacy_manifest.bats b/test/shell/legacy_manifest.bats index af4b33949..2b3cab045 100644 --- a/test/shell/legacy_manifest.bats +++ b/test/shell/legacy_manifest.bats @@ -6,7 +6,7 @@ --manifest /workspace/build/legacy-manifest.json [ "$status" -eq 0 ] - [[ "$output" == "legacy manifest validate: 330 components" ]] + [[ "$output" == "legacy manifest validate: 331 components" ]] [ ! -e "/workspace/static/packages/RApp/SNK - Neo Geo (GnGeo)" ] [ ! -e "/workspace/static/packages/RApp/PICO-8 (PICO-8 standalone)" ] [ ! -e "/workspace/static/packages/RApp/SCUMM (ScummVM standalone)" ] @@ -104,7 +104,7 @@ import sys components = json.load(open(sys.argv[1], encoding="utf-8"))["components"] runtime = [item for item in components if item["id"].startswith("runtime-")] bloom = [item for item in runtime if item["source"] == "https://github.com/boburning/BloomOS"] -assert len(bloom) == 53 +assert len(bloom) == 54 assert all(item["resolution"] == "source-build" for item in bloom) assert all(item["source_revision"] == "release-commit" for item in bloom) PY diff --git a/test/shell/wifi_profiles.bats b/test/shell/wifi_profiles.bats new file mode 100644 index 000000000..fe917fbe2 --- /dev/null +++ b/test/shell/wifi_profiles.bats @@ -0,0 +1,285 @@ +#!/usr/bin/env bats + +setup() { + export SERVICE=/workspace/static/build/.tmp_update/bin/bloom-wifi-profiles + export APP_CONFIGS="$BATS_TEST_TMPDIR/appconfigs" + export BLOOM_WIFI_PROFILE_STORE_ROOT="$APP_CONFIGS/bloom-network" + export BLOOM_WIFI_PROFILE_CONFIG="$APP_CONFIGS/wpa_supplicant.conf" + export BLOOM_WIFI_PROFILE_JQ_BIN=/usr/bin/jq + export BLOOM_WIFI_PROFILE_DD_BIN=/bin/dd + export BLOOM_WIFI_PROFILE_SYNC_BIN="$BATS_TEST_TMPDIR/sync" + export INPUT="$BATS_TEST_TMPDIR/profiles.json" + export SYNC_LOG="$BATS_TEST_TMPDIR/sync.log" + mkdir -p "$APP_CONFIGS" + cat >"$BLOOM_WIFI_PROFILE_SYNC_BIN" <<'SH' +#!/bin/sh +printf '%s\n' sync >>"$SYNC_LOG" +SH + chmod +x "$BLOOM_WIFI_PROFILE_SYNC_BIN" +} + +write_valid_store() { + cat >"$INPUT" <<'JSON' +{ + "schema": 1, + "profiles": [ + { + "id": "fixture-wpa2", + "ssid": "Fixture WPA2", + "security": "wpa2-psk", + "hidden": false, + "psk": "test-only-passphrase" + }, + { + "id": "fixture-open", + "ssid": "Fixture Open", + "security": "open", + "hidden": false + } + ] +} +JSON +} + +replace_from_input() { + run bash -c '"$1" replace <"$2"' _ "$SERVICE" "$INPUT" +} + +@test "replace publishes a bounded private store and status is redacted" { + write_valid_store + replace_from_input + [ "$status" -eq 0 ] + [ "$output" = '{"schema":1,"service":"bloom-wifi-profiles","operation":"replace","published":true,"profile_count":2}' ] + [ "$(stat -c %a "$BLOOM_WIFI_PROFILE_STORE_ROOT")" = 700 ] + [ "$(stat -c %a "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json")" = 600 ] + [ "$(grep -Fc sync "$SYNC_LOG")" -eq 2 ] + + run "$SERVICE" status --json + [ "$status" -eq 0 ] + [ "$output" = '{"schema":1,"service":"bloom-wifi-profiles","initialized":true,"profile_count":2,"projection_managed":false}' ] + [[ "$output" != *'Fixture WPA2'* ]] + [[ "$output" != *'test-only-passphrase'* ]] +} + +@test "WPA2 projection atomically publishes one managed supplicant network" { + write_valid_store + replace_from_input + : >"$SYNC_LOG" + printf '%s\n' 'previous-config' >"$BLOOM_WIFI_PROFILE_CONFIG" + + run "$SERVICE" project fixture-wpa2 + [ "$status" -eq 0 ] + [ "$output" = '{"schema":1,"service":"bloom-wifi-profiles","operation":"project","projected":true,"security":"wpa2-psk","hidden":false}' ] + [[ "$output" != *'Fixture WPA2'* ]] + [[ "$output" != *'test-only-passphrase'* ]] + [ "$(stat -c %a "$BLOOM_WIFI_PROFILE_CONFIG")" = 600 ] + grep -Fqx 'ctrl_interface=/var/run/wpa_supplicant' "$BLOOM_WIFI_PROFILE_CONFIG" + grep -Fqx 'update_config=0' "$BLOOM_WIFI_PROFILE_CONFIG" + grep -Fqx '# managed-by=BloomOS wifi-profile-store-v1' "$BLOOM_WIFI_PROFILE_CONFIG" + grep -Fqx $'\tssid="Fixture WPA2"' "$BLOOM_WIFI_PROFILE_CONFIG" + grep -Fqx $'\tkey_mgmt=WPA-PSK' "$BLOOM_WIFI_PROFILE_CONFIG" + grep -Fqx $'\tpsk="test-only-passphrase"' "$BLOOM_WIFI_PROFILE_CONFIG" + ! grep -Fqx $'\tscan_ssid=1' "$BLOOM_WIFI_PROFILE_CONFIG" + [ "$(grep -Fc 'network={' "$BLOOM_WIFI_PROFILE_CONFIG")" -eq 1 ] + [ "$(grep -Fc sync "$SYNC_LOG")" -eq 2 ] + + run "$SERVICE" status --json + [ "$status" -eq 0 ] + [[ "$output" == *'"projection_managed":true'* ]] +} + +@test "explicit open projection contains no credential and hidden open is rejected" { + write_valid_store + replace_from_input + run "$SERVICE" project fixture-open + [ "$status" -eq 0 ] + [[ "$output" == *'"security":"open"'* ]] + grep -Fqx $'\tkey_mgmt=NONE' "$BLOOM_WIFI_PROFILE_CONFIG" + ! grep -Fq 'psk=' "$BLOOM_WIFI_PROFILE_CONFIG" + ! grep -Fq 'scan_ssid=' "$BLOOM_WIFI_PROFILE_CONFIG" + + cat >"$INPUT" <<'JSON' +{"schema":1,"profiles":[{"id":"hidden-open","ssid":"Fixture Hidden Open","security":"open","hidden":true}]} +JSON + replace_from_input + [ "$status" -ne 0 ] + [[ "$output" == *'profile store is invalid'* ]] +} + +@test "manual hidden WPA2 and an exact raw PSK project deterministically" { + cat >"$INPUT" <<'JSON' +{"schema":1,"profiles":[{"id":"hidden-wpa2","ssid":"Fixture Hidden","security":"wpa2-psk","hidden":true,"psk":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}]} +JSON + replace_from_input + [ "$status" -eq 0 ] + run "$SERVICE" project hidden-wpa2 + [ "$status" -eq 0 ] + [[ "$output" == *'"hidden":true'* ]] + grep -Fqx $'\tscan_ssid=1' "$BLOOM_WIFI_PROFILE_CONFIG" + grep -Fqx $'\tpsk=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' "$BLOOM_WIFI_PROFILE_CONFIG" +} + +@test "WEP unknown security and extra keys are rejected without replacing prior state" { + write_valid_store + replace_from_input + before=$(sha256sum "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json" | awk '{print $1}') + + for invalid in \ + '{"schema":1,"profiles":[{"id":"wep","ssid":"Fixture","security":"wep","hidden":false,"psk":"test-only-passphrase"}]}' \ + '{"schema":1,"profiles":[{"id":"future","ssid":"Fixture","security":"future","hidden":false}]}' \ + '{"schema":1,"profiles":[],"unexpected":true}' \ + '{"schema":2,"profiles":[]}' + do + printf '%s\n' "$invalid" >"$INPUT" + replace_from_input + [ "$status" -ne 0 ] + [[ "$output" == *'profile store is invalid'* ]] + [ "$(sha256sum "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json" | awk '{print $1}')" = "$before" ] + done +} + +@test "duplicate ids SSIDs unsafe ids and malformed credentials are rejected" { + for invalid in \ + '{"schema":1,"profiles":[{"id":"same","ssid":"One","security":"open","hidden":false},{"id":"same","ssid":"Two","security":"open","hidden":false}]}' \ + '{"schema":1,"profiles":[{"id":"one","ssid":"Same","security":"open","hidden":false},{"id":"two","ssid":"Same","security":"open","hidden":false}]}' \ + '{"schema":1,"profiles":[{"id":"../escape","ssid":"Fixture","security":"open","hidden":false}]}' \ + '{"schema":1,"profiles":[{"id":"short","ssid":"Fixture","security":"wpa2-psk","hidden":false,"psk":"short"}]}' \ + '{"schema":1,"profiles":[{"id":"control","ssid":"Fixture\nNetwork","security":"open","hidden":false}]}' + do + printf '%s\n' "$invalid" >"$INPUT" + replace_from_input + [ "$status" -ne 0 ] + [[ "$output" == *'profile store is invalid'* ]] + done +} + +@test "profile count and serialized store bytes are bounded" { + jq -n '{schema:1,profiles:[range(0;17) as $n | {id:("fixture-"+($n|tostring)),ssid:("Fixture "+($n|tostring)),security:"open",hidden:false}]}' >"$INPUT" + replace_from_input + [ "$status" -ne 0 ] + [[ "$output" == *'profile store is invalid'* ]] + [ ! -e "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json" ] + + dd if=/dev/zero bs=17000 count=1 2>/dev/null | tr '\000' A >"$INPUT" + replace_from_input + [ "$status" -ne 0 ] + [[ "$output" == *'profile store exceeds the bounded size'* ]] + [ ! -e "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json" ] +} + +@test "unsafe profile and projection paths fail closed" { + write_valid_store + mkdir "$BATS_TEST_TMPDIR/elsewhere" + ln -s "$BATS_TEST_TMPDIR/elsewhere" "$BLOOM_WIFI_PROFILE_STORE_ROOT" + replace_from_input + [ "$status" -ne 0 ] + [[ "$output" == *'profile-store root is unsafe'* ]] + [ -z "$(find "$BATS_TEST_TMPDIR/elsewhere" -mindepth 1 -print -quit)" ] + + rm "$BLOOM_WIFI_PROFILE_STORE_ROOT" + mkdir "$BLOOM_WIFI_PROFILE_STORE_ROOT" + ln -s "$BATS_TEST_TMPDIR/missing-store" "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json" + replace_from_input + [ "$status" -ne 0 ] + [[ "$output" == *'profile store is unsafe'* ]] + [ -L "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json" ] + rm "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json" + rmdir "$BLOOM_WIFI_PROFILE_STORE_ROOT" + + replace_from_input + [ "$status" -eq 0 ] + printf '%s\n' 'preserve-me' >"$BATS_TEST_TMPDIR/target" + rm -f "$BLOOM_WIFI_PROFILE_CONFIG" + ln -s "$BATS_TEST_TMPDIR/target" "$BLOOM_WIFI_PROFILE_CONFIG" + run "$SERVICE" project fixture-wpa2 + [ "$status" -ne 0 ] + [[ "$output" == *'supplicant configuration is unsafe'* ]] + [ "$(cat "$BATS_TEST_TMPDIR/target")" = preserve-me ] + + rm "$BLOOM_WIFI_PROFILE_CONFIG" + ln -s "$BATS_TEST_TMPDIR/missing-config" "$BLOOM_WIFI_PROFILE_CONFIG" + run "$SERVICE" project fixture-wpa2 + [ "$status" -ne 0 ] + [[ "$output" == *'supplicant configuration is unsafe'* ]] + [ -L "$BLOOM_WIFI_PROFILE_CONFIG" ] +} + +@test "occupied atomic slots preserve the previous store and supplicant bytes" { + write_valid_store + replace_from_input + printf '%s\n' 'previous-config' >"$BLOOM_WIFI_PROFILE_CONFIG" + store_before=$(sha256sum "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json" | awk '{print $1}') + config_before=$(sha256sum "$BLOOM_WIFI_PROFILE_CONFIG" | awk '{print $1}') + + : >"$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json.incoming" + replace_from_input + [ "$status" -ne 0 ] + [ "$(sha256sum "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json" | awk '{print $1}')" = "$store_before" ] + rm "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json.incoming" + + : >"$BLOOM_WIFI_PROFILE_CONFIG.bloom-incoming" + run "$SERVICE" project fixture-wpa2 + [ "$status" -ne 0 ] + [ "$(sha256sum "$BLOOM_WIFI_PROFILE_CONFIG" | awk '{print $1}')" = "$config_before" ] +} + +@test "a stale lock is recovered while a live owner remains fail closed" { + write_valid_store + mkdir -p "$BLOOM_WIFI_PROFILE_STORE_ROOT/.lock" + printf '%s\n' 999999 >"$BLOOM_WIFI_PROFILE_STORE_ROOT/.lock/owner" + replace_from_input + [ "$status" -eq 0 ] + [ ! -e "$BLOOM_WIFI_PROFILE_STORE_ROOT/.lock" ] + + mkdir "$BLOOM_WIFI_PROFILE_STORE_ROOT/.lock" + printf '%s\n' "$$" >"$BLOOM_WIFI_PROFILE_STORE_ROOT/.lock/owner" + run "$SERVICE" project fixture-wpa2 + [ "$status" -ne 0 ] + [[ "$output" == *'profile store is busy'* ]] +} + +@test "unknown profiles and invalid commands never alter runtime configuration" { + write_valid_store + replace_from_input + printf '%s\n' 'previous-config' >"$BLOOM_WIFI_PROFILE_CONFIG" + before=$(sha256sum "$BLOOM_WIFI_PROFILE_CONFIG" | awk '{print $1}') + + run "$SERVICE" project unavailable + [ "$status" -ne 0 ] + [[ "$output" == *'profile is unavailable'* ]] + [ "$(sha256sum "$BLOOM_WIFI_PROFILE_CONFIG" | awk '{print $1}')" = "$before" ] + + run "$SERVICE" export + [ "$status" -ne 0 ] + [[ "$output" == *'invalid arguments'* ]] + [ "$(sha256sum "$BLOOM_WIFI_PROFILE_CONFIG" | awk '{print $1}')" = "$before" ] +} + +@test "versioned schema records the same public bounds and initial security vocabulary" { + schema=/workspace/build/wifi-profile-store.schema.json + run jq -e ' + .properties.schema.const == 1 and + .properties.profiles.maxItems == 16 and + (.properties.profiles.items.oneOf | length) == 2 and + ([.properties.profiles.items.oneOf[].properties.security.const] | sort) == ["open", "wpa2-psk"] and + any(.properties.profiles.items.oneOf[]; .properties.security.const == "open" and .properties.hidden.const == false) and + any(.properties.profiles.items.oneOf[]; .properties.security.const == "wpa2-psk" and (.required | index("psk")) != null) + ' "$schema" + [ "$status" -eq 0 ] +} + +@test "quoted metacharacters remain data and cannot inject supplicant directives" { + cat >"$INPUT" <<'JSON' +{"schema":1,"profiles":[{"id":"escaped","ssid":"Fixture \"Quoted\" \\ Path","security":"wpa2-psk","hidden":false,"psk":"test\"only\\passphrase$()#"}]} +JSON + replace_from_input + [ "$status" -eq 0 ] + run "$SERVICE" project escaped + [ "$status" -eq 0 ] + [[ "$output" != *'Fixture'* ]] + [[ "$output" != *'passphrase'* ]] + grep -Fqx $'\tssid="Fixture \\"Quoted\\" \\\\ Path"' "$BLOOM_WIFI_PROFILE_CONFIG" + grep -Fqx $'\tpsk="test\\"only\\\\passphrase$()#"' "$BLOOM_WIFI_PROFILE_CONFIG" + [ "$(grep -Fc 'network={' "$BLOOM_WIFI_PROFILE_CONFIG")" -eq 1 ] + [ "$(grep -Fc 'key_mgmt=' "$BLOOM_WIFI_PROFILE_CONFIG")" -eq 1 ] +} diff --git a/tools/legacy_manifest.py b/tools/legacy_manifest.py index a33c4bab3..0e4149180 100644 --- a/tools/legacy_manifest.py +++ b/tools/legacy_manifest.py @@ -65,6 +65,7 @@ "runtime-tmp-update-bin-bloom-remote-keys", "runtime-tmp-update-bin-bloom-samba-service", "runtime-tmp-update-bin-bloom-wifi", + "runtime-tmp-update-bin-bloom-wifi-profiles", "runtime-tmp-update-bin-bloom-platform", "runtime-tmp-update-bin-bloom-power", "runtime-tmp-update-bin-bloom-ra-login",