feat(factory): add local flashing toolkit and secure OTA defaults

This commit is contained in:
admin
2026-03-02 01:57:39 +08:00
parent 809fcf6548
commit 3d3c3ebac2
27 changed files with 5260 additions and 4 deletions

View 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",
]

View 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

View 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

View 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)

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