Files
SDR-Rover/tools/rf001_agent_update.py
2026-09-30 11:42:19 +03:00

423 lines
17 KiB
Python

"""Fixed release packaging and verification for the RF-001 RX agent.
The module deliberately has no command execution, SDR access, or generic
archive extraction. A release contains one exact allowlist of Python files
and a content manifest. Approval metadata is authenticated separately with
the already provisioned HTTP-channel HMAC secret.
"""
from __future__ import annotations
import ast
from dataclasses import dataclass
import hashlib
import hmac
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
from typing import Any, Mapping
import zipfile
UPDATE_MANIFEST_DOMAIN = b"RF001-UPDATE-MANIFEST-V1\n"
CONTENT_MANIFEST_NAME = "RELEASE_CONTENT.json"
RELEASE_ID_PREFIX = "rf001-agent-"
RELEASE_ID_PATTERN = re.compile(r"^rf001-agent-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$")
AGENT_VERSION_PATTERN = re.compile(r"^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$")
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
MAX_PACKAGE_BYTES = 64 * 1024 * 1024
MAX_UNCOMPRESSED_BYTES = 128 * 1024 * 1024
MAX_ARCHIVE_ENTRIES = 32
MAX_CONTENT_MANIFEST_BYTES = 64 * 1024
RELEASE_SOURCE_FILES: dict[str, str] = {
"app/tools/hackrf_process_check.py": "tools/hackrf_process_check.py",
"app/tools/rf001_agent_update.py": "tools/rf001_agent_update.py",
"app/tools/rf001_capture_runner.py": "tools/rf001_capture_runner.py",
"app/tools/rf001_http_control.py": "tools/rf001_http_control.py",
"app/tools/rf001_offline_decode.py": "tools/rf001_offline_decode.py",
"app/tools/rf001_rx_capture.py": "tools/rf001_rx_capture.py",
"app/tools/rf001_rx_http_agent.py": "tools/rf001_rx_http_agent.py",
"app/tools/rf001_staircase_job.py": "tools/rf001_staircase_job.py",
"app/tools/rf001_staircase_search.py": "tools/rf001_staircase_search.py",
}
REQUIRED_RELEASE_FILES = frozenset(RELEASE_SOURCE_FILES)
class ReleaseValidationError(ValueError):
"""A release is malformed, unauthenticated, or outside its allowlist."""
@dataclass(frozen=True)
class ReleaseInspection:
release_id: str
agent_version: str
protocol_version: int
package_sha256: str
package_bytes: int
content_manifest_sha256: str
uncompressed_bytes: int
file_count: int
def descriptor(self) -> dict[str, object]:
return {
"schema_version": 1,
"release_id": self.release_id,
"agent_version": self.agent_version,
"protocol_version": self.protocol_version,
"package_sha256": self.package_sha256,
"package_bytes": self.package_bytes,
"content_manifest_sha256": self.content_manifest_sha256,
"uncompressed_bytes": self.uncompressed_bytes,
"file_count": self.file_count,
}
def source_agent_version(agent_source: Path) -> str:
"""Read the single literal AGENT_VERSION from an agent source file."""
try:
syntax = ast.parse(agent_source.read_text(encoding="utf-8"), agent_source.name)
except (OSError, SyntaxError, UnicodeError) as error:
raise ReleaseValidationError("agent source cannot be parsed") from error
values: list[ast.expr] = []
for statement in syntax.body:
if isinstance(statement, ast.Assign):
if any(
isinstance(target, ast.Name) and target.id == "AGENT_VERSION"
for target in statement.targets
):
if not (
len(statement.targets) == 1
and isinstance(statement.targets[0], ast.Name)
and statement.targets[0].id == "AGENT_VERSION"
):
raise ReleaseValidationError(
"agent source AGENT_VERSION assignment is ambiguous"
)
values.append(statement.value)
elif (
isinstance(statement, ast.AnnAssign)
and isinstance(statement.target, ast.Name)
and statement.target.id == "AGENT_VERSION"
):
if statement.value is None:
raise ReleaseValidationError("agent source AGENT_VERSION has no value")
values.append(statement.value)
if len(values) != 1:
raise ReleaseValidationError(
"agent source must contain one AGENT_VERSION assignment"
)
value = values[0]
if not isinstance(value, ast.Constant) or not isinstance(value.value, str):
raise ReleaseValidationError("agent source AGENT_VERSION must be a literal string")
if AGENT_VERSION_PATTERN.fullmatch(value.value) is None:
raise ReleaseValidationError("agent source AGENT_VERSION is invalid")
return value.value
def validate_release_identity(
source_root: Path,
*,
release_id: str,
requested_agent_version: str,
) -> str:
"""Require release id, requested version, and packaged source to agree."""
if RELEASE_ID_PATTERN.fullmatch(release_id) is None:
raise ReleaseValidationError("release_id is invalid")
if AGENT_VERSION_PATTERN.fullmatch(requested_agent_version) is None:
raise ReleaseValidationError("agent_version is invalid")
if release_id != f"{RELEASE_ID_PREFIX}{requested_agent_version}":
raise ReleaseValidationError("release_id does not match agent_version")
agent_source = (
source_root / RELEASE_SOURCE_FILES["app/tools/rf001_rx_http_agent.py"]
).resolve()
actual_agent_version = source_agent_version(agent_source)
if actual_agent_version != requested_agent_version:
raise ReleaseValidationError(
"source AGENT_VERSION does not match requested agent_version"
)
return actual_agent_version
def canonical_json_bytes(value: Mapping[str, Any]) -> bytes:
return json.dumps(
value,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
while block := stream.read(4 * 1024 * 1024):
digest.update(block)
return digest.hexdigest()
def sign_update_manifest(token: bytes, manifest: Mapping[str, Any]) -> str:
return hmac.new(
token,
UPDATE_MANIFEST_DOMAIN + canonical_json_bytes(manifest),
hashlib.sha256,
).hexdigest()
def verify_update_manifest_signature(
token: bytes,
manifest: Mapping[str, Any],
signature: str,
) -> None:
if SHA256_PATTERN.fullmatch(signature) is None:
raise ReleaseValidationError("update manifest signature is invalid")
expected = sign_update_manifest(token, manifest)
if not hmac.compare_digest(expected, signature):
raise ReleaseValidationError("update manifest signature mismatch")
def _validate_member_name(name: str) -> PurePosixPath:
if not name or "\\" in name or name.startswith("/"):
raise ReleaseValidationError(f"unsafe archive path: {name!r}")
path = PurePosixPath(name)
if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
raise ReleaseValidationError(f"unsafe archive path: {name!r}")
if any(":" in part for part in path.parts):
raise ReleaseValidationError(f"drive-like archive path: {name!r}")
return path
def _is_link(info: zipfile.ZipInfo) -> bool:
unix_mode = (info.external_attr >> 16) & 0xFFFF
return stat.S_IFMT(unix_mode) == stat.S_IFLNK
def _load_content_manifest(
archive: zipfile.ZipFile,
info: zipfile.ZipInfo,
) -> tuple[dict[str, Any], bytes]:
if info.file_size > MAX_CONTENT_MANIFEST_BYTES:
raise ReleaseValidationError("release content manifest is too large")
raw = archive.read(info)
try:
decoded = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise ReleaseValidationError("release content manifest is invalid") from error
if not isinstance(decoded, dict):
raise ReleaseValidationError("release content manifest must be an object")
if raw != canonical_json_bytes(decoded):
raise ReleaseValidationError("release content manifest is not canonical JSON")
return decoded, raw
def _validate_content_manifest(manifest: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
if manifest.get("schema_version") != 1:
raise ReleaseValidationError("unsupported release content schema")
release_id = manifest.get("release_id")
agent_version = manifest.get("agent_version")
protocol_version = manifest.get("protocol_version")
if not isinstance(release_id, str) or RELEASE_ID_PATTERN.fullmatch(release_id) is None:
raise ReleaseValidationError("release_id is invalid")
if not isinstance(agent_version, str) or AGENT_VERSION_PATTERN.fullmatch(agent_version) is None:
raise ReleaseValidationError("agent_version is invalid")
if type(protocol_version) is not int or protocol_version <= 0:
raise ReleaseValidationError("protocol_version is invalid")
rows = manifest.get("files")
if not isinstance(rows, list):
raise ReleaseValidationError("release files list is invalid")
files: dict[str, dict[str, Any]] = {}
for row in rows:
if not isinstance(row, dict):
raise ReleaseValidationError("release file entry is invalid")
name = row.get("path")
digest = row.get("sha256")
size = row.get("bytes")
if not isinstance(name, str):
raise ReleaseValidationError("release file path is invalid")
_validate_member_name(name)
key = name.casefold()
if key in (existing.casefold() for existing in files):
raise ReleaseValidationError("duplicate release file path")
if SHA256_PATTERN.fullmatch(str(digest)) is None:
raise ReleaseValidationError(f"release file hash is invalid: {name}")
if type(size) is not int or size < 0:
raise ReleaseValidationError(f"release file size is invalid: {name}")
files[name] = {"path": name, "sha256": digest, "bytes": size}
if frozenset(files) != REQUIRED_RELEASE_FILES:
unexpected = sorted(set(files) - REQUIRED_RELEASE_FILES)
missing = sorted(REQUIRED_RELEASE_FILES - set(files))
raise ReleaseValidationError(
f"release file allowlist mismatch; unexpected={unexpected}; missing={missing}"
)
return files
def inspect_release_archive(
package_path: Path,
*,
expected_package_sha256: str | None = None,
expected_package_bytes: int | None = None,
expected_content_manifest_sha256: str | None = None,
extraction_root: Path | None = None,
) -> ReleaseInspection:
package_path = package_path.resolve()
package_bytes = package_path.stat().st_size
if package_bytes <= 0 or package_bytes > MAX_PACKAGE_BYTES:
raise ReleaseValidationError("release package size is outside the limit")
if expected_package_bytes is not None and package_bytes != expected_package_bytes:
raise ReleaseValidationError("release package size mismatch")
package_sha256 = sha256_file(package_path)
if expected_package_sha256 is not None and package_sha256 != expected_package_sha256:
raise ReleaseValidationError("release package SHA256 mismatch")
with zipfile.ZipFile(package_path, "r") as archive:
entries = archive.infolist()
if not entries or len(entries) > MAX_ARCHIVE_ENTRIES:
raise ReleaseValidationError("release archive entry count is invalid")
seen: set[str] = set()
regular: dict[str, zipfile.ZipInfo] = {}
uncompressed_bytes = 0
for info in entries:
path = _validate_member_name(info.filename)
normalized = path.as_posix()
key = normalized.casefold()
if key in seen:
raise ReleaseValidationError("ambiguous duplicate archive entry")
seen.add(key)
if info.flag_bits & 0x1:
raise ReleaseValidationError("encrypted archive entries are prohibited")
if _is_link(info):
raise ReleaseValidationError("archive links are prohibited")
if info.is_dir():
raise ReleaseValidationError("explicit archive directory entries are prohibited")
uncompressed_bytes += info.file_size
if uncompressed_bytes > MAX_UNCOMPRESSED_BYTES:
raise ReleaseValidationError("release uncompressed size exceeds the limit")
regular[normalized] = info
if CONTENT_MANIFEST_NAME not in regular:
raise ReleaseValidationError("release content manifest is absent")
content_manifest, content_raw = _load_content_manifest(
archive,
regular[CONTENT_MANIFEST_NAME],
)
content_sha256 = hashlib.sha256(content_raw).hexdigest()
if (
expected_content_manifest_sha256 is not None
and content_sha256 != expected_content_manifest_sha256
):
raise ReleaseValidationError("release content manifest SHA256 mismatch")
files = _validate_content_manifest(content_manifest)
expected_names = set(REQUIRED_RELEASE_FILES) | {CONTENT_MANIFEST_NAME}
if set(regular) != expected_names:
raise ReleaseValidationError("release archive contents are not allowlisted")
for name, metadata in files.items():
info = regular[name]
if info.file_size != metadata["bytes"]:
raise ReleaseValidationError(f"release file size mismatch: {name}")
digest = hashlib.sha256()
with archive.open(info, "r") as stream:
while block := stream.read(1024 * 1024):
digest.update(block)
if digest.hexdigest() != metadata["sha256"]:
raise ReleaseValidationError(f"release file SHA256 mismatch: {name}")
if extraction_root is not None:
extraction_root = extraction_root.resolve()
extraction_root.mkdir(parents=True, exist_ok=False)
for name in sorted(REQUIRED_RELEASE_FILES):
target = extraction_root.joinpath(*PurePosixPath(name).parts)
target.parent.mkdir(parents=True, exist_ok=True)
resolved = target.resolve()
try:
resolved.relative_to(extraction_root)
except ValueError as error:
raise ReleaseValidationError("archive path escaped extraction root") from error
with archive.open(regular[name], "r") as source:
with target.open("xb") as destination:
shutil.copyfileobj(source, destination, length=1024 * 1024)
manifest_target = extraction_root / CONTENT_MANIFEST_NAME
with manifest_target.open("xb") as stream:
stream.write(content_raw)
return ReleaseInspection(
release_id=str(content_manifest["release_id"]),
agent_version=str(content_manifest["agent_version"]),
protocol_version=int(content_manifest["protocol_version"]),
package_sha256=package_sha256,
package_bytes=package_bytes,
content_manifest_sha256=content_sha256,
uncompressed_bytes=uncompressed_bytes,
file_count=len(files),
)
def build_release_archive(
source_root: Path,
package_path: Path,
*,
release_id: str,
agent_version: str,
protocol_version: int,
) -> ReleaseInspection:
if protocol_version <= 0:
raise ReleaseValidationError("protocol_version is invalid")
source_root = source_root.resolve()
validate_release_identity(
source_root,
release_id=release_id,
requested_agent_version=agent_version,
)
rows = []
sources: dict[str, Path] = {}
for archive_name, relative_source in RELEASE_SOURCE_FILES.items():
source = (source_root / relative_source).resolve()
if not source.is_file():
raise FileNotFoundError(f"release source is absent: {source}")
sources[archive_name] = source
rows.append(
{
"path": archive_name,
"bytes": source.stat().st_size,
"sha256": sha256_file(source),
}
)
content = {
"schema_version": 1,
"release_id": release_id,
"agent_version": agent_version,
"protocol_version": protocol_version,
"files": sorted(rows, key=lambda row: str(row["path"])),
}
content_raw = canonical_json_bytes(content)
package_path.parent.mkdir(parents=True, exist_ok=True)
if package_path.exists():
raise FileExistsError(f"release package already exists: {package_path}")
timestamp = (2026, 9, 29, 0, 0, 0)
with zipfile.ZipFile(
package_path,
"x",
compression=zipfile.ZIP_DEFLATED,
compresslevel=9,
) as archive:
manifest_info = zipfile.ZipInfo(CONTENT_MANIFEST_NAME, timestamp)
manifest_info.compress_type = zipfile.ZIP_DEFLATED
manifest_info.external_attr = 0o100600 << 16
archive.writestr(manifest_info, content_raw)
for name in sorted(sources):
info = zipfile.ZipInfo(name, timestamp)
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o100600 << 16
archive.writestr(info, sources[name].read_bytes())
return inspect_release_archive(package_path)
def atomic_write_json(path: Path, value: Mapping[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_bytes(canonical_json_bytes(value) + b"\n")
os.replace(temporary, path)