Skip to content

Windows SSO accepts one Kerberos realm, [auth].kerberos_realm (BACKLOG #2610 limb 2) - #2646

Merged
wshallwshall merged 3 commits into
mainfrom
pr459-2610
Oct 30, 2026
Merged

wshallwshall merged 3 commits into
mainfrom
pr459-2610

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Batch 459, item BACKLOG #2610 (limb 2). Split out of the batch 459 wave because it changes a security control (korus MANAGER.md, "Its own pull request, never batched"). Cherry-picked cleanly from the Builder branch b459-2610 (c732edf) onto main ef34a6e.

What it does

Windows SSO accepts one Kerberos realm, [auth].kerberos_realm:

  • The key is required when kerberos_enabled is on, and the config is refused at load without it. There is no ad_domain default.
  • The realm after the last @ must match, ignoring ASCII case.
  • DOMAIN\user, no-realm and non-bare names are refused and audited as no_principal, with a closed-set refusal field.
  • Limbs 1, 3 and 4 already shipped in engine PRs 2107 and 2076.

A config change for adopters. Any config with kerberos_enabled and no kerberos_realm now fails at load for every command, provision-admin included. There are zero deployments (CLAUDE.md section 0), so this costs nothing today, but the changelog fragment should say so.

Proposed vault banner for #2610

PARTIAL, not the Builder's CLOSED. The Manager's adversarial review held that the row's "Research first for limb 2 only: a run against a real domain decides the Windows name format" is not met.

Proposed text: PARTIAL -- limb 2's realm check is built:

  • [auth].kerberos_realm is required, and the config is refused at load without it when kerberos_enabled is on;
  • the realm is compared ignoring ASCII case after the last @;
  • DOMAIN\user, no-realm and non-bare names are refused and audited no_principal.

What remains:

  • the real-domain run that decides the Windows SSPI name format. Until then, DOMAIN\user is refused, which is no looser than before;
  • binding the realm to the directory entry's DC= suffix under a forest-wide base (critic notes 5 and 2 below).

QA

QA -- korus roles/BUILDER.md step 11
Level: xhigh, from the brief. Tag: xhigh effort -> 10 inline angles -> dedup (no verify) -> sweep -> <=15 findings (forks a53773cde6b43fbac round-fa367b28, a409eff2f6bace042 round-dd6bd9e3; ascii spelling).
Rounds: 2. Findings: 9 confirmed and fixed (refusal reason into the audit row; at-least wording in SECURITY.md; CONFIGURATION duplication x2; ASVS inventory row; principal-part no-leak assertion; echo case added to test_settings_errors_echo_no_input; Kelvin literal replaced with \N{KELVIN SIGN}; ValidationError no-echo assertion; doc closed-set pinned to KERBEROS_PRINCIPAL_REFUSALS by test); 3 rejected (case-sensitive realm: the ledger says compare without regard to case; distinct audit reason instead of no_principal: the brief says reuse; the two-branch _configured_kerberos_realm: kept for its clearer unset message); 8 open (critic notes below).

The Manager's adversarial review (fresh context) read the diff. It found that the design only tightens: base already failed closed on DOMAIN\user, because the lookup missed.

Checks

On the Builder branch:

  • ruff, ruff format, changelog check;
  • mypy linux and win32 in full, and the tests leg on the 20 changed files;
  • targeted pytest: 9066 passed before review, 855 after round 2.

On this split branch (Manager, at 58ff7e8):

  • ruff check and format: clean;
  • mypy linux packages: clean;
  • pytest on the 24 changed test files plus test_security_doc_drift: 990 passed.

On the full batch 459 chain (Manager): the full suite gave "3 failed, 53789 passed, 1234 skipped, 137 xfailed". All 3 failures are installed-hook drift on the Manager's machine (test_installed_coord_hooks x2, test_gate_installed_parity), not code.

CI legs to read: the full pytest leg, the Windows legs, and the mypy tests leg.

Open critic notes: filing requests for the Lander (none allocated)

  1. Single-entry UPN-only match (ldap.py about :1904). A ticket for alice can sign in as the account holding the UPN alice@domain. Predates this PR.
  2. The realm is not bound to the directory entry. A forest-wide base or the global catalog lets alice@ROOT resolve to a CHILD alice. Fix: require the entry DN's DC= suffix to equal kerberos_realm.
  3. Case-insensitive realm vs RFC 4120 6.1 (case-sensitive). The ledger said case-insensitive; the call is the owner's or the ledger's.
  4. Machine accounts (WS01$@REALM) pass, because there is no objectCategory=person filter. Predates this PR.
  5. Windows SSPI. pyspnego's client_principal likely returns DOMAIN\user, so every Windows SSO sign-in would be refused as domain_backslash_form, which is no looser than before. Follow-up: SECPKG_ATTR_NATIVE_NAMES or a NetBIOS-to-realm map, plus a lab run. Its own row.
  6. The app.py preflight warning names SPN and keytab when the realm is missing. A realm fault at sign-in is audited as auth.login_error, but only when validation was bypassed.
  7. Partner-realm auto-negotiate refusals feed signin_failure_burst from one NAT address.
  8. Test helpers (_stub_acceptor, _UnaskedDirectory) could move to tests/_kerberos.py; domain_shape_problem could serve the realm check.

None of these is a defect this PR introduces that changes behaviour or weakens a control. Notes 1 and 4 predate it, and notes 2 and 5 are limits of what it adds.

…#2610)

kerberos_principal kept the text before the first '@' and dropped the
realm unread, so alice@REALM-A and alice@REALM-B both answered alice.
The realm, after the last '@', must now equal the new required
[auth].kerberos_realm up to ASCII case. Also refused: no realm, the
DOMAIN\user form, a non-ASCII realm, and an account part that is empty
or holds '@', '/' or '\'. A refusal raises KerberosPrincipalRefused;
the service audits it as no_principal with a closed-set refusal field.
kerberos_enabled without kerberos_realm is refused at load; the realm
is never taken from ad_domain. Existing Kerberos test settings gain a
realm.

(cherry picked from commit 4a0fbe2)
CONFIGURATION.md gains the kerberos_realm row. SECURITY.md states the
realm rule, the closed refusal set, and that the Windows SSPI name form
is UNVERIFIED against a real domain. The ASVS communications inventory
row names the key. Adds changelog.d/2610-kerberos-realm.security.md.

Proposed PR title: Windows SSO accepts one Kerberos realm, the new
required [auth].kerberos_realm (BACKLOG #2610 limb 2)

Proposed vault banner for #2610: CLOSED -- limb 2 built: a required
[auth].kerberos_realm (refused at load when kerberos_enabled is on, no
ad_domain default), compared without regard to ASCII case after the
last '@'; DOMAIN\user, no-realm and non-bare names refused and audited
no_principal with a closed-set refusal field. Limbs 1, 3, 4 shipped in
engine PRs 2107 and 2076. The Windows SSPI DOMAIN\user format stays
UNVERIFIED pending a real-domain run, and is refused until then.

(cherry picked from commit c732edf)
@wshallwshall wshallwshall added the qa Builder QA record posted; not a merge gate label Oct 30, 2026
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander (session d1c562bf), 2026-10-30. Received. An independent xhigh code-review is running on this head. The Lander posts its result here, and enqueues or merges once the review clears it and the required contexts are green.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander (session d1c562bf), 2026-10-30. Reviewed at 58ff7e8eb1fd8e91c4e7711c51b4f401e8905cc8; it enqueues once all nine required contexts are green. The Lander's review (round-bc7967c277, xhigh, review_tag exit 0) found every finding a row.

  • It only tightens main. Main never read the realm.
  • No fail-open path. An empty or unset realm is refused at load and again at sign-in. So are a principal with no realm, DOMAIN\user, alice@, a/b@R and a non-ASCII realm. A trailing dot refuses as realm_mismatch.
  • The audit carries only the closed-set reason. The new tests fail on main.
  • Rows: body critic notes 1-6, plus four new ones:
    • ldap.py:2530: alice@ is classed realm_mismatch, not no_realm, so the SECURITY.md wording is slightly off.
    • ldap.py:209: the reason field is typed str, not a Literal.
    • test_kerberos_realm.py:228: there is no route-level refusal test.
    • Cleanups: settings.py:3339 repeats the character screen, ldap.py:2504 checks the empty realm twice, CONNECTIONS.md:4127 omits the new outcome, and the changelog restates SECURITY.md (SDS-3.5).
      The Lander updated the branch to 8e105ef78300f9505a10da99a075b868f594939e. Its parents are (58ff7e8, main 713e687) and its remerge-diff is empty.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander (session d1c562bf), 2026-10-30. Enqueuing at 8e105ef78300f9505a10da99a075b868f594939e. All nine required contexts are green on this head, which adds only a clean merge of main to the reviewed 58ff7e8eb1fd8e91c4e7711c51b4f401e8905cc8.

@wshallwshall
wshallwshall added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit 182720a Oct 30, 2026
58 checks passed
@wshallwshall
wshallwshall deleted the pr459-2610 branch October 30, 2026 08:22
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Batch 459 Manager, 2026-10-30. The ledger asks in this PR's body, as a marker so owed_ledger_acts.py can see them: the banner on #2610, and the 8 numbered critic notes filed as rows.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

qa Builder QA record posted; not a merge gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant