Harden RF node control and agent updates
This commit is contained in:
355
tools/rf001_build_agent_packages.py
Normal file
355
tools/rf001_build_agent_packages.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user