From 0d7c8734d82f9f0584bd7c591a0b2a49cf7bd28a Mon Sep 17 00:00:00 2001 From: RobinLee Date: Fri, 11 Sep 2026 17:28:15 +0800 Subject: [PATCH] feat(stage2): add Lab2 credential backend binding policy --- ...nly_s2_ro_04_windows_credential_backend.md | 91 +++++-- .../stage2/test_windows_credential_backend.py | 251 +++++++++++++++++- .../stage2_windows_credential_backend.py | 57 +++- 3 files changed, 369 insertions(+), 30 deletions(-) diff --git a/docs/automation_readiness/stage2_vrrp_readonly_s2_ro_04_windows_credential_backend.md b/docs/automation_readiness/stage2_vrrp_readonly_s2_ro_04_windows_credential_backend.md index 8fba922..e95219f 100644 --- a/docs/automation_readiness/stage2_vrrp_readonly_s2_ro_04_windows_credential_backend.md +++ b/docs/automation_readiness/stage2_vrrp_readonly_s2_ro_04_windows_credential_backend.md @@ -2,11 +2,12 @@ ## Decision summary -S2-RO-04 implements one bounded Windows Credential Manager read capability for -the exact immutable S2-RO-03 binding. The credential target is trusted runtime -configuration supplied outside Git, and the operational caller can request only -`read(binding)`. The implementation has no enumeration, mutation, persistence, -subprocess, network, or live-device capability. +S2-RO-04 extends only the trusted policy wrapper to accept the two exact +S2-RO-03 target/credential bindings, each with its matching trusted locator. +Legacy `read(binding)` remains Lab1-only; `read_for_target(target_ref, binding)` +checks the exact pair through S2-RO-03 before comparing configuration identity. +The native Windows reader is unchanged. This is an **offline policy extension**, +not permission to read a credential store or contact either lab. Status: implementation candidate ready for independent review after validation. @@ -28,29 +29,53 @@ bounded ephemeral credential material future transport (not implemented) ``` -S2-RO-04 owns credential retrieval only. Target selection, authorization, +S2-RO-04 owns bounded credential retrieval policy only. Endpoint selection, authorization, Owner verification, replay protection, host-key trust, network transport, command execution, runtime composition, and live-device access remain outside this slice. ## Accepted input and trusted target boundary -The backend accepts only the S2-RO-03 binding whose values are: +The target-aware API accepts only these exact relationships, as resolved by +`Stage2FixedCredentialResolver.resolve_for_target`: -- `credential_ref`: `credential.mikrotik.lab01` -- `backend_kind`: `WINDOWS_CREDENTIAL_MANAGER` -- `locator_ref`: `locator.stage2.mikrotik.lab01.readonly` +| Logical target | Credential identity | Required trusted configuration | +| --- | --- | --- | +| `target.mikrotik.lab01` | `credential.mikrotik.lab01` | Existing Lab1 locator, equal to the Lab1 binding locator | +| `target.mikrotik.lab02` | `credential.mikrotik.lab02` | Distinct externally provisioned Lab2 credential locator, equal to the Lab2 binding locator | -Backend and locator identity are validated before any Windows boundary call. -Invalid objects, alternate backends, alternate locators, and altered credential -references fail closed without a read attempt. +Both bindings use `WINDOWS_CREDENTIAL_MANAGER`; sharing the native read primitive +does not share credential authority. S2-RO-03 remains the authority for the +target/credential relationship. S2-RO-04 does not maintain a second pair registry. + +Before any Windows boundary call, the policy requires an exact binding object, +the fixed backend kind, a resolver-approved target/credential pair, a binding +locator equal to that resolver result, and a trusted configuration locator equal +to the same result. Cross-lab credentials or locators, unknown identities, +aliases, prefixes, case-confused values, and malformed objects fail closed with +zero reader calls. Valid pairs perform exactly one read, without retries. + +`read(binding)` retains its original signature and Lab1-only identity checks; +it then applies the same target-aware policy with the fixed Lab1 target. It +cannot read a Lab2 binding or use a Lab2 configuration for Lab1. Existing Lab1 +configuration, error sanitization, reader call semantics, and output fields are +unchanged. No S2-RO-05 or later production consumer is modified or enabled for +Lab2 by this extension. The real Windows Credential Manager target name is not present in Git. A future trusted composition layer must supply exactly one target through the redacted `Stage2TrustedWindowsCredentialConfiguration`. The request and operational -`read(binding)` call cannot select or override a Windows target, backend, +`read(binding)` and `read_for_target(target_ref, binding)` calls cannot select or +override a Windows target, backend, locator, username, secret, credential type, or read flags. +Each backend instance has one immutable trusted configuration, with no default +locator. The actual Windows record name must be separately provisioned and +supplied by the trusted owner outside Git. This offline policy neither probes +record existence nor verifies external provisioning; distinct real Lab2 record +provisioning remains separately authorized work. Logical locators are not stored +secret values or evidence that an actual credential record exists. + This trusted configuration boundary is not S2-RO-10 runtime composition and does not discover configuration from files, environment variables, command line input, or a remote provider. @@ -123,7 +148,12 @@ alternate target, or fallback lookup. Focused tests use only a synthetic target, username, and secret with an injected fake API. They prove: -- the exact accepted binding performs exactly one read; +- both exact target-bound identities perform exactly one read; +- the 16-case target/credential/binding-locator/configuration-locator matrix + admits only the two fully matched combinations, with zero calls otherwise; +- legacy Lab1 calls still work and cannot retrieve Lab2 credentials; +- unknown, alias, prefix, case-confused, malformed, and caller-override inputs + reject before the reader, without a default or fallback; - the binding is not mutated and returned material is immutable; - wrong backend, locator, or malformed binding rejects before the API boundary; - callers cannot override target, backend, locator, credential type, or flags; @@ -134,11 +164,24 @@ fake API. They prove: - no retry, enumeration, mutation, cache, persistence, subprocess, DPAPI, network, transport, or command-execution surface exists; - non-Windows invocation fails before a Windows library is loaded; -- tests use the fake boundary and never call the real adapter. - -Validation also includes the accepted S2-RO-01, S2-RO-02, and S2-RO-03 focused -regression suites, full pytest, report-index, complete-diff review, and tracked -file verification. +- behavioral tests inject a fake reader; the existing native-layout tests use + a fake DLL and test-owned memory, never the real credential store. An autouse + test guard denies real Windows library loading unless a test installs its + deterministic fake boundary. + +Validation order is focused S2-RO-04 tests, all `tests/stage2`, full pytest, +report-index, and `git diff --check`. Python runs use `-B`; pytest disables its +cache provider. Validation uses an external disposable copy of the exact +candidate, without dependency downloads or source-worktree runtime artifacts. +The source worktree must retain its pre-validation HEAD/tree, clean status, and +file content/size/mtime during sandbox validation. Only the three authorized +backend/test/document files are applied afterward, before the separately +authorized single local implementation commit. + +Every test suite requires zero failures. Existing platform-specific skips are +acceptable. Report-index may retain WARN only for optional missing artifacts, +with no mandatory failure. This policy extension does not remediate unrelated +CI maintenance warnings or constitute independent review PASS. ## Explicit exclusions @@ -148,5 +191,9 @@ RESTCONF, HTTP, live commands, live-device access, evidence serialization, or S2-RO-05 and later capabilities. No real Credential Manager target, username, password, or secret is committed. -No real credential store was probed. Commit, push, pull request, merge, branch -cleanup, and the start of S2-RO-05 require separate Owner authorization. +No real credential store was probed. The bounded implementation authorization +permits one local commit only after validation passes. Push, pull request, +merge, branch/worktree cleanup, and S2-RO-05 or later Lab2 work still require +separate Owner authorization. No Lab2 credential record, known-host data, +authorization package, replay access, private-key access, live attempt, or +Stage-3 work is authorized here. diff --git a/tests/stage2/test_windows_credential_backend.py b/tests/stage2/test_windows_credential_backend.py index 23fb931..ebe1d4a 100644 --- a/tests/stage2/test_windows_credential_backend.py +++ b/tests/stage2/test_windows_credential_backend.py @@ -14,9 +14,15 @@ STAGE2_CREDENTIAL_BACKEND_KIND, STAGE2_CREDENTIAL_LOCATOR_REF, STAGE2_FIXED_CREDENTIAL_REF, + STAGE2_SECOND_CREDENTIAL_REF, + STAGE2_SECOND_CREDENTIAL_LOCATOR_REF, Stage2CredentialBinding, build_stage2_fixed_credential_resolver, ) +from validation_framework.stage2_mikrotik_target_registry import ( + STAGE2_FIXED_TARGET_REF, + STAGE2_SECOND_TARGET_REF, +) from validation_framework.stage2_windows_credential_backend import ( MAX_CREDENTIAL_SECRET_BLOB_LENGTH, MAX_CREDENTIAL_USERNAME_LENGTH, @@ -35,6 +41,16 @@ _SYNTHETIC_SECRET = b"synthetic-secret-bytes" +@pytest.fixture(autouse=True) +def _deny_real_windows_library(monkeypatch): + """Native-layout tests may install a fake DLL; a real DLL is never allowed.""" + + def denied(*args, **kwargs): + pytest.fail("real Windows credential library access is forbidden") + + monkeypatch.setattr(ctypes, "WinDLL", denied, raising=False) + + class FakeWindowsCredentialApi: def __init__(self, result=None, error=None): self.result = result @@ -334,7 +350,7 @@ def test_read_call_has_no_target_backend_locator_or_windows_options(): @pytest.mark.parametrize( "changes", [ - {"locator_ref": "locator.stage2.mikrotik.lab02.readonly"}, + {"locator_ref": "locator.stage2.mikrotik.lab03.readonly"}, {"locator_ref": None}, {"credential_target": ""}, {"credential_target": " target"}, @@ -700,3 +716,236 @@ def test_fixed_policy_matches_accepted_s2_ro_03_binding(): assert binding.credential_ref == STAGE2_FIXED_CREDENTIAL_REF assert binding.backend_kind == STAGE2_CREDENTIAL_BACKEND_KIND assert binding.locator_ref == STAGE2_CREDENTIAL_LOCATOR_REF + + +# Non-secret logical identities and synthetic records, never real store data. +_LAB_PAIRS = ( + (STAGE2_FIXED_TARGET_REF, STAGE2_FIXED_CREDENTIAL_REF), + (STAGE2_SECOND_TARGET_REF, STAGE2_SECOND_CREDENTIAL_REF), +) +_LAB_LOCATORS = ( + STAGE2_CREDENTIAL_LOCATOR_REF, + STAGE2_SECOND_CREDENTIAL_LOCATOR_REF, +) + + +def _lab_backend(lab, *, result=None, error=None): + fake = FakeWindowsCredentialApi(result=result, error=error) + configuration = _configuration( + locator_ref=_LAB_LOCATORS[lab], + credential_target=f"synthetic.stage2.test.lab{lab + 1}.credential-record", + ) + return build_stage2_windows_credential_backend( + configuration, windows_api=fake + ), fake, configuration + + +@pytest.mark.parametrize("lab", [0, 1]) +def test_target_aware_exact_binding_reads_once_with_immutable_redacted_output(lab): + target, credential_ref = _LAB_PAIRS[lab] + binding = build_stage2_fixed_credential_resolver().resolve_for_target( + target, credential_ref + ) + backend, fake, configuration = _lab_backend(lab, result=_record()) + before = tuple(getattr(binding, item.name) for item in fields(binding)) + + result = backend.read_for_target(target, binding) + + assert fake.read_calls == [configuration.credential_target] + assert type(result) is Stage2ResolvedCredential + assert result.username == _SYNTHETIC_USERNAME + assert type(result.secret_blob) is bytes + assert result.secret_blob == _SYNTHETIC_SECRET + assert {item.name for item in fields(result)} == {"username", "secret_blob"} + assert before == tuple(getattr(binding, item.name) for item in fields(binding)) + for attribute, value in (("username", "changed"), ("secret_blob", b"changed")): + with pytest.raises(FrozenInstanceError): + setattr(result, attribute, value) + assert not hasattr(result, "__dict__") + with pytest.raises(TypeError): + json.dumps(result) + rendered = " ".join(repr(item) + str(item) for item in ( + configuration, backend, _record(), result + )) + for private_value in ( + configuration.credential_target, _SYNTHETIC_USERNAME, _SYNTHETIC_SECRET.decode() + ): + assert private_value not in rendered + + +def test_lab_identities_and_trusted_locators_are_distinct(): + resolver = build_stage2_fixed_credential_resolver() + first, second = (resolver.resolve_for_target(*pair) for pair in _LAB_PAIRS) + assert first.credential_ref != second.credential_ref + assert first.locator_ref != second.locator_ref + assert first.backend_kind == second.backend_kind == STAGE2_CREDENTIAL_BACKEND_KIND + assert _lab_backend(0)[2].credential_target != _lab_backend(1)[2].credential_target + + +@pytest.mark.parametrize("target_lab", [0, 1]) +@pytest.mark.parametrize("credential_lab", [0, 1]) +@pytest.mark.parametrize("binding_locator_lab", [0, 1]) +@pytest.mark.parametrize("configuration_lab", [0, 1]) +def test_exact_tuple_matrix_rejects_every_cross_lab_combination_before_read( + target_lab, credential_lab, binding_locator_lab, configuration_lab +): + backend, fake, configuration = _lab_backend(configuration_lab, result=_record()) + binding = _tampered_binding( + credential_ref=_LAB_PAIRS[credential_lab][1], + locator_ref=_LAB_LOCATORS[binding_locator_lab], + ) + if target_lab == credential_lab == binding_locator_lab == configuration_lab: + assert backend.read_for_target(_LAB_PAIRS[target_lab][0], binding) == ( + Stage2ResolvedCredential(_SYNTHETIC_USERNAME, _SYNTHETIC_SECRET) + ) + assert fake.read_calls == [configuration.credential_target] + else: + with pytest.raises(Stage2WindowsCredentialError): + backend.read_for_target(_LAB_PAIRS[target_lab][0], binding) + assert fake.read_calls == [] + + +@pytest.mark.parametrize("configuration_lab", [0, 1]) +def test_legacy_read_cannot_retrieve_lab2_or_use_lab2_configuration(configuration_lab): + backend, fake, _ = _lab_backend(configuration_lab, result=_record()) + resolver = build_stage2_fixed_credential_resolver() + second = resolver.resolve_for_target(*_LAB_PAIRS[1]) + _assert_error(lambda: backend.read(second), Stage2WindowsCredentialFailure.UNSUPPORTED_LOCATOR) + assert fake.read_calls == [] + if configuration_lab == 1: + _assert_error(lambda: backend.read(_binding()), Stage2WindowsCredentialFailure.UNSUPPORTED_LOCATOR) + assert fake.read_calls == [] + + +class _ReferenceSubclass(str): + pass + + +def _invalid_variants(reference): + return ( + None, {}, "", reference.upper(), reference + ".alias", reference[:-1], + reference + "*", " " + reference, reference + "\n", reference + "\x00", + reference.replace("lab0", "lab"), _ReferenceSubclass(reference), + reference.replace("mikrotik", "mikrotіk"), # synthetic Unicode lookalike + ) + + +@pytest.mark.parametrize("lab", [0, 1]) +@pytest.mark.parametrize("field", ["target_ref", "credential_ref", "locator_ref"]) +def test_alias_prefix_case_type_and_unknown_references_reject_before_read(lab, field): + target, credential_ref = _LAB_PAIRS[lab] + backend, fake, _ = _lab_backend(lab, result=_record()) + original = { + "target_ref": target, "credential_ref": credential_ref, + "locator_ref": _LAB_LOCATORS[lab], + } + unknown = original[field].replace(f"lab0{lab + 1}", "lab03") + for variant in (*_invalid_variants(original[field]), unknown): + values = dict(original, **{field: variant}) + binding = _tampered_binding( + credential_ref=values["credential_ref"], locator_ref=values["locator_ref"] + ) + with pytest.raises(Stage2WindowsCredentialError) as captured: + backend.read_for_target(values["target_ref"], binding) + assert captured.value.__context__ is None + assert captured.value.__cause__ is None + assert fake.read_calls == [] + + +@pytest.mark.parametrize("lab", [0, 1]) +def test_unknown_or_confused_configuration_locators_reject_without_read(lab): + fake = FakeWindowsCredentialApi(result=_record()) + for locator in (*_invalid_variants(_LAB_LOCATORS[lab]), "locator.unknown.readonly"): + with pytest.raises(Stage2WindowsCredentialError): + configuration = _configuration(locator_ref=locator) + build_stage2_windows_credential_backend(configuration, windows_api=fake) + assert fake.read_calls == [] + + +def test_unknown_target_credential_and_locator_together_reject_without_read(): + backend, fake, _ = _lab_backend(0, result=_record()) + binding = _tampered_binding( + credential_ref="credential.unknown.lab", locator_ref="locator.unknown.lab" + ) + _assert_error( + lambda: backend.read_for_target("target.unknown.lab", binding), + Stage2WindowsCredentialFailure.INVALID_BINDING, + ) + assert fake.read_calls == [] + + +@pytest.mark.parametrize("lab", [0, 1]) +def test_target_aware_api_requires_pair_and_has_no_caller_overrides(lab): + target, credential_ref = _LAB_PAIRS[lab] + binding = build_stage2_fixed_credential_resolver().resolve_for_target(target, credential_ref) + backend, fake, _ = _lab_backend(lab, result=_record()) + parameters = inspect.signature(backend.read_for_target).parameters + assert list(parameters) == ["target_ref", "binding"] + assert all(value.default is inspect.Parameter.empty for value in parameters.values()) + for name in ("locator_ref", "credential_target", "backend_kind", "credential_type", "read_flags"): + with pytest.raises(TypeError): + backend.read_for_target(target, binding, **{name: "synthetic-override"}) + assert fake.read_calls == [] + with pytest.raises(TypeError): + backend.read_for_target(binding) + assert fake.read_calls == [] + + +@pytest.mark.parametrize("lab", [0, 1]) +@pytest.mark.parametrize("kind", ["missing", "error", "malformed"]) +def test_target_aware_failures_remain_sanitized_with_one_read_and_no_fallback(lab, kind): + unsafe = f"{_SYNTHETIC_USERNAME}:{_SYNTHETIC_SECRET.decode()}" + backend, fake, configuration = _lab_backend( + lab, result=object() if kind == "malformed" else None, + error=OSError(unsafe) if kind == "error" else None, + ) + code = { + "missing": Stage2WindowsCredentialFailure.CREDENTIAL_NOT_FOUND, + "error": Stage2WindowsCredentialFailure.CREDENTIAL_READ_FAILED, + "malformed": Stage2WindowsCredentialFailure.MALFORMED_CREDENTIAL_RECORD, + }[kind] + binding = build_stage2_fixed_credential_resolver().resolve_for_target(*_LAB_PAIRS[lab]) + error = _assert_error(lambda: backend.read_for_target(_LAB_PAIRS[lab][0], binding), code) + assert error.__context__ is None + assert error.__cause__ is None + assert fake.read_calls == [configuration.credential_target] + + +@pytest.mark.parametrize("lab", [0, 1]) +def test_target_aware_binding_objects_and_backend_kinds_are_exact(lab): + backend, fake, _ = _lab_backend(lab, result=_record()) + for binding in (None, {}, object(), "binding"): + _assert_error( + lambda: backend.read_for_target(_LAB_PAIRS[lab][0], binding), + Stage2WindowsCredentialFailure.INVALID_BINDING, + ) + assert fake.read_calls == [] + for kind in (None, "OTHER_BACKEND", _ReferenceSubclass(STAGE2_CREDENTIAL_BACKEND_KIND)): + binding = _tampered_binding(backend_kind=kind) + _assert_error( + lambda: backend.read_for_target(_LAB_PAIRS[lab][0], binding), + Stage2WindowsCredentialFailure.UNSUPPORTED_BACKEND, + ) + assert fake.read_calls == [] + + +@pytest.mark.parametrize("lab", [0, 1]) +def test_target_aware_policy_uses_s2_ro_03_authority_and_never_real_reader(monkeypatch, lab): + resolver = build_stage2_fixed_credential_resolver() + binding = resolver.resolve_for_target(*_LAB_PAIRS[lab]) + calls = [] + original = type(resolver).resolve_for_target + + def observed(self, target_ref, credential_ref): + calls.append((target_ref, credential_ref)) + return original(self, target_ref, credential_ref) + + def denied(*args): + pytest.fail("real credential primitive must not be invoked") + + monkeypatch.setattr(type(resolver), "resolve_for_target", observed) + monkeypatch.setattr(module, "_read_windows_credential_exact", denied) + backend, fake, configuration = _lab_backend(lab, result=_record()) + backend.read_for_target(_LAB_PAIRS[lab][0], binding) + assert calls == [_LAB_PAIRS[lab]] + assert fake.read_calls == [configuration.credential_target] diff --git a/validation_framework/stage2_windows_credential_backend.py b/validation_framework/stage2_windows_credential_backend.py index e067c02..2a442f4 100644 --- a/validation_framework/stage2_windows_credential_backend.py +++ b/validation_framework/stage2_windows_credential_backend.py @@ -1,10 +1,10 @@ """Bounded Windows Credential Manager reader for the Stage 2 VRRP lab. -The public operation consumes the exact immutable S2-RO-03 binding and performs -one credential read through an injected narrow API. A real Windows target is -trusted runtime configuration: it is never accepted by ``read`` and no target -value is committed here. Windows libraries are loaded only when the real API -adapter is explicitly invoked. +The target-aware operation checks the supplied binding against S2-RO-03 and its +trusted configuration before one read through an injected narrow API. Legacy +``read(binding)`` remains Lab1-only. A real Windows target is trusted runtime +configuration, never an operational read argument. Windows libraries are loaded +only when the real API adapter is explicitly invoked. """ from __future__ import annotations @@ -18,8 +18,12 @@ STAGE2_CREDENTIAL_BACKEND_KIND, STAGE2_CREDENTIAL_LOCATOR_REF, STAGE2_FIXED_CREDENTIAL_REF, + STAGE2_SECOND_CREDENTIAL_LOCATOR_REF, Stage2CredentialBinding, + Stage2CredentialResolverError, + build_stage2_fixed_credential_resolver, ) +from validation_framework.stage2_mikrotik_target_registry import STAGE2_FIXED_TARGET_REF MAX_WINDOWS_CREDENTIAL_TARGET_LENGTH: Final = 512 @@ -68,7 +72,10 @@ class Stage2TrustedWindowsCredentialConfiguration: def __post_init__(self) -> None: if ( type(self.locator_ref) is not str - or self.locator_ref != STAGE2_CREDENTIAL_LOCATOR_REF + or self.locator_ref not in ( + STAGE2_CREDENTIAL_LOCATOR_REF, + STAGE2_SECOND_CREDENTIAL_LOCATOR_REF, + ) or not _is_bounded_windows_target(self.credential_target) ): _fail(Stage2WindowsCredentialFailure.INVALID_TRUSTED_CONFIGURATION) @@ -161,9 +168,45 @@ def __repr__(self) -> str: __str__ = __repr__ def read(self, binding: object) -> Stage2ResolvedCredential: - """Perform exactly one read after validating the immutable binding.""" + """Compatible Lab1-only operation; cannot retrieve a Lab2 binding.""" _validate_binding(binding) + return self.read_for_target(STAGE2_FIXED_TARGET_REF, binding) + + def read_for_target( + self, target_ref: object, binding: object + ) -> Stage2ResolvedCredential: + """Read only an S2-RO-03 exact pair matching trusted configuration. + + Neither the logical target nor the binding can override the configured + Windows record. This policy check is not Owner/live authorization. + """ + + if type(binding) is not Stage2CredentialBinding: + _fail(Stage2WindowsCredentialFailure.INVALID_BINDING) + if ( + type(binding.backend_kind) is not str + or binding.backend_kind != STAGE2_CREDENTIAL_BACKEND_KIND + ): + _fail(Stage2WindowsCredentialFailure.UNSUPPORTED_BACKEND) + invalid_pair = False + try: + expected_binding = build_stage2_fixed_credential_resolver().resolve_for_target( + target_ref, binding.credential_ref + ) + except Stage2CredentialResolverError: + invalid_pair = True + if invalid_pair: + _fail(Stage2WindowsCredentialFailure.INVALID_BINDING) + if ( + type(binding.locator_ref) is not str + or binding.locator_ref != expected_binding.locator_ref + ): + _fail(Stage2WindowsCredentialFailure.UNSUPPORTED_LOCATOR) + self._configuration.__post_init__() + if self._configuration.locator_ref != expected_binding.locator_ref: + _fail(Stage2WindowsCredentialFailure.UNSUPPORTED_LOCATOR) + failure: Stage2WindowsCredentialFailure | None = None try: record = self._windows_api.read_exact(