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

356 lines
13 KiB
Python

"""Build authenticated RF-001 agent release and bootstrap packages."""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import hashlib
import hmac
import json
from pathlib import Path, PurePosixPath
import re
import shutil
from typing import BinaryIO, Mapping
import zipfile
try:
from tools.rf001_agent_update import (
RELEASE_SOURCE_FILES,
UPDATE_MANIFEST_DOMAIN,
build_release_archive,
canonical_json_bytes,
sha256_file,
validate_release_identity,
)
except ModuleNotFoundError: # direct execution from tools/
from rf001_agent_update import ( # type: ignore[no-redef]
RELEASE_SOURCE_FILES,
UPDATE_MANIFEST_DOMAIN,
build_release_archive,
canonical_json_bytes,
sha256_file,
validate_release_identity,
)
BOOTSTRAP_ID_PATTERN = re.compile(
r"^rf001-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$"
)
BOOTSTRAP_MANIFEST_NAME = "BOOTSTRAP_MANIFEST.json"
BOOTSTRAP_SIGNATURE_NAME = "BOOTSTRAP_MANIFEST.hmac"
REFERENCE_SHA256 = "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9"
MAX_BOOTSTRAP_BYTES = 64 * 1024 * 1024
@dataclass(frozen=True)
class BootstrapInspection:
bootstrap_id: str
agent_release_id: str
agent_version: str
protocol_version: int
package_sha256: str
package_bytes: int
content_manifest_sha256: str
file_count: int
def _token_bytes(path: Path) -> bytes:
value = path.read_text(encoding="utf-8-sig").strip().encode("utf-8")
if len(value) < 32:
raise ValueError("existing signing token is missing or too short")
return value
def _validate_archive_name(name: str) -> None:
path = PurePosixPath(name)
if (
not name
or "\\" in name
or name.startswith("/")
or path.is_absolute()
or any(part in ("", ".", "..") or ":" in part for part in path.parts)
):
raise ValueError(f"unsafe bootstrap path: {name!r}")
def _source_files(
source_root: Path,
*,
agent_release_id: str,
) -> dict[str, Path]:
result = {
"INSTALL_RX_AGENT.cmd": source_root / "INSTALL_RX_AGENT.cmd",
"README_RX_AGENT.txt": source_root / "tools" / "rf001_bootstrap_readme.txt",
"UNINSTALL_RX_AGENT.cmd": source_root / "UNINSTALL_RX_AGENT.cmd",
"launcher/rf001_agent_launcher.py": source_root
/ "tools"
/ "rf001_agent_launcher.py",
"reference/lab065_reference_staircase.cs8": source_root
/ ".local"
/ "rf001_probe"
/ "lab065_reference_staircase.cs8",
"tools/install_rf001_rx_autostart.ps1": source_root
/ "tools"
/ "install_rf001_rx_autostart.ps1",
"tools/uninstall_rf001_rx_autostart.ps1": source_root
/ "tools"
/ "uninstall_rf001_rx_autostart.ps1",
}
for archive_name, relative in RELEASE_SOURCE_FILES.items():
result[f"versions/{agent_release_id}/{archive_name}"] = source_root / relative
return result
def _copy_to_zip(
archive: zipfile.ZipFile,
name: str,
source: Path,
*,
timestamp: tuple[int, int, int, int, int, int],
) -> None:
info = zipfile.ZipInfo(name, timestamp)
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o100600 << 16
with source.open("rb") as input_stream, archive.open(info, "w") as output_stream:
shutil.copyfileobj(input_stream, output_stream, length=1024 * 1024)
def _write_bytes(
archive: zipfile.ZipFile,
name: str,
value: bytes,
*,
timestamp: tuple[int, int, int, int, int, int],
) -> None:
info = zipfile.ZipInfo(name, timestamp)
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o100600 << 16
archive.writestr(info, value)
def build_bootstrap_archive(
source_root: Path,
package_path: Path,
*,
bootstrap_id: str,
agent_release_id: str,
agent_version: str,
protocol_version: int,
token_path: Path,
agent_release_bootstrap_id: str | None = None,
) -> BootstrapInspection:
if BOOTSTRAP_ID_PATTERN.fullmatch(bootstrap_id) is None:
raise ValueError("bootstrap_id is invalid")
if protocol_version <= 0:
raise ValueError("protocol_version is invalid")
release_bootstrap_id = agent_release_bootstrap_id or bootstrap_id
if BOOTSTRAP_ID_PATTERN.fullmatch(release_bootstrap_id) is None:
raise ValueError("agent_release_bootstrap_id is invalid")
source_root = source_root.resolve()
validate_release_identity(
source_root,
release_id=agent_release_id,
requested_agent_version=agent_version,
)
sources = _source_files(source_root, agent_release_id=agent_release_id)
for name, source in sources.items():
_validate_archive_name(name)
if not source.is_file():
raise FileNotFoundError(f"bootstrap source is absent: {source}")
reference = sources["reference/lab065_reference_staircase.cs8"]
if sha256_file(reference) != REFERENCE_SHA256:
raise ValueError("staircase reference SHA256 is invalid")
ready = canonical_json_bytes(
{
"agent_version": agent_version,
"bootstrap_id": release_bootstrap_id,
"hardware_accessed": False,
"protocol_version": protocol_version,
"release_id": agent_release_id,
"rf_tx": 0,
"schema_version": 1,
"status": "READY",
}
) + b"\n"
ready_name = f"versions/{agent_release_id}/READY.json"
rows = [
{
"path": name,
"bytes": source.stat().st_size,
"sha256": sha256_file(source),
}
for name, source in sources.items()
]
rows.append(
{
"path": ready_name,
"bytes": len(ready),
"sha256": hashlib.sha256(ready).hexdigest(),
}
)
manifest: dict[str, object] = {
"schema_version": 1,
"package_kind": "BOOTSTRAP",
"bootstrap_id": bootstrap_id,
"agent_release_bootstrap_id": release_bootstrap_id,
"agent_release_id": agent_release_id,
"agent_version": agent_version,
"protocol_version": protocol_version,
"main_server": "http://192.168.255.185:8765",
"allowed_rx_ipv4": "192.168.255.56",
"reference_sha256": REFERENCE_SHA256,
"files": sorted(rows, key=lambda row: str(row["path"])),
"hardware_accessed": False,
"rf_tx": 0,
}
manifest_raw = canonical_json_bytes(manifest)
signature = hmac.new(
_token_bytes(token_path),
UPDATE_MANIFEST_DOMAIN + manifest_raw,
hashlib.sha256,
).hexdigest().encode("ascii") + b"\n"
package_path.parent.mkdir(parents=True, exist_ok=True)
if package_path.exists():
raise FileExistsError(f"bootstrap 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:
_write_bytes(
archive, BOOTSTRAP_MANIFEST_NAME, manifest_raw, timestamp=timestamp
)
_write_bytes(
archive, BOOTSTRAP_SIGNATURE_NAME, signature, timestamp=timestamp
)
_write_bytes(archive, ready_name, ready, timestamp=timestamp)
for name in sorted(sources):
_copy_to_zip(archive, name, sources[name], timestamp=timestamp)
return inspect_bootstrap_archive(package_path, token_path=token_path)
def _digest_stream(stream: BinaryIO) -> str:
digest = hashlib.sha256()
while block := stream.read(1024 * 1024):
digest.update(block)
return digest.hexdigest()
def inspect_bootstrap_archive(
package_path: Path,
*,
token_path: Path,
) -> BootstrapInspection:
package_bytes = package_path.stat().st_size
if package_bytes <= 0 or package_bytes > MAX_BOOTSTRAP_BYTES:
raise ValueError("bootstrap package size is outside the limit")
with zipfile.ZipFile(package_path, "r") as archive:
infos: dict[str, zipfile.ZipInfo] = {}
for info in archive.infolist():
_validate_archive_name(info.filename)
key = info.filename.casefold()
if key in (name.casefold() for name in infos):
raise ValueError("duplicate bootstrap archive entry")
if info.is_dir() or info.flag_bits & 0x1:
raise ValueError("bootstrap archive contains a prohibited entry")
infos[info.filename] = info
if BOOTSTRAP_MANIFEST_NAME not in infos or BOOTSTRAP_SIGNATURE_NAME not in infos:
raise ValueError("bootstrap authentication metadata is absent")
manifest_raw = archive.read(infos[BOOTSTRAP_MANIFEST_NAME])
manifest = json.loads(manifest_raw.decode("utf-8"))
if not isinstance(manifest, dict) or manifest_raw != canonical_json_bytes(manifest):
raise ValueError("bootstrap manifest is not canonical JSON")
supplied = archive.read(infos[BOOTSTRAP_SIGNATURE_NAME]).decode("ascii").strip()
expected = hmac.new(
_token_bytes(token_path),
UPDATE_MANIFEST_DOMAIN + manifest_raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(supplied, expected):
raise ValueError("bootstrap manifest signature mismatch")
rows = manifest.get("files")
if not isinstance(rows, list):
raise ValueError("bootstrap file list is invalid")
expected_names = {BOOTSTRAP_MANIFEST_NAME, BOOTSTRAP_SIGNATURE_NAME}
for row in rows:
if not isinstance(row, dict):
raise ValueError("bootstrap file entry is invalid")
name = row.get("path")
if not isinstance(name, str):
raise ValueError("bootstrap file path is invalid")
_validate_archive_name(name)
if name in expected_names or name not in infos:
raise ValueError("bootstrap file set is invalid")
expected_names.add(name)
info = infos[name]
if info.file_size != row.get("bytes"):
raise ValueError(f"bootstrap file size mismatch: {name}")
with archive.open(info, "r") as stream:
digest = _digest_stream(stream)
if digest != row.get("sha256"):
raise ValueError(f"bootstrap file SHA256 mismatch: {name}")
if set(infos) != expected_names:
raise ValueError("bootstrap archive contains unexpected files")
return BootstrapInspection(
bootstrap_id=str(manifest["bootstrap_id"]),
agent_release_id=str(manifest["agent_release_id"]),
agent_version=str(manifest["agent_version"]),
protocol_version=int(manifest["protocol_version"]),
package_sha256=sha256_file(package_path),
package_bytes=package_bytes,
content_manifest_sha256=hashlib.sha256(manifest_raw).hexdigest(),
file_count=len(rows),
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
release = subparsers.add_parser("release")
release.add_argument("--source-root", type=Path, required=True)
release.add_argument("--package", type=Path, required=True)
release.add_argument("--release-id", required=True)
release.add_argument("--agent-version", required=True)
release.add_argument("--protocol-version", type=int, default=1)
bootstrap = subparsers.add_parser("bootstrap")
bootstrap.add_argument("--source-root", type=Path, required=True)
bootstrap.add_argument("--package", type=Path, required=True)
bootstrap.add_argument("--bootstrap-id", required=True)
bootstrap.add_argument("--agent-release-id", required=True)
bootstrap.add_argument("--agent-version", required=True)
bootstrap.add_argument("--agent-release-bootstrap-id")
bootstrap.add_argument("--protocol-version", type=int, default=1)
bootstrap.add_argument("--token-file", type=Path, required=True)
arguments = parser.parse_args()
if arguments.command == "release":
inspection = build_release_archive(
arguments.source_root,
arguments.package,
release_id=arguments.release_id,
agent_version=arguments.agent_version,
protocol_version=arguments.protocol_version,
)
else:
inspection = build_bootstrap_archive(
arguments.source_root,
arguments.package,
bootstrap_id=arguments.bootstrap_id,
agent_release_id=arguments.agent_release_id,
agent_version=arguments.agent_version,
protocol_version=arguments.protocol_version,
token_path=arguments.token_file,
agent_release_bootstrap_id=arguments.agent_release_bootstrap_id,
)
print(f"PACKAGE = {arguments.package.resolve()}")
print(f"PACKAGE SHA256 = {inspection.package_sha256}")
print(f"PACKAGE BYTES = {inspection.package_bytes}")
print("HARDWARE ACCESSED = false")
print("RF TX = 0")
if __name__ == "__main__":
main()