Public Access
OTA step 2: signing key, Update File builder, push client
- scripts/ota_keygen.sh: ECDSA P-256 key pair; the private key goes to ~/.config/roro9stack/ (0600), the public key to keys/ and src/platform/ota_public_key.h; .gitignore refuses *key.pem - scripts/make_ota.py: wraps firmware.bin into a signed .ota (openssl) - scripts/ota_push.py: sends it over TCP 3232, prints the device's answer - scripts/flash.sh --ota <host>: build, sign, push Checked: a generated .ota has the documented layout and its signature verifies with openssl against the committed public key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -1,3 +1,6 @@
|
|||||||
.pio/
|
.pio/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
|
# Private signing keys never belong in the repository (ADR 0003)
|
||||||
|
*key.pem
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIWzT07fvTpQxWjTdewMipYH6f42+
|
||||||
|
mM8niHm+T8y+Mvjanb3H8hpYXg3VjuJGFtcHw/hFX0Q2f2AiSHMF0DhMbQ==
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
+16
-1
@@ -1,7 +1,22 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Flash the firmware over USB, then open the serial monitor.
|
# 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
|
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 <device IP> (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)}"
|
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; }
|
[ -n "$PORT" ] || { echo "No Cardputer found on USB (is it plugged in / attached to the VM?)" >&2; exit 1; }
|
||||||
source "$(dirname "$0")/_docker.sh"
|
source "$(dirname "$0")/_docker.sh"
|
||||||
|
|||||||
Executable
+50
@@ -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 <firmware.bin> <version> <out.ota> [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("<HHI", 1, HEADER_SIZE, len(image)) +
|
||||||
|
hashlib.sha256(image).digest() + version_bytes.ljust(32, b"\0"))
|
||||||
|
assert len(signed) == SIGNED_BYTES
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile() as f:
|
||||||
|
f.write(signed)
|
||||||
|
f.flush()
|
||||||
|
signature = subprocess.run(["openssl", "dgst", "-sha256", "-sign", key, f.name],
|
||||||
|
check=True, capture_output=True).stdout
|
||||||
|
if len(signature) > MAX_SIGNATURE:
|
||||||
|
sys.exit("unexpected signature size")
|
||||||
|
|
||||||
|
header = signed + struct.pack("<H", len(signature)) + signature
|
||||||
|
header = header.ljust(HEADER_SIZE, b"\0")
|
||||||
|
with open(out_path, "wb") as out:
|
||||||
|
out.write(header + image)
|
||||||
|
print(f"{out_path}: {version}, {len(image)} bytes, signed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Creates the Firmware Update signing key pair, once (ADR 0003).
|
||||||
|
# The private key stays in ~/.config/roro9stack/ and must never be committed; the public key is
|
||||||
|
# written into the firmware source. Losing the private key means the next update goes over USB.
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
KEY="${RORO_OTA_KEY:-$HOME/.config/roro9stack/ota-key.pem}"
|
||||||
|
PUB_PEM="$ROOT/keys/ota-public.pem"
|
||||||
|
PUB_H="$ROOT/src/platform/ota_public_key.h"
|
||||||
|
|
||||||
|
if [ -e "$KEY" ]; then
|
||||||
|
echo "A signing key already exists at $KEY; not overwriting it." >&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)"
|
||||||
Executable
+40
@@ -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 <file.ota> <host>
|
||||||
|
<host> 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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user