Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build/dependencies.lock
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json"
schema = 1
component_count = 331
file_count = 2130
byte_count = 314646675
byte_count = 314651736
source_build_components = 227
unresolved_components = 104
inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b"
Expand Down
4 changes: 2 additions & 2 deletions build/legacy-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -995,8 +995,8 @@
"kind": "runtime",
"path": "static/build/.tmp_update/bin/bloom-wifi-profiles",
"file_count": 1,
"byte_count": 9043,
"tree_sha256": "269e3600718c2d1c88c3083c9a751ad572d2de9e5e12d50aa1883a333be74c13",
"byte_count": 14104,
"tree_sha256": "07502370e9ac8bc9a6df02a732bdf60ecf362f620641b0f659c734a40ff6552a",
"source": "https://github.com/boburning/BloomOS",
"source_revision": "release-commit",
"license": "GPL-3.0-only",
Expand Down
2 changes: 1 addition & 1 deletion docs/1.0_TEST_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +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 |
| Wi-Fi profile store | JFFS2 private root; stdin-only bounded replacement and single-profile upsert; requested/document ID binding; inactive-ID deletion with active-profile refusal; 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 |
Expand Down
7 changes: 7 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,13 @@ 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.
NET-04 extends the same locked authority with stdin-only `upsert ID` and
non-secret `delete ID` operations. Upsert binds the profile document's ID to
the requested handle and republishes the complete validated store without
returning profile data. Delete refuses the currently projected ID, identified
by a non-secret managed-config marker, so forgetting a profile cannot silently
strand or mutate the active station. Active-profile replacement and removal
remain the responsibility of the later rollback-bounded association slice.
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.
Expand Down
19 changes: 12 additions & 7 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,15 +271,19 @@ received on stdin; credentials are never command arguments:

```sh
bloom-wifi-profiles replace < synthetic-profiles.json
bloom-wifi-profiles upsert fixture-profile-id < synthetic-profile.json
bloom-wifi-profiles delete inactive-fixture-profile-id
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:
`replace` and `upsert` are bounded to 16 profiles and 16 KiB. `upsert` binds
the stdin document to the requested non-secret ID and republishes the complete
validated store. `delete` refuses the currently projected ID. `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. None of these operations 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 \
Expand All @@ -289,8 +293,9 @@ network. Run the focused gate with the pinned shell-test image:
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.
redacted health. The remaining NET-04 scan/connect orchestration and active
profile removal 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
Expand Down
6 changes: 6 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,11 @@ single-network mode-0600 configuration with `update_config=0`, preventing
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.
Incremental profile mutation preserves that boundary: `upsert ID` receives one
profile only on stdin, requires its document ID to match the non-secret command
handle, revalidates the resulting complete store, and publishes atomically.
`delete ID` returns only a redacted count and refuses the ID marked as active in
the managed supplicant projection. Neither operation scans, associates,
restarts Wi-Fi, or exposes an SSID or credential.

