release: pin RAK3401 b40 mOTA chain
This commit is contained in:
1 parent
541980a82f
commit
362e1005aa
3 files changed
+348
-127
No files matched your search
@@ -29,16 +29,36 @@ except ImportError:
|
||||
import lora_ota as ota
|
||||
|
||||
|
||||
# These exact ten package transitions passed directly on the physical target,
|
||||
# and the resulting endpoint passed independent SWD readback. The host runner
|
||||
# has received cleanup/recovery fixes since that run and has not itself had a
|
||||
# new clean end-to-end qualification run. Keep the artifact unpublished and
|
||||
# normally gated from live use; never invent a release URL.
|
||||
RELEASE_URL = "unreleased local RAK3401 10-step candidate"
|
||||
ASSET_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90.zip"
|
||||
ASSET_SHA256 = "c0b33f4568985e8b2b8dc99411295907212cf2bad21764b6333d5e0ba298fd61"
|
||||
CHECKSUM_LIST_SHA256 = "3f8c4af8096b96a4aa6506825c387cc8a06f74d5213a29c9387bd11689546881"
|
||||
BUNDLE_ROOT_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90"
|
||||
# The published b40d2e6c prerelease keeps the first nine package bytes from the
|
||||
# physically passed fd98bc90 chain and replaces its endpoint package. The new
|
||||
# step 10 and this hardened host runner have exhaustive offline/simulator
|
||||
# evidence, but not a clean physical transition run, so live use remains gated.
|
||||
RELEASE_URL = (
|
||||
"https://github.com/mikecarper/MeshCore/releases/tag/"
|
||||
"rak3401-mota-v1.16.07-c1caa5ad-to-v1.17.1.5-b40d2e6c"
|
||||
)
|
||||
ASSET_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-b40d2e6c.zip"
|
||||
ASSET_SHA256 = "8e7f0c2565f37d4372fa51dc3ba72b591e7f38b74be09303e48641623f99bce4"
|
||||
CHECKSUM_LIST_SHA256 = "29a02a6c4c83eafa80168a80562b0ded5d8432e6cc02cfaca289cb2ed426681a"
|
||||
BUNDLE_ROOT_NAME = "RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-b40d2e6c"
|
||||
|
||||
# Preserve the exact fd98bc90 candidate whose ten package transitions passed
|
||||
# directly on the physical RAK3401 and whose endpoint passed independent SWD
|
||||
# readback. It remains a separately identified, hidden-lab-ack-gated legacy
|
||||
# artifact; sharing its version and first nine targets with b40d2e6c must never
|
||||
# make the two archives interchangeable.
|
||||
PHYSICALLY_PASSED_FD98_ASSET_NAME = (
|
||||
"RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90.zip"
|
||||
)
|
||||
PHYSICALLY_PASSED_FD98_ASSET_SHA256 = (
|
||||
"c0b33f4568985e8b2b8dc99411295907212cf2bad21764b6333d5e0ba298fd61"
|
||||
)
|
||||
PHYSICALLY_PASSED_FD98_CHECKSUM_LIST_SHA256 = (
|
||||
"3f8c4af8096b96a4aa6506825c387cc8a06f74d5213a29c9387bd11689546881"
|
||||
)
|
||||
PHYSICALLY_PASSED_FD98_ROOT_NAME = (
|
||||
"RAK3401-update-chain-v1.16.7-c1caa5ad-to-v1.17.1.5-fd98bc90"
|
||||
)
|
||||
|
||||
# Preserve the physically qualified nine-step chain for exact offline
|
||||
# recognition and existing live deployments.
|
||||
@@ -140,6 +160,9 @@ MAX_CHECKSUM_LIST_BYTES = 1024 * 1024
|
||||
MAX_PROGRESS_BYTES = 1024 * 1024
|
||||
PINNED_ARCHIVE_CHECKSUMS = {
|
||||
ASSET_SHA256: CHECKSUM_LIST_SHA256,
|
||||
PHYSICALLY_PASSED_FD98_ASSET_SHA256: (
|
||||
PHYSICALLY_PASSED_FD98_CHECKSUM_LIST_SHA256
|
||||
),
|
||||
PHYSICALLY_QUALIFIED_9_ASSET_SHA256: (
|
||||
PHYSICALLY_QUALIFIED_9_CHECKSUM_LIST_SHA256
|
||||
),
|
||||
@@ -241,11 +264,10 @@ COMPACT_RELEASE_ANCHORS = (
|
||||
(9, "2784e4b645bc3dc198de0b8b18d3d7369cd02eca61cd71c46a51b61854da5345"),
|
||||
)
|
||||
|
||||
# Every target in the exact unreleased ten-package fd98bc90 sequence that
|
||||
# passed direct physical transitions. These pins bind both offline verification
|
||||
# and the hidden explicit live-lab override to those exact package bytes; they
|
||||
# do not qualify later host-runner changes.
|
||||
CURRENT_10_CANDIDATE_ANCHORS = (
|
||||
# Every target in the exact legacy fd98bc90 sequence whose package transitions
|
||||
# passed directly on the physical RAK3401. The b40d2e6c prerelease reuses the
|
||||
# first nine package bytes exactly, but has its own endpoint pin below.
|
||||
PHYSICALLY_PASSED_FD98_ANCHORS = (
|
||||
(1, "8364257a2b3a219905e870fad6fbb2040a96ca4b4bb7201b2867534cc2b45530"),
|
||||
(2, "ac5f50e5028378ccfe6ea08bbf32f227f50fdcf5285a7deb866e309fbdd0a88f"),
|
||||
(3, "884b5e9355b4585b7a4e079dbb44d2858ec46e2fa7bb0dba47e810db2a82e349"),
|
||||
@@ -258,6 +280,11 @@ CURRENT_10_CANDIDATE_ANCHORS = (
|
||||
(10, "31c182c888ceb1135e5afb2376610d93cee2e807b556c838e07fd4486c79d095"),
|
||||
)
|
||||
|
||||
CURRENT_10_CANDIDATE_ANCHORS = (
|
||||
*PHYSICALLY_PASSED_FD98_ANCHORS[:9],
|
||||
(10, "a7761da8af1f2447d7f01d72e2b1456b99c0e874a165d6483f48ab1f58e8d905"),
|
||||
)
|
||||
|
||||
# Exact anchors for every image in the accelerated 30-step release. The outer
|
||||
# asset and inner checksum-list pins cover every byte as well; the complete
|
||||
# anchor table keeps structural validation fail-closed if those layers are
|
||||
@@ -336,31 +363,86 @@ SUPERSEDED_30_MESSAGE = (
|
||||
"the deployed bootloader. Use --verify-only for the older bundle."
|
||||
)
|
||||
CURRENT_10_CANDIDATE_MESSAGE = (
|
||||
"live installation of the exact fd98bc90 ten-step candidate is disabled: "
|
||||
"the ten pinned package transitions completed directly on the physical "
|
||||
"RAK3401 and the endpoint passed independent SWD readback, but the current "
|
||||
"host runner includes later cleanup/recovery fixes that have not had a new "
|
||||
"clean end-to-end physical run. The artifact is also local and unpublished. "
|
||||
"Use --verify-only with the explicit local bundle path, or the hidden "
|
||||
"controlled-lab override, until the exact artifact and qualification record "
|
||||
"are published."
|
||||
"live installation of the exact published b40d2e6c ten-step prerelease is "
|
||||
"disabled: steps 1-9 are byte-identical to package transitions that "
|
||||
"completed directly on the physical RAK3401, but the new b40d2e6c step 10 "
|
||||
"and this hardened host runner have passed offline reconstruction, motatool "
|
||||
"verification, and both bootloader simulators only. The exact final "
|
||||
"transition has not had a clean physical run. Use --verify-only, or the "
|
||||
"hidden controlled-lab override, until that qualification is complete."
|
||||
)
|
||||
PHYSICALLY_PASSED_FD98_MESSAGE = (
|
||||
"live installation of the exact legacy fd98bc90 ten-step candidate is "
|
||||
"disabled: all ten pinned package transitions completed directly on the "
|
||||
"physical RAK3401 and the endpoint passed independent SWD readback, but this "
|
||||
"later hardened host runner has not had a clean end-to-end physical run. "
|
||||
"Use --verify-only with the explicit legacy bundle path, or the hidden "
|
||||
"controlled-lab override."
|
||||
)
|
||||
|
||||
|
||||
class KnownUnsafeReleaseError(ota.OtaError):
|
||||
"""The pinned artifacts are intact but their live transition is unsafe."""
|
||||
|
||||
|
||||
def matches_exact_anchors(
|
||||
steps: list[ChainStep],
|
||||
anchors: tuple[tuple[int, str], ...],
|
||||
) -> bool:
|
||||
return len(steps) == len(anchors) and all(
|
||||
number == position
|
||||
and steps[position - 1].target_sha256 == expected_sha256
|
||||
for position, (number, expected_sha256) in enumerate(anchors, 1)
|
||||
)
|
||||
|
||||
|
||||
def ten_step_candidate_kind(steps: list[ChainStep]) -> str | None:
|
||||
if matches_exact_anchors(steps, CURRENT_10_CANDIDATE_ANCHORS):
|
||||
return "current-b40d2e6c"
|
||||
if matches_exact_anchors(steps, PHYSICALLY_PASSED_FD98_ANCHORS):
|
||||
return "legacy-fd98bc90"
|
||||
return None
|
||||
|
||||
|
||||
def ten_step_verification_message(steps: list[ChainStep]) -> str:
|
||||
candidate_kind = ten_step_candidate_kind(steps)
|
||||
if candidate_kind == "current-b40d2e6c":
|
||||
return (
|
||||
"Current b40d2e6c ten-step prerelease verified offline: exact "
|
||||
"archive/checksum pins, all target anchors, zero-filled and "
|
||||
"erased-workspace reconstruction, independent motatool verification, "
|
||||
"and both bootloader simulations passed. Steps 1-9 are byte-identical "
|
||||
"to packages that passed directly on the RAK3401. The new step 10 and "
|
||||
"this hardened host runner have not had a clean physical run; live "
|
||||
"use remains gated."
|
||||
)
|
||||
if candidate_kind == "legacy-fd98bc90":
|
||||
return (
|
||||
"Legacy fd98bc90 ten-step candidate verified offline: exact "
|
||||
"archive/checksum pins, all target anchors, zero-filled and "
|
||||
"erased-workspace reconstruction, independent motatool verification, "
|
||||
"and bootloader simulation passed. These exact ten package "
|
||||
"transitions also passed directly on the RAK3401 and the endpoint "
|
||||
"passed independent SWD readback. This later hardened host runner has "
|
||||
"not had a clean physical rerun; live use remains gated."
|
||||
)
|
||||
raise ota.OtaError("unrecognized ten-step candidate reached verification report")
|
||||
|
||||
|
||||
def require_live_release_safe(
|
||||
args: argparse.Namespace,
|
||||
steps: list[ChainStep],
|
||||
) -> None:
|
||||
if len(steps) == EXPECTED_STEP_COUNT:
|
||||
if all(
|
||||
steps[number - 1].target_sha256 == expected_sha256
|
||||
for number, expected_sha256 in CURRENT_10_CANDIDATE_ANCHORS
|
||||
):
|
||||
candidate_kind = ten_step_candidate_kind(steps)
|
||||
if candidate_kind == "current-b40d2e6c":
|
||||
if args.accept_test_candidate:
|
||||
return
|
||||
raise KnownUnsafeReleaseError(CURRENT_10_CANDIDATE_MESSAGE)
|
||||
if candidate_kind == "legacy-fd98bc90":
|
||||
if args.accept_test_candidate:
|
||||
return
|
||||
raise KnownUnsafeReleaseError(PHYSICALLY_PASSED_FD98_MESSAGE)
|
||||
raise KnownUnsafeReleaseError(
|
||||
"live installation is disabled: this is an unrecognized variant "
|
||||
"of the pinned ten-step candidate"
|
||||
@@ -615,8 +697,8 @@ def download_release_asset(destination: Path) -> None:
|
||||
print(f"[bundle] using verified cached asset {destination}")
|
||||
return
|
||||
raise ota.OtaError(
|
||||
"the exact ten-step candidate is not released; pass its explicit local "
|
||||
"ZIP or extracted root with --bundle"
|
||||
f"automatic release download is disabled; download {ASSET_NAME} from "
|
||||
f"{RELEASE_URL} and pass its ZIP or extracted root with --bundle"
|
||||
)
|
||||
|
||||
|
||||
@@ -926,6 +1008,7 @@ def locate_bundle(args: argparse.Namespace, work_dir: Path) -> Path:
|
||||
)
|
||||
if actual not in {
|
||||
ASSET_SHA256,
|
||||
PHYSICALLY_PASSED_FD98_ASSET_SHA256,
|
||||
PHYSICALLY_QUALIFIED_9_ASSET_SHA256,
|
||||
SUPERSEDED_30_ASSET_SHA256,
|
||||
SUPERSEDED_29_ASSET_SHA256,
|
||||
@@ -950,6 +1033,7 @@ def read_checksum_entries(bundle_root: Path) -> dict[PurePosixPath, str]:
|
||||
checksum_digest = hashlib.sha256(checksum_bytes).hexdigest()
|
||||
expected_lists = {
|
||||
CHECKSUM_LIST_SHA256,
|
||||
PHYSICALLY_PASSED_FD98_CHECKSUM_LIST_SHA256,
|
||||
PHYSICALLY_QUALIFIED_9_CHECKSUM_LIST_SHA256,
|
||||
SUPERSEDED_30_CHECKSUM_LIST_SHA256,
|
||||
SUPERSEDED_29_CHECKSUM_LIST_SHA256,
|
||||
@@ -1246,11 +1330,12 @@ def parse_chain(bundle_root: Path) -> tuple[list[ChainStep], bytes]:
|
||||
if steps[0].from_version != EXPECTED_START_VERSION:
|
||||
raise ota.OtaError("chain has an unexpected starting version")
|
||||
if len(steps) == EXPECTED_STEP_COUNT:
|
||||
for number, expected_sha256 in CURRENT_10_CANDIDATE_ANCHORS:
|
||||
if steps[number - 1].target_sha256 != expected_sha256:
|
||||
raise ota.OtaError(
|
||||
f"ten-step candidate step {number} does not match its audited image pin"
|
||||
)
|
||||
candidate_kind = ten_step_candidate_kind(steps)
|
||||
if candidate_kind is None:
|
||||
raise ota.OtaError(
|
||||
"ten-step candidate does not match the exact b40d2e6c or "
|
||||
"legacy fd98bc90 audited image pins"
|
||||
)
|
||||
expected_final_version = EXPECTED_FINAL_VERSION
|
||||
elif len(steps) == PHYSICALLY_QUALIFIED_9_STEP_COUNT:
|
||||
for number, expected_sha256 in COMPACT_RELEASE_ANCHORS:
|
||||
@@ -2241,16 +2326,20 @@ def run_step(
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Verify the exact local ten-step RAK3401 candidate from c1caa5ad "
|
||||
"to fd98bc90. Its exact package transitions passed directly on "
|
||||
"hardware, but this later host-runner revision has not had a clean "
|
||||
"end-to-end physical rerun and normal live use remains disabled."
|
||||
"Verify the exact published ten-step RAK3401 prerelease from "
|
||||
"c1caa5ad to b40d2e6c. Its first nine package bytes passed directly "
|
||||
"on hardware; the new final transition and this hardened host runner "
|
||||
"have offline/simulator qualification only, so live use remains "
|
||||
"disabled without the controlled-lab override."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bundle",
|
||||
type=Path,
|
||||
help="pinned local ZIP or extracted bundle root (required until release)",
|
||||
help=(
|
||||
"pinned prerelease ZIP or extracted bundle root "
|
||||
"(required; automatic download is disabled)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--work-dir",
|
||||
@@ -2510,15 +2599,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.verify_only:
|
||||
print(f"Verified pinned bundle: {supplied_bundle_root}")
|
||||
if len(steps) == EXPECTED_STEP_COUNT:
|
||||
print(
|
||||
"Ten-step candidate verified offline: exact archive/checksum "
|
||||
"pins, all target anchors, zero-filled and erased-workspace "
|
||||
"reconstruction, independent motatool verification, and "
|
||||
"bootloader simulation passed. These exact package transitions "
|
||||
"also passed directly on the RAK3401 and the endpoint passed "
|
||||
"independent SWD readback. This later host-runner revision has "
|
||||
"not had a new clean physical rerun; live use remains gated."
|
||||
)
|
||||
print(ten_step_verification_message(steps))
|
||||
elif len(steps) == PHYSICALLY_QUALIFIED_9_STEP_COUNT:
|
||||
print(
|
||||
"Compact release verified: all 9 transitions passed its exact "
|
||||
|
||||
@@ -3983,7 +3983,7 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase):
|
||||
steps[number - 1].target_sha256 = image_sha256
|
||||
with self.assertRaisesRegex(
|
||||
rak_chain.KnownUnsafeReleaseError,
|
||||
"package transitions completed directly on the physical RAK3401",
|
||||
"new b40d2e6c step 10.*bootloader simulators only",
|
||||
):
|
||||
rak_chain.require_live_release_safe(
|
||||
argparse.Namespace(accept_test_candidate=False), steps
|
||||
@@ -3992,6 +3992,52 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase):
|
||||
argparse.Namespace(accept_test_candidate=True), steps
|
||||
)
|
||||
|
||||
def test_exact_fd98_ten_step_candidate_remains_gated_legacy(self) -> None:
|
||||
steps = [mock.Mock(target_sha256="") for _ in range(10)]
|
||||
for number, image_sha256 in rak_chain.PHYSICALLY_PASSED_FD98_ANCHORS:
|
||||
steps[number - 1].target_sha256 = image_sha256
|
||||
with self.assertRaisesRegex(
|
||||
rak_chain.KnownUnsafeReleaseError,
|
||||
"legacy fd98bc90.*all ten pinned package transitions completed",
|
||||
):
|
||||
rak_chain.require_live_release_safe(
|
||||
argparse.Namespace(accept_test_candidate=False), steps
|
||||
)
|
||||
rak_chain.require_live_release_safe(
|
||||
argparse.Namespace(accept_test_candidate=True), steps
|
||||
)
|
||||
|
||||
def test_current_and_legacy_ten_step_reports_do_not_mix_evidence(self) -> None:
|
||||
current = [mock.Mock(target_sha256="") for _ in range(10)]
|
||||
legacy = [mock.Mock(target_sha256="") for _ in range(10)]
|
||||
for number, image_sha256 in rak_chain.CURRENT_10_CANDIDATE_ANCHORS:
|
||||
current[number - 1].target_sha256 = image_sha256
|
||||
for number, image_sha256 in rak_chain.PHYSICALLY_PASSED_FD98_ANCHORS:
|
||||
legacy[number - 1].target_sha256 = image_sha256
|
||||
|
||||
current_message = rak_chain.ten_step_verification_message(current)
|
||||
legacy_message = rak_chain.ten_step_verification_message(legacy)
|
||||
self.assertIn("new step 10", current_message)
|
||||
self.assertIn("not had a clean physical run", current_message)
|
||||
self.assertNotIn("endpoint passed independent SWD readback", current_message)
|
||||
self.assertIn("exact ten package transitions", legacy_message)
|
||||
self.assertIn("endpoint passed independent SWD readback", legacy_message)
|
||||
|
||||
def test_ten_step_candidates_share_only_the_pinned_physical_prefix(self) -> None:
|
||||
self.assertEqual(
|
||||
rak_chain.CURRENT_10_CANDIDATE_ANCHORS[:9],
|
||||
rak_chain.PHYSICALLY_PASSED_FD98_ANCHORS[:9],
|
||||
)
|
||||
self.assertNotEqual(
|
||||
rak_chain.CURRENT_10_CANDIDATE_ANCHORS[-1],
|
||||
rak_chain.PHYSICALLY_PASSED_FD98_ANCHORS[-1],
|
||||
)
|
||||
steps = [mock.Mock(target_sha256="") for _ in range(10)]
|
||||
for number, image_sha256 in rak_chain.CURRENT_10_CANDIDATE_ANCHORS:
|
||||
steps[number - 1].target_sha256 = image_sha256
|
||||
steps[8].target_sha256 = "00" * 32
|
||||
self.assertIsNone(rak_chain.ten_step_candidate_kind(steps))
|
||||
|
||||
def test_changed_current_ten_step_anchor_is_not_recognized(self) -> None:
|
||||
steps = [mock.Mock(target_sha256="") for _ in range(10)]
|
||||
for number, image_sha256 in rak_chain.CURRENT_10_CANDIDATE_ANCHORS:
|
||||
@@ -4005,14 +4051,46 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase):
|
||||
argparse.Namespace(accept_test_candidate=True), steps
|
||||
)
|
||||
|
||||
def test_unreleased_candidate_has_no_implicit_download(self) -> None:
|
||||
def test_published_candidate_has_no_implicit_download(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
destination = Path(directory) / rak_chain.ASSET_NAME
|
||||
with self.assertRaisesRegex(
|
||||
ota.OtaError, "not released.*--bundle"
|
||||
ota.OtaError, "automatic release download is disabled.*--bundle"
|
||||
):
|
||||
rak_chain.download_release_asset(destination)
|
||||
|
||||
def test_fd98_archive_and_inner_checksum_remain_pinned(self) -> None:
|
||||
self.assertEqual(
|
||||
rak_chain.PINNED_ARCHIVE_CHECKSUMS[
|
||||
rak_chain.PHYSICALLY_PASSED_FD98_ASSET_SHA256
|
||||
],
|
||||
rak_chain.PHYSICALLY_PASSED_FD98_CHECKSUM_LIST_SHA256,
|
||||
)
|
||||
|
||||
def test_locate_bundle_accepts_exact_fd98_legacy_archive(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
archive = root / rak_chain.PHYSICALLY_PASSED_FD98_ASSET_NAME
|
||||
archive.write_bytes(b"fixture is identified by the mocked digest")
|
||||
extracted = root / rak_chain.PHYSICALLY_PASSED_FD98_ROOT_NAME
|
||||
args = argparse.Namespace(bundle=archive)
|
||||
with (
|
||||
mock.patch.object(
|
||||
rak_chain,
|
||||
"sha256_file_limited",
|
||||
return_value=rak_chain.PHYSICALLY_PASSED_FD98_ASSET_SHA256,
|
||||
),
|
||||
mock.patch.object(
|
||||
rak_chain, "extract_bundle", return_value=extracted
|
||||
) as extract_bundle,
|
||||
):
|
||||
self.assertEqual(rak_chain.locate_bundle(args, root / "work"), extracted)
|
||||
extract_bundle.assert_called_once_with(
|
||||
archive.resolve(),
|
||||
root / "work" / "bundle",
|
||||
rak_chain.PHYSICALLY_PASSED_FD98_ASSET_SHA256,
|
||||
)
|
||||
|
||||
def test_compact_9_step_release_rejects_changed_anchor(self) -> None:
|
||||
steps = [mock.Mock(target_sha256="") for _ in range(9)]
|
||||
for number, image_sha256 in rak_chain.COMPACT_RELEASE_ANCHORS:
|
||||
|
||||
Reference in new issue
Block a user