diff --git a/.gitignore b/.gitignore index 5706dce..45ce5b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .pio/ .vscode/ *.pyc + +# Private signing keys never belong in the repository (ADR 0003) +*key.pem diff --git a/keys/ota-public.pem b/keys/ota-public.pem new file mode 100644 index 0000000..4076b8b --- /dev/null +++ b/keys/ota-public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIWzT07fvTpQxWjTdewMipYH6f42+ +mM8niHm+T8y+Mvjanb3H8hpYXg3VjuJGFtcHw/hFX0Q2f2AiSHMF0DhMbQ== +-----END PUBLIC KEY----- diff --git a/scripts/flash.sh b/scripts/flash.sh index 01739ac..0b49078 100755 --- a/scripts/flash.sh +++ b/scripts/flash.sh @@ -1,7 +1,22 @@ #!/usr/bin/env bash # Flash the firmware over USB, then open the serial monitor. -# Usage: scripts/flash.sh [port] (default: the first Espressif device found) +# Usage: scripts/flash.sh [port] USB (default: the first Espressif device found) +# scripts/flash.sh --ota [host] Wi-Fi: build, sign and push a Firmware Update +# (host: the device's IP from Settings > About, or $RORO_OTA_HOST) set -euo pipefail + +if [ "${1:-}" = "--ota" ]; then + ROOT="$(cd "$(dirname "$0")/.." && pwd)" + HOST="${2:-${RORO_OTA_HOST:-}}" + [ -n "$HOST" ] || { echo "Usage: scripts/flash.sh --ota (or set RORO_OTA_HOST)" >&2; exit 1; } + source "$(dirname "$0")/_docker.sh" + DOCKER_EXTRA=() + run_in_container bash -c 'git config --global --add safe.directory /work && pio run -e cardputer-adv' | tail -3 + VERSION="$(git -C "$ROOT" describe --tags --always --dirty)" + OUT="$ROOT/.pio/build/cardputer-adv/roro9stack-$VERSION.ota" + "$ROOT/scripts/make_ota.py" "$ROOT/.pio/build/cardputer-adv/firmware.bin" "$VERSION" "$OUT" + exec "$ROOT/scripts/ota_push.py" "$OUT" "$HOST" +fi PORT="${1:-$(readlink -f /dev/serial/by-id/*Espressif* 2>/dev/null | head -1)}" [ -n "$PORT" ] || { echo "No Cardputer found on USB (is it plugged in / attached to the VM?)" >&2; exit 1; } source "$(dirname "$0")/_docker.sh" diff --git a/scripts/make_ota.py b/scripts/make_ota.py new file mode 100755 index 0000000..2d53bb1 --- /dev/null +++ b/scripts/make_ota.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Wraps a firmware image into a signed Update File (.ota). See lib/ota/src/update_parser.h. + +Usage: scripts/make_ota.py [private key] +Signs with openssl (ECDSA P-256 over SHA-256 of the header's first 80 bytes). +""" +import hashlib +import os +import struct +import subprocess +import sys +import tempfile + +HEADER_SIZE = 160 +SIGNED_BYTES = 80 +MAX_SIGNATURE = 72 + + +def main(): + if len(sys.argv) < 4: + sys.exit(__doc__) + image_path, version, out_path = sys.argv[1:4] + key = sys.argv[4] if len(sys.argv) > 4 else os.environ.get( + "RORO_OTA_KEY", os.path.expanduser("~/.config/roro9stack/ota-key.pem")) + if not os.path.exists(key): + sys.exit(f"No signing key at {key}: run scripts/ota_keygen.sh first.") + + image = open(image_path, "rb").read() + version_bytes = version.encode()[:31] + signed = (b"RORO-OTA" + struct.pack(" MAX_SIGNATURE: + sys.exit("unexpected signature size") + + header = signed + struct.pack("&2 + exit 1 +fi +mkdir -p "$(dirname "$KEY")" "$ROOT/keys" +( umask 077; openssl ecparam -name prime256v1 -genkey -noout -out "$KEY" ) +openssl ec -in "$KEY" -pubout -out "$PUB_PEM" 2>/dev/null + +{ + echo "#pragma once" + echo "" + echo "// Public key that Firmware Updates must be signed for (ECDSA P-256). Generated by" + echo "// scripts/ota_keygen.sh; the private key is not in this repository (ADR 0003)." + echo "namespace roro {" + echo "inline constexpr char kOtaPublicKeyPem[] =" + sed 's/^/ "/; s/$/\\n"/' "$PUB_PEM" + echo " ;" + echo "} // namespace roro" +} > "$PUB_H" +echo "Private key: $KEY (keep it safe)" +echo "Public key: $PUB_PEM and $PUB_H (commit these)" diff --git a/scripts/ota_push.py b/scripts/ota_push.py new file mode 100755 index 0000000..aaf06a3 --- /dev/null +++ b/scripts/ota_push.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Pushes a signed Update File to a Cardputer over Wi-Fi (TCP 3232) and reports the result. + +Usage: scripts/ota_push.py + is the device's IP (shown in Settings > About), or its name when mDNS works on your network. +""" +import socket +import sys + +PORT = 3232 + + +def main(): + if len(sys.argv) < 3: + sys.exit(__doc__) + path, host = sys.argv[1:3] + data = open(path, "rb").read() + with socket.create_connection((host, PORT), timeout=15) as s: + s.settimeout(60) + sent = 0 + while sent < len(data): + chunk = data[sent:sent + 4096] + s.sendall(chunk) + sent += len(chunk) + print(f"\rsending {sent * 100 // len(data):3d}%", end="", flush=True) + s.shutdown(socket.SHUT_WR) # end of file: the device checks it and answers + reply = b"" + while not reply.endswith(b"\n"): + part = s.recv(256) + if not part: + break + reply += part + print() + reply = reply.decode(errors="replace").strip() + print(f"device: {reply or '(no answer)'}") + sys.exit(0 if reply.startswith("OK") else 1) + + +if __name__ == "__main__": + main() diff --git a/src/platform/ota_public_key.h b/src/platform/ota_public_key.h new file mode 100644 index 0000000..a64f01e --- /dev/null +++ b/src/platform/ota_public_key.h @@ -0,0 +1,12 @@ +#pragma once + +// Public key that Firmware Updates must be signed for (ECDSA P-256). Generated by +// scripts/ota_keygen.sh; the private key is not in this repository (ADR 0003). +namespace roro { +inline constexpr char kOtaPublicKeyPem[] = + "-----BEGIN PUBLIC KEY-----\n" + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIWzT07fvTpQxWjTdewMipYH6f42+\n" + "mM8niHm+T8y+Mvjanb3H8hpYXg3VjuJGFtcHw/hFX0Q2f2AiSHMF0DhMbQ==\n" + "-----END PUBLIC KEY-----\n" + ; +} // namespace roro