feat(tools): add ECDSA P-256 key generation tool and public key header

Generates firmware signing keypair; private key stays in gitignored
secrets/, public key written as 65-byte C array to
firmware/lib/ota_updater/ota_pubkey.h for compile-time OTA verification.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 06:47:10 -07:00
parent 21a3c646aa
commit 437f73739f
4 changed files with 112 additions and 0 deletions

57
tools/gen_signing_key.py Normal file
View File

@@ -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()

View File

@@ -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