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
22 changes: 21 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,27 @@ network tether once TinyUSB starts and the serial console goes quiet. Use
adapter to the UART0 pins. Full first-time walkthrough: `docs/QUICKSTART.md`.

`sdkconfig.credentials` holds secrets (Wi-Fi, Tailscale auth key, admin
password) and is gitignored — never commit it.
password) and is gitignored — never commit it. Every value in it is compiled
into the `.bin` **and** the `.elf` as a plain string, so a build made with a
credentials file present is private and must never be attached to a GitHub
release or shared outside the organisation.

### Publishing release binaries

Only builds made **without** a credentials file go on a release. Build them in a
clean checkout with `idf.py -DPROJECT_VER=<tag>-public build merge-bin` (the
`-public` suffix keeps `fw_ver` distinguishable from private builds of the same
commit), then run the guard on every artifact before `gh release upload`:

```sh
tools/release_guard.sh firmware/build/pstop_remote.bin firmware/build/pstop_remote.elf ...
```

It refuses (exit 1) any file that carries the private-build brand (every
image compiled while a credentials file was present is branded
`ML-BUILD-WITH-CREDENTIALS`, so the verdict does not depend on which
credentials file the checking machine has), any value of a local credentials
file, or a secret-shaped string (`tskey-…`, PEM keys, literal auth tokens).

### Host runner (robot-side machine)

Expand Down
12 changes: 12 additions & 0 deletions components/microlink/src/ml_app.c
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,18 @@ ml_app_t * ml_app_start(const ml_app_config_t * cfg)
ESP_ERROR_CHECK(ret);

