diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/actual-strict-test-r3.txt b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/actual-strict-test-r3.txt new file mode 100644 index 00000000..410a7a52 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/actual-strict-test-r3.txt @@ -0,0 +1,21 @@ +{ + "scope": "disposable synthetic only", + "actual_source": "Approval App over TLS with authenticated create, configured synthetic human entry, read; actual SQLite engine", + "actual_access": "production serve binary from frozen B2/B3 temporal R2", + "allow": true, + "original_context_unchanged": true, + "published_digest_match": true, + "returned_source_record_match": true, + "actual_source_consumed_refused": true, + "actual_strict_prepare_and_consume": true, + "no_record_after_refusal": true, + "native_admission": false, + "files_sha256": { + "negative-cases.json": "sha256:1bef572773c34f87a3c6587dbc60ebeb2835c01125f690e8d2f5b0d70a6abadd", + "source-record.json": "sha256:e7e9f7a0ecfc86c7cd449de7443f615e9ba19cecab1cf85f67ec03267e949e64", + "handoff.json": "sha256:ef9739fce5d6be84f0a9e5a593499d4289fcc8bda79573c7d21316a45310234a", + "response.json": "sha256:e7e9f7a0ecfc86c7cd449de7443f615e9ba19cecab1cf85f67ec03267e949e64", + "original-approval-claim.json": "sha256:9e62510e1599d9bc61b15ebb24099e48a6992b808451af4fb3ce26a4b3338f1e", + "request.json": "sha256:d65fb473885c9d6fbb089f5b7dcdcecb75cf31cf20e60a9158e562cb0eb3af4d" + } +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/base.txt b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/base.txt new file mode 100644 index 00000000..4cf4e632 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/base.txt @@ -0,0 +1 @@ +5a2099f400fb0c97fed2d991832c0e31513bbdfb diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/focused-tests-final-r4.txt b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/focused-tests-final-r4.txt new file mode 100644 index 00000000..98954a19 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/focused-tests-final-r4.txt @@ -0,0 +1,4 @@ +........................................................................ [ 33%] +........................................................................ [ 67%] +..................................................................... [100%] +213 passed in 17.26s diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/focused-tests-r2.txt b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/focused-tests-r2.txt new file mode 100644 index 00000000..a04600c4 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/focused-tests-r2.txt @@ -0,0 +1,4 @@ +........................................................................ [ 36%] +........................................................................ [ 73%] +.................................................... [100%] +196 passed in 24.01s diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/focused-tests-r4.txt b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/focused-tests-r4.txt new file mode 100644 index 00000000..bb52e817 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/focused-tests-r4.txt @@ -0,0 +1,4 @@ +........................................................................ [ 34%] +........................................................................ [ 69%] +................................................................ [100%] +208 passed in 19.25s diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/focused-tests.txt b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/focused-tests.txt new file mode 100644 index 00000000..835014c0 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/focused-tests.txt @@ -0,0 +1,4 @@ +........................................................................ [ 37%] +........................................................................ [ 75%] +................................................ [100%] +192 passed in 25.13s diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/full-suite-baseline-comparison-r4.json b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/full-suite-baseline-comparison-r4.json new file mode 100644 index 00000000..1a4ddf69 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/full-suite-baseline-comparison-r4.json @@ -0,0 +1,24 @@ +{ + "prior_handoff_failure_file": "docs/changes/netkingdom-target-2026-10-16/decision-handoff/secrets-task-handoff-full.log", + "actual_current_result": "784 passed,16 failed,7 skipped", + "exact_failure_list_matches_prior": true, + "failures": [ + "FAILED tests/test_exec_owner.py::test_actual_bound_child_gets_only_selected_secret_and_fixed_environment", + "FAILED tests/test_exec_owner.py::test_config_changed_during_fetch_does_not_launch", + "FAILED tests/test_exec_owner.py::test_binding_changed_after_approval_refuses_before_fetch", + "FAILED tests/test_exec_owner_companions.py::test_companion_value_reaches_bound_child_and_is_redacted", + "FAILED tests/test_exec_owner_companions.py::test_companion_fetch_failure_starts_no_child", + "FAILED tests/test_exec_owner_companions.py::test_each_lane_is_gated_separately_before_backend", + "FAILED tests/test_exec_owner_companions.py::test_unresolvable_companion_refuses_before_any_gate", + "FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[missing]", + "FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[extra]", + "FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[env]", + "FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[field]", + "FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[lane]", + "FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[consent]", + "FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[stage]", + "FAILED tests/test_human_control.py::test_real_exec_handler_rejects_undeclared_claim_before_consume_backend_child", + "FAILED tests/test_integration_companions.py::test_two_lanes_reach_one_bound_owner" + ], + "new_failures": [] +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/full-suite-r4.txt b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/full-suite-r4.txt new file mode 100644 index 00000000..84806c43 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/full-suite-r4.txt @@ -0,0 +1,562 @@ +........................................................................ [ 8%] +........................................................................ [ 17%] +............s........................................................... [ 26%] +.................................................................F...... [ 35%] +..FF..................FF....................FF..FFFFFFF................. [ 44%] +........................................................................ [ 53%] +..........................................F............F................ [ 62%] +........................................................................ [ 71%] +............................................ssssss...................... [ 80%] +........................................................................ [ 89%] +........................................................................ [ 98%] +............... [100%] +=================================== FAILURES =================================== +___ test_actual_bound_child_gets_only_selected_secret_and_fixed_environment ____ + +bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...nd_child_gets_o0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_actual_bound_child_gets_o0/owner/owner.sh')) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756740d72750> +capsys = <_pytest.capture.CaptureFixture object at 0x756740d70da0> + + def test_actual_bound_child_gets_only_selected_secret_and_fixed_environment(bound, monkeypatch, capsys): + data, private, _ = bound + entry = validate_entry(data) + for name in ["PARENT_CREDENTIAL", "BAO_TOKEN", "SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE", "PYTHONPATH"]: + monkeypatch.setenv(name, "parent-sensitive-fixture") + monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: "synthetic-owner-value") +> assert exec_delivery.exec_with_secret(object(), entry, "api_token", data["delivery_config"]["exec_owner"]["command"], mode="exec-env", expected_owner_digest=owner_digest(entry)) == 0 + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +tests/test_exec_owner.py:55: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/secrets_engine/exec_delivery.py:199: in exec_with_secret + binding_digest = validate_delivery_target(entry, field, command, mode) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +src/secrets_engine/exec_owner.py:171: in validate_delivery_target + _check_path(Path(binding["cwd"]), directory=True, private=True) +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +path = PosixPath('/tmp/pytest-of-worsch/pytest-207/test_actual_bound_child_gets_o0/owner') +directory = True, private = True + + def _check_path(path: Path, *, directory: bool = False, private: bool = False) -> None: + """No symlink or untrusted writable ancestor; root-owned sticky /tmp is safe.""" + for candidate in [*reversed(path.parents), path]: + info = candidate.lstat() + leaf = candidate == path + is_dir = not leaf or directory + if not (stat.S_ISDIR(info.st_mode) if is_dir else stat.S_ISREG(info.st_mode)): + raise DeliveryError("exec owner path is not a regular file/directory") + if info.st_uid not in {0, os.getuid()}: +> raise DeliveryError("exec owner path has an untrusted owner") +E secrets_engine.errors.DeliveryError: exec owner path has an untrusted owner + +src/secrets_engine/exec_owner.py:146: DeliveryError +_______________ test_config_changed_during_fetch_does_not_launch _______________ + +bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...nged_during_fet0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_config_changed_during_fet0/owner/owner.sh')) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756740da8500> + + def test_config_changed_during_fetch_does_not_launch(bound, monkeypatch): + data, _, script = bound + entry = validate_entry(data) + def fetch(*a, **k): + script.write_text("changed during fetch") + return "synthetic-owner-value" + monkeypatch.setattr(exec_delivery, "_fetch_value", fetch) + monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("must not launch")) +> with pytest.raises(DeliveryError, match="digest"): +E AssertionError: Regex pattern did not match. +E Expected regex: 'digest' +E Actual message: 'exec owner path has an untrusted owner' + +tests/test_exec_owner.py:91: AssertionError +___________ test_binding_changed_after_approval_refuses_before_fetch ___________ + +bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...anged_after_app0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_binding_changed_after_app0/owner/owner.sh')) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756740d5cbf0> + + def test_binding_changed_after_approval_refuses_before_fetch(bound, monkeypatch): + data, _, _ = bound + entry = validate_entry(data) + expected = owner_digest(entry) + entry.delivery_config["exec_owner"]["environment"]["LANG"] = "C" + monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("must not read")) +> with pytest.raises(DeliveryError, match="after action admission"): +E AssertionError: Regex pattern did not match. +E Expected regex: 'after action admission' +E Actual message: 'exec owner path has an untrusted owner' + +tests/test_exec_owner.py:101: AssertionError +___________ test_companion_value_reaches_bound_child_and_is_redacted ___________ + +bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...value_reaches_b0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_companion_value_reaches_b0/owner/owner.sh')) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741ef8b00> +capsys = <_pytest.capture.CaptureFixture object at 0x756741efa2a0> + + def test_companion_value_reaches_bound_child_and_is_redacted(bound, monkeypatch, capsys): + data, _, script = bound + script.write_text('''test "$API_TOKEN" = synthetic-owner-value || exit 10 + test "$WORKER_TOKEN" = synthetic-worker-value || exit 11 + read ignored && exit 15 + printf '%s %s\\n' "$API_TOKEN" "$WORKER_TOKEN" + printf 'companion-child-ok\\n' + ''') + import hashlib + data["delivery_config"]["exec_owner"]["files"][str(script)]["sha256"] = hashlib.sha256(script.read_bytes()).hexdigest() + entry = validate_entry(_with_companion(data)) + lane = validate_entry(_companion()) + companions = resolve_companions(entry, _lookup(lane)) + values = {"test-lane": "synthetic-owner-value", "test-worker": "synthetic-worker-value"} + seen = [] + def fetch(client, e, field, **kwargs): + seen.append((e.id, field)) + return values[e.id] + monkeypatch.setattr(exec_delivery, "_fetch_value", fetch) + sessions = {} +> rc = exec_delivery.exec_with_secret( + object(), entry, "api_token", data["delivery_config"]["exec_owner"]["command"], + mode="exec-env", expected_owner_digest=owner_digest(entry), + companions=companions, companion_sessions=sessions, + ) + +tests/test_exec_owner_companions.py:55: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/secrets_engine/exec_delivery.py:199: in exec_with_secret + binding_digest = validate_delivery_target(entry, field, command, mode) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +src/secrets_engine/exec_owner.py:171: in validate_delivery_target + _check_path(Path(binding["cwd"]), directory=True, private=True) +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +path = PosixPath('/tmp/pytest-of-worsch/pytest-207/test_companion_value_reaches_b0/owner') +directory = True, private = True + + def _check_path(path: Path, *, directory: bool = False, private: bool = False) -> None: + """No symlink or untrusted writable ancestor; root-owned sticky /tmp is safe.""" + for candidate in [*reversed(path.parents), path]: + info = candidate.lstat() + leaf = candidate == path + is_dir = not leaf or directory + if not (stat.S_ISDIR(info.st_mode) if is_dir else stat.S_ISREG(info.st_mode)): + raise DeliveryError("exec owner path is not a regular file/directory") + if info.st_uid not in {0, os.getuid()}: +> raise DeliveryError("exec owner path has an untrusted owner") +E secrets_engine.errors.DeliveryError: exec owner path has an untrusted owner + +src/secrets_engine/exec_owner.py:146: DeliveryError +_________________ test_companion_fetch_failure_starts_no_child _________________ + +bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...fetch_failure_s0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_companion_fetch_failure_s0/owner/owner.sh')) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741f37ec0> + + def test_companion_fetch_failure_starts_no_child(bound, monkeypatch): + data, _, _ = bound + entry = validate_entry(_with_companion(data)) + lane = validate_entry(_companion()) + def fetch(client, e, field, **kwargs): + if e.id == "test-worker": + raise DeliveryError("scoped read failed for lane 'test-worker' (denied or absent)") + return "synthetic-owner-value" + monkeypatch.setattr(exec_delivery, "_fetch_value", fetch) + monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("must not launch")) +> with pytest.raises(DeliveryError, match="test-worker"): +E AssertionError: Regex pattern did not match. +E Expected regex: 'test-worker' +E Actual message: 'exec owner path has an untrusted owner' + +tests/test_exec_owner_companions.py:77: AssertionError +______________ test_each_lane_is_gated_separately_before_backend _______________ + +bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...is_gated_separa0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_each_lane_is_gated_separa0/owner/owner.sh')) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741f36990> +tmp_path = PosixPath('/tmp/pytest-of-worsch/pytest-207/test_each_lane_is_gated_separa0') + + def test_each_lane_is_gated_separately_before_backend(bound, monkeypatch, tmp_path): + data, _, _ = bound + entry = validate_entry(_with_companion(data)) + lane = validate_entry(_companion()) + table = {entry.id: entry, lane.id: lane} + monkeypatch.setattr(cli, "get_entry", lambda _dir, cid: table[cid]) + gated = [] + def gate(cfg, e, action, evidence, *, fields=(), auth_args=None): + assert auth_args is args + gated.append((e.id, action, fields)) + if e.id == "test-worker": + raise DeliveryError("companion lane denied") + return SimpleNamespace(status="approved") + monkeypatch.setattr(cli, "_require_lane_approval", gate) + monkeypatch.setattr(cli, "require_delivery_state", lambda *a: None) + monkeypatch.setattr(cli, "_open_backend", lambda *a, **k: pytest.fail("no backend after a lane refusal")) + command = data["delivery_config"]["exec_owner"]["command"] + args = SimpleNamespace(field=None, catalog=entry.id, command=command, mode="exec-env") +> with pytest.raises(DeliveryError, match="companion lane denied"): +E AssertionError: Regex pattern did not match. +E Expected regex: 'companion lane denied' +E Actual message: 'exec owner path has an untrusted owner' + +tests/test_exec_owner_companions.py:166: AssertionError +_____________ test_unresolvable_companion_refuses_before_any_gate ______________ + +bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...le_companion_re0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_unresolvable_companion_re0/owner/owner.sh')) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741f37bf0> +tmp_path = PosixPath('/tmp/pytest-of-worsch/pytest-207/test_unresolvable_companion_re0') + + def test_unresolvable_companion_refuses_before_any_gate(bound, monkeypatch, tmp_path): + data, _, _ = bound + entry = validate_entry(_with_companion(data)) + def get(_dir, cid): + if cid == entry.id: + return entry + raise KeyError(cid) + monkeypatch.setattr(cli, "get_entry", get) + monkeypatch.setattr(cli, "_require_lane_approval", lambda *a, **k: pytest.fail("no gate")) + monkeypatch.setattr(cli, "_open_backend", lambda *a, **k: pytest.fail("no backend")) + command = data["delivery_config"]["exec_owner"]["command"] +> with pytest.raises(DeliveryError, match="unavailable"): +E AssertionError: Regex pattern did not match. +E Expected regex: 'unavailable' +E Actual message: 'exec owner path has an untrusted owner' + +tests/test_exec_owner_companions.py:182: AssertionError +______ test_delivery_rechecks_companion_contract_before_any_read[missing] ______ + +bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...echecks_compani0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_delivery_rechecks_compani0/owner/owner.sh')) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756740d5c860> +change = 'missing' + + @pytest.mark.parametrize("change", ["missing", "extra", "env", "field", "lane", "consent", "stage"]) + def test_delivery_rechecks_companion_contract_before_any_read(bound, monkeypatch, change): + data, _, _ = bound + entry = validate_entry(_with_companion(data)) + lane_data = _companion() + if change == "consent": + lane_data["delivery_config"] = {} + elif change == "stage": + lane_data.update(stage="build", path="build/team/worker") + elif change == "lane": + lane_data["id"] = "another-worker" + lane = validate_entry(lane_data) + companions = [(lane, "worker_token", "WORKER_TOKEN")] + if change == "missing": companions = [] + elif change == "extra": companions *= 2 + elif change == "env": companions = [(lane, "worker_token", "API_TOKEN")] + elif change == "field": companions = [(lane, "other_field", "WORKER_TOKEN")] + monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("no read")) + monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("no child")) +> with pytest.raises(DeliveryError, match="companion"): +E AssertionError: Regex pattern did not match. +E Expected regex: 'companion' +E Actual message: 'exec owner path has an untrusted owner' + +tests/test_exec_owner_companions.py:223: AssertionError +_______ test_delivery_rechecks_companion_contract_before_any_read[extra] _______ + +bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...echecks_compani1/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_delivery_rechecks_compani1/owner/owner.sh')) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756740d5d9a0> +change = 'extra' + + @pytest.mark.parametrize("change", ["missing", "extra", "env", "field", "lane", "consent", "stage"]) + def test_delivery_rechecks_companion_contract_before_any_read(bound, monkeypatch, change): + data, _, _ = bound + entry = validate_entry(_with_companion(data)) + lane_data = _companion() + if change == "consent": + lane_data["delivery_config"] = {} + elif change == "stage": + lane_data.update(stage="build", path="build/team/worker") + elif change == "lane": + lane_data["id"] = "another-worker" + lane = validate_entry(lane_data) + companions = [(lane, "worker_token", "WORKER_TOKEN")] + if change == "missing": companions = [] + elif change == "extra": companions *= 2 + elif change == "env": companions = [(lane, "worker_token", "API_TOKEN")] + elif change == "field": companions = [(lane, "other_field", "WORKER_TOKEN")] + monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("no read")) + monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("no child")) +> with pytest.raises(DeliveryError, match="companion"): +E AssertionError: Regex pattern did not match. +E Expected regex: 'companion' +E Actual message: 'exec owner path has an untrusted owner' + +tests/test_exec_owner_companions.py:223: AssertionError +________ test_delivery_rechecks_companion_contract_before_any_read[env] ________ + +bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...echecks_compani2/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_delivery_rechecks_compani2/owner/owner.sh')) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741ec4800> +change = 'env' + + @pytest.mark.parametrize("change", ["missing", "extra", "env", "field", "lane", "consent", "stage"]) + def test_delivery_rechecks_companion_contract_before_any_read(bound, monkeypatch, change): + data, _, _ = bound + entry = validate_entry(_with_companion(data)) + lane_data = _companion() + if change == "consent": + lane_data["delivery_config"] = {} + elif change == "stage": + lane_data.update(stage="build", path="build/team/worker") + elif change == "lane": + lane_data["id"] = "another-worker" + lane = validate_entry(lane_data) + companions = [(lane, "worker_token", "WORKER_TOKEN")] + if change == "missing": companions = [] + elif change == "extra": companions *= 2 + elif change == "env": companions = [(lane, "worker_token", "API_TOKEN")] + elif change == "field": companions = [(lane, "other_field", "WORKER_TOKEN")] + monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("no read")) + monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("no child")) +> with pytest.raises(DeliveryError, match="companion"): +E AssertionError: Regex pattern did not match. +E Expected regex: 'companion' +E Actual message: 'exec owner path has an untrusted owner' + +tests/test_exec_owner_companions.py:223: AssertionError +_______ test_delivery_rechecks_companion_contract_before_any_read[field] _______ + +bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...echecks_compani3/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_delivery_rechecks_compani3/owner/owner.sh')) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741ec4260> +change = 'field' + + @pytest.mark.parametrize("change", ["missing", "extra", "env", "field", "lane", "consent", "stage"]) + def test_delivery_rechecks_companion_contract_before_any_read(bound, monkeypatch, change): + data, _, _ = bound + entry = validate_entry(_with_companion(data)) + lane_data = _companion() + if change == "consent": + lane_data["delivery_config"] = {} + elif change == "stage": + lane_data.update(stage="build", path="build/team/worker") + elif change == "lane": + lane_data["id"] = "another-worker" + lane = validate_entry(lane_data) + companions = [(lane, "worker_token", "WORKER_TOKEN")] + if change == "missing": companions = [] + elif change == "extra": companions *= 2 + elif change == "env": companions = [(lane, "worker_token", "API_TOKEN")] + elif change == "field": companions = [(lane, "other_field", "WORKER_TOKEN")] + monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("no read")) + monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("no child")) +> with pytest.raises(DeliveryError, match="companion"): +E AssertionError: Regex pattern did not match. +E Expected regex: 'companion' +E Actual message: 'exec owner path has an untrusted owner' + +tests/test_exec_owner_companions.py:223: AssertionError +_______ test_delivery_rechecks_companion_contract_before_any_read[lane] ________ + +bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...echecks_compani4/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_delivery_rechecks_compani4/owner/owner.sh')) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741ec7b60> +change = 'lane' + + @pytest.mark.parametrize("change", ["missing", "extra", "env", "field", "lane", "consent", "stage"]) + def test_delivery_rechecks_companion_contract_before_any_read(bound, monkeypatch, change): + data, _, _ = bound + entry = validate_entry(_with_companion(data)) + lane_data = _companion() + if change == "consent": + lane_data["delivery_config"] = {} + elif change == "stage": + lane_data.update(stage="build", path="build/team/worker") + elif change == "lane": + lane_data["id"] = "another-worker" + lane = validate_entry(lane_data) + companions = [(lane, "worker_token", "WORKER_TOKEN")] + if change == "missing": companions = [] + elif change == "extra": companions *= 2 + elif change == "env": companions = [(lane, "worker_token", "API_TOKEN")] + elif change == "field": companions = [(lane, "other_field", "WORKER_TOKEN")] + monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("no read")) + monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("no child")) +> with pytest.raises(DeliveryError, match="companion"): +E AssertionError: Regex pattern did not match. +E Expected regex: 'companion' +E Actual message: 'exec owner path has an untrusted owner' + +tests/test_exec_owner_companions.py:223: AssertionError +______ test_delivery_rechecks_companion_contract_before_any_read[consent] ______ + +bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...echecks_compani5/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_delivery_rechecks_compani5/owner/owner.sh')) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741f37b60> +change = 'consent' + + @pytest.mark.parametrize("change", ["missing", "extra", "env", "field", "lane", "consent", "stage"]) + def test_delivery_rechecks_companion_contract_before_any_read(bound, monkeypatch, change): + data, _, _ = bound + entry = validate_entry(_with_companion(data)) + lane_data = _companion() + if change == "consent": + lane_data["delivery_config"] = {} + elif change == "stage": + lane_data.update(stage="build", path="build/team/worker") + elif change == "lane": + lane_data["id"] = "another-worker" + lane = validate_entry(lane_data) + companions = [(lane, "worker_token", "WORKER_TOKEN")] + if change == "missing": companions = [] + elif change == "extra": companions *= 2 + elif change == "env": companions = [(lane, "worker_token", "API_TOKEN")] + elif change == "field": companions = [(lane, "other_field", "WORKER_TOKEN")] + monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("no read")) + monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("no child")) +> with pytest.raises(DeliveryError, match="companion"): +E AssertionError: Regex pattern did not match. +E Expected regex: 'companion' +E Actual message: 'exec owner path has an untrusted owner' + +tests/test_exec_owner_companions.py:223: AssertionError +_______ test_delivery_rechecks_companion_contract_before_any_read[stage] _______ + +bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...echecks_compani6/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_delivery_rechecks_compani6/owner/owner.sh')) +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741f34200> +change = 'stage' + + @pytest.mark.parametrize("change", ["missing", "extra", "env", "field", "lane", "consent", "stage"]) + def test_delivery_rechecks_companion_contract_before_any_read(bound, monkeypatch, change): + data, _, _ = bound + entry = validate_entry(_with_companion(data)) + lane_data = _companion() + if change == "consent": + lane_data["delivery_config"] = {} + elif change == "stage": + lane_data.update(stage="build", path="build/team/worker") + elif change == "lane": + lane_data["id"] = "another-worker" + lane = validate_entry(lane_data) + companions = [(lane, "worker_token", "WORKER_TOKEN")] + if change == "missing": companions = [] + elif change == "extra": companions *= 2 + elif change == "env": companions = [(lane, "worker_token", "API_TOKEN")] + elif change == "field": companions = [(lane, "other_field", "WORKER_TOKEN")] + monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("no read")) + monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("no child")) +> with pytest.raises(DeliveryError, match="companion"): +E AssertionError: Regex pattern did not match. +E Expected regex: 'companion' +E Actual message: 'exec owner path has an untrusted owner' + +tests/test_exec_owner_companions.py:223: AssertionError +_ test_real_exec_handler_rejects_undeclared_claim_before_consume_backend_child _ + +bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'prod', ...}, PosixPath('/tmp/pytest-of-worsch/py...handler_rejects0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_real_exec_handler_rejects0/owner/owner.sh')) +tmp_path = PosixPath('/tmp/pytest-of-worsch/pytest-207/test_real_exec_handler_rejects0') +monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x756741eef5f0> + + def test_real_exec_handler_rejects_undeclared_claim_before_consume_backend_child(bound, tmp_path, monkeypatch): + raw, _, _ = bound + raw["stage"] = "prod" + raw["approval"].update(model="decision", authorization_id=APPROVAL_ID, + purpose="contract-test", human_control=True) + entry = validate_entry(raw) + cfg = replace(_config(tmp_path), approval_url="https://approval.example", + approval_token_file=_token(tmp_path), authorization_subject_id="user:alice", + authorization_subject_type="Human") + claim = _served(entry, action="exec") + monkeypatch.setattr(cli, "get_entry", lambda *a: entry) + monkeypatch.setattr(cli, "authorize_action", lambda *a, **k: authorize_action(*a, **k, + opener=_opener(claim), pdp_opener=lambda *a, **k: pytest.fail("must not check"))) + for name in ("require_production_consume", "_open_backend"): + monkeypatch.setattr(cli, name, lambda *a, **k: pytest.fail("must not consume or open backend")) + with pytest.raises(DecisionError, match="human_control"): +> cli.cmd_exec(cfg, SimpleNamespace(catalog=entry.id, field="api_token", mode="exec-env", + command=raw["delivery_config"]["exec_owner"]["command"])) + +tests/test_human_control.py:161: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/secrets_engine/cli.py:569: in cmd_exec + owner_digest = validate_delivery_target(entry, field, args.command, args.mode) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +src/secrets_engine/exec_owner.py:171: in validate_delivery_target + _check_path(Path(binding["cwd"]), directory=True, private=True) +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +path = PosixPath('/tmp/pytest-of-worsch/pytest-207/test_real_exec_handler_rejects0/owner') +directory = True, private = True + + def _check_path(path: Path, *, directory: bool = False, private: bool = False) -> None: + """No symlink or untrusted writable ancestor; root-owned sticky /tmp is safe.""" + for candidate in [*reversed(path.parents), path]: + info = candidate.lstat() + leaf = candidate == path + is_dir = not leaf or directory + if not (stat.S_ISDIR(info.st_mode) if is_dir else stat.S_ISREG(info.st_mode)): + raise DeliveryError("exec owner path is not a regular file/directory") + if info.st_uid not in {0, os.getuid()}: +> raise DeliveryError("exec owner path has an untrusted owner") +E secrets_engine.errors.DeliveryError: exec owner path has an untrusted owner + +src/secrets_engine/exec_owner.py:146: DeliveryError +_____________________ test_two_lanes_reach_one_bound_owner _____________________ + +bao_dev = OpenBaoClient(addr='http://127.0.0.1:55527', token='se-test-root', bao_bin='/home/worsch/.local/bin/bao') +bound = ({'id': 'test-lane', 'org': 'coulomb', 'repo': 'team-repo', 'stage': 'test', ...}, PosixPath('/tmp/pytest-of-worsch/py...reach_one_bound0/owner'), PosixPath('/tmp/pytest-of-worsch/pytest-207/test_two_lanes_reach_one_bound0/owner/owner.sh')) +tmp_path = PosixPath('/tmp/pytest-of-worsch/pytest-207/test_two_lanes_reach_one_bound0') +capsys = <_pytest.capture.CaptureFixture object at 0x756740d5fd70> + + def test_two_lanes_reach_one_bound_owner(bao_dev, bound, tmp_path, capsys): + data, _, script = bound + script.write_text('''test "$API_TOKEN" = primary-integration-value || exit 10 + test "$WORKER_TOKEN" = worker-integration-value || exit 11 + test -z "$BAO_TOKEN" || exit 12 + read ignored && exit 15 + printf '%s %s\\n' "$API_TOKEN" "$WORKER_TOKEN" + printf 'two-lane-child-ok\\n' + ''') + data["delivery_config"]["exec_owner"]["files"][str(script)]["sha256"] = hashlib.sha256( + script.read_bytes() + ).hexdigest() + entry = validate_entry(_with_companion(copy.deepcopy(data))) + lane = validate_entry(_companion()) + _provision(bao_dev, entry, "api_token", "primary-integration-value", tmp_path) + _provision(bao_dev, lane, "worker_token", "worker-integration-value", tmp_path) + + sessions, primary_session = {}, {} +> rc = exec_with_secret( + bao_dev, entry, "api_token", data["delivery_config"]["exec_owner"]["command"], + mode="exec-env", session_evidence=primary_session, + expected_owner_digest=owner_digest(entry), + companions=resolve_companions(entry, _lookup(lane)), companion_sessions=sessions, + ) + +tests/test_integration_companions.py:47: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +src/secrets_engine/exec_delivery.py:199: in exec_with_secret + binding_digest = validate_delivery_target(entry, field, command, mode) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +src/secrets_engine/exec_owner.py:171: in validate_delivery_target + _check_path(Path(binding["cwd"]), directory=True, private=True) +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +path = PosixPath('/tmp/pytest-of-worsch/pytest-207/test_two_lanes_reach_one_bound0/owner') +directory = True, private = True + + def _check_path(path: Path, *, directory: bool = False, private: bool = False) -> None: + """No symlink or untrusted writable ancestor; root-owned sticky /tmp is safe.""" + for candidate in [*reversed(path.parents), path]: + info = candidate.lstat() + leaf = candidate == path + is_dir = not leaf or directory + if not (stat.S_ISDIR(info.st_mode) if is_dir else stat.S_ISREG(info.st_mode)): + raise DeliveryError("exec owner path is not a regular file/directory") + if info.st_uid not in {0, os.getuid()}: +> raise DeliveryError("exec owner path has an untrusted owner") +E secrets_engine.errors.DeliveryError: exec owner path has an untrusted owner + +src/secrets_engine/exec_owner.py:146: DeliveryError +=========================== short test summary info ============================ +FAILED tests/test_exec_owner.py::test_actual_bound_child_gets_only_selected_secret_and_fixed_environment +FAILED tests/test_exec_owner.py::test_config_changed_during_fetch_does_not_launch +FAILED tests/test_exec_owner.py::test_binding_changed_after_approval_refuses_before_fetch +FAILED tests/test_exec_owner_companions.py::test_companion_value_reaches_bound_child_and_is_redacted +FAILED tests/test_exec_owner_companions.py::test_companion_fetch_failure_starts_no_child +FAILED tests/test_exec_owner_companions.py::test_each_lane_is_gated_separately_before_backend +FAILED tests/test_exec_owner_companions.py::test_unresolvable_companion_refuses_before_any_gate +FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[missing] +FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[extra] +FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[env] +FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[field] +FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[lane] +FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[consent] +FAILED tests/test_exec_owner_companions.py::test_delivery_rechecks_companion_contract_before_any_read[stage] +FAILED tests/test_human_control.py::test_real_exec_handler_rejects_undeclared_claim_before_consume_backend_child +FAILED tests/test_integration_companions.py::test_two_lanes_reach_one_bound_owner +16 failed, 784 passed, 7 skipped in 55.47s diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/owner-preexisting-tracked.diff b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/owner-preexisting-tracked.diff new file mode 100644 index 00000000..acf1eeaa --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/owner-preexisting-tracked.diff @@ -0,0 +1,96 @@ +diff --git a/.custodian-brief.md b/.custodian-brief.md +index 0cb912a..ba794c8 100644 +--- a/.custodian-brief.md ++++ b/.custodian-brief.md +@@ -2,7 +2,7 @@ + # Custodian Brief — secrets-engine + + **Domain:** infotech +-**Last synced:** 2026-10-11 17:52 UTC ++**Last synced:** 2026-10-16 05:55 UTC + **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* + + ## Active Workstreams +@@ -11,8 +11,24 @@ + Progress: 1/3 done | workplan_id: `5e38aecc-85ca-5f55-94c6-9fbb08604819` + + **Open tasks:** ++- ! Define the exact mediated execution contract `b2f0d462` ++ *(wait: Independent Platform mediated-contract review and identity/approval/PDP owner returns remain required; D3 wrapped primitive source is implemented, no native entitlement is inferred.)* + - ! Implement and prove one native metadata action `5f8f7c41` +-- ► Define the exact mediated execution contract `b2f0d462` ++ ++### Constrain PDP transport and authenticate decision responses ++Progress: 1/3 done | workplan_id: `7252469d-d7a5-5c18-8911-b511b8c6a736` ++ ++**Open tasks:** ++- ! Verify the admitted native consumer and rotation path `85aa4c78` ++ *(wait: Requires reviewed verification-key admission, source enforcement and exact native rollout receipt; no production trust change is proposed yet.)* ++- ► Implement independently anchored responder authentication `877782e5` ++ ++### Production-safe provisioning, authorization, and lifecycle hardening ++Progress: 6/7 done | workplan_id: `68a39be1-bd9c-5133-ad64-e7bca892aaf3` ++ ++**Open tasks:** ++- ! Resume native production lane adoption `a0a1dd92` ++ *(wait: Engine approval hardening is complete. Remaining per-lane cutover includes native routing/proxy retirement with ops-warden under SECRETS-WP-0006-T05/T06; the exact OpenRouter key-check receipt does not establish broader recipient readiness.)* + + ### Activate native Claude credential delivery for Glas + Progress: 2/3 done | workplan_id: `40ccc3b4-d046-5a58-8649-e7935f45c974` +@@ -26,27 +42,20 @@ Progress: 4/6 done | workplan_id: `31f7f8ea-7f73-516c-8877-f03a13f1db82` + + **Open tasks:** + - ! Stage live apply and verification `fb103f1e` +- *(wait: OpenRouter key-check native acceptance is recorded in SECRETS-WP-0010-T03. Other lanes still need exact approvals, scoped attended apply and per-lane positive/negative/health/revocation evidence.)* ++ *(wait: Owner work first: publish a reviewed scoped-apply command per lane. Per-lane approvals are no longer needed (D1 standing rule); each lane with a command enters the attended window.)* + - ! Reconcile routing ownership and retire interim proxies `1431edae` + *(wait: Needs owner-agreed routing/proxy retirement for each verified lane and custody disposition of the legacy npm pointer; a single approved OpenRouter key-check does not authorize broader routing cutover.)* + +-### Production-safe provisioning, authorization, and lifecycle hardening +-Progress: 6/7 done | workplan_id: `68a39be1-bd9c-5133-ad64-e7bca892aaf3` +- +-**Open tasks:** +-- ! Resume native production lane adoption `a0a1dd92` +- *(wait: Engine approval hardening is complete. Remaining per-lane cutover includes native routing/proxy retirement with ops-warden under SECRETS-WP-0006-T05/T06; the exact OpenRouter key-check receipt does not establish broader recipient readiness.)* +- + ### Evolve the Lifecycle engine to the accepted security layer model + Progress: 5/6 done | workplan_id: `9c9e5164-b2f5-5ea2-a557-5368d65e9fe0` + + **Open tasks:** + - ! No standing engine credential `d7bc8bdc` +- *(wait: Positive service-JWT login proof passed 2026-10-10 (SECRETS-WP-0012-T03, docs/evidence/2026-10-10-keycape-openbao-probe.json). Waits on railiance-platform closing RPF-WP-0035-T02 with the CCR-2026-0038 negative checks, then on routing live engine actions through service-jwt instead of bootstrap/env.)* ++ *(wait: Published JWT contract and positive login/revocation are delivered. Native KEY-WP-0039-T02 claim negatives, CCR-2026-0038 effective-policy/capability/expiry/use checks and MASON-WP-0009-T05 remain. Login-self-only grants no protected-operation authority: agree and implement separately reviewed task-scoped backend execution entitlement, then prove functional steady-state service-identity adoption without bootstrap/env fallback.)* + + ## Inbox Hygiene + +-**Stale unread:** 2 message(s) older than 3 day(s) — triage at session start. ++**Stale unread:** 3 message(s) older than 3 day(s) — triage at session start. + **Missing thread_id:** 2 unread message(s) lack supersession chains. + + --- +diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md +index 70e5831..4d1cfb6 100644 +--- a/WORK-RECORDS.md ++++ b/WORK-RECORDS.md +@@ -22,6 +22,7 @@ + | workplan | SECRETS-WP-0011 | finished | — | workplans/SECRETS-WP-0011-multi-lane-exec-owner-delivery.md | + | workplan | SECRETS-WP-0012 | finished | — | workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md | + | workplan | SECRETS-WP-0013 | proposed | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md | ++| workplan | SECRETS-WP-0014 | active | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md | + | task | SECRETS-WP-ADHOC-2026-08-21-T01 | done | — | workplans/ADHOC-2026-08-21.md | + | task | SECRETS-WP-ADHOC-2026-08-23-T01 | done | — | workplans/ADHOC-2026-08-23.md | + | task | SECRETS-WP-0001-T01 | done | — | workplans/SECRETS-WP-0001-statehub-bootstrap.md | +@@ -83,9 +84,12 @@ + | task | SECRETS-WP-0012-T02 | cancel | — | workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md | + | task | SECRETS-WP-0012-T03 | done | — | workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md | + | task | SECRETS-WP-0012-T04 | done | — | workplans/SECRETS-WP-0012-keycape-openbao-consumer-custody.md | +-| task | SECRETS-WP-0013-T01 | progress | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md | ++| task | SECRETS-WP-0013-T01 | wait | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md | + | task | SECRETS-WP-0013-T02 | done | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md | + | task | SECRETS-WP-0013-T03 | wait | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md | ++| task | SECRETS-WP-0014-T01 | done | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md | ++| task | SECRETS-WP-0014-T02 | progress | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md | ++| task | SECRETS-WP-0014-T03 | wait | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md | + | intake | SECRETS-IN-0001 | closed | — | intakes/intakes.md | + | intake | SECRETS-IN-0002 | closed | — | intakes/intakes.md | + | intake | SECRETS-IN-0003 | closed | — | intakes/intakes.md | diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/owner-review-r1.md b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/owner-review-r1.md new file mode 100644 index 00000000..595e4865 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/owner-review-r1.md @@ -0,0 +1,49 @@ +# Secrets trusted-successor source review R1 + +Base: canonical Secrets `5a2099f400fb0c97fed2d991832c0e31513bbdfb`. +Source proposal only. Canonical owner checkout and production installed tools, +credentials, configuration, keys, policy, grants and native state are unchanged. +Preexisting owner metadata diff is retained separately and excluded from patch. + +The exact producer API/sign files match frozen Access B2/B3 R1 SHA +`6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1`. +No signature or canonicalization implementation was forked. Wrapper calls +producer DecodeEnvelope. Exact authenticated typed JSON is retained through +strict CAS, rather than reconstructing signed bytes from Python numbers. +Snapshots additionally compare parsed types via JSON serialization (which is +never signing input), refusing boolean/integer/float substitution. The helper +runs with empty environment. Direct authentication now also rejects malformed, +duplicate, nonfinite and invalid UTF-8 inputs before the producer process. + +Signed optional source observation is independently signature/policy-pin bound. +Strict handoff checks nonempty source/revision, digest syntax, exact native act +digest, observation <= validation <= current upper bound, current freshness, +and decision expiry <= source deadline. It supplements original carried claim; +original request/context/freshness, exact descriptor and caller, native and full +PDP joins, deadlines and once-only CAS remain required. Source identifiers are +not independently admitted by a fixture, and raw claim-byte digest is not +recomputed from a parsed claim with different formatting. + +103 focused signature/strict tests pass on final runtime source; 192 combined +focused tests passed before the final direct-parser strengthening (unchanged +strict signature suite rerun on final source). Seven actual offline bundle tests +pass, including deterministic builds/fresh installation/signed consumer and +missing or tampered helper refusals. No test signer is installed. Go packages +build/test and exact producer source checker pass. Existing legacy producer +signed rotate fixture remains accepted; fourteen actual Approval owner native +vectors verify and each tampered envelope refuses. These signature vectors do +not broaden request numeric admission. + +Actual regenerated Approval App HTTPS -> frozen Access production serve v2 +ALLOW -> Secrets signature boundary -> exact-byte reauthentication succeeds; +actual provider revocation then refuses, without a new durable record. +`actual-source-signature/receipt.json` retains artifacts/hashes. That separate +synthetic request is not accepted as a strict prepared task or estate admission. + +REMAINING POSITIVE STRICT BLOCKER: actual Secrets builder emits no +`service_actor_binding`, whereas v2 service input and policy require that exact +five-field public declaration before approval issuance. Current builder's actual +request therefore refuses. No injected context, callback, or equality relaxation +hides this mismatch. Parent has been asked to scope the narrow owner mapping +addition. This R1 package is ready for verifier-source review, not final v2 strict +positive handoff acceptance. Native trust/custody/runtime blockers remain open. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/owner-review-r2.md b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/owner-review-r2.md new file mode 100644 index 00000000..c27e77c5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/owner-review-r2.md @@ -0,0 +1,40 @@ +# Secrets successor R2 source disposition + +R1 artifacts remain immutable. The full patch applies to canonical Secrets +5a2099f400fb0c97fed2d991832c0e31513bbdfb; the delta applies after frozen R1. +Exact hashes and source preimages are retained in manifest-r2.json/preimages. + +The execution builder adds only the fixed Access DeclaredServiceBinding public +mapping after validating an actual ExecutionContract, prod/platform lane and exact +secrets-engine service subject. It sets subject/resource platform tenants before +approval issue, matching existing Access normalization. The strict exact subject +check accepts only that complete fixed subject. Other subjects refuse; lifecycle +requests gain no declaration or tenant change. This declaration is caller-supplied +public input, never authentication evidence; the independent signed enforced +transport caller remains mandatory. No arbitrary enrichment API or schema exists. + +196 focused tests and 7 actual offline reproducible build/install tests pass on +final R2 source. Actual source checker matches frozen producer bytes. The real +builder -> actual Approval App/SQLite with separate synthetic human entry -> +Access production serve with actual TokenReview and separately configured source +reader -> strict Secrets authorize -> exact CAS -> independent actual +ExecutionConfirmationClient GET succeeds. Six negative cases refuse: absent +service declaration, changed descriptor, changed native target, expired original +claim despite fresh provider, signature tamper, and prepared replay. After CAS, +Access also refuses the carried claim without writing another durable record. +No monkeypatch, injected approval context or authorization callback substitutes +for production request/claim/signature/handoff paths. Original context is exact. + +The proof uses fixed whole-second operation deadline inside v2's15-second allow +TTL, because consumer requires operation deadline<=decision expiry while producer +caps/floors expiry at that deadline. Fractional deadlines or longer operations +continue to refuse; this source change does not waive or extend any authority. +No native metadata task, package install outside disposable prefix, production +trust/key/custody/grant/policy admission or live protected effect occurred. + +The first actual proof reached strict CAS then failed a test-only GET assertion +using a nonexistent top-level provider key. Failure is preserved in +actual-strict-attempt1-r2.txt; correction uses actual owner ConfirmationClient. +Final source proof and six negative cases pass in actual-strict-test-r2.txt and +actual-strict-r2/. Prior R1 blocker report remains dated historical evidence; +this R2 resolves only that source mapping gap, not native acceptance. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/owner-review-r3.md b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/owner-review-r3.md new file mode 100644 index 00000000..4fc504d1 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/owner-review-r3.md @@ -0,0 +1,35 @@ +# Secrets temporal successor R3 review + +R1/R2 patches, manifests, tests and proof evidence remain immutable. This delta +changes only contract/workplan/verifier-provenance documentation; all Python and +Go runtime bytes match frozen Secrets R2 exactly (manifest-r3.json records each +runtime hash). Exact vendored producer API/sign source also matches temporal +Access R2; there is no canonicalizer refresh or algorithm change. + +The contract now describes the actual fixed backend_budget.ttl_seconds request, +not a dynamic min calculation. Before issuance the native owner requires +remaining>=declaredTTL+5s; requested/explicit maximum TTL equal the immutable +budget and returned TTL must be positive and <=budget. Operation deadline remains +bounded by decision/approval/caller and original/fresh source authority; token +expiry may precede operation deadline, with no retry/remint. A120s budget plus5s +cannot fit the120s hard operation cap and refuses. These are conservative bounds, +not authority extensions. Platform owner coordination was requested. + +Actual ApprovalApp -> Access temporalR2 production serve -> strict Secrets prepare +and CAS -> independent actual Approval ExecutionConfirmationClient GET passes +using explicit fixture freshness120/backendTTL30/operationdeadline60. Returned +signed expiry equals immutable operation deadline as an aware instant (Z and ++00:00 presentation differ). The original subsecond-context, full/native/approval +and submitted digest checks remain unchanged. Six negatives plus Access refusal +after consume pass. Seven actual offline bundle/install tests pass. + +An unresolved precision seam is retained: Approval consumed_at is whole-second +truncated, whereas Access temporalR2 signs subsecond not_before. Fast real CAS +can fail the strict receipt-before-start guard; actual-strict-fast-cas-refusal-r3.txt +retains this. Positive proof sequences CAS using real elapsed time until the +signed start's next whole second (<1s); it does not inject time or relax checks. +This establishes reachable positive source interoperability, not reliable fast-CAS +admission. Root and Access/Approval owners were notified; runtime is frozen until +they resolve the precision contract. The first R3 proof additionally failed a +test-only lexical Z/+00 comparison; preserved separately and corrected to aware +instant equality. No live credentials/effects/trust/policy/native admission. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/owner-review-r4.md b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/owner-review-r4.md new file mode 100644 index 00000000..2e896fb7 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/owner-review-r4.md @@ -0,0 +1,46 @@ +# Secrets retained-registry source R4 review + +R1/R2/R3 source packages remain immutable. R4 full applies to canonical5a2099f; +over-R3 delta applies only after frozen R3. Manifest records exact patch/source +hashes, producer R3 full46bfeb706d3a88bf6054309cc9303eb360a91e57dda3b0fcbb7b80d551a75db6, +and unchanged admitted-policy proposal digest de5683e5... (not native admission). +Exact producer API/sign snapshot is unchanged; no cryptography/canonicalizer fork. + +prepare_task_approval(cfg,entry,execution_contract=typed_contract) uses actual +existing signed Check with no approval claim. It accepts DENY only, authenticates +independently pinned signer/evaluator/mode/package/version/digest, checks original +request and type-sensitive immutable context/submitted digest, producer evaluated +self-digest, explicit claimfree approval-free digest, task/service/native/caller +correspondence, live original operation/caller bounds, nonfuture decision time, +and signed registry digest syntax. It never consumes or authorizes execution. +Its detached original_request, native_binding, approval_binding_digest, +submitted_request_digest, decision_id, execution_contract and +registry_snapshot_digest are public preapproval metadata. Normal Approval issue +uses normalized native act and evaluated approval-free digest, never a local +registry reconstruction. Final authorization preserves the original proposal and +attaches only the original issued claim; Access renormalizes and source-joins. + +No independent normalization TTL is invented: DENY is not authority. An old +normalization cannot replace final signed ALLOW/current source verification. +Original operation/caller deadlines still apply. Unit case demonstrates an old +valid timestamp produces only metadata and cannot consume; actual retained +registry drift source proof refuses final Check with no new ALLOW record/CAS and +unconsumed Approval. Full final proof awaits owner timestamp repair; prototype +wallclock success is not precision acceptance. Registry fileSHA and producer +canonical registry digest are distinct evidence fields, not interchangeable pins. + +Shared task identity validation now checks requested resource-attribute subsets +with type-sensitive JSON semantic comparisons, preserving legitimate extra +registry facts while refusing bool/int/float substitutions. Original signed +context/descriptor checks are also type-sensitive. This JSON comparison never +supplies signature bytes; exact producer-authenticated JSON remains reverified. + +213 focused checks and7 actual offline bundle tests pass. Full suite784pass, +16fail,7skip; exact16failure list matches retained previous handoff baseline +(ownership guards on sandbox/tmp ancestry), with no new failures. Full logs and +explicit list comparison retained. No guard is relaxed to obtain a green suite. +Canonical checkout, installed tools, production keys/config/grants/policy and +native state are untouched. No actual protected native effect is claimed. + + +Subsequent final cross-owner proof: frozen Access R3 and Approval precision patch74eb0ff67d14508c2a2cee14d055790321ee171a96d5290ad6d037ce8f61dbcd resolve the pending provider dependency. Actual retained-registry signed-DENY preapproval, human approval, registry drift refusal before CAS, restored signed ALLOW, immediate strict CAS and independent GET pass without sleep/backdating/waiver. Precise durable consumption instant 2026-10-16T15:05:44.235319+00:00 is independently confirmed. Original signed DENY/ALLOW records and schema-valid synthetic public trust replay offline. Exact final dependency/proof hashes are in verification-final-r4.json. Earlier pending/fast-CAS observations remain historical evidence; no native runtime/trust/grant/policy adoption is inferred. Source R4 and all prior source patch hashes remain unchanged. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/package-tests-r2.txt b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/package-tests-r2.txt new file mode 100644 index 00000000..85ca0486 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/package-tests-r2.txt @@ -0,0 +1,2 @@ +....... [100%] +7 passed in 6.17s diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/package-tests-r3.txt b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/package-tests-r3.txt new file mode 100644 index 00000000..9af251a7 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/package-tests-r3.txt @@ -0,0 +1,2 @@ +....... [100%] +7 passed in 4.03s diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/package-tests-r4.txt b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/package-tests-r4.txt new file mode 100644 index 00000000..c6bec4eb --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/package-tests-r4.txt @@ -0,0 +1,2 @@ +....... [100%] +7 passed in 8.91s diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/package-tests.txt b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/package-tests.txt new file mode 100644 index 00000000..94f05216 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/package-tests.txt @@ -0,0 +1,2 @@ +....... [100%] +7 passed in 3.92s diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preapproval-tests-r4.txt b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preapproval-tests-r4.txt new file mode 100644 index 00000000..363ccbee --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preapproval-tests-r4.txt @@ -0,0 +1,2 @@ +............ [100%] +12 passed in 1.74s diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preapproval-type-tests-r4.txt b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preapproval-type-tests-r4.txt new file mode 100644 index 00000000..45cb61d2 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preapproval-type-tests-r4.txt @@ -0,0 +1,2 @@ +..................................................................... [100%] +69 passed in 8.59s diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/docs/task-execution-handoff.md b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/docs/task-execution-handoff.md new file mode 100644 index 00000000..58211756 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/docs/task-execution-handoff.md @@ -0,0 +1,78 @@ +# Strict Secrets service task-execution handoff + +This is a narrow source seam for existing SECRETS-WP-0013, not a native grant or +an execute-ccr CLI. Both Access runtime-deny policy variants remain unchanged. +The proposed service subject/transport mapping and the complete native executor +still need independent owner admission. Positive source fixtures do not admit +that mapping, a policy, a caller, a production key or an OpenBao entitlement. + +Platform's trusted owner process calls: + +```python +from secrets_engine.task_execution_handoff import ( + authorize_task_execution, consume_task_execution, +) +authorization = authorize_task_execution(cfg, entry, execution_contract=contract) +record = authorization.as_owner_handoff() +# Independently bind exact owner Plan/descriptor; reserve durable one-shot journal. +# Persist pending strict-CAS intent before calling: +consumption = consume_task_execution(cfg, authorization) +# Independently GET-confirm exact original consumption with Approval Engine's +# ExecutionConfirmationClient before journaling consume-confirmed/native work. +``` + +Preparation uses the existing empty fields/policy_targets/auth_targets defaults +and existing build_action_request/claim/Check validators. No caller-selected +transport, authorizer callback, policy result or raw handoff mapping is accepted. +The normal authorizer retains detached serialized request, claim and authenticated +Go-typed envelope snapshots; its existing evidence output does not expose them. +The narrow adapter reuses the validators, rather than evaluating a policy. + +The adapter checks the exact descriptor in the signed context, full evaluated and +submitted request digests, approval-free digest, exact five-field native act and +native binding digest, service subject/resource/tenant correspondence and original +decision/approval/deadline bounds. Initiating/represented subject is strictly +`secrets-engine` of type `service`; the execution actor is `service:secrets-engine`. +Signed caller provenance must report `enforce`, the proposed exact Secrets +ServiceAccount principal, `flex-auth` audience and live expiry bounding the +operation deadline. This consumes the signed PDP's caller attestation; it does +not independently perform TokenReview or promote the proposed server mapping. +Human/delegation/projected-identity flags cannot substitute for this service seam. +No service_actor_binding request propagation or source-policy activation is added. + +`as_owner_handoff()` returns a detached non-secret mapping with: + +- execution_contract +- approval_id, request_digest, decision_id +- native_binding, approval_binding_digest, submitted_request_digest +- decision_not_before, decision_expires_at, approval_expires_at + +The exact native binding is derived from the signed evaluated request using the +published Approval five-field mapping, and is compared to the served claim. +Descriptor/CCR/grant/source/catalog/target correspondence is retained end to end; +Platform still independently compiles its owner Plan and grant from owner bytes. +The mapping is evidence for that join, never a transferable bearer authorization. + +Consumption accepts only the module-created opaque prepared object in that same +trusted owner process. It fixes source/config coordinates, reauthenticates using +currently admitted responder keys, and rechecks claim/decision/descriptor/caller +freshness before reading the CAS credential. Each object is reserved once before +CAS/revalidation and cannot retry after any failure or unknown outcome. Its +process-local guard complements Platform's durable cross-process journal; it is +not a replacement for that journal or process isolation. + +The strict response path uses the existing CAS transport but rejects duplicate, +nonfinite or excessively deep JSON, missing/mismatched approval and decision IDs, +wrong digest/status, missing or nonboolean idempotence, historical idempotent +success, verdict fields and absent/naive/invalid consumption timestamps. The +consumption instant and post-CAS freshness must remain inside the original +bounds. No field is coerced or filled from the submitted binding. A late/uncertain +CAS may have occurred; it never licenses retry or protected native work. + +The returned immutable TaskExecutionConsumption contains exact approval_id, +request_digest, decision_id, consumed_at and explicit false idempotent. It proves +only the strict CAS response observed by Secrets. Platform must independently +confirm original durable consumption using Approval's real provider client, +journal it, then recheck its exact Plan, owner sources, grant and deadlines before +any native operation. Neither a dataclass nor a caller-supplied boolean admits +execution. Existing generic CLI CAS behavior is otherwise unchanged. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/docs/task-scoped-openbao-execution-contract.md b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/docs/task-scoped-openbao-execution-contract.md new file mode 100644 index 00000000..6128f2ec --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/docs/task-scoped-openbao-execution-contract.md @@ -0,0 +1,334 @@ +# Task-scoped protected OpenBao execution — owner design + +Design ID: `SECRETS-EXEC-20261011-v1`. Status: proposed, source preparation only. +Owning draft: `SECRETS-WP-0013`; adoption remains `SECRETS-WP-0008-T06`. +No native entitlement, owner assent or founder go is established here. + +## The concrete gap and immediate correction + +`cli._require_lane_approval` consumes the approved action before `_open_backend`. +`engine_auth.select_engine_auth` selects service-jwt for auto whenever its login +contract is configured. CCR-2026-0038 grants a 300-second, eight-use token with +exactly `secrets-engine-login-self`. It explicitly denies custody data/metadata, +policy/auth writes and child-token creation. Thus a valid approval can be +consumed before an ordinary apply/provision/exec fails for lack of backend power. + +The staged correction checks **local execution readiness before claim/PDP/CAS** +for every caller about to access protected OpenBao work. Until a real reviewed +execution backend ships, service-jwt is rejected for those callers. Explicit +bootstrap/env providers retain their existing approval/stance gates; no fallback +or default changes. Service-auth login/self probes remain available. Local-only +compromise/reactivate records retain their existing path. A file/boolean claiming +entitlement cannot make the guard pass. Tests exercise the actual apply handler, +asserting zero claim/PDP/consume/login/apply calls for configured auto and missing +explicit service-jwt entitlement. This correction fixes early consumption; it +does **not** implement task-scoped protected work or complete T06. + +## One initial execution surface + +Implement `secrets-engine execute-ccr --catalog --stage prod --request ` +as an explicit, metadata-only action. It admits one owner-reviewed Platform +`workload-kv-read` CCR with already-existing KV and auth mounts, exact generated +policy and exact Kubernetes or existing netkingdom OIDC role. It does not call +generic `apply_plan`: that function can manage AppRoles/mounts, which differs +from Platform's typed CCR applier. Provision, exec delivery, rotation, KV reads, +SecretIDs, token creation by the caller, mounting and arbitrary CLI commands +remain outside this first entitlement. Their backend obligations require their +own later exact action contracts, not reuse of this metadata allowance. + +CCR38 and its role/policy stay byte-for-byte unchanged. The engine identifies +itself through that login-only service session; authentication does not create +authority for the metadata action. A new separately reviewed Platform +`task-execution-grant` CCR admits the founder-owned execution mediator and its +temporary exact two-path task token. Existing applied CCRs, including CCR38, +cannot be replayed to manufacture new native work. A real unexecuted, separately +approved metadata CCR and native success/negative/cleanup receipts are needed +for the first proof; none is selected or approved by this design. + +## Exact request binding + +Use `authorization.build_action_request(entry, 'execute-ccr', ...)` with the +existing canonical digest rules. `apply` is a distinct legacy action: its v2 +policy ignores this metadata contract and must never stand in for execute-ccr. +Add a reviewed `context.execution_contract` object +before observing the claim or calling Check. Its fields are: + +| Field | Binding | +| --- | --- | +| contract_version | `SECRETS-EXEC-20261011-v1` | +| operation_id | Owner-issued UUID, part of the request digest | +| operation | `metadata-apply-ccr`, closed enum | +| execution_actor | Verified `service:secrets-engine`, separate from initiating subject | +| ccr_id / ccr_immutable_sha256 | Exact unapplied owner CCR ID and immutable content digest | +| execution_grant_ccr_id / grant_immutable_sha256 | Exact reviewed task-token materialization and cleanup contract | +| catalog_sha256 | Exact non-secret approved catalog target specification | +| source_bundle | Absolute owner-resolved files and immutable SHA256 hashes for engine dispatcher, Platform mediator, typed CCR module and policy/auth source | +| backend_origin / cluster_uid | Exact reviewed estate OpenBao origin and railiance01 identity | +| operations | Ordered exact HTTP method, normalized path and canonical JSON/text payload hash for each read/write/readback; no user supplied body | +| operation_deadline | Upper bound no later than approval, PDP or task-token expiry | +| backend_budget | TTL at most 120s, at most twelve uses, no renew/child/wrap/unlimited uses | +| expected_before / expected_after | Owner-authenticated revision/state digests, never secret contents | + +The action subject/type/purpose/tenant are the actual initiating principal's +verified identity and existing catalog purpose, not inferred from engine service +identity or arbitrary environment fields. Founder-attended invocation names that +principal and the representation explicitly. The mediator refuses absent owner +identity correspondence; a caller-authored subject string is not evidence. + +The signed/served approval must bind the same approval-free execution context; +the PDP must return the exact canonical request and matching approval digest. +Publish and pin the updated access-engine policy package/version that evaluates +this context; an older package accepting only a catalog ID cannot authorize it. +No package default or implicit semantic acceptance is assumed. Approval identity, +decision ID and canonical request digest travel together in a non-secret request +descriptor. They are provenance until freshly validated and CAS-consumed. + +CCR immutable digests exclude only explicitly owner-defined append-only review/ +evidence fields; policy bytes, role bytes, typed mutation body, target coordinates, +grant/deadline and source revisions are included. Approval IDs added after +issuance do not change their own binding. The mediator independently reconstructs +all operation bodies from owner sources and compares the resulting digest list. +It accepts no caller ACL template, path override, shell argv or raw JSON body. + +## Execution mediation and consume ordering + +Use the existing attended Platform front door, invoked by the founder, to run a +new fixed Platform owner mediator. No daemon, broad new service or new bearer +delivery to agents is required. The caller engine prepares a non-secret request +and receives a non-secret receipt. The founder-held admin credential exists only +inside the reviewed Platform child, through the existing attended route. It is +never exported to the caller. Neither local receipt files nor user-authored +`approved` labels authorize the child. + +The Platform child runs the reviewed Secrets Engine authorization/consume code +at the last responsible execution boundary; the CLI preparation path does not +consume early and then hand an untrusted receipt to a more privileged process. +The backend mediation contract must preserve engine ownership of lifecycle +validation while Platform owns its OpenBao credential and typed native primitive. + +1. Load pinned sources and exact non-secret request; check typed CCR/grant owner + approval, catalog/subject/operation correspondence, source/state expectations, + owner-only execution state directory, supported method/body allowlist and + local clock/deadline. No protected OpenBao call occurs yet. +2. Atomically reserve the operation in a dedicated engine execution ledger, + uniquely keyed by `approval_id + request_digest`; the operation UUID is inside + the digest. Bind the authenticated caller, sources and deadline. This is + execution state, not the audit outbox or a local evidence-presence gate. + Multiworker locking uses SQLite `BEGIN IMMEDIATE` and uniqueness constraints; + only the reservation creator may proceed. +3. Use `authorize_action` / approval-claim validation / `check_decision` / exact + envelope validation, with pinned policy package and trusted application time. + Recheck freshness after network calls. Require served human control for the + production grant; missing claims, unreachable PDP or mismatched binding refuse. +4. Call `require_production_consume` once for that exact binding. Require + confirmed matching consumption. Persist `consume-confirmed` before any + protected OpenBao login, custody read, capability check or mutation. A consume + timeout is `unknown`; never treat it as absence or automatically retry. + A consumed/idempotent response without the same live local admission is not + permission to execute again. Retrying a completed operation returns only its + existing receipt; an incomplete/unknown operation requires owner reconciliation + and a fresh exact approval for any additional backend action. +5. After consume, perform the existing attended in-memory client-secret source + and CCR38 service login without provider fallback. Verify its exact login-self + policy/TTL/uses and service identity; keep this session self-only. Failure + consumes the trial without metadata effects; no implicit re-login or retry. +6. In the Platform child, use the separately authorized founder-held identity + only to materialize the reviewed exact ephemeral task policy/token. OpenBao's + token parent remains platform-owned; the CCR38 token cannot mint it. The grant + binds every policy/token create/revoke/delete step to this operation and its + cleanup. Do not give a standing `sys/policies/acl/*`, `auth/*/role/*` or + `auth/token/create` grant to the engine service. +7. Recheck decision, approval and operation deadlines before **each** protected + request. The task token is nonrenewable, no default/identity policies and + grants only read/create/update on the one approved ACL policy path and the + one approved workload auth-role path, plus revoke-self. No child token, mount + administration, KV data/metadata, SecretID or sibling role is admitted. + OpenBao ACL narrows endpoint access; it cannot restrict the HCL/role payload. + Only the mediator's independently reconstructed body equality enforces that. +8. Use an injected restricted backend adapter for the exact ordered native + calls. Compare current expected state; write only the approved generated body, + read it back, and compare it. No `OpenBaoClient._run_ok` raw failure reaches + receipts: capture/discard backend output and use closed reason codes. A generic + 403/timeout or “already exists” is not successful verification. +9. Finally self-revoke the task token and login-self token, then retire only the + exact per-operation grant policy under founder-owned cleanup authority. + Record confirmed revocation/absence separately from native apply status. + Policy retirement alone does not prove issued-token revocation. + +Task-token use budget is declared before issuance: two before-state reads, two +writes, two readbacks and one self-revoke (seven uses), plus at most four explicit +metadata capability checks and one reserved cleanup use (twelve total). Failed +backend calls count; never retry to evade limits. Task TTL is +`min(120s, approval_remaining, decision_remaining, operation_remaining)` minus +the declared clock uncertainty/cleanup reserve. Reject if fewer than 30s remain +for planned requests. No renewal or remint extends the operation. CCR38 keeps its +own 300s/eight-use limit; login-self checks and revoke fit its separate budget. + +## Failure, replay and recovery + +Ledger states: `reserved → consume-confirmed → authenticating → grant-issued → +applying → verified`, followed by a separate `cleanup-confirmed` or +`cleanup-unconfirmed` result. Every external call is durably recorded as about +to execute before the call, then recorded complete afterward. Crash between +those steps is `unknown` rather than presumed failure. Concurrent duplicate +requests never both reach consume or OpenBao. + +An operation can end refused before consumption, consumed-without-backend-work, +partially-applied, backend-outcome-unknown, verified-cleanup-unconfirmed or +verified-cleanup-confirmed. No incomplete state permits blind reapplication. +Cleanup continues when an action deadline expires, under the original explicit +cleanup obligation, without new business writes. Read/repair of changed target +state requires a fresh exact owner-reviewed recovery approval; no old snapshot +overwrites concurrent owner edits. Hard interruption uses the Platform owner's +bounded revocation/cleanup procedure with protected accessor custody. Ledger +and evidence carry only fingerprints/metadata; the raw accessor/token never +leaves owner memory or the separately admitted owner-private recovery channel. + +Revocation failure blocks owner acceptance and T06 closure, even if TTL will +eventually expire. Emit non-secret atomic local lifecycle events/outbox records +at the ledger boundaries; audit-core outage does not suppress cleanup. Do not +claim an empty archive proves no effects, or that local ledger state is founder +approval. Owner acceptance requires native before/after equality, denied sibling +actions and verified cleanup under the same exact trial. + +## Implementation hooks and closure tests + +- `engine_auth.py`: staged readiness guard now. Later separate identity-session + and execution-adapter objects; generic service-jwt client remains self-only. +- `cli.py`: staged guard passed through backend-action approval callers now. + New explicit execute-ccr preparation/mediated dispatch later; ordinary apply + does not acquire new rights merely because the login contract is configured. +- `authorization.py`, `approval_consume.py`: execution context participates in + both approval-free binding and full PDP digest; owner child returns the actual + `ConsumedApproval`, not only an `AuthorizedAction` ID. +- New `execution_contract.py`, `execution_ledger.py`, `mediated_backend.py`: + strict closed schema, deterministic compiler, separate admission state, + pinned owner-native adapter and non-secret outcome types. Ordinary subprocess + command execution is not an implementation of this adapter. +- Platform owns task-execution-grant CCR/typed validator/token materializer/ + fixed execution child/recovery hooks. Approval Engine/PDP owners acknowledge + the request+claim/package correspondence; secrets-engine cannot assent for them. + +Counterexamples required before shipping: login-only auto provider consumes +nothing; stale/forged grant file changes nothing; same name with changed HCL or +role body refuses; wrong method/sibling policy/role/KV/SecretID/child-token/mount +write refuses; changed source/caller/tenant/catalog/deadline alters the bound +request and refuses; consume conflict/unavailability/timeout produces zero +backend calls; idempotent approval consumption never causes a second execution; +concurrent calls admit one worker; postconsume login failure never falls back; +expiry during a multi-call operation stops new mutations but permits scoped +cleanup; readback mismatch/transport failure is incomplete; revoke failure +never passes; partial apply and crash after write require explicit recovery. + +Current staged readiness regression suite: eight new checks plus 28 existing +auth, consume and privileged-evidence checks pass (36 total). No native action +was performed. Full entitlement implementation, policy-package publication, +independent Platform/Secrets Engine/identity/approval/PDP acknowledgement and +exact founder-scoped native proof remain required. A successful metadata trial +alone would not cover value provision or exec delivery. + +## Existing commands and the next contract they actually need + +| Existing operation | Current login-only outcome | Contract required after the metadata pilot | +| --- | --- | --- | +| apply / apply_plan | Readiness refusal before consume; no backend | A separate exact policy/AppRole/mount structure compiler and grant for each admitted PlanAction. The workload-kv-read execute-ccr pilot does not implement these generic AppRole/mount calls. | +| provision / rotate | Readiness refusal before consume; no KV writes | Exact KV v2 data path, declared field set, expected CAS version, no sibling changes, owner-held opaque input handle, confirmed new version and separately admitted revoke/cleanup. Never expose secret plaintext or guessable secret-value hashes in request/evidence. | +| verify positive/negative | Readiness refusal before consume; no custody reads | Metadata capability probe plus, only when required, exact declared field-presence read through a restricted owner adapter. Return booleans; malformed/denied/transport error is inconclusive, not absence. Independent negative identity and exact deny paths are separate bindings. | +| exec / wrap | Readiness refusal before consume; no read/injection/wrap | Exact custody path/field, signed executable/cwd/destination/exec-owner binding and duration, no user arbitrary shell, owner-mediated read and approved process-local delivery, or exact one-use wrap creation/unwrap/expiry; self revoke and injected material cleanup. | +| handoff | Readiness refusal before consume; no role-id/SecretID mint | Exact existing AppRole and single-use SecretID creation, bounded TTL/uses and owner-admitted delivery; native role-id lookup/SecretID mint/login/revoke budget and no caller standing credential. | +| revoke / native suspend/deactivate | Readiness refusal before consume; no backend revocation/deletion | Exact policy/role/lease/accessor target and expected state, admitted lifecycle effect, rollback/custody preservation and denial readback. Token/lease handles remain owner-private; receipt fingerprints cannot authorize another handle. | +| session revoke | Readiness refusal before its backend; legacy stance remains | Exact already-held protected handle, typed token/lease revocation and independent subject/authority binding; no arbitrary accessor or lease-prefix wildcard. | +| destroy | Existing live destroy refusal remains | Exact destructive dual-control contract and custody/backup/version proof; metadata pilot supplies neither. | +| compromise / reactivate local state | Existing local state gate remains | No new OpenBao entitlement; these local records do not establish actual native revocation/reactivation. | +| service-auth probe-openbao | Existing explicit self-only identity trial | CCR38 positive/negative/lifetime/revoke proof, with no implied entitlement to any catalog lane. | + +Each subsequent contract needs its own owner-reviewed compiler, request/PDP +package binding, method/body enforcement, consume admission, TTL/use budget, +failure/recovery rules and native counterexamples. No success in the initial +metadata pilot unblocks these rows by default. Full T06 adoption requires the +actual selected engine production workflow to run through the corresponding +reviewed entitlement, with native effect, denial/no-fallback and cleanup proof. + +## Source preparation: typed request propagation (2026-10-11) + +The staged ExecutionContract descriptor is deliberately a closed, immutable +non-secret value. `parse` validates shape and captures JSON; every `as_dict` +revalidates even directly constructed instances and returns a detached copy. +It is not an owner approval, source authenticity attestation, principal +representation or proof that the requested origin is admitted. Compiler and +mediator must independently reconstruct and authenticate sources and bodies. + +The exact proposed descriptor serialization refines the table above: + +- Digests use `sha256:` plus 64 lowercase hexadecimal characters. UUIDs use + canonical lowercase hyphenated form. CCR and grant IDs must be distinct. +- `source_bundle` has exactly engine_dispatcher, platform_mediator, typed_ccr and + policy_auth; each has exactly an absolute normalized `path` and `sha256`. + This parses paths without reading files; pin verification remains unimplemented. +- `operations` is an ordered list of exactly method/path/payload_sha256 objects. + It accepts only GET/PUT metadata policy and auth-role endpoint shapes, with + normalized relative paths. This schema does not reconstruct bodies or prove + that all before/write/readback operations occur or target exactly two paths. + A later owner compiler must enforce those semantics before any execution. +- `backend_origin` is an explicit-port HTTP(S) origin without credentials, path, + query or fragment. Syntactic acceptance creates no approved route or network + call. Estate pinning and server authentication remain mediator obligations. +- `backend_budget` has exactly ttl_seconds (integer 30–120) and uses (integer + 1–12); booleans fail. Listed calls must leave at least one cleanup use. + Renewal, remint and live per-call accounting remain outside this descriptor. +- expected_before/expected_after are non-secret state digests; operation_deadline + is timezone-aware. Before claim, refuse expired deadlines using the existing + trusted-time window. After claim and Check, refuse a deadline later than either + served approval or decision expiry. A future mediator must recheck before every + effect and provide separately authorized cleanup after business expiry. +- catalog_sha256 is a descriptor-local sorted compact JSON fingerprint of the + complete existing context.catalog_target, distinct from canonical PDP digests. + Resource catalog ID/stage, subject, tenant and purpose remain bound separately + through the unchanged canonical request hashing implementation. + +The same typed descriptor passes through build_action_request, _expected_request, +resolve_approval_observation and authorize_action. Any contract on apply or another +legacy action refuses. execute-ccr without a typed valid contract, prod stage or +platform tenant refuses. execute-ccr always binds human_control=true, even on a +legacy bootstrap catalog, and cannot use an unserved/demo fallback. Invalid or +catalog-mismatched descriptors refuse before claim/PDP/CAS. Returned requests are +independent copies; later caller mutation cannot change the stored descriptor. + +99 focused source tests pass across context propagation, the existing approval +join, decision validation, readiness, consume and engine auth. New tests exercise +all four actual builders, actual consume request formatting and claim-free/full +joins; mutation of operation, CCR/grant/source/body/method/path/budget/deadline/ +estate/state/principal/purpose/catalog/tenant/stage refuses the original approval. +Transport/PDP stand-ins deliberately supply hypothetical human control and an +ALLOW: they prove propagation, not a shipped policy or real principal admission. + +No execute-ccr CLI, identity-owner verified initiating-principal interface, +new access policy package, typed owner compiler, task-grant materializer, +independent authenticated consume-confirmation integration, durable admission +journal, restricted backend, per-call deadline checks or native entitlement is +supplied by this stage. Existing cfg subject strings remain legacy provenance; +this change does not make them verified human representation. Existing generic +consume receipt validation limitations remain explicitly separate. service-jwt +readiness guard/default and ordinary actions remain unchanged. T01 stays in +progress, T03 stays wait, WP13 stays proposed, and WP8-T06 stays open. + +## D3 wrapped-delivery primitive and its separate entitlement + +D3 P3=b chooses response-wrapped issuer delivery with one-time unwrap. The +existing wrapped-handoff primitive now verifies native TTL/accessor/creation-path +metadata and reserves a private immutable recipient file before minting. Native +KV-v2 creation paths include the `data/` endpoint; caller-requested CLI paths +cannot substitute for returned metadata. Failed mint/write or an altered +reservation retains an uncertain attempt and never silently overwrites/remints. + +A disposable local OpenBao integration proves the exact source path,60-second +TTL, successful first unwrap and specific HTTP400 consumed-token replay denial. +This proves backend semantics, not the estate's first delivered lane, audited +initiating principal, consumer identity, independent owner acceptance or cleanup +under crash/revocation. Production CLI wrapping remains fail-closed. Existing +metadata execute-ccr contracts expressly exclude wrap/KV value delivery and cannot +be borrowed for this purpose. A separate exact owner-admitted delivery operation +must bind actor,path,audience,deadline,authorization/consume state,destination, +audit,replay/crash recovery and rollback before native adoption. The prepared +D2 repository-issuer lane remains attended until its own reviewed execution and +independent destination/adoption proofs pass. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/src/secrets_engine/approval_consume.py b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/src/secrets_engine/approval_consume.py new file mode 100644 index 00000000..1cf41405 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/src/secrets_engine/approval_consume.py @@ -0,0 +1,563 @@ +"""PEP consume-before-side-effect client (GH-DEC-2026-003). + +The PEP that is about to cause a protected OpenBao write MUST obtain a +successful approval-engine CAS consume first. Holding a claim or an ALLOW is +not authority to act. Conflict, unavailability, or a missing binding means +do not call OpenBao. + +This module does not render an authorization decision. The consume response +is mutation evidence, never a permission. +""" +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable +from urllib.error import HTTPError, URLError +from urllib.request import Request + +from secrets_engine.approval_auth import approval_auth_configured, approval_token, credential_urlopen +from secrets_engine.execution_contract import ExecutionContract, require_execution_deadline +from secrets_engine.approval_claim import observe_pdp_approval_claim, validate_approval_claim +from secrets_engine.catalog import human_control_required +from secrets_engine.decision_check import check_decision, require_supported_pdp_address +from secrets_engine.decision_signature import load_trust, strict_json +from secrets_engine.authorization import ( + build_action_request, + validate_decision_envelope, + wait_for_decision_start, +) +from secrets_engine.errors import DecisionError +from secrets_engine.application_time import read_window, recheck_binding +from secrets_engine.openbao import read_strict_token_file +from secrets_engine.pep_stance import demo_exception_enabled + +DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +_MAX_BODY = 256 * 1024 + + +@dataclass(frozen=True) +class ConsumeBinding: + """Inputs the PEP presents to approval-engine consume.""" + + approval_id: str + request_digest: str + decision_id: str = "" + human_control: bool = False + decision_not_before: str = "" + decision_expires_at: str = "" + + +@dataclass(frozen=True) +class ApprovalObservation: + """Fresh approval input awaiting PDP correspondence; never a consume binding.""" + + approval_id: str + request: dict[str, Any] = field(repr=False) + claim: dict[str, Any] = field(repr=False) + + +@dataclass(frozen=True) +class AuthorizedAction: + """A validated claim and decision for one exact proposed action. + + Holding this is not authority to act: GH-DEC-2026-003 still requires a + successful CAS consume before the OpenBao call. + """ + + binding: ConsumeBinding + decision_id: str + expires_at: str + # Detached original served artifacts for the narrow task-execution handoff. + artifacts: str = field(default="", repr=False, compare=False) + + @property + def id(self) -> str: + """The actual PDP decision id for shared CLI evidence/plan callers.""" + return self.decision_id + + def as_evidence(self) -> dict[str, object]: + return { + "authorization_decision_id": self.decision_id, + "authorization_expires_at": self.expires_at, + "request_digest": self.binding.request_digest, + "approval_human_control": self.binding.human_control, + } + + +@dataclass(frozen=True) +class ConsumedApproval: + """Non-secret confirmation that consume succeeded for this request.""" + + approval_id: str + request_digest: str + decision_id: str = "" + idempotent: bool = False + consumed_at: str = "" + + def as_evidence(self) -> dict[str, object]: + payload: dict[str, object] = { + "approval_consumed": True, + "approval_id": self.approval_id, + "request_digest": self.request_digest, + "approval_consume_idempotent": self.idempotent, + } + if self.decision_id: + payload["decision_id"] = self.decision_id + if self.consumed_at: + payload["approval_consumed_at"] = self.consumed_at + return payload + + +def _authorization_id(entry: Any, decision: Any) -> str: + """Non-secret approval-engine object id for this lane, or "" if unbound. + + flex-auth: ``ActionAuthorization.id`` is the approval-engine object UUID. + It is never the State Hub decision UUID, so it is not inferred from one. + """ + approval = getattr(entry, "approval", None) or {} + if isinstance(approval, dict): + declared = str(approval.get("authorization_id", "") or "").strip() + if declared: + return declared + for attr in ("authorization_id", "action_authorization_id"): + served = str(getattr(decision, attr, "") or "").strip() + if served: + return served + return "" + + +def _request_purpose(entry: Any) -> str: + """Declared purpose for the request context. Never invented at call time.""" + approval = getattr(entry, "approval", None) or {} + if isinstance(approval, dict): + declared = str(approval.get("purpose", "") or "").strip() + if declared: + return declared + for consumer in getattr(entry, "consumers", None) or []: + if isinstance(consumer, dict): + declared = str(consumer.get("purpose", "") or "").strip() + if declared: + return declared + return "" + + +def _expected_request( + cfg: Any, + entry: Any, + action: str, + *, + fields: tuple[str, ...] = (), + policy_targets: tuple[str, ...] = (), + auth_targets: tuple[str, ...] = (), + execution_contract: ExecutionContract | None = None, +) -> dict[str, Any]: + """Build the exact CheckRequest both steps must agree on. + + Steps 1 and 2 must describe the same proposed action or the digests cannot + correspond, so neither builds its own. + """ + subject_id = str(getattr(cfg, "authorization_subject_id", "") or "") + subject_type = str(getattr(cfg, "authorization_subject_type", "") or "") + if not subject_id or not subject_type: + raise DecisionError( + "authorization join requires SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID " + "and _SUBJECT_TYPE; the PEP must not assert an unnamed subject" + ) + purpose = _request_purpose(entry) + if not purpose: + raise DecisionError( + "authorization join requires a declared approval/consumer purpose" + ) + return build_action_request( + entry, + action, + subject_id=subject_id, + subject_type=subject_type, + purpose=purpose, + fields=fields, + policy_targets=policy_targets, + auth_targets=auth_targets, + execution_contract=execution_contract, + ) + + +def fetch_approval_claim( + *, + base_url: str, + token_file: Path | None = None, + token_provider: Callable[[], str] | None = None, + authorization_id: str, + timeout_seconds: float = 3, + opener: Callable[..., Any] = credential_urlopen, +) -> dict[str, Any]: + """GET /v1/approvals/{id}/claim (PIP). Any non-200 fails closed. + + The body is approval-engine's approval-claim, not a flex-auth + ActionAuthorization -- that object is deferred and was never ratified + (GH-DEC-2026-005 / FLEX-DEC-2026-006). + """ + if not base_url or not base_url.startswith(("http://", "https://")): + raise DecisionError("approval-engine claim URL is missing or invalid") + ident = authorization_id.strip() + if not ident or "/" in ident or any(ch.isspace() for ch in ident): + raise DecisionError("approval claim requires a concrete authorization id") + token = _request_token(token_file, token_provider) + request = Request( + base_url.rstrip("/") + f"/v1/approvals/{ident}/claim", + method="GET", + ) + request.add_header("Authorization", f"Bearer {token}") + request.add_header("Accept", "application/json") + try: + with opener(request, timeout=timeout_seconds) as response: + if getattr(response, "status", 200) != 200: + raise DecisionError("approval claim did not return a claim") + payload = json.loads(response.read(_MAX_BODY).decode("utf-8")) + except HTTPError as e: + raise DecisionError(f"approval claim refused: {_status_message(e.code)}") from e + except URLError as e: + raise DecisionError("approval-engine is unreachable for claim") from e + except json.JSONDecodeError as e: + raise DecisionError("approval claim returned a non-JSON body") from e + if not isinstance(payload, dict): + raise DecisionError("approval claim returned a non-object body") + return payload + + +def resolve_approval_observation( + cfg: Any, + entry: Any, + action: str, + decision: Any, + *, + fields: tuple[str, ...] = (), + policy_targets: tuple[str, ...] = (), + auth_targets: tuple[str, ...] = (), + execution_contract: ExecutionContract | None = None, + opener: Callable[..., Any] | None = None, +) -> ApprovalObservation | None: + """Observe a fresh fact and prepare the claim-bearing request (step 1). + + Missing serving coordinates return None for undeclared lanes. A declared + human control raises instead, so no stage/demo fallback can discharge it. + Correspondence and a consume binding require authorize_action's PDP step. + """ + execution_request = None + if action == "execute-ccr" or execution_contract is not None: + execution_request = _expected_request( + cfg, entry, action, fields=fields, policy_targets=policy_targets, + auth_targets=auth_targets, execution_contract=execution_contract, + ) + # The caller's descriptor is not authenticated principal/owner evidence. + # This requirement cannot disappear on an unserved/demo lane. + base_url = str(getattr(cfg, "approval_url", "") or "") + auth_configured = approval_auth_configured(cfg) + authorization_id = _authorization_id(entry, decision) + required_human_control = action == "execute-ccr" or human_control_required(entry) + if not base_url or not auth_configured or not authorization_id: + if required_human_control: + raise DecisionError("human_control requires a served approval claim and exact action binding") + return None + + expected_request = execution_request or _expected_request( + cfg, entry, action, + fields=fields, policy_targets=policy_targets, auth_targets=auth_targets, + execution_contract=execution_contract, + ) + + require_execution_deadline(expected_request, time_window=read_window(cfg)) + claim = fetch_approval_claim( + base_url=base_url, + token_provider=lambda: approval_token(cfg, scope="approval:read"), + authorization_id=authorization_id, + opener=opener or credential_urlopen, + ) + observe_pdp_approval_claim(claim, approval_id=authorization_id, + require_human_control=required_human_control, time_window=read_window(cfg)) + require_execution_deadline(expected_request, time_window=read_window(cfg), + bounds=(claim["validity"]["expires_at"],)) + expected_request["context"]["approval"] = claim + return ApprovalObservation(authorization_id, expected_request, claim) + + +def authorize_action( + cfg: Any, + entry: Any, + action: str, + decision: Any = None, + *, + fields: tuple[str, ...] = (), + policy_targets: tuple[str, ...] = (), + auth_targets: tuple[str, ...] = (), + execution_contract: ExecutionContract | None = None, + opener: Callable[..., Any] | None = None, + pdp_opener: Callable[..., Any] | None = None, +) -> AuthorizedAction | None: + """Run steps 1 and 2 for one proposed action, or return None if unserved. + + Step 1 validates the approval-claim; step 2 obtains and validates the + flex-auth DecisionEnvelope. Returning None means no serving path is + configured at all, which leaves production fail-closed. A configured but + failing path raises: a partial deployment must not read as an absent one. + """ + serving = (getattr(cfg, "approval_url", "") and approval_auth_configured(cfg) + and _authorization_id(entry, decision)) + if serving or (getattr(cfg, "pdp_url", "") and getattr(cfg, "pdp_token_file", None)): + if not getattr(cfg, "pdp_url", "") or not getattr(cfg, "pdp_token_file", None): + raise DecisionError("production action requires configured access-engine URL/token and responder trust") + require_supported_pdp_address(str(cfg.pdp_url)) + load_trust(getattr(cfg, "pdp_trust_file", None)) + verifier = getattr(cfg, "pdp_verifier", None) + if verifier is None or not Path(verifier).is_absolute() or not Path(verifier).is_file(): + raise DecisionError("access-engine signature verifier is not installed") + observation = resolve_approval_observation( + cfg, entry, action, decision, + fields=fields, + policy_targets=policy_targets, + auth_targets=auth_targets, + execution_contract=execution_contract, + opener=opener, + ) + if observation is None: + return None + + pdp_url = str(getattr(cfg, "pdp_url", "") or "") + pdp_token = getattr(cfg, "pdp_token_file", None) + if not pdp_url or not pdp_token: + raise DecisionError( + "production action requires an access-engine decision; " + "SECRETS_ENGINE_PDP_URL / _PDP_TOKEN_FILE are unset" + ) + package = str(getattr(cfg, "authorization_policy_package", "") or "") + version = str(getattr(cfg, "authorization_policy_version", "") or "") + if not package or not version: + raise DecisionError( + "authorization join requires an explicitly configured policy " + "package/version pin; the reserved coordinate is a reservation, " + "not a publication, and must not be used as a default" + ) + + expected_request = observation.request + envelope = check_decision( + base_url=pdp_url, + token_file=Path(pdp_token), + request=expected_request, + opener=pdp_opener, + trust_file=getattr(cfg, "pdp_trust_file", None), + verifier=getattr(cfg, "pdp_verifier", None), + ) + if not getattr(cfg, "clock_trust_file", None): + wait_for_decision_start(envelope) + window = read_window(cfg) + validated = validate_decision_envelope( + envelope, + expected_request, + accepted_policy_packages={package}, + accepted_policy_versions={version}, + expected_approval_binding_digest=observation.claim["binding"]["pdp_digest"], + time_window=window, + ) + # Recheck freshness after the network call before issuing a consume binding. + validate_approval_claim( + observation.claim, approval_id=observation.approval_id, + expected_pdp_digest=envelope["binding"]["approval_binding_digest"], + require_human_control=expected_request["context"].get("human_control") is True, + time_window=window, + ) + require_execution_deadline(expected_request, time_window=window, + bounds=(observation.claim["validity"]["expires_at"], validated.expires_at)) + if validated.action != action: + raise DecisionError("access-engine decision does not bind this action") + return AuthorizedAction( + binding=ConsumeBinding( + approval_id=observation.approval_id, + request_digest=validated.request_digest, + decision_id=validated.decision_id, + human_control=observation.claim["binding"].get("human_control") is True, + decision_not_before=envelope["lifetime"].get("not_before") or "", + decision_expires_at=validated.expires_at, + ), + decision_id=validated.decision_id, + expires_at=validated.expires_at, + artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope}, + sort_keys=True,separators=(",",":"),allow_nan=False), + ) + + +def consume_approval( + *, + base_url: str, + token_file: Path | None = None, + token_provider: Callable[[], str] | None = None, + binding: ConsumeBinding, + timeout_seconds: float = 3, + opener: Callable[..., Any] = credential_urlopen, + strict_response: bool = False, +) -> ConsumedApproval: + """POST /v1/approvals/{id}/consume. Fail closed on anything but confirmed use.""" + if not base_url or not base_url.startswith(("http://", "https://")): + raise DecisionError("approval-engine consume URL is missing or invalid") + approval_id = binding.approval_id.strip() + if not approval_id or "/" in approval_id or any(ch.isspace() for ch in approval_id): + raise DecisionError("approval consume requires a concrete approval id") + if not DIGEST_RE.fullmatch(binding.request_digest): + raise DecisionError("approval consume requires the canonical request digest") + + token = _request_token(token_file, token_provider) + body: dict[str, str] = {"request_digest": binding.request_digest} + if binding.decision_id: + body["decision_id"] = binding.decision_id + encoded = json.dumps(body).encode("utf-8") + request = Request( + base_url.rstrip("/") + f"/v1/approvals/{approval_id}/consume", + data=encoded, + method="POST", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + try: + response = opener(request, timeout=timeout_seconds) + try: + status = int(response.getcode()) + raw = response.read(_MAX_BODY + 1) + finally: + response.close() + except HTTPError as exc: + status = int(getattr(exc, "code", 0) or 0) + try: + exc.read(_MAX_BODY) + except Exception: + pass + raise DecisionError(_status_message(status)) from None + except (URLError, TimeoutError, OSError): + raise DecisionError( + "approval-engine unreachable; OpenBao must not be called" + ) from None + + if status != 200 or len(raw) > _MAX_BODY: + raise DecisionError(_status_message(status if status != 200 else 502)) + try: + payload = (strict_json if strict_response else json.loads)(raw.decode("utf-8")) + except (UnicodeDecodeError, ValueError, RecursionError) as exc: + raise DecisionError("approval consume returned invalid JSON") from exc + if not isinstance(payload, dict): + raise DecisionError("approval consume returned invalid payload") + if payload.get("status") != "consumed": + raise DecisionError("approval consumption was not confirmed") + if payload.get("request_digest") != binding.request_digest: + raise DecisionError("approval consume digest does not match the request") + if strict_response: + from datetime import datetime + if (payload.get("approval_id") != approval_id + or payload.get("decision_id") != binding.decision_id + or payload.get("idempotent") is not False + or any(key in payload for key in ("effect","allow","deny","decision"))): + raise DecisionError("strict task consumption correspondence refused") + try: + stamp = datetime.fromisoformat(payload["consumed_at"].replace("Z","+00:00")) + if stamp.tzinfo is None or stamp.utcoffset() is None: + raise ValueError("timezone required") + except (KeyError,TypeError,AttributeError,ValueError): + raise DecisionError("strict task consumption timestamp refused") from None + decision_id = payload.get("decision_id") + if decision_id is not None and ( + not isinstance(decision_id, str) or not decision_id + ): + raise DecisionError("approval consume returned an invalid decision id") + consumed_at = payload.get("consumed_at") + if consumed_at is not None and not isinstance(consumed_at, str): + raise DecisionError("approval consume returned an invalid consumed_at") + return ConsumedApproval( + approval_id=str(payload.get("approval_id") or approval_id), + request_digest=binding.request_digest, + decision_id=decision_id or binding.decision_id, + idempotent=bool(payload.get("idempotent")), + consumed_at=consumed_at or "", + ) + + +def require_production_consume( + cfg: Any, + entry: Any, + *, + binding: ConsumeBinding | None, + evidence: Any = None, + opener: Callable[..., Any] | None = None, +) -> ConsumedApproval | None: + """CAS-consume before production or an explicitly human-controlled action. + + Undeclared build/test and the unsafe-demo exception retain their existing + behavior. Declared human controls require a verified binding and consume + regardless of stage/demo; missing inputs cannot fall back to a lane review. + """ + required_human_control = human_control_required(entry) + if required_human_control: + if binding is None or binding.human_control is not True: + raise DecisionError("human_control requires an observed declared-human consume binding") + else: + if getattr(entry, "stage", "") != "prod": + return None + if demo_exception_enabled(cfg): + return None + if binding is None: + raise DecisionError( + "production OpenBao call requires CAS consume of an approval " + "after an access-engine ALLOW; no durable consume binding is served" + ) + base_url = str(getattr(cfg, "approval_url", "") or "") + auth_configured = approval_auth_configured(cfg) + if not base_url: + raise DecisionError( + "production OpenBao call requires approval-engine consume; " + "SECRETS_ENGINE_APPROVAL_URL is unset" + ) + if not auth_configured: + raise DecisionError( + "production OpenBao call requires approval-engine consume; " + "SECRETS_ENGINE_APPROVAL_TOKEN_FILE or _CLIENT_SECRET_FILE is unset" + ) + recheck_binding(cfg, binding) + consumed = consume_approval( + base_url=base_url, + token_provider=lambda: approval_token(cfg, scope="approval:consume"), + binding=binding, + opener=opener or credential_urlopen, + ) + if evidence is not None and hasattr(evidence, "mark_consumed"): + evidence.mark_consumed(consumed) + recheck_binding(cfg, binding) + return consumed + + +def _status_message(status: int) -> str: + if status == 409: + return "approval consume conflict; OpenBao must not be called" + if status == 404: + return "approval not found; OpenBao must not be called" + if status in {401, 403}: + return "approval consume unauthorized; OpenBao must not be called" + if status == 503: + return "approval-engine unavailable; OpenBao must not be called" + if status == 0: + return "approval-engine unreachable; OpenBao must not be called" + return "approval consume failed; OpenBao must not be called" + + +def _request_token( + token_file: Path | None, token_provider: Callable[[], str] | None, +) -> str: + if (token_file is None) == (token_provider is None): + raise DecisionError("approval request requires exactly one credential provider") + token = ( + token_provider() if token_provider is not None + else read_strict_token_file(Path(token_file), purpose="approval credential") + ) + if not isinstance(token, str) or not token or any(ch.isspace() for ch in token): + raise DecisionError("approval credential is invalid") + return token diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/src/secrets_engine/decision_signature.py b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/src/secrets_engine/decision_signature.py new file mode 100644 index 00000000..24fa8d38 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/src/secrets_engine/decision_signature.py @@ -0,0 +1,83 @@ +"""Independently admitted Access Engine responder keys, never responder discovery. + +The bundled Go helper uses the exact producer types and verifier. No Python +canonicalization or unsigned compatibility mode participates in authorization. +""" +from __future__ import annotations + +import base64 +import json +from pathlib import Path +import stat +import subprocess +from typing import Any + +from secrets_engine.errors import DecisionError + + +def _unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON member") + result[key] = value + return result + + +def strict_json(data): + return json.loads(data, object_pairs_hook=_unique_object, + parse_constant=lambda value: (_ for _ in ()).throw(ValueError("invalid number"))) + + +def load_trust(path: Path | None) -> dict[str, Any]: + """Read an operator-owned public admission file; missing trust is a refusal. + + Admission config is a local authority input like the existing policy pin. + File provenance/approval and the executable's installation are rollout gates, + not established by a successful local signature test. + """ + try: + if path is None or not Path(path).is_absolute() or Path(path).is_symlink(): + raise ValueError("trust path must be absolute and regular") + with Path(path).open('rb') as stream: + import os + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o022: + raise ValueError("trust must not be group/world writable") + raw = stream.read(65537) + if len(raw) > 65536: + raise ValueError("trust is oversized") + trust = strict_json(raw) + if set(trust) != {'schema', 'evaluator', 'mode', 'policy_package', 'policy_version', 'policy_package_digest', 'keys'}: + raise ValueError("invalid trust fields") + if trust['schema'] != 'secrets-engine.pdp-trust.v1': + raise ValueError("unsupported trust schema") + if any(not isinstance(trust[k], str) or not trust[k] for k in trust if k != 'keys'): + raise ValueError("missing issuer/pin") + if not isinstance(trust['keys'], dict) or not trust['keys'] or len(trust['keys']) > 16: + raise ValueError("missing admitted keys") + if any(not isinstance(k,str) or not k or not isinstance(v,str) or not v for k,v in trust['keys'].items()): + raise ValueError("invalid keys") + for key in trust["keys"].values(): + if len(key) != 43 or len(base64.b64decode(key + "=", altchars=b"-_", validate=True)) != 32: + raise ValueError("invalid Ed25519 public key") + return trust + except (OSError, ValueError, TypeError, KeyError, RecursionError) as exc: + raise DecisionError("access-engine responder trust is missing or invalid") from exc + + +def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) -> dict: + if verifier is None or not Path(verifier).is_absolute(): + raise DecisionError("access-engine signature verifier is not installed") + try: + result = subprocess.run([str(verifier)], input=b'{"envelope":'+body+b',"keys":'+json.dumps(trust['keys']).encode()+b'}', + capture_output=True, env={}, timeout=3, check=False) + if result.returncode != 0 or len(result.stdout) > 524288: + raise ValueError("signature refused") + authenticated = strict_json(result.stdout) + provenance = authenticated['provenance'] + if any(provenance.get(k) != trust[k] for k in ('evaluator','mode','policy_package','policy_version','policy_package_digest')): + raise ValueError("issuer/pin mismatch") + return authenticated + except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError) as exc: + raise DecisionError("access-engine responder authentication failed") from exc diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/src/secrets_engine/execution_contract.py b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/src/secrets_engine/execution_contract.py new file mode 100644 index 00000000..5dc1cae3 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/src/secrets_engine/execution_contract.py @@ -0,0 +1,176 @@ +"""Closed, immutable metadata descriptor; shape is never execution authority. + +This source-only compiler input cannot grant an action, authenticate a principal, +prove owner source authenticity or open a backend. Those integration gates remain +outside this type. Existing actions cannot borrow the new descriptor. +""" +from __future__ import annotations + +import copy +import hashlib +import json +import re +from dataclasses import dataclass +from datetime import datetime +from pathlib import PurePosixPath +from urllib.parse import urlsplit +from uuid import UUID + +from secrets_engine.errors import DecisionError + +VERSION = "SECRETS-EXEC-20261011-v1" +HASH = re.compile(r"^sha256:[0-9a-f]{64}$") +KEYS = { + "contract_version", "operation_id", "operation", "execution_actor", "ccr_id", + "ccr_immutable_sha256", "execution_grant_ccr_id", "grant_immutable_sha256", + "catalog_sha256", "source_bundle", "backend_origin", "cluster_uid", + "operations", "operation_deadline", "backend_budget", "expected_before", + "expected_after", +} +SOURCE_ROLES = {"engine_dispatcher", "platform_mediator", "typed_ccr", "policy_auth"} + + +def _refuse() -> None: + # Do not interpolate untrusted descriptors into messages/evidence. + raise DecisionError("invalid execution_contract descriptor") + + +def _closed(value, keys): + if type(value) is not dict or set(value) != set(keys): + _refuse() + return value + + +def _text(value): + if type(value) is not str or not value or len(value) > 4096 or any(ord(c) < 32 for c in value): + _refuse() + return value + + +def _hash(value): + if type(value) is not str or not HASH.fullmatch(value): + _refuse() + + +def _path(value, *, absolute=False): + _text(value) + if (value.startswith("/") != absolute or "\\" in value or "%" in value + or "?" in value or "#" in value or ":" in value + or any(p in {"", ".", ".."} for p in value.lstrip("/").split("/")) + or str(PurePosixPath(value)) != value): + _refuse() + + +def _validated(value): + value = copy.deepcopy(_closed(value, KEYS)) + if value['contract_version'] != VERSION or value['operation'] != 'metadata-apply-ccr' or value['execution_actor'] != 'service:secrets-engine': + _refuse() + try: + if str(UUID(_text(value['operation_id']))) != value['operation_id']: + _refuse() + except (ValueError, AttributeError): + _refuse() + for key in ['ccr_id', 'execution_grant_ccr_id', 'cluster_uid']: + _text(value[key]) + if value['ccr_id'] == value['execution_grant_ccr_id']: + _refuse() + for key in ['ccr_immutable_sha256', 'grant_immutable_sha256', 'catalog_sha256', 'expected_before', 'expected_after']: + _hash(value[key]) + sources = _closed(value['source_bundle'], SOURCE_ROLES) + for source in sources.values(): + _closed(source, {'path', 'sha256'}) + _path(source['path'], absolute=True) + _hash(source['sha256']) + try: + origin = urlsplit(_text(value['backend_origin'])) + if (origin.scheme not in {'http', 'https'} or not origin.hostname + or origin.username is not None or origin.password is not None + or origin.path or origin.query or origin.fragment or origin.port is None): + _refuse() + except ValueError: + _refuse() + try: + deadline = datetime.fromisoformat(_text(value['operation_deadline']).replace('Z', '+00:00')) + if deadline.tzinfo is None: + _refuse() + except ValueError: + _refuse() + budget = _closed(value['backend_budget'], {'ttl_seconds', 'uses'}) + if (type(budget['ttl_seconds']) is not int or not 30 <= budget['ttl_seconds'] <= 120 + or type(budget['uses']) is not int or not 1 <= budget['uses'] <= 12): + _refuse() + operations = value['operations'] + if type(operations) is not list or not 1 <= len(operations) < budget['uses']: + _refuse() # Reserve at least one cleanup use; no silent retries. + for operation in operations: + _closed(operation, {'method', 'path', 'payload_sha256'}) + if type(operation['method']) is not str or operation['method'] not in {'GET', 'PUT'}: + _refuse() + _path(operation['path']) + # Only metadata pilot endpoint shapes, never KV/mount/token/secretid. + if not re.fullmatch(r'(sys/policies/acl/[A-Za-z0-9_-]+|auth/[A-Za-z0-9_-]+/role/[A-Za-z0-9_-]+)', operation['path']): + _refuse() + _hash(operation['payload_sha256']) + return value + + +def catalog_target_digest(target: dict) -> str: + """Descriptor-local sorted JSON fingerprint, distinct from PDP digest.""" + encoded = json.dumps(target, sort_keys=True, ensure_ascii=False, allow_nan=False, + separators=(',', ':')).encode('utf-8') + return 'sha256:' + hashlib.sha256(encoded).hexdigest() + + +@dataclass(frozen=True) +class ExecutionContract: + _serialized: str + + @classmethod + def parse(cls, descriptor: dict) -> 'ExecutionContract': + validated = _validated(descriptor) + return cls(json.dumps(validated, sort_keys=True, separators=(',', ':'), allow_nan=False)) + + def as_dict(self) -> dict: + # Revalidate even a directly constructed instance: no trusted label bypass. + try: + return _validated(json.loads(self._serialized)) + except (ValueError, TypeError, RecursionError): + _refuse() + + +def attach_execution_contract(request: dict, contract: ExecutionContract | None) -> None: + action = request['action'] + if action != 'execute-ccr': + if contract is not None: + raise DecisionError('execution_contract requires execute-ccr action') + return + if type(contract) is not ExecutionContract: + raise DecisionError('execute-ccr requires typed execution_contract') + value = contract.as_dict() + if request['resource']['attributes']['stage'] != 'prod': + raise DecisionError('execute-ccr requires prod stage') + if request['tenant'] != 'tenant:platform': + raise DecisionError('execute-ccr requires platform tenant') + if value['catalog_sha256'] != catalog_target_digest(request['context']['catalog_target']): + raise DecisionError('execution_contract catalog target does not match') + request['context']['execution_contract'] = value + request['context']['human_control'] = True + + +def require_execution_deadline(request: dict, *, time_window=None, bounds=()) -> None: + """Check trusted-time freshness and served upper bounds before CAS binding.""" + contract = request.get('context', {}).get('execution_contract') + if contract is None: + return + from secrets_engine.application_time import validity_bounds + deadline = datetime.fromisoformat(contract['operation_deadline'].replace('Z', '+00:00')) + _, upper = validity_bounds(None, time_window) + if upper >= deadline: + raise DecisionError('execution_contract deadline has expired') + for bound in bounds: + try: + limit = datetime.fromisoformat(bound.replace('Z', '+00:00')) + if limit.tzinfo is None or deadline > limit: + raise DecisionError('execution_contract deadline exceeds served authority') + except (ValueError, TypeError, AttributeError): + raise DecisionError('execution_contract requires served deadline') from None diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/src/secrets_engine/task_execution_handoff.py b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/src/secrets_engine/task_execution_handoff.py new file mode 100644 index 00000000..97728612 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/src/secrets_engine/task_execution_handoff.py @@ -0,0 +1,194 @@ +"""Narrow service-task PEP handoff; records never grant native execution. + +Platform calls preparation itself, binds its independently compiled owner Plan, +reserves/journals once, then calls strict consumption and independent Approval +GET confirmation. No inbound mapping/boolean or constructor creates authority. +Actual policy/server input mapping and runtime entitlement remain unadmitted. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import json +from threading import Lock + +from secrets_engine.approval_auth import approval_token, credential_urlopen +from secrets_engine.approval_claim import binding_from_check_request, claim_binding_digest, validate_approval_claim +from secrets_engine.approval_consume import authorize_action, consume_approval +from secrets_engine.application_time import read_window, validity_bounds +from secrets_engine.authorization import validate_decision_envelope, request_digest, approval_binding_digest +from secrets_engine.decision_signature import authenticate_response, load_trust, strict_json +from secrets_engine.errors import DecisionError +from secrets_engine.execution_contract import ExecutionContract, require_execution_deadline + +_CALLER = 'system:serviceaccount:secrets-engine:secrets-engine' +_NATIVE_FIELDS = ('action','actor','principal','purpose','target') +_COORDINATES = ('approval_url','approval_token_file','approval_client_secret_file','keycape_token_url','keycape_issuer', + 'pdp_url','pdp_token_file','pdp_trust_file','pdp_verifier','clock_trust_file', + 'authorization_subject_id','authorization_subject_type','authorization_policy_package','authorization_policy_version') +_ISSUED = object() + + +def _refuse(): + raise DecisionError('strict task execution handoff correspondence refused') + + +def _coordinates(cfg): + return tuple(str(getattr(cfg,name,'') or '') for name in _COORDINATES) + + +def _stamp(value): + try: + parsed=datetime.fromisoformat(value.replace('Z','+00:00')) + if parsed.tzinfo is None or parsed.utcoffset() is None: + _refuse() + return parsed + except (AttributeError,TypeError,ValueError): + _refuse() + + +class TaskExecutionAuthorization: + """Opaque process-local prepared state; not a bearer credential or allow.""" + __slots__=('_action','_contract','_coordinates','_handoff','_used','_lock') + + def __init__(self, issuer=None, *, action=None, contract=None, coordinates=None, handoff=None): + if issuer is not _ISSUED: + _refuse() + object.__setattr__(self,'_action',action) + object.__setattr__(self,'_contract',contract) + object.__setattr__(self,'_coordinates',coordinates) + object.__setattr__(self,'_handoff',json.dumps(handoff,sort_keys=True,separators=(',',':'),allow_nan=False)) + object.__setattr__(self,'_used',False) + object.__setattr__(self,'_lock',Lock()) + + def __setattr__(self,name,value): + raise AttributeError('prepared task handoff is immutable') + + def as_owner_handoff(self): + return strict_json(self._handoff) + + +@dataclass(frozen=True) +class TaskExecutionConsumption: + approval_id: str + request_digest: str + decision_id: str + consumed_at: str + idempotent: bool + + +def _validate(cfg, action, contract): + if type(contract) is not ExecutionContract or not action.artifacts: + _refuse() + descriptor=contract.as_dict() + artifacts=strict_json(action.artifacts) + request,claim,envelope=(artifacts[name] for name in ('request','claim','envelope')) + # Rotation/removal and installed-helper changes cannot hide behind a cached + # prepared label. Reauthenticate before each strict CAS, without networking. + authenticated=authenticate_response(json.dumps(envelope,allow_nan=False).encode(), + trust=load_trust(getattr(cfg,'pdp_trust_file',None)), + verifier=getattr(cfg,'pdp_verifier',None)) + if authenticated != envelope: + _refuse() + window=read_window(cfg) + lower,upper=validity_bounds(window=window) + validated=validate_decision_envelope(envelope,request, + accepted_policy_packages={str(cfg.authorization_policy_package)}, + accepted_policy_versions={str(cfg.authorization_policy_version)}, + expected_approval_binding_digest=claim['binding']['pdp_digest'],time_window=window) + validate_approval_claim(claim,approval_id=action.binding.approval_id, + expected_pdp_digest=envelope['binding']['approval_binding_digest'], + require_human_control=True,time_window=window) + require_execution_deadline(request,time_window=window, + bounds=(claim['validity']['expires_at'],envelope['lifetime']['expires_at'])) + binding=envelope['binding'] + if (envelope['lifetime'].get('kind')!='ttl' + or _stamp(envelope['lifetime'].get('not_before'))>lower): + _refuse() + if (request['action']!='execute-ccr' or binding.get('action')!='execute-ccr' + or request['tenant']!='tenant:platform' or binding.get('tenant')!='tenant:platform' + or binding.get('context')!=request['context'] + or request['context'].get('execution_contract')!=descriptor + or binding.get('request_digest')!=request_digest(binding) + or binding.get('approval_binding_digest')!=approval_binding_digest(binding) + or action.binding.request_digest!=validated.request_digest + or action.binding.decision_id!=validated.decision_id): + _refuse() + subject=binding['subject']; resource=binding['resource'] + if (request['subject']!={'id':'secrets-engine','type':'service'} + or subject.get('id')!='secrets-engine' or subject.get('type')!='service' + or subject.get('tenant') not in (None,'tenant:platform') + or 'principal' in subject.get('attributes',{}) + or envelope['subject']!=subject or envelope['resource']!=resource + or descriptor['execution_actor']!='service:secrets-engine' + or any(resource.get(key)!=request['resource'].get(key) for key in ('id','type','system')) + or resource.get('tenant') not in (None,'tenant:platform') + or any(resource.get('attributes',{}).get(key)!=value for key,value in request['resource']['attributes'].items()) + or any(key in binding['context'] for key in ('task_execution_projection','initiator','represented_principal','delegation','verified','owner_admission'))): + _refuse() + caller=envelope['provenance'].get('caller') + if (not isinstance(caller,dict) or caller.get('mode')!='enforce' + or caller.get('principal')!=_CALLER or caller.get('audience')!='flex-auth' + or _stamp(caller.get('not_after')) < _stamp(descriptor['operation_deadline']) + or upper >= _stamp(caller.get('not_after'))): + _refuse() + native=binding_from_check_request(binding) + supplied=claim['binding'] + # Recompute the served native act too: Python equality alone conflates + # boolean/integer enrichment and cannot establish canonical correspondence. + if (any(supplied.get(key)!=native[key] for key in _NATIVE_FIELDS) + or supplied.get('digest')!=claim_binding_digest(**native) + or supplied.get('digest')!=claim_binding_digest(**{key:supplied.get(key) for key in _NATIVE_FIELDS})): + _refuse() + return {'execution_contract':descriptor,'approval_id':action.binding.approval_id, + 'request_digest':validated.request_digest,'decision_id':validated.decision_id, + 'native_binding':native,'approval_binding_digest':binding['approval_binding_digest'], + 'submitted_request_digest':validated.submitted_request_digest, + 'decision_not_before':envelope['lifetime'].get('not_before') or '', + 'decision_expires_at':envelope['lifetime']['expires_at'], + 'approval_expires_at':claim['validity']['expires_at']} + + +def authorize_task_execution(cfg, entry, *, execution_contract): + if type(execution_contract) is not ExecutionContract: + _refuse() + # No injected transports or policy callbacks: use the actual existing PIP/PDP + # clients and mandatory signature validators in the owner process. + action=authorize_action(cfg,entry,'execute-ccr',execution_contract=execution_contract) + if action is None: + _refuse() + try: + handoff=_validate(cfg,action,execution_contract) + return TaskExecutionAuthorization(_ISSUED,action=action,contract=ExecutionContract.parse(execution_contract.as_dict()), + coordinates=_coordinates(cfg),handoff=handoff) + except (ValueError,KeyError,TypeError,RecursionError) as exc: + raise DecisionError('strict task execution handoff malformed') from exc + + +def consume_task_execution(cfg, authorization): + if type(authorization) is not TaskExecutionAuthorization: + _refuse() + with authorization._lock: + if authorization._used or _coordinates(cfg)!=authorization._coordinates: + _refuse() + # Reserve once before all work; failures/unknown outcomes never retry this + # prepared object. Platform's durable journal is the cross-process guard. + object.__setattr__(authorization,'_used',True) + try: + handoff=_validate(cfg,authorization._action,authorization._contract) + if handoff!=authorization.as_owner_handoff(): + _refuse() + result=consume_approval(base_url=str(cfg.approval_url), + token_provider=lambda:approval_token(cfg,scope='approval:consume'), + binding=authorization._action.binding,opener=credential_urlopen,strict_response=True) + stamp=_stamp(result.consumed_at) + window=read_window(cfg);_,upper=validity_bounds(window=window) + if (stamp>upper or (handoff['decision_not_before'] and stamp<_stamp(handoff['decision_not_before'])) + or any(stamp>=_stamp(value) for value in (handoff['decision_expires_at'],handoff['approval_expires_at'], + handoff['execution_contract']['operation_deadline']))): + _refuse() + # Late CAS completion is historical evidence only; refuse native dispatch. + _validate(cfg,authorization._action,authorization._contract) + return TaskExecutionConsumption(result.approval_id,result.request_digest,result.decision_id,result.consumed_at,result.idempotent) + except (ValueError,KeyError,TypeError,RecursionError) as exc: + raise DecisionError('strict task execution consumption malformed') from exc diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/tests/test_execution_context.py b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/tests/test_execution_context.py new file mode 100644 index 00000000..23b6419c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/tests/test_execution_context.py @@ -0,0 +1,202 @@ +"""Source-only builder tests; mocks confer no real grant or principal admission.""" +import copy +from dataclasses import replace +import json +from datetime import datetime, timedelta, timezone + +import pytest + +from secrets_engine.approval_consume import consume_approval, _expected_request, authorize_action, resolve_approval_observation +from secrets_engine.authorization import approval_binding_digest, build_action_request, request_digest +from secrets_engine.catalog import validate_entry +from secrets_engine.errors import DecisionError +from secrets_engine.execution_contract import ExecutionContract, catalog_target_digest +from tests.test_catalog import VALID +from tests.test_consume_binding_join import _Cfg, _token, _opener, APPROVAL_ID +from tests.authorization_stub import AuthorizationStub + +H = 'sha256:' + 'a' * 64 + + +def entry(): + raw = copy.deepcopy(VALID) + raw['stage'] = 'prod' + raw['approval'] = {'model': 'bootstrap-only', 'authorization_id': APPROVAL_ID, 'purpose': 'metadata-test'} + return validate_entry(raw) + + +def descriptor(e): + baseline = build_action_request(e, 'apply', subject_id='secrets-engine', subject_type='service', purpose='metadata-test') + return { + 'contract_version': 'SECRETS-EXEC-20261011-v1', 'operation_id': 'cbfbced0-09d5-48c8-a1e7-a2ea4dc69153', + 'operation': 'metadata-apply-ccr', 'execution_actor': 'service:secrets-engine', + 'ccr_id': 'CCR-2099-0001', 'ccr_immutable_sha256': H, 'execution_grant_ccr_id': 'CCR-2099-0002', + 'grant_immutable_sha256': H, 'catalog_sha256': catalog_target_digest(baseline['context']['catalog_target']), + 'source_bundle': {name: {'path': '/owner/'+name+'.py', 'sha256': H} + for name in ['engine_dispatcher', 'platform_mediator', 'typed_ccr', 'policy_auth']}, + 'backend_origin': 'http://127.0.0.1:18200', 'cluster_uid': 'test-estate-not-live', + 'operations': [{'method': 'GET', 'path': 'sys/policies/acl/test-policy', 'payload_sha256': H}, + {'method': 'PUT', 'path': 'auth/netkingdom/role/test-role', 'payload_sha256': H}], + 'operation_deadline': (datetime.now(timezone.utc)+timedelta(seconds=60)).isoformat(), + 'backend_budget': {'ttl_seconds': 120, 'uses': 12}, 'expected_before': H, 'expected_after': H, + } + + +def request(e, contract): + return build_action_request(e, 'execute-ccr', subject_id='secrets-engine', subject_type='service', + purpose='metadata-test', execution_contract=contract) + + +def served(pdp): + claim = pdp.claim() + claim['binding']['human_control'] = True + return claim + + +def test_four_builders_preserve_context_and_binding(tmp_path): + e = entry(); raw = descriptor(e); contract = ExecutionContract.parse(raw) + cfg = _Cfg(_token(tmp_path), authorization_subject_id='secrets-engine', authorization_subject_type='service') + expected = request(e, contract) + assert expected['context']['human_control'] is True + assert _expected_request(cfg,e,'execute-ccr',execution_contract=contract) == expected + pdp = AuthorizationStub(approval_id=APPROVAL_ID, package=cfg.authorization_policy_package, version=cfg.authorization_policy_version) + pdp.bind_request(expected); claim = served(pdp) + observed = resolve_approval_observation(cfg,e,'execute-ccr',None,execution_contract=contract,opener=_opener(claim)) + assert observed.request['context']['execution_contract'] == raw + assert approval_binding_digest(observed.request) == request_digest(expected) + assert request_digest(observed.request) != request_digest(expected) + submitted=[] + def check(req, timeout=None): + value=json.loads(req.data); submitted.append(value) + return _opener(pdp.decision(value))(req) + authorized = authorize_action(cfg,e,'execute-ccr',execution_contract=contract,opener=_opener(claim),pdp_opener=check) + assert authorized.binding.request_digest == request_digest(submitted[0]) + assert submitted[0] == observed.request + from tests.test_approval_consume import _Response + consume_bodies = [] + def consume(req, timeout=None): + value = json.loads(req.data); consume_bodies.append(value) + return _Response(200, {'status':'consumed', 'approval_id':APPROVAL_ID, + 'request_digest':value['request_digest'], 'decision_id':value['decision_id'], + 'consumed_at':datetime.now(timezone.utc).isoformat(), 'idempotent':False}) + confirmed = consume_approval(base_url=cfg.approval_url, token_file=cfg.approval_token_file, + binding=authorized.binding, opener=consume) + assert consume_bodies == [{'request_digest':request_digest(submitted[0]), 'decision_id':authorized.decision_id}] + assert confirmed.request_digest != request_digest(expected) + raw['operations'][0]['path']='sys/policies/acl/changed' + observed.request['context']['execution_contract']['source_bundle']['typed_ccr']['path']='/changed.py' + assert contract.as_dict()['operations'][0]['path']=='sys/policies/acl/test-policy' + assert contract.as_dict()['source_bundle']['typed_ccr']['path']=='/owner/typed_ccr.py' + + +@pytest.mark.parametrize('change', ['operation_id','ccr','grant','source','body','method','path','budget','deadline','state','cluster']) +def test_changed_binding_cannot_use_original_claim(tmp_path,change): + e=entry(); original=descriptor(e); cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service') + pdp=AuthorizationStub(approval_id=APPROVAL_ID,package=cfg.authorization_policy_package,version=cfg.authorization_policy_version) + pdp.bind_request(request(e,ExecutionContract.parse(original))); claim=served(pdp) + changed=copy.deepcopy(original) + if change=='operation_id': changed[change]='775ab8f4-4731-47c8-bd98-342af22885dd' + elif change=='ccr': changed['ccr_immutable_sha256']='sha256:'+'b'*64 + elif change=='grant': changed['grant_immutable_sha256']='sha256:'+'b'*64 + elif change=='source': changed['source_bundle']['typed_ccr']['sha256']='sha256:'+'b'*64 + elif change=='body': changed['operations'][1]['payload_sha256']='sha256:'+'b'*64 + elif change=='method': changed['operations'][0]['method']='PUT' + elif change=='path': changed['operations'][0]['path']='sys/policies/acl/changed' + elif change=='budget': changed['backend_budget']['uses']=11 + elif change=='deadline': changed['operation_deadline']=(datetime.now(timezone.utc)+timedelta(seconds=50)).isoformat() + elif change=='state': changed['expected_before']='sha256:'+'b'*64 + elif change=='cluster': changed['cluster_uid']='different-estate' + typed=ExecutionContract.parse(changed) + assert approval_binding_digest(request(e,typed)) != pdp._pdp_digest + def check(req,timeout=None): return _opener(pdp.decision(json.loads(req.data)))(req) + with pytest.raises(DecisionError,match='digest'): + authorize_action(cfg,e,'execute-ccr',execution_contract=typed,opener=_opener(claim),pdp_opener=check) + + +@pytest.mark.parametrize('mutation', ['unknown','extra_nested','missing','wrong_actor','boolean_budget','oversize_budget','sibling_endpoint','path_traversal','source_relative','wrong_hash','bad_deadline','method_object']) +def test_closed_schema_refuses(mutation): + raw=descriptor(entry()) + if mutation=='unknown': raw['approved']=True + elif mutation=='extra_nested': raw['backend_budget']['renewable']=True + elif mutation=='missing': del raw['source_bundle']['typed_ccr'] + elif mutation=='wrong_actor': raw['execution_actor']='user:admin' + elif mutation=='boolean_budget': raw['backend_budget']['uses']=True + elif mutation=='oversize_budget': raw['backend_budget']['ttl_seconds']=121 + elif mutation=='sibling_endpoint': raw['operations'][0]['path']='secret/data/x' + elif mutation=='path_traversal': raw['operations'][0]['path']='auth/a/role/../x' + elif mutation=='source_relative': raw['source_bundle']['typed_ccr']['path']='foo.py' + elif mutation=='wrong_hash': raw['expected_after']='unsafe' + elif mutation=='bad_deadline': raw['operation_deadline']='2026-10-11T10:00:00' + elif mutation=='method_object': raw['operations'][0]['method']={} + with pytest.raises(DecisionError): ExecutionContract.parse(raw) + + +@pytest.mark.parametrize('action', ['apply','rotate','exec','execute-ccr']) +def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action): + e=entry(); cfg=_Cfg(_token(tmp_path)); calls=[] + def fetch(*args,**kwargs): calls.append('claim'); raise AssertionError + contract=None if action=='execute-ccr' else ExecutionContract.parse(descriptor(e)) + with pytest.raises(DecisionError): + authorize_action(cfg,e,action,execution_contract=contract,opener=fetch,pdp_opener=fetch) + assert calls==[] + + +def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path): + e=entry(); typed=ExecutionContract.parse(descriptor(e)); e=replace(e,path='changed/path') + cfg=_Cfg(_token(tmp_path)); calls=[] + with pytest.raises(DecisionError,match='catalog'): + resolve_approval_observation(cfg,e,'execute-ccr',None,execution_contract=typed,opener=lambda *a,**k:calls.append('claim')) + assert not calls + e=entry(); cfg.approval_url='' + with pytest.raises(DecisionError,match='human_control'): + resolve_approval_observation(cfg,e,'execute-ccr',None,execution_contract=ExecutionContract.parse(descriptor(e))) + + +def test_expired_contract_refuses_before_claim(tmp_path): + e=entry(); raw=descriptor(e); raw['operation_deadline']=(datetime.now(timezone.utc)-timedelta(seconds=1)).isoformat() + cfg=_Cfg(_token(tmp_path)); calls=[] + with pytest.raises(DecisionError,match='expired'): + authorize_action(cfg,e,'execute-ccr',execution_contract=ExecutionContract.parse(raw),opener=lambda *a,**k:calls.append('claim')) + assert not calls + + +@pytest.mark.parametrize('changed', ['subject', 'purpose', 'catalog', 'tenant', 'stage']) +def test_request_correspondence_changes_refuse_before_authorization(tmp_path, changed): + e = entry(); contract = ExecutionContract.parse(descriptor(e)) + cfg = _Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service') + pdp = AuthorizationStub(approval_id=APPROVAL_ID,package=cfg.authorization_policy_package,version=cfg.authorization_policy_version) + pdp.bind_request(request(e,contract)); claim = served(pdp) + if changed == 'subject': cfg.authorization_subject_id = 'foreign-service' + elif changed == 'purpose': e = replace(e,approval={**e.approval, 'purpose':'different-purpose'}) + elif changed == 'catalog': e = replace(e,path='changed/path') + elif changed == 'stage': e = replace(e,stage='test') + calls = [] + def check(req,timeout=None): + calls.append('check'); return _opener(pdp.decision(json.loads(req.data)))(req) + with pytest.raises(DecisionError): + if changed == 'tenant': + build_action_request(e,'execute-ccr',subject_id='secrets-engine',subject_type='service',purpose='metadata-test',tenant='tenant:foreign',execution_contract=contract) + else: + authorize_action(cfg,e,'execute-ccr',execution_contract=contract,opener=_opener(claim),pdp_opener=check) + if changed in {'catalog','stage','tenant'}: assert not calls + + +def test_raw_dictionary_and_direct_invalid_type_cannot_bypass_validation(): + e = entry() + with pytest.raises(DecisionError): request(e,descriptor(e)) + with pytest.raises(DecisionError): request(e,ExecutionContract('{}')) + + +def test_deadline_beyond_claim_refuses_before_pdp(tmp_path): + e=entry(); raw=descriptor(e); raw['operation_deadline']=(datetime.now(timezone.utc)+timedelta(hours=2)).isoformat() + contract=ExecutionContract.parse(raw); cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service') + pdp=AuthorizationStub(approval_id=APPROVAL_ID,package=cfg.authorization_policy_package,version=cfg.authorization_policy_version) + pdp.bind_request(request(e,contract)); calls=[] + with pytest.raises(DecisionError,match='exceeds served authority'): + authorize_action(cfg,e,'execute-ccr',execution_contract=contract,opener=_opener(served(pdp)),pdp_opener=lambda *a,**k:calls.append('check')) + assert not calls + +# Reuse admitted native producer signing, never bypass responder verification. +from tests.test_consume_binding_join import admitted_native_signer + +from tests.signature_support import signature_tools diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/README.md b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/README.md new file mode 100644 index 00000000..a63b2a04 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/README.md @@ -0,0 +1,35 @@ +# Access Engine decision verifier + +The API types and `internal/sign/sign.go` are unchanged producer source, pinned +by revision and SHA-256 in `producer-source.json`. License attribution and the +original license text are in `LICENSE.access-engine`. This is source vendoring +necessitated by the producer's Go `internal` package boundary, not a separately +maintained crypto implementation. There are no external Go dependencies. + +Build in this directory: `go build -trimpath -o /tmp/secrets-engine-pdp-verify .`. +Install that binary through the deployment's reviewed package/image path and set +`SECRETS_ENGINE_PDP_VERIFIER` to its absolute path. Do not compile at request time. +The Python wheel alone is insufficient: deployments must install this helper. +The `testsupport/signer` executable is test-only and must not be installed. + +From repository root, run `python3 scripts/check_pdp_producer_source.py` before +building, and optionally add `--upstream /path/to/access-engine` to detect producer +drift. To update: select/review an upstream revision, copy these exact source +files and native replay fixtures, update the manifest, run both upstream signature +checks and downstream consumer tests, then review the complete diff. Never change +canonicalization locally. Producer contract additions fail closed until reviewed. + +The helper reads the original JSON envelope, verifies using Go's producer verifier, +and returns only the authenticated typed envelope. The Python boundary rejects +duplicate keys, nonfinite values and invalid UTF-8 before invoking it. Unknown +typed fields refuse. The unsigned producer mode is never a production fallback. + +The Check API defaults to mandatory authentication. Its deliberately named +`allow_unsigned_test_fixture` argument is used only by synthetic transport tests; +production `authorize_action` never passes it. The helper runs with an empty +environment, so ambient backend and identity credentials cannot reach it. + +For the complete reproducible CLI build and offline fresh-prefix installation, +use [the release bundle contract](../docs/cli-release-bundle.md). This replaces +manual helper copying for packaged deployments; package and key admission remain +separate owner decisions. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/internal/sign/sign.go b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/internal/sign/sign.go new file mode 100644 index 00000000..fc607f73 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/internal/sign/sign.go @@ -0,0 +1,76 @@ +// Package sign attaches a detached Ed25519 signature to a decision envelope +// (FLEX-WP-0024). The signed material is json.Marshal of the envelope with +// the signature field omitted. +package sign + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/netkingdom/access-engine/pkg/api" +) + +const AlgorithmEd25519 = "ed25519" + +// CanonicalBytes is the exact payload that is signed and verified: the +// envelope as emitted, with signature absent. +func CanonicalBytes(envelope api.DecisionEnvelope) ([]byte, error) { + envelope.Signature = nil + data, err := json.Marshal(envelope) + if err != nil { + return nil, fmt.Errorf("marshal canonical envelope: %w", err) + } + return data, nil +} + +func Sign(envelope *api.DecisionEnvelope, private ed25519.PrivateKey, kid string) error { + if envelope == nil { + return fmt.Errorf("envelope is required") + } + if len(private) != ed25519.PrivateKeySize { + return fmt.Errorf("ed25519 private key has length %d", len(private)) + } + if kid == "" { + return fmt.Errorf("kid is required") + } + payload, err := CanonicalBytes(*envelope) + if err != nil { + return err + } + sig := ed25519.Sign(private, payload) + envelope.Signature = &api.EnvelopeSignature{ + Mode: api.EnvelopeSignatureSigned, + Alg: AlgorithmEd25519, + Kid: kid, + Value: base64.RawURLEncoding.EncodeToString(sig), + } + return nil +} + +func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) error { + sig := envelope.Signature + if sig == nil || sig.Mode != api.EnvelopeSignatureSigned { + return fmt.Errorf("envelope is not signed") + } + if sig.Alg != AlgorithmEd25519 { + return fmt.Errorf("unsupported signature alg %q", sig.Alg) + } + pub, ok := keys[sig.Kid] + if !ok { + return fmt.Errorf("unknown signature kid %q", sig.Kid) + } + raw, err := base64.RawURLEncoding.DecodeString(sig.Value) + if err != nil { + return fmt.Errorf("decode signature: %w", err) + } + payload, err := CanonicalBytes(envelope) + if err != nil { + return err + } + if !ed25519.Verify(pub, payload, raw) { + return fmt.Errorf("signature does not match envelope") + } + return nil +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/main.go b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/main.go new file mode 100644 index 00000000..1f3f9de5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/main.go @@ -0,0 +1,52 @@ +// Decision verification uses the unchanged Access Engine producer implementation. +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/netkingdom/access-engine/internal/sign" + "github.com/netkingdom/access-engine/pkg/api" + "io" + "os" +) + +type input struct { + Envelope json.RawMessage `json:"envelope"` + Keys map[string]string `json:"keys"` +} + +func run() error { + var in input + if err := json.NewDecoder(io.LimitReader(os.Stdin, 1048577)).Decode(&in); err != nil { + return err + } + var envelope api.DecisionEnvelope + decoder := json.NewDecoder(bytes.NewReader(in.Envelope)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&envelope); err != nil { + return err + } + keys := map[string]ed25519.PublicKey{} + for kid, encoded := range in.Keys { + raw, err := base64.RawURLEncoding.Strict().DecodeString(encoded) + if err != nil || len(raw) != ed25519.PublicKeySize || kid == "" { + return fmt.Errorf("invalid admitted key") + } + keys[kid] = ed25519.PublicKey(raw) + } + if err := sign.Verify(envelope, keys); err != nil { + return err + } + // Return the exact typed interpretation authenticated by the producer; Python + // must never act on a second interpretation of unverified response fields. + return json.NewEncoder(os.Stdout).Encode(envelope) +} +func main() { + if run() != nil { + fmt.Fprintln(os.Stderr, "decision signature verification refused") + os.Exit(1) + } +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/pkg/api/canonical.go b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/pkg/api/canonical.go new file mode 100644 index 00000000..3baa784d --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/pkg/api/canonical.go @@ -0,0 +1,685 @@ +package api + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" +) + +// ProtectedSystemManifest describes a system that delegates authorization to +// flex-auth. +type ProtectedSystemManifest struct { + ID string `json:"id" yaml:"id"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + ClientBinding *ClientBinding `json:"client_binding,omitempty" yaml:"client_binding,omitempty"` + ResourceTypes []ResourceType `json:"resource_types,omitempty" yaml:"resource_types,omitempty"` + Actions []ActionDefinition `json:"actions,omitempty" yaml:"actions,omitempty"` + CaringProfiles []string `json:"caring_profiles,omitempty" yaml:"caring_profiles,omitempty"` + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` +} + +// ClientBinding names the key-cape client a protected system's users sign in +// through. It is descriptive only: the readback registry projection lists it, +// and it never decides an allow (FLEX-DEC-2026-019). +type ClientBinding struct { + Issuer string `json:"issuer" yaml:"issuer"` + ClientID string `json:"client_id" yaml:"client_id"` +} + +// ResourceType describes a resource namespace entry owned by a protected system. +type ResourceType struct { + Name string `json:"name" yaml:"name"` + ParentTypes []string `json:"parent_types,omitempty" yaml:"parent_types,omitempty"` + ScopeLevel ScopeLevel `json:"scope_level,omitempty" yaml:"scope_level,omitempty"` + Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"` + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` +} + +// ActionDefinition maps a protected-system action to CARING capabilities. +type ActionDefinition struct { + Name string `json:"name" yaml:"name"` + Capabilities []Capability `json:"capabilities,omitempty" yaml:"capabilities,omitempty"` + Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"` + ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"` + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` +} + +// SubjectManifest declares subjects, groups, teams, and tenants for local +// registry loading. +type SubjectManifest struct { + ID string `json:"id" yaml:"id"` + Subjects []Subject `json:"subjects,omitempty" yaml:"subjects,omitempty"` + Groups []Group `json:"groups,omitempty" yaml:"groups,omitempty"` + Teams []Team `json:"teams,omitempty" yaml:"teams,omitempty"` + Tenants []Tenant `json:"tenants,omitempty" yaml:"tenants,omitempty"` + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` +} + +// Subject is a human, service, automation, agent, or other acting identity. +type Subject struct { + ID string `json:"id" yaml:"id"` + Type SubjectType `json:"type" yaml:"type"` + DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"` + OrganizationRelation OrganizationRelation `json:"organization_relation,omitempty" yaml:"organization_relation,omitempty"` + Roles []CanonicalRole `json:"roles,omitempty" yaml:"roles,omitempty"` + Groups []string `json:"groups,omitempty" yaml:"groups,omitempty"` + Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` + Claims map[string]any `json:"claims,omitempty" yaml:"claims,omitempty"` + CaringDescriptors []CaringAccessDescriptor `json:"caring_descriptors,omitempty" yaml:"caring_descriptors,omitempty"` + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` +} + +// Group is an assignment convenience, not a canonical role. +type Group struct { + ID string `json:"id" yaml:"id"` + DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"` + Members []string `json:"members,omitempty" yaml:"members,omitempty"` + Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` + CaringDescriptors []CaringAccessDescriptor `json:"caring_descriptors,omitempty" yaml:"caring_descriptors,omitempty"` + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` +} + +// Team is a group-like ownership unit used by protected systems. +type Team struct { + ID string `json:"id" yaml:"id"` + DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"` + Members []string `json:"members,omitempty" yaml:"members,omitempty"` + Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` + CaringDescriptors []CaringAccessDescriptor `json:"caring_descriptors,omitempty" yaml:"caring_descriptors,omitempty"` + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` +} + +// Tenant is a structural isolation boundary. +type Tenant struct { + ID string `json:"id" yaml:"id"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` +} + +// RelationshipFact records a relation between subjects, groups, teams, tenants, +// and resources. +type RelationshipFact struct { + ID string `json:"id" yaml:"id"` + System string `json:"system,omitempty" yaml:"system,omitempty"` + Subject string `json:"subject" yaml:"subject"` + Relation string `json:"relation" yaml:"relation"` + Object string `json:"object" yaml:"object"` + Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` + Conditions []Condition `json:"conditions,omitempty" yaml:"conditions,omitempty"` + Caring *CaringAccessDescriptor `json:"caring,omitempty" yaml:"caring,omitempty"` + Provenance map[string]any `json:"provenance,omitempty" yaml:"provenance,omitempty"` + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` +} + +// PolicyPackageMetadata is the frontmatter contract for Rego-in-Markdown +// policy packages. +type PolicyPackageMetadata struct { + ID string `json:"id" yaml:"id"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` + Version string `json:"version" yaml:"version"` + Status string `json:"status,omitempty" yaml:"status,omitempty"` + Package string `json:"package" yaml:"package"` + Actions []string `json:"actions,omitempty" yaml:"actions,omitempty"` + Owner string `json:"owner,omitempty" yaml:"owner,omitempty"` + Fixtures []string `json:"fixtures,omitempty" yaml:"fixtures,omitempty"` + Caring CaringPolicyMetadata `json:"caring" yaml:"caring"` + Activation map[string]any `json:"activation,omitempty" yaml:"activation,omitempty"` + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` + // AllowTTL is a Go duration (for example "15m") that bounds every allow + // this package produces. Omit to use DefaultAllowTTL. "none" or "0s" + // means no stated end; the engine denies those allows (§9.7.1). + AllowTTL string `json:"allow_ttl,omitempty" yaml:"allow_ttl,omitempty"` +} + +// CaringPolicyMetadata declares the CARING envelope a policy governs. +type CaringPolicyMetadata struct { + Profile string `json:"profile" yaml:"profile"` + Enforce bool `json:"enforce,omitempty" yaml:"enforce,omitempty"` + CanonicalRoles []CanonicalRole `json:"canonical_roles,omitempty" yaml:"canonical_roles,omitempty"` + OrganizationRelations []OrganizationRelation `json:"organization_relations,omitempty" yaml:"organization_relations,omitempty"` + Scopes []CaringScope `json:"scopes,omitempty" yaml:"scopes,omitempty"` + Planes []Plane `json:"planes,omitempty" yaml:"planes,omitempty"` + Capabilities []Capability `json:"capabilities,omitempty" yaml:"capabilities,omitempty"` + ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"` + Conditions []Condition `json:"conditions,omitempty" yaml:"conditions,omitempty"` + Restrictions []Restriction `json:"restrictions,omitempty" yaml:"restrictions,omitempty"` + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` +} + +// PolicyFixture binds a check request to an expected decision. +type PolicyFixture struct { + ID string `json:"id" yaml:"id"` + Request CheckRequest `json:"request" yaml:"request"` + Expect DecisionExpectation `json:"expect" yaml:"expect"` + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` +} + +// DecisionExpectation is the compact fixture expectation for policy tests. +type DecisionExpectation struct { + Effect DecisionEffect `json:"effect" yaml:"effect"` + Reason string `json:"reason,omitempty" yaml:"reason,omitempty"` + Obligations []Obligation `json:"obligations,omitempty" yaml:"obligations,omitempty"` + ConformanceFindings []CaringConformanceFinding `json:"conformance_findings,omitempty" yaml:"conformance_findings,omitempty"` +} + +// CheckRequest is the stable protected-system-facing decision request. +type CheckRequest struct { + ID string `json:"id,omitempty" yaml:"id,omitempty"` + Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` + Subject SubjectRef `json:"subject" yaml:"subject"` + Action string `json:"action" yaml:"action"` + Resource ResourceRef `json:"resource" yaml:"resource"` + Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"` + CaringContext *CaringAccessDescriptor `json:"caring_context,omitempty" yaml:"caring_context,omitempty"` + PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` + + // Purpose is empty on every enforcement request. The readback pin sets it + // to PurposeReadback, and it is hashed into every request digest, so an + // advisory readback decision can never pass a PEP's §6.4.2 replay test for + // an enforcement request (FLEX-DEC-2026-019). /v1/check and /v1/batch_check + // refuse a request that carries it. + Purpose string `json:"purpose,omitempty" yaml:"purpose,omitempty"` +} + +// PurposeReadback marks an advisory application-access readback decision. +const PurposeReadback = "readback" + +// BatchCheckRequest evaluates one subject/action against multiple resources. +type BatchCheckRequest struct { + ID string `json:"id,omitempty" yaml:"id,omitempty"` + Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` + Subject SubjectRef `json:"subject" yaml:"subject"` + Action string `json:"action" yaml:"action"` + Resources []ResourceRef `json:"resources" yaml:"resources"` + Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"` + PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` +} + +// SubjectRef is a normalized subject reference in request and decision shapes. +type SubjectRef struct { + ID string `json:"id" yaml:"id"` + Type SubjectType `json:"type,omitempty" yaml:"type,omitempty"` + Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` + Attributes map[string]any `json:"attributes,omitempty" yaml:"attributes,omitempty"` +} + +// ResourceRef is a normalized resource reference in request and decision shapes. +type ResourceRef struct { + ID string `json:"id" yaml:"id"` + Type string `json:"type,omitempty" yaml:"type,omitempty"` + System string `json:"system,omitempty" yaml:"system,omitempty"` + Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` + Attributes map[string]any `json:"attributes,omitempty" yaml:"attributes,omitempty"` +} + +// DecisionEffect is the stable decision outcome vocabulary. +type DecisionEffect string + +const ( + DecisionEffectAllow DecisionEffect = "allow" + DecisionEffectDeny DecisionEffect = "deny" + DecisionEffectRedact DecisionEffect = "redact" + DecisionEffectAuditOnly DecisionEffect = "audit_only" + DecisionEffectNotApplicable DecisionEffect = "not_applicable" +) + +// DecisionEffects returns the complete effect vocabulary. Every effect is an +// emission class in cadence.yaml; a test fails if a constant above is missing +// here, so a new effect cannot ship unclassified (FLEX-WP-0031-T03). +func DecisionEffects() []DecisionEffect { + return []DecisionEffect{ + DecisionEffectAllow, + DecisionEffectDeny, + DecisionEffectRedact, + DecisionEffectAuditOnly, + DecisionEffectNotApplicable, + } +} + +// Restricts reports whether an effect withholds authority. This classification +// does not exempt an effect from required durable emission (GH-DEC-2026-024). +func (e DecisionEffect) Restricts() bool { + switch e { + case DecisionEffectDeny, DecisionEffectRedact, DecisionEffectNotApplicable: + return true + } + return false +} + +// DecisionRecordContractV1 is the published decision-record contract identifier. +const DecisionRecordContractV1 = "flex-auth.decision-record.v1" + +// DecisionEnvelope is the stable response produced by standalone and delegated +// evaluators. It is flex-auth's published decision-record contract (§17). +type DecisionEnvelope struct { + ID string `json:"id" yaml:"id"` + ContractVersion string `json:"contract_version,omitempty" yaml:"contract_version,omitempty"` + RequestID string `json:"request_id,omitempty" yaml:"request_id,omitempty"` + Effect DecisionEffect `json:"effect" yaml:"effect"` + Reason string `json:"reason,omitempty" yaml:"reason,omitempty"` + MatchedPolicyVersion string `json:"matched_policy_version,omitempty" yaml:"matched_policy_version,omitempty"` + MatchedRule string `json:"matched_rule,omitempty" yaml:"matched_rule,omitempty"` + Resource ResourceRef `json:"resource" yaml:"resource"` + Subject SubjectRef `json:"subject" yaml:"subject"` + Binding *DecisionBinding `json:"binding,omitempty" yaml:"binding,omitempty"` + Lifetime *DecisionLifetime `json:"lifetime,omitempty" yaml:"lifetime,omitempty"` + Obligations []Obligation `json:"obligations,omitempty" yaml:"obligations,omitempty"` + Diagnostics map[string]any `json:"diagnostics,omitempty" yaml:"diagnostics,omitempty"` + Provenance DecisionProvenance `json:"provenance" yaml:"provenance"` + Signature *EnvelopeSignature `json:"signature,omitempty" yaml:"signature,omitempty"` + Caring *CaringDecisionMetadata `json:"caring,omitempty" yaml:"caring,omitempty"` +} + +// EnvelopeSignatureMode is how the responder authenticates the envelope. +type EnvelopeSignatureMode string + +const ( + EnvelopeSignatureSigned EnvelopeSignatureMode = "signed" + EnvelopeSignatureUnsigned EnvelopeSignatureMode = "unsigned" +) + +// EnvelopeSignature is a detached signature over the canonical envelope with +// this field omitted (FLEX-WP-0024). It is not decision material and is not +// part of request_digest. +type EnvelopeSignature struct { + Mode EnvelopeSignatureMode `json:"mode" yaml:"mode"` + Alg string `json:"alg,omitempty" yaml:"alg,omitempty"` + Kid string `json:"kid,omitempty" yaml:"kid,omitempty"` + Value string `json:"value,omitempty" yaml:"value,omitempty"` +} + +func UnsignedEnvelopeSignature() *EnvelopeSignature { + return &EnvelopeSignature{Mode: EnvelopeSignatureUnsigned} +} + +// DecisionLifetimeKind identifies how an allow ends. +type DecisionLifetimeKind string + +const ( + DecisionLifetimeTTL DecisionLifetimeKind = "ttl" +) + +// DefaultAllowTTL is the engine default when a policy package omits allow_ttl. +const DefaultAllowTTL = 15 * time.Minute + +// ReasonAllowLifetimeUnstated is the deny reason for an allow with no stated end. +const ReasonAllowLifetimeUnstated = "allow_lifetime_unstated" + +// DecisionLifetime bounds an allow (§9.7.1). flex-auth has no session concept, +// so the first honest shape is a policy-package-declared TTL. +type DecisionLifetime struct { + Kind DecisionLifetimeKind `json:"kind" yaml:"kind"` + TTL string `json:"ttl,omitempty" yaml:"ttl,omitempty"` + NotBefore string `json:"not_before,omitempty" yaml:"not_before,omitempty"` + ExpiresAt string `json:"expires_at" yaml:"expires_at"` +} + +// DecisionBinding is the exact normalized authorization request evaluated by +// a decision. It lets a consumer verify structured action, target, actor, and +// context fields without parsing reason or diagnostic prose. +type DecisionBinding struct { + Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` + Subject SubjectRef `json:"subject" yaml:"subject"` + Action string `json:"action" yaml:"action"` + Resource ResourceRef `json:"resource" yaml:"resource"` + Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"` + // Purpose is "readback" on an advisory readback decision and absent on an + // enforcement decision (FLEX-DEC-2026-019). + Purpose string `json:"purpose,omitempty" yaml:"purpose,omitempty"` + RequestDigest string `json:"request_digest" yaml:"request_digest"` + + // ApprovalBindingDigest is RequestDigest computed with the approval claim + // removed from context. It exists to break a circularity: an approval's + // pdp_digest is recorded at issue time, but the request that later carries + // the claim inside its hashed context has a different RequestDigest by + // construction, so a claim can never name the request that carries it. + // + // This digest is stable across attaching the claim, so it is the value + // claim.binding.pdp_digest must equal (GH-DEC-2026-008). It is NOT a replay + // identity and must not be used as one: two requests differing only in + // which approval was presented share it, and the decision does not. + ApprovalBindingDigest string `json:"approval_binding_digest,omitempty" yaml:"approval_binding_digest,omitempty"` + + // SubmittedRequestDigest is RequestDigest over the request exactly as the + // consumer sent it, before the evaluator overlays registry facts onto the + // subject and resource. + // + // It exists because RequestDigest is not consumer-computable and was + // published as though it were. The evaluator hashes the ENRICHED request — + // subject.tenant, subject.attributes, and resource attributes the registry + // contributed — and the registry is flex-auth's, so a consumer recomputing + // the digest over what it sent gets a different value on every real allow. + // secrets-engine found this on its first live request (FLEX-DEC-2026-012). + // + // This is the digest a PEP compares for §6.4 obligation 2's replay test. + // Nothing is lost by hashing the pre-enrichment form: enrichment is a + // function of the request and the registry snapshot, and + // provenance.registry_snapshot_digest already pins the snapshot, so + // SubmittedRequestDigest together with that digest identifies the evaluated + // request completely. + SubmittedRequestDigest string `json:"submitted_request_digest,omitempty" yaml:"submitted_request_digest,omitempty"` +} + +// ApprovalContextKey is the context key carrying an approval-engine +// approval-claim. It is excluded from ApprovalBindingDigest and from nothing +// else. +const ApprovalContextKey = "approval" + +// requestDigestMaterial is the exact tuple hashed for §6.4.2 replay. Request +// id, policy version, and caring_context are excluded: id is correlation, the +// version is provenance, and caring_context is an input-claim digest. +type requestDigestMaterial struct { + Tenant string `json:"tenant,omitempty"` + Subject SubjectRef `json:"subject"` + Action string `json:"action"` + Resource ResourceRef `json:"resource"` + Context map[string]any `json:"context,omitempty"` + Purpose string `json:"purpose,omitempty"` +} + +// NewDecisionBinding returns a stable structured binding for the exact request +// an evaluator consumed. +// +// Prefer NewDecisionBindingFor, which also records the digest of the request as +// submitted. This form leaves SubmittedRequestDigest empty, which reads as "the +// evaluator did not record one" rather than "the two are equal". +func NewDecisionBinding(request CheckRequest) *DecisionBinding { + contextCopy := make(map[string]any, len(request.Context)) + for key, value := range request.Context { + contextCopy[key] = value + } + binding := &DecisionBinding{ + Tenant: request.Tenant, + Subject: request.Subject, + Action: request.Action, + Resource: request.Resource, + Context: contextCopy, + Purpose: request.Purpose, + RequestDigest: RequestDigest(request), + } + if _, carries := request.Context[ApprovalContextKey]; carries { + binding.ApprovalBindingDigest = ApprovalBindingDigest(request) + } + return binding +} + +// NewDecisionBindingFor records the binding of the evaluated request together +// with the digest of the request as submitted. +// +// The two arguments are the same request before and after the evaluator +// overlaid registry facts. Where a request named nothing the registry knows they +// are equal, and the field is emitted anyway: a consumer that only sees it on +// enriched decisions would build a check that passes by absence. +func NewDecisionBindingFor(evaluated, submitted CheckRequest) *DecisionBinding { + binding := NewDecisionBinding(evaluated) + binding.SubmittedRequestDigest = RequestDigest(submitted) + return binding +} + +// ApprovalBindingDigest is RequestDigest over the same material with the +// approval claim removed from context. +// +// When a request carries no approval claim the two are identical, which is the +// point: an approval issued against a claim-free Check records that Check's +// request_digest, and the later claim-bearing request reproduces the same value +// here. Comparing it to claim.binding.pdp_digest establishes correspondence by +// identity rather than by translating between two action vocabularies +// (GH-DEC-2026-008). +func ApprovalBindingDigest(request CheckRequest) string { + if _, carries := request.Context[ApprovalContextKey]; !carries { + return RequestDigest(request) + } + stripped := make(map[string]any, len(request.Context)) + for key, value := range request.Context { + if key == ApprovalContextKey { + continue + } + stripped[key] = value + } + if len(stripped) == 0 { + stripped = nil + } + return CanonicalDigest(requestDigestMaterial{ + Tenant: request.Tenant, + Subject: request.Subject, + Action: request.Action, + Resource: request.Resource, + Context: stripped, + Purpose: request.Purpose, + }) +} + +// RequestDigest is the mechanical §6.4.2 replay test: SHA-256 over canonical +// JSON of tenant, subject, action, resource, context, and purpose. Purpose is +// omitted when empty, so enforcement digests are unchanged by its existence. +func RequestDigest(request CheckRequest) string { + return CanonicalDigest(requestDigestMaterial{ + Tenant: request.Tenant, + Subject: request.Subject, + Action: request.Action, + Resource: request.Resource, + Context: request.Context, + Purpose: request.Purpose, + }) +} + +// CanonicalDigest returns "sha256:" plus the hex SHA-256 of canonical JSON. +// encoding/json sorts map keys, so two equal Go values agree. +func CanonicalDigest(value any) string { + data, err := json.Marshal(value) + if err != nil { + sum := sha256.Sum256(nil) + return "sha256:" + hex.EncodeToString(sum[:]) + } + sum := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// InputClaimDigests hashes the request-time claim classes the evaluator joined. +func InputClaimDigests(request CheckRequest) map[string]string { + digests := make(map[string]string) + if len(request.Context) > 0 { + digests["context"] = CanonicalDigest(request.Context) + } + if request.CaringContext != nil { + digests["caring_context"] = CanonicalDigest(request.CaringContext) + } + if len(digests) == 0 { + return nil + } + return digests +} + +// DecisionCompletion carries evaluator-side inputs used to finish an envelope. +type DecisionCompletion struct { + AllowTTL string + Now time.Time +} + +// CompleteDecision stamps contract version, input-claim digests, decision time, +// and an explicit allow lifetime. An allow with no stated end becomes a deny. +func CompleteDecision(envelope *DecisionEnvelope, request CheckRequest, completion DecisionCompletion) { + if envelope == nil { + return + } + if envelope.ContractVersion == "" { + envelope.ContractVersion = DecisionRecordContractV1 + } + if envelope.Provenance.InputClaimDigests == nil { + envelope.Provenance.InputClaimDigests = InputClaimDigests(request) + } + ApplyAllowLifetime(envelope, completion.AllowTTL, completion.Now) +} + +// ParseAllowTTL resolves a package-declared TTL. ok is false when the allow +// would have no stated end. Invalid strings return an error so package +// validation can reject them. +func ParseAllowTTL(declared string) (time.Duration, error) { + trimmed := strings.TrimSpace(declared) + if trimmed == "" { + return DefaultAllowTTL, nil + } + if strings.EqualFold(trimmed, "none") { + return 0, nil + } + ttl, err := time.ParseDuration(trimmed) + if err != nil { + return 0, fmt.Errorf("allow_ttl %q is not a Go duration: %w", declared, err) + } + if ttl <= 0 { + return 0, nil + } + return ttl, nil +} + +// ApplyAllowLifetime sets DecisionTime and, for allows, an explicit TTL. A +// missing or zero TTL denies the allow rather than mint a standing grant. +func ApplyAllowLifetime(envelope *DecisionEnvelope, declaredTTL string, now time.Time) { + if envelope == nil { + return + } + if now.IsZero() { + now = time.Now().UTC() + } else { + now = now.UTC() + } + if envelope.Provenance.DecisionTime == "" { + envelope.Provenance.DecisionTime = now.Format(time.RFC3339) + } + if envelope.Effect != DecisionEffectAllow { + return + } + ttl, err := ParseAllowTTL(declaredTTL) + if err != nil || ttl <= 0 { + if envelope.Diagnostics == nil { + envelope.Diagnostics = map[string]any{} + } + if envelope.Reason != "" { + envelope.Diagnostics["unstated_allow_reason"] = envelope.Reason + } + envelope.Effect = DecisionEffectDeny + envelope.Reason = ReasonAllowLifetimeUnstated + envelope.MatchedRule = ReasonAllowLifetimeUnstated + envelope.Lifetime = nil + return + } + display := strings.TrimSpace(declaredTTL) + if display == "" { + display = "15m" + } + envelope.Lifetime = &DecisionLifetime{ + Kind: DecisionLifetimeTTL, + TTL: display, + NotBefore: now.Format(time.RFC3339), + ExpiresAt: now.Add(ttl).Format(time.RFC3339), + } +} + +// ActionAuthorizationStatus is the lifecycle state of a durable authorization. +type ActionAuthorizationStatus string + +const ( + ActionAuthorizationPending ActionAuthorizationStatus = "pending" + ActionAuthorizationApproved ActionAuthorizationStatus = "approved" + ActionAuthorizationDenied ActionAuthorizationStatus = "denied" + ActionAuthorizationSuperseded ActionAuthorizationStatus = "superseded" + ActionAuthorizationExpired ActionAuthorizationStatus = "expired" + ActionAuthorizationRevoked ActionAuthorizationStatus = "revoked" +) + +// ActionAuthorization joins a durable approval lifecycle to one exact +// flex-auth request and decision. Storage and approval collection remain the +// responsibility of the organizational decision authority. +type ActionAuthorization struct { + SchemaVersion string `json:"schema_version" yaml:"schema_version"` + ID string `json:"id" yaml:"id"` + Status ActionAuthorizationStatus `json:"status" yaml:"status"` + SupersededBy string `json:"superseded_by,omitempty" yaml:"superseded_by,omitempty"` + Request CheckRequest `json:"request" yaml:"request"` + Validity ActionAuthorizationValidity `json:"validity" yaml:"validity"` + Approvals ActionAuthorizationApprovals `json:"approvals" yaml:"approvals"` + Decision DecisionEnvelope `json:"decision" yaml:"decision"` + Provenance map[string]any `json:"provenance,omitempty" yaml:"provenance,omitempty"` +} + +// ActionAuthorizationValidity bounds execution of an approved action. +type ActionAuthorizationValidity struct { + NotBefore string `json:"not_before,omitempty" yaml:"not_before,omitempty"` + ExpiresAt string `json:"expires_at" yaml:"expires_at"` +} + +// ActionAuthorizationApprovals declares the approval threshold and evidence. +type ActionAuthorizationApprovals struct { + RequiredCount int `json:"required_count" yaml:"required_count"` + Entries []ActionAuthorizationApprovalEntry `json:"entries" yaml:"entries"` +} + +// ActionAuthorizationApprovalEntry is one authenticated approver's evidence. +type ActionAuthorizationApprovalEntry struct { + SubjectID string `json:"subject_id" yaml:"subject_id"` + ApprovedAt string `json:"approved_at" yaml:"approved_at"` + Assurance string `json:"assurance,omitempty" yaml:"assurance,omitempty"` + EvidenceRef string `json:"evidence_ref,omitempty" yaml:"evidence_ref,omitempty"` +} + +// Obligation describes a follow-up behavior required by a decision. +type Obligation struct { + Type string `json:"type" yaml:"type"` + Parameters map[string]any `json:"parameters,omitempty" yaml:"parameters,omitempty"` +} + +// DecisionProvenance captures evaluator and policy provenance. +type DecisionProvenance struct { + Evaluator string `json:"evaluator" yaml:"evaluator"` + Mode string `json:"mode" yaml:"mode"` + PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"` + PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` + PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"` + RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"` + DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"` + InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"` + DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"` + Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"` +} + +// CallerProvenance records how the request was authenticated to flex-auth. +// It is not decision material: the same request from a different authenticated +// caller must decide identically. Mode is required. Under disabled the object +// is {"mode":"disabled"} with no principal (FLEX-DEC-2026-009). +type CallerProvenance struct { + Mode string `json:"mode" yaml:"mode"` + Principal string `json:"principal,omitempty" yaml:"principal,omitempty"` + Audience string `json:"audience,omitempty" yaml:"audience,omitempty"` + NotAfter string `json:"not_after,omitempty" yaml:"not_after,omitempty"` +} + +// CaringDecisionMetadata carries CARING descriptor and conformance details in +// a decision envelope. +type CaringDecisionMetadata struct { + Profile string `json:"profile" yaml:"profile"` + Descriptor *CaringAccessDescriptor `json:"descriptor,omitempty" yaml:"descriptor,omitempty"` + RestrictionsEvaluated []Restriction `json:"restrictions_evaluated,omitempty" yaml:"restrictions_evaluated,omitempty"` + ExposureModes []ExposureMode `json:"exposure_modes,omitempty" yaml:"exposure_modes,omitempty"` + DerivedCapabilities []CaringDerivedCapability `json:"derived_capabilities,omitempty" yaml:"derived_capabilities,omitempty"` + ConformanceFindings []CaringConformanceFinding `json:"conformance_findings,omitempty" yaml:"conformance_findings,omitempty"` + ExposureEvent *CaringExposureEvent `json:"exposure_event,omitempty" yaml:"exposure_event,omitempty"` +} + +// AuditEvent is the local log shape for decisions and exposure events. +type AuditEvent struct { + ID string `json:"id" yaml:"id"` + Type string `json:"type" yaml:"type"` + DecisionID string `json:"decision_id,omitempty" yaml:"decision_id,omitempty"` + Subject SubjectRef `json:"subject" yaml:"subject"` + Resource ResourceRef `json:"resource,omitempty" yaml:"resource,omitempty"` + Action string `json:"action,omitempty" yaml:"action,omitempty"` + Effect DecisionEffect `json:"effect,omitempty" yaml:"effect,omitempty"` + Timestamp string `json:"timestamp,omitempty" yaml:"timestamp,omitempty"` + ExposureEvent *CaringExposureEvent `json:"exposure_event,omitempty" yaml:"exposure_event,omitempty"` + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/producer-source.json b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/producer-source.json new file mode 100644 index 00000000..b6b0cbab --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/producer-source.json @@ -0,0 +1,15 @@ +{ + "upstream": "https://github.com/netkingdom/access-engine", + "revision": "62ac2b4e99dec2005c7c407fe633612578a7907d", + "files": { + "pkg/api/canonical.go": "21e754617c2b82d673cef31b7075f8d177e4367782b63b20b37125587bf9e300", + "pkg/api/caring.go": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3", + "pkg/api/resource_manifest.go": "f0fa4080287d389ed48c2b1d6af96cc62e57ef72c902333f677f964420be133f", + "pkg/api/doc.go": "0638f06ae47369899dd1e2dd5dd23a7200e7dbb3b505b04f4d3c78c0a074def9", + "internal/sign/sign.go": "dad2d0c9f6d1fb2fd68e774949ccdf954382acd075191b5ddfea2c3cc2f4cb62" + }, + "fixtures": { + "decision_rotate_signed.json": "a7bc499f3583f53551cf163109a3c108188345100176092adc31eccefc222fd8", + "decision_rotate_signed_tampered.json": "7f2d2e9372a9617bd4b197033f09a5e33a08b202046de8109582da1dc90cb527" + } +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/testsupport/signer/main.go b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/testsupport/signer/main.go new file mode 100644 index 00000000..2a7fecd5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/verifier/testsupport/signer/main.go @@ -0,0 +1,28 @@ +// Synthetic test-only signer with the producer's published nonproduction seed. +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/json" + "github.com/netkingdom/access-engine/internal/sign" + "github.com/netkingdom/access-engine/pkg/api" + "os" +) + +func main() { + seed := byte(0x42) + kid := "testdata-ed25519" + if len(os.Args) > 1 { + seed = 0x43 + kid = "next-admitted" + } + var env api.DecisionEnvelope + if json.NewDecoder(os.Stdin).Decode(&env) != nil { + os.Exit(1) + } + if sign.Sign(&env, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, 32)), kid) != nil { + os.Exit(1) + } + json.NewEncoder(os.Stdout).Encode(env) +} diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md new file mode 100644 index 00000000..a8a3845e --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md @@ -0,0 +1,126 @@ +--- +id: SECRETS-WP-0013 +type: workplan +title: "Design and prove task-scoped protected OpenBao execution" +domain: infotech +repo: secrets-engine +status: proposed +flavor: planning +owner: codex +topic_slug: netkingdom +created: "2026-10-11" +updated: "2026-10-16" +depends_on: [] +state_hub_workstream_id: "5e38aecc-85ca-5f55-94c6-9fbb08604819" +--- + +Execution-contract direction remains proposed; the narrow local readiness guard +is now reviewed, published and verified in the owner checkout. No native +execution authority or general entitlement adoption is inferred. +Existing SECRETS-WP-0008-T06 remains open for actual adoption. CCR38 is an +unchanged identity-only login and is not a protected execution entitlement. + +## Define the exact mediated execution contract + +```task +id: SECRETS-WP-0013-T01 +status: wait +needs_human: false +blocked_on: message-from:railiance-platform +blocking_reason: "Independent Platform mediated-contract review and identity/approval/PDP owner returns remain required; D3 wrapped primitive source is implemented, no native entitlement is inferred." +priority: high +state_hub_task_id: "b2f0d462-a542-518b-a26b-b7ec07b3c7c1" +``` + +Design SECRETS-EXEC-20261011-v1 in docs/task-scoped-openbao-execution-contract.md +binds action, target, payload/source digests, initiating identity, exact PDP/CAS, +owner-held ephemeral two-path token, bounded TTL/uses, durable admission and +cleanup. Independent Platform/Secrets Engine/identity/approval/PDP owner returns +are required. No foreign review is written by this draft. + +2026-10-11 source-only preparation: typed execution_contract is staged through +all four request builders. The new descriptor is restricted to execute-ccr, +requires prod/platform/human control and binds the catalog fingerprint before +claim. Existing apply/other actions cannot borrow this descriptor. 99 focused +source checks verify propagation, canonical approval/full consume joins, +immutable copies and changed-binding refusals. Detailed schema/limitations are +in the execution-contract document. No source owner assent, initiating-principal +admission, new policy package, ledger/mediator/backend or native acceptance is +claimed. Independent source review remains required. Task statuses are unchanged. + +2026-10-11: D3 response-wrapped primitive now verifies native creation path and bounded TTL/accessor, rejects overwrite/symlink/shared-parent paths, and reserves/fsyncs the private recipient before mint. Backend/write uncertainty retains a non-replayable reservation. Disposable OpenBao proves one successful unwrap and native consumed-token HTTP400 denial, not a production lane or entitlement. Separate delivery contracts cannot reuse metadata execute-ccr authority. Source design is ready for independent owner review; RPF-IN-0012 requests the Platform return. Identity/approval/PDP returns and the complete native mediated path remain open; no founder decision or foreign owner assent is fabricated. + +## Prevent consumption without execution readiness + +```task +id: SECRETS-WP-0013-T02 +status: done +priority: high +state_hub_task_id: "4ad61917-506d-5c12-a715-3d336e6b95e0" +``` + +Completed the narrow local implementation and source-verification obligation. +Secrets Engine source commit 97dfa5ea8fbfe998094bf03a3581ee0c5313ac30 was published +through the root's C17 source-publication record. Independent Platform source +acknowledgment is published in 46f5c956b52524d9d8177002bb60020c7e558039 and +`railiance-platform/docs/reviews/2026-10-11-secrets-execution-readiness-v2-platform-review.json`. +The root verified all 36 focused guard/auth/consume/privileged-evidence checks in +the actual owner checkout; Platform's independent source review passed 58 checks. +Current published CLI/auth/test hashes match those accepted exact sources. + +Configured auto and explicit service-jwt refuse the ten guarded backend handlers +before claim/PDP/CAS/login/backend work because no task-execution backend exists. +Named bootstrap/env paths retain their existing approval/stance gates; login-self +probes remain available. The unchanged ambient route/readiness surface is outside +this guard's scope and is not service-JWT adoption. Task completion covers only +this local fail-closed guard, its source review/publication and source verification. +T01 remains progress, T03 remains wait with qualified dependencies, WP13 remains +proposed, and SECRETS-WP-0008-T06 remains open for actual protected execution +adoption/native effect, denial/no-fallback and cleanup evidence. No task-scoped +backend entitlement, provider default flip, founder native go or native acceptance +is claimed. + +## Implement and prove one native metadata action + +```task +id: SECRETS-WP-0013-T03 +status: wait +depends_on: [SECRETS-WP-0013-T01, RPF-WP-0035-T12] +wait_reason: "Wait for independent execution-contract acceptance and Platform task-scoped entitlement design/materialization, then exact founder native go." +priority: high +state_hub_task_id: "5f8f7c41-5921-543f-a7c6-0b1350ea6d82" +``` + +After T01 independent owner acceptance, implement the narrow execute-ccr surface, +Platform task-grant mediator, bound policy package and admission ledger. Select +an actual unexecuted approved metadata CCR, exact founder go and attended owner +custody. Prove real protected effect, sibling/body/refusal/replay counterexamples +and confirmed revocation/cleanup. No applied CCR38 replay or broad default flip. +Native metadata proof does not certify provision/exec delivery or all T06 scope. + +2026-10-11 source revision v2: qualified T03 dependencies point to the owning design and Platform execution-entitlement task. WP13 owns the missing task-scoped execution seam; WP8-T06 retains production adoption and its full workflow proof. Source publication and fixture checks do not imply native acceptance. Original v1 stage remains unchanged. + +2026-10-11 completed bounded source preparation within T01: repaired the actual +Python/Go canonical request digest mismatch for valid Unicode/HTML purpose and +nested strings, optional maps and decoded numeric formatting. Full request and +approval-free request bindings retain the opaque sha256 prefix; catalogue, +claim-binding and body fingerprints remain distinct and unchanged. Real Access +Go-oracle cases and unchanged canonical/replay/approval/descriptor fixtures pass. +This is source preparation awaiting independent technical review/publication, +not a formal owner assent, policy deployment or native execution milestone. T01 +remains progress; T03 and SECRETS-WP-0008-T06 remain open. Detailed limitations +and retained failed test observations are in the source preparation receipt. + + +2026-10-16 bounded source handoff preparation: `task_execution_handoff.py` uses +the actual serving approval/mandatory signed Check path and retained detached +artifacts to check exact descriptor/request/native approval/caller/lifetime joins, +then performs one strict exact-response CAS only after Platform journal admission. +It accepts no inbound boolean/mapping/authorizer callback and grants no native +execution. Platform independently confirms durable consumption with the actual +Approval provider client before native dispatch. Focused local HTTP/native Go +producer-signature source checks cover altered joins/caller/grant, missing fields, +coercion/idempotence, expiry, signer removal and replay refusal. No service binding +propagation, policy gate flip, identity mapping admission or production trust/key +change was made. Independent source integration/review and actual whole-provider +proof remain required. T01/T03 and WP8-T06 retain their open native obligations. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md new file mode 100644 index 00000000..28102cf6 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/preimages/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md @@ -0,0 +1,116 @@ +--- +id: SECRETS-WP-0014 +type: workplan +title: "Constrain PDP transport and authenticate decision responses" +domain: infotech +repo: secrets-engine +status: active +owner: codex +topic_slug: secrets-engine +created: "2026-10-12" +updated: "2026-10-16" +flavor: implementation +depends_on: [] +state_hub_workstream_id: "7252469d-d7a5-5c18-8911-b511b8c6a736" +--- + +Native owner work for CUST-WP-0080 G13. Reuses the accepted Check contract and +FLEX-WP-0024 producer signature surface; does not create another PDP or grant +any catalog-lane execution authority. SECRETS-WP-0013 still owns task-scoped +protected execution. Transport review is complete; key admission and native +rollout remain separate gates below. + +## Prevent the Check credential from leaving the admitted local transport + +```task +id: SECRETS-WP-0014-T01 +status: done +priority: high +state_hub_task_id: "8baba9a3-3513-5f02-bafe-ed9dce743173" +``` + +Reproduced redirects (301/302/303), environment proxy forwarding, malformed URL +handling, oversized-body truncation and invalid UTF-8 against the prior source. +The bounded fix disables redirects and ambient proxies/global openers, requires +a literal loopback bare origin before credential access, caps the complete +response body and rejects invalid UTF-8 as a DecisionError. The approval consumer +no longer overrides this safe default with urlopen. Synthetic local-server tests +cover both entry points, all five redirect status codes and proxy forwarding. +No real credentials or protected backend were touched. Source verification is +recorded in the Custodian evidence directory for 2026-10-12, pdp-transport.json. +This closes the source transport defects only; T02/T03 remain mandatory. + +## Implement independently anchored responder authentication + +```task +id: SECRETS-WP-0014-T02 +status: progress +priority: high +state_hub_task_id: "877782e5-045b-5186-8b95-82e25db21a5a" +``` + +Review producer canonical signature bytes and the native pin's unsigned/signed +mode. Establish independently admitted verification keys and exact issuer/pin +binding with AccessEngine and Platform; an unauthenticated keys endpoint on the +same responder is not a trust anchor. Reuse the native producer fixtures rather +than inventing a Python canonicalization. Require valid signed envelopes on the +production consumer path; prove forged allow, altered payload, unknown key, +wrong issuer/pin, unsigned/downgrade, expired and rotation overlap/refusal cases. +Track any temporary exception using all required SEC-DSM-EXC fields and actual +approval; neither source support nor a loopback URL is that approval. + +2026-10-16 source preparation: production `authorize_action` now requires an +independently admitted local trust file and installed verifier before approval/PDP +credential reads. The Go helper vendors exact producer API/signature sources, +checks source hashes, authenticates the original response and returns only its +verified typed interpretation. Signed evaluator/mode and exact package/version/ +artifact digest are bound to admitted public keys. Unknown/unsigned/altered +responses refuse; existing request/approval/lifetime/CAS checks remain required. +Native replay, real producer signing and consumer-boundary tests cover downgrade, +issuer/pin, request joins, and distinct-key overlap/removal. Build/install and +source update procedures are documented in `verifier/README.md`. + +This is an isolated source implementation awaiting integration/independent review. +No production verification keys, native pin signed-mode change, trust admission, +owner assent or rollout has occurred. T02 stays progress because independent +key admission remains part of its scope; T03 stays wait. WP13 execution and +identity-only CCR38 are unchanged. Custodian source/verification handoff: +`docs/changes/netkingdom-target-2026-10-16/decision-auth/` in the Custodian repo. + +2026-10-16 packaging preparation: `scripts/build_release_bundle.py` and its +separate offline installer deliver the existing wheel plus the reviewed producer +verifier together into a new owner-controlled prefix. Source/hash drift checks, +artifact/toolchain receipts and a narrow fixed-helper launcher remove the silent +wheel-only deployment gap. Disposable build/install tests prove deterministic +artifacts, signed HTTP consumption and missing/tampered artifact refusal; they do +not admit packages, keys or a production prefix. T02 remains progress and T03 +wait; no native installation, trust change or protected entitlement is claimed. + +## Verify the admitted native consumer and rotation path + +```task +id: SECRETS-WP-0014-T03 +status: wait +priority: high +depends_on: [SECRETS-WP-0014-T02] +blocking_reason: "Requires reviewed verification-key admission, source enforcement and exact native rollout receipt; no production trust change is proposed yet." +state_hub_task_id: "85aa4c78-b66d-5201-8e31-0ed50e7eaeec" +``` + +Under an exact reviewed rollout, prove the real pin and consumer accept the +admitted signer, reject forgery/downgrade, rotate without accepting removed keys, +and preserve request/approval binding, lifetime and single-use consumption. +Retain rollback and failure evidence. Update CUST-WP-0080 G13 only with these +native receipts; do not equate passing transport tests with closure. + + +2026-10-16 native preparation (no rollout): `docs/proposals/native-cli-admission/` +now scopes the existing CLI/lifecycle signer stage, exact delegated PEP denominator, +independent key/policy admission, owner-controlled bundle/config placement and +rotation/rollback refusal. Canonical ff1a13d (packaging plus strict handoff) was +rebuilt in an isolated clean checkout; artifact receipts are in Custodian +`decision-native-preparation/`. Production kid/public fingerprint/custody receipt, +producer candidate OCI and actual runtime/config placement remain missing. The +empty-key trust template deliberately refuses. No install, key read, caller mint, +protected effect, policy switch or trust admission occurred. T02 remains progress, +T03 wait; task source/live obligations remain separately in SECRETS-WP-0013. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/signature-strict-tests.txt b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/signature-strict-tests.txt new file mode 100644 index 00000000..bf353653 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/signature-strict-tests.txt @@ -0,0 +1,3 @@ +........................................................................ [ 69%] +............................... [100%] +103 passed in 14.94s diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r1.patch b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r1.patch new file mode 100644 index 00000000..733c64e8 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r1.patch @@ -0,0 +1,642 @@ +diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py +index 1cf4140..e4865bb 100644 +--- a/src/secrets_engine/approval_consume.py ++++ b/src/secrets_engine/approval_consume.py +@@ -382,7 +382,8 @@ def authorize_action( + ), + decision_id=validated.decision_id, + expires_at=validated.expires_at, +- artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope}, ++ artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope, ++ "authenticated_envelope_json":getattr(envelope,"authenticated_json","")}, + sort_keys=True,separators=(",",":"),allow_nan=False), + ) + +diff --git a/src/secrets_engine/decision_signature.py b/src/secrets_engine/decision_signature.py +index 24fa8d3..d9bd0b9 100644 +--- a/src/secrets_engine/decision_signature.py ++++ b/src/secrets_engine/decision_signature.py +@@ -7,6 +7,7 @@ from __future__ import annotations + + import base64 + import json ++import math + from pathlib import Path + import stat + import subprocess +@@ -24,8 +25,15 @@ def _unique_object(pairs): + return result + + ++def _finite_float(value): ++ parsed = float(value) ++ if not math.isfinite(parsed): ++ raise ValueError("nonfinite JSON number") ++ return parsed ++ ++ + def strict_json(data): +- return json.loads(data, object_pairs_hook=_unique_object, ++ return json.loads(data, object_pairs_hook=_unique_object, parse_float=_finite_float, + parse_constant=lambda value: (_ for _ in ()).throw(ValueError("invalid number"))) + + +@@ -66,10 +74,18 @@ def load_trust(path: Path | None) -> dict[str, Any]: + raise DecisionError("access-engine responder trust is missing or invalid") from exc + + ++class AuthenticatedEnvelope(dict): ++ """Verified typed values with exact producer JSON retained for reauthentication.""" ++ def __init__(self, values, authenticated_json): ++ super().__init__(values) ++ self.authenticated_json = authenticated_json ++ ++ + def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) -> dict: + if verifier is None or not Path(verifier).is_absolute(): + raise DecisionError("access-engine signature verifier is not installed") + try: ++ strict_json(body.decode("utf-8")) + result = subprocess.run([str(verifier)], input=b'{"envelope":'+body+b',"keys":'+json.dumps(trust['keys']).encode()+b'}', + capture_output=True, env={}, timeout=3, check=False) + if result.returncode != 0 or len(result.stdout) > 524288: +@@ -78,6 +94,6 @@ def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) -> + provenance = authenticated['provenance'] + if any(provenance.get(k) != trust[k] for k in ('evaluator','mode','policy_package','policy_version','policy_package_digest')): + raise ValueError("issuer/pin mismatch") +- return authenticated +- except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError) as exc: ++ return AuthenticatedEnvelope(authenticated, result.stdout.decode("utf-8")) ++ except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError, UnicodeDecodeError) as exc: + raise DecisionError("access-engine responder authentication failed") from exc +diff --git a/src/secrets_engine/task_execution_handoff.py b/src/secrets_engine/task_execution_handoff.py +index 9772861..eea6a85 100644 +--- a/src/secrets_engine/task_execution_handoff.py ++++ b/src/secrets_engine/task_execution_handoff.py +@@ -10,6 +10,7 @@ from __future__ import annotations + from dataclasses import dataclass + from datetime import datetime + import json ++import re + from threading import Lock + + from secrets_engine.approval_auth import approval_token, credential_urlopen +@@ -85,10 +86,10 @@ def _validate(cfg, action, contract): + request,claim,envelope=(artifacts[name] for name in ('request','claim','envelope')) + # Rotation/removal and installed-helper changes cannot hide behind a cached + # prepared label. Reauthenticate before each strict CAS, without networking. +- authenticated=authenticate_response(json.dumps(envelope,allow_nan=False).encode(), ++ authenticated=authenticate_response(artifacts["authenticated_envelope_json"].encode("utf-8"), + trust=load_trust(getattr(cfg,'pdp_trust_file',None)), + verifier=getattr(cfg,'pdp_verifier',None)) +- if authenticated != envelope: ++ if json.dumps(authenticated,sort_keys=True,allow_nan=False) != json.dumps(envelope,sort_keys=True,allow_nan=False): + _refuse() + window=read_window(cfg) + lower,upper=validity_bounds(window=window) +@@ -140,6 +141,22 @@ def _validate(cfg, action, contract): + or supplied.get('digest')!=claim_binding_digest(**native) + or supplied.get('digest')!=claim_binding_digest(**{key:supplied.get(key) for key in _NATIVE_FIELDS})): + _refuse() ++ observation=envelope['provenance'].get('approval_observation') ++ if observation is not None: ++ # Signed producer source evidence supplements the original carried claim; ++ # it cannot replace original context, extend freshness, or authorize CAS. ++ if (not isinstance(observation,dict) ++ or any(not isinstance(observation.get(k),str) or not observation[k] ++ for k in ('source_id','source_revision')) ++ or any(not isinstance(observation.get(k),str) ++ or re.fullmatch(r'sha256:[0-9a-f]{64}',observation[k]) is None ++ for k in ('configuration_digest','claim_digest','native_binding_digest')) ++ or observation['native_binding_digest']!=supplied['digest'] ++ or _stamp(observation.get('observed_at'))>_stamp(observation.get('validated_at')) ++ or _stamp(observation.get('validated_at'))>upper ++ or upper>=_stamp(observation.get('not_after')) ++ or _stamp(envelope['lifetime']['expires_at'])>_stamp(observation.get('not_after'))): ++ _refuse() + return {'execution_contract':descriptor,'approval_id':action.binding.approval_id, + 'request_digest':validated.request_digest,'decision_id':validated.decision_id, + 'native_binding':native,'approval_binding_digest':binding['approval_binding_digest'], +diff --git a/tests/fixtures/pdp-signature/approval-native-vectors.json b/tests/fixtures/pdp-signature/approval-native-vectors.json +new file mode 100644 +index 0000000..46adb26 +--- /dev/null ++++ b/tests/fixtures/pdp-signature/approval-native-vectors.json +@@ -0,0 +1,210 @@ ++[ ++ { ++ "input_target_json": "{\"value\":true}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": true ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":true}}", ++ "native_digest": "sha256:e1f537daa849e5da023efb5f3d62cc33d94cce20f9ca9c5a708c76e225fd702f" ++ }, ++ { ++ "input_target_json": "{\"value\":1}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1}}", ++ "native_digest": "sha256:3a3d2da6267e4b917505a7c2247e6ce36fd8ee074eef37251bcc66839b602332" ++ }, ++ { ++ "input_target_json": "{\"value\":1.0}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1.0 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1.0}}", ++ "native_digest": "sha256:21c2cdb9b161071ebfe361e50703511b2abf6c6f327f153282d247aa92c3898e" ++ }, ++ { ++ "input_target_json": "{\"value\":false}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": false ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":false}}", ++ "native_digest": "sha256:278cbcd013f7923db5258dd860f911d688505a8259f15ccb77983b044f312c74" ++ }, ++ { ++ "input_target_json": "{\"value\":0}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 0 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":0}}", ++ "native_digest": "sha256:e691b8fb55d4bf82a310dee37c045331100c8031e26ea6f51bdcceeeb3b1f20e" ++ }, ++ { ++ "input_target_json": "{\"value\":-0.0}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": -0.0 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":-0.0}}", ++ "native_digest": "sha256:eccde458c686bff67506bb2d94c750e648a311bf23967cb22b37f11e84b6844f" ++ }, ++ { ++ "input_target_json": "{\"value\":1e-7}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1e-07 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e-07}}", ++ "native_digest": "sha256:779042b6c0e675f6aacdcd3a02199aab66f534bb84dd410a95c18657afae563c" ++ }, ++ { ++ "input_target_json": "{\"value\":1e-6}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1e-06 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e-06}}", ++ "native_digest": "sha256:d021f402231fcd59e764b31e6f08a5298ebb328dfd93da58ecde8feb11fde0b5" ++ }, ++ { ++ "input_target_json": "{\"value\":1e20}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1e+20 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e+20}}", ++ "native_digest": "sha256:14270ffca4ab10e9d9302d52e988b4aa3e2facd494c12a60e2edafef70277a20" ++ }, ++ { ++ "input_target_json": "{\"value\":1e21}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1e+21 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e+21}}", ++ "native_digest": "sha256:75a60a595b2578430b0be466b6bf9a46b62ddc99bbbcebd7fa0e8d22e3078c0a" ++ }, ++ { ++ "input_target_json": "{\"value\":2.5e-7}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 2.5e-07 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":2.5e-07}}", ++ "native_digest": "sha256:50af37ff75a3e7a22629e746f814995c0c2cd96adb7f1ed5b26999b71d0ac498" ++ }, ++ { ++ "input_target_json": "{\"value\":9007199254740993}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 9007199254740993 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":9007199254740993}}", ++ "native_digest": "sha256:765360dc5a1d949bd114c6b749d83069123d8b4b6b1d939f831428a800f50dab" ++ }, ++ { ++ "input_target_json": "{\"html\":\"<>&\", \"unicode\":\"é雪😀\", \"separator\":\"\\u2028\\u2029\"}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "html": "<>&", ++ "unicode": "é雪😀", ++ "separator": "

" ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"html\":\"<>&\",\"separator\":\"

\",\"unicode\":\"é雪😀\"}}", ++ "native_digest": "sha256:c203d934743e2954342038a3746b6c37a0f3101e886ef855b1c6ad824adf5dea" ++ }, ++ { ++ "input_target_json": "{\"b\":[true,1,1.0,null,{\"z\":\"x\",\"a\":\"y\"}],\"a\":{}}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "b": [ ++ true, ++ 1, ++ 1.0, ++ null, ++ { ++ "z": "x", ++ "a": "y" ++ } ++ ], ++ "a": {} ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"a\":{},\"b\":[true,1,1.0,null,{\"a\":\"y\",\"z\":\"x\"}]}}", ++ "native_digest": "sha256:56306505c882af6edfae5f3d2a5b42e92737a0859236fe5574a21d1874655d51" ++ } ++] +diff --git a/tests/test_trusted_successor.py b/tests/test_trusted_successor.py +new file mode 100644 +index 0000000..3c47a1d +--- /dev/null ++++ b/tests/test_trusted_successor.py +@@ -0,0 +1,80 @@ ++"""Owner signature interoperability, independent synthetic admission only.""" ++import copy ++import json ++from pathlib import Path ++import subprocess ++ ++import pytest ++from secrets_engine.decision_signature import authenticate_response,load_trust ++from secrets_engine.errors import DecisionError ++from tests.test_decision_signature import FIXTURE,trust_file,verifier ++from tests.signature_support import signature_tools ++from tests.test_task_execution_handoff import lane,prepared,consume_task_execution ++ ++VECTORS=json.loads((Path(__file__).parent/'fixtures/pdp-signature/approval-native-vectors.json').read_text()) ++ ++@pytest.mark.parametrize('vector',VECTORS) ++def test_fourteen_owner_native_types_retain_signed_bytes(vector,trust_file,signature_tools): ++ env=json.loads(FIXTURE.read_text());env.pop('signature',None) ++ env['diagnostics']={'native_vector':'REPLACE_RAW_TARGET'} ++ raw=json.dumps(env).replace('"REPLACE_RAW_TARGET"',vector['input_target_json']).encode() ++ signed=subprocess.run([str(signature_tools/'sign')],input=raw,capture_output=True,check=True).stdout ++ result=authenticate_response(signed,trust=load_trust(trust_file),verifier=signature_tools/'verify') ++ again=authenticate_response(result.authenticated_json.encode(),trust=load_trust(trust_file),verifier=signature_tools/'verify') ++ assert again['diagnostics']['native_vector']==json.loads(vector['input_target_json']) ++ tampered=signed.replace(b'"action":"rotate"',b'"action":"destroy"') ++ assert tampered!=signed ++ with pytest.raises(DecisionError):authenticate_response(tampered,trust=load_trust(trust_file),verifier=signature_tools/'verify') ++ ++ ++def test_unknown_provenance_refuses(trust_file,signature_tools): ++ env=json.loads(FIXTURE.read_text());env['provenance']['unknown_source_authority']=True ++ with pytest.raises(DecisionError): ++ authenticate_response(json.dumps(env).encode(),trust=load_trust(trust_file),verifier=signature_tools/'verify') ++ ++ ++def test_exact_original_numeric_lexeme_survives_strict_cas(lane,monkeypatch,signature_tools): ++ import tests.test_task_execution_handoff as fixtures ++ cfg,e,contract,state=lane ++ def raw_sign(env,tools): ++ env['diagnostics']={'numeric':'LEXEME'} ++ raw=json.dumps(env).replace('"LEXEME"','1e-7').encode() ++ return subprocess.run([str(tools/'sign')],input=raw,capture_output=True,check=True).stdout ++ monkeypatch.setattr(fixtures,'sign_envelope',raw_sign) ++ authorization=prepared(lane) ++ assert '1e-7' in authorization._action.artifacts ++ assert consume_task_execution(cfg,authorization).idempotent is False ++ ++ ++def source_evidence(envelope,claim): ++ from datetime import datetime,timezone ++ return {'source_id':'synthetic-source','source_revision':'synthetic-revision', ++ 'configuration_digest':'sha256:'+'2'*64,'claim_digest':'sha256:'+'3'*64, ++ 'native_binding_digest':claim['binding']['digest'], ++ 'observed_at':datetime.now(timezone.utc).isoformat(), ++ 'validated_at':datetime.now(timezone.utc).isoformat(), ++ 'not_after':envelope['lifetime']['expires_at']} ++ ++ ++def test_signed_source_evidence_keeps_original_context_and_cas(lane): ++ cfg,e,contract,state=lane ++ state['decision_change']=lambda env:env['provenance'].__setitem__('approval_observation',source_evidence(env,state['claim'])) ++ authorization=prepared(lane) ++ snapshot=json.loads(authorization._action.artifacts) ++ assert snapshot['envelope']['binding']['context']==snapshot['request']['context'] ++ assert snapshot['request']['context']['approval']==state['claim'] ++ assert consume_task_execution(cfg,authorization).idempotent is False ++ ++ ++@pytest.mark.parametrize('field,value',[ ++ ('source_id',''),('source_revision',''),('configuration_digest','wrong'),('claim_digest','wrong'), ++ ('native_binding_digest','sha256:'+'0'*64),('observed_at','2099-01-01T00:00:00Z'), ++ ('validated_at','2099-01-01T00:00:00Z'),('not_after','2000-01-01T00:00:00Z')]) ++def test_signed_invalid_source_evidence_refuses_before_cas(lane,field,value): ++ cfg,e,contract,state=lane ++ def change(env): ++ evidence=source_evidence(env,state['claim']);evidence[field]=value ++ env['provenance']['approval_observation']=evidence ++ state['decision_change']=change ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] +diff --git a/verifier/README.md b/verifier/README.md +index a63b2a0..6a1b56b 100644 +--- a/verifier/README.md ++++ b/verifier/README.md +@@ -1,7 +1,7 @@ + # Access Engine decision verifier + + The API types and `internal/sign/sign.go` are unchanged producer source, pinned +-by revision and SHA-256 in `producer-source.json`. License attribution and the ++by source revision/proposal and SHA-256 in `producer-source.json`. License attribution and the + original license text are in `LICENSE.access-engine`. This is source vendoring + necessitated by the producer's Go `internal` package boundary, not a separately + maintained crypto implementation. There are no external Go dependencies. +@@ -33,3 +33,15 @@ For the complete reproducible CLI build and offline fresh-prefix installation, + use [the release bundle contract](../docs/cli-release-bundle.md). This replaces + manual helper copying for packaged deployments; package and key admission remain + separate owner decisions. ++ ++The trusted-service successor snapshot is the frozen Access B2/B3 R1 proposal, ++identified by its patch SHA-256 in `producer-source.json`; this is source review, ++not a claim of published producer revision or runtime/key admission. The wrapper ++uses producer-owned `sign.DecodeEnvelope` to preserve JSON numeric lexemes in ++signed maps. It retains exact authenticated producer JSON through strict ++preparation and CAS reauthentication; Python does not recreate signed bytes. ++Approval's fourteen native binding vectors are signature interoperability cases, ++not an expansion of existing request numeric admission. Signed ++`approval_observation` supplements the original immutable carried claim; ++strict handoff checks its native digest and observation/deadline bounds. It never ++replaces original context or freshness. +diff --git a/verifier/internal/sign/sign.go b/verifier/internal/sign/sign.go +index fc607f7..011f8f7 100644 +--- a/verifier/internal/sign/sign.go ++++ b/verifier/internal/sign/sign.go +@@ -4,10 +4,12 @@ + package sign + + import ( ++ "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" ++ "io" + + "github.com/netkingdom/access-engine/pkg/api" + ) +@@ -74,3 +76,20 @@ func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) er + } + return nil + } ++ ++// DecodeEnvelope preserves JSON numeric kinds/lexemes emitted by task inputs. ++// Legacy producer envelopes remain byte-compatible. It does not invent another ++// signature canonicalization: CanonicalBytes still calls typed json.Marshal. ++func DecodeEnvelope(raw []byte) (api.DecisionEnvelope, error) { ++ var envelope api.DecisionEnvelope ++ decoder := json.NewDecoder(bytes.NewReader(raw)) ++ decoder.UseNumber() ++ decoder.DisallowUnknownFields() ++ if err := decoder.Decode(&envelope); err != nil { ++ return envelope, err ++ } ++ if _, err := decoder.Token(); err != io.EOF { ++ return api.DecisionEnvelope{}, fmt.Errorf("trailing envelope data") ++ } ++ return envelope, nil ++} +diff --git a/verifier/main.go b/verifier/main.go +index 1f3f9de..7449c64 100644 +--- a/verifier/main.go ++++ b/verifier/main.go +@@ -2,13 +2,11 @@ + package main + + import ( +- "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/netkingdom/access-engine/internal/sign" +- "github.com/netkingdom/access-engine/pkg/api" + "io" + "os" + ) +@@ -23,10 +21,8 @@ func run() error { + if err := json.NewDecoder(io.LimitReader(os.Stdin, 1048577)).Decode(&in); err != nil { + return err + } +- var envelope api.DecisionEnvelope +- decoder := json.NewDecoder(bytes.NewReader(in.Envelope)) +- decoder.DisallowUnknownFields() +- if err := decoder.Decode(&envelope); err != nil { ++ envelope, err := sign.DecodeEnvelope(in.Envelope) ++ if err != nil { + return err + } + keys := map[string]ed25519.PublicKey{} +diff --git a/verifier/pkg/api/canonical.go b/verifier/pkg/api/canonical.go +index 3baa784..ae11a2d 100644 +--- a/verifier/pkg/api/canonical.go ++++ b/verifier/pkg/api/canonical.go +@@ -635,16 +635,30 @@ type Obligation struct { + + // DecisionProvenance captures evaluator and policy provenance. + type DecisionProvenance struct { +- Evaluator string `json:"evaluator" yaml:"evaluator"` +- Mode string `json:"mode" yaml:"mode"` +- PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"` +- PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` +- PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"` +- RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"` +- DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"` +- InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"` +- DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"` +- Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"` ++ Evaluator string `json:"evaluator" yaml:"evaluator"` ++ Mode string `json:"mode" yaml:"mode"` ++ PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"` ++ PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` ++ PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"` ++ RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"` ++ DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"` ++ InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"` ++ DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"` ++ ApprovalObservation *ApprovalObservationProvenance `json:"approval_observation,omitempty" yaml:"approval_observation,omitempty"` ++ Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"` ++} ++ ++// ApprovalObservationProvenance binds fresh server-owned fact evidence without ++// rewriting the original carried claim/context or either PDP request digest. ++type ApprovalObservationProvenance struct { ++ SourceID string `json:"source_id"` ++ SourceRevision string `json:"source_revision"` ++ ConfigurationDigest string `json:"configuration_digest"` ++ ClaimDigest string `json:"claim_digest"` ++ NativeBindingDigest string `json:"native_binding_digest"` ++ ObservedAt string `json:"observed_at"` ++ NotAfter string `json:"not_after"` ++ ValidatedAt string `json:"validated_at"` + } + + // CallerProvenance records how the request was authenticated to flex-auth. +diff --git a/verifier/producer-source.json b/verifier/producer-source.json +index b6b0cba..99e651d 100644 +--- a/verifier/producer-source.json ++++ b/verifier/producer-source.json +@@ -1,15 +1,19 @@ + { + "upstream": "https://github.com/netkingdom/access-engine", +- "revision": "62ac2b4e99dec2005c7c407fe633612578a7907d", ++ "revision": "B2/B3 R1 source proposal over B1 R2; canonical native-prep base 919ce25", + "files": { +- "pkg/api/canonical.go": "21e754617c2b82d673cef31b7075f8d177e4367782b63b20b37125587bf9e300", ++ "pkg/api/canonical.go": "a122a2e8cd524333b13c5939c7a3685e41efbe47497a64e0c7297c92b7f10d19", + "pkg/api/caring.go": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3", + "pkg/api/resource_manifest.go": "f0fa4080287d389ed48c2b1d6af96cc62e57ef72c902333f677f964420be133f", + "pkg/api/doc.go": "0638f06ae47369899dd1e2dd5dd23a7200e7dbb3b505b04f4d3c78c0a074def9", +- "internal/sign/sign.go": "dad2d0c9f6d1fb2fd68e774949ccdf954382acd075191b5ddfea2c3cc2f4cb62" ++ "internal/sign/sign.go": "0df9515922e90775ebbfac798361dcafb20da8a4213796b44e246b8a92046cde" + }, + "fixtures": { + "decision_rotate_signed.json": "a7bc499f3583f53551cf163109a3c108188345100176092adc31eccefc222fd8", + "decision_rotate_signed_tampered.json": "7f2d2e9372a9617bd4b197033f09a5e33a08b202046de8109582da1dc90cb527" ++ }, ++ "source_proposal": { ++ "patch_sha256": "6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1", ++ "status": "source proposal; no runtime or key admission" + } + } +diff --git a/verifier/testsupport/signer/main.go b/verifier/testsupport/signer/main.go +index 2a7fecd..994ea82 100644 +--- a/verifier/testsupport/signer/main.go ++++ b/verifier/testsupport/signer/main.go +@@ -6,7 +6,7 @@ import ( + "crypto/ed25519" + "encoding/json" + "github.com/netkingdom/access-engine/internal/sign" +- "github.com/netkingdom/access-engine/pkg/api" ++ "io" + "os" + ) + +@@ -17,8 +17,12 @@ func main() { + seed = 0x43 + kid = "next-admitted" + } +- var env api.DecisionEnvelope +- if json.NewDecoder(os.Stdin).Decode(&env) != nil { ++ raw, err := io.ReadAll(os.Stdin) ++ if err != nil { ++ os.Exit(1) ++ } ++ env, err := sign.DecodeEnvelope(raw) ++ if err != nil { + os.Exit(1) + } + if sign.Sign(&env, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, 32)), kid) != nil { +diff --git a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md +index 28102cf..8d42c05 100644 +--- a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md ++++ b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md +@@ -114,3 +114,18 @@ producer candidate OCI and actual runtime/config placement remain missing. The + empty-key trust template deliberately refuses. No install, key read, caller mint, + protected effect, policy switch or trust admission occurred. T02 remains progress, + T03 wait; task source/live obligations remain separately in SECRETS-WP-0013. ++ ++2026-10-16 trusted successor source preparation: refreshed exact API/sign source ++from frozen Access B2/B3 R1 proposal (patch SHA-256 ++6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1). ++Producer DecodeEnvelope preserves signed numeric lexemes; strict CAS retains and ++reauthenticates original producer JSON. Existing request/claim/native/caller joins ++remain mandatory; signed source observation additionally binds the native digest ++and caps decision lifetime. Fourteen Approval owner vectors, legacy signatures, ++tamper/unknown provenance and source deadline refusals are locally checked. ++Actual ApprovalApp-to-Access v2 signed response verifies with the consumer helper; ++actual source revocation refuses. Actual builder-to-v2 strict positive execution ++is separately blocked on missing owner-reviewed service_actor_binding mapping; ++no caller context injection or equality relaxation hides that gap. ++Artifacts: Custodian `decision-trusted-successor/`. T02 remains progress, T03 ++wait; no package/key/policy/native admission or runtime installation occurred. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r2-full.patch b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r2-full.patch new file mode 100644 index 00000000..2f230236 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r2-full.patch @@ -0,0 +1,749 @@ +diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py +index 1cf4140..e4865bb 100644 +--- a/src/secrets_engine/approval_consume.py ++++ b/src/secrets_engine/approval_consume.py +@@ -382,7 +382,8 @@ def authorize_action( + ), + decision_id=validated.decision_id, + expires_at=validated.expires_at, +- artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope}, ++ artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope, ++ "authenticated_envelope_json":getattr(envelope,"authenticated_json","")}, + sort_keys=True,separators=(",",":"),allow_nan=False), + ) + +diff --git a/src/secrets_engine/decision_signature.py b/src/secrets_engine/decision_signature.py +index 24fa8d3..d9bd0b9 100644 +--- a/src/secrets_engine/decision_signature.py ++++ b/src/secrets_engine/decision_signature.py +@@ -7,6 +7,7 @@ from __future__ import annotations + + import base64 + import json ++import math + from pathlib import Path + import stat + import subprocess +@@ -24,8 +25,15 @@ def _unique_object(pairs): + return result + + ++def _finite_float(value): ++ parsed = float(value) ++ if not math.isfinite(parsed): ++ raise ValueError("nonfinite JSON number") ++ return parsed ++ ++ + def strict_json(data): +- return json.loads(data, object_pairs_hook=_unique_object, ++ return json.loads(data, object_pairs_hook=_unique_object, parse_float=_finite_float, + parse_constant=lambda value: (_ for _ in ()).throw(ValueError("invalid number"))) + + +@@ -66,10 +74,18 @@ def load_trust(path: Path | None) -> dict[str, Any]: + raise DecisionError("access-engine responder trust is missing or invalid") from exc + + ++class AuthenticatedEnvelope(dict): ++ """Verified typed values with exact producer JSON retained for reauthentication.""" ++ def __init__(self, values, authenticated_json): ++ super().__init__(values) ++ self.authenticated_json = authenticated_json ++ ++ + def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) -> dict: + if verifier is None or not Path(verifier).is_absolute(): + raise DecisionError("access-engine signature verifier is not installed") + try: ++ strict_json(body.decode("utf-8")) + result = subprocess.run([str(verifier)], input=b'{"envelope":'+body+b',"keys":'+json.dumps(trust['keys']).encode()+b'}', + capture_output=True, env={}, timeout=3, check=False) + if result.returncode != 0 or len(result.stdout) > 524288: +@@ -78,6 +94,6 @@ def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) -> + provenance = authenticated['provenance'] + if any(provenance.get(k) != trust[k] for k in ('evaluator','mode','policy_package','policy_version','policy_package_digest')): + raise ValueError("issuer/pin mismatch") +- return authenticated +- except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError) as exc: ++ return AuthenticatedEnvelope(authenticated, result.stdout.decode("utf-8")) ++ except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError, UnicodeDecodeError) as exc: + raise DecisionError("access-engine responder authentication failed") from exc +diff --git a/src/secrets_engine/execution_contract.py b/src/secrets_engine/execution_contract.py +index 5dc1cae..f9b2538 100644 +--- a/src/secrets_engine/execution_contract.py ++++ b/src/secrets_engine/execution_contract.py +@@ -153,6 +153,18 @@ def attach_execution_contract(request: dict, contract: ExecutionContract | None) + raise DecisionError('execute-ccr requires platform tenant') + if value['catalog_sha256'] != catalog_target_digest(request['context']['catalog_target']): + raise DecisionError('execution_contract catalog target does not match') ++ if request['subject'] != {'id':'secrets-engine','type':'service'}: ++ raise DecisionError('execute-ccr requires secrets-engine service subject') ++ # Match the producer's fixed tenant normalization before approval issue. ++ request['subject']['tenant'] = 'tenant:platform' ++ request['resource']['tenant'] = 'tenant:platform' ++ # Public declaration from Access ServicePilotInputs.DeclaredServiceBinding; ++ # bound before approval issuance, never an authentication assertion. ++ request['context']['service_actor_binding'] = { ++ 'version':'proposed-service-task-caller/v1', ++ 'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine', ++ 'subject_id':'secrets-engine','subject_type':'service','tenant':'tenant:platform', ++ } + request['context']['execution_contract'] = value + request['context']['human_control'] = True + +diff --git a/src/secrets_engine/task_execution_handoff.py b/src/secrets_engine/task_execution_handoff.py +index 9772861..5af36be 100644 +--- a/src/secrets_engine/task_execution_handoff.py ++++ b/src/secrets_engine/task_execution_handoff.py +@@ -10,6 +10,7 @@ from __future__ import annotations + from dataclasses import dataclass + from datetime import datetime + import json ++import re + from threading import Lock + + from secrets_engine.approval_auth import approval_token, credential_urlopen +@@ -85,10 +86,10 @@ def _validate(cfg, action, contract): + request,claim,envelope=(artifacts[name] for name in ('request','claim','envelope')) + # Rotation/removal and installed-helper changes cannot hide behind a cached + # prepared label. Reauthenticate before each strict CAS, without networking. +- authenticated=authenticate_response(json.dumps(envelope,allow_nan=False).encode(), ++ authenticated=authenticate_response(artifacts["authenticated_envelope_json"].encode("utf-8"), + trust=load_trust(getattr(cfg,'pdp_trust_file',None)), + verifier=getattr(cfg,'pdp_verifier',None)) +- if authenticated != envelope: ++ if json.dumps(authenticated,sort_keys=True,allow_nan=False) != json.dumps(envelope,sort_keys=True,allow_nan=False): + _refuse() + window=read_window(cfg) + lower,upper=validity_bounds(window=window) +@@ -115,7 +116,7 @@ def _validate(cfg, action, contract): + or action.binding.decision_id!=validated.decision_id): + _refuse() + subject=binding['subject']; resource=binding['resource'] +- if (request['subject']!={'id':'secrets-engine','type':'service'} ++ if (request['subject']!={'id':'secrets-engine','type':'service','tenant':'tenant:platform'} + or subject.get('id')!='secrets-engine' or subject.get('type')!='service' + or subject.get('tenant') not in (None,'tenant:platform') + or 'principal' in subject.get('attributes',{}) +@@ -140,6 +141,22 @@ def _validate(cfg, action, contract): + or supplied.get('digest')!=claim_binding_digest(**native) + or supplied.get('digest')!=claim_binding_digest(**{key:supplied.get(key) for key in _NATIVE_FIELDS})): + _refuse() ++ observation=envelope['provenance'].get('approval_observation') ++ if observation is not None: ++ # Signed producer source evidence supplements the original carried claim; ++ # it cannot replace original context, extend freshness, or authorize CAS. ++ if (not isinstance(observation,dict) ++ or any(not isinstance(observation.get(k),str) or not observation[k] ++ for k in ('source_id','source_revision')) ++ or any(not isinstance(observation.get(k),str) ++ or re.fullmatch(r'sha256:[0-9a-f]{64}',observation[k]) is None ++ for k in ('configuration_digest','claim_digest','native_binding_digest')) ++ or observation['native_binding_digest']!=supplied['digest'] ++ or _stamp(observation.get('observed_at'))>_stamp(observation.get('validated_at')) ++ or _stamp(observation.get('validated_at'))>upper ++ or upper>=_stamp(observation.get('not_after')) ++ or _stamp(envelope['lifetime']['expires_at'])>_stamp(observation.get('not_after'))): ++ _refuse() + return {'execution_contract':descriptor,'approval_id':action.binding.approval_id, + 'request_digest':validated.request_digest,'decision_id':validated.decision_id, + 'native_binding':native,'approval_binding_digest':binding['approval_binding_digest'], +diff --git a/tests/fixtures/pdp-signature/approval-native-vectors.json b/tests/fixtures/pdp-signature/approval-native-vectors.json +new file mode 100644 +index 0000000..46adb26 +--- /dev/null ++++ b/tests/fixtures/pdp-signature/approval-native-vectors.json +@@ -0,0 +1,210 @@ ++[ ++ { ++ "input_target_json": "{\"value\":true}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": true ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":true}}", ++ "native_digest": "sha256:e1f537daa849e5da023efb5f3d62cc33d94cce20f9ca9c5a708c76e225fd702f" ++ }, ++ { ++ "input_target_json": "{\"value\":1}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1}}", ++ "native_digest": "sha256:3a3d2da6267e4b917505a7c2247e6ce36fd8ee074eef37251bcc66839b602332" ++ }, ++ { ++ "input_target_json": "{\"value\":1.0}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1.0 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1.0}}", ++ "native_digest": "sha256:21c2cdb9b161071ebfe361e50703511b2abf6c6f327f153282d247aa92c3898e" ++ }, ++ { ++ "input_target_json": "{\"value\":false}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": false ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":false}}", ++ "native_digest": "sha256:278cbcd013f7923db5258dd860f911d688505a8259f15ccb77983b044f312c74" ++ }, ++ { ++ "input_target_json": "{\"value\":0}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 0 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":0}}", ++ "native_digest": "sha256:e691b8fb55d4bf82a310dee37c045331100c8031e26ea6f51bdcceeeb3b1f20e" ++ }, ++ { ++ "input_target_json": "{\"value\":-0.0}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": -0.0 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":-0.0}}", ++ "native_digest": "sha256:eccde458c686bff67506bb2d94c750e648a311bf23967cb22b37f11e84b6844f" ++ }, ++ { ++ "input_target_json": "{\"value\":1e-7}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1e-07 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e-07}}", ++ "native_digest": "sha256:779042b6c0e675f6aacdcd3a02199aab66f534bb84dd410a95c18657afae563c" ++ }, ++ { ++ "input_target_json": "{\"value\":1e-6}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1e-06 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e-06}}", ++ "native_digest": "sha256:d021f402231fcd59e764b31e6f08a5298ebb328dfd93da58ecde8feb11fde0b5" ++ }, ++ { ++ "input_target_json": "{\"value\":1e20}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1e+20 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e+20}}", ++ "native_digest": "sha256:14270ffca4ab10e9d9302d52e988b4aa3e2facd494c12a60e2edafef70277a20" ++ }, ++ { ++ "input_target_json": "{\"value\":1e21}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1e+21 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e+21}}", ++ "native_digest": "sha256:75a60a595b2578430b0be466b6bf9a46b62ddc99bbbcebd7fa0e8d22e3078c0a" ++ }, ++ { ++ "input_target_json": "{\"value\":2.5e-7}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 2.5e-07 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":2.5e-07}}", ++ "native_digest": "sha256:50af37ff75a3e7a22629e746f814995c0c2cd96adb7f1ed5b26999b71d0ac498" ++ }, ++ { ++ "input_target_json": "{\"value\":9007199254740993}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 9007199254740993 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":9007199254740993}}", ++ "native_digest": "sha256:765360dc5a1d949bd114c6b749d83069123d8b4b6b1d939f831428a800f50dab" ++ }, ++ { ++ "input_target_json": "{\"html\":\"<>&\", \"unicode\":\"é雪😀\", \"separator\":\"\\u2028\\u2029\"}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "html": "<>&", ++ "unicode": "é雪😀", ++ "separator": "

" ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"html\":\"<>&\",\"separator\":\"

\",\"unicode\":\"é雪😀\"}}", ++ "native_digest": "sha256:c203d934743e2954342038a3746b6c37a0f3101e886ef855b1c6ad824adf5dea" ++ }, ++ { ++ "input_target_json": "{\"b\":[true,1,1.0,null,{\"z\":\"x\",\"a\":\"y\"}],\"a\":{}}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "b": [ ++ true, ++ 1, ++ 1.0, ++ null, ++ { ++ "z": "x", ++ "a": "y" ++ } ++ ], ++ "a": {} ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"a\":{},\"b\":[true,1,1.0,null,{\"a\":\"y\",\"z\":\"x\"}]}}", ++ "native_digest": "sha256:56306505c882af6edfae5f3d2a5b42e92737a0859236fe5574a21d1874655d51" ++ } ++] +diff --git a/tests/test_execution_context.py b/tests/test_execution_context.py +index 23b6419..70d9415 100644 +--- a/tests/test_execution_context.py ++++ b/tests/test_execution_context.py +@@ -133,7 +133,7 @@ def test_closed_schema_refuses(mutation): + + @pytest.mark.parametrize('action', ['apply','rotate','exec','execute-ccr']) + def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action): +- e=entry(); cfg=_Cfg(_token(tmp_path)); calls=[] ++ e=entry(); cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[] + def fetch(*args,**kwargs): calls.append('claim'); raise AssertionError + contract=None if action=='execute-ccr' else ExecutionContract.parse(descriptor(e)) + with pytest.raises(DecisionError): +@@ -143,7 +143,7 @@ def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action) + + def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path): + e=entry(); typed=ExecutionContract.parse(descriptor(e)); e=replace(e,path='changed/path') +- cfg=_Cfg(_token(tmp_path)); calls=[] ++ cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[] + with pytest.raises(DecisionError,match='catalog'): + resolve_approval_observation(cfg,e,'execute-ccr',None,execution_contract=typed,opener=lambda *a,**k:calls.append('claim')) + assert not calls +@@ -154,7 +154,7 @@ def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path): + + def test_expired_contract_refuses_before_claim(tmp_path): + e=entry(); raw=descriptor(e); raw['operation_deadline']=(datetime.now(timezone.utc)-timedelta(seconds=1)).isoformat() +- cfg=_Cfg(_token(tmp_path)); calls=[] ++ cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[] + with pytest.raises(DecisionError,match='expired'): + authorize_action(cfg,e,'execute-ccr',execution_contract=ExecutionContract.parse(raw),opener=lambda *a,**k:calls.append('claim')) + assert not calls +@@ -200,3 +200,22 @@ def test_deadline_beyond_claim_refuses_before_pdp(tmp_path): + from tests.test_consume_binding_join import admitted_native_signer + + from tests.signature_support import signature_tools ++ ++ ++def test_execution_service_declaration_is_fixed_before_issue(): ++ e=entry();contract=ExecutionContract.parse(descriptor(e)) ++ built=request(e,contract) ++ assert built['context']['service_actor_binding']=={ ++ 'version':'proposed-service-task-caller/v1', ++ 'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine', ++ 'subject_id':'secrets-engine','subject_type':'service','tenant':'tenant:platform'} ++ assert built['subject']=={'id':'secrets-engine','type':'service','tenant':'tenant:platform'} ++ assert built['resource']['tenant']=='tenant:platform' ++ assert 'service_actor_binding' not in build_action_request(e,'apply',subject_id='secrets-engine',subject_type='service',purpose='metadata-test')['context'] ++ ++ ++@pytest.mark.parametrize('subject_id,subject_type',[('other','service'),('secrets-engine','Human'),('user:alice','Human')]) ++def test_execution_declaration_cannot_assert_foreign_service(subject_id,subject_type): ++ e=entry();contract=ExecutionContract.parse(descriptor(e)) ++ with pytest.raises(DecisionError,match='service subject'): ++ build_action_request(e,'execute-ccr',subject_id=subject_id,subject_type=subject_type,purpose='metadata-test',execution_contract=contract) +diff --git a/tests/test_trusted_successor.py b/tests/test_trusted_successor.py +new file mode 100644 +index 0000000..3c47a1d +--- /dev/null ++++ b/tests/test_trusted_successor.py +@@ -0,0 +1,80 @@ ++"""Owner signature interoperability, independent synthetic admission only.""" ++import copy ++import json ++from pathlib import Path ++import subprocess ++ ++import pytest ++from secrets_engine.decision_signature import authenticate_response,load_trust ++from secrets_engine.errors import DecisionError ++from tests.test_decision_signature import FIXTURE,trust_file,verifier ++from tests.signature_support import signature_tools ++from tests.test_task_execution_handoff import lane,prepared,consume_task_execution ++ ++VECTORS=json.loads((Path(__file__).parent/'fixtures/pdp-signature/approval-native-vectors.json').read_text()) ++ ++@pytest.mark.parametrize('vector',VECTORS) ++def test_fourteen_owner_native_types_retain_signed_bytes(vector,trust_file,signature_tools): ++ env=json.loads(FIXTURE.read_text());env.pop('signature',None) ++ env['diagnostics']={'native_vector':'REPLACE_RAW_TARGET'} ++ raw=json.dumps(env).replace('"REPLACE_RAW_TARGET"',vector['input_target_json']).encode() ++ signed=subprocess.run([str(signature_tools/'sign')],input=raw,capture_output=True,check=True).stdout ++ result=authenticate_response(signed,trust=load_trust(trust_file),verifier=signature_tools/'verify') ++ again=authenticate_response(result.authenticated_json.encode(),trust=load_trust(trust_file),verifier=signature_tools/'verify') ++ assert again['diagnostics']['native_vector']==json.loads(vector['input_target_json']) ++ tampered=signed.replace(b'"action":"rotate"',b'"action":"destroy"') ++ assert tampered!=signed ++ with pytest.raises(DecisionError):authenticate_response(tampered,trust=load_trust(trust_file),verifier=signature_tools/'verify') ++ ++ ++def test_unknown_provenance_refuses(trust_file,signature_tools): ++ env=json.loads(FIXTURE.read_text());env['provenance']['unknown_source_authority']=True ++ with pytest.raises(DecisionError): ++ authenticate_response(json.dumps(env).encode(),trust=load_trust(trust_file),verifier=signature_tools/'verify') ++ ++ ++def test_exact_original_numeric_lexeme_survives_strict_cas(lane,monkeypatch,signature_tools): ++ import tests.test_task_execution_handoff as fixtures ++ cfg,e,contract,state=lane ++ def raw_sign(env,tools): ++ env['diagnostics']={'numeric':'LEXEME'} ++ raw=json.dumps(env).replace('"LEXEME"','1e-7').encode() ++ return subprocess.run([str(tools/'sign')],input=raw,capture_output=True,check=True).stdout ++ monkeypatch.setattr(fixtures,'sign_envelope',raw_sign) ++ authorization=prepared(lane) ++ assert '1e-7' in authorization._action.artifacts ++ assert consume_task_execution(cfg,authorization).idempotent is False ++ ++ ++def source_evidence(envelope,claim): ++ from datetime import datetime,timezone ++ return {'source_id':'synthetic-source','source_revision':'synthetic-revision', ++ 'configuration_digest':'sha256:'+'2'*64,'claim_digest':'sha256:'+'3'*64, ++ 'native_binding_digest':claim['binding']['digest'], ++ 'observed_at':datetime.now(timezone.utc).isoformat(), ++ 'validated_at':datetime.now(timezone.utc).isoformat(), ++ 'not_after':envelope['lifetime']['expires_at']} ++ ++ ++def test_signed_source_evidence_keeps_original_context_and_cas(lane): ++ cfg,e,contract,state=lane ++ state['decision_change']=lambda env:env['provenance'].__setitem__('approval_observation',source_evidence(env,state['claim'])) ++ authorization=prepared(lane) ++ snapshot=json.loads(authorization._action.artifacts) ++ assert snapshot['envelope']['binding']['context']==snapshot['request']['context'] ++ assert snapshot['request']['context']['approval']==state['claim'] ++ assert consume_task_execution(cfg,authorization).idempotent is False ++ ++ ++@pytest.mark.parametrize('field,value',[ ++ ('source_id',''),('source_revision',''),('configuration_digest','wrong'),('claim_digest','wrong'), ++ ('native_binding_digest','sha256:'+'0'*64),('observed_at','2099-01-01T00:00:00Z'), ++ ('validated_at','2099-01-01T00:00:00Z'),('not_after','2000-01-01T00:00:00Z')]) ++def test_signed_invalid_source_evidence_refuses_before_cas(lane,field,value): ++ cfg,e,contract,state=lane ++ def change(env): ++ evidence=source_evidence(env,state['claim']);evidence[field]=value ++ env['provenance']['approval_observation']=evidence ++ state['decision_change']=change ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] +diff --git a/verifier/README.md b/verifier/README.md +index a63b2a0..c2c3bec 100644 +--- a/verifier/README.md ++++ b/verifier/README.md +@@ -1,7 +1,7 @@ + # Access Engine decision verifier + + The API types and `internal/sign/sign.go` are unchanged producer source, pinned +-by revision and SHA-256 in `producer-source.json`. License attribution and the ++by source revision/proposal and SHA-256 in `producer-source.json`. License attribution and the + original license text are in `LICENSE.access-engine`. This is source vendoring + necessitated by the producer's Go `internal` package boundary, not a separately + maintained crypto implementation. There are no external Go dependencies. +@@ -33,3 +33,23 @@ For the complete reproducible CLI build and offline fresh-prefix installation, + use [the release bundle contract](../docs/cli-release-bundle.md). This replaces + manual helper copying for packaged deployments; package and key admission remain + separate owner decisions. ++ ++The trusted-service successor snapshot is the frozen Access B2/B3 R1 proposal, ++identified by its patch SHA-256 in `producer-source.json`; this is source review, ++not a claim of published producer revision or runtime/key admission. The wrapper ++uses producer-owned `sign.DecodeEnvelope` to preserve JSON numeric lexemes in ++signed maps. It retains exact authenticated producer JSON through strict ++preparation and CAS reauthentication; Python does not recreate signed bytes. ++Approval's fourteen native binding vectors are signature interoperability cases, ++not an expansion of existing request numeric admission. Signed ++`approval_observation` supplements the original immutable carried claim; ++strict handoff checks its native digest and observation/deadline bounds. It never ++replaces original context or freshness. ++ ++Successor R2 execution builder correspondence emits the producer's fixed public ++DeclaredServiceBinding only for the typed prod/platform secrets-engine service ++lane before approval issuance. Both reference tenants are explicit to match ++existing producer normalization. It is not caller authentication; independently ++signed enforced caller, exact original/native/descriptor joins and all existing ++approval, expiry and CAS gates still apply. Actual production source-server ++interoperability receipts are separate from native runtime admission. +diff --git a/verifier/internal/sign/sign.go b/verifier/internal/sign/sign.go +index fc607f7..011f8f7 100644 +--- a/verifier/internal/sign/sign.go ++++ b/verifier/internal/sign/sign.go +@@ -4,10 +4,12 @@ + package sign + + import ( ++ "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" ++ "io" + + "github.com/netkingdom/access-engine/pkg/api" + ) +@@ -74,3 +76,20 @@ func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) er + } + return nil + } ++ ++// DecodeEnvelope preserves JSON numeric kinds/lexemes emitted by task inputs. ++// Legacy producer envelopes remain byte-compatible. It does not invent another ++// signature canonicalization: CanonicalBytes still calls typed json.Marshal. ++func DecodeEnvelope(raw []byte) (api.DecisionEnvelope, error) { ++ var envelope api.DecisionEnvelope ++ decoder := json.NewDecoder(bytes.NewReader(raw)) ++ decoder.UseNumber() ++ decoder.DisallowUnknownFields() ++ if err := decoder.Decode(&envelope); err != nil { ++ return envelope, err ++ } ++ if _, err := decoder.Token(); err != io.EOF { ++ return api.DecisionEnvelope{}, fmt.Errorf("trailing envelope data") ++ } ++ return envelope, nil ++} +diff --git a/verifier/main.go b/verifier/main.go +index 1f3f9de..7449c64 100644 +--- a/verifier/main.go ++++ b/verifier/main.go +@@ -2,13 +2,11 @@ + package main + + import ( +- "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/netkingdom/access-engine/internal/sign" +- "github.com/netkingdom/access-engine/pkg/api" + "io" + "os" + ) +@@ -23,10 +21,8 @@ func run() error { + if err := json.NewDecoder(io.LimitReader(os.Stdin, 1048577)).Decode(&in); err != nil { + return err + } +- var envelope api.DecisionEnvelope +- decoder := json.NewDecoder(bytes.NewReader(in.Envelope)) +- decoder.DisallowUnknownFields() +- if err := decoder.Decode(&envelope); err != nil { ++ envelope, err := sign.DecodeEnvelope(in.Envelope) ++ if err != nil { + return err + } + keys := map[string]ed25519.PublicKey{} +diff --git a/verifier/pkg/api/canonical.go b/verifier/pkg/api/canonical.go +index 3baa784..ae11a2d 100644 +--- a/verifier/pkg/api/canonical.go ++++ b/verifier/pkg/api/canonical.go +@@ -635,16 +635,30 @@ type Obligation struct { + + // DecisionProvenance captures evaluator and policy provenance. + type DecisionProvenance struct { +- Evaluator string `json:"evaluator" yaml:"evaluator"` +- Mode string `json:"mode" yaml:"mode"` +- PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"` +- PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` +- PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"` +- RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"` +- DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"` +- InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"` +- DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"` +- Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"` ++ Evaluator string `json:"evaluator" yaml:"evaluator"` ++ Mode string `json:"mode" yaml:"mode"` ++ PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"` ++ PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` ++ PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"` ++ RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"` ++ DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"` ++ InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"` ++ DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"` ++ ApprovalObservation *ApprovalObservationProvenance `json:"approval_observation,omitempty" yaml:"approval_observation,omitempty"` ++ Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"` ++} ++ ++// ApprovalObservationProvenance binds fresh server-owned fact evidence without ++// rewriting the original carried claim/context or either PDP request digest. ++type ApprovalObservationProvenance struct { ++ SourceID string `json:"source_id"` ++ SourceRevision string `json:"source_revision"` ++ ConfigurationDigest string `json:"configuration_digest"` ++ ClaimDigest string `json:"claim_digest"` ++ NativeBindingDigest string `json:"native_binding_digest"` ++ ObservedAt string `json:"observed_at"` ++ NotAfter string `json:"not_after"` ++ ValidatedAt string `json:"validated_at"` + } + + // CallerProvenance records how the request was authenticated to flex-auth. +diff --git a/verifier/producer-source.json b/verifier/producer-source.json +index b6b0cba..99e651d 100644 +--- a/verifier/producer-source.json ++++ b/verifier/producer-source.json +@@ -1,15 +1,19 @@ + { + "upstream": "https://github.com/netkingdom/access-engine", +- "revision": "62ac2b4e99dec2005c7c407fe633612578a7907d", ++ "revision": "B2/B3 R1 source proposal over B1 R2; canonical native-prep base 919ce25", + "files": { +- "pkg/api/canonical.go": "21e754617c2b82d673cef31b7075f8d177e4367782b63b20b37125587bf9e300", ++ "pkg/api/canonical.go": "a122a2e8cd524333b13c5939c7a3685e41efbe47497a64e0c7297c92b7f10d19", + "pkg/api/caring.go": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3", + "pkg/api/resource_manifest.go": "f0fa4080287d389ed48c2b1d6af96cc62e57ef72c902333f677f964420be133f", + "pkg/api/doc.go": "0638f06ae47369899dd1e2dd5dd23a7200e7dbb3b505b04f4d3c78c0a074def9", +- "internal/sign/sign.go": "dad2d0c9f6d1fb2fd68e774949ccdf954382acd075191b5ddfea2c3cc2f4cb62" ++ "internal/sign/sign.go": "0df9515922e90775ebbfac798361dcafb20da8a4213796b44e246b8a92046cde" + }, + "fixtures": { + "decision_rotate_signed.json": "a7bc499f3583f53551cf163109a3c108188345100176092adc31eccefc222fd8", + "decision_rotate_signed_tampered.json": "7f2d2e9372a9617bd4b197033f09a5e33a08b202046de8109582da1dc90cb527" ++ }, ++ "source_proposal": { ++ "patch_sha256": "6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1", ++ "status": "source proposal; no runtime or key admission" + } + } +diff --git a/verifier/testsupport/signer/main.go b/verifier/testsupport/signer/main.go +index 2a7fecd..994ea82 100644 +--- a/verifier/testsupport/signer/main.go ++++ b/verifier/testsupport/signer/main.go +@@ -6,7 +6,7 @@ import ( + "crypto/ed25519" + "encoding/json" + "github.com/netkingdom/access-engine/internal/sign" +- "github.com/netkingdom/access-engine/pkg/api" ++ "io" + "os" + ) + +@@ -17,8 +17,12 @@ func main() { + seed = 0x43 + kid = "next-admitted" + } +- var env api.DecisionEnvelope +- if json.NewDecoder(os.Stdin).Decode(&env) != nil { ++ raw, err := io.ReadAll(os.Stdin) ++ if err != nil { ++ os.Exit(1) ++ } ++ env, err := sign.DecodeEnvelope(raw) ++ if err != nil { + os.Exit(1) + } + if sign.Sign(&env, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, 32)), kid) != nil { +diff --git a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md +index 28102cf..3109752 100644 +--- a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md ++++ b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md +@@ -114,3 +114,31 @@ producer candidate OCI and actual runtime/config placement remain missing. The + empty-key trust template deliberately refuses. No install, key read, caller mint, + protected effect, policy switch or trust admission occurred. T02 remains progress, + T03 wait; task source/live obligations remain separately in SECRETS-WP-0013. ++ ++2026-10-16 trusted successor source preparation: refreshed exact API/sign source ++from frozen Access B2/B3 R1 proposal (patch SHA-256 ++6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1). ++Producer DecodeEnvelope preserves signed numeric lexemes; strict CAS retains and ++reauthenticates original producer JSON. Existing request/claim/native/caller joins ++remain mandatory; signed source observation additionally binds the native digest ++and caps decision lifetime. Fourteen Approval owner vectors, legacy signatures, ++tamper/unknown provenance and source deadline refusals are locally checked. ++Actual ApprovalApp-to-Access v2 signed response verifies with the consumer helper; ++actual source revocation refuses. Actual builder-to-v2 strict positive execution ++is separately blocked on missing owner-reviewed service_actor_binding mapping; ++no caller context injection or equality relaxation hides that gap. ++Artifacts: Custodian `decision-trusted-successor/`. T02 remains progress, T03 ++wait; no package/key/policy/native admission or runtime installation occurred. ++ ++2026-10-16 successor R2 source preparation resolves the preceding builder gap: ++only typed prod/platform execute-ccr with exact secrets-engine service subject ++emits Access's fixed five-field public service_actor_binding before approval ++issue. Subject/resource platform tenants are explicit before issue, matching the ++producer's existing normalization and native act; lifecycle requests are unchanged. ++Original carried context is still immutable. Actual builder -> Approval App -> ++Access v2 signed ALLOW -> strict preparation/CAS -> independent Approval confirmation ++GET passes with synthetic scoped identities/trust. Missing declaration, descriptor/ ++native changes, stale original claim, signature tamper and replay refuse. The ++operation deadline is explicit whole seconds within v2's 15-second allow TTL; ++no deadline is extended or waived. This resolves source correspondence only; ++T02 remains progress/T03 wait, with no native trust/grant/policy admission. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r2-over-r1.patch b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r2-over-r1.patch new file mode 100644 index 00000000..bda051da --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r2-over-r1.patch @@ -0,0 +1,127 @@ +diff --git a/src/secrets_engine/execution_contract.py b/src/secrets_engine/execution_contract.py +index 5dc1cae..f9b2538 100644 +--- a/src/secrets_engine/execution_contract.py ++++ b/src/secrets_engine/execution_contract.py +@@ -153,6 +153,18 @@ def attach_execution_contract(request: dict, contract: ExecutionContract | None) + raise DecisionError('execute-ccr requires platform tenant') + if value['catalog_sha256'] != catalog_target_digest(request['context']['catalog_target']): + raise DecisionError('execution_contract catalog target does not match') ++ if request['subject'] != {'id':'secrets-engine','type':'service'}: ++ raise DecisionError('execute-ccr requires secrets-engine service subject') ++ # Match the producer's fixed tenant normalization before approval issue. ++ request['subject']['tenant'] = 'tenant:platform' ++ request['resource']['tenant'] = 'tenant:platform' ++ # Public declaration from Access ServicePilotInputs.DeclaredServiceBinding; ++ # bound before approval issuance, never an authentication assertion. ++ request['context']['service_actor_binding'] = { ++ 'version':'proposed-service-task-caller/v1', ++ 'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine', ++ 'subject_id':'secrets-engine','subject_type':'service','tenant':'tenant:platform', ++ } + request['context']['execution_contract'] = value + request['context']['human_control'] = True + +diff --git a/src/secrets_engine/task_execution_handoff.py b/src/secrets_engine/task_execution_handoff.py +index eea6a85..5af36be 100644 +--- a/src/secrets_engine/task_execution_handoff.py ++++ b/src/secrets_engine/task_execution_handoff.py +@@ -116,7 +116,7 @@ def _validate(cfg, action, contract): + or action.binding.decision_id!=validated.decision_id): + _refuse() + subject=binding['subject']; resource=binding['resource'] +- if (request['subject']!={'id':'secrets-engine','type':'service'} ++ if (request['subject']!={'id':'secrets-engine','type':'service','tenant':'tenant:platform'} + or subject.get('id')!='secrets-engine' or subject.get('type')!='service' + or subject.get('tenant') not in (None,'tenant:platform') + or 'principal' in subject.get('attributes',{}) +diff --git a/tests/test_execution_context.py b/tests/test_execution_context.py +index 23b6419..70d9415 100644 +--- a/tests/test_execution_context.py ++++ b/tests/test_execution_context.py +@@ -133,7 +133,7 @@ def test_closed_schema_refuses(mutation): + + @pytest.mark.parametrize('action', ['apply','rotate','exec','execute-ccr']) + def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action): +- e=entry(); cfg=_Cfg(_token(tmp_path)); calls=[] ++ e=entry(); cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[] + def fetch(*args,**kwargs): calls.append('claim'); raise AssertionError + contract=None if action=='execute-ccr' else ExecutionContract.parse(descriptor(e)) + with pytest.raises(DecisionError): +@@ -143,7 +143,7 @@ def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action) + + def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path): + e=entry(); typed=ExecutionContract.parse(descriptor(e)); e=replace(e,path='changed/path') +- cfg=_Cfg(_token(tmp_path)); calls=[] ++ cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[] + with pytest.raises(DecisionError,match='catalog'): + resolve_approval_observation(cfg,e,'execute-ccr',None,execution_contract=typed,opener=lambda *a,**k:calls.append('claim')) + assert not calls +@@ -154,7 +154,7 @@ def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path): + + def test_expired_contract_refuses_before_claim(tmp_path): + e=entry(); raw=descriptor(e); raw['operation_deadline']=(datetime.now(timezone.utc)-timedelta(seconds=1)).isoformat() +- cfg=_Cfg(_token(tmp_path)); calls=[] ++ cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[] + with pytest.raises(DecisionError,match='expired'): + authorize_action(cfg,e,'execute-ccr',execution_contract=ExecutionContract.parse(raw),opener=lambda *a,**k:calls.append('claim')) + assert not calls +@@ -200,3 +200,22 @@ def test_deadline_beyond_claim_refuses_before_pdp(tmp_path): + from tests.test_consume_binding_join import admitted_native_signer + + from tests.signature_support import signature_tools ++ ++ ++def test_execution_service_declaration_is_fixed_before_issue(): ++ e=entry();contract=ExecutionContract.parse(descriptor(e)) ++ built=request(e,contract) ++ assert built['context']['service_actor_binding']=={ ++ 'version':'proposed-service-task-caller/v1', ++ 'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine', ++ 'subject_id':'secrets-engine','subject_type':'service','tenant':'tenant:platform'} ++ assert built['subject']=={'id':'secrets-engine','type':'service','tenant':'tenant:platform'} ++ assert built['resource']['tenant']=='tenant:platform' ++ assert 'service_actor_binding' not in build_action_request(e,'apply',subject_id='secrets-engine',subject_type='service',purpose='metadata-test')['context'] ++ ++ ++@pytest.mark.parametrize('subject_id,subject_type',[('other','service'),('secrets-engine','Human'),('user:alice','Human')]) ++def test_execution_declaration_cannot_assert_foreign_service(subject_id,subject_type): ++ e=entry();contract=ExecutionContract.parse(descriptor(e)) ++ with pytest.raises(DecisionError,match='service subject'): ++ build_action_request(e,'execute-ccr',subject_id=subject_id,subject_type=subject_type,purpose='metadata-test',execution_contract=contract) +diff --git a/verifier/README.md b/verifier/README.md +index 6a1b56b..c2c3bec 100644 +--- a/verifier/README.md ++++ b/verifier/README.md +@@ -45,3 +45,11 @@ not an expansion of existing request numeric admission. Signed + `approval_observation` supplements the original immutable carried claim; + strict handoff checks its native digest and observation/deadline bounds. It never + replaces original context or freshness. ++ ++Successor R2 execution builder correspondence emits the producer's fixed public ++DeclaredServiceBinding only for the typed prod/platform secrets-engine service ++lane before approval issuance. Both reference tenants are explicit to match ++existing producer normalization. It is not caller authentication; independently ++signed enforced caller, exact original/native/descriptor joins and all existing ++approval, expiry and CAS gates still apply. Actual production source-server ++interoperability receipts are separate from native runtime admission. +diff --git a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md +index 8d42c05..3109752 100644 +--- a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md ++++ b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md +@@ -129,3 +129,16 @@ is separately blocked on missing owner-reviewed service_actor_binding mapping; + no caller context injection or equality relaxation hides that gap. + Artifacts: Custodian `decision-trusted-successor/`. T02 remains progress, T03 + wait; no package/key/policy/native admission or runtime installation occurred. ++ ++2026-10-16 successor R2 source preparation resolves the preceding builder gap: ++only typed prod/platform execute-ccr with exact secrets-engine service subject ++emits Access's fixed five-field public service_actor_binding before approval ++issue. Subject/resource platform tenants are explicit before issue, matching the ++producer's existing normalization and native act; lifecycle requests are unchanged. ++Original carried context is still immutable. Actual builder -> Approval App -> ++Access v2 signed ALLOW -> strict preparation/CAS -> independent Approval confirmation ++GET passes with synthetic scoped identities/trust. Missing declaration, descriptor/ ++native changes, stale original claim, signature tamper and replay refuse. The ++operation deadline is explicit whole seconds within v2's 15-second allow TTL; ++no deadline is extended or waived. This resolves source correspondence only; ++T02 remains progress/T03 wait, with no native trust/grant/policy admission. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r3-full.patch b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r3-full.patch new file mode 100644 index 00000000..08e3e16f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r3-full.patch @@ -0,0 +1,806 @@ +diff --git a/docs/task-scoped-openbao-execution-contract.md b/docs/task-scoped-openbao-execution-contract.md +index 6128f2e..b9fea01 100644 +--- a/docs/task-scoped-openbao-execution-contract.md ++++ b/docs/task-scoped-openbao-execution-contract.md +@@ -65,7 +65,7 @@ before observing the claim or calling Check. Its fields are: + | source_bundle | Absolute owner-resolved files and immutable SHA256 hashes for engine dispatcher, Platform mediator, typed CCR module and policy/auth source | + | backend_origin / cluster_uid | Exact reviewed estate OpenBao origin and railiance01 identity | + | operations | Ordered exact HTTP method, normalized path and canonical JSON/text payload hash for each read/write/readback; no user supplied body | +-| operation_deadline | Upper bound no later than approval, PDP or task-token expiry | ++| operation_deadline | Upper bound no later than approval, PDP, caller and original/fresh source validity; task-token expiry may be earlier | + | backend_budget | TTL at most 120s, at most twelve uses, no renew/child/wrap/unlimited uses | + | expected_before / expected_after | Owner-authenticated revision/state digests, never secret contents | + +@@ -158,10 +158,20 @@ validation while Platform owns its OpenBao credential and typed native primitive + Task-token use budget is declared before issuance: two before-state reads, two + writes, two readbacks and one self-revoke (seven uses), plus at most four explicit + metadata capability checks and one reserved cleanup use (twelve total). Failed +-backend calls count; never retry to evade limits. Task TTL is +-`min(120s, approval_remaining, decision_remaining, operation_remaining)` minus +-the declared clock uncertainty/cleanup reserve. Reject if fewer than 30s remain +-for planned requests. No renewal or remint extends the operation. CCR38 keeps its ++backend calls count; never retry to evade limits. The immutable ++`backend_budget.ttl_seconds` declares the requested task-token TTL (30–120s). ++The native adapter does not dynamically recompute or shorten that declaration. ++Before issuance, conservatively require at least the declared TTL plus the 5s ++cleanup reserve within the remaining operation/approval/PDP authority, including ++clock uncertainty. The requested and explicit maximum native token TTL equal ++the declared budget; returned native TTL must be positive and at most that budget. ++The token can expire before the operation deadline; failure or expiry stops work ++without remint. The signed operation deadline remains no later than approval, ++PDP, authenticated caller and both original/fresh source validity. Access's ++trusted source path independently requires the same TTL-plus-5s fit after source ++I/O and an operation interval no longer than 120s. Therefore a structural 120s ++backend budget cannot fit inside a 120s authority interval with the reserve and ++refuses. No renewal or remint extends the operation. CCR38 keeps its + own 300s/eight-use limit; login-self checks and revoke fit its separate budget. + + ## Failure, replay and recovery +diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py +index 1cf4140..e4865bb 100644 +--- a/src/secrets_engine/approval_consume.py ++++ b/src/secrets_engine/approval_consume.py +@@ -382,7 +382,8 @@ def authorize_action( + ), + decision_id=validated.decision_id, + expires_at=validated.expires_at, +- artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope}, ++ artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope, ++ "authenticated_envelope_json":getattr(envelope,"authenticated_json","")}, + sort_keys=True,separators=(",",":"),allow_nan=False), + ) + +diff --git a/src/secrets_engine/decision_signature.py b/src/secrets_engine/decision_signature.py +index 24fa8d3..d9bd0b9 100644 +--- a/src/secrets_engine/decision_signature.py ++++ b/src/secrets_engine/decision_signature.py +@@ -7,6 +7,7 @@ from __future__ import annotations + + import base64 + import json ++import math + from pathlib import Path + import stat + import subprocess +@@ -24,8 +25,15 @@ def _unique_object(pairs): + return result + + ++def _finite_float(value): ++ parsed = float(value) ++ if not math.isfinite(parsed): ++ raise ValueError("nonfinite JSON number") ++ return parsed ++ ++ + def strict_json(data): +- return json.loads(data, object_pairs_hook=_unique_object, ++ return json.loads(data, object_pairs_hook=_unique_object, parse_float=_finite_float, + parse_constant=lambda value: (_ for _ in ()).throw(ValueError("invalid number"))) + + +@@ -66,10 +74,18 @@ def load_trust(path: Path | None) -> dict[str, Any]: + raise DecisionError("access-engine responder trust is missing or invalid") from exc + + ++class AuthenticatedEnvelope(dict): ++ """Verified typed values with exact producer JSON retained for reauthentication.""" ++ def __init__(self, values, authenticated_json): ++ super().__init__(values) ++ self.authenticated_json = authenticated_json ++ ++ + def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) -> dict: + if verifier is None or not Path(verifier).is_absolute(): + raise DecisionError("access-engine signature verifier is not installed") + try: ++ strict_json(body.decode("utf-8")) + result = subprocess.run([str(verifier)], input=b'{"envelope":'+body+b',"keys":'+json.dumps(trust['keys']).encode()+b'}', + capture_output=True, env={}, timeout=3, check=False) + if result.returncode != 0 or len(result.stdout) > 524288: +@@ -78,6 +94,6 @@ def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) -> + provenance = authenticated['provenance'] + if any(provenance.get(k) != trust[k] for k in ('evaluator','mode','policy_package','policy_version','policy_package_digest')): + raise ValueError("issuer/pin mismatch") +- return authenticated +- except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError) as exc: ++ return AuthenticatedEnvelope(authenticated, result.stdout.decode("utf-8")) ++ except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError, UnicodeDecodeError) as exc: + raise DecisionError("access-engine responder authentication failed") from exc +diff --git a/src/secrets_engine/execution_contract.py b/src/secrets_engine/execution_contract.py +index 5dc1cae..f9b2538 100644 +--- a/src/secrets_engine/execution_contract.py ++++ b/src/secrets_engine/execution_contract.py +@@ -153,6 +153,18 @@ def attach_execution_contract(request: dict, contract: ExecutionContract | None) + raise DecisionError('execute-ccr requires platform tenant') + if value['catalog_sha256'] != catalog_target_digest(request['context']['catalog_target']): + raise DecisionError('execution_contract catalog target does not match') ++ if request['subject'] != {'id':'secrets-engine','type':'service'}: ++ raise DecisionError('execute-ccr requires secrets-engine service subject') ++ # Match the producer's fixed tenant normalization before approval issue. ++ request['subject']['tenant'] = 'tenant:platform' ++ request['resource']['tenant'] = 'tenant:platform' ++ # Public declaration from Access ServicePilotInputs.DeclaredServiceBinding; ++ # bound before approval issuance, never an authentication assertion. ++ request['context']['service_actor_binding'] = { ++ 'version':'proposed-service-task-caller/v1', ++ 'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine', ++ 'subject_id':'secrets-engine','subject_type':'service','tenant':'tenant:platform', ++ } + request['context']['execution_contract'] = value + request['context']['human_control'] = True + +diff --git a/src/secrets_engine/task_execution_handoff.py b/src/secrets_engine/task_execution_handoff.py +index 9772861..5af36be 100644 +--- a/src/secrets_engine/task_execution_handoff.py ++++ b/src/secrets_engine/task_execution_handoff.py +@@ -10,6 +10,7 @@ from __future__ import annotations + from dataclasses import dataclass + from datetime import datetime + import json ++import re + from threading import Lock + + from secrets_engine.approval_auth import approval_token, credential_urlopen +@@ -85,10 +86,10 @@ def _validate(cfg, action, contract): + request,claim,envelope=(artifacts[name] for name in ('request','claim','envelope')) + # Rotation/removal and installed-helper changes cannot hide behind a cached + # prepared label. Reauthenticate before each strict CAS, without networking. +- authenticated=authenticate_response(json.dumps(envelope,allow_nan=False).encode(), ++ authenticated=authenticate_response(artifacts["authenticated_envelope_json"].encode("utf-8"), + trust=load_trust(getattr(cfg,'pdp_trust_file',None)), + verifier=getattr(cfg,'pdp_verifier',None)) +- if authenticated != envelope: ++ if json.dumps(authenticated,sort_keys=True,allow_nan=False) != json.dumps(envelope,sort_keys=True,allow_nan=False): + _refuse() + window=read_window(cfg) + lower,upper=validity_bounds(window=window) +@@ -115,7 +116,7 @@ def _validate(cfg, action, contract): + or action.binding.decision_id!=validated.decision_id): + _refuse() + subject=binding['subject']; resource=binding['resource'] +- if (request['subject']!={'id':'secrets-engine','type':'service'} ++ if (request['subject']!={'id':'secrets-engine','type':'service','tenant':'tenant:platform'} + or subject.get('id')!='secrets-engine' or subject.get('type')!='service' + or subject.get('tenant') not in (None,'tenant:platform') + or 'principal' in subject.get('attributes',{}) +@@ -140,6 +141,22 @@ def _validate(cfg, action, contract): + or supplied.get('digest')!=claim_binding_digest(**native) + or supplied.get('digest')!=claim_binding_digest(**{key:supplied.get(key) for key in _NATIVE_FIELDS})): + _refuse() ++ observation=envelope['provenance'].get('approval_observation') ++ if observation is not None: ++ # Signed producer source evidence supplements the original carried claim; ++ # it cannot replace original context, extend freshness, or authorize CAS. ++ if (not isinstance(observation,dict) ++ or any(not isinstance(observation.get(k),str) or not observation[k] ++ for k in ('source_id','source_revision')) ++ or any(not isinstance(observation.get(k),str) ++ or re.fullmatch(r'sha256:[0-9a-f]{64}',observation[k]) is None ++ for k in ('configuration_digest','claim_digest','native_binding_digest')) ++ or observation['native_binding_digest']!=supplied['digest'] ++ or _stamp(observation.get('observed_at'))>_stamp(observation.get('validated_at')) ++ or _stamp(observation.get('validated_at'))>upper ++ or upper>=_stamp(observation.get('not_after')) ++ or _stamp(envelope['lifetime']['expires_at'])>_stamp(observation.get('not_after'))): ++ _refuse() + return {'execution_contract':descriptor,'approval_id':action.binding.approval_id, + 'request_digest':validated.request_digest,'decision_id':validated.decision_id, + 'native_binding':native,'approval_binding_digest':binding['approval_binding_digest'], +diff --git a/tests/fixtures/pdp-signature/approval-native-vectors.json b/tests/fixtures/pdp-signature/approval-native-vectors.json +new file mode 100644 +index 0000000..46adb26 +--- /dev/null ++++ b/tests/fixtures/pdp-signature/approval-native-vectors.json +@@ -0,0 +1,210 @@ ++[ ++ { ++ "input_target_json": "{\"value\":true}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": true ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":true}}", ++ "native_digest": "sha256:e1f537daa849e5da023efb5f3d62cc33d94cce20f9ca9c5a708c76e225fd702f" ++ }, ++ { ++ "input_target_json": "{\"value\":1}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1}}", ++ "native_digest": "sha256:3a3d2da6267e4b917505a7c2247e6ce36fd8ee074eef37251bcc66839b602332" ++ }, ++ { ++ "input_target_json": "{\"value\":1.0}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1.0 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1.0}}", ++ "native_digest": "sha256:21c2cdb9b161071ebfe361e50703511b2abf6c6f327f153282d247aa92c3898e" ++ }, ++ { ++ "input_target_json": "{\"value\":false}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": false ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":false}}", ++ "native_digest": "sha256:278cbcd013f7923db5258dd860f911d688505a8259f15ccb77983b044f312c74" ++ }, ++ { ++ "input_target_json": "{\"value\":0}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 0 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":0}}", ++ "native_digest": "sha256:e691b8fb55d4bf82a310dee37c045331100c8031e26ea6f51bdcceeeb3b1f20e" ++ }, ++ { ++ "input_target_json": "{\"value\":-0.0}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": -0.0 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":-0.0}}", ++ "native_digest": "sha256:eccde458c686bff67506bb2d94c750e648a311bf23967cb22b37f11e84b6844f" ++ }, ++ { ++ "input_target_json": "{\"value\":1e-7}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1e-07 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e-07}}", ++ "native_digest": "sha256:779042b6c0e675f6aacdcd3a02199aab66f534bb84dd410a95c18657afae563c" ++ }, ++ { ++ "input_target_json": "{\"value\":1e-6}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1e-06 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e-06}}", ++ "native_digest": "sha256:d021f402231fcd59e764b31e6f08a5298ebb328dfd93da58ecde8feb11fde0b5" ++ }, ++ { ++ "input_target_json": "{\"value\":1e20}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1e+20 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e+20}}", ++ "native_digest": "sha256:14270ffca4ab10e9d9302d52e988b4aa3e2facd494c12a60e2edafef70277a20" ++ }, ++ { ++ "input_target_json": "{\"value\":1e21}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1e+21 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e+21}}", ++ "native_digest": "sha256:75a60a595b2578430b0be466b6bf9a46b62ddc99bbbcebd7fa0e8d22e3078c0a" ++ }, ++ { ++ "input_target_json": "{\"value\":2.5e-7}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 2.5e-07 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":2.5e-07}}", ++ "native_digest": "sha256:50af37ff75a3e7a22629e746f814995c0c2cd96adb7f1ed5b26999b71d0ac498" ++ }, ++ { ++ "input_target_json": "{\"value\":9007199254740993}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 9007199254740993 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":9007199254740993}}", ++ "native_digest": "sha256:765360dc5a1d949bd114c6b749d83069123d8b4b6b1d939f831428a800f50dab" ++ }, ++ { ++ "input_target_json": "{\"html\":\"<>&\", \"unicode\":\"é雪😀\", \"separator\":\"\\u2028\\u2029\"}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "html": "<>&", ++ "unicode": "é雪😀", ++ "separator": "

" ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"html\":\"<>&\",\"separator\":\"

\",\"unicode\":\"é雪😀\"}}", ++ "native_digest": "sha256:c203d934743e2954342038a3746b6c37a0f3101e886ef855b1c6ad824adf5dea" ++ }, ++ { ++ "input_target_json": "{\"b\":[true,1,1.0,null,{\"z\":\"x\",\"a\":\"y\"}],\"a\":{}}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "b": [ ++ true, ++ 1, ++ 1.0, ++ null, ++ { ++ "z": "x", ++ "a": "y" ++ } ++ ], ++ "a": {} ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"a\":{},\"b\":[true,1,1.0,null,{\"a\":\"y\",\"z\":\"x\"}]}}", ++ "native_digest": "sha256:56306505c882af6edfae5f3d2a5b42e92737a0859236fe5574a21d1874655d51" ++ } ++] +diff --git a/tests/test_execution_context.py b/tests/test_execution_context.py +index 23b6419..70d9415 100644 +--- a/tests/test_execution_context.py ++++ b/tests/test_execution_context.py +@@ -133,7 +133,7 @@ def test_closed_schema_refuses(mutation): + + @pytest.mark.parametrize('action', ['apply','rotate','exec','execute-ccr']) + def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action): +- e=entry(); cfg=_Cfg(_token(tmp_path)); calls=[] ++ e=entry(); cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[] + def fetch(*args,**kwargs): calls.append('claim'); raise AssertionError + contract=None if action=='execute-ccr' else ExecutionContract.parse(descriptor(e)) + with pytest.raises(DecisionError): +@@ -143,7 +143,7 @@ def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action) + + def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path): + e=entry(); typed=ExecutionContract.parse(descriptor(e)); e=replace(e,path='changed/path') +- cfg=_Cfg(_token(tmp_path)); calls=[] ++ cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[] + with pytest.raises(DecisionError,match='catalog'): + resolve_approval_observation(cfg,e,'execute-ccr',None,execution_contract=typed,opener=lambda *a,**k:calls.append('claim')) + assert not calls +@@ -154,7 +154,7 @@ def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path): + + def test_expired_contract_refuses_before_claim(tmp_path): + e=entry(); raw=descriptor(e); raw['operation_deadline']=(datetime.now(timezone.utc)-timedelta(seconds=1)).isoformat() +- cfg=_Cfg(_token(tmp_path)); calls=[] ++ cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[] + with pytest.raises(DecisionError,match='expired'): + authorize_action(cfg,e,'execute-ccr',execution_contract=ExecutionContract.parse(raw),opener=lambda *a,**k:calls.append('claim')) + assert not calls +@@ -200,3 +200,22 @@ def test_deadline_beyond_claim_refuses_before_pdp(tmp_path): + from tests.test_consume_binding_join import admitted_native_signer + + from tests.signature_support import signature_tools ++ ++ ++def test_execution_service_declaration_is_fixed_before_issue(): ++ e=entry();contract=ExecutionContract.parse(descriptor(e)) ++ built=request(e,contract) ++ assert built['context']['service_actor_binding']=={ ++ 'version':'proposed-service-task-caller/v1', ++ 'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine', ++ 'subject_id':'secrets-engine','subject_type':'service','tenant':'tenant:platform'} ++ assert built['subject']=={'id':'secrets-engine','type':'service','tenant':'tenant:platform'} ++ assert built['resource']['tenant']=='tenant:platform' ++ assert 'service_actor_binding' not in build_action_request(e,'apply',subject_id='secrets-engine',subject_type='service',purpose='metadata-test')['context'] ++ ++ ++@pytest.mark.parametrize('subject_id,subject_type',[('other','service'),('secrets-engine','Human'),('user:alice','Human')]) ++def test_execution_declaration_cannot_assert_foreign_service(subject_id,subject_type): ++ e=entry();contract=ExecutionContract.parse(descriptor(e)) ++ with pytest.raises(DecisionError,match='service subject'): ++ build_action_request(e,'execute-ccr',subject_id=subject_id,subject_type=subject_type,purpose='metadata-test',execution_contract=contract) +diff --git a/tests/test_trusted_successor.py b/tests/test_trusted_successor.py +new file mode 100644 +index 0000000..3c47a1d +--- /dev/null ++++ b/tests/test_trusted_successor.py +@@ -0,0 +1,80 @@ ++"""Owner signature interoperability, independent synthetic admission only.""" ++import copy ++import json ++from pathlib import Path ++import subprocess ++ ++import pytest ++from secrets_engine.decision_signature import authenticate_response,load_trust ++from secrets_engine.errors import DecisionError ++from tests.test_decision_signature import FIXTURE,trust_file,verifier ++from tests.signature_support import signature_tools ++from tests.test_task_execution_handoff import lane,prepared,consume_task_execution ++ ++VECTORS=json.loads((Path(__file__).parent/'fixtures/pdp-signature/approval-native-vectors.json').read_text()) ++ ++@pytest.mark.parametrize('vector',VECTORS) ++def test_fourteen_owner_native_types_retain_signed_bytes(vector,trust_file,signature_tools): ++ env=json.loads(FIXTURE.read_text());env.pop('signature',None) ++ env['diagnostics']={'native_vector':'REPLACE_RAW_TARGET'} ++ raw=json.dumps(env).replace('"REPLACE_RAW_TARGET"',vector['input_target_json']).encode() ++ signed=subprocess.run([str(signature_tools/'sign')],input=raw,capture_output=True,check=True).stdout ++ result=authenticate_response(signed,trust=load_trust(trust_file),verifier=signature_tools/'verify') ++ again=authenticate_response(result.authenticated_json.encode(),trust=load_trust(trust_file),verifier=signature_tools/'verify') ++ assert again['diagnostics']['native_vector']==json.loads(vector['input_target_json']) ++ tampered=signed.replace(b'"action":"rotate"',b'"action":"destroy"') ++ assert tampered!=signed ++ with pytest.raises(DecisionError):authenticate_response(tampered,trust=load_trust(trust_file),verifier=signature_tools/'verify') ++ ++ ++def test_unknown_provenance_refuses(trust_file,signature_tools): ++ env=json.loads(FIXTURE.read_text());env['provenance']['unknown_source_authority']=True ++ with pytest.raises(DecisionError): ++ authenticate_response(json.dumps(env).encode(),trust=load_trust(trust_file),verifier=signature_tools/'verify') ++ ++ ++def test_exact_original_numeric_lexeme_survives_strict_cas(lane,monkeypatch,signature_tools): ++ import tests.test_task_execution_handoff as fixtures ++ cfg,e,contract,state=lane ++ def raw_sign(env,tools): ++ env['diagnostics']={'numeric':'LEXEME'} ++ raw=json.dumps(env).replace('"LEXEME"','1e-7').encode() ++ return subprocess.run([str(tools/'sign')],input=raw,capture_output=True,check=True).stdout ++ monkeypatch.setattr(fixtures,'sign_envelope',raw_sign) ++ authorization=prepared(lane) ++ assert '1e-7' in authorization._action.artifacts ++ assert consume_task_execution(cfg,authorization).idempotent is False ++ ++ ++def source_evidence(envelope,claim): ++ from datetime import datetime,timezone ++ return {'source_id':'synthetic-source','source_revision':'synthetic-revision', ++ 'configuration_digest':'sha256:'+'2'*64,'claim_digest':'sha256:'+'3'*64, ++ 'native_binding_digest':claim['binding']['digest'], ++ 'observed_at':datetime.now(timezone.utc).isoformat(), ++ 'validated_at':datetime.now(timezone.utc).isoformat(), ++ 'not_after':envelope['lifetime']['expires_at']} ++ ++ ++def test_signed_source_evidence_keeps_original_context_and_cas(lane): ++ cfg,e,contract,state=lane ++ state['decision_change']=lambda env:env['provenance'].__setitem__('approval_observation',source_evidence(env,state['claim'])) ++ authorization=prepared(lane) ++ snapshot=json.loads(authorization._action.artifacts) ++ assert snapshot['envelope']['binding']['context']==snapshot['request']['context'] ++ assert snapshot['request']['context']['approval']==state['claim'] ++ assert consume_task_execution(cfg,authorization).idempotent is False ++ ++ ++@pytest.mark.parametrize('field,value',[ ++ ('source_id',''),('source_revision',''),('configuration_digest','wrong'),('claim_digest','wrong'), ++ ('native_binding_digest','sha256:'+'0'*64),('observed_at','2099-01-01T00:00:00Z'), ++ ('validated_at','2099-01-01T00:00:00Z'),('not_after','2000-01-01T00:00:00Z')]) ++def test_signed_invalid_source_evidence_refuses_before_cas(lane,field,value): ++ cfg,e,contract,state=lane ++ def change(env): ++ evidence=source_evidence(env,state['claim']);evidence[field]=value ++ env['provenance']['approval_observation']=evidence ++ state['decision_change']=change ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] +diff --git a/verifier/README.md b/verifier/README.md +index a63b2a0..fe7ec93 100644 +--- a/verifier/README.md ++++ b/verifier/README.md +@@ -1,7 +1,7 @@ + # Access Engine decision verifier + + The API types and `internal/sign/sign.go` are unchanged producer source, pinned +-by revision and SHA-256 in `producer-source.json`. License attribution and the ++by source revision/proposal and SHA-256 in `producer-source.json`. License attribution and the + original license text are in `LICENSE.access-engine`. This is source vendoring + necessitated by the producer's Go `internal` package boundary, not a separately + maintained crypto implementation. There are no external Go dependencies. +@@ -33,3 +33,23 @@ For the complete reproducible CLI build and offline fresh-prefix installation, + use [the release bundle contract](../docs/cli-release-bundle.md). This replaces + manual helper copying for packaged deployments; package and key admission remain + separate owner decisions. ++ ++The trusted-service successor snapshot follows the frozen Access B2/B3 proposal lineage, ++identified by its patch SHA-256 in `producer-source.json`; this is source review, ++not a claim of published producer revision or runtime/key admission. The wrapper ++uses producer-owned `sign.DecodeEnvelope` to preserve JSON numeric lexemes in ++signed maps. It retains exact authenticated producer JSON through strict ++preparation and CAS reauthentication; Python does not recreate signed bytes. ++Approval's fourteen native binding vectors are signature interoperability cases, ++not an expansion of existing request numeric admission. Signed ++`approval_observation` supplements the original immutable carried claim; ++strict handoff checks its native digest and observation/deadline bounds. It never ++replaces original context or freshness. ++ ++Successor R2 execution builder correspondence emits the producer's fixed public ++DeclaredServiceBinding only for the typed prod/platform secrets-engine service ++lane before approval issuance. Both reference tenants are explicit to match ++existing producer normalization. It is not caller authentication; independently ++signed enforced caller, exact original/native/descriptor joins and all existing ++approval, expiry and CAS gates still apply. Actual production source-server ++interoperability receipts are separate from native runtime admission. +diff --git a/verifier/internal/sign/sign.go b/verifier/internal/sign/sign.go +index fc607f7..011f8f7 100644 +--- a/verifier/internal/sign/sign.go ++++ b/verifier/internal/sign/sign.go +@@ -4,10 +4,12 @@ + package sign + + import ( ++ "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" ++ "io" + + "github.com/netkingdom/access-engine/pkg/api" + ) +@@ -74,3 +76,20 @@ func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) er + } + return nil + } ++ ++// DecodeEnvelope preserves JSON numeric kinds/lexemes emitted by task inputs. ++// Legacy producer envelopes remain byte-compatible. It does not invent another ++// signature canonicalization: CanonicalBytes still calls typed json.Marshal. ++func DecodeEnvelope(raw []byte) (api.DecisionEnvelope, error) { ++ var envelope api.DecisionEnvelope ++ decoder := json.NewDecoder(bytes.NewReader(raw)) ++ decoder.UseNumber() ++ decoder.DisallowUnknownFields() ++ if err := decoder.Decode(&envelope); err != nil { ++ return envelope, err ++ } ++ if _, err := decoder.Token(); err != io.EOF { ++ return api.DecisionEnvelope{}, fmt.Errorf("trailing envelope data") ++ } ++ return envelope, nil ++} +diff --git a/verifier/main.go b/verifier/main.go +index 1f3f9de..7449c64 100644 +--- a/verifier/main.go ++++ b/verifier/main.go +@@ -2,13 +2,11 @@ + package main + + import ( +- "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/netkingdom/access-engine/internal/sign" +- "github.com/netkingdom/access-engine/pkg/api" + "io" + "os" + ) +@@ -23,10 +21,8 @@ func run() error { + if err := json.NewDecoder(io.LimitReader(os.Stdin, 1048577)).Decode(&in); err != nil { + return err + } +- var envelope api.DecisionEnvelope +- decoder := json.NewDecoder(bytes.NewReader(in.Envelope)) +- decoder.DisallowUnknownFields() +- if err := decoder.Decode(&envelope); err != nil { ++ envelope, err := sign.DecodeEnvelope(in.Envelope) ++ if err != nil { + return err + } + keys := map[string]ed25519.PublicKey{} +diff --git a/verifier/pkg/api/canonical.go b/verifier/pkg/api/canonical.go +index 3baa784..ae11a2d 100644 +--- a/verifier/pkg/api/canonical.go ++++ b/verifier/pkg/api/canonical.go +@@ -635,16 +635,30 @@ type Obligation struct { + + // DecisionProvenance captures evaluator and policy provenance. + type DecisionProvenance struct { +- Evaluator string `json:"evaluator" yaml:"evaluator"` +- Mode string `json:"mode" yaml:"mode"` +- PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"` +- PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` +- PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"` +- RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"` +- DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"` +- InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"` +- DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"` +- Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"` ++ Evaluator string `json:"evaluator" yaml:"evaluator"` ++ Mode string `json:"mode" yaml:"mode"` ++ PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"` ++ PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` ++ PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"` ++ RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"` ++ DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"` ++ InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"` ++ DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"` ++ ApprovalObservation *ApprovalObservationProvenance `json:"approval_observation,omitempty" yaml:"approval_observation,omitempty"` ++ Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"` ++} ++ ++// ApprovalObservationProvenance binds fresh server-owned fact evidence without ++// rewriting the original carried claim/context or either PDP request digest. ++type ApprovalObservationProvenance struct { ++ SourceID string `json:"source_id"` ++ SourceRevision string `json:"source_revision"` ++ ConfigurationDigest string `json:"configuration_digest"` ++ ClaimDigest string `json:"claim_digest"` ++ NativeBindingDigest string `json:"native_binding_digest"` ++ ObservedAt string `json:"observed_at"` ++ NotAfter string `json:"not_after"` ++ ValidatedAt string `json:"validated_at"` + } + + // CallerProvenance records how the request was authenticated to flex-auth. +diff --git a/verifier/producer-source.json b/verifier/producer-source.json +index b6b0cba..9a4d5a3 100644 +--- a/verifier/producer-source.json ++++ b/verifier/producer-source.json +@@ -1,15 +1,21 @@ + { + "upstream": "https://github.com/netkingdom/access-engine", +- "revision": "62ac2b4e99dec2005c7c407fe633612578a7907d", ++ "revision": "B2/B3 R2 temporal source proposal over B1 R2; canonical native-prep base 919ce25", + "files": { +- "pkg/api/canonical.go": "21e754617c2b82d673cef31b7075f8d177e4367782b63b20b37125587bf9e300", ++ "pkg/api/canonical.go": "a122a2e8cd524333b13c5939c7a3685e41efbe47497a64e0c7297c92b7f10d19", + "pkg/api/caring.go": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3", + "pkg/api/resource_manifest.go": "f0fa4080287d389ed48c2b1d6af96cc62e57ef72c902333f677f964420be133f", + "pkg/api/doc.go": "0638f06ae47369899dd1e2dd5dd23a7200e7dbb3b505b04f4d3c78c0a074def9", +- "internal/sign/sign.go": "dad2d0c9f6d1fb2fd68e774949ccdf954382acd075191b5ddfea2c3cc2f4cb62" ++ "internal/sign/sign.go": "0df9515922e90775ebbfac798361dcafb20da8a4213796b44e246b8a92046cde" + }, + "fixtures": { + "decision_rotate_signed.json": "a7bc499f3583f53551cf163109a3c108188345100176092adc31eccefc222fd8", + "decision_rotate_signed_tampered.json": "7f2d2e9372a9617bd4b197033f09a5e33a08b202046de8109582da1dc90cb527" ++ }, ++ "source_proposal": { ++ "patch_sha256": "ed85a55b54f8380a657c0688d6f216699a0873b1b94b4c0c2e09332620c8ebf4", ++ "predecessor_patch_sha256": "6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1", ++ "api_sign_bytes_unchanged": true, ++ "status": "source proposal; no runtime or key admission" + } + } +diff --git a/verifier/testsupport/signer/main.go b/verifier/testsupport/signer/main.go +index 2a7fecd..994ea82 100644 +--- a/verifier/testsupport/signer/main.go ++++ b/verifier/testsupport/signer/main.go +@@ -6,7 +6,7 @@ import ( + "crypto/ed25519" + "encoding/json" + "github.com/netkingdom/access-engine/internal/sign" +- "github.com/netkingdom/access-engine/pkg/api" ++ "io" + "os" + ) + +@@ -17,8 +17,12 @@ func main() { + seed = 0x43 + kid = "next-admitted" + } +- var env api.DecisionEnvelope +- if json.NewDecoder(os.Stdin).Decode(&env) != nil { ++ raw, err := io.ReadAll(os.Stdin) ++ if err != nil { ++ os.Exit(1) ++ } ++ env, err := sign.DecodeEnvelope(raw) ++ if err != nil { + os.Exit(1) + } + if sign.Sign(&env, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, 32)), kid) != nil { +diff --git a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md +index 28102cf..60f9e18 100644 +--- a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md ++++ b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md +@@ -114,3 +114,48 @@ producer candidate OCI and actual runtime/config placement remain missing. The + empty-key trust template deliberately refuses. No install, key read, caller mint, + protected effect, policy switch or trust admission occurred. T02 remains progress, + T03 wait; task source/live obligations remain separately in SECRETS-WP-0013. ++ ++2026-10-16 trusted successor source preparation: refreshed exact API/sign source ++from frozen Access B2/B3 R1 proposal (patch SHA-256 ++6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1). ++Producer DecodeEnvelope preserves signed numeric lexemes; strict CAS retains and ++reauthenticates original producer JSON. Existing request/claim/native/caller joins ++remain mandatory; signed source observation additionally binds the native digest ++and caps decision lifetime. Fourteen Approval owner vectors, legacy signatures, ++tamper/unknown provenance and source deadline refusals are locally checked. ++Actual ApprovalApp-to-Access v2 signed response verifies with the consumer helper; ++actual source revocation refuses. Actual builder-to-v2 strict positive execution ++is separately blocked on missing owner-reviewed service_actor_binding mapping; ++no caller context injection or equality relaxation hides that gap. ++Artifacts: Custodian `decision-trusted-successor/`. T02 remains progress, T03 ++wait; no package/key/policy/native admission or runtime installation occurred. ++ ++2026-10-16 successor R2 source preparation resolves the preceding builder gap: ++only typed prod/platform execute-ccr with exact secrets-engine service subject ++emits Access's fixed five-field public service_actor_binding before approval ++issue. Subject/resource platform tenants are explicit before issue, matching the ++producer's existing normalization and native act; lifecycle requests are unchanged. ++Original carried context is still immutable. Actual builder -> Approval App -> ++Access v2 signed ALLOW -> strict preparation/CAS -> independent Approval confirmation ++GET passes with synthetic scoped identities/trust. Missing declaration, descriptor/ ++native changes, stale original claim, signature tamper and replay refuse. The ++operation deadline is explicit whole seconds within v2's 15-second allow TTL; ++no deadline is extended or waived. This resolves source correspondence only; ++T02 remains progress/T03 wait, with no native trust/grant/policy admission. ++ ++2026-10-16 temporal successor source proof: Access B2/B3 R2 replaces its earlier ++15s lifetime with the exact immutable operation deadline, bounded by its 120s ++hard cap, original/fresh source and caller bounds, and post-I/O backend TTL+5s ++fit. Verifier API/sign bytes are unchanged; source provenance records R2. Secrets ++runtime and strict entire-operation deadline checks are unchanged. Contract prose ++now states the actual fixed backend TTL and conservative fit rather than dynamic ++TTL recomputation, and permits native token expiry earlier than operation deadline. ++The actual source proof uses explicit synthetic Approval freshness120/backendTTL30/ ++operationdeadline60, without a consumer timing waiver or native admission. ++ ++Temporal proof precision observation: Approval consumption receipts use whole ++seconds while Access R2 not_before preserves subsecond precision. Fast CAS can ++therefore refuse receipt-before-start even though live calls are correctly ++ordered. The positive proof explicitly waits real time until the next whole ++second before CAS, without injected clocks or relaxed checks. Fast-CAS failure ++is retained for owner resolution; no Secrets runtime change conceals the seam. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r3-over-r2.patch b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r3-over-r2.patch new file mode 100644 index 00000000..3e6e7244 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r3-over-r2.patch @@ -0,0 +1,99 @@ +diff --git a/docs/task-scoped-openbao-execution-contract.md b/docs/task-scoped-openbao-execution-contract.md +index 6128f2e..b9fea01 100644 +--- a/docs/task-scoped-openbao-execution-contract.md ++++ b/docs/task-scoped-openbao-execution-contract.md +@@ -65,7 +65,7 @@ before observing the claim or calling Check. Its fields are: + | source_bundle | Absolute owner-resolved files and immutable SHA256 hashes for engine dispatcher, Platform mediator, typed CCR module and policy/auth source | + | backend_origin / cluster_uid | Exact reviewed estate OpenBao origin and railiance01 identity | + | operations | Ordered exact HTTP method, normalized path and canonical JSON/text payload hash for each read/write/readback; no user supplied body | +-| operation_deadline | Upper bound no later than approval, PDP or task-token expiry | ++| operation_deadline | Upper bound no later than approval, PDP, caller and original/fresh source validity; task-token expiry may be earlier | + | backend_budget | TTL at most 120s, at most twelve uses, no renew/child/wrap/unlimited uses | + | expected_before / expected_after | Owner-authenticated revision/state digests, never secret contents | + +@@ -158,10 +158,20 @@ validation while Platform owns its OpenBao credential and typed native primitive + Task-token use budget is declared before issuance: two before-state reads, two + writes, two readbacks and one self-revoke (seven uses), plus at most four explicit + metadata capability checks and one reserved cleanup use (twelve total). Failed +-backend calls count; never retry to evade limits. Task TTL is +-`min(120s, approval_remaining, decision_remaining, operation_remaining)` minus +-the declared clock uncertainty/cleanup reserve. Reject if fewer than 30s remain +-for planned requests. No renewal or remint extends the operation. CCR38 keeps its ++backend calls count; never retry to evade limits. The immutable ++`backend_budget.ttl_seconds` declares the requested task-token TTL (30–120s). ++The native adapter does not dynamically recompute or shorten that declaration. ++Before issuance, conservatively require at least the declared TTL plus the 5s ++cleanup reserve within the remaining operation/approval/PDP authority, including ++clock uncertainty. The requested and explicit maximum native token TTL equal ++the declared budget; returned native TTL must be positive and at most that budget. ++The token can expire before the operation deadline; failure or expiry stops work ++without remint. The signed operation deadline remains no later than approval, ++PDP, authenticated caller and both original/fresh source validity. Access's ++trusted source path independently requires the same TTL-plus-5s fit after source ++I/O and an operation interval no longer than 120s. Therefore a structural 120s ++backend budget cannot fit inside a 120s authority interval with the reserve and ++refuses. No renewal or remint extends the operation. CCR38 keeps its + own 300s/eight-use limit; login-self checks and revoke fit its separate budget. + + ## Failure, replay and recovery +diff --git a/verifier/README.md b/verifier/README.md +index c2c3bec..fe7ec93 100644 +--- a/verifier/README.md ++++ b/verifier/README.md +@@ -34,7 +34,7 @@ use [the release bundle contract](../docs/cli-release-bundle.md). This replaces + manual helper copying for packaged deployments; package and key admission remain + separate owner decisions. + +-The trusted-service successor snapshot is the frozen Access B2/B3 R1 proposal, ++The trusted-service successor snapshot follows the frozen Access B2/B3 proposal lineage, + identified by its patch SHA-256 in `producer-source.json`; this is source review, + not a claim of published producer revision or runtime/key admission. The wrapper + uses producer-owned `sign.DecodeEnvelope` to preserve JSON numeric lexemes in +diff --git a/verifier/producer-source.json b/verifier/producer-source.json +index 99e651d..9a4d5a3 100644 +--- a/verifier/producer-source.json ++++ b/verifier/producer-source.json +@@ -1,6 +1,6 @@ + { + "upstream": "https://github.com/netkingdom/access-engine", +- "revision": "B2/B3 R1 source proposal over B1 R2; canonical native-prep base 919ce25", ++ "revision": "B2/B3 R2 temporal source proposal over B1 R2; canonical native-prep base 919ce25", + "files": { + "pkg/api/canonical.go": "a122a2e8cd524333b13c5939c7a3685e41efbe47497a64e0c7297c92b7f10d19", + "pkg/api/caring.go": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3", +@@ -13,7 +13,9 @@ + "decision_rotate_signed_tampered.json": "7f2d2e9372a9617bd4b197033f09a5e33a08b202046de8109582da1dc90cb527" + }, + "source_proposal": { +- "patch_sha256": "6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1", ++ "patch_sha256": "ed85a55b54f8380a657c0688d6f216699a0873b1b94b4c0c2e09332620c8ebf4", ++ "predecessor_patch_sha256": "6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1", ++ "api_sign_bytes_unchanged": true, + "status": "source proposal; no runtime or key admission" + } + } +diff --git a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md +index 3109752..60f9e18 100644 +--- a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md ++++ b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md +@@ -142,3 +142,20 @@ native changes, stale original claim, signature tamper and replay refuse. The + operation deadline is explicit whole seconds within v2's 15-second allow TTL; + no deadline is extended or waived. This resolves source correspondence only; + T02 remains progress/T03 wait, with no native trust/grant/policy admission. ++ ++2026-10-16 temporal successor source proof: Access B2/B3 R2 replaces its earlier ++15s lifetime with the exact immutable operation deadline, bounded by its 120s ++hard cap, original/fresh source and caller bounds, and post-I/O backend TTL+5s ++fit. Verifier API/sign bytes are unchanged; source provenance records R2. Secrets ++runtime and strict entire-operation deadline checks are unchanged. Contract prose ++now states the actual fixed backend TTL and conservative fit rather than dynamic ++TTL recomputation, and permits native token expiry earlier than operation deadline. ++The actual source proof uses explicit synthetic Approval freshness120/backendTTL30/ ++operationdeadline60, without a consumer timing waiver or native admission. ++ ++Temporal proof precision observation: Approval consumption receipts use whole ++seconds while Access R2 not_before preserves subsecond precision. Fast CAS can ++therefore refuse receipt-before-start even though live calls are correctly ++ordered. The positive proof explicitly waits real time until the next whole ++second before CAS, without injected clocks or relaxed checks. Fast-CAS failure ++is retained for owner resolution; no Secrets runtime change conceals the seam. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r4-full.patch b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r4-full.patch new file mode 100644 index 00000000..54a15c60 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r4-full.patch @@ -0,0 +1,1124 @@ +diff --git a/docs/task-execution-handoff.md b/docs/task-execution-handoff.md +index 5821175..c62dbe4 100644 +--- a/docs/task-execution-handoff.md ++++ b/docs/task-execution-handoff.md +@@ -38,7 +38,9 @@ ServiceAccount principal, `flex-auth` audience and live expiry bounding the + operation deadline. This consumes the signed PDP's caller attestation; it does + not independently perform TokenReview or promote the proposed server mapping. + Human/delegation/projected-identity flags cannot substitute for this service seam. +-No service_actor_binding request propagation or source-policy activation is added. ++The later typed execution builder now propagates the fixed Access public ++service_actor_binding before approval issue; it is not caller authentication. ++No source-policy activation is added. + + `as_owner_handoff()` returns a detached non-secret mapping with: + +@@ -76,3 +78,36 @@ confirm original durable consumption using Approval's real provider client, + journal it, then recheck its exact Plan, owner sources, grant and deadlines before + any native operation. Neither a dataclass nor a caller-supplied boolean admits + execution. Existing generic CLI CAS behavior is otherwise unchanged. ++ ++ ++2026-10-16 retained-registry preapproval source preparation adds ++`prepare_task_approval(cfg, entry, execution_contract=contract)`. It calls the ++existing signed Check client with the original claim-free typed proposal and ++accepts only a signed DENY normalization record. This neither authorizes nor ++consumes an operation. No registry algorithm is copied and no registry is emptied. ++ ++The detached `as_owner_handoff()` returns `original_request`, ++`submitted_request_digest`, authoritative `native_binding`, ++`approval_binding_digest`, normalization `decision_id`, `execution_contract`, and ++signed `registry_snapshot_digest`. Exact original context (including JSON kinds), ++submitted digest, recomputed evaluated digest, explicit approval-free digest, ++policy/key admission, service/caller identity, decision time and deadline are ++checked. Registry digest is evidence from the admitted signer; this method does ++not independently grant registry/config admission. The caller uses the returned ++native act and approval-free digest for normal Approval issuance and human entry. ++It preserves the original proposal; final authorize_task_execution builds that ++same proposal and attaches only the original issued claim. Access normalizes ++again and independently joins current source facts. Changed evaluated facts or ++native target refuse before CAS. A normalization DENY object/mapping is never ++accepted by consume_task_execution. ++ ++Normalization decision_time must be valid and nonfuture; there is deliberately ++no invented standalone normalization TTL. The DENY receipt is not permission and ++cannot discharge final signed ALLOW or source validation. Expired original task/ ++caller bounds refuse at preparation; a stale normalization's changed evaluated ++facts refuse when Access independently checks the original issued claim against ++the current retained registry. Unchanged historical normalization can only supply ++public issue metadata; it cannot itself produce a CAS/native admission. The actual ++source-server drift proof restarts Access with changed authoritative subject ++roles after preapproval and observes final refusal with no new ALLOW record, no ++CAS and unconsumed Approval state. +diff --git a/docs/task-scoped-openbao-execution-contract.md b/docs/task-scoped-openbao-execution-contract.md +index 6128f2e..b9fea01 100644 +--- a/docs/task-scoped-openbao-execution-contract.md ++++ b/docs/task-scoped-openbao-execution-contract.md +@@ -65,7 +65,7 @@ before observing the claim or calling Check. Its fields are: + | source_bundle | Absolute owner-resolved files and immutable SHA256 hashes for engine dispatcher, Platform mediator, typed CCR module and policy/auth source | + | backend_origin / cluster_uid | Exact reviewed estate OpenBao origin and railiance01 identity | + | operations | Ordered exact HTTP method, normalized path and canonical JSON/text payload hash for each read/write/readback; no user supplied body | +-| operation_deadline | Upper bound no later than approval, PDP or task-token expiry | ++| operation_deadline | Upper bound no later than approval, PDP, caller and original/fresh source validity; task-token expiry may be earlier | + | backend_budget | TTL at most 120s, at most twelve uses, no renew/child/wrap/unlimited uses | + | expected_before / expected_after | Owner-authenticated revision/state digests, never secret contents | + +@@ -158,10 +158,20 @@ validation while Platform owns its OpenBao credential and typed native primitive + Task-token use budget is declared before issuance: two before-state reads, two + writes, two readbacks and one self-revoke (seven uses), plus at most four explicit + metadata capability checks and one reserved cleanup use (twelve total). Failed +-backend calls count; never retry to evade limits. Task TTL is +-`min(120s, approval_remaining, decision_remaining, operation_remaining)` minus +-the declared clock uncertainty/cleanup reserve. Reject if fewer than 30s remain +-for planned requests. No renewal or remint extends the operation. CCR38 keeps its ++backend calls count; never retry to evade limits. The immutable ++`backend_budget.ttl_seconds` declares the requested task-token TTL (30–120s). ++The native adapter does not dynamically recompute or shorten that declaration. ++Before issuance, conservatively require at least the declared TTL plus the 5s ++cleanup reserve within the remaining operation/approval/PDP authority, including ++clock uncertainty. The requested and explicit maximum native token TTL equal ++the declared budget; returned native TTL must be positive and at most that budget. ++The token can expire before the operation deadline; failure or expiry stops work ++without remint. The signed operation deadline remains no later than approval, ++PDP, authenticated caller and both original/fresh source validity. Access's ++trusted source path independently requires the same TTL-plus-5s fit after source ++I/O and an operation interval no longer than 120s. Therefore a structural 120s ++backend budget cannot fit inside a 120s authority interval with the reserve and ++refuses. No renewal or remint extends the operation. CCR38 keeps its + own 300s/eight-use limit; login-self checks and revoke fit its separate budget. + + ## Failure, replay and recovery +diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py +index 1cf4140..e4865bb 100644 +--- a/src/secrets_engine/approval_consume.py ++++ b/src/secrets_engine/approval_consume.py +@@ -382,7 +382,8 @@ def authorize_action( + ), + decision_id=validated.decision_id, + expires_at=validated.expires_at, +- artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope}, ++ artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope, ++ "authenticated_envelope_json":getattr(envelope,"authenticated_json","")}, + sort_keys=True,separators=(",",":"),allow_nan=False), + ) + +diff --git a/src/secrets_engine/decision_signature.py b/src/secrets_engine/decision_signature.py +index 24fa8d3..d9bd0b9 100644 +--- a/src/secrets_engine/decision_signature.py ++++ b/src/secrets_engine/decision_signature.py +@@ -7,6 +7,7 @@ from __future__ import annotations + + import base64 + import json ++import math + from pathlib import Path + import stat + import subprocess +@@ -24,8 +25,15 @@ def _unique_object(pairs): + return result + + ++def _finite_float(value): ++ parsed = float(value) ++ if not math.isfinite(parsed): ++ raise ValueError("nonfinite JSON number") ++ return parsed ++ ++ + def strict_json(data): +- return json.loads(data, object_pairs_hook=_unique_object, ++ return json.loads(data, object_pairs_hook=_unique_object, parse_float=_finite_float, + parse_constant=lambda value: (_ for _ in ()).throw(ValueError("invalid number"))) + + +@@ -66,10 +74,18 @@ def load_trust(path: Path | None) -> dict[str, Any]: + raise DecisionError("access-engine responder trust is missing or invalid") from exc + + ++class AuthenticatedEnvelope(dict): ++ """Verified typed values with exact producer JSON retained for reauthentication.""" ++ def __init__(self, values, authenticated_json): ++ super().__init__(values) ++ self.authenticated_json = authenticated_json ++ ++ + def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) -> dict: + if verifier is None or not Path(verifier).is_absolute(): + raise DecisionError("access-engine signature verifier is not installed") + try: ++ strict_json(body.decode("utf-8")) + result = subprocess.run([str(verifier)], input=b'{"envelope":'+body+b',"keys":'+json.dumps(trust['keys']).encode()+b'}', + capture_output=True, env={}, timeout=3, check=False) + if result.returncode != 0 or len(result.stdout) > 524288: +@@ -78,6 +94,6 @@ def authenticate_response(body: bytes, *, trust: dict, verifier: Path | None) -> + provenance = authenticated['provenance'] + if any(provenance.get(k) != trust[k] for k in ('evaluator','mode','policy_package','policy_version','policy_package_digest')): + raise ValueError("issuer/pin mismatch") +- return authenticated +- except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError) as exc: ++ return AuthenticatedEnvelope(authenticated, result.stdout.decode("utf-8")) ++ except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, RecursionError, UnicodeDecodeError) as exc: + raise DecisionError("access-engine responder authentication failed") from exc +diff --git a/src/secrets_engine/execution_contract.py b/src/secrets_engine/execution_contract.py +index 5dc1cae..f9b2538 100644 +--- a/src/secrets_engine/execution_contract.py ++++ b/src/secrets_engine/execution_contract.py +@@ -153,6 +153,18 @@ def attach_execution_contract(request: dict, contract: ExecutionContract | None) + raise DecisionError('execute-ccr requires platform tenant') + if value['catalog_sha256'] != catalog_target_digest(request['context']['catalog_target']): + raise DecisionError('execution_contract catalog target does not match') ++ if request['subject'] != {'id':'secrets-engine','type':'service'}: ++ raise DecisionError('execute-ccr requires secrets-engine service subject') ++ # Match the producer's fixed tenant normalization before approval issue. ++ request['subject']['tenant'] = 'tenant:platform' ++ request['resource']['tenant'] = 'tenant:platform' ++ # Public declaration from Access ServicePilotInputs.DeclaredServiceBinding; ++ # bound before approval issuance, never an authentication assertion. ++ request['context']['service_actor_binding'] = { ++ 'version':'proposed-service-task-caller/v1', ++ 'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine', ++ 'subject_id':'secrets-engine','subject_type':'service','tenant':'tenant:platform', ++ } + request['context']['execution_contract'] = value + request['context']['human_control'] = True + +diff --git a/src/secrets_engine/task_execution_handoff.py b/src/secrets_engine/task_execution_handoff.py +index 9772861..aa998ab 100644 +--- a/src/secrets_engine/task_execution_handoff.py ++++ b/src/secrets_engine/task_execution_handoff.py +@@ -10,14 +10,16 @@ from __future__ import annotations + from dataclasses import dataclass + from datetime import datetime + import json ++import re + from threading import Lock + + from secrets_engine.approval_auth import approval_token, credential_urlopen + from secrets_engine.approval_claim import binding_from_check_request, claim_binding_digest, validate_approval_claim +-from secrets_engine.approval_consume import authorize_action, consume_approval ++from secrets_engine.approval_consume import authorize_action, consume_approval, _expected_request + from secrets_engine.application_time import read_window, validity_bounds +-from secrets_engine.authorization import validate_decision_envelope, request_digest, approval_binding_digest ++from secrets_engine.authorization import validate_decision_envelope, request_digest, approval_binding_digest, CONTRACT_VERSION + from secrets_engine.decision_signature import authenticate_response, load_trust, strict_json ++from secrets_engine.decision_check import check_decision + from secrets_engine.errors import DecisionError + from secrets_engine.execution_contract import ExecutionContract, require_execution_deadline + +@@ -77,6 +79,93 @@ class TaskExecutionConsumption: + idempotent: bool + + ++def _same_json(left, right): ++ # Type-sensitive semantic correspondence only; never signature material. ++ return json.dumps(left,sort_keys=True,allow_nan=False)==json.dumps(right,sort_keys=True,allow_nan=False) ++ ++ ++def _task_identity(request,envelope,descriptor,upper): ++ binding=envelope['binding'] ++ subject=binding['subject']; resource=binding['resource'] ++ if (request['subject']!={'id':'secrets-engine','type':'service','tenant':'tenant:platform'} ++ or subject.get('id')!='secrets-engine' or subject.get('type')!='service' ++ or subject.get('tenant') not in (None,'tenant:platform') ++ or 'principal' in subject.get('attributes',{}) ++ or not _same_json(envelope['subject'],subject) or not _same_json(envelope['resource'],resource) ++ or descriptor['execution_actor']!='service:secrets-engine' ++ or any(resource.get(key)!=request['resource'].get(key) for key in ('id','type','system')) ++ or resource.get('tenant') not in (None,'tenant:platform') ++ or any(not _same_json(resource.get('attributes',{}).get(key),value) ++ for key,value in request['resource']['attributes'].items()) ++ or any(key in binding['context'] for key in ('task_execution_projection','initiator','represented_principal','delegation','verified','owner_admission'))): ++ _refuse() ++ caller=envelope['provenance'].get('caller') ++ if (not isinstance(caller,dict) or caller.get('mode')!='enforce' ++ or caller.get('principal')!=_CALLER or caller.get('audience')!='flex-auth' ++ or _stamp(caller.get('not_after')) < _stamp(descriptor['operation_deadline']) ++ or upper >= _stamp(caller.get('not_after'))): ++ _refuse() ++ ++ ++@dataclass(frozen=True) ++class TaskApprovalPreparation: ++ """Detached public normalization facts; never authorization or consumption.""" ++ _serialized: str ++ ++ def as_owner_handoff(self): ++ return strict_json(self._serialized) ++ ++ ++def prepare_task_approval(cfg, entry, *, execution_contract): ++ """Read signed DENY normalization before Approval issue; never permit an act. ++ ++ This read-only normalization record has no independent permission window. ++ decision_time must be a valid nonfuture instant; original operation/caller ++ deadlines remain live. Final source Check independently revalidates current ++ registry/source facts; stale normalized facts cannot replace that Check. ++ ++ Use returned authoritative native_binding and approval_binding_digest for ++ issue, preserving original_request. Final Check carries only the original ++ issued claim; producer renormalization independently refuses registry drift. ++ """ ++ if type(execution_contract) is not ExecutionContract: ++ _refuse() ++ request=_expected_request(cfg,entry,'execute-ccr',execution_contract=execution_contract) ++ window=read_window(cfg) ++ require_execution_deadline(request,time_window=window) ++ envelope=check_decision(base_url=str(cfg.pdp_url),token_file=cfg.pdp_token_file,request=request, ++ trust_file=cfg.pdp_trust_file,verifier=cfg.pdp_verifier) ++ try: ++ window=read_window(cfg);lower,upper=validity_bounds(window=window) ++ descriptor=execution_contract.as_dict();binding=envelope['binding'];provenance=envelope['provenance'] ++ if (envelope.get('contract_version')!=CONTRACT_VERSION or envelope.get('effect')!='deny' or not isinstance(envelope.get('id'),str) or not envelope['id'] ++ or provenance.get('policy_package')!=str(cfg.authorization_policy_package) ++ or provenance.get('policy_version')!=str(cfg.authorization_policy_version) ++ or not isinstance(provenance.get('registry_snapshot_digest'),str) ++ or re.fullmatch(r'sha256:[0-9a-f]{64}',provenance['registry_snapshot_digest']) is None ++ or request['action']!='execute-ccr' or binding.get('action')!='execute-ccr' ++ or request['tenant']!='tenant:platform' or binding.get('tenant')!='tenant:platform' ++ or 'approval' in request['context'] or 'approval' in binding['context'] ++ or not _same_json(binding.get('context'),request['context']) ++ or binding.get('submitted_request_digest')!=request_digest(request) ++ or binding.get('request_digest')!=request_digest(binding) ++ or binding.get('approval_binding_digest')!=binding['request_digest'] ++ or not _same_json(envelope.get('subject'),binding['subject']) or not _same_json(envelope.get('resource'),binding['resource']) ++ or _stamp(provenance.get('decision_time'))>upper): ++ _refuse() ++ _task_identity(request,envelope,descriptor,upper) ++ require_execution_deadline(request,time_window=window,bounds=(provenance['caller']['not_after'],)) ++ native=binding_from_check_request(binding) ++ claim_binding_digest(**native) # Require native JSON profile, not a translated digest. ++ result={'original_request':request,'submitted_request_digest':binding['submitted_request_digest'], ++ 'native_binding':native,'approval_binding_digest':binding['approval_binding_digest'], ++ 'decision_id':envelope['id'],'execution_contract':descriptor, ++ 'registry_snapshot_digest':provenance['registry_snapshot_digest']} ++ return TaskApprovalPreparation(json.dumps(result,sort_keys=True,separators=(',',':'),allow_nan=False)) ++ except (ValueError,KeyError,TypeError,RecursionError,AttributeError) as exc: ++ raise DecisionError('task approval preparation malformed') from exc ++ ++ + def _validate(cfg, action, contract): + if type(contract) is not ExecutionContract or not action.artifacts: + _refuse() +@@ -85,10 +174,10 @@ def _validate(cfg, action, contract): + request,claim,envelope=(artifacts[name] for name in ('request','claim','envelope')) + # Rotation/removal and installed-helper changes cannot hide behind a cached + # prepared label. Reauthenticate before each strict CAS, without networking. +- authenticated=authenticate_response(json.dumps(envelope,allow_nan=False).encode(), ++ authenticated=authenticate_response(artifacts["authenticated_envelope_json"].encode("utf-8"), + trust=load_trust(getattr(cfg,'pdp_trust_file',None)), + verifier=getattr(cfg,'pdp_verifier',None)) +- if authenticated != envelope: ++ if json.dumps(authenticated,sort_keys=True,allow_nan=False) != json.dumps(envelope,sort_keys=True,allow_nan=False): + _refuse() + window=read_window(cfg) + lower,upper=validity_bounds(window=window) +@@ -107,31 +196,14 @@ def _validate(cfg, action, contract): + _refuse() + if (request['action']!='execute-ccr' or binding.get('action')!='execute-ccr' + or request['tenant']!='tenant:platform' or binding.get('tenant')!='tenant:platform' +- or binding.get('context')!=request['context'] +- or request['context'].get('execution_contract')!=descriptor ++ or not _same_json(binding.get('context'),request['context']) ++ or not _same_json(request['context'].get('execution_contract'),descriptor) + or binding.get('request_digest')!=request_digest(binding) + or binding.get('approval_binding_digest')!=approval_binding_digest(binding) + or action.binding.request_digest!=validated.request_digest + or action.binding.decision_id!=validated.decision_id): + _refuse() +- subject=binding['subject']; resource=binding['resource'] +- if (request['subject']!={'id':'secrets-engine','type':'service'} +- or subject.get('id')!='secrets-engine' or subject.get('type')!='service' +- or subject.get('tenant') not in (None,'tenant:platform') +- or 'principal' in subject.get('attributes',{}) +- or envelope['subject']!=subject or envelope['resource']!=resource +- or descriptor['execution_actor']!='service:secrets-engine' +- or any(resource.get(key)!=request['resource'].get(key) for key in ('id','type','system')) +- or resource.get('tenant') not in (None,'tenant:platform') +- or any(resource.get('attributes',{}).get(key)!=value for key,value in request['resource']['attributes'].items()) +- or any(key in binding['context'] for key in ('task_execution_projection','initiator','represented_principal','delegation','verified','owner_admission'))): +- _refuse() +- caller=envelope['provenance'].get('caller') +- if (not isinstance(caller,dict) or caller.get('mode')!='enforce' +- or caller.get('principal')!=_CALLER or caller.get('audience')!='flex-auth' +- or _stamp(caller.get('not_after')) < _stamp(descriptor['operation_deadline']) +- or upper >= _stamp(caller.get('not_after'))): +- _refuse() ++ _task_identity(request,envelope,descriptor,upper) + native=binding_from_check_request(binding) + supplied=claim['binding'] + # Recompute the served native act too: Python equality alone conflates +@@ -140,6 +212,22 @@ def _validate(cfg, action, contract): + or supplied.get('digest')!=claim_binding_digest(**native) + or supplied.get('digest')!=claim_binding_digest(**{key:supplied.get(key) for key in _NATIVE_FIELDS})): + _refuse() ++ observation=envelope['provenance'].get('approval_observation') ++ if observation is not None: ++ # Signed producer source evidence supplements the original carried claim; ++ # it cannot replace original context, extend freshness, or authorize CAS. ++ if (not isinstance(observation,dict) ++ or any(not isinstance(observation.get(k),str) or not observation[k] ++ for k in ('source_id','source_revision')) ++ or any(not isinstance(observation.get(k),str) ++ or re.fullmatch(r'sha256:[0-9a-f]{64}',observation[k]) is None ++ for k in ('configuration_digest','claim_digest','native_binding_digest')) ++ or observation['native_binding_digest']!=supplied['digest'] ++ or _stamp(observation.get('observed_at'))>_stamp(observation.get('validated_at')) ++ or _stamp(observation.get('validated_at'))>upper ++ or upper>=_stamp(observation.get('not_after')) ++ or _stamp(envelope['lifetime']['expires_at'])>_stamp(observation.get('not_after'))): ++ _refuse() + return {'execution_contract':descriptor,'approval_id':action.binding.approval_id, + 'request_digest':validated.request_digest,'decision_id':validated.decision_id, + 'native_binding':native,'approval_binding_digest':binding['approval_binding_digest'], +diff --git a/tests/fixtures/pdp-signature/approval-native-vectors.json b/tests/fixtures/pdp-signature/approval-native-vectors.json +new file mode 100644 +index 0000000..46adb26 +--- /dev/null ++++ b/tests/fixtures/pdp-signature/approval-native-vectors.json +@@ -0,0 +1,210 @@ ++[ ++ { ++ "input_target_json": "{\"value\":true}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": true ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":true}}", ++ "native_digest": "sha256:e1f537daa849e5da023efb5f3d62cc33d94cce20f9ca9c5a708c76e225fd702f" ++ }, ++ { ++ "input_target_json": "{\"value\":1}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1}}", ++ "native_digest": "sha256:3a3d2da6267e4b917505a7c2247e6ce36fd8ee074eef37251bcc66839b602332" ++ }, ++ { ++ "input_target_json": "{\"value\":1.0}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1.0 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1.0}}", ++ "native_digest": "sha256:21c2cdb9b161071ebfe361e50703511b2abf6c6f327f153282d247aa92c3898e" ++ }, ++ { ++ "input_target_json": "{\"value\":false}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": false ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":false}}", ++ "native_digest": "sha256:278cbcd013f7923db5258dd860f911d688505a8259f15ccb77983b044f312c74" ++ }, ++ { ++ "input_target_json": "{\"value\":0}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 0 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":0}}", ++ "native_digest": "sha256:e691b8fb55d4bf82a310dee37c045331100c8031e26ea6f51bdcceeeb3b1f20e" ++ }, ++ { ++ "input_target_json": "{\"value\":-0.0}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": -0.0 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":-0.0}}", ++ "native_digest": "sha256:eccde458c686bff67506bb2d94c750e648a311bf23967cb22b37f11e84b6844f" ++ }, ++ { ++ "input_target_json": "{\"value\":1e-7}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1e-07 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e-07}}", ++ "native_digest": "sha256:779042b6c0e675f6aacdcd3a02199aab66f534bb84dd410a95c18657afae563c" ++ }, ++ { ++ "input_target_json": "{\"value\":1e-6}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1e-06 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e-06}}", ++ "native_digest": "sha256:d021f402231fcd59e764b31e6f08a5298ebb328dfd93da58ecde8feb11fde0b5" ++ }, ++ { ++ "input_target_json": "{\"value\":1e20}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1e+20 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e+20}}", ++ "native_digest": "sha256:14270ffca4ab10e9d9302d52e988b4aa3e2facd494c12a60e2edafef70277a20" ++ }, ++ { ++ "input_target_json": "{\"value\":1e21}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 1e+21 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":1e+21}}", ++ "native_digest": "sha256:75a60a595b2578430b0be466b6bf9a46b62ddc99bbbcebd7fa0e8d22e3078c0a" ++ }, ++ { ++ "input_target_json": "{\"value\":2.5e-7}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 2.5e-07 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":2.5e-07}}", ++ "native_digest": "sha256:50af37ff75a3e7a22629e746f814995c0c2cd96adb7f1ed5b26999b71d0ac498" ++ }, ++ { ++ "input_target_json": "{\"value\":9007199254740993}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "value": 9007199254740993 ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"value\":9007199254740993}}", ++ "native_digest": "sha256:765360dc5a1d949bd114c6b749d83069123d8b4b6b1d939f831428a800f50dab" ++ }, ++ { ++ "input_target_json": "{\"html\":\"<>&\", \"unicode\":\"é雪😀\", \"separator\":\"\\u2028\\u2029\"}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "html": "<>&", ++ "unicode": "é雪😀", ++ "separator": "

" ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"html\":\"<>&\",\"separator\":\"

\",\"unicode\":\"é雪😀\"}}", ++ "native_digest": "sha256:c203d934743e2954342038a3746b6c37a0f3101e886ef855b1c6ad824adf5dea" ++ }, ++ { ++ "input_target_json": "{\"b\":[true,1,1.0,null,{\"z\":\"x\",\"a\":\"y\"}],\"a\":{}}", ++ "binding": { ++ "action": "secrets.kv.destroy", ++ "target": { ++ "b": [ ++ true, ++ 1, ++ 1.0, ++ null, ++ { ++ "z": "x", ++ "a": "y" ++ } ++ ], ++ "a": {} ++ }, ++ "actor": "agt-secrets-engine", ++ "principal": "bernd", ++ "purpose": "rotate-exposed-key" ++ }, ++ "canonical_binding_utf8": "{\"action\":\"secrets.kv.destroy\",\"actor\":\"agt-secrets-engine\",\"principal\":\"bernd\",\"purpose\":\"rotate-exposed-key\",\"target\":{\"a\":{},\"b\":[true,1,1.0,null,{\"a\":\"y\",\"z\":\"x\"}]}}", ++ "native_digest": "sha256:56306505c882af6edfae5f3d2a5b42e92737a0859236fe5574a21d1874655d51" ++ } ++] +diff --git a/tests/test_execution_context.py b/tests/test_execution_context.py +index 23b6419..70d9415 100644 +--- a/tests/test_execution_context.py ++++ b/tests/test_execution_context.py +@@ -133,7 +133,7 @@ def test_closed_schema_refuses(mutation): + + @pytest.mark.parametrize('action', ['apply','rotate','exec','execute-ccr']) + def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action): +- e=entry(); cfg=_Cfg(_token(tmp_path)); calls=[] ++ e=entry(); cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[] + def fetch(*args,**kwargs): calls.append('claim'); raise AssertionError + contract=None if action=='execute-ccr' else ExecutionContract.parse(descriptor(e)) + with pytest.raises(DecisionError): +@@ -143,7 +143,7 @@ def test_invalid_action_or_absent_contract_refuses_before_claim(tmp_path,action) + + def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path): + e=entry(); typed=ExecutionContract.parse(descriptor(e)); e=replace(e,path='changed/path') +- cfg=_Cfg(_token(tmp_path)); calls=[] ++ cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[] + with pytest.raises(DecisionError,match='catalog'): + resolve_approval_observation(cfg,e,'execute-ccr',None,execution_contract=typed,opener=lambda *a,**k:calls.append('claim')) + assert not calls +@@ -154,7 +154,7 @@ def test_catalog_mismatch_and_unserved_execution_refuse(tmp_path): + + def test_expired_contract_refuses_before_claim(tmp_path): + e=entry(); raw=descriptor(e); raw['operation_deadline']=(datetime.now(timezone.utc)-timedelta(seconds=1)).isoformat() +- cfg=_Cfg(_token(tmp_path)); calls=[] ++ cfg=_Cfg(_token(tmp_path),authorization_subject_id='secrets-engine',authorization_subject_type='service'); calls=[] + with pytest.raises(DecisionError,match='expired'): + authorize_action(cfg,e,'execute-ccr',execution_contract=ExecutionContract.parse(raw),opener=lambda *a,**k:calls.append('claim')) + assert not calls +@@ -200,3 +200,22 @@ def test_deadline_beyond_claim_refuses_before_pdp(tmp_path): + from tests.test_consume_binding_join import admitted_native_signer + + from tests.signature_support import signature_tools ++ ++ ++def test_execution_service_declaration_is_fixed_before_issue(): ++ e=entry();contract=ExecutionContract.parse(descriptor(e)) ++ built=request(e,contract) ++ assert built['context']['service_actor_binding']=={ ++ 'version':'proposed-service-task-caller/v1', ++ 'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine', ++ 'subject_id':'secrets-engine','subject_type':'service','tenant':'tenant:platform'} ++ assert built['subject']=={'id':'secrets-engine','type':'service','tenant':'tenant:platform'} ++ assert built['resource']['tenant']=='tenant:platform' ++ assert 'service_actor_binding' not in build_action_request(e,'apply',subject_id='secrets-engine',subject_type='service',purpose='metadata-test')['context'] ++ ++ ++@pytest.mark.parametrize('subject_id,subject_type',[('other','service'),('secrets-engine','Human'),('user:alice','Human')]) ++def test_execution_declaration_cannot_assert_foreign_service(subject_id,subject_type): ++ e=entry();contract=ExecutionContract.parse(descriptor(e)) ++ with pytest.raises(DecisionError,match='service subject'): ++ build_action_request(e,'execute-ccr',subject_id=subject_id,subject_type=subject_type,purpose='metadata-test',execution_contract=contract) +diff --git a/tests/test_task_approval_preparation.py b/tests/test_task_approval_preparation.py +new file mode 100644 +index 0000000..96b254f +--- /dev/null ++++ b/tests/test_task_approval_preparation.py +@@ -0,0 +1,89 @@ ++"""Signed DENY supplies preapproval facts; it never supplies execution authority.""" ++import copy ++import pytest ++from secrets_engine.authorization import request_digest ++from secrets_engine.errors import DecisionError ++from secrets_engine.task_execution_handoff import prepare_task_approval,consume_task_execution ++from tests.test_task_execution_handoff import lane,prepared ++from tests.signature_support import signature_tools ++ ++ ++def normalize(envelope): ++ envelope['effect']='deny' ++ envelope['provenance']['registry_snapshot_digest']='sha256:'+'4'*64 ++ envelope['binding']['subject']['attributes']={'roles':['Operator'],'display_name':'registry service'} ++ envelope['binding']['request_digest']=request_digest(envelope['binding']) ++ envelope['binding']['approval_binding_digest']=envelope['binding']['request_digest'] ++ ++ ++def test_signed_registry_normalization_is_detached_and_cannot_consume(lane): ++ cfg,e,contract,state=lane ++ state['decision_change']=normalize ++ result=prepare_task_approval(cfg,e,execution_contract=contract) ++ output=result.as_owner_handoff() ++ assert state['calls']==['check'] ++ assert 'approval' not in output['original_request']['context'] ++ assert output['approval_binding_digest']!=output['submitted_request_digest'] ++ assert 'attributes' not in output['original_request']['subject'] ++ assert output['execution_contract']==contract.as_dict() ++ assert output['native_binding']['target']==output['original_request']['resource'] ++ output['original_request']['context']['purpose']='mutated' ++ assert result.as_owner_handoff()['original_request']['context']['purpose']!='mutated' ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ assert state['calls']==['check'] ++ ++ ++@pytest.mark.parametrize('case',['allow','context','descriptor-type','submitted','evaluated','approval-digest','caller','native','future-time','policy']) ++def test_signed_normalization_refuses_changed_correspondence(lane,case): ++ cfg,e,contract,state=lane ++ def change(envelope): ++ normalize(envelope) ++ if case=='allow':envelope['effect']='allow' ++ elif case=='context':envelope['binding']['context']['purpose']='other' ++ elif case=='descriptor-type':envelope['binding']['context']['execution_contract']['backend_budget']['ttl_seconds']=120.0 ++ elif case=='submitted':envelope['binding']['submitted_request_digest']='sha256:'+'0'*64 ++ elif case=='evaluated':envelope['binding']['request_digest']='sha256:'+'0'*64 ++ elif case=='approval-digest':envelope['binding']['approval_binding_digest']='sha256:'+'0'*64 ++ elif case=='caller':envelope['provenance']['caller']['mode']='disabled' ++ elif case=='native':envelope['binding']['resource']['system']='other' ++ elif case=='future-time':envelope['provenance']['decision_time']='2099-01-01T00:00:00Z' ++ else:envelope['provenance']['policy_version']='other' ++ state['decision_change']=change ++ with pytest.raises(DecisionError):prepare_task_approval(cfg,e,execution_contract=contract) ++ assert 'claim' not in state['calls'] and 'consume' not in state['calls'] ++ ++ ++def test_signed_context_numeric_kind_cannot_change(lane): ++ cfg,e,contract,state=lane ++ state['decision_change']=lambda envelope:envelope['binding']['context']['execution_contract']['backend_budget'].__setitem__('ttl_seconds',120.0) ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] ++ ++ ++@pytest.mark.parametrize('original,served',[(1,True),(1,1.0),(1.0,1),(False,0)]) ++def test_shared_original_target_subset_is_type_sensitive(lane,original,served): ++ from datetime import datetime,timedelta,timezone ++ from secrets_engine.task_execution_handoff import _task_identity ++ from secrets_engine.approval_consume import _expected_request ++ cfg,e,contract,state=lane ++ request=_expected_request(cfg,e,'execute-ccr',execution_contract=contract) ++ request['resource']['attributes']['requested_number']=original ++ binding=copy.deepcopy(request);binding['resource']['attributes']['requested_number']=served ++ envelope={'binding':binding,'subject':binding['subject'],'resource':binding['resource'], ++ 'provenance':{'caller':{'mode':'enforce','principal':'system:serviceaccount:secrets-engine:secrets-engine', ++ 'audience':'flex-auth','not_after':(datetime.now(timezone.utc)+timedelta(minutes=20)).isoformat()}}} ++ with pytest.raises(DecisionError):_task_identity(request,envelope,contract.as_dict(),datetime.now(timezone.utc)) ++ binding['resource']['attributes']['requested_number']=original ++ binding['resource']['attributes']['legitimate_extra_registry_fact']=1.0 ++ _task_identity(request,envelope,contract.as_dict(),datetime.now(timezone.utc)) ++ ++ ++def test_old_normalization_record_has_no_permission_window(lane): ++ cfg,e,contract,state=lane ++ def old(envelope): ++ normalize(envelope) ++ envelope['provenance']['decision_time']='2000-01-01T00:00:00Z' ++ state['decision_change']=old ++ normalization=prepare_task_approval(cfg,e,execution_contract=contract) ++ with pytest.raises(DecisionError):consume_task_execution(cfg,normalization) ++ assert state['calls']==['check'] +diff --git a/tests/test_trusted_successor.py b/tests/test_trusted_successor.py +new file mode 100644 +index 0000000..3c47a1d +--- /dev/null ++++ b/tests/test_trusted_successor.py +@@ -0,0 +1,80 @@ ++"""Owner signature interoperability, independent synthetic admission only.""" ++import copy ++import json ++from pathlib import Path ++import subprocess ++ ++import pytest ++from secrets_engine.decision_signature import authenticate_response,load_trust ++from secrets_engine.errors import DecisionError ++from tests.test_decision_signature import FIXTURE,trust_file,verifier ++from tests.signature_support import signature_tools ++from tests.test_task_execution_handoff import lane,prepared,consume_task_execution ++ ++VECTORS=json.loads((Path(__file__).parent/'fixtures/pdp-signature/approval-native-vectors.json').read_text()) ++ ++@pytest.mark.parametrize('vector',VECTORS) ++def test_fourteen_owner_native_types_retain_signed_bytes(vector,trust_file,signature_tools): ++ env=json.loads(FIXTURE.read_text());env.pop('signature',None) ++ env['diagnostics']={'native_vector':'REPLACE_RAW_TARGET'} ++ raw=json.dumps(env).replace('"REPLACE_RAW_TARGET"',vector['input_target_json']).encode() ++ signed=subprocess.run([str(signature_tools/'sign')],input=raw,capture_output=True,check=True).stdout ++ result=authenticate_response(signed,trust=load_trust(trust_file),verifier=signature_tools/'verify') ++ again=authenticate_response(result.authenticated_json.encode(),trust=load_trust(trust_file),verifier=signature_tools/'verify') ++ assert again['diagnostics']['native_vector']==json.loads(vector['input_target_json']) ++ tampered=signed.replace(b'"action":"rotate"',b'"action":"destroy"') ++ assert tampered!=signed ++ with pytest.raises(DecisionError):authenticate_response(tampered,trust=load_trust(trust_file),verifier=signature_tools/'verify') ++ ++ ++def test_unknown_provenance_refuses(trust_file,signature_tools): ++ env=json.loads(FIXTURE.read_text());env['provenance']['unknown_source_authority']=True ++ with pytest.raises(DecisionError): ++ authenticate_response(json.dumps(env).encode(),trust=load_trust(trust_file),verifier=signature_tools/'verify') ++ ++ ++def test_exact_original_numeric_lexeme_survives_strict_cas(lane,monkeypatch,signature_tools): ++ import tests.test_task_execution_handoff as fixtures ++ cfg,e,contract,state=lane ++ def raw_sign(env,tools): ++ env['diagnostics']={'numeric':'LEXEME'} ++ raw=json.dumps(env).replace('"LEXEME"','1e-7').encode() ++ return subprocess.run([str(tools/'sign')],input=raw,capture_output=True,check=True).stdout ++ monkeypatch.setattr(fixtures,'sign_envelope',raw_sign) ++ authorization=prepared(lane) ++ assert '1e-7' in authorization._action.artifacts ++ assert consume_task_execution(cfg,authorization).idempotent is False ++ ++ ++def source_evidence(envelope,claim): ++ from datetime import datetime,timezone ++ return {'source_id':'synthetic-source','source_revision':'synthetic-revision', ++ 'configuration_digest':'sha256:'+'2'*64,'claim_digest':'sha256:'+'3'*64, ++ 'native_binding_digest':claim['binding']['digest'], ++ 'observed_at':datetime.now(timezone.utc).isoformat(), ++ 'validated_at':datetime.now(timezone.utc).isoformat(), ++ 'not_after':envelope['lifetime']['expires_at']} ++ ++ ++def test_signed_source_evidence_keeps_original_context_and_cas(lane): ++ cfg,e,contract,state=lane ++ state['decision_change']=lambda env:env['provenance'].__setitem__('approval_observation',source_evidence(env,state['claim'])) ++ authorization=prepared(lane) ++ snapshot=json.loads(authorization._action.artifacts) ++ assert snapshot['envelope']['binding']['context']==snapshot['request']['context'] ++ assert snapshot['request']['context']['approval']==state['claim'] ++ assert consume_task_execution(cfg,authorization).idempotent is False ++ ++ ++@pytest.mark.parametrize('field,value',[ ++ ('source_id',''),('source_revision',''),('configuration_digest','wrong'),('claim_digest','wrong'), ++ ('native_binding_digest','sha256:'+'0'*64),('observed_at','2099-01-01T00:00:00Z'), ++ ('validated_at','2099-01-01T00:00:00Z'),('not_after','2000-01-01T00:00:00Z')]) ++def test_signed_invalid_source_evidence_refuses_before_cas(lane,field,value): ++ cfg,e,contract,state=lane ++ def change(env): ++ evidence=source_evidence(env,state['claim']);evidence[field]=value ++ env['provenance']['approval_observation']=evidence ++ state['decision_change']=change ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] +diff --git a/verifier/README.md b/verifier/README.md +index a63b2a0..fe7ec93 100644 +--- a/verifier/README.md ++++ b/verifier/README.md +@@ -1,7 +1,7 @@ + # Access Engine decision verifier + + The API types and `internal/sign/sign.go` are unchanged producer source, pinned +-by revision and SHA-256 in `producer-source.json`. License attribution and the ++by source revision/proposal and SHA-256 in `producer-source.json`. License attribution and the + original license text are in `LICENSE.access-engine`. This is source vendoring + necessitated by the producer's Go `internal` package boundary, not a separately + maintained crypto implementation. There are no external Go dependencies. +@@ -33,3 +33,23 @@ For the complete reproducible CLI build and offline fresh-prefix installation, + use [the release bundle contract](../docs/cli-release-bundle.md). This replaces + manual helper copying for packaged deployments; package and key admission remain + separate owner decisions. ++ ++The trusted-service successor snapshot follows the frozen Access B2/B3 proposal lineage, ++identified by its patch SHA-256 in `producer-source.json`; this is source review, ++not a claim of published producer revision or runtime/key admission. The wrapper ++uses producer-owned `sign.DecodeEnvelope` to preserve JSON numeric lexemes in ++signed maps. It retains exact authenticated producer JSON through strict ++preparation and CAS reauthentication; Python does not recreate signed bytes. ++Approval's fourteen native binding vectors are signature interoperability cases, ++not an expansion of existing request numeric admission. Signed ++`approval_observation` supplements the original immutable carried claim; ++strict handoff checks its native digest and observation/deadline bounds. It never ++replaces original context or freshness. ++ ++Successor R2 execution builder correspondence emits the producer's fixed public ++DeclaredServiceBinding only for the typed prod/platform secrets-engine service ++lane before approval issuance. Both reference tenants are explicit to match ++existing producer normalization. It is not caller authentication; independently ++signed enforced caller, exact original/native/descriptor joins and all existing ++approval, expiry and CAS gates still apply. Actual production source-server ++interoperability receipts are separate from native runtime admission. +diff --git a/verifier/internal/sign/sign.go b/verifier/internal/sign/sign.go +index fc607f7..011f8f7 100644 +--- a/verifier/internal/sign/sign.go ++++ b/verifier/internal/sign/sign.go +@@ -4,10 +4,12 @@ + package sign + + import ( ++ "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" ++ "io" + + "github.com/netkingdom/access-engine/pkg/api" + ) +@@ -74,3 +76,20 @@ func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) er + } + return nil + } ++ ++// DecodeEnvelope preserves JSON numeric kinds/lexemes emitted by task inputs. ++// Legacy producer envelopes remain byte-compatible. It does not invent another ++// signature canonicalization: CanonicalBytes still calls typed json.Marshal. ++func DecodeEnvelope(raw []byte) (api.DecisionEnvelope, error) { ++ var envelope api.DecisionEnvelope ++ decoder := json.NewDecoder(bytes.NewReader(raw)) ++ decoder.UseNumber() ++ decoder.DisallowUnknownFields() ++ if err := decoder.Decode(&envelope); err != nil { ++ return envelope, err ++ } ++ if _, err := decoder.Token(); err != io.EOF { ++ return api.DecisionEnvelope{}, fmt.Errorf("trailing envelope data") ++ } ++ return envelope, nil ++} +diff --git a/verifier/main.go b/verifier/main.go +index 1f3f9de..7449c64 100644 +--- a/verifier/main.go ++++ b/verifier/main.go +@@ -2,13 +2,11 @@ + package main + + import ( +- "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/netkingdom/access-engine/internal/sign" +- "github.com/netkingdom/access-engine/pkg/api" + "io" + "os" + ) +@@ -23,10 +21,8 @@ func run() error { + if err := json.NewDecoder(io.LimitReader(os.Stdin, 1048577)).Decode(&in); err != nil { + return err + } +- var envelope api.DecisionEnvelope +- decoder := json.NewDecoder(bytes.NewReader(in.Envelope)) +- decoder.DisallowUnknownFields() +- if err := decoder.Decode(&envelope); err != nil { ++ envelope, err := sign.DecodeEnvelope(in.Envelope) ++ if err != nil { + return err + } + keys := map[string]ed25519.PublicKey{} +diff --git a/verifier/pkg/api/canonical.go b/verifier/pkg/api/canonical.go +index 3baa784..ae11a2d 100644 +--- a/verifier/pkg/api/canonical.go ++++ b/verifier/pkg/api/canonical.go +@@ -635,16 +635,30 @@ type Obligation struct { + + // DecisionProvenance captures evaluator and policy provenance. + type DecisionProvenance struct { +- Evaluator string `json:"evaluator" yaml:"evaluator"` +- Mode string `json:"mode" yaml:"mode"` +- PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"` +- PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` +- PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"` +- RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"` +- DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"` +- InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"` +- DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"` +- Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"` ++ Evaluator string `json:"evaluator" yaml:"evaluator"` ++ Mode string `json:"mode" yaml:"mode"` ++ PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"` ++ PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` ++ PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"` ++ RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"` ++ DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"` ++ InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"` ++ DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"` ++ ApprovalObservation *ApprovalObservationProvenance `json:"approval_observation,omitempty" yaml:"approval_observation,omitempty"` ++ Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"` ++} ++ ++// ApprovalObservationProvenance binds fresh server-owned fact evidence without ++// rewriting the original carried claim/context or either PDP request digest. ++type ApprovalObservationProvenance struct { ++ SourceID string `json:"source_id"` ++ SourceRevision string `json:"source_revision"` ++ ConfigurationDigest string `json:"configuration_digest"` ++ ClaimDigest string `json:"claim_digest"` ++ NativeBindingDigest string `json:"native_binding_digest"` ++ ObservedAt string `json:"observed_at"` ++ NotAfter string `json:"not_after"` ++ ValidatedAt string `json:"validated_at"` + } + + // CallerProvenance records how the request was authenticated to flex-auth. +diff --git a/verifier/producer-source.json b/verifier/producer-source.json +index b6b0cba..d34b5a6 100644 +--- a/verifier/producer-source.json ++++ b/verifier/producer-source.json +@@ -1,15 +1,21 @@ + { + "upstream": "https://github.com/netkingdom/access-engine", +- "revision": "62ac2b4e99dec2005c7c407fe633612578a7907d", ++ "revision": "B2/B3 R3 retained-registry source proposal over B1 R2; canonical native-prep base 919ce25", + "files": { +- "pkg/api/canonical.go": "21e754617c2b82d673cef31b7075f8d177e4367782b63b20b37125587bf9e300", ++ "pkg/api/canonical.go": "a122a2e8cd524333b13c5939c7a3685e41efbe47497a64e0c7297c92b7f10d19", + "pkg/api/caring.go": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3", + "pkg/api/resource_manifest.go": "f0fa4080287d389ed48c2b1d6af96cc62e57ef72c902333f677f964420be133f", + "pkg/api/doc.go": "0638f06ae47369899dd1e2dd5dd23a7200e7dbb3b505b04f4d3c78c0a074def9", +- "internal/sign/sign.go": "dad2d0c9f6d1fb2fd68e774949ccdf954382acd075191b5ddfea2c3cc2f4cb62" ++ "internal/sign/sign.go": "0df9515922e90775ebbfac798361dcafb20da8a4213796b44e246b8a92046cde" + }, + "fixtures": { + "decision_rotate_signed.json": "a7bc499f3583f53551cf163109a3c108188345100176092adc31eccefc222fd8", + "decision_rotate_signed_tampered.json": "7f2d2e9372a9617bd4b197033f09a5e33a08b202046de8109582da1dc90cb527" ++ }, ++ "source_proposal": { ++ "patch_sha256": "46bfeb706d3a88bf6054309cc9303eb360a91e57dda3b0fcbb7b80d551a75db6", ++ "predecessor_patch_sha256": "ed85a55b54f8380a657c0688d6f216699a0873b1b94b4c0c2e09332620c8ebf4", ++ "api_sign_bytes_unchanged": true, ++ "status": "source proposal; no runtime or key admission" + } + } +diff --git a/verifier/testsupport/signer/main.go b/verifier/testsupport/signer/main.go +index 2a7fecd..994ea82 100644 +--- a/verifier/testsupport/signer/main.go ++++ b/verifier/testsupport/signer/main.go +@@ -6,7 +6,7 @@ import ( + "crypto/ed25519" + "encoding/json" + "github.com/netkingdom/access-engine/internal/sign" +- "github.com/netkingdom/access-engine/pkg/api" ++ "io" + "os" + ) + +@@ -17,8 +17,12 @@ func main() { + seed = 0x43 + kid = "next-admitted" + } +- var env api.DecisionEnvelope +- if json.NewDecoder(os.Stdin).Decode(&env) != nil { ++ raw, err := io.ReadAll(os.Stdin) ++ if err != nil { ++ os.Exit(1) ++ } ++ env, err := sign.DecodeEnvelope(raw) ++ if err != nil { + os.Exit(1) + } + if sign.Sign(&env, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, 32)), kid) != nil { +diff --git a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md +index a8a3845..a7e1e3a 100644 +--- a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md ++++ b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md +@@ -124,3 +124,19 @@ coercion/idempotence, expiry, signer removal and replay refusal. No service bind + propagation, policy gate flip, identity mapping admission or production trust/key + change was made. Independent source integration/review and actual whole-provider + proof remain required. T01/T03 and WP8-T06 retain their open native obligations. ++ ++2026-10-16 retained-registry successor source preparation adds only the existing ++signed Check/DENY preapproval flow. The original typed service proposal is sent ++unchanged; normalized native act and evaluated approval-free digest from the ++admitted producer record are used for normal Approval issuance. The original ++proposal then receives only the original issued claim. Final Check re-normalizes ++against retained owner registry/source facts, so authoritative drift refuses ++before CAS. No local registry algorithm, new endpoint, empty-registry workaround ++or consumer digest waiver is used. Signed DENY preparation has no independent ++permission/freshness window and cannot consume or execute; current caller and ++operation bounds are checked, and final signed ALLOW/source checks remain ++mandatory. Exact original JSON kinds and requested-target subsets refuse ++bool/int/float substitution while allowing extra authoritative registry facts. ++Actual provider proof preparation is separately waiting on frozen Access source ++and Approval's timestamp/transaction-clock repair; source native/trust/custody and ++actual task admission remain open. T01/T03 and WP8-T06 are not closed. +diff --git a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md +index 28102cf..f65ac96 100644 +--- a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md ++++ b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md +@@ -114,3 +114,64 @@ producer candidate OCI and actual runtime/config placement remain missing. The + empty-key trust template deliberately refuses. No install, key read, caller mint, + protected effect, policy switch or trust admission occurred. T02 remains progress, + T03 wait; task source/live obligations remain separately in SECRETS-WP-0013. ++ ++2026-10-16 trusted successor source preparation: refreshed exact API/sign source ++from frozen Access B2/B3 R1 proposal (patch SHA-256 ++6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1). ++Producer DecodeEnvelope preserves signed numeric lexemes; strict CAS retains and ++reauthenticates original producer JSON. Existing request/claim/native/caller joins ++remain mandatory; signed source observation additionally binds the native digest ++and caps decision lifetime. Fourteen Approval owner vectors, legacy signatures, ++tamper/unknown provenance and source deadline refusals are locally checked. ++Actual ApprovalApp-to-Access v2 signed response verifies with the consumer helper; ++actual source revocation refuses. Actual builder-to-v2 strict positive execution ++is separately blocked on missing owner-reviewed service_actor_binding mapping; ++no caller context injection or equality relaxation hides that gap. ++Artifacts: Custodian `decision-trusted-successor/`. T02 remains progress, T03 ++wait; no package/key/policy/native admission or runtime installation occurred. ++ ++2026-10-16 successor R2 source preparation resolves the preceding builder gap: ++only typed prod/platform execute-ccr with exact secrets-engine service subject ++emits Access's fixed five-field public service_actor_binding before approval ++issue. Subject/resource platform tenants are explicit before issue, matching the ++producer's existing normalization and native act; lifecycle requests are unchanged. ++Original carried context is still immutable. Actual builder -> Approval App -> ++Access v2 signed ALLOW -> strict preparation/CAS -> independent Approval confirmation ++GET passes with synthetic scoped identities/trust. Missing declaration, descriptor/ ++native changes, stale original claim, signature tamper and replay refuse. The ++operation deadline is explicit whole seconds within v2's 15-second allow TTL; ++no deadline is extended or waived. This resolves source correspondence only; ++T02 remains progress/T03 wait, with no native trust/grant/policy admission. ++ ++2026-10-16 temporal successor source proof: Access B2/B3 R2 replaces its earlier ++15s lifetime with the exact immutable operation deadline, bounded by its 120s ++hard cap, original/fresh source and caller bounds, and post-I/O backend TTL+5s ++fit. Verifier API/sign bytes are unchanged; source provenance records R2. Secrets ++runtime and strict entire-operation deadline checks are unchanged. Contract prose ++now states the actual fixed backend TTL and conservative fit rather than dynamic ++TTL recomputation, and permits native token expiry earlier than operation deadline. ++The actual source proof uses explicit synthetic Approval freshness120/backendTTL30/ ++operationdeadline60, without a consumer timing waiver or native admission. ++ ++Temporal proof precision observation: Approval consumption receipts use whole ++seconds while Access R2 not_before preserves subsecond precision. Fast CAS can ++therefore refuse receipt-before-start even though live calls are correctly ++ordered. The positive proof explicitly waits real time until the next whole ++second before CAS, without injected clocks or relaxed checks. Fast-CAS failure ++is retained for owner resolution; no Secrets runtime change conceals the seam. ++ ++2026-10-16 retained-registry successor source preparation adds only the existing ++signed Check/DENY preapproval flow. The original typed service proposal is sent ++unchanged; normalized native act and evaluated approval-free digest from the ++admitted producer record are used for normal Approval issuance. The original ++proposal then receives only the original issued claim. Final Check re-normalizes ++against retained owner registry/source facts, so authoritative drift refuses ++before CAS. No local registry algorithm, new endpoint, empty-registry workaround ++or consumer digest waiver is used. Signed DENY preparation has no independent ++permission/freshness window and cannot consume or execute; current caller and ++operation bounds are checked, and final signed ALLOW/source checks remain ++mandatory. Exact original JSON kinds and requested-target subsets refuse ++bool/int/float substitution while allowing extra authoritative registry facts. ++Actual provider proof preparation is separately waiting on frozen Access source ++and Approval's timestamp/transaction-clock repair; source native/trust/custody and ++actual task admission remain open. T02 remains progress and T03 wait; no native adoption is claimed. diff --git a/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r4-over-r3.patch b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r4-over-r3.patch new file mode 100644 index 00000000..76e0ad19 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/decision-trusted-successor/successor-r4-over-r3.patch @@ -0,0 +1,365 @@ +diff --git a/docs/task-execution-handoff.md b/docs/task-execution-handoff.md +index 5821175..c62dbe4 100644 +--- a/docs/task-execution-handoff.md ++++ b/docs/task-execution-handoff.md +@@ -38,7 +38,9 @@ ServiceAccount principal, `flex-auth` audience and live expiry bounding the + operation deadline. This consumes the signed PDP's caller attestation; it does + not independently perform TokenReview or promote the proposed server mapping. + Human/delegation/projected-identity flags cannot substitute for this service seam. +-No service_actor_binding request propagation or source-policy activation is added. ++The later typed execution builder now propagates the fixed Access public ++service_actor_binding before approval issue; it is not caller authentication. ++No source-policy activation is added. + + `as_owner_handoff()` returns a detached non-secret mapping with: + +@@ -76,3 +78,36 @@ confirm original durable consumption using Approval's real provider client, + journal it, then recheck its exact Plan, owner sources, grant and deadlines before + any native operation. Neither a dataclass nor a caller-supplied boolean admits + execution. Existing generic CLI CAS behavior is otherwise unchanged. ++ ++ ++2026-10-16 retained-registry preapproval source preparation adds ++`prepare_task_approval(cfg, entry, execution_contract=contract)`. It calls the ++existing signed Check client with the original claim-free typed proposal and ++accepts only a signed DENY normalization record. This neither authorizes nor ++consumes an operation. No registry algorithm is copied and no registry is emptied. ++ ++The detached `as_owner_handoff()` returns `original_request`, ++`submitted_request_digest`, authoritative `native_binding`, ++`approval_binding_digest`, normalization `decision_id`, `execution_contract`, and ++signed `registry_snapshot_digest`. Exact original context (including JSON kinds), ++submitted digest, recomputed evaluated digest, explicit approval-free digest, ++policy/key admission, service/caller identity, decision time and deadline are ++checked. Registry digest is evidence from the admitted signer; this method does ++not independently grant registry/config admission. The caller uses the returned ++native act and approval-free digest for normal Approval issuance and human entry. ++It preserves the original proposal; final authorize_task_execution builds that ++same proposal and attaches only the original issued claim. Access normalizes ++again and independently joins current source facts. Changed evaluated facts or ++native target refuse before CAS. A normalization DENY object/mapping is never ++accepted by consume_task_execution. ++ ++Normalization decision_time must be valid and nonfuture; there is deliberately ++no invented standalone normalization TTL. The DENY receipt is not permission and ++cannot discharge final signed ALLOW or source validation. Expired original task/ ++caller bounds refuse at preparation; a stale normalization's changed evaluated ++facts refuse when Access independently checks the original issued claim against ++the current retained registry. Unchanged historical normalization can only supply ++public issue metadata; it cannot itself produce a CAS/native admission. The actual ++source-server drift proof restarts Access with changed authoritative subject ++roles after preapproval and observes final refusal with no new ALLOW record, no ++CAS and unconsumed Approval state. +diff --git a/src/secrets_engine/task_execution_handoff.py b/src/secrets_engine/task_execution_handoff.py +index 5af36be..aa998ab 100644 +--- a/src/secrets_engine/task_execution_handoff.py ++++ b/src/secrets_engine/task_execution_handoff.py +@@ -15,10 +15,11 @@ from threading import Lock + + from secrets_engine.approval_auth import approval_token, credential_urlopen + from secrets_engine.approval_claim import binding_from_check_request, claim_binding_digest, validate_approval_claim +-from secrets_engine.approval_consume import authorize_action, consume_approval ++from secrets_engine.approval_consume import authorize_action, consume_approval, _expected_request + from secrets_engine.application_time import read_window, validity_bounds +-from secrets_engine.authorization import validate_decision_envelope, request_digest, approval_binding_digest ++from secrets_engine.authorization import validate_decision_envelope, request_digest, approval_binding_digest, CONTRACT_VERSION + from secrets_engine.decision_signature import authenticate_response, load_trust, strict_json ++from secrets_engine.decision_check import check_decision + from secrets_engine.errors import DecisionError + from secrets_engine.execution_contract import ExecutionContract, require_execution_deadline + +@@ -78,6 +79,93 @@ class TaskExecutionConsumption: + idempotent: bool + + ++def _same_json(left, right): ++ # Type-sensitive semantic correspondence only; never signature material. ++ return json.dumps(left,sort_keys=True,allow_nan=False)==json.dumps(right,sort_keys=True,allow_nan=False) ++ ++ ++def _task_identity(request,envelope,descriptor,upper): ++ binding=envelope['binding'] ++ subject=binding['subject']; resource=binding['resource'] ++ if (request['subject']!={'id':'secrets-engine','type':'service','tenant':'tenant:platform'} ++ or subject.get('id')!='secrets-engine' or subject.get('type')!='service' ++ or subject.get('tenant') not in (None,'tenant:platform') ++ or 'principal' in subject.get('attributes',{}) ++ or not _same_json(envelope['subject'],subject) or not _same_json(envelope['resource'],resource) ++ or descriptor['execution_actor']!='service:secrets-engine' ++ or any(resource.get(key)!=request['resource'].get(key) for key in ('id','type','system')) ++ or resource.get('tenant') not in (None,'tenant:platform') ++ or any(not _same_json(resource.get('attributes',{}).get(key),value) ++ for key,value in request['resource']['attributes'].items()) ++ or any(key in binding['context'] for key in ('task_execution_projection','initiator','represented_principal','delegation','verified','owner_admission'))): ++ _refuse() ++ caller=envelope['provenance'].get('caller') ++ if (not isinstance(caller,dict) or caller.get('mode')!='enforce' ++ or caller.get('principal')!=_CALLER or caller.get('audience')!='flex-auth' ++ or _stamp(caller.get('not_after')) < _stamp(descriptor['operation_deadline']) ++ or upper >= _stamp(caller.get('not_after'))): ++ _refuse() ++ ++ ++@dataclass(frozen=True) ++class TaskApprovalPreparation: ++ """Detached public normalization facts; never authorization or consumption.""" ++ _serialized: str ++ ++ def as_owner_handoff(self): ++ return strict_json(self._serialized) ++ ++ ++def prepare_task_approval(cfg, entry, *, execution_contract): ++ """Read signed DENY normalization before Approval issue; never permit an act. ++ ++ This read-only normalization record has no independent permission window. ++ decision_time must be a valid nonfuture instant; original operation/caller ++ deadlines remain live. Final source Check independently revalidates current ++ registry/source facts; stale normalized facts cannot replace that Check. ++ ++ Use returned authoritative native_binding and approval_binding_digest for ++ issue, preserving original_request. Final Check carries only the original ++ issued claim; producer renormalization independently refuses registry drift. ++ """ ++ if type(execution_contract) is not ExecutionContract: ++ _refuse() ++ request=_expected_request(cfg,entry,'execute-ccr',execution_contract=execution_contract) ++ window=read_window(cfg) ++ require_execution_deadline(request,time_window=window) ++ envelope=check_decision(base_url=str(cfg.pdp_url),token_file=cfg.pdp_token_file,request=request, ++ trust_file=cfg.pdp_trust_file,verifier=cfg.pdp_verifier) ++ try: ++ window=read_window(cfg);lower,upper=validity_bounds(window=window) ++ descriptor=execution_contract.as_dict();binding=envelope['binding'];provenance=envelope['provenance'] ++ if (envelope.get('contract_version')!=CONTRACT_VERSION or envelope.get('effect')!='deny' or not isinstance(envelope.get('id'),str) or not envelope['id'] ++ or provenance.get('policy_package')!=str(cfg.authorization_policy_package) ++ or provenance.get('policy_version')!=str(cfg.authorization_policy_version) ++ or not isinstance(provenance.get('registry_snapshot_digest'),str) ++ or re.fullmatch(r'sha256:[0-9a-f]{64}',provenance['registry_snapshot_digest']) is None ++ or request['action']!='execute-ccr' or binding.get('action')!='execute-ccr' ++ or request['tenant']!='tenant:platform' or binding.get('tenant')!='tenant:platform' ++ or 'approval' in request['context'] or 'approval' in binding['context'] ++ or not _same_json(binding.get('context'),request['context']) ++ or binding.get('submitted_request_digest')!=request_digest(request) ++ or binding.get('request_digest')!=request_digest(binding) ++ or binding.get('approval_binding_digest')!=binding['request_digest'] ++ or not _same_json(envelope.get('subject'),binding['subject']) or not _same_json(envelope.get('resource'),binding['resource']) ++ or _stamp(provenance.get('decision_time'))>upper): ++ _refuse() ++ _task_identity(request,envelope,descriptor,upper) ++ require_execution_deadline(request,time_window=window,bounds=(provenance['caller']['not_after'],)) ++ native=binding_from_check_request(binding) ++ claim_binding_digest(**native) # Require native JSON profile, not a translated digest. ++ result={'original_request':request,'submitted_request_digest':binding['submitted_request_digest'], ++ 'native_binding':native,'approval_binding_digest':binding['approval_binding_digest'], ++ 'decision_id':envelope['id'],'execution_contract':descriptor, ++ 'registry_snapshot_digest':provenance['registry_snapshot_digest']} ++ return TaskApprovalPreparation(json.dumps(result,sort_keys=True,separators=(',',':'),allow_nan=False)) ++ except (ValueError,KeyError,TypeError,RecursionError,AttributeError) as exc: ++ raise DecisionError('task approval preparation malformed') from exc ++ ++ + def _validate(cfg, action, contract): + if type(contract) is not ExecutionContract or not action.artifacts: + _refuse() +@@ -108,31 +196,14 @@ def _validate(cfg, action, contract): + _refuse() + if (request['action']!='execute-ccr' or binding.get('action')!='execute-ccr' + or request['tenant']!='tenant:platform' or binding.get('tenant')!='tenant:platform' +- or binding.get('context')!=request['context'] +- or request['context'].get('execution_contract')!=descriptor ++ or not _same_json(binding.get('context'),request['context']) ++ or not _same_json(request['context'].get('execution_contract'),descriptor) + or binding.get('request_digest')!=request_digest(binding) + or binding.get('approval_binding_digest')!=approval_binding_digest(binding) + or action.binding.request_digest!=validated.request_digest + or action.binding.decision_id!=validated.decision_id): + _refuse() +- subject=binding['subject']; resource=binding['resource'] +- if (request['subject']!={'id':'secrets-engine','type':'service','tenant':'tenant:platform'} +- or subject.get('id')!='secrets-engine' or subject.get('type')!='service' +- or subject.get('tenant') not in (None,'tenant:platform') +- or 'principal' in subject.get('attributes',{}) +- or envelope['subject']!=subject or envelope['resource']!=resource +- or descriptor['execution_actor']!='service:secrets-engine' +- or any(resource.get(key)!=request['resource'].get(key) for key in ('id','type','system')) +- or resource.get('tenant') not in (None,'tenant:platform') +- or any(resource.get('attributes',{}).get(key)!=value for key,value in request['resource']['attributes'].items()) +- or any(key in binding['context'] for key in ('task_execution_projection','initiator','represented_principal','delegation','verified','owner_admission'))): +- _refuse() +- caller=envelope['provenance'].get('caller') +- if (not isinstance(caller,dict) or caller.get('mode')!='enforce' +- or caller.get('principal')!=_CALLER or caller.get('audience')!='flex-auth' +- or _stamp(caller.get('not_after')) < _stamp(descriptor['operation_deadline']) +- or upper >= _stamp(caller.get('not_after'))): +- _refuse() ++ _task_identity(request,envelope,descriptor,upper) + native=binding_from_check_request(binding) + supplied=claim['binding'] + # Recompute the served native act too: Python equality alone conflates +diff --git a/tests/test_task_approval_preparation.py b/tests/test_task_approval_preparation.py +new file mode 100644 +index 0000000..96b254f +--- /dev/null ++++ b/tests/test_task_approval_preparation.py +@@ -0,0 +1,89 @@ ++"""Signed DENY supplies preapproval facts; it never supplies execution authority.""" ++import copy ++import pytest ++from secrets_engine.authorization import request_digest ++from secrets_engine.errors import DecisionError ++from secrets_engine.task_execution_handoff import prepare_task_approval,consume_task_execution ++from tests.test_task_execution_handoff import lane,prepared ++from tests.signature_support import signature_tools ++ ++ ++def normalize(envelope): ++ envelope['effect']='deny' ++ envelope['provenance']['registry_snapshot_digest']='sha256:'+'4'*64 ++ envelope['binding']['subject']['attributes']={'roles':['Operator'],'display_name':'registry service'} ++ envelope['binding']['request_digest']=request_digest(envelope['binding']) ++ envelope['binding']['approval_binding_digest']=envelope['binding']['request_digest'] ++ ++ ++def test_signed_registry_normalization_is_detached_and_cannot_consume(lane): ++ cfg,e,contract,state=lane ++ state['decision_change']=normalize ++ result=prepare_task_approval(cfg,e,execution_contract=contract) ++ output=result.as_owner_handoff() ++ assert state['calls']==['check'] ++ assert 'approval' not in output['original_request']['context'] ++ assert output['approval_binding_digest']!=output['submitted_request_digest'] ++ assert 'attributes' not in output['original_request']['subject'] ++ assert output['execution_contract']==contract.as_dict() ++ assert output['native_binding']['target']==output['original_request']['resource'] ++ output['original_request']['context']['purpose']='mutated' ++ assert result.as_owner_handoff()['original_request']['context']['purpose']!='mutated' ++ with pytest.raises(DecisionError):consume_task_execution(cfg,result) ++ assert state['calls']==['check'] ++ ++ ++@pytest.mark.parametrize('case',['allow','context','descriptor-type','submitted','evaluated','approval-digest','caller','native','future-time','policy']) ++def test_signed_normalization_refuses_changed_correspondence(lane,case): ++ cfg,e,contract,state=lane ++ def change(envelope): ++ normalize(envelope) ++ if case=='allow':envelope['effect']='allow' ++ elif case=='context':envelope['binding']['context']['purpose']='other' ++ elif case=='descriptor-type':envelope['binding']['context']['execution_contract']['backend_budget']['ttl_seconds']=120.0 ++ elif case=='submitted':envelope['binding']['submitted_request_digest']='sha256:'+'0'*64 ++ elif case=='evaluated':envelope['binding']['request_digest']='sha256:'+'0'*64 ++ elif case=='approval-digest':envelope['binding']['approval_binding_digest']='sha256:'+'0'*64 ++ elif case=='caller':envelope['provenance']['caller']['mode']='disabled' ++ elif case=='native':envelope['binding']['resource']['system']='other' ++ elif case=='future-time':envelope['provenance']['decision_time']='2099-01-01T00:00:00Z' ++ else:envelope['provenance']['policy_version']='other' ++ state['decision_change']=change ++ with pytest.raises(DecisionError):prepare_task_approval(cfg,e,execution_contract=contract) ++ assert 'claim' not in state['calls'] and 'consume' not in state['calls'] ++ ++ ++def test_signed_context_numeric_kind_cannot_change(lane): ++ cfg,e,contract,state=lane ++ state['decision_change']=lambda envelope:envelope['binding']['context']['execution_contract']['backend_budget'].__setitem__('ttl_seconds',120.0) ++ with pytest.raises(DecisionError):prepared(lane) ++ assert 'consume' not in state['calls'] ++ ++ ++@pytest.mark.parametrize('original,served',[(1,True),(1,1.0),(1.0,1),(False,0)]) ++def test_shared_original_target_subset_is_type_sensitive(lane,original,served): ++ from datetime import datetime,timedelta,timezone ++ from secrets_engine.task_execution_handoff import _task_identity ++ from secrets_engine.approval_consume import _expected_request ++ cfg,e,contract,state=lane ++ request=_expected_request(cfg,e,'execute-ccr',execution_contract=contract) ++ request['resource']['attributes']['requested_number']=original ++ binding=copy.deepcopy(request);binding['resource']['attributes']['requested_number']=served ++ envelope={'binding':binding,'subject':binding['subject'],'resource':binding['resource'], ++ 'provenance':{'caller':{'mode':'enforce','principal':'system:serviceaccount:secrets-engine:secrets-engine', ++ 'audience':'flex-auth','not_after':(datetime.now(timezone.utc)+timedelta(minutes=20)).isoformat()}}} ++ with pytest.raises(DecisionError):_task_identity(request,envelope,contract.as_dict(),datetime.now(timezone.utc)) ++ binding['resource']['attributes']['requested_number']=original ++ binding['resource']['attributes']['legitimate_extra_registry_fact']=1.0 ++ _task_identity(request,envelope,contract.as_dict(),datetime.now(timezone.utc)) ++ ++ ++def test_old_normalization_record_has_no_permission_window(lane): ++ cfg,e,contract,state=lane ++ def old(envelope): ++ normalize(envelope) ++ envelope['provenance']['decision_time']='2000-01-01T00:00:00Z' ++ state['decision_change']=old ++ normalization=prepare_task_approval(cfg,e,execution_contract=contract) ++ with pytest.raises(DecisionError):consume_task_execution(cfg,normalization) ++ assert state['calls']==['check'] +diff --git a/verifier/producer-source.json b/verifier/producer-source.json +index 9a4d5a3..d34b5a6 100644 +--- a/verifier/producer-source.json ++++ b/verifier/producer-source.json +@@ -1,6 +1,6 @@ + { + "upstream": "https://github.com/netkingdom/access-engine", +- "revision": "B2/B3 R2 temporal source proposal over B1 R2; canonical native-prep base 919ce25", ++ "revision": "B2/B3 R3 retained-registry source proposal over B1 R2; canonical native-prep base 919ce25", + "files": { + "pkg/api/canonical.go": "a122a2e8cd524333b13c5939c7a3685e41efbe47497a64e0c7297c92b7f10d19", + "pkg/api/caring.go": "1907156f0f889e7b9e9c14970cf7a86a20d00478a496efaf92c7231f27fec5e3", +@@ -13,8 +13,8 @@ + "decision_rotate_signed_tampered.json": "7f2d2e9372a9617bd4b197033f09a5e33a08b202046de8109582da1dc90cb527" + }, + "source_proposal": { +- "patch_sha256": "ed85a55b54f8380a657c0688d6f216699a0873b1b94b4c0c2e09332620c8ebf4", +- "predecessor_patch_sha256": "6079ed2ff40b131aa2c5efc8d33608ecb3475c8158f0486b44ec4469e9f222c1", ++ "patch_sha256": "46bfeb706d3a88bf6054309cc9303eb360a91e57dda3b0fcbb7b80d551a75db6", ++ "predecessor_patch_sha256": "ed85a55b54f8380a657c0688d6f216699a0873b1b94b4c0c2e09332620c8ebf4", + "api_sign_bytes_unchanged": true, + "status": "source proposal; no runtime or key admission" + } +diff --git a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md +index a8a3845..a7e1e3a 100644 +--- a/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md ++++ b/workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md +@@ -124,3 +124,19 @@ coercion/idempotence, expiry, signer removal and replay refusal. No service bind + propagation, policy gate flip, identity mapping admission or production trust/key + change was made. Independent source integration/review and actual whole-provider + proof remain required. T01/T03 and WP8-T06 retain their open native obligations. ++ ++2026-10-16 retained-registry successor source preparation adds only the existing ++signed Check/DENY preapproval flow. The original typed service proposal is sent ++unchanged; normalized native act and evaluated approval-free digest from the ++admitted producer record are used for normal Approval issuance. The original ++proposal then receives only the original issued claim. Final Check re-normalizes ++against retained owner registry/source facts, so authoritative drift refuses ++before CAS. No local registry algorithm, new endpoint, empty-registry workaround ++or consumer digest waiver is used. Signed DENY preparation has no independent ++permission/freshness window and cannot consume or execute; current caller and ++operation bounds are checked, and final signed ALLOW/source checks remain ++mandatory. Exact original JSON kinds and requested-target subsets refuse ++bool/int/float substitution while allowing extra authoritative registry facts. ++Actual provider proof preparation is separately waiting on frozen Access source ++and Approval's timestamp/transaction-clock repair; source native/trust/custody and ++actual task admission remain open. T01/T03 and WP8-T06 are not closed. +diff --git a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md +index 60f9e18..f65ac96 100644 +--- a/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md ++++ b/workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md +@@ -159,3 +159,19 @@ therefore refuse receipt-before-start even though live calls are correctly + ordered. The positive proof explicitly waits real time until the next whole + second before CAS, without injected clocks or relaxed checks. Fast-CAS failure + is retained for owner resolution; no Secrets runtime change conceals the seam. ++ ++2026-10-16 retained-registry successor source preparation adds only the existing ++signed Check/DENY preapproval flow. The original typed service proposal is sent ++unchanged; normalized native act and evaluated approval-free digest from the ++admitted producer record are used for normal Approval issuance. The original ++proposal then receives only the original issued claim. Final Check re-normalizes ++against retained owner registry/source facts, so authoritative drift refuses ++before CAS. No local registry algorithm, new endpoint, empty-registry workaround ++or consumer digest waiver is used. Signed DENY preparation has no independent ++permission/freshness window and cannot consume or execute; current caller and ++operation bounds are checked, and final signed ALLOW/source checks remain ++mandatory. Exact original JSON kinds and requested-target subsets refuse ++bool/int/float substitution while allowing extra authoritative registry facts. ++Actual provider proof preparation is separately waiting on frozen Access source ++and Approval's timestamp/transaction-clock repair; source native/trust/custody and ++actual task admission remain open. T02 remains progress and T03 wait; no native adoption is claimed. diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/README.md b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/README.md new file mode 100644 index 00000000..32d3afae --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/README.md @@ -0,0 +1,82 @@ +# PostgreSQL group journal source candidate + +Prepared against NetKingdom `6cb43c0cc55212363a5406b02db046e2384a7c40` for +NK-WP-0048-T14. No canonical change, native database/credential access, package +installation, Docker call or production admission occurred in preparation. +`source.patch`, exact postimages and `source-manifest.json` bind the source. + +The existing reserve/get/claim-revoke/save/check seam is now explicit as +`GroupJournalStore`. The new injection-only PostgreSQL implementation preserves +prestate, intents, confirmed creation inventories and active fences using the +same provisioner paths. SQL updates require the exact context/key/request and +receipt version, plus exactly one affected row; the journal head and exact +transition digest also advance conditionally under a metadata-row lock. Each +new digest binds the prior marker and canonical body/context/key/version. An +independent checkpoint compares and CAS-advances both sequence and content, +refusing a divergent clone even if it reaches the same counter. SQL errors, stale CAS and ambiguous +commit/checkpoint acknowledgment refuse rather than permitting the next LDAP +effect. No exception handler can automatically turn those failures into success. + +A frozen explicit tenant/directory/case/source/principal/epoch binding is required; +there is no request-derived authority or default platform-root scope. The owner +must additionally supply independent durable continuity checkpoint callbacks and +renewable connection facts. These are injection seams, not new authorization or +evidence services, and no native implementation or grant is invented. The +ordinary server has no Pg environment switch: missing native callbacks keep the +production Pg group path disabled. The existing SQLite path remains local/test +only under accepted ADR-0015. + +The migration creates tables inside the existing consumer schema. It performs +no provisioning, initializes no epoch and admits/clears no quarantine. A journal +restored behind the separately retained checkpoint is refused. Admission must +still bind native restore/continuity, record retention, owner scope and runtime +credentials; an injected callback that merely asserts success proves none of +those boundaries. SQL locking fences only cooperating journal callers and does +not close LLDAP external-writer/ABA races. + +The Containerfile includes the module/migration and hash-pinned psycopg3 closure +selected from User Engine's existing lockfile (Linux amd64 CPython3.12 only). +No image was built and no package was fetched. Immutable rebuild, dependency +scanning, platform/cell/consumer acceptance and native storage/backup/lease/TLS +proof remain owner work. The native fixture uses the exact cached User Engine +image's bundled psycopg version and records it; this is compatibility proof, +not verification of a future rebuilt image. + +Source checks cover explicit binding/no default, disabled server routing, stale +checkpoint/quarantine/epoch refusal, exact row CAS, commit-before-checkpoint +ordering, missing acknowledgment, cross-context/version import refusal, +active-fence/capacity and connection failure sanitization. Scripted DB-API unit +checks do not prove PostgreSQL semantics. The separately review-gated +`native-fixture` uses real disposable PostgreSQL SQL and the existing synthetic +LDAP Directory, with actual separate-process reopen and pg_dump/pg_restore. +No PostgreSQL fixture execution or native acceptance is counted yet. + +The counter-only predecessor and coordinator finding remain under `review-r0`; +it is superseded and never admitted or executed. The successor adds content-bound +continuity rather than accepting a same-counter result. + +The source deliverable does not finish T14: runtime package/operating assignment, +CNPG/rapp-postgres consumer/capacity and lease/custody approval, independently +retained native checkpoints, accepted case/source/principal and protected +session/evidence callbacks, LDAP writer fencing/CAS and production rollout all +remain open. Existing native-storage-proposal-r1 records those owner routes. + + +## R4 native failure refinement + +R3 reached `bootstrap_ensure` then failed safely with RuntimeError and no SQLSTATE; +initdb/readiness/migration/runtime grants and checkpoint initialization completed. +Its exact internal exception text was never collected. Frozen fixture/receipt and +source503e3 remain in `native-fixture/revisions/r3` and `review-r3`. + +Local diagnosis demonstrated a PostgreSQL save contract defect: save replaced the +nested receipt dictionaries while the real provisioner retained a group-state +reference across its intent save. The returned created ID therefore updated a +detached object; final receipt postimage checking refused. R4 updates only the +acknowledged `_pg_version` after successful commit and independent checkpoint; +it retains nested caller references, with no change to SQL/CAS, digest, ownership +or ambiguous-commit behavior. A regression fails against frozen source, and a +second runs the actual synthetic Directory/provisioner with actual PostgreSQL +save/write/transaction code and mocked SQL transport to verify complete ensure. +82 source tests pass. Actual native compatibility remains unproved until reviewed +R4 execution; this fix does not imply production or tenant admission. diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/final-integration-r1/execution-result.json b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/final-integration-r1/execution-result.json new file mode 100644 index 00000000..1068a7e3 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/final-integration-r1/execution-result.json @@ -0,0 +1,300 @@ +{ + "schema": "nk48.synthetic-postgres-journal-result.v1", + "status": "synthetic_postgres_journal_compatibility_passed", + "manifest_sha256": "d51f90816616848b93bcdc482e10a98d07061deb01eeed9e50700fc6e68b6051", + "production_contacted": false, + "actual_credentials_used": false, + "native_acceptance_claimed": false, + "session": { + "run_id": "492a7afaec6b43ef92b98eb76025362f", + "containers": [ + "nk49-kc-492a7afaec6b43ef92b98eb76025362f-journal-postgres", + "nk49-kc-492a7afaec6b43ef92b98eb76025362f-journal-probe" + ], + "label": "net-kingdom.recovery-run=492a7afaec6b43ef92b98eb76025362f" + }, + "driver_stage": "cleanup", + "rpc_trace": [ + { + "stage": "anchor_launch", + "operation": "run", + "status": "completed" + }, + { + "stage": "anchor_containment", + "operation": "context", + "status": "completed" + }, + { + "stage": "anchor_containment", + "operation": "info", + "status": "completed" + }, + { + "stage": "anchor_containment", + "operation": "container", + "status": "completed" + }, + { + "stage": "anchor_containment", + "operation": "container", + "status": "completed" + }, + { + "stage": "postgres_private_import", + "operation": "exec", + "status": "completed" + }, + { + "stage": "postgres_initdb", + "operation": "exec", + "status": "completed" + }, + { + "stage": "postgres_launch", + "operation": "exec", + "status": "completed" + }, + { + "stage": "anchor_identity", + "operation": "container", + "status": "completed" + }, + { + "stage": "helper_launch", + "operation": "run", + "status": "completed" + }, + { + "stage": "full_containment", + "operation": "context", + "status": "completed" + }, + { + "stage": "full_containment", + "operation": "info", + "status": "completed" + }, + { + "stage": "full_containment", + "operation": "container", + "status": "completed" + }, + { + "stage": "full_containment", + "operation": "container", + "status": "completed" + }, + { + "stage": "full_containment", + "operation": "container", + "status": "completed" + }, + { + "stage": "helper_interpreter", + "operation": "exec", + "status": "completed" + }, + { + "stage": "candidate_import", + "operation": "exec", + "status": "completed" + }, + { + "stage": "bootstrap", + "operation": "exec", + "status": "completed" + }, + { + "stage": "database_dump", + "operation": "exec", + "status": "completed" + }, + { + "stage": "restart", + "operation": "exec", + "status": "completed" + }, + { + "stage": "concurrency", + "operation": "exec", + "status": "completed" + }, + { + "stage": "fork", + "operation": "exec", + "status": "completed" + }, + { + "stage": "lost-ack", + "operation": "exec", + "status": "completed" + }, + { + "stage": "database_restore", + "operation": "exec", + "status": "completed" + }, + { + "stage": "restored", + "operation": "exec", + "status": "completed" + }, + { + "stage": "cleanup", + "operation": "container", + "status": "completed" + }, + { + "stage": "cleanup", + "operation": "container", + "status": "completed" + }, + { + "stage": "cleanup", + "operation": "rm", + "status": "completed" + }, + { + "stage": "cleanup", + "operation": "container", + "status": "completed" + }, + { + "stage": "cleanup", + "operation": "container", + "status": "completed" + }, + { + "stage": "cleanup", + "operation": "container", + "status": "completed" + }, + { + "stage": "cleanup", + "operation": "rm", + "status": "completed" + }, + { + "stage": "cleanup", + "operation": "container", + "status": "completed" + } + ], + "postgres_initdb_complete": true, + "containment_verified_before_generated_private_import": true, + "helper_interpreter_verified_before_candidate_import": true, + "helper_psycopg_version": "3.3.6", + "checks": { + "only_loopback_no_external_routes": true, + "actual_postgresql_journal_ensure": true, + "explicit_scope_context_retained": true, + "confirmed_inventory_excludes_preexisting": true, + "runtime_ddl_denied": true, + "separate_process_postgres_restart_and_revoke": true, + "preexisting_shared_state_preserved": true, + "foreign_context_receipt_refused": true, + "concurrent_original_reservation_exactly_once": true, + "stale_version_update_refused_without_head_advance": true, + "concurrent_divergent_clones_same_counter_verified": true, + "independent_exact_transition_cas_selects_one_winner": true, + "reopened_loser_clone_refused_at_same_counter": true, + "lost_checkpoint_ack_retains_pending_reservation": true, + "reopened_uncertainty_refuses_ldap_effects": true, + "active_fence_survives_process_reopen": true, + "lost_synthetic_directory_response_retains_postgres_intent": true, + "lost_sql_commit_reply_retains_committed_head": true, + "reopened_stale_checkpoint_refuses_continuation": true, + "actual_pg_restore_behind_checkpoint_detected": true, + "restored_store_cannot_gain_replay_authority": true + }, + "helper_diagnostics": { + "bootstrap": { + "status": "passed", + "stages": [ + "probe_entry", + "loopback_check", + "phase_dispatch", + "bootstrap_connect_wait", + "bootstrap_connected", + "bootstrap_schema", + "bootstrap_migration", + "bootstrap_epoch_insert", + "bootstrap_role_create", + "bootstrap_runtime_grants", + "bootstrap_checkpoint_init", + "bootstrap_directory_init", + "bootstrap_ensure", + "bootstrap_ensure_readback", + "bootstrap_runtime_ddl_refusal", + "bootstrap_complete", + "phase_complete" + ] + }, + "restart": { + "status": "passed", + "stages": [ + "probe_entry", + "loopback_check", + "phase_dispatch", + "restart_load_snapshot", + "restart_reopen_store", + "restart_replay_ensure", + "restart_revoke", + "restart_revoke_readback", + "restart_prestate_equality", + "restart_foreign_context", + "restart_complete", + "phase_complete" + ] + }, + "concurrency": { + "status": "passed", + "stages": [ + "probe_entry", + "loopback_check", + "phase_dispatch", + "phase_complete" + ] + }, + "fork": { + "status": "passed", + "stages": [ + "probe_entry", + "loopback_check", + "phase_dispatch", + "phase_complete" + ] + }, + "lost-ack": { + "status": "passed", + "stages": [ + "probe_entry", + "loopback_check", + "phase_dispatch", + "phase_complete" + ] + }, + "restored": { + "status": "passed", + "stages": [ + "probe_entry", + "loopback_check", + "phase_dispatch", + "phase_complete" + ] + } + }, + "psycopg_version": "3.3.6", + "container_cleanup_obligations": [ + { + "container": "nk49-kc-492a7afaec6b43ef92b98eb76025362f-journal-probe", + "status": "absence_verified" + }, + { + "container": "nk49-kc-492a7afaec6b43ef92b98eb76025362f-journal-postgres", + "status": "absence_verified" + } + ], + "container_cleanup_verified": true +} diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/final-integration-r1/integration-review.json b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/final-integration-r1/integration-review.json new file mode 100644 index 00000000..3f50a04a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/final-integration-r1/integration-review.json @@ -0,0 +1,32 @@ +{ + "schema": "nk48.postgres-source-integration-review.v1", + "status": "prepared_for_coordinator_integration", + "canonical_head_observed": "6cb43c0cc55212363a5406b02db046e2384a7c40", + "base_revision": "6cb43c0cc55212363a5406b02db046e2384a7c40", + "source_patch_sha256": "5549d2982b61ff70aba4b3928fecd5639db0f1a732ab2f8630f678e7acf4d34d", + "source_paths": [ + "docs/service-group-provenance.md", + "identity-provisioner/Containerfile", + "identity-provisioner/group_provenance.py", + "identity-provisioner/group_provenance_postgres.py", + "identity-provisioner/migrations/group_journal_postgres_v1.sql", + "identity-provisioner/provisioner.py", + "identity-provisioner/requirements-postgres.txt", + "identity-provisioner/tests/test_group_postgres.py" + ], + "source_hashes": "../source-manifest.json exact pre/postimages verified", + "source_paths_count": 8, + "workplan_patch_sha256": "af9f720fba1b931679f866a29e8b3bc7d08f24a7f653db48daf485e35cb16eea", + "workplan_preimage_sha256": "08215f6dc91a89706bf456a897d77de4032d47a21cd0252d202249619def135d", + "combined_source_workplan_apply_check": true, + "bounded_SQL_compatibility": "passed_R5", + "raw_safe_receipt_sha256": "d72cc5e0788b7be69fc40c641e20680d29a175181334396b5f7fcecc0c0763d9", + "source_tests": 82, + "fixture_plan_tests": 13, + "snapshot_tests": 2, + "native_admission_claimed": false, + "source_backend_unchanged_since_reviewed_R4": true, + "canonical_worker_mutations": false, + "remaining_gate": "T14progress: actual owner/package, CNPG+lease/TLS/capacity/recovery admission; authoritative protected binding and independently retained continuity callbacks; session/evidence/revocation; all LDAP writers fenced/CAS/ABA; production activation", + "publication_projection_cleanup_followthrough": "coordinator separate receipts" +} diff --git a/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/final-integration-r1/typed-execution-evidence.json b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/final-integration-r1/typed-execution-evidence.json new file mode 100644 index 00000000..2563ce17 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/group-postgres-r1/final-integration-r1/typed-execution-evidence.json @@ -0,0 +1,45 @@ +{ + "schema": "nk48.postgres-journal-compatibility-evidence.v1", + "status": "bounded_disposable_compatibility_passed", + "source_patch_sha256": "5549d2982b61ff70aba4b3928fecd5639db0f1a732ab2f8630f678e7acf4d34d", + "manifest_sha256": "d51f90816616848b93bcdc482e10a98d07061deb01eeed9e50700fc6e68b6051", + "raw_safe_receipt_sha256": "d72cc5e0788b7be69fc40c641e20680d29a175181334396b5f7fcecc0c0763d9", + "original_safe_receipt": "/tmp/nk48-postgres-native-review-sguj7t8p/result.json", + "phase_oracle_sets_exact": true, + "all_declared_oracles_true": true, + "fixture_reported_cleanup_absence": true, + "independent_coordinator_cleanup_verification": "pending separate coordinator evidence", + "source_checks": { + "passed": 82, + "seconds": 8.46 + }, + "fixture_plan_checks": { + "passed": 13, + "seconds": 0.019 + }, + "snapshot_regressions": { + "passed": 2, + "seconds": 0.236 + }, + "counts_are_native_acceptance": false, + "helper_bundled_psycopg": "3.3.6", + "production_packaged_psycopg": "3.3.5 (pinned closure, not image built here)", + "failed_predecessors": [ + "native-fixture/revisions/r2", + "native-fixture/revisions/r3", + "native-fixture/revisions/r4" + ], + "refinements": [ + "R1 helper cache preflight failed before execute; no raw stderr byte file exists", + "R2 failed helper outcome not retained by old driver; precise failure unknown", + "R3 diagnostics localized ensure failure; local source regression proved detached nested receipt state in Pg save, fixed version-only acknowledgment", + "R4 actual bootstrap verified source fix; restart failed; local fixture regression proved group map alias and empty orphan backing sets; corrected snapshot retains complete current visible prestate and refuses nonempty orphans" + ], + "not_claimed": [ + "CNPG native storage/admission/HA/backup/lease/TLS/placement", + "authoritative tenant/case/source/principal bindings and independently retained continuity callbacks", + "PDP/session/evidence/revocation owner acceptance", + "all LDAP writer fencing/CAS/ABA exclusion", + "production endpoint activation or owner assent" + ] +}