From f37e0d6b07e6772ed7615c73ccbebb29eed454af Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 11 May 2026 06:55:44 -0700 Subject: [PATCH] feat(tools): add firmware deploy tool (sign + stage for server) --- tools/deploy_firmware.py | 43 ++++++++++++++++++++++++ tools/test_deploy_firmware.py | 63 +++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 tools/deploy_firmware.py create mode 100644 tools/test_deploy_firmware.py diff --git a/tools/deploy_firmware.py b/tools/deploy_firmware.py new file mode 100644 index 0000000..8a93ad9 --- /dev/null +++ b/tools/deploy_firmware.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Sign firmware and stage it for the server OTA endpoint.""" +import argparse, hashlib, json +from pathlib import Path + +from sign_firmware import sign_firmware + + +def deploy(firmware_path: Path, key_path: Path, + version: str, output_dir: Path) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + + data = firmware_path.read_bytes() + sig = sign_firmware(firmware_path, key_path) + + (output_dir / "current.bin").write_bytes(data) + (output_dir / "current.sig").write_bytes(sig) + (output_dir / "manifest.json").write_text(json.dumps({ + "version": version, + "size": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + }, indent=2)) + + print(f"Deployed {firmware_path.name} v{version} → {output_dir}/") + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("firmware", help="Path to .bin") + p.add_argument("version", help="Version string, e.g. 1.2.3") + p.add_argument("--key", default="secrets/firmware_signing_key.pem") + p.add_argument("--out-dir", default="server/firmware") + args = p.parse_args() + deploy( + firmware_path=Path(args.firmware), + key_path=Path(args.key), + version=args.version, + output_dir=Path(args.out_dir), + ) + + +if __name__ == "__main__": + main() diff --git a/tools/test_deploy_firmware.py b/tools/test_deploy_firmware.py new file mode 100644 index 0000000..ef2e69b --- /dev/null +++ b/tools/test_deploy_firmware.py @@ -0,0 +1,63 @@ +import json, hashlib, sys +from pathlib import Path +import pytest + +REPO_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(REPO_ROOT / "tools")) + +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization + +from deploy_firmware import deploy + + +@pytest.fixture() +def key_pem(tmp_path): + key = ec.generate_private_key(ec.SECP256R1()) + pem_path = tmp_path / "key.pem" + pem_path.write_bytes(key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + )) + return pem_path + + +def test_deploy_writes_all_artifacts(tmp_path, key_pem): + firmware = tmp_path / "firmware.bin" + firmware.write_bytes(b"fake firmware" * 200) + out_dir = tmp_path / "server_firmware" + + deploy(firmware_path=firmware, key_path=key_pem, + version="1.2.3", output_dir=out_dir) + + assert (out_dir / "current.bin").exists() + assert (out_dir / "current.sig").exists() + assert (out_dir / "manifest.json").exists() + + +def test_manifest_contents(tmp_path, key_pem): + data = b"firmware payload" + firmware = tmp_path / "fw.bin" + firmware.write_bytes(data) + out_dir = tmp_path / "out" + + deploy(firmware_path=firmware, key_path=key_pem, + version="2.0.1", output_dir=out_dir) + + manifest = json.loads((out_dir / "manifest.json").read_text()) + assert manifest["version"] == "2.0.1" + assert manifest["size"] == len(data) + assert manifest["sha256"] == hashlib.sha256(data).hexdigest() + + +def test_signature_is_64_bytes(tmp_path, key_pem): + firmware = tmp_path / "fw.bin" + firmware.write_bytes(b"fw") + out_dir = tmp_path / "out" + + deploy(firmware_path=firmware, key_path=key_pem, + version="1.0.0", output_dir=out_dir) + + sig = (out_dir / "current.sig").read_bytes() + assert len(sig) == 64