diff --git a/.gitignore b/.gitignore index c009a69..9b8ac3c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ firmware/.pio/ *.log *secret* __pycache__/ +secrets/ +server/firmware/ diff --git a/firmware/lib/ota_updater/ota_pubkey.h b/firmware/lib/ota_updater/ota_pubkey.h new file mode 100644 index 0000000..c0ed66a --- /dev/null +++ b/firmware/lib/ota_updater/ota_pubkey.h @@ -0,0 +1,4 @@ +#pragma once +// Auto-generated by tools/gen_signing_key.py — DO NOT EDIT +// ECDSA P-256 public key, uncompressed X9.62 (04 || X || Y) +static const uint8_t kOtaPublicKey[65] = {0x04, 0x1c, 0x92, 0x43, 0x23, 0xe9, 0xac, 0xd1, 0xe8, 0x05, 0x32, 0x49, 0x39, 0x12, 0x95, 0xb2, 0x0a, 0x3e, 0xfb, 0x9d, 0xdf, 0xee, 0xd1, 0x98, 0x87, 0x97, 0xa3, 0xb8, 0xcb, 0x2b, 0xa6, 0x06, 0xe0, 0x83, 0x32, 0x71, 0xd2, 0x5f, 0x80, 0x40, 0x68, 0xcd, 0x00, 0xe5, 0x0e, 0xba, 0x13, 0xf6, 0x97, 0x43, 0x6f, 0xe6, 0x4f, 0xd0, 0x95, 0x53, 0x0e, 0xd7, 0x9a, 0x8a, 0x2e, 0x25, 0x52, 0xb4, 0xaf}; diff --git a/tools/gen_signing_key.py b/tools/gen_signing_key.py new file mode 100644 index 0000000..c335fa7 --- /dev/null +++ b/tools/gen_signing_key.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Generate ECDSA P-256 signing keypair for OTA firmware verification.""" +import argparse +import os +from pathlib import Path + +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization + + +def generate(secrets_dir: Path, header_out: Path) -> None: + secrets_dir.mkdir(parents=True, exist_ok=True) + + key = ec.generate_private_key(ec.SECP256R1()) + + pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + key_path = secrets_dir / "firmware_signing_key.pem" + key_path.write_bytes(pem) + key_path.chmod(0o600) + + pub_bytes = key.public_key().public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint, + ) + assert len(pub_bytes) == 65 and pub_bytes[0] == 0x04 + + hex_values = ", ".join(f"0x{b:02x}" for b in pub_bytes) + header = ( + "#pragma once\n" + "// Auto-generated by tools/gen_signing_key.py — DO NOT EDIT\n" + "// ECDSA P-256 public key, uncompressed X9.62 (04 || X || Y)\n" + f"static const uint8_t kOtaPublicKey[65] = {{{hex_values}}};\n" + ) + header_out.parent.mkdir(parents=True, exist_ok=True) + header_out.write_text(header) + + print(f"Private key → {key_path}") + print(f"Public key header → {header_out}") + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--secrets-dir", default="secrets", + help="Directory for private key (default: secrets/)") + p.add_argument("--header-out", + default="firmware/lib/ota_updater/ota_pubkey.h", + help="Path to write the C header") + args = p.parse_args() + generate(Path(args.secrets_dir), Path(args.header_out)) + + +if __name__ == "__main__": + main() diff --git a/tools/test_gen_signing_key.py b/tools/test_gen_signing_key.py new file mode 100644 index 0000000..ddd1b36 --- /dev/null +++ b/tools/test_gen_signing_key.py @@ -0,0 +1,49 @@ +import os, subprocess, sys, tempfile +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent + +def run_gen(secrets_dir, header_path): + env = os.environ.copy() + result = subprocess.run( + [sys.executable, str(REPO_ROOT / "tools/gen_signing_key.py"), + "--secrets-dir", str(secrets_dir), + "--header-out", str(header_path)], + capture_output=True, text=True, env=env + ) + assert result.returncode == 0, result.stderr + return result + +def test_private_key_created(): + with tempfile.TemporaryDirectory() as d: + header = Path(d) / "ota_pubkey.h" + run_gen(d, header) + pem = Path(d) / "firmware_signing_key.pem" + assert pem.exists() + content = pem.read_text() + assert "BEGIN PRIVATE KEY" in content + +def test_header_created(): + with tempfile.TemporaryDirectory() as d: + header = Path(d) / "ota_pubkey.h" + run_gen(d, header) + assert header.exists() + content = header.read_text() + assert "kOtaPublicKey" in content + assert "0x04" in content # uncompressed point prefix + assert "[65]" in content + +def test_public_key_is_valid_p256_point(): + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.hazmat.primitives import serialization + with tempfile.TemporaryDirectory() as d: + header = Path(d) / "ota_pubkey.h" + run_gen(d, header) + pem = (Path(d) / "firmware_signing_key.pem").read_bytes() + priv = serialization.load_pem_private_key(pem, password=None) + pub_bytes = priv.public_key().public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint, + ) + assert len(pub_bytes) == 65 + assert pub_bytes[0] == 0x04