From cbf90a98dee6e11b6b125ad778e73f5ca7f6d1d3 Mon Sep 17 00:00:00 2001 From: RobinLee Date: Mon, 14 Sep 2026 13:47:27 +0800 Subject: [PATCH] fix(stage2): normalize Windows credential blob for SSH auth --- ...nly_s2_ro_04_windows_credential_backend.md | 98 +++++++++--- .../stage2/test_windows_credential_backend.py | 143 ++++++++++++++++-- .../stage2_windows_credential_backend.py | 39 ++++- 3 files changed, 247 insertions(+), 33 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 e95219f..f28740e 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,16 @@ ## Decision summary -S2-RO-04 extends only the trusted policy wrapper to accept the two exact +S2-RO-04 validates the trusted Stage-2 Windows password representation as strict +UTF-16LE and returns the same password as strict UTF-8 transport bytes. This +offline remediation corrects the credential handoff to the unchanged S2-RO-09 +transport. It does not authorize a live retry or claim compatibility PASS. + +The trusted policy wrapper continues 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**, +The native Windows reader is unchanged. This is an **offline remediation**, not permission to read a credential store or contact either lab. Status: implementation candidate ready for independent review after validation. @@ -23,13 +28,14 @@ S2-RO-03 immutable non-secret binding S2-RO-04 exact Windows credential read | v -bounded ephemeral credential material +bounded raw UTF-16LE bytes -> strict validation -> canonical UTF-8 bytes | v -future transport (not implemented) +unchanged S2-RO-09 transport (no execution authorized here) ``` -S2-RO-04 owns bounded credential retrieval policy only. Endpoint selection, authorization, +S2-RO-04 owns bounded credential retrieval and representation validation. +Endpoint selection, authorization, Owner verification, replay protection, host-key trust, network transport, command execution, runtime composition, and live-device access remain outside this slice. @@ -101,10 +107,30 @@ Successful retrieval returns an immutable, slotted `Stage2ResolvedCredential` with exactly: - `username`: a non-empty bounded string of at most 256 characters; -- `secret_blob`: non-empty immutable bytes of at most 4096 bytes. - -The blob remains bytes. S2-RO-04 does not assume UTF-8 or silently decode -arbitrary credential material. +- `secret_blob`: non-empty canonical UTF-8 password bytes of at most 4096 bytes. + +`Stage2WindowsCredentialApiRecord.secret_blob` holds raw bytes copied from the +trusted `CRED_TYPE_GENERIC` Windows `CredentialBlob`. Under the Stage-2 +provisioning contract these bytes contain strict UTF-16LE password data. +Generic credential blob semantics are application/provisioning-defined: this +is not a universal claim about every Windows Generic Credential. + +The backend has exactly one private conversion: `STRICT_UTF16LE -> STRICT_UTF8`. +It requires exact `bytes`, non-empty input, the existing 4096-byte raw bound, +even byte length, and no leading UTF-16 BOM in either byte order. Strict +UTF-16LE decoding must produce non-empty text without U+0000. Strict UTF-8 +encoding must produce non-empty bytes within the same 4096-byte bound. + +The logical password is unchanged, including leading/trailing whitespace, +Unicode form, case, and line endings. There is no encoding autodetection, +raw UTF-8 fallback, alternate encoding, repair, replacement, normalization, +trimming, or retry. Interior U+FEFF is preserved as password data; a leading +BOM is rejected. Public field names and failure categories remain unchanged. + +S2-RO-09 passes these transport-ready bytes exactly to +`transport.auth_password(username, secret, event=..., fallback=False)`. +It performs no credential decoding, alternate encoding, or fallback +authentication. Neither its production code nor its tests change here. Configuration, raw API records, backend objects, and resolved material all use redacted `repr` and `str` output. No `to_dict`, `to_json`, evidence, report, @@ -119,8 +145,10 @@ filesystem persistence or global cache, and secret material must not be logged or serialized. Later consumers must minimize the secret's lifetime and must not persist, serialize, or log it. -`GUARANTEED_PYTHON_MEMORY_ZEROIZATION = NO`: Python immutable `bytes` do not -provide a reliable guarantee that their underlying memory can be zeroized. +`GUARANTEED_PYTHON_MEMORY_ZEROIZATION = NO`: the conversion may transiently +create raw Windows bytes, a decoded Python string, and canonical UTF-8 bytes. +All must remain ephemeral and must never be logged or persisted. Python +immutable `bytes` and `str` provide no reliable underlying-memory zeroization. S2-RO-04 therefore does not claim guaranteed secure erase of Python-managed memory. The ephemeral-lifetime intent limits exposure, but it is not a memory clearing guarantee. @@ -138,10 +166,28 @@ the Windows target, username, or secret blob. Deterministic categories cover: - Windows read failure; - malformed credential record; - missing or oversized username; -- invalid, empty, or oversized secret blob. +- `CREDENTIAL_SECRET_INVALID` for wrong type, empty, odd-length, BOM-bearing, + malformed UTF-16LE, or decoded U+0000 input; +- `CREDENTIAL_SECRET_TOO_LARGE` for raw or canonical UTF-8 bytes above the bound. + +API and encoding exceptions become sanitized backend errors outside their +exception handlers, with no native cause/context or representation detail in +the public error. There is no retry, alternate target, or fallback lookup. + +## Sanitized integration finding -An API exception is converted to a sanitized backend error. There is no retry, -alternate target, or fallback lookup. +Two separately authorized Lab2 attempts passed pinned host-key verification +but failed password authentication; no remote command executed. An authorized +offline Owner-secret comparison established that the stored bytes matched +UTF-16LE and did not match UTF-8. The accepted source returned raw Windows +bytes from S2-RO-04 and supplied them unchanged to S2-RO-09 authentication. +This remediation corrects only that representation handoff. No credential +store or device is accessed during this offline implementation or validation. + +`POST_REMEDIATION_LIVE_AUTHENTICATION_RESULT = NOT_YET_VERIFIED` + +Independent read-only review and any later live retry require separate Owner +authorization. Offline test success does not establish S2-RO-09 compatibility. ## Offline reviewer evidence @@ -149,6 +195,13 @@ Focused tests use only a synthetic target, username, and secret with an injected fake API. They prove: - both exact target-bound identities perform exactly one read; +- ASCII, non-ASCII (including distinct Unicode forms), and whitespace passwords + yield exact UTF-8 bytes from synthetic UTF-16LE input for both targets; +- raw and canonical size bounds are enforced independently; +- odd length, either BOM, unpaired surrogates, and decoded U+0000 reject without + encoding fallback and with sanitized unchained errors; +- the fake native DLL copies bytes before `CredFree`; backend conversion occurs + after freeing the test-owned Windows allocation, including rejection paths; - 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; @@ -169,8 +222,8 @@ fake API. They prove: 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 +Validation order is focused S2-RO-04 tests, unchanged focused S2-RO-09 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 @@ -178,9 +231,16 @@ 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 +Windows runs follow the existing guarded, non-TTY policy described in the +[S2-RO-09 validation evidence](stage2_vrrp_readonly_s2_ro_09_pinned_ssh_transport.md): +native/network/process guards precede pytest import; plugin autoload and cache +are disabled, with guards retained in Python/Node regression children. Test +results must not render synthetic passwords in failure output. + +Every test suite requires zero failures. Only the existing accepted safety +skips may remain; this remediation adds none. Report-index may retain WARN only +for optional missing artifacts, with no mandatory failure. This remediation +does not remediate unrelated CI maintenance warnings or constitute independent review PASS. ## Explicit exclusions diff --git a/tests/stage2/test_windows_credential_backend.py b/tests/stage2/test_windows_credential_backend.py index ebe1d4a..1f7dcdc 100644 --- a/tests/stage2/test_windows_credential_backend.py +++ b/tests/stage2/test_windows_credential_backend.py @@ -39,6 +39,7 @@ _SYNTHETIC_TARGET = "synthetic.stage2.test.windows-credential-target" _SYNTHETIC_USERNAME = "synthetic-readonly-user" _SYNTHETIC_SECRET = b"synthetic-secret-bytes" +_SYNTHETIC_WINDOWS_SECRET = _SYNTHETIC_SECRET.decode("utf-8").encode("utf-16-le") @pytest.fixture(autouse=True) @@ -107,7 +108,7 @@ def _native_boundary_harness( monkeypatch, *, username=_SYNTHETIC_USERNAME, - secret_blob=_SYNTHETIC_SECRET, + secret_blob=_SYNTHETIC_WINDOWS_SECRET, blob_size=None, null_blob=False, read_succeeds=True, @@ -213,7 +214,7 @@ def _configuration(**changes): def _record(**changes): values = { "username": _SYNTHETIC_USERNAME, - "secret_blob": _SYNTHETIC_SECRET, + "secret_blob": _SYNTHETIC_WINDOWS_SECRET, } values.update(changes) return Stage2WindowsCredentialApiRecord(**values) @@ -230,12 +231,136 @@ def _backend(*, result=None, error=None): def _assert_error(function, code): with pytest.raises(Stage2WindowsCredentialError) as captured: function() - assert captured.value.code is code - assert str(captured.value) == code.value - assert repr(captured.value) == f"Stage2WindowsCredentialError('{code.value}')" + if ( + captured.value.code is not code + or str(captured.value) != code.value + or repr(captured.value) != f"Stage2WindowsCredentialError('{code.value}')" + or captured.value.__cause__ is not None + or captured.value.__context__ is not None + ): + pytest.fail("credential error sanitization/category mismatch", pytrace=False) return captured.value +def _assert_secret_equal(actual, expected): + """Avoid rendering credential material through assertion introspection.""" + + if type(actual) is not bytes or actual != expected: + pytest.fail("canonical password bytes mismatch", pytrace=False) + + +@pytest.mark.parametrize( + "password", + [ + pytest.param("synthetic-ascii", id="ascii"), + pytest.param("synthetic-\u00e9-e\u0301-\u6e2c\U0001f642", id="unicode-unmodified"), + pytest.param(" \tsynthetic\r\npassword\n ", id="whitespace-line-endings"), + pytest.param(" \t\r\n ", id="whitespace-only"), + pytest.param("synthetic\ufeffdata", id="interior-codepoint-preserved"), + pytest.param("x" * 2048, id="raw-exact-bound"), + pytest.param("\u0800" * 1365 + "x", id="utf8-exact-bound"), + ], +) +@pytest.mark.parametrize("lab", [0, 1], ids=["lab1", "lab2"]) +def test_stage2_password_contract_preserves_exact_text_for_each_target(password, lab): + target_ref, credential_ref = _LAB_PAIRS[lab] + binding = build_stage2_fixed_credential_resolver().resolve_for_target( + target_ref, credential_ref + ) + raw_blob = password.encode("utf-16-le", errors="strict") + record = _record(secret_blob=raw_blob) + backend, fake, configuration = _lab_backend(lab, result=record) + + resolved = backend.read_for_target(target_ref, binding) + + _assert_secret_equal(resolved.secret_blob, password.encode("utf-8", errors="strict")) + _assert_secret_equal(record.secret_blob, raw_blob) + assert fake.read_calls == [configuration.credential_target] + assert repr(record) == "Stage2WindowsCredentialApiRecord()" + assert str(resolved) == "Stage2ResolvedCredential()" + + +class _BytesSubclass(bytes): + pass + + +@pytest.mark.parametrize( + "raw_blob", + [ + pytest.param(_BytesSubclass(b"a\0"), id="bytes-subclass"), + pytest.param(memoryview(b"a\0"), id="memoryview"), + pytest.param(b"abc", id="odd-valid-utf8-no-fallback"), + pytest.param(b"\xff\xfe" + b"a\0", id="le-bom"), + pytest.param(b"\xfe\xff" + b"\0a", id="be-bom"), + pytest.param(b"\x00\xd8", id="lone-high-surrogate"), + pytest.param(b"\x00\xdc", id="lone-low-surrogate"), + pytest.param(b"\x00\xd8a\0", id="unpaired-surrogate"), + pytest.param(b"\x00\xdc\x00\xd8", id="reversed-surrogates"), + pytest.param(b"\0\0", id="nul-only"), + pytest.param("synthetic\0data".encode("utf-16-le"), id="embedded-nul"), + ], +) +def test_invalid_stage2_representation_is_sanitized_without_retry_or_fallback(raw_blob): + backend, fake = _backend(result=_record(secret_blob=raw_blob)) + + _assert_error( + lambda: backend.read(_binding()), + Stage2WindowsCredentialFailure.CREDENTIAL_SECRET_INVALID, + ) + + assert fake.read_calls == [_SYNTHETIC_TARGET] + + +def test_valid_raw_blob_with_oversized_utf8_result_is_rejected(): + raw_blob = ("\u0800" * 1366).encode("utf-16-le") + assert len(raw_blob) <= MAX_CREDENTIAL_SECRET_BLOB_LENGTH + backend, fake = _backend(result=_record(secret_blob=raw_blob)) + + _assert_error( + lambda: backend.read(_binding()), + Stage2WindowsCredentialFailure.CREDENTIAL_SECRET_TOO_LARGE, + ) + + assert fake.read_calls == [_SYNTHETIC_TARGET] + + +def test_native_fake_copy_is_freed_before_backend_transcodes(monkeypatch): + password = "synthetic-e\u0301-\u6e2c\U0001f642 " + raw_blob = password.encode("utf-16-le") + backend, library, state = _native_boundary_harness( + monkeypatch, secret_blob=raw_blob + ) + original = module._canonical_password_bytes + + def observed_conversion(copied_blob): + assert state["freed"] is True + _assert_secret_equal(copied_blob, raw_blob) + state["events"].append("transcode") + return original(copied_blob) + + monkeypatch.setattr(module, "_canonical_password_bytes", observed_conversion) + resolved = backend.read(_binding()) + + _assert_secret_equal(resolved.secret_blob, password.encode("utf-8")) + assert state["events"] == ["cred_read", "secret_read", "cred_free", "transcode"] + assert len(library.CredReadW.calls) == len(library.CredFree.calls) == 1 + + +def test_native_fake_invalid_encoding_is_freed_and_error_has_no_native_chain(monkeypatch): + backend, library, state = _native_boundary_harness( + monkeypatch, secret_blob=b"\x00\xd8" + ) + + _assert_error( + lambda: backend.read(_binding()), + Stage2WindowsCredentialFailure.CREDENTIAL_SECRET_INVALID, + ) + + assert state["events"] == ["cred_read", "secret_read", "cred_free"] + assert state["freed"] is True + assert len(library.CredReadW.calls) == len(library.CredFree.calls) == 1 + + def _tampered_binding(**changes): original = _binding() result = object.__new__(Stage2CredentialBinding) @@ -276,7 +401,7 @@ def test_exact_accepted_binding_reads_once_and_returns_immutable_material(): assert type(credential) is Stage2ResolvedCredential assert credential.username == _SYNTHETIC_USERNAME - assert credential.secret_blob == _SYNTHETIC_SECRET + _assert_secret_equal(credential.secret_blob, _SYNTHETIC_SECRET) assert fake.read_calls == [_SYNTHETIC_TARGET] assert (binding.credential_ref, binding.backend_kind, binding.locator_ref) == before with pytest.raises(FrozenInstanceError): @@ -497,7 +622,7 @@ def unexpected_call(_target): monkeypatch.setattr(module, "_read_windows_credential_exact", unexpected_call) backend, fake = _backend(result=_record()) - assert backend.read(_binding()).secret_blob == _SYNTHETIC_SECRET + _assert_secret_equal(backend.read(_binding()).secret_blob, _SYNTHETIC_SECRET) assert fake.read_calls == [_SYNTHETIC_TARGET] assert real_calls == [] @@ -523,7 +648,7 @@ def test_native_success_copies_before_free_without_pointer_escape(monkeypatch): assert type(resolved.username) is str assert type(resolved.secret_blob) is bytes assert resolved.username == _SYNTHETIC_USERNAME - assert resolved.secret_blob == _SYNTHETIC_SECRET + _assert_secret_equal(resolved.secret_blob, _SYNTHETIC_SECRET) assert state["events"] == ["cred_read", "secret_read", "cred_free"] assert state["freed"] is True assert state["win_dll_calls"] == [("Advapi32.dll", True)] @@ -755,7 +880,7 @@ def test_target_aware_exact_binding_reads_once_with_immutable_redacted_output(la assert type(result) is Stage2ResolvedCredential assert result.username == _SYNTHETIC_USERNAME assert type(result.secret_blob) is bytes - assert result.secret_blob == _SYNTHETIC_SECRET + _assert_secret_equal(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")): diff --git a/validation_framework/stage2_windows_credential_backend.py b/validation_framework/stage2_windows_credential_backend.py index 2a442f4..589eaba 100644 --- a/validation_framework/stage2_windows_credential_backend.py +++ b/validation_framework/stage2_windows_credential_backend.py @@ -88,7 +88,7 @@ def __repr__(self) -> str: @dataclass(frozen=True, slots=True, repr=False) class Stage2WindowsCredentialApiRecord: - """Untrusted narrow result returned by the Windows API boundary.""" + """Untrusted result with raw bytes copied from Windows CredentialBlob.""" username: object = field(repr=False) secret_blob: object = field(repr=False) @@ -101,7 +101,7 @@ def __repr__(self) -> str: @dataclass(frozen=True, slots=True, repr=False) class Stage2ResolvedCredential: - """Bounded ephemeral material for a future transport.""" + """Bounded ephemeral material; backend output is a UTF-8 password blob.""" username: str = field(repr=False) secret_blob: bytes = field(repr=False) @@ -272,12 +272,41 @@ def _validate_record(record: object) -> Stage2ResolvedCredential: ): _fail(Stage2WindowsCredentialFailure.MALFORMED_CREDENTIAL_RECORD) - if type(secret_blob) is not bytes or not secret_blob: + return Stage2ResolvedCredential( + username=username, secret_blob=_canonical_password_bytes(secret_blob) + ) + + +def _canonical_password_bytes(raw_blob: object) -> bytes: + """Validate only the trusted Stage-2 UTF-16LE provisioning contract.""" + + if type(raw_blob) is not bytes or not raw_blob: _fail(Stage2WindowsCredentialFailure.CREDENTIAL_SECRET_INVALID) - if len(secret_blob) > MAX_CREDENTIAL_SECRET_BLOB_LENGTH: + if len(raw_blob) > MAX_CREDENTIAL_SECRET_BLOB_LENGTH: _fail(Stage2WindowsCredentialFailure.CREDENTIAL_SECRET_TOO_LARGE) + if len(raw_blob) % 2 or raw_blob.startswith((b"\xff\xfe", b"\xfe\xff")): + _fail(Stage2WindowsCredentialFailure.CREDENTIAL_SECRET_INVALID) + + invalid_encoding = False + try: + password = raw_blob.decode("utf-16-le", errors="strict") + except UnicodeError: + invalid_encoding = True + # Raise outside the handler so encoding exceptions retain no public chain. + if invalid_encoding: + _fail(Stage2WindowsCredentialFailure.CREDENTIAL_SECRET_INVALID) + if not password or "\0" in password: + _fail(Stage2WindowsCredentialFailure.CREDENTIAL_SECRET_INVALID) - return Stage2ResolvedCredential(username=username, secret_blob=secret_blob) + try: + canonical_blob = password.encode("utf-8", errors="strict") + except UnicodeError: + invalid_encoding = True + if invalid_encoding or not canonical_blob: + _fail(Stage2WindowsCredentialFailure.CREDENTIAL_SECRET_INVALID) + if len(canonical_blob) > MAX_CREDENTIAL_SECRET_BLOB_LENGTH: + _fail(Stage2WindowsCredentialFailure.CREDENTIAL_SECRET_TOO_LARGE) + return canonical_blob def _is_bounded_windows_target(value: object) -> bool: