Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -138,17 +166,42 @@ 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

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;
Expand All @@ -169,18 +222,25 @@ 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
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
Expand Down
143 changes: 134 additions & 9 deletions tests/stage2/test_windows_credential_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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(<redacted>)"
assert str(resolved) == "Stage2ResolvedCredential(<redacted>)"


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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 == []

Expand All @@ -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)]
Expand Down Expand Up @@ -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")):
Expand Down
Loading