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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,3 +7,5 @@ firmware/.pio/
|
|||||||
*.log
|
*.log
|
||||||
*secret*
|
*secret*
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
secrets/
|
||||||
|
server/firmware/
|
||||||
|
|||||||
4
firmware/lib/ota_updater/ota_pubkey.h
Normal file
4
firmware/lib/ota_updater/ota_pubkey.h
Normal file
@@ -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};
|
||||||
57
tools/gen_signing_key.py
Normal file
57
tools/gen_signing_key.py
Normal 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()
|
||||||
49
tools/test_gen_signing_key.py
Normal file
49
tools/test_gen_signing_key.py
Normal 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
|
||||||
Reference in New Issue
Block a user