ESP_LOGI(TAG, "MicroLink App Framework starting...");
/* Brand credentials-bearing images so tools/release_guard.sh can refuse them
* on any machine, whatever sdkconfig.credentials it does or doesn't have.
* The brand lives in a `used` object, not only in a log format string: an
* ESP_LOGI is compiled out below LOG_LOCAL_LEVEL and would silently defeat
* the guard (review). */
#ifdef ML_BUILD_WITH_CREDENTIALS
static const char s_build_flavour[] __attribute__((used)) = "ML-BUILD-WITH-CREDENTIALS";
ESP_LOGW(TAG, "Build flavour: %s (private, not for release)", s_build_flavour);
#else
static const char s_build_flavour[] __attribute__((used)) = "ML-BUILD-PUBLIC";
ESP_LOGW(TAG, "Build flavour: %s (no credentials compiled in)", s_build_flavour);
#endif
Comment thread
claude[bot] marked this conversation as resolved.
ESP_LOGI(
TAG,
"Free heap: %lu bytes (PSRAM: %lu bytes)",
Expand Down
8 changes: 8 additions & 0 deletions firmware/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.credentials")
endif()

include($ENV{IDF_PATH}/tools/cmake/project.cmake)

# A build made WITH a credentials file is private: brand the image (see
# ml_app.c) so tools/release_guard.sh refuses it even on a machine that has a
# different credentials file, or none.
if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.credentials")
idf_build_set_property(COMPILE_DEFINITIONS "-DML_BUILD_WITH_CREDENTIALS=1" APPEND)
endif()

project(pstop_remote)

# Override lwIP's pbuf pool. IDF 5.5 doesn't expose PBUF_POOL_SIZE via
Expand Down
8 changes: 8 additions & 0 deletions machn/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.credentials")
endif()

include($ENV{IDF_PATH}/tools/cmake/project.cmake)

# A build made WITH a credentials file is private: brand the image (see
# ml_app.c) so tools/release_guard.sh refuses it even on a machine that has a
# different credentials file, or none.
if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.credentials")
idf_build_set_property(COMPILE_DEFINITIONS "-DML_BUILD_WITH_CREDENTIALS=1" APPEND)
endif()

project(machn_machine)

# Identity prefix: machn-01<mac24> instead of pstop-01<mac24>.
Expand Down
85 changes: 85 additions & 0 deletions tools/release_guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Polymath Robotics
# SPDX-License-Identifier: Apache-2.0
#
# release_guard.sh — refuse to publish firmware artifacts that carry secrets.
#
# Every value in the git-ignored sdkconfig.credentials (Tailscale auth key,
# WiFi credentials, admin password, OTA URL/API key, management-server IP) is
# compiled into BOTH the .bin and the .elf as a plain string. Only a build made
# WITHOUT a credentials file may ever be attached to a public release.
#
# tools/release_guard.sh <artifact>...
#
# Three independent layers, so the verdict does not depend on which
# credentials file happens to be on the machine running the guard:
# 1. build brand — any image compiled with a credentials file present carries
# the ML-BUILD-WITH-CREDENTIALS marker (CMakeLists + ml_app.c)
# 2. local values — every value of a credentials file found in the tree
# 3. secret shapes — tskey-…, PEM private keys, literal HTTP auth tokens
#
# Exit 0 = every artifact clean. Exit 1 = a leak (pattern named, value never
# printed). Exit 2 = usage.

set -euo pipefail

REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
[[ $# -ge 1 ]] || { echo "usage: $0 <artifact>..." >&2; exit 2; }

# Values that are already public (Kconfig defaults in the tracked tree, e.g. the
# documented default admin password) are not secrets and must not trip the guard.
public_defaults="$(grep -hoE 'default "[^"]+"' "$REPO"/components/microlink/Kconfig \
"$REPO"/firmware/components/*/Kconfig 2>/dev/null | sed -E 's/^default "//; s/"$//' || true)"

# Kconfig writes strings with \" and \\ escaped; the compiler embeds the unescaped bytes.
kconfig_unescape() { printf '%s' "$1" | sed -E 's/\\(["\\])/\1/g'; }
# Whole-C-string view of an artifact: every NUL becomes a newline, so a
# NUL-terminated string literal is exactly one line. Portable — needs no
# grep -P / -z (absent from BSD grep).
cstrings() { tr '\0' '\n' < "$1"; }

# Layer 2 inputs: "KEY<TAB>value" lines from every credentials file in the tree.
values=""
for f in "$REPO"/firmware/sdkconfig.credentials "$REPO"/machn/sdkconfig.credentials; do
[[ -f "$f" ]] || continue
while IFS= read -r line; do
[[ "$line" =~ ^(CONFIG_[A-Z0-9_]+)=\"(.*)\"$ ]] || continue
k="${BASH_REMATCH[1]}"; v="$(kconfig_unescape "${BASH_REMATCH[2]}")"
[[ -n "$v" && "$v" != "y" && "$v" != "n" ]] || continue
grep -qxF -- "$v" <<< "$public_defaults" && continue
values+="$k"$'\t'"$v"$'\n'
done < "$f"
done
values="$(printf '%s' "$values" | sort -u)"

overall=0
for art in "$@"; do
if [[ ! -f "$art" ]]; then echo "MISSING $art" >&2; overall=1; continue; fi
leak=0

# 1. build brand
if grep -qF -- 'ML-BUILD-WITH-CREDENTIALS' "$art"; then
echo "LEAK $art: built with a credentials file (private build brand present)"; leak=1
fi

# 2. local credential values. Short values would match by accident anywhere,
# so they are required as a whole NUL-terminated C string.
while IFS=$'\t' read -r k v; do
[[ -n "$k" ]] || continue
if [[ ${#v} -ge 6 ]]; then
grep -qF -- "$v" "$art" && { echo "LEAK $art: value of $k present"; leak=1; }
else
cstrings "$art" | grep -qaxF -- "$v" && { echo "LEAK $art: value of $k present"; leak=1; }
fi
done <<< "$values"

# 3. secret-shaped strings regardless of any credentials file
grep -qaE -- 'tskey-(auth|client|api)-[A-Za-z0-9]+' "$art" && { echo "LEAK $art: Tailscale key pattern present"; leak=1; }
# header followed by a base64 body — the bare header is an mbedTLS parser constant
tr '\n\r\0' ' ' < "$art" | grep -qaE -- '-----BEGIN [A-Z ]*PRIVATE KEY----- *[A-Za-z0-9+/=]{40,}' && { echo "LEAK $art: PEM private key present"; leak=1; }
grep -qaE -- '(^|[^A-Za-z])(Bearer|Basic) [A-Za-z0-9+/=_-]{16,}' "$art" && { echo "LEAK $art: literal HTTP auth token present"; leak=1; }

if [[ $leak -eq 0 ]]; then echo "clean $art"; else overall=1; fi
done

exit $overall
Loading