feat(factory): add local flashing toolkit and secure OTA defaults
This commit is contained in:
21
tools/esptool-factory/factory_common/__init__.py
Normal file
21
tools/esptool-factory/factory_common/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Common helpers for factory tooling."""
|
||||
|
||||
from .firmware_envelope import (
|
||||
FirmwareEnvelopeError,
|
||||
decrypt_firmware_blob,
|
||||
encrypt_firmware_blob,
|
||||
load_firmware_decrypt_key,
|
||||
load_firmware_encrypt_key,
|
||||
)
|
||||
from .signing import SignatureError, sign_file, verify_file_signature
|
||||
|
||||
__all__ = [
|
||||
"FirmwareEnvelopeError",
|
||||
"SignatureError",
|
||||
"decrypt_firmware_blob",
|
||||
"encrypt_firmware_blob",
|
||||
"load_firmware_decrypt_key",
|
||||
"load_firmware_encrypt_key",
|
||||
"sign_file",
|
||||
"verify_file_signature",
|
||||
]
|
||||
153
tools/esptool-factory/factory_common/firmware_envelope.py
Normal file
153
tools/esptool-factory/factory_common/firmware_envelope.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
try:
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
except Exception as exc: # noqa: BLE001
|
||||
AESGCM = None # type: ignore[assignment]
|
||||
InvalidTag = Exception # type: ignore[assignment]
|
||||
_CRYPTO_IMPORT_ERROR = exc
|
||||
else:
|
||||
_CRYPTO_IMPORT_ERROR = None
|
||||
|
||||
|
||||
MAGIC = b"TQFWENC1"
|
||||
NONCE_SIZE = 12
|
||||
SHA256_SIZE = 32
|
||||
MIN_PAYLOAD_SIZE = len(MAGIC) + NONCE_SIZE + SHA256_SIZE + 16
|
||||
DEFAULT_FIRMWARE_KEY_RELATIVE_PATH = Path("keys") / "firmware" / "fw_key.bin"
|
||||
|
||||
|
||||
class FirmwareEnvelopeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _require_crypto() -> None:
|
||||
if _CRYPTO_IMPORT_ERROR is not None or AESGCM is None:
|
||||
raise FirmwareEnvelopeError("cryptography 不可用,无法处理 .enc 固件。")
|
||||
|
||||
|
||||
def _parse_key_material(raw: bytes, source: str) -> bytes:
|
||||
if len(raw) == 32:
|
||||
return raw
|
||||
|
||||
try:
|
||||
text = raw.decode("utf-8").strip()
|
||||
except UnicodeDecodeError as exc:
|
||||
raise FirmwareEnvelopeError(f"固件密钥格式无效({source}):既不是 32 字节原始密钥,也不是文本编码。") from exc
|
||||
|
||||
if not text:
|
||||
raise FirmwareEnvelopeError(f"固件密钥为空({source})。")
|
||||
|
||||
lowered = text.lower()
|
||||
if lowered.startswith("hex:"):
|
||||
text = text[4:].strip()
|
||||
if len(text) == 64:
|
||||
try:
|
||||
key = bytes.fromhex(text)
|
||||
except ValueError as exc:
|
||||
raise FirmwareEnvelopeError(f"固件密钥 HEX 无效({source})。") from exc
|
||||
if len(key) == 32:
|
||||
return key
|
||||
|
||||
if lowered.startswith("base64:"):
|
||||
text = text[7:].strip()
|
||||
try:
|
||||
key = base64.b64decode(text.encode("utf-8"), validate=True)
|
||||
except Exception: # noqa: BLE001
|
||||
key = b""
|
||||
if len(key) == 32:
|
||||
return key
|
||||
|
||||
raise FirmwareEnvelopeError(
|
||||
f"固件密钥长度无效({source}):需要 32 字节,或 64 位 HEX,或 32 字节 Base64。"
|
||||
)
|
||||
|
||||
|
||||
def _iter_runtime_roots() -> Iterable[Path]:
|
||||
exe_path = Path(sys.executable).resolve()
|
||||
yield exe_path.parent
|
||||
for parent in exe_path.parents:
|
||||
if parent.suffix.lower() == ".app":
|
||||
yield parent.parent
|
||||
break
|
||||
|
||||
|
||||
def _candidate_key_paths() -> Iterable[Path]:
|
||||
if getattr(sys, "frozen", False):
|
||||
for root in _iter_runtime_roots():
|
||||
yield root / DEFAULT_FIRMWARE_KEY_RELATIVE_PATH
|
||||
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
yield parent / DEFAULT_FIRMWARE_KEY_RELATIVE_PATH
|
||||
|
||||
|
||||
def _load_key_from_fixed_path(purpose: str) -> bytes:
|
||||
for path in _candidate_key_paths():
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise FirmwareEnvelopeError(f"{purpose}密钥文件读取失败:{path}") from exc
|
||||
return _parse_key_material(raw, str(path))
|
||||
|
||||
raise FirmwareEnvelopeError(
|
||||
f"缺少{purpose}密钥文件。请在运行目录提供 {DEFAULT_FIRMWARE_KEY_RELATIVE_PATH}。"
|
||||
)
|
||||
|
||||
|
||||
def load_firmware_encrypt_key() -> bytes:
|
||||
return _load_key_from_fixed_path(purpose="固件加密")
|
||||
|
||||
|
||||
def load_firmware_decrypt_key() -> bytes:
|
||||
return _load_key_from_fixed_path(purpose="固件解密")
|
||||
|
||||
|
||||
def encrypt_firmware_blob(plain: bytes, key: bytes) -> bytes:
|
||||
_require_crypto()
|
||||
if len(key) != 32:
|
||||
raise FirmwareEnvelopeError("固件加密密钥长度无效,必须为 32 字节。")
|
||||
nonce = os.urandom(NONCE_SIZE)
|
||||
digest = hashlib.sha256(plain).digest()
|
||||
aes = AESGCM(key)
|
||||
ciphertext = aes.encrypt(nonce, plain, MAGIC)
|
||||
return MAGIC + nonce + digest + ciphertext
|
||||
|
||||
|
||||
def decrypt_firmware_blob(payload: bytes, key: bytes) -> bytes:
|
||||
_require_crypto()
|
||||
if len(key) != 32:
|
||||
raise FirmwareEnvelopeError("固件解密密钥长度无效,必须为 32 字节。")
|
||||
if len(payload) < MIN_PAYLOAD_SIZE:
|
||||
raise FirmwareEnvelopeError("固件 .enc 文件格式无效:长度不足。")
|
||||
if payload[: len(MAGIC)] != MAGIC:
|
||||
raise FirmwareEnvelopeError("固件 .enc 文件格式无效:magic 不匹配。")
|
||||
|
||||
cursor = len(MAGIC)
|
||||
nonce = payload[cursor : cursor + NONCE_SIZE]
|
||||
cursor += NONCE_SIZE
|
||||
expected_digest = payload[cursor : cursor + SHA256_SIZE]
|
||||
cursor += SHA256_SIZE
|
||||
ciphertext = payload[cursor:]
|
||||
|
||||
aes = AESGCM(key)
|
||||
try:
|
||||
plain = aes.decrypt(nonce, ciphertext, MAGIC)
|
||||
except InvalidTag as exc:
|
||||
raise FirmwareEnvelopeError("固件 .enc 解密失败:密钥错误或密文损坏。") from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise FirmwareEnvelopeError("固件 .enc 解密失败。") from exc
|
||||
|
||||
digest = hashlib.sha256(plain).digest()
|
||||
if digest != expected_digest:
|
||||
raise FirmwareEnvelopeError("固件 .enc 解密后哈希不一致。")
|
||||
return plain
|
||||
92
tools/esptool-factory/factory_common/idf_artifacts.py
Normal file
92
tools/esptool-factory/factory_common/idf_artifacts.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict:
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _from_build(build_dir: Path, relative_path: object) -> Path | None:
|
||||
if not isinstance(relative_path, str) or not relative_path.strip():
|
||||
return None
|
||||
candidate = build_dir / relative_path
|
||||
if candidate.exists() and candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _collect_from_flasher_args(build_dir: Path) -> dict[str, Path]:
|
||||
result: dict[str, Path] = {}
|
||||
data = _load_json(build_dir / "flasher_args.json")
|
||||
|
||||
flash_files = data.get("flash_files")
|
||||
if isinstance(flash_files, dict):
|
||||
for rel in flash_files.values():
|
||||
path = _from_build(build_dir, rel)
|
||||
if path is not None:
|
||||
result[path.name] = path
|
||||
|
||||
for key in ("bootloader", "app", "partition-table"):
|
||||
section = data.get(key)
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
path = _from_build(build_dir, section.get("file"))
|
||||
if path is not None:
|
||||
result[path.name] = path
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _find_in_build_tree(build_dir: Path, name: str) -> Path | None:
|
||||
direct = build_dir / name
|
||||
if direct.exists() and direct.is_file():
|
||||
return direct
|
||||
for path in build_dir.glob(f"**/{name}"):
|
||||
if path.is_file():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def sync_bins_from_idf_build(
|
||||
bin_dir: Path,
|
||||
required_bins: Iterable[str],
|
||||
build_dir: Path,
|
||||
) -> list[tuple[Path, Path]]:
|
||||
if not build_dir.exists() or not build_dir.is_dir():
|
||||
return []
|
||||
|
||||
build_map = _collect_from_flasher_args(build_dir)
|
||||
copied: list[tuple[Path, Path]] = []
|
||||
seen: set[str] = set()
|
||||
for raw_name in required_bins:
|
||||
name = str(raw_name).strip()
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
|
||||
target = bin_dir / name
|
||||
if target.exists():
|
||||
continue
|
||||
|
||||
source = build_map.get(name)
|
||||
if source is None:
|
||||
source = _find_in_build_tree(build_dir, name)
|
||||
if source is None:
|
||||
continue
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
copied.append((source, target))
|
||||
return copied
|
||||
182
tools/esptool-factory/factory_common/signing.py
Normal file
182
tools/esptool-factory/factory_common/signing.py
Normal file
@@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from factory_common.signing_pubkey import PUBLIC_KEY_PEM
|
||||
|
||||
try:
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed25519, ed448, padding, rsa
|
||||
except Exception as exc: # noqa: BLE001
|
||||
InvalidSignature = None # type: ignore[assignment]
|
||||
hashes = None # type: ignore[assignment]
|
||||
serialization = None # type: ignore[assignment]
|
||||
dsa = ec = ed25519 = ed448 = padding = rsa = None # type: ignore[assignment]
|
||||
_CRYPTO_IMPORT_ERROR = exc
|
||||
else:
|
||||
_CRYPTO_IMPORT_ERROR = None
|
||||
|
||||
|
||||
class SignatureError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _require_crypto() -> None:
|
||||
if _CRYPTO_IMPORT_ERROR is not None or serialization is None:
|
||||
raise SignatureError("cryptography is required for signature operations")
|
||||
|
||||
|
||||
def sig_path_for(path: Path) -> Path:
|
||||
return Path(str(path) + ".sig")
|
||||
|
||||
|
||||
def _load_public_key(pem: bytes) -> object:
|
||||
_require_crypto()
|
||||
if not pem:
|
||||
raise SignatureError("public key is empty")
|
||||
try:
|
||||
return serialization.load_pem_public_key(pem)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise SignatureError("invalid public key format") from exc
|
||||
|
||||
|
||||
def _load_private_key(path: Path, password: Optional[str]) -> object:
|
||||
_require_crypto()
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise SignatureError(f"unable to read private key: {path}") from exc
|
||||
password_bytes = password.encode("utf-8") if password else None
|
||||
try:
|
||||
return serialization.load_pem_private_key(data, password=password_bytes)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SignatureError("invalid private key or password") from exc
|
||||
|
||||
|
||||
def _sha256_b64(data: bytes) -> str:
|
||||
digest = hashlib.sha256(data).digest()
|
||||
return base64.b64encode(digest).decode("ascii")
|
||||
|
||||
|
||||
def _sign_bytes(data: bytes, private_key: object) -> Tuple[bytes, str]:
|
||||
_require_crypto()
|
||||
if ed25519 and isinstance(private_key, ed25519.Ed25519PrivateKey):
|
||||
return private_key.sign(data), "ed25519"
|
||||
if ed448 and isinstance(private_key, ed448.Ed448PrivateKey):
|
||||
return private_key.sign(data), "ed448"
|
||||
if ec and isinstance(private_key, ec.EllipticCurvePrivateKey):
|
||||
return private_key.sign(data, ec.ECDSA(hashes.SHA256())), "ecdsa-sha256"
|
||||
if rsa and isinstance(private_key, rsa.RSAPrivateKey):
|
||||
signature = private_key.sign(
|
||||
data,
|
||||
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
return signature, "rsa-pss-sha256"
|
||||
if dsa and isinstance(private_key, dsa.DSAPrivateKey):
|
||||
return private_key.sign(data, hashes.SHA256()), "dsa-sha256"
|
||||
raise SignatureError("unsupported private key type")
|
||||
|
||||
|
||||
def _verify_bytes(data: bytes, signature: bytes, public_key: object, alg_hint: Optional[str]) -> None:
|
||||
_require_crypto()
|
||||
try:
|
||||
if ed25519 and isinstance(public_key, ed25519.Ed25519PublicKey):
|
||||
public_key.verify(signature, data)
|
||||
return
|
||||
if ed448 and isinstance(public_key, ed448.Ed448PublicKey):
|
||||
public_key.verify(signature, data)
|
||||
return
|
||||
if ec and isinstance(public_key, ec.EllipticCurvePublicKey):
|
||||
public_key.verify(signature, data, ec.ECDSA(hashes.SHA256()))
|
||||
return
|
||||
if rsa and isinstance(public_key, rsa.RSAPublicKey):
|
||||
public_key.verify(
|
||||
signature,
|
||||
data,
|
||||
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
return
|
||||
if dsa and isinstance(public_key, dsa.DSAPublicKey):
|
||||
public_key.verify(signature, data, hashes.SHA256())
|
||||
return
|
||||
except InvalidSignature as exc:
|
||||
raise SignatureError("signature verification failed") from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise SignatureError("signature verification failed") from exc
|
||||
raise SignatureError("unsupported public key type")
|
||||
|
||||
|
||||
def _parse_signature(sig_path: Path) -> Tuple[bytes, Dict[str, Any]]:
|
||||
try:
|
||||
raw = sig_path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise SignatureError(f"unable to read signature file: {sig_path}") from exc
|
||||
text = raw.decode("utf-8", errors="strict").strip()
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
payload = None
|
||||
if isinstance(payload, dict):
|
||||
sig_b64 = payload.get("sig_b64")
|
||||
if not isinstance(sig_b64, str):
|
||||
raise SignatureError("signature file missing sig_b64")
|
||||
try:
|
||||
signature = base64.b64decode(sig_b64.encode("ascii"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise SignatureError("invalid base64 signature") from exc
|
||||
return signature, payload
|
||||
try:
|
||||
signature = base64.b64decode(text.encode("ascii"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise SignatureError("invalid signature format") from exc
|
||||
return signature, {}
|
||||
|
||||
|
||||
def sign_file(path: Path, private_key_path: Path, password: Optional[str] = None) -> Path:
|
||||
data = path.read_bytes()
|
||||
private_key = _load_private_key(private_key_path, password)
|
||||
signature, alg = _sign_bytes(data, private_key)
|
||||
payload = {
|
||||
"alg": alg,
|
||||
"hash": "sha256",
|
||||
"hash_b64": _sha256_b64(data),
|
||||
"sig_b64": base64.b64encode(signature).decode("ascii"),
|
||||
}
|
||||
sig_path = sig_path_for(path)
|
||||
sig_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
return sig_path
|
||||
|
||||
|
||||
def verify_file_signature(path: Path) -> None:
|
||||
sig_path = sig_path_for(path)
|
||||
if not sig_path.exists():
|
||||
raise SignatureError(f"missing signature file: {sig_path.name}")
|
||||
signature, payload = _parse_signature(sig_path)
|
||||
data = path.read_bytes()
|
||||
hash_b64 = payload.get("hash_b64")
|
||||
if isinstance(hash_b64, str):
|
||||
current = _sha256_b64(data)
|
||||
if current != hash_b64:
|
||||
raise SignatureError("content hash mismatch")
|
||||
public_key = _load_public_key(PUBLIC_KEY_PEM)
|
||||
alg_hint = payload.get("alg") if isinstance(payload, dict) else None
|
||||
_verify_bytes(data, signature, public_key, alg_hint)
|
||||
|
||||
|
||||
def verify_payload_signature(payload: bytes, sig_b64: str, alg_hint: Optional[str] = None) -> None:
|
||||
_require_crypto()
|
||||
if not isinstance(sig_b64, str) or not sig_b64.strip():
|
||||
raise SignatureError("missing signature")
|
||||
try:
|
||||
signature = base64.b64decode(sig_b64.encode("ascii"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise SignatureError("invalid base64 signature") from exc
|
||||
public_key = _load_public_key(PUBLIC_KEY_PEM)
|
||||
_verify_bytes(payload, signature, public_key, alg_hint)
|
||||
51
tools/esptool-factory/factory_common/signing_pubkey.py
Normal file
51
tools/esptool-factory/factory_common/signing_pubkey.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Factory signing public key loader (single source of truth)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
_DEFAULT_RELATIVE_PATH = Path("keys") / "factory_signing" / "factory_signing_pubkey.pem"
|
||||
|
||||
def _iter_runtime_roots() -> Iterable[Path]:
|
||||
exe_path = Path(sys.executable).resolve()
|
||||
yield exe_path.parent
|
||||
for parent in exe_path.parents:
|
||||
if parent.suffix.lower() == ".app":
|
||||
yield parent.parent
|
||||
break
|
||||
|
||||
|
||||
def _candidate_paths() -> Iterable[Path]:
|
||||
env_path = os.getenv("FACTORY_SIGNING_PUBKEY_PATH")
|
||||
if env_path:
|
||||
yield Path(env_path).expanduser()
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
for root in _iter_runtime_roots():
|
||||
yield root / _DEFAULT_RELATIVE_PATH
|
||||
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
yield parent / _DEFAULT_RELATIVE_PATH
|
||||
|
||||
|
||||
def _load_public_key_pem() -> bytes:
|
||||
for path in _candidate_paths():
|
||||
if path.exists():
|
||||
data = path.read_bytes()
|
||||
try:
|
||||
data.decode("ascii")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise RuntimeError(f"Signing public key must be ASCII PEM: {path}") from exc
|
||||
return data
|
||||
raise RuntimeError(
|
||||
"Factory signing public key not found. "
|
||||
"Set FACTORY_SIGNING_PUBKEY_PATH or place "
|
||||
f"{_DEFAULT_RELATIVE_PATH} next to the executable."
|
||||
)
|
||||
|
||||
|
||||
PUBLIC_KEY_PEM = _load_public_key_pem()
|
||||
Reference in New Issue
Block a user