Security-sensitive migrations and repair operations must be bounded, logged, reversible where possible, and validated before activation.
14 changes: 10 additions & 4 deletions docs/program/BloomOS_Work_Program_v3.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,9 @@
"title": "Establish clean recovery workspace and current ledger",
"status": "merged",
"dependencies": [],
"github_prs": [],
"github_prs": [
487
],
"risk_tier": 0,
"deployment_tier": "host",
"device_gate_required": false,
Expand Down Expand Up @@ -827,16 +829,20 @@
"id": "NET-04",
"track": "Network",
"title": "Bounded scan/connect/forget API",
"status": "planned",
"status": "in_progress",
"dependencies": [
"NET-03"
],
"github_prs": [],
"risk_tier": 2,
"deployment_tier": "warm",
"device_gate_required": true,
"last_audited_main": "3a7e78290285806e1ce3eb0c126887644ff665f2",
"evidence": []
"last_audited_main": "fca10bb0168d8bc38737881745b68cd3bab9da59",
"evidence": [
"issue #486 tracks NET-04 after NET-03 merged as fca10bb0168d8bc38737881745b68cd3bab9da59; no prior NET-04 issue or implementation PR exists and current main has no bounded scan/connect/forget orchestration",
"draft PR #487 is the first host-safe NET-04 slice and extends the private NET-03 authority with stdin-only atomic single-profile upsert, requested/document ID binding, inactive-profile deletion, a non-secret projected-profile marker, and fail-closed refusal to replace or forget the active profile; 16/16 focused tests passed in 4.8 seconds with exact 314651736-byte dependency preflight, and the slice does not scan, associate, restart Wi-Fi, change preferences, or complete NET-04",
"exact head b6e96eebd08df0eb8b75867b8e341f3e7a76a830 passed risk-scoped shell workflow 33171784731 with 1m29s test execution; its exact 14104-byte helper with SHA-256 5fd88c120a0a4a979a890865a5ac06e7dbeb7e50ffcd99ce08cfa389e637ecc7 was transferred through strict key-only SSH and exercised only under /tmp/bloom-net04a-fixture-b6e96eeb with synthetic profiles, where active deletion failed closed, inactive deletion succeeded, root/store/config modes were 0700/0600/0600, live supplicant SHA-256 d7fbadd510e76c423638513337620e17944d51b4505ff314354971d97114bdef remained unchanged, temporary state was removed, and aggregate device health remained green"
]
},
{
"id": "NET-05",
Expand Down
124 changes: 119 additions & 5 deletions static/build/.tmp_update/bin/bloom-wifi-profiles
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ MAX_PROFILES=16
LOCKED=0
TEMP_STORE=
TEMP_CONFIG=
TEMP_INPUT=

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
[ -z "$TEMP_INPUT" ] || rm -f "$TEMP_INPUT" 2>/dev/null || true
if [ "$LOCKED" -eq 1 ]; then
rm -f "$LOCK/owner" 2>/dev/null || true
rmdir "$LOCK" 2>/dev/null || true
Expand All @@ -35,6 +37,16 @@ safe_profile_id() {
[ "${#1}" -le 32 ]
}

config_has_profile() {
expected_id=$1
if [ -e "$CONFIG" ] || [ -L "$CONFIG" ]; then
[ -f "$CONFIG" ] && [ ! -L "$CONFIG" ] || fail "supplicant configuration is unsafe"
grep -Fqx "# bloom-profile-id=$expected_id" "$CONFIG"
return
fi
return 1
}

ensure_root() {
parent=${ROOT%/*}
[ -n "$parent" ] || parent=/
Expand Down Expand Up @@ -94,6 +106,48 @@ validate_store() {
' "$candidate" >/dev/null 2>&1
}

receive_bounded_input() {
destination=$1
[ ! -e "$destination" ] && [ ! -L "$destination" ] || fail "profile input slot is occupied"
TEMP_INPUT="$destination"
(umask 077; "$DD" of="$TEMP_INPUT" bs=$((MAX_STORE_BYTES + 1)) count=1 2>/dev/null) || fail "cannot receive profile input"
bytes=$(wc -c <"$TEMP_INPUT" | tr -d ' ')
case "$bytes" in ''|*[!0-9]*) fail "profile input size is invalid" ;; esac
[ "$bytes" -gt 0 ] && [ "$bytes" -le "$MAX_STORE_BYTES" ] || fail "profile input exceeds the bounded size"
}

validate_single_profile() {
candidate=$1
expected_id=$2
[ -f "$candidate" ] && [ ! -L "$candidate" ] || return 1
"$JQ" -e --arg id "$expected_id" '
type == "object" and
.id == $id 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)
' "$candidate" >/dev/null 2>&1
}

publish_store_candidate() {
candidate=$1
validate_store "$candidate" || fail "profile store is invalid"
chmod 600 "$candidate" || fail "cannot protect profile store"
sync_filesystem
mv -f "$candidate" "$STORE" || fail "cannot atomically publish profile store"
TEMP_STORE=
sync_filesystem
}

sync_filesystem() {
[ -x "$SYNC" ] || fail "durability helper is unavailable"
"$SYNC" || fail "cannot synchronize profile state"
Expand All @@ -113,15 +167,72 @@ replace_store() {
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
publish_store_candidate "$TEMP_STORE"
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"
}

upsert_profile() {
profile_id=$1
safe_profile_id "$profile_id" || fail "profile id is invalid"
ensure_root
acquire_lock
if [ -e "$STORE" ] || [ -L "$STORE" ]; then
[ -f "$STORE" ] && [ ! -L "$STORE" ] || fail "profile store is unsafe"
validate_store "$STORE" || fail "profile store is invalid"
fi

profile_input="$STORE.profile-incoming"
receive_bounded_input "$profile_input"
validate_single_profile "$TEMP_INPUT" "$profile_id" || fail "profile input is invalid"

candidate_store="$STORE.incoming"
[ ! -e "$candidate_store" ] && [ ! -L "$candidate_store" ] || fail "profile-store replacement slot is occupied"
TEMP_STORE="$candidate_store"
replaced=false
if [ -f "$STORE" ]; then
matches=$("$JQ" -r --arg id "$profile_id" '[.profiles[] | select(.id == $id)] | length' "$STORE") || fail "cannot read profile store"
[ "$matches" -le 1 ] || fail "profile store is invalid"
[ "$matches" -eq 0 ] || replaced=true
if [ "$replaced" = true ] && config_has_profile "$profile_id"; then
fail "active profile cannot be replaced"
fi
(umask 077; "$JQ" --slurpfile profile "$TEMP_INPUT" --arg id "$profile_id" \
'.profiles = ((.profiles | map(select(.id != $id))) + [$profile[0]])' \
"$STORE" >"$TEMP_STORE") || fail "cannot build profile store"
else
(umask 077; "$JQ" -n --slurpfile profile "$TEMP_INPUT" \
'{schema:1,profiles:[$profile[0]]}' >"$TEMP_STORE") || fail "cannot build profile store"
fi
publish_store_candidate "$TEMP_STORE"
rm -f "$TEMP_INPUT" || fail "cannot remove profile input"
TEMP_INPUT=
count=$("$JQ" -r '.profiles | length' "$STORE") || fail "cannot read published profile store"
printf '{"schema":1,"service":"bloom-wifi-profiles","operation":"upsert","published":true,"replaced":%s,"profile_count":%s}\n' "$replaced" "$count"
}

delete_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" -eq 1 ] || fail "profile is unavailable"
if config_has_profile "$profile_id"; then
fail "active profile cannot be forgotten"
fi

candidate_store="$STORE.incoming"
[ ! -e "$candidate_store" ] && [ ! -L "$candidate_store" ] || fail "profile-store replacement slot is occupied"
TEMP_STORE="$candidate_store"
(umask 077; "$JQ" --arg id "$profile_id" \
'.profiles = [.profiles[] | select(.id != $id)]' "$STORE" >"$TEMP_STORE") || fail "cannot build profile store"
publish_store_candidate "$TEMP_STORE"
count=$("$JQ" -r '.profiles | length' "$STORE") || fail "cannot read published profile store"
printf '{"schema":1,"service":"bloom-wifi-profiles","operation":"delete","deleted":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'
Expand Down Expand Up @@ -169,6 +280,7 @@ project_profile() {
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 '# bloom-profile-id=%s\n' "$profile_id"
printf '%s\n' 'network={'
printf '\tssid=%s\n' "$ssid_quoted"
[ "$hidden" = true ] && printf '\tscan_ssid=1\n'
Expand Down Expand Up @@ -205,6 +317,8 @@ project_profile() {
case "$#:${1:-}" in
1:replace) replace_store ;;
2:status) [ "$2" = --json ] || fail "invalid arguments"; store_status ;;
2:upsert) upsert_profile "$2" ;;
2:delete) delete_profile "$2" ;;
2:project) project_profile "$2" ;;
*) fail "invalid arguments" ;;
esac
Loading