fix(release): close RC06 trust replay and transaction gaps
This commit is contained in:
@@ -35,14 +35,19 @@ APPROVAL_SCHEMA_VERSION = "yuqei.rc06-publication-batch-approval.v4"
|
||||
APPROVAL_PAYLOAD_SCHEMA_VERSION = "yuqei.rc06-publication-batch-approval-payload.v3"
|
||||
APPROVAL_REVIEW_SUMMARY_SCHEMA_VERSION = "yuqei.rc06-single-reviewer-summary.v1"
|
||||
APPROVAL_KEYRING_SCHEMA_VERSION = "yuqei.rc06-publication-approval-keyring.v3"
|
||||
APPLY_SCHEMA_VERSION = "yuqei.rc06-publication-system-item-apply-receipt.v4"
|
||||
APPLY_PAYLOAD_SCHEMA_VERSION = "yuqei.rc06-publication-system-item-apply-payload.v2"
|
||||
ONLINE_TRANSACTION_RECEIPT_SCHEMA_VERSION = "yuqei.rc06-online-transaction-receipt.v1"
|
||||
ONLINE_TRANSACTION_PAYLOAD_SCHEMA_VERSION = "yuqei.rc06-online-transaction-payload.v1"
|
||||
APPLY_SCHEMA_VERSION = "yuqei.rc06-publication-system-item-apply-receipt.v5"
|
||||
APPLY_PAYLOAD_SCHEMA_VERSION = "yuqei.rc06-publication-system-item-apply-payload.v3"
|
||||
ONLINE_TRANSACTION_RECEIPT_SCHEMA_VERSION = "yuqei.rc06-online-transaction-receipt.v2"
|
||||
ONLINE_TRANSACTION_PAYLOAD_SCHEMA_VERSION = "yuqei.rc06-online-transaction-payload.v2"
|
||||
APPLY_CHALLENGE_SCHEMA_VERSION = "yuqei.rc06-apply-challenge.v1"
|
||||
APPLY_CHALLENGE_PAYLOAD_SCHEMA_VERSION = "yuqei.rc06-apply-challenge-payload.v1"
|
||||
APPROVAL_SIGNATURE_DOMAIN = b"YUQEI_RC06_PUBLICATION_BATCH_APPROVAL_V1\0"
|
||||
APPROVAL_KEYRING_SIGNATURE_DOMAIN = b"YUQEI_RC06_APPROVAL_KEYRING_V1\0"
|
||||
APPLY_SIGNATURE_DOMAIN = b"YUQEI_RC06_SYSTEM_APPLY_RECEIPT_V1\0"
|
||||
ONLINE_TRANSACTION_SIGNATURE_DOMAIN = b"YUQEI_RC06_ONLINE_TRANSACTION_RECEIPT_V1\0"
|
||||
APPLY_CHALLENGE_SIGNATURE_DOMAIN = b"YUQEI_RC06_APPLY_CHALLENGE_V1\0"
|
||||
APPLY_OPERATION = "apply_rc06_publication_batch"
|
||||
MAX_APPLY_CHALLENGE_TTL = timedelta(minutes=10)
|
||||
TRUSTED_APPROVAL_ROOT_KEY_ID = "yuqei-rc06-approval-root-v1"
|
||||
# Provision the long-lived root/apply pins and exact candidate keyring epoch
|
||||
# before source freeze. A root-signed keyring then authorizes the exact frozen
|
||||
@@ -2874,10 +2879,113 @@ def _expected_apply_item_results(manifest: Mapping[str, Any]) -> list[dict[str,
|
||||
return results
|
||||
|
||||
|
||||
def validate_apply_challenge(
|
||||
manifest: Mapping[str, Any],
|
||||
manifest_raw_sha256: str,
|
||||
approval_raw_sha256: str,
|
||||
apply_service_public_key: Ed25519PublicKey,
|
||||
challenge: Any,
|
||||
) -> dict[str, Any]:
|
||||
if not HEX64_RE.fullmatch(TRUSTED_APPLY_SERVICE_PUBLIC_KEY_SHA256):
|
||||
raise PublicationBatchError("apply service public key pin is not provisioned")
|
||||
apply_public_raw = _public_key_raw(apply_service_public_key)
|
||||
if _sha256_bytes(apply_public_raw) != TRUSTED_APPLY_SERVICE_PUBLIC_KEY_SHA256:
|
||||
raise PublicationBatchError("apply challenge key does not match the frozen pin")
|
||||
envelope = _require_keys(
|
||||
challenge,
|
||||
"apply challenge",
|
||||
("schema_version", "challenge_id", "payload", "signature"),
|
||||
)
|
||||
if envelope["schema_version"] != APPLY_CHALLENGE_SCHEMA_VERSION:
|
||||
raise PublicationBatchError("apply challenge schema is unsupported")
|
||||
payload = _require_keys(
|
||||
envelope["payload"],
|
||||
"apply challenge payload",
|
||||
(
|
||||
"schema_version",
|
||||
"nonce_base64",
|
||||
"candidate_commit",
|
||||
"candidate_tag",
|
||||
"batch_manifest_raw_sha256",
|
||||
"batch_manifest_payload_sha256",
|
||||
"approval_receipt_sha256",
|
||||
"operation",
|
||||
"environment_id",
|
||||
"actor",
|
||||
"issued_at",
|
||||
"expires_at",
|
||||
),
|
||||
)
|
||||
if (
|
||||
payload["schema_version"] != APPLY_CHALLENGE_PAYLOAD_SCHEMA_VERSION
|
||||
or payload["candidate_commit"] != manifest["candidate"]["commit"]
|
||||
or payload["candidate_tag"] != manifest["candidate"]["tag"]
|
||||
or payload["batch_manifest_payload_sha256"]
|
||||
!= manifest["manifest_payload_sha256"]
|
||||
or payload["approval_receipt_sha256"]
|
||||
!= _require_sha(approval_raw_sha256, "approval raw SHA-256")
|
||||
or payload["operation"] != APPLY_OPERATION
|
||||
):
|
||||
raise PublicationBatchError("apply challenge candidate or operation binding drift")
|
||||
if payload["batch_manifest_raw_sha256"] != _require_sha(
|
||||
manifest_raw_sha256, "apply challenge manifest raw SHA-256"
|
||||
):
|
||||
raise PublicationBatchError("apply challenge manifest raw binding drift")
|
||||
environment_id = _require_string(
|
||||
payload["environment_id"], "apply challenge environment_id", max_length=256
|
||||
)
|
||||
if environment_id != payload["environment_id"]:
|
||||
raise PublicationBatchError("apply challenge environment_id must be canonical")
|
||||
actor = _require_keys(payload["actor"], "apply challenge actor", ("type", "component"))
|
||||
if actor != {
|
||||
"type": "system",
|
||||
"component": TRUSTED_ONLINE_APPLY_SERVICE_COMPONENT,
|
||||
}:
|
||||
raise PublicationBatchError("apply challenge actor is not the trusted online service")
|
||||
issued_at = _parse_time(payload["issued_at"], "apply challenge issued_at")
|
||||
expires_at = _parse_time(payload["expires_at"], "apply challenge expires_at")
|
||||
if (
|
||||
expires_at <= issued_at
|
||||
or expires_at - issued_at > MAX_APPLY_CHALLENGE_TTL
|
||||
):
|
||||
raise PublicationBatchError("apply challenge TTL is invalid")
|
||||
try:
|
||||
nonce = base64.b64decode(
|
||||
_require_string(
|
||||
payload["nonce_base64"], "apply challenge nonce", max_length=128
|
||||
),
|
||||
validate=True,
|
||||
)
|
||||
signature = base64.b64decode(
|
||||
_require_string(
|
||||
envelope["signature"], "apply challenge signature", max_length=1024
|
||||
),
|
||||
validate=True,
|
||||
)
|
||||
if len(nonce) != 32:
|
||||
raise ValueError("challenge nonce must contain 32 bytes")
|
||||
apply_service_public_key.verify(
|
||||
signature,
|
||||
APPLY_CHALLENGE_SIGNATURE_DOMAIN + _canonical(payload),
|
||||
)
|
||||
except (TypeError, ValueError, InvalidSignature) as exc:
|
||||
raise PublicationBatchError("apply challenge signature verification failed") from exc
|
||||
expected_id = (
|
||||
"rc06-apply-challenge-"
|
||||
+ _sha256_bytes(_canonical(payload) + signature)[:24]
|
||||
)
|
||||
if envelope["challenge_id"] != expected_id:
|
||||
raise PublicationBatchError("apply challenge_id does not match its signed material")
|
||||
_assert_no_secret_material(envelope, "apply challenge")
|
||||
return envelope
|
||||
|
||||
|
||||
def validate_online_transaction_receipt(
|
||||
manifest: Mapping[str, Any],
|
||||
manifest_raw_sha256: str,
|
||||
approval_raw_sha256: str,
|
||||
approval: Mapping[str, Any],
|
||||
apply_service_public_key: Ed25519PublicKey,
|
||||
online_service_public_key: Ed25519PublicKey,
|
||||
receipt: Any,
|
||||
) -> dict[str, Any]:
|
||||
@@ -2928,6 +3036,7 @@ def validate_online_transaction_receipt(
|
||||
"transaction_id",
|
||||
"database_commit_id",
|
||||
"committed_at",
|
||||
"authority_challenge",
|
||||
"generated_by",
|
||||
"item_results",
|
||||
"write_count",
|
||||
@@ -2982,6 +3091,36 @@ def validate_online_transaction_receipt(
|
||||
raise PublicationBatchError(
|
||||
"online transaction receipt was generated outside the approval window"
|
||||
)
|
||||
challenge = validate_apply_challenge(
|
||||
manifest,
|
||||
manifest_raw_sha256,
|
||||
approval_raw_sha256,
|
||||
apply_service_public_key,
|
||||
payload["authority_challenge"],
|
||||
)
|
||||
challenge_payload = challenge["payload"]
|
||||
challenge_issued_at = _parse_time(
|
||||
challenge_payload["issued_at"], "apply challenge issued_at"
|
||||
)
|
||||
challenge_expires_at = _parse_time(
|
||||
challenge_payload["expires_at"], "apply challenge expires_at"
|
||||
)
|
||||
approval_approved_at = _parse_time(
|
||||
approval_payload["approved_at"], "approval approved_at"
|
||||
)
|
||||
approval_expires_at = _parse_time(
|
||||
approval_payload["expires_at"], "approval expires_at"
|
||||
)
|
||||
if (
|
||||
challenge_payload["environment_id"] != environment_id
|
||||
or challenge_issued_at < approval_approved_at
|
||||
or challenge_expires_at > approval_expires_at
|
||||
or committed_at < challenge_issued_at
|
||||
or committed_at >= challenge_expires_at
|
||||
):
|
||||
raise PublicationBatchError(
|
||||
"online transaction receipt does not satisfy its apply challenge"
|
||||
)
|
||||
actor = _require_keys(
|
||||
payload["generated_by"], "online transaction generator", ("type", "component")
|
||||
)
|
||||
@@ -3084,8 +3223,10 @@ def validate_apply_receipt(
|
||||
raise PublicationBatchError("approval/apply batch identity mismatch")
|
||||
online_receipt = validate_online_transaction_receipt(
|
||||
manifest,
|
||||
manifest_raw_sha256,
|
||||
approval_raw_sha256,
|
||||
approval,
|
||||
apply_service_public_key,
|
||||
online_service_public_key,
|
||||
row["online_transaction_receipt"],
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -988,6 +988,34 @@ def test_one_reviewer_batch_signature_and_system_item_receipts_remove_human_repe
|
||||
for item in manifest["prompt_module_batch"]["items"]
|
||||
]
|
||||
item_results.sort(key=lambda item: (item["kind"], item["item_id"]))
|
||||
challenge_payload = {
|
||||
"schema_version": batch.APPLY_CHALLENGE_PAYLOAD_SCHEMA_VERSION,
|
||||
"nonce_base64": base64.b64encode(b"c" * 32).decode("ascii"),
|
||||
"candidate_commit": manifest["candidate"]["commit"],
|
||||
"candidate_tag": manifest["candidate"]["tag"],
|
||||
"batch_manifest_raw_sha256": manifest_raw_sha,
|
||||
"batch_manifest_payload_sha256": manifest["manifest_payload_sha256"],
|
||||
"approval_receipt_sha256": approval_raw_sha,
|
||||
"operation": batch.APPLY_OPERATION,
|
||||
"environment_id": "isolated-uat-fixture",
|
||||
"actor": {"type": "system", "component": "rc06-publication-service"},
|
||||
"issued_at": "2026-09-21T09:55:00+08:00",
|
||||
"expires_at": "2026-09-21T10:05:00+08:00",
|
||||
}
|
||||
challenge_signature = apply_service_private_key.sign(
|
||||
batch.APPLY_CHALLENGE_SIGNATURE_DOMAIN + batch._canonical(challenge_payload)
|
||||
)
|
||||
authority_challenge = {
|
||||
"schema_version": batch.APPLY_CHALLENGE_SCHEMA_VERSION,
|
||||
"challenge_id": (
|
||||
"rc06-apply-challenge-"
|
||||
+ batch._sha256_bytes(
|
||||
batch._canonical(challenge_payload) + challenge_signature
|
||||
)[:24]
|
||||
),
|
||||
"payload": challenge_payload,
|
||||
"signature": base64.b64encode(challenge_signature).decode("ascii"),
|
||||
}
|
||||
online_payload = {
|
||||
"schema_version": batch.ONLINE_TRANSACTION_PAYLOAD_SCHEMA_VERSION,
|
||||
"key_id": batch.TRUSTED_ONLINE_APPLY_SERVICE_KEY_ID,
|
||||
@@ -997,6 +1025,7 @@ def test_one_reviewer_batch_signature_and_system_item_receipts_remove_human_repe
|
||||
"transaction_id": "fixture-transaction-001",
|
||||
"database_commit_id": "fixture-database-commit-001",
|
||||
"committed_at": "2026-09-21T10:00:00+08:00",
|
||||
"authority_challenge": authority_challenge,
|
||||
"generated_by": {"type": "system", "component": "rc06-publication-service"},
|
||||
"item_results": item_results,
|
||||
"write_count": 74,
|
||||
|
||||
@@ -6,6 +6,8 @@ import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
@@ -21,6 +23,20 @@ EXISTING_BATCH_TEST = REPO_ROOT / "yuqei-infra" / "tests" / "test_rc06_publicati
|
||||
AUTHORITY_LAUNCHER = REPO_ROOT / "yuqei-infra" / "scripts" / "Launch-ReleaseAuthority.ps1"
|
||||
|
||||
|
||||
def _acl_snapshot(path: Path) -> bytes | int:
|
||||
if os.name != "nt":
|
||||
return stat.S_IMODE(path.stat().st_mode)
|
||||
completed = subprocess.run(
|
||||
["icacls", os.fspath(path)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
pytest.fail(f"cannot snapshot fixture ACL: {completed.stderr!r}")
|
||||
return completed.stdout
|
||||
|
||||
|
||||
def _load_script():
|
||||
spec = importlib.util.spec_from_file_location("rc06_release_trust_bootstrap", SCRIPT)
|
||||
assert spec is not None and spec.loader is not None
|
||||
@@ -216,6 +232,24 @@ def _formal_apply_materials(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
|
||||
approval_file = tmp_path / "rc06-batch-approval.json"
|
||||
approval_raw = authority._canonical(approval)
|
||||
approval_file.write_bytes(approval_raw)
|
||||
approval_sha = authority.batch._sha256_bytes(approval_raw)
|
||||
challenge_result = authority.issue_apply_challenge(
|
||||
state,
|
||||
manifest_file=manifest_file,
|
||||
manifest_sha256=manifest_sha,
|
||||
approval_file=approval_file,
|
||||
approval_sha256=approval_sha,
|
||||
approval_keyring_file=keyring_file,
|
||||
approval_keyring_sha256=authority.batch._sha256_bytes(keyring_raw),
|
||||
approval_root_public_key_file=(
|
||||
keyring_directory / "rc06-approval-root-public-key.raw"
|
||||
),
|
||||
environment_id="isolated-uat-fixture",
|
||||
unprotect=_unprotect,
|
||||
now=datetime(2026, 9, 27, 3, 55, tzinfo=UTC),
|
||||
)
|
||||
challenge_file = Path(challenge_result["challenge_file"])
|
||||
challenge = json.loads(challenge_file.read_bytes())
|
||||
item_results = [
|
||||
{
|
||||
"kind": "asset",
|
||||
@@ -245,6 +279,7 @@ def _formal_apply_materials(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
|
||||
"transaction_id": "fixture-transaction-001",
|
||||
"database_commit_id": "fixture-database-commit-001",
|
||||
"committed_at": "2026-09-27T04:00:00Z",
|
||||
"authority_challenge": challenge,
|
||||
"generated_by": {"type": "system", "component": "rc06-publication-service"},
|
||||
"item_results": item_results,
|
||||
"write_count": 74,
|
||||
@@ -282,7 +317,9 @@ def _formal_apply_materials(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
|
||||
"root_file": keyring_directory / "rc06-approval-root-public-key.raw",
|
||||
"approval": approval,
|
||||
"approval_file": approval_file,
|
||||
"approval_sha": authority.batch._sha256_bytes(approval_raw),
|
||||
"approval_sha": approval_sha,
|
||||
"challenge": challenge,
|
||||
"challenge_file": challenge_file,
|
||||
"online_private": online_private,
|
||||
"online_public_file": online_public_file,
|
||||
"online_receipt": online_receipt,
|
||||
@@ -344,6 +381,28 @@ def test_authority_rejects_partial_or_tampered_bundle(tmp_path: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_authority_manifest_requires_exact_role_array_objects(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
state, _result, _keys = _initialize(tmp_path)
|
||||
manifest_path = state / authority.KEYS_DIRECTORY_NAME / authority.AUTHORITY_MANIFEST_FILENAME
|
||||
original = json.loads(manifest_path.read_bytes())
|
||||
monkeypatch.setattr(authority.signer, "_verify_complete_bundle", lambda *args, **kwargs: None)
|
||||
|
||||
extra = copy.deepcopy(original)
|
||||
extra["roles"].append(copy.deepcopy(extra["roles"][0]))
|
||||
manifest_path.write_bytes(authority._canonical(extra))
|
||||
with pytest.raises(authority.AuthorityError, match="role inventory drift"):
|
||||
authority.load_authority(state, unprotect=_unprotect, unseal_roles=())
|
||||
|
||||
missing_field = copy.deepcopy(original)
|
||||
del missing_field["roles"][0]["algorithm"]
|
||||
manifest_path.write_bytes(authority._canonical(missing_field))
|
||||
with pytest.raises(authority.AuthorityError, match="role inventory drift"):
|
||||
authority.load_authority(state, unprotect=_unprotect, unseal_roles=())
|
||||
|
||||
|
||||
def test_recovery_keyrings_share_only_the_recovery_root_and_verify_both_schemas(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
@@ -521,6 +580,71 @@ def test_pin_planner_refuses_unknown_existing_pin_without_overwriting(tmp_path:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("injection", "message"),
|
||||
(
|
||||
(
|
||||
'\nTRUSTED_APPROVAL_ROOT_PUBLIC_KEY_SHA256 = ""\n',
|
||||
"one unique module-level literal",
|
||||
),
|
||||
(
|
||||
'\nglobals()["TRUSTED_APPROVAL_ROOT_PUBLIC_KEY_SHA256"] = "' + "f" * 64 + '"\n',
|
||||
"dynamic namespace mutation",
|
||||
),
|
||||
(
|
||||
'\nexec("TRUSTED_APPROVAL_ROOT_PUBLIC_KEY_SHA256 = \\\"' + "f" * 64 + '\\\"")\n',
|
||||
"untrusted dynamic exec",
|
||||
),
|
||||
(
|
||||
"\nfrom attacker_pin_side_effect import *\n",
|
||||
"star import",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_pin_planner_rejects_duplicate_dynamic_and_import_side_effect_assignments(
|
||||
tmp_path: Path,
|
||||
injection: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
state, repo, _plan = _pin_plan_fixture(tmp_path, include_online=False)
|
||||
rc06 = repo / "yuqei-infra/scripts/build-rc06-publication-batch.py"
|
||||
rc06.write_text(rc06.read_text(encoding="utf-8") + injection, encoding="utf-8")
|
||||
with pytest.raises(authority.AuthorityError, match=message):
|
||||
authority.plan_pin_updates(
|
||||
repo,
|
||||
state,
|
||||
state / "keyrings" / "recovery-epoch-1",
|
||||
epoch=1,
|
||||
unprotect=_unprotect,
|
||||
)
|
||||
|
||||
|
||||
def test_pin_planner_isolated_runtime_detects_import_side_effect_override(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
state, repo, _plan = _pin_plan_fixture(tmp_path, include_online=False)
|
||||
rc06 = repo / "yuqei-infra/scripts/build-rc06-publication-batch.py"
|
||||
side_effect = rc06.parent / "attacker_pin_side_effect.py"
|
||||
side_effect.write_text(
|
||||
"import sys\n"
|
||||
"module = sys.modules['_yuqei_pin_runtime_validation']\n"
|
||||
"module.TRUSTED_APPROVAL_ROOT_PUBLIC_KEY_SHA256 = '" + "f" * 64 + "'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
rc06.write_text(
|
||||
rc06.read_text(encoding="utf-8") + "\nimport attacker_pin_side_effect\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(authority.AuthorityError, match="runtime values drift"):
|
||||
authority.plan_pin_updates(
|
||||
repo,
|
||||
state,
|
||||
state / "keyrings" / "recovery-epoch-1",
|
||||
epoch=1,
|
||||
unprotect=_unprotect,
|
||||
)
|
||||
|
||||
|
||||
def test_pin_apply_rolls_back_completed_files_when_later_replace_fails(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -547,17 +671,17 @@ def test_pin_apply_rolls_back_completed_files_when_later_replace_fails(
|
||||
online_service_registration_file=online_registration_file,
|
||||
online_environment_ids=("isolated-uat-fixture",),
|
||||
)
|
||||
real_replace = authority._write_atomic_replacement
|
||||
real_replace = authority._write_bound_source_replacement
|
||||
calls = 0
|
||||
|
||||
def fail_second(path: Path, raw: bytes) -> None:
|
||||
def fail_second(path: Path, repo: Path, expected: bytes, raw: bytes) -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 2:
|
||||
raise OSError("fixture replacement failure")
|
||||
real_replace(path, raw)
|
||||
real_replace(path, repo, expected, raw)
|
||||
|
||||
monkeypatch.setattr(authority, "_write_atomic_replacement", fail_second)
|
||||
monkeypatch.setattr(authority, "_write_bound_source_replacement", fail_second)
|
||||
with pytest.raises(authority.AuthorityError, match="transaction failed"):
|
||||
authority.apply_pin_plan(plan)
|
||||
assert all(change.path.read_bytes() == change.original for change in plan.changes)
|
||||
@@ -668,7 +792,7 @@ def test_apply_payload_signing_is_bound_verified_atomic_and_idempotent(
|
||||
online_transaction_receipts_sha256=materials["online_receipts_sha"],
|
||||
output_directory=output,
|
||||
unprotect=_unprotect,
|
||||
now=datetime(2026, 9, 27, 4, 30, tzinfo=UTC),
|
||||
now=datetime(2026, 9, 27, 4, 1, tzinfo=UTC),
|
||||
)
|
||||
assert result["status"] == "apply_receipts_signed_and_verified_not_published"
|
||||
assert result["receipt_count"] == 1
|
||||
@@ -701,11 +825,32 @@ def test_apply_payload_signing_is_bound_verified_atomic_and_idempotent(
|
||||
online_transaction_receipts_sha256=materials["online_receipts_sha"],
|
||||
output_directory=output,
|
||||
unprotect=_unprotect,
|
||||
now=datetime(2026, 9, 27, 4, 30, tzinfo=UTC),
|
||||
now=datetime(2026, 9, 27, 4, 1, tzinfo=UTC),
|
||||
)
|
||||
assert repeated["status"] == "apply_receipts_already_signed_and_verified_not_published"
|
||||
assert repeated["receipts"] == result["receipts"]
|
||||
|
||||
with pytest.raises(
|
||||
authority.AuthorityError,
|
||||
match="already consumed or reserved for another output",
|
||||
):
|
||||
authority.sign_apply_payloads(
|
||||
materials["state"],
|
||||
manifest_file=materials["manifest_file"],
|
||||
manifest_sha256=materials["manifest_sha"],
|
||||
approval_file=materials["approval_file"],
|
||||
approval_sha256=materials["approval_sha"],
|
||||
approval_keyring_file=materials["keyring_file"],
|
||||
approval_keyring_sha256=materials["keyring_sha"],
|
||||
approval_root_public_key_file=materials["root_file"],
|
||||
online_service_public_key_file=materials["online_public_file"],
|
||||
online_transaction_receipts_file=materials["online_receipts_file"],
|
||||
online_transaction_receipts_sha256=materials["online_receipts_sha"],
|
||||
output_directory=tmp_path / "replayed-to-another-output",
|
||||
unprotect=_unprotect,
|
||||
now=datetime(2026, 9, 27, 4, 2, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_apply_payload_signing_rejects_tamper_cross_approval_and_partial_bundle(
|
||||
tmp_path: Path,
|
||||
@@ -734,10 +879,51 @@ def test_apply_payload_signing_rejects_tamper_cross_approval_and_partial_bundle(
|
||||
online_transaction_receipts_sha256=authority.batch._sha256_bytes(tampered_raw),
|
||||
output_directory=tampered_output,
|
||||
unprotect=_unprotect,
|
||||
now=datetime(2026, 9, 27, 4, 30, tzinfo=UTC),
|
||||
now=datetime(2026, 9, 27, 4, 1, tzinfo=UTC),
|
||||
)
|
||||
assert not tampered_output.exists()
|
||||
|
||||
challenge_tampered = copy.deepcopy(materials["online_receipt"])
|
||||
challenge_tampered["payload"]["authority_challenge"]["payload"][
|
||||
"operation"
|
||||
] = "attacker-operation"
|
||||
challenge_online_signature = materials["online_private"].sign(
|
||||
authority.batch.ONLINE_TRANSACTION_SIGNATURE_DOMAIN
|
||||
+ authority._canonical(challenge_tampered["payload"])
|
||||
)
|
||||
challenge_tampered["signature"] = base64.b64encode(
|
||||
challenge_online_signature
|
||||
).decode("ascii")
|
||||
challenge_tampered["receipt_id"] = (
|
||||
"rc06-online-transaction-"
|
||||
+ authority.batch._sha256_bytes(
|
||||
authority._canonical(challenge_tampered["payload"])
|
||||
+ challenge_online_signature
|
||||
)[:24]
|
||||
)
|
||||
challenge_tampered_file = tmp_path / "tampered-challenge-receipt.json"
|
||||
challenge_tampered_raw = authority._canonical(challenge_tampered)
|
||||
challenge_tampered_file.write_bytes(challenge_tampered_raw)
|
||||
with pytest.raises(authority.AuthorityError, match="challenge.*binding drift"):
|
||||
authority.sign_apply_payloads(
|
||||
materials["state"],
|
||||
manifest_file=materials["manifest_file"],
|
||||
manifest_sha256=materials["manifest_sha"],
|
||||
approval_file=materials["approval_file"],
|
||||
approval_sha256=materials["approval_sha"],
|
||||
approval_keyring_file=materials["keyring_file"],
|
||||
approval_keyring_sha256=materials["keyring_sha"],
|
||||
approval_root_public_key_file=materials["root_file"],
|
||||
online_service_public_key_file=materials["online_public_file"],
|
||||
online_transaction_receipts_file=challenge_tampered_file,
|
||||
online_transaction_receipts_sha256=authority.batch._sha256_bytes(
|
||||
challenge_tampered_raw
|
||||
),
|
||||
output_directory=tmp_path / "tampered-challenge-output",
|
||||
unprotect=_unprotect,
|
||||
now=datetime(2026, 9, 27, 4, 1, tzinfo=UTC),
|
||||
)
|
||||
|
||||
second_payload = dict(materials["approval"]["payload"])
|
||||
second_payload["approved_at"] = "2026-09-27T03:15:00Z"
|
||||
second_signature = materials["reviewer_private"].sign(
|
||||
@@ -772,7 +958,7 @@ def test_apply_payload_signing_rejects_tamper_cross_approval_and_partial_bundle(
|
||||
online_transaction_receipts_sha256=materials["online_receipts_sha"],
|
||||
output_directory=tmp_path / "cross-approval-output",
|
||||
unprotect=_unprotect,
|
||||
now=datetime(2026, 9, 27, 4, 30, tzinfo=UTC),
|
||||
now=datetime(2026, 9, 27, 4, 1, tzinfo=UTC),
|
||||
)
|
||||
|
||||
partial = copy.deepcopy(materials["online_receipt"])
|
||||
@@ -797,11 +983,81 @@ def test_apply_payload_signing_rejects_tamper_cross_approval_and_partial_bundle(
|
||||
online_transaction_receipts_sha256=authority.batch._sha256_bytes(payload_list_raw),
|
||||
output_directory=partial_output,
|
||||
unprotect=_unprotect,
|
||||
now=datetime(2026, 9, 27, 4, 30, tzinfo=UTC),
|
||||
now=datetime(2026, 9, 27, 4, 1, tzinfo=UTC),
|
||||
)
|
||||
assert not partial_output.exists()
|
||||
|
||||
|
||||
def test_apply_challenge_consumption_recovers_after_finalize_interrupt(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
materials = _formal_apply_materials(tmp_path, monkeypatch)
|
||||
output = tmp_path / "interrupted-consumption-output"
|
||||
real_finalize = authority._finalize_apply_consumption
|
||||
interrupted = False
|
||||
|
||||
def interrupt_finalize(*args, **kwargs):
|
||||
nonlocal interrupted
|
||||
if not interrupted:
|
||||
interrupted = True
|
||||
raise KeyboardInterrupt()
|
||||
return real_finalize(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(authority, "_finalize_apply_consumption", interrupt_finalize)
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
authority.sign_apply_payloads(
|
||||
materials["state"],
|
||||
manifest_file=materials["manifest_file"],
|
||||
manifest_sha256=materials["manifest_sha"],
|
||||
approval_file=materials["approval_file"],
|
||||
approval_sha256=materials["approval_sha"],
|
||||
approval_keyring_file=materials["keyring_file"],
|
||||
approval_keyring_sha256=materials["keyring_sha"],
|
||||
approval_root_public_key_file=materials["root_file"],
|
||||
online_service_public_key_file=materials["online_public_file"],
|
||||
online_transaction_receipts_file=materials["online_receipts_file"],
|
||||
online_transaction_receipts_sha256=materials["online_receipts_sha"],
|
||||
output_directory=output,
|
||||
unprotect=_unprotect,
|
||||
now=datetime(2026, 9, 27, 4, 1, tzinfo=UTC),
|
||||
)
|
||||
assert output.is_dir()
|
||||
consumption_path = (
|
||||
materials["state"]
|
||||
/ "apply-challenge-consumption"
|
||||
/ f"{materials['challenge']['challenge_id']}.json"
|
||||
)
|
||||
assert json.loads(consumption_path.read_bytes())["status"] == "reserved"
|
||||
|
||||
monkeypatch.setattr(authority, "_finalize_apply_consumption", real_finalize)
|
||||
calls: list[str] = []
|
||||
|
||||
def observed_unprotect(raw: bytes, role: str) -> bytes:
|
||||
calls.append(role)
|
||||
return _unprotect(raw, role)
|
||||
|
||||
recovered = authority.sign_apply_payloads(
|
||||
materials["state"],
|
||||
manifest_file=materials["manifest_file"],
|
||||
manifest_sha256=materials["manifest_sha"],
|
||||
approval_file=materials["approval_file"],
|
||||
approval_sha256=materials["approval_sha"],
|
||||
approval_keyring_file=materials["keyring_file"],
|
||||
approval_keyring_sha256=materials["keyring_sha"],
|
||||
approval_root_public_key_file=materials["root_file"],
|
||||
online_service_public_key_file=materials["online_public_file"],
|
||||
online_transaction_receipts_file=materials["online_receipts_file"],
|
||||
online_transaction_receipts_sha256=materials["online_receipts_sha"],
|
||||
output_directory=output,
|
||||
unprotect=observed_unprotect,
|
||||
now=datetime(2026, 9, 27, 4, 2, tzinfo=UTC),
|
||||
)
|
||||
assert recovered["status"] == "apply_receipts_already_signed_and_verified_not_published"
|
||||
assert json.loads(consumption_path.read_bytes())["status"] == "consumed"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_authority_binds_exact_reviewer_registration_and_derived_key_id(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
@@ -876,7 +1132,7 @@ def test_public_status_and_apply_validation_use_least_private_key_unsealing(
|
||||
online_transaction_receipts_sha256=materials["online_receipts_sha"],
|
||||
output_directory=output,
|
||||
unprotect=observed_unprotect,
|
||||
now=datetime(2026, 9, 27, 4, 30, tzinfo=UTC),
|
||||
now=datetime(2026, 9, 27, 4, 1, tzinfo=UTC),
|
||||
)
|
||||
assert calls == [authority.ROLE_APPLY_SERVICE]
|
||||
|
||||
@@ -933,7 +1189,7 @@ def test_apply_signing_rejects_unprovisioned_custom_environment_and_expired_wind
|
||||
authority.sign_apply_payloads(
|
||||
materials["state"],
|
||||
output_directory=tmp_path / "unprovisioned-output",
|
||||
now=datetime(2026, 9, 27, 4, 30, tzinfo=UTC),
|
||||
now=datetime(2026, 9, 27, 4, 1, tzinfo=UTC),
|
||||
**common,
|
||||
)
|
||||
assert calls == []
|
||||
@@ -963,7 +1219,7 @@ def test_apply_signing_rejects_unprovisioned_custom_environment_and_expired_wind
|
||||
authority.sign_apply_payloads(
|
||||
materials["state"],
|
||||
output_directory=tmp_path / "custom-output",
|
||||
now=datetime(2026, 9, 27, 4, 30, tzinfo=UTC),
|
||||
now=datetime(2026, 9, 27, 4, 1, tzinfo=UTC),
|
||||
**{
|
||||
**common,
|
||||
"online_transaction_receipts_file": custom_file,
|
||||
@@ -990,11 +1246,14 @@ def test_apply_signing_rejects_unprovisioned_custom_environment_and_expired_wind
|
||||
future_file = tmp_path / "future-online-receipt.json"
|
||||
future_raw = authority._canonical(future)
|
||||
future_file.write_bytes(future_raw)
|
||||
with pytest.raises(authority.AuthorityError, match="after the current signing time"):
|
||||
with pytest.raises(
|
||||
authority.AuthorityError,
|
||||
match="does not satisfy its apply challenge|after the current signing time",
|
||||
):
|
||||
authority.sign_apply_payloads(
|
||||
materials["state"],
|
||||
output_directory=tmp_path / "future-output",
|
||||
now=datetime(2026, 9, 27, 4, 30, tzinfo=UTC),
|
||||
now=datetime(2026, 9, 27, 4, 1, tzinfo=UTC),
|
||||
**{
|
||||
**common,
|
||||
"online_transaction_receipts_file": future_file,
|
||||
@@ -1005,6 +1264,15 @@ def test_apply_signing_rejects_unprovisioned_custom_environment_and_expired_wind
|
||||
)
|
||||
assert calls == []
|
||||
|
||||
with pytest.raises(authority.AuthorityError, match="short-lived apply challenge"):
|
||||
authority.sign_apply_payloads(
|
||||
materials["state"],
|
||||
output_directory=tmp_path / "expired-challenge-output",
|
||||
now=datetime(2026, 9, 27, 4, 6, tzinfo=UTC),
|
||||
**common,
|
||||
)
|
||||
assert calls == []
|
||||
|
||||
with pytest.raises(authority.AuthorityError, match="outside the approval window"):
|
||||
authority.sign_apply_payloads(
|
||||
materials["state"],
|
||||
@@ -1050,21 +1318,21 @@ def test_pin_transaction_handles_keyboard_interrupt_and_concurrent_writer(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
state, repo, plan = _pin_plan_fixture(tmp_path)
|
||||
real_replace = authority._write_atomic_replacement
|
||||
real_replace = authority._write_bound_source_replacement
|
||||
interrupted = False
|
||||
|
||||
def interrupt_first_source(path: Path, raw: bytes) -> None:
|
||||
def interrupt_first_source(path: Path, repo: Path, expected: bytes, raw: bytes) -> None:
|
||||
nonlocal interrupted
|
||||
if not interrupted and any(path == change.path for change in plan.changes):
|
||||
interrupted = True
|
||||
raise KeyboardInterrupt()
|
||||
real_replace(path, raw)
|
||||
real_replace(path, repo, expected, raw)
|
||||
|
||||
monkeypatch.setattr(authority, "_write_atomic_replacement", interrupt_first_source)
|
||||
monkeypatch.setattr(authority, "_write_bound_source_replacement", interrupt_first_source)
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
authority.apply_pin_plan(plan)
|
||||
assert all(change.path.read_bytes() == change.original for change in plan.changes)
|
||||
monkeypatch.setattr(authority, "_write_atomic_replacement", real_replace)
|
||||
monkeypatch.setattr(authority, "_write_bound_source_replacement", real_replace)
|
||||
|
||||
with authority._repository_pin_lock(repo, state):
|
||||
with pytest.raises(authority.AuthorityError, match="repository lock"):
|
||||
@@ -1101,6 +1369,64 @@ def test_pin_apply_rechecks_ancestor_reparse_state_immediately_before_write(
|
||||
assert all(change.path.read_bytes() == change.original for change in plan.changes)
|
||||
|
||||
|
||||
def test_bound_source_publish_rejects_parent_path_swap_without_touching_outside_file(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
state, _repo, plan = _pin_plan_fixture(tmp_path)
|
||||
target = next(change.path for change in plan.changes if change.original != change.proposed)
|
||||
outside = tmp_path / "outside-protected.py"
|
||||
outside.write_bytes(b"outside-content-must-not-change\n")
|
||||
os.chmod(outside, 0o444)
|
||||
outside_before = outside.read_bytes()
|
||||
outside_mode = stat.S_IMODE(outside.stat().st_mode)
|
||||
outside_attributes = getattr(outside.stat(), "st_file_attributes", None)
|
||||
outside_acl = _acl_snapshot(outside)
|
||||
real_assert_bound = authority._assert_descriptor_still_bound
|
||||
swapped = False
|
||||
swap_blocked_by_open_handle = False
|
||||
|
||||
def swap_parent_after_open(descriptor, path, repo, label, *, original_resolved):
|
||||
nonlocal swapped, swap_blocked_by_open_handle
|
||||
if path == target and not swapped:
|
||||
swapped = True
|
||||
original_parent = path.parent.with_name(path.parent.name + "-opened-original")
|
||||
try:
|
||||
path.parent.rename(original_parent)
|
||||
path.parent.mkdir()
|
||||
os.link(outside, path)
|
||||
except PermissionError as exc:
|
||||
swap_blocked_by_open_handle = True
|
||||
raise authority.AuthorityError(
|
||||
"source path or parent identity changed; operating system blocked the swap"
|
||||
) from exc
|
||||
return real_assert_bound(
|
||||
descriptor,
|
||||
path,
|
||||
repo,
|
||||
label,
|
||||
original_resolved=original_resolved,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
authority,
|
||||
"_assert_descriptor_still_bound",
|
||||
swap_parent_after_open,
|
||||
)
|
||||
with pytest.raises(authority.AuthorityError, match="path or parent identity changed"):
|
||||
authority.apply_pin_plan(plan)
|
||||
assert swapped is True
|
||||
assert swap_blocked_by_open_handle is True or not target.parent.samefile(
|
||||
target.parent.with_name(target.parent.name + "-opened-original")
|
||||
)
|
||||
assert outside.read_bytes() == outside_before
|
||||
assert stat.S_IMODE(outside.stat().st_mode) == outside_mode
|
||||
assert getattr(outside.stat(), "st_file_attributes", None) == outside_attributes
|
||||
assert _acl_snapshot(outside) == outside_acl
|
||||
control = authority._pin_control_directory(plan.repo, state)
|
||||
assert not (control / "transaction.json").exists()
|
||||
|
||||
|
||||
def test_pin_transaction_persistent_journal_blocks_and_recovers_after_crash(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
@@ -1110,7 +1436,12 @@ def test_pin_transaction_persistent_journal_blocks_and_recovers_after_crash(
|
||||
journal = authority._journal_payload(plan)
|
||||
journal_path = control / "transaction.json"
|
||||
authority._create_pin_journal(journal_path, journal)
|
||||
authority._write_atomic_replacement(first.path, first.proposed)
|
||||
authority._write_bound_source_replacement(
|
||||
first.path,
|
||||
repo,
|
||||
first.original,
|
||||
first.proposed,
|
||||
)
|
||||
journal["status"] = "applying"
|
||||
journal["completed_paths"].append(first.path.relative_to(repo).as_posix())
|
||||
authority._write_pin_journal(journal_path, journal)
|
||||
@@ -1126,6 +1457,53 @@ def test_pin_transaction_persistent_journal_blocks_and_recovers_after_crash(
|
||||
)
|
||||
|
||||
|
||||
def test_pin_initial_journal_publish_interrupt_is_inside_recovery_boundary(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
state, repo, plan = _pin_plan_fixture(tmp_path)
|
||||
real_create = authority._create_pin_journal
|
||||
interrupted = False
|
||||
|
||||
def publish_then_interrupt(path: Path, payload: dict) -> None:
|
||||
nonlocal interrupted
|
||||
real_create(path, payload)
|
||||
if not interrupted:
|
||||
interrupted = True
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
monkeypatch.setattr(authority, "_create_pin_journal", publish_then_interrupt)
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
authority.apply_pin_plan(plan)
|
||||
assert all(change.path.read_bytes() == change.original for change in plan.changes)
|
||||
control = authority._pin_control_directory(repo, state)
|
||||
assert not (control / "transaction.json").exists()
|
||||
assert list((control / "history").glob("*.rolled_back.json"))
|
||||
|
||||
|
||||
def test_partial_pin_journal_is_quarantined_and_blocks_future_apply(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
state, repo, plan = _pin_plan_fixture(tmp_path)
|
||||
with authority._repository_pin_lock(repo, state) as control:
|
||||
journal_path = control / "transaction.json"
|
||||
authority._publish_private_no_replace(
|
||||
journal_path,
|
||||
b'{"partial":',
|
||||
"partial test journal",
|
||||
)
|
||||
before = {change.path: change.path.read_bytes() for change in plan.changes}
|
||||
result = authority.recover_pin_transaction(repo, state)
|
||||
assert result["status"] == (
|
||||
"source_pin_transaction_quarantined_manual_recovery_required"
|
||||
)
|
||||
assert Path(result["quarantine_marker"]).is_file()
|
||||
assert Path(result["quarantine_file"]).read_bytes() == b'{"partial":'
|
||||
assert all(path.read_bytes() == raw for path, raw in before.items())
|
||||
with pytest.raises(authority.AuthorityError, match="quarantine requires manual recovery"):
|
||||
authority.apply_pin_plan(plan)
|
||||
|
||||
|
||||
def test_pin_commit_point_survives_archive_failure_without_rollback(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -1168,7 +1546,12 @@ def test_pin_recovery_validates_entire_journal_before_restoring_any_file(
|
||||
with authority._repository_pin_lock(repo, state) as control:
|
||||
journal = authority._journal_payload(plan)
|
||||
for change in changed[:2]:
|
||||
authority._write_atomic_replacement(change.path, change.proposed)
|
||||
authority._write_bound_source_replacement(
|
||||
change.path,
|
||||
repo,
|
||||
change.original,
|
||||
change.proposed,
|
||||
)
|
||||
journal["completed_paths"].append(change.path.relative_to(repo).as_posix())
|
||||
journal["status"] = "applying"
|
||||
journal["changes"][-1]["original_base64"] = base64.b64encode(
|
||||
@@ -1176,8 +1559,10 @@ def test_pin_recovery_validates_entire_journal_before_restoring_any_file(
|
||||
).decode("ascii")
|
||||
authority._create_pin_journal(control / "transaction.json", journal)
|
||||
|
||||
with pytest.raises(authority.AuthorityError, match="journal hash drift"):
|
||||
authority.recover_pin_transaction(repo, state)
|
||||
result = authority.recover_pin_transaction(repo, state)
|
||||
assert result["status"] == (
|
||||
"source_pin_transaction_quarantined_manual_recovery_required"
|
||||
)
|
||||
assert all(change.path.read_bytes() == change.proposed for change in changed[:2])
|
||||
|
||||
|
||||
@@ -1284,7 +1669,10 @@ def test_pin_planner_rejects_hardlinked_source_and_recovery_refuses_unknown_byte
|
||||
os.link(first.path, alias)
|
||||
except OSError:
|
||||
pytest.skip("hard links are unavailable on this platform")
|
||||
with pytest.raises(authority.AuthorityError, match="single-link regular"):
|
||||
with pytest.raises(
|
||||
authority.AuthorityError,
|
||||
match="single-link regular|path or parent identity changed",
|
||||
):
|
||||
authority._read_source_file(first.path, repo, first.path.name)
|
||||
alias.unlink()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user