📖 Project overview in Spanish/Portuguese: README.es-ES.md · README.pt-BR.md (this security policy is English-only).
This document describes the threat model, implemented defenses, and operational security procedures of the SIMUT firmware. It must be kept in sync with the code — any security changes to the project require a review of this file.
Context: SIMUT is firmware for the Raspberry Pi Pico W (RP2040) that
manages temperature/humidity sensors, exposes a dashboard and management
interface via HTTP, has a USB serial CLI, and sends telemetry to a
remote server. There is also a Bluetooth SPP CLI: it is compiled out of
the pico_w_release image, but it is compiled into the published
pico_w_alpha and pico_w_air images, so on those two it is live
attack surface — see §2 and §8. It is designed to operate on a trusted
local network
(industrial LAN or home automation); it is not hardened against an
adversarial public network.
Security fixes always ship in the most recent beta release; earlier versions receive no backports. Update to the latest release before reporting a vulnerability.
| Version | Supported |
|---|---|
| Latest published release tag | ✅ receives fixes |
| All earlier versions | ❌ no backports |
- Configuration integrity against accidental corruption (CRC32 +
dual-bank backup,
StorageManager). - User credentials in flash: HMAC-SHA256 hash with per-user salt + pepper derived from the chip serial. Plaintext passwords are never written to flash.
- Path traversal in uploads (
handleApiUploadvalidates filename and uploadDir — SEC-001/SEC-002). - The
/configcredential store against file-manager mutation:POST /api/delete,POST /api/uploadandGET /api/lsrefuse any path under/config(findings ACH-01/02/04) — the store is only reached byGET /api/backup/POST /api/restore?op=apply(PERM_FULL_ADMIN). - Stored XSS via the language pack:
applyLangescapes every value read from/api/lang(the@WEBDICTof the active.lng, which is uploadable) before writing it toinnerHTML(finding ACH-07). - Slow-request denial of service on the HTTP parser, bounded by a wall-clock budget with the watchdog fed per byte (SEC-008 — fixed in v2.1.1-beta, see §6).
- Authorization on routes that consume an upload body, checked at
UPLOAD_FILE_STARTrather than in the handler that answers (SEC-007 — fixed in v2.1.0-beta, see §6). - CLI buffer DoS (
CLI_LINE_MAX=256discards streams without\n— SEC-005). - JSON formatting in
/api/lsagainst control bytes that would break the listing (WEB-001 —jsonEscapeFilename). - JSON formatting in every other API response (V-04):
jsonEscapecovers quote, backslash and every control byte (\u00xx) in/api/network,/api/status,/api/alarmsand/api/perms. Values a permitted user may store used to reach those responses raw, so one edit broke a page for every user — and, for@NAMEin a language pack, survived reboots, because a.lngis only read at boot. - Identity strings validated as keys, not free text (O-2): an
hwIdis a JSON string, a telemetry CSV column header and a telemetry JSON field name, soisValidHwIdrestricts it to[A-Za-z0-9_-]; SSID, Wi-Fi password and NTP server go throughisValidCfgStringon the web commit path as they always did on the CLI;langIdentSanitizestrips what JSON cannot hold from a pack's@NAME/@CODE. - Login brute force (rate limiting + exponential lockout per IP
slot,
authLockoutMs— see §4 for the overflow that used to open it), and the same lockout on the Bluetooth CLI (§2). - Read permissions that mean what the users page says:
/downloadrequiresPERM_HISTORYfor/history/...andPERM_LOGSfor*.blogon top ofPERM_FILE_READ(O-1), so those two bits are controls rather than decoration. - Crash autopsy after HW watchdog (
LogManager::performCrashAutopsy— F13.1) — forensic telemetry of the last freeze.
- Public/hostile network: plain HTTP without TLS. SHA-256 passwords in the client payload, but cookies and payloads are exposed on untrusted networks. Use a VPN or isolated network.
- LAN MitM: no mutual TLS authentication. An attacker on the path can forge responses or intercept cookies.
- Physical attack: access to USB UART gives privileged CLI without authentication (by design — console recovery). BOOTSEL pin access allows flashing arbitrary firmware.
- DMA/side-channel: the RP2040 chip has no secure enclave; any code running has full access to flash and RAM.
- Availability under DDoS: rate-limiting is per-connection and does not withstand a coordinated flood.
- Factory defaults: user
adminwith a random 8-character password from the alphabet[A-Z2-9](32 symbols, noO/0/I/1). Entropy ≈ 2^40 — sufficient against casual brute-force; change immediately after first access. The password is generated byrp2040.hwrand32()(ROSC, hardware). See SEC-003/F12.3. - Factory password exposure: printed once on USB Serial during
factory boot (banner
SEC-003: FACTORY DEFAULTS ATIVADO). Never persisted — cleared from RAM as soon as the admin changes the password or a non-factory config is loaded. mustChangePasswordflag: blocks navigation until changed on the 1st web login.- Rotation: via
/usersUI (admin edits own password) or CLIconf user pass <user> <newpass>. Periodic rotation recommended per operator policy. - Reset without changing factory:
conf system admin reset confirm(CLI) regenerates a random password and displays it on the console — useful if the admin forgets the password.
- Default:
1234— deliberately trivial, protects only against accidental tampering on the display. FLAG_MUST_CHANGE_PIN(SEC-004/F12.4): forces change on the 1st access to the display config menu. Overlay inSystemConfig.reserved[26..27].
SIMUT_BLUETOOTHis 0 inpico_w_release, and 1 in bothpico_w_alphaandpico_w_air(platformio.ini). Those two images are published, so for them the Bluetooth SPP CLI is live attack surface and not a source-only feature.- Authentication is the admin account's web password, not the
display PIN (
BluetoothManagercalls the validator installed byAppManager). Before auth, onlyhelpandlanguageanswer; after it, the CLI has the same privileges as the USB CLI. - Pairing takes no confirmation on the device, so the password prompt is
the only barrier. Discovery is closed 5 minutes after boot
(
BT_DISCOVERABLE_MS, V-01b): the unit stops appearing in scans, but a phone that already paired — or anyone who noted the address — still connects, because connectability is untouched. A reboot reopens the window, which is how a new phone is paired. - Exponential lockout on wrong passwords (V-01a):
authLockoutMs, the same backoff as the web login (2 s on the first failure, ceiling 300 s). The state lives in RAM inBluetoothManager, so dropping the RFCOMM link and reconnecting does not reset it — that loop is what the lockout exists to close. Cleared by a correct password or a reboot. Logged asSEC_BT_LOCKOUTwith the delay in seconds as context. - Recovery commands are USB-only (V-01a):
conf system factory,system format,conf system admin resetandsystem https offare refused over Bluetooth. Whoever authenticated on that link already has the admin password and has nothing to recover;admin resetin particular would convert a foothold into a printed credential.apstays allowed over Bluetooth — it is the documented reason this CLI exists — and prints the setup AP's key to the Bluetooth session. - Password policy on the CLI:
conf user passanduser addapply the same floor as the web (≥ 8 characters, a letter and a digit). Over plain HTTP the web cannot check this — it only ever sees the browser's SHA-256 — so the CLI and HTTPS are where the policy is enforceable.
- User
viewercreated at factory with limited permissions (PERM_DASHBOARD | PERM_HISTORY). Default public password (viewer), documented as such, withmustChangePassword=trueto force change even for the read-only account.
- Password hashes — currently in transition (F15):
- Legacy (
hashVersion=0, v14 and earlier): HMAC-SHA256 × 2500 rounds with salt = username.toLowerCase() (deterministic) + pepper derived from board serial. Output truncated to 30 hex chars (120 bits). Two devices with same user+pass generate different hashes via pepper, but within a device the salt is predictable. - v1 (
hashVersion=1, F15.2.c onward): HMAC-SHA256 × 5000 rounds with random per-user salt (8 bytes viahwrand32) + same pepper. Output 32 hex chars (128 bits, meets NIST minimum). Generated when creating/changing password or on factory reset. - v15 schema:
UserAccountgained fieldssalt[8]andhashVersionto support both schemes in parallel. Transparent migration: v13/v14 configs are read, users are markedhashVersion=0(legacy), and will be auto-upgraded to v1 on the next valid login (F15.2.c).
- Legacy (
- Sensitive flash fields (WiFi pass, telemetry API key): XOR obfuscation with a SHA-256(chipID + domain) keystream before writing. Not strong encryption — it is defense in depth against trivial flash dumps. An adversary with code execution on the chip extracts them easily.
- RAM-only secrets:
_initialAdminPassword(plaintext factory password) only in RAM; zeroed when admin changes password or a valid config is loaded. - Login nonces (
LoginState.nonce): 64 hex chars from SHA-256(hardware entropy × 4). Valid forNONCE_LIFETIME_MS = 60s. Atomically consumed after use.
- State per client IP in
_loginStates[LOGIN_STATE_SLOTS=8](LRU evict bylastActivity, but only among evictable slots — see SEC-006 below). - Exponential backoff:
authLockoutMs(failCount)—(1 << n) × 1s, ceiling 300 s, with the failure counter saturating atAUTH_FAIL_CAP = 12. Resets to 0 after a successful login. - The counter saturates for a reason (2026-09-07): the previous form
computed
(1U << failCount) * 1000and clamped the product, with an unbounded counter. AtfailCount29, 30 and 31 the multiplication wraps to exactly zero —2^29 × 1000 = 125 × 2^32— so the penalty was zero milliseconds and the account was open; past 31 the shift is undefined. An attacker willing to sit through ~108 minutes of escalation was handed free attempts. The shift is clamped before it happens now, because a wrapped product can land below the ceiling and read as a legitimate short penalty. - Expired nonce counts as failure (same backoff).
- Failure log:
LOG_WARN SEC SEC_LOGIN_FAILwith reason (invalid nonce, expired nonce, invalid credential). - SEC-006/F15.1: the LRU evict algorithm ignores slots under active
lockout (
lockoutUntil > now). Locked slots become "sticky" until the penalty expires — prevents an attacker from escaping backoff by cycling through 8+ different IPs until the locked slot becomes LRU. If all 8 slots are locked simultaneously (edge case),/api/login_initresponds HTTP 429 withRetry-Afterin seconds.
- USB/BT buffer bound:
CLI_LINE_MAX = 256. Longer lines discarded +LOG_WARN CLI CLI_UNKNOWN_CMD. Prevents DoS likeyes | cat > /dev/ttyACM0(SEC-005/F12.5).
- During display interaction, heavy web handlers respond 503
(
TouchPriority::isActive()→rejectIfTouchPriority— REF-004). Not rate-limiting, it's UX priority, but also reduces the window for CPU-exhaustion attacks.
- Auth: both require a valid web login +
PERM_HISTORY/PERM_LOGSpermission respectively. Without a validSIMUTSESScookie → HTTP 403. - Atomic guard: each endpoint has a flag (
_inHistoryHandler,_inExportLogsHandler) that prevents 2 concurrent exports of the same type — the second receives HTTP 503{"error":"Already processing"}. Allows 1 history export + 1 log export simultaneously. - HeavyTaskGuard: serializes against flash writes (config saves,
sensor accept). Concurrency → HTTP 503
System Busy. - Hard range cap: 31 days per request (rejected with HTTP 400
Range exceeds 31 days). The JS client splits larger ranges into 24h chunks (~30 chunks for full history). - Server deadline:
WEB_LONG_HANDLER_DEADLINE_MS = 15s(calibrated bytools/test_perf.sh— 3d export ~10s typical case). On timeout, the handler aborts cleanly viaisHandlerOvertime()and the CRC32 trailer is not emitted — the client detects invalid CRC and retries with a smaller chunk. - Integrity: the
.simxbundle has a CRC32-IEEE-802.3 trailer. The client validates byte-by-byte before generating CSV — network failure/truncation detected deterministically. Since 2.1.8 the web CSV button no longer goes through.simx— the page downloads the raw.h5day files and decodes them in the browser; the.simxendpoint remains reachable by direct URL. - Server-side filters:
/api/export/logs.bin?level=err|inf|allfilters on the server (reduces bytes on the wire and client CPU). History filters byfrom/to(epoch UTC, hard cap 31d).
handleApiExportHistoryiterates by day in the range. If the file for a day does not exist (LittleFS.exists() == false),dayStartis always advanced before anycontinue— prevents an infinite loop that would trigger the WDT on ranges covering dates without data.
- Binary files
/system.blog(current) and/system.old.blog(rotated),MAX_RECORDS_PER_FILE = 800. Each record has timestamp, core, level, tag, code, context. - Reading via web
/history→ Logs tab, CLIshow system log, or direct download/download?file=/system.blog— which requiresPERM_LOGSon top ofPERM_FILE_READ, like every*.blogpath (downloadPermFor, finding O-1). - Clear:
clear log confirm(CLI) or UI — audited action (LOG_CODE(LOG_WARN, "SEC", SYS_REBOOT_USER, ...)fires in the flow).
GET /api/sec_status(perm:PERM_USER_MGR) exposes active attempts, lockouts, failCount, slot age — useful for triage.
- If a user is interacting on the display, logs are buffered in RAM and flushed on touch release. Prevents evidence loss on crash-during-interaction.
If compromise is suspected:
-
Remote triage:
GET /api/sec_status(if login still works) — how many active slots, failCounts, recent lockouts.GET /api/logsorshow system log— look forSEC_LOGIN_FAIL,SEC_CONFIG_CHANGED, unexpectedSYS_REBOOT_USER,STO_*indicating unsolicited writes.
-
Containment:
- Disconnect from the network (power cycle or remove WiFi).
- If physical access is available: USB CLI remains available even with the network down.
-
Revocation:
- Rotate admin:
conf user pass admin <newPassword>+write memory+reload confirm. - Revoke other users:
conf user del <name>. - If extensive compromise:
conf system factory confirm(wipes ALL config).
- Rotate admin:
-
Preservation:
- Download
/system.blogand/system.old.blogbefore factory reset — they are wiped along with everything. Files in/history/as well.
- Download
-
Recovery:
- After factory reset, reconfigure from scratch. The admin password appears once on USB Serial (connect to capture it).
Every build up to and including v2.0.3-alpha accepted an
unauthenticated POST /api/restore?op=apply and wrote the payload's
files to LittleFS. The permission check existed, correctly named in this
document, and ran in the handler that emits the response — which the web
framework calls only after the whole multipart body has been fed
through the upload callback. The apply feed writes each entry straight to
its final path as bytes arrive, so the 403 was accurate about the verdict
and late about the effect.
Reach: anything the backup format can name — /config, /calib.csv,
/history, the language packs. The path check rejects .. and nothing
else. No session cookie was needed.
The lesson generalises past this route: on any endpoint with an upload
callback, a permission checked in the final handler is checked too late.
/api/upload and ?op=stage always got this right; restore was the one
that did not, and the design intent recorded here read as if it did.
Triage on a device that was exposed is thin, and the reason is worth
stating plainly: the exploited path left no log entry. The only
LOG_CODE on this route sits in the finish handler, behind the very
check that failed, so a device that was written to has nothing to show
for it. The available evidence is indirect — compare /api/backup
against a known-good copy, and look for file timestamps or history gaps
that do not match the device's own writes.
From v2.1.0-beta a rejected restore logs SEC_UNAUTHORIZED with the
requested op, so the refusal itself is auditable going forward.
Every build up to and including v2.1.0-beta could be rebooted by an
unauthenticated HTTP request that arrives slowly. WebServer::handleClient
parses with readStringUntil, which waits the client timeout per byte and
resets it on every byte received; a peer dribbling one byte just under that
timeout holds Core 0 inside the parse indefinitely, and the main loop feeds the
hardware watchdog before handleClient, never inside it. One slow GET took the
device down in about eight seconds — no auth, no concurrency, no large payload.
Autopsy on the boot serial: C0=[WEB_POLL] hp=0 sc3=0x80088013 (219), where
hp=0 means handleClient never returned.
Fixed by reading each request line under one wall-clock budget with the
watchdog fed on every byte; a request that overruns is dropped. This covers the
unauthenticated request line and headers (_parseRequest). The multipart body
path (_parseForm) is reachable only after authentication and a valid request
line, and its slow-body variant is a documented follow-up.
Not fully closed: the same autopsy also appears under six concurrent clients, which may have a second cause that is not the slow parse. That case stays open.
Triage: the reboot logs as a watchdog reset with ctx=219, distinct from a
power loss (ctx=0). There is no per-request audit line for the parse itself —
a device rebooting on C0=[WEB_POLL] with no offending large transfer in the
access pattern is the signature.
conf system factory confirm
Effect:
StorageManager::resetToFactory()— appliesloadDefaults+ save + clean reboot.- Overlays in
reserved[]recreated with defaults (SetupFlagsMUST_CHANGE_PIN, NetworkTimedns_auto/ntp_enabled). - Users revoked (random admin, default viewer), sensors unmapped, telemetry cache zeroed on next load.
- Preserves:
/history/YYYYMMDD.h5day files (collected sensor data),/system.blog(logs). To wipe, delete via/filesorclear log confirmbeforehand.
- Press BOOTSEL + reboot → UF2 mode.
- Flash a clean
.uf2or a Pico SDK clear tool. - Reflash SIMUT.
Wipes 100% of flash: code, config, history, logs.
- HTTP/HTTPS: port 80 (or value in
WebConfigData.port— 1..65535). Authenticated via session cookie after login. With/config/web_cert.pem+web_key.pemprovisioned the server runs HTTPS instead (TLS 1.2, EC P-256; defaults to port 443 when the configured port is 80) and the cookie carriesSecure; one TLS client is served at a time, and an absent or invalid pair falls back to HTTP. - mDNS:
<deviceName>.local(defaultsimut.local) — for discovery only; does not expose additional endpoints. - Bluetooth SPP:
SIMUT_CLI—SIMUT_BLUETOOTH 0inpico_w_release, 1 in the publishedpico_w_alphaandpico_w_airimages, where it is therefore real attack surface. Discoverable for 5 minutes after boot, then removed from scans (connectability unchanged). Pairing takes no confirmation on the device; application-layer auth is the admin web password, with the exponential lockout and USB-only recovery commands described in §2. - Setup access point (
<deviceName>_SETUP, 192.168.4.1, captive portal on UDP/53 + the web server): WPA2 with a per-device key since V-05. The key is derived — SHA-256 over the board's unique id plus a domain string, mapped to 10 characters of[A-HJ-NP-Z2-9](~50 bits) — and shown on the USB console, on the display and in the reply toap. It is not a secret:show system infoprints the board id, so anyone who has that can recompute the key. It raises the bar from "in radio range" to "has been told the key", which is the bar a setup network should have; the admin password still guards the device.SIMUT_AP_OPEN=1restores the open AP for bench work and is never in a shipped image. - SIMUT Air hibernation: an Air unit spends most of its life asleep
with the radio down, so its exposed surface is the wake window. Only an
authenticated request holds it awake (V-03); unauthenticated
traffic gets
WEB_PREAUTH_MAX_EXT = 3timer extensions per boot, which is what an operator needs to finish logging in and is also the most an anonymous poller can take.air.bincarries the hibernation configuration outsideSystemConfig;airSanitiserefuses GPIO 23/24/25/29 on load, so a forged or restored file cannot point the charger sense at the CYW43 (V-06). - USB CDC: serial always available, no auth (requires physical access).
- NTP: outbound UDP/123 traffic.
- Telemetry: outbound HTTP/HTTPS or MQTT/MQTTS to the configured
server (
cfg.telServer).
Permissions (bitmask, 10 bits): PERM_DASHBOARD, PERM_HISTORY,
PERM_LOGS, PERM_SYS_CONFIG, PERM_NET_CONFIG, PERM_FILE_READ,
PERM_FILE_UPLOAD, PERM_FILE_DELETE, PERM_USER_MGR,
PERM_CALIB (0x0200).
Destructive endpoints require a specific permission + touch-priority
check (503 if user is on the display): /api/commit_all, /api/delete,
/api/mkdir, /api/clear_logs, /api/reset_touch_cal.
OTA endpoints (F-OTA):
GET /api/backup(PERM_FULL_ADMINexactly) — generates a.bkpof the LFS tied to the chip_id. Read-only, but the dump is the entire FS —/config/system.binincluded, with every secret and the password hashes — so reading it is a full-admin operation; no account created through the web UI reaches it.POST /api/restore?op=validate|apply(PERM_FILE_READfor validate,PERM_FULL_ADMINexactly for apply — the.bkpis the whole FS,/config/system.binincluded) — permission pre-checked atUPLOAD_FILE_START, before the first byte is consumed (see SEC-007 below for why this sentence had to be added). apply is destructive (overwrites restored.bkpfiles); chip_id must match. F-RESTORE closed in an earlier hardening pass, before the current version numbering (98/100 PASS in loop_real, 0 ConnResets, 100% integrity — see the restore-gate entries in the CHANGELOG). Apply is AUTO-REBOOT: after writing LFS, the device callsLogManager::safeReboot()to reload stale caches (display, sensor loader, theme). The client receives 200 OK or ConnectionReset (both valid — thedoRestoreUI inWebUI.hpolls/api/login_initfor up to 90s after apply to confirm boot). Core 1 stays paused during the entire upload (1 lockout transition instead of 1-per-chunk, avoiding deadlock).POST /api/restore?op=stage(ADMIN-ONLY since an earlier hardening pass, before the current version numbering —getAuthPerms() == PERM_FULL_ADMIN) — destructive (erases 1 MB of LFS to receive firmware RAW.bin). Permission pre-check atUPLOAD_FILE_STARTbefore erasure. Validation dry-run runs onop=stage&commit=1: size range + boot2 CRC-32/MPEG-2.POST /api/ota/apply(ADMIN-ONLY since the same earlier hardening pass) — IRREVERSIBLE DESTRUCTIVE: copies staging→app slot, watchdog reboot./config/system.binsnapshot preserved viaOTA_SNAPSHOT_OFFSET(last sector of the staging area) and restored inStorageManager::beginpost-apply (chpass, users, WiFi, sensors, MQTT, NTP — 11/11 fields preserved byte-for-byte). Other files (history/lang/themes/calib) require a.bkpdownloaded by the browser before upload + manual restore via/api/restore?op=apply. Recovery in case of brick: BOOTSEL +picotool load -x firmware.uf2.
Immediate action (no reboot): /api/set_time — sets RTC manually,
requires PERM_SYS_CONFIG.
- OTA via web (admin): a
PERM_FULL_ADMINaccount uploads the RAW.binvia/files, validates (boot2 CRC + size range), and applies via/api/ota/apply— both stage and apply are full-admin only (so isPOST /api/restore?op=apply). Config snapshot is preserved through the apply. Threat surface: any compromised admin credential = ability to flash arbitrary firmware remotely. Mitigations: (1) rate-limit on/api/login_init+ exponential lockout; (2)mustChangePinat factory; (3) random admin password on factory reset (SEC-003); (4) device network access should be restricted (isolated subnet / VPN). UF2 is not yet signed — the operator is responsible for validating the binary origin. - OTA via BOOTSEL + UF2: remains available as recovery and as a secure path for the first flash. Requires physical access.
- Firmware integrity: UF2/BIN have no cryptographic signature; validation is limited to boot2 CRC-32/MPEG-2 heuristic (catches gzip/random, but does not prevent valid-signed malicious firmware). The operator is responsible for downloading the binary from a trusted channel.
- Rollback: flashing a previous UF2 restores. Config in
/config/survives OTA (snapshot) and USB reflash if the flash layout hasn't changed; if the LittleFS partition changes it may wipe everything (validate beforehand via the response headers ofGET /api/backup—X-Backup-PSize,X-Backup-PCrc,X-Backup-Files,X-Backup-Schema).
Prerequisites:
- Admin account (
PERM_FULL_ADMIN— firmware stage/apply is full-admin only). - FS backup recommended ("Backup" button in
/files). - RAW
.binbinary (not.bin.gz— the gzip path was removed in an earlier cleanup, before the current version numbering).
Steps:
- Access
/filesauthenticated, click "Firmware". - WARN modal explains non-config data loss + residual bricks.
- Auto-download backup of
.bkp(client-side parse compares BKP1 header with announcedX-Backup-PSize/X-Backup-PCrcheaders; abort on mismatch). - Select
.bin, client-side parser validates: range size + boot2 CRC +SIMUT_VERSIONregex. Final confirm modal with summary + downgrade warn. - Upload (POST
/api/restore?op=stage&commit=1) + apply (POST/api/ota/apply). Device responds 202 and reboots. - Post-boot (~30s), auto-redirect to
/login. Config snapshot restored — login works with same credentials.
Recovery / first-flash path. For routine updates, prefer OTA (§9.1).
Prerequisites:
- Physical access to Pico W (USB).
- UF2 from the official channel (GitHub releases).
Steps:
- Config backup:
GET /api/backup(PERM_FULL_ADMIN) — the.bkpcovers the whole FS,/config/system.binincluded. Per-file download (GET /download?file=,PERM_FILE_READ) refuses any path under/config(FsSecretPath) and additionally requiresPERM_HISTORYfor/history/...andPERM_LOGSfor*.blog, and so do the mutation routes —POST /api/delete,POST /api/uploadandGET /api/lsrefuse/config(findings ACH-01/02/04). The sanctioned way to move config off the device isGET /api/backup. Skip only if config-default is acceptable. - FS backup (recommended if LittleFS layout changed): the same
.bkpfromGET /api/backup; non-secret files can also be pulled one by one via/api/ls+GET /download?file=(PERM_FILE_READ). Empirical lesson:uploadfson a new release has wipedsystem.bin+/history/when the layout changed. - Enter BOOTSEL: press BOOTSEL while plugging in USB or via
1200-baud trick (
stty -F /dev/ttyACM0 1200). The device appears as mass storage (RPI-RP2). - Copy UF2 to the mounted drive. Pico reboots automatically.
- Verify version after boot via
GET /api/perms(versionfield) or Serial banner: must match the published release. - Restore config if step 1 downloaded a
.bkp: apply it viaPOST /api/restore?op=apply(PERM_FULL_ADMIN). Direct upload to/config/system.binvia/api/uploadis no longer allowed — that path was the ACH-02 vulnerability, now closed.
Broken boot recovery:
- BOOTSEL force: hold the BOOTSEL button during power-up; always enters recovery mode, even if firmware is frozen.
- If config is corrupted, factory reset via Serial CLI:
conf system factory confirm(regenerates random passwords, keeps calib.csv).
Versioning:
- Git tags follow semver (
v<MAJOR>.<MINOR>.<PATCH>). - Patch bump = bug fix without schema change.
- Minor bump = feature or schema bump (CONFIG_VERSION incremented;
attemptLoadmigrates from previous versions transparently — seeattemptLoad()insrc/StorageManager.cpp). - Major bump = compatibility break (rare).
Suspect a vulnerability? Report privately before publishing:
- Use GitHub private vulnerability reporting on the project repository: Security tab → "Report a vulnerability".
- If that option is unavailable, open a regular issue asking for a private contact channel — do not include technical details of the flaw in the public issue.
Avoid publishing to public forums/issues until coordination — devices in production may be compromised during the disclosure window.