Skip to content

fix: force file-based keyring on WSL to prevent grant login hanging - #59

Merged
aaearon merged 7 commits into
mainfrom
fix/wsl-keyring-basic-override
Aug 14, 2026
Merged

fix: force file-based keyring on WSL to prevent grant login hanging#59
aaearon merged 7 commits into
mainfrom
fix/wsl-keyring-basic-override

Conversation

@aaearon

@aaearon aaearon commented Aug 14, 2026

Copy link
Copy Markdown
Owner

P0. grant login hangs forever with no output on WSL2. Found by live-tenant testing, not review.

Root cause

The SDK's WSL detection is case-sensitive:

// pkg/common/keyring/idsec_keyring.go:90
return strings.Contains(string(data), "Microsoft")

Modern WSL2 kernels report lowercase — 6.18.33.2-microsoft-standard-WSL2 — so isWSL() returns false. Under WSLg DBUS_SESSION_BUS_ADDRESS is set, so GetKeyring selects the OS-provided D-Bus keyring, and the call to gnome-keyring-daemon blocks indefinitely. Observed: the process parks on a futex with one ESTAB unix socket to /run/user/1000/bus, no timeout, no error, no output.

No SDK fallback can save it. Both IdsecOSProvidedKeyring and SaveToken fall back to the basic keyring only when the underlying call returns an error. A blocked call never returns and never errors. This is also not write-only — LoadToken/GetPassword runs during authentication before any write and can hang identically.

Fix

New internal/keyringenv detects WSL via four signals — /proc/version and /proc/sys/kernel/osrelease (both case-insensitive, matching microsoft or wsl), plus WSL_DISTRO_NAME and WSL_INTEROP — and sets IDSEC_BASIC_KEYRING=1 before any keyring access. Wired into Execute() as its first action, which runs before Cobra parsing and before every keyring call site.

Environment precedence, which is counter-intuitive: the SDK checks os.Getenv(...) != "", so any non-empty value forces the safe basic keyring — IDSEC_BASIC_KEYRING=0 and =false included. The dangerous case is an explicitly-set empty value, which leaves the OS keyring in play. So this preserves any non-empty value and overwrites only unset-or-empty on detected WSL.

Fails closed. If Setenv fails on a detected WSL host, startup aborts with the manual workaround rather than continuing into the known hang.

Two SDK seams were considered and rejected: IdsecAuthBase.CacheKeyring covers only the outer keyring grant supplies, not ones Identity authentication builds internally; keyring.OSKeyringOpen is process-global mutable state and would couple grant to 99designs/keyring, an SDK transitive dependency.

Verification

On a real WSL2 host with IDSEC_BASIC_KEYRING unset: grant login --help returns immediately, and grant version --verbose reports WSL detected (/proc/version); forcing the file-based keyring via IDSEC_BASIC_KEYRING=1. Without --verbose, nothing extra is printed.

internal/keyringenv needs no build tags — it short-circuits on GOOS != "linux" before touching /proc. GOOS=windows go vet ./... and GOOS=darwin go vet ./... both pass.

Tests cover ten IsWSL cases (including the lowercase regression and that an empty WSL_DISTRO_NAME is not a signal), nine Apply cases (including =0 preserved, explicitly-empty overwritten on WSL, and Setenv failure failing closed), plus a startup-hook test that records an ordered trace proving the override runs before command execution rather than only testing the detector in isolation.

Note for existing users

Switching backends makes a token previously stored in the OS keyring invisible. On WSL that token almost certainly does not exist, since login could never complete. Worst case: re-run grant login.

Follow-ups, not in this PR

  • The same hang occurs on any Linux host with a wedged secret service. The general fix is a D-Bus pre-flight probe, deliberately deferred to keep this P0 small.
  • Upstream issue for cyberark/idsec-sdk-golang covering both the case-insensitive detection and the uncancellable OS-keyring operations.
  • cmd/logout.go calls GetKeyring(true) (always basic) while login may have used the OS keyring — a pre-existing mismatch, now consistent on WSL as a side effect.

The idsec SDK detects WSL by matching "Microsoft" case-sensitively against
/proc/version. Modern WSL2 kernels report "-microsoft-standard-WSL2", so
detection fails; WSLg sets DBUS_SESSION_BUS_ADDRESS, so GetKeyring returns
the OS-provided D-Bus keyring, which can block indefinitely against an
unresponsive gnome-keyring-daemon. The SDK's fallbacks only trigger on a
returned error, and a hang is never an error, so it is unrecoverable.

Add internal/keyringenv, which detects WSL case-insensitively via
/proc/version, /proc/sys/kernel/osrelease, WSL_DISTRO_NAME and WSL_INTEROP,
and sets IDSEC_BASIC_KEYRING=1 before any keyring access. Any existing
non-empty value is preserved (the SDK treats every non-empty value as
"force basic"); an explicitly empty value is overwritten on WSL because
empty is the setting that selects the hanging backend.

Applied from Execute() ahead of rootCmd.Execute(), and fails closed: a
Setenv error aborts rather than walking the user into the hang. The
diagnostic is emitted only under --verbose.
@aaearon
aaearon marked this pull request as draft August 14, 2026 05:35
@aaearon
aaearon marked this pull request as ready for review August 14, 2026 05:35
/proc/sys/kernel/osrelease was matched against "microsoft" and "wsl2",
so an osrelease carrying only a "WSL" marker was missed when no other
signal was available. Both files now check both tokens.

The osrelease fixture contained microsoft and WSL2 together and so could
not prove either token on its own; the /proc/version fixtures had the
same overlap. Split into single-token cases, keeping the verbatim host
string as an explicit end-to-end regression case.

Also correct the README/CLAUDE.md claim that only unset/empty selects the
OS keyring (Docker, SDK-detected WSL and enforceBasic still force file
storage), condense the CHANGELOG entry to one line per the new
convention, and note that keyringEnvNotice is set but never cleared.
…ersion

Add filesystem markers, checked first: /run/WSL, plus
/proc/sys/fs/binfmt_misc/WSLInterop as a supplement (that binfmt entry
exists only when interop is enabled in wsl.conf, so it cannot stand
alone). /run/WSL is the only signal that survives custom kernels, sudo,
systemd units and cron: WSL_DISTRO_NAME is lost under sudo -i
(microsoft/WSL#5914) and absent in systemd units (#9719), and a custom
WSL2 kernel may carry neither "microsoft" nor "wsl" (#6911). Until now
there was no signal at all for a systemd unit on such a kernel. snapd
moved to this marker for the same reason (Launchpad #1991823).

Stop matching a bare "wsl" in /proc/version. That string is free-form and
includes the kernel build user, build host and compiler banner, so a
plain Linux box built on a host named "wsl-builder" would match — a false
positive we introduced ourselves. Neither systemd nor npm is-wsl matches
it there. /proc/version now checks "microsoft" only; osrelease, which is
short and structured, keeps both tokens.

Detector gains an injected Exists func. Correct the doc comment, which
credited the "wsl" token with covering custom kernels, and record the
error asymmetry that justifies biasing toward over-detection: a false
negative hangs forever with no error and no timeout, a false positive is
a bounded security downgrade.
The tests asserted only booleans, so the documented signal order could
have been reordered silently. Add a wantSignal field and five cases with
multiple signals present at once, each pinning which one wins. Verified
by mutation: swapping the marker order or the two string sources fails
exactly the affected precedence cases.

The non-linux short-circuit probe now also trips on LookupEnv, and the
Apply table gained the same probe across ReadFile, Exists, LookupEnv and
Setenv, so "no access at all off linux" is fully asserted rather than
filesystem-only.

Correct the npm is-wsl attribution: it does not match "wsl" on osrelease.
It matches "microsoft" only, on os.release() then /proc/version, then
falls back to WSLInterop/run/WSL existence, all gated behind
!isInsideContainer(). systemd is the precedent for the osrelease "wsl"
token; is-wsl is a precedent for the markers.
The detector checked five signals; two of them could never independently
decide anything. Both are removed, leaving /run/WSL -> WSLInterop ->
osrelease. No user-visible behaviour change, so the CHANGELOG entry stands.

/proc/version is redundant with /proc/sys/kernel/osrelease by kernel
construction. fs/proc/version.c formats the file as

    seq_printf(m, linux_proc_banner, utsname()->sysname,
               utsname()->release, utsname()->version);

and /proc/sys/kernel/osrelease *is* utsname()->release --
kernel/utsname_sysctl.c registers uts_kern_table entry "osrelease" over
init_uts_ns.name.release, resolved against the caller's UTS namespace by
get_uts(). So "microsoft" in the release component cannot match unless
osrelease matches too. The file's other substrings are LINUX_COMPILE_BY,
LINUX_COMPILE_HOST and LINUX_COMPILER (init/version.c) -- build metadata,
where a match is a false positive rather than extra coverage. WSL1 loses
nothing: it exposes osrelease as 4.4.0-19041-Microsoft, and the WSL team
named both files together in microsoft/WSL#423.

WSL_DISTRO_NAME / WSL_INTEROP are redundant with /run/WSL. WSL's init
setenv's WSL_DISTRO_NAME in ConfigInitializeInstance()
(src/linux/init/config.cpp), the same function that ~100 lines later, with
no conditional in between, creates /run/WSL via InteropServer::Create()
under FATAL_ERROR -- a distro that cannot create the marker does not boot.
The var therefore cannot be set in a process whose distro lacks the marker.
WSL_INTEROP is narrower still, exported only when interop is enabled. Both
also point the wrong way for the error asymmetry, being absent under
sudo -i (#5914), in systemd units (#9719), in cron and over ssh (#12647),
exactly where /run/WSL keeps working. snapd and npm is-wsl both check the
markers and decline the env vars.

Detector.LookupEnv stays: Apply() still needs it for the
IDSEC_BASIC_KEYRING precedence check.

Tests: the precedence regression-lock is preserved for the three remaining
signals. The /proc/version-only and env-var-only cases are replaced by
TestProcVersionIsNeverRead and TestWSLSignalReadsNoEnvVars, which assert
the removed sources are not consulted at all, so a future re-add fails
loudly. The former /proc/version false-positive case ("wsl" in the build
host) survives as a microsoft-builder fixture.
This reverts commit db73645.

Both removals failed the redundancy bar. The mistake was conflating
content equivalence with signal redundancy: two sources can be guaranteed
to hold the same bytes and still not be interchangeable, because they can
differ in *visibility*.

/proc/version. The kernel-source finding stands -- fs/proc/version.c and
/proc/sys/kernel/osrelease both render utsname()->release through the
caller's UTS namespace, so their contents cannot disagree. But they are
separate mount-visible paths. A chroot or mount namespace can expose
/proc/version while masking, omitting or replacing
/proc/sys/kernel/osrelease. There the old detector succeeded and the
simplified one returned false. Identical content does not help when the
file is not visible.

WSL_DISTRO_NAME / WSL_INTEROP. The second call site is real: init sets
WSL_DISTRO_NAME (config.cpp) and then creates /run/WSL via an unguarded
InteropServer::Create() in the same function, so current distro init does
create the marker even with interop disabled. But that proves only that
*init* created it, not that every descendant retaining the env var can
still see it. Environment variables cross chroot and mount-namespace
boundaries; /run/WSL does not. With a clean /run, no binfmt and a renamed
osrelease, the env var is the only surviving signal.

The mitigation claimed for that gap is also wrong: DBUS_SESSION_BUS_ADDRESS
is not reliably absent in such contexts. WSL init injects it explicitly for
systemd-backed launches (init.cpp), and ordinary chroot preserves it, so
the SDK cannot be assumed to fall back to the basic keyring.

Restores all five signals and the precedence order /run/WSL -> WSLInterop
-> osrelease -> /proc/version -> env vars. TestWSLSignalReadsNoEnvVars goes
with the revert rather than surviving it: it locked in exactly the
namespace false negative described above.

Given the error asymmetry -- a false negative hangs the OS keyring under
WSLg with no error and no timeout, unrecoverably -- a redundant check is
the cheap side of the trade.
The wording predated the reverted simplification and was self-contradicting:
it claimed the signal set covered "every configuration" and said "do not
re-add", then documented a residual gap two paragraphs later. That framing
is what made db73645 look justified.

Replace it with the actual reason all five stay: the redundancy is about
VISIBILITY, not reliability. The signals fail independently because each is
reached by a different mechanism -- filesystem markers are namespace-local,
the two proc paths are separately mount-visible and independently maskable,
and the env vars are the only ones inherited across chroot and
mount-namespace boundaries, which is exactly where the markers vanish. So
"this signal is weaker" is never on its own grounds for deleting it.

Also records the trap explicitly: content equivalence is not signal
redundancy. Two sources can be guaranteed to hold the same bytes and still
not be interchangeable.

Keeps the two findings from the reverted work that remain correct and
useful, now framed as reasoning aids rather than grounds for removal:

- the kernel-source proof that /proc/version and osrelease cannot disagree
  in content (fs/proc/version.c renders utsname()->release via
  linux_proc_banner; kernel/utsname_sysctl.c maps osrelease onto that same
  field through get_uts());
- the corrected WSLInterop gating -- interop-gated per-distro on WSL1
  (config.cpp:543-551), kernel-global VM-wide on WSL2, so the previous
  blanket "exists only when interop is enabled" was wrong.

Corrects the rejected-alternatives note too: the claim that
DBUS_SESSION_BUS_ADDRESS is rarely set in containers is refuted -- WSL init
injects it for systemd-backed launches and chroot preserves it, so the SDK
cannot be assumed to fall back to the basic keyring by itself.
@aaearon
aaearon merged commit 6509e21 into main Aug 14, 2026
2 checks passed
aaearon added a commit that referenced this pull request Aug 14, 2026
…verstating it

Codex review of ed738ad. Three defects, one of them real.

**The guard did not cover PersistentPreRunE.** It was installed inside
RunE, and Cobra runs the root pre-run first — which on --verbose emits the
WSL keyring notice through the package-level `log`, an SDK logger built on
os.Stdout. That line went straight into kubectl's protocol stream. The
guard now goes up in executeWithKeyringOverride, where #59 already
established that startup work belongs, ahead of every Cobra hook.

Which commands get it is driven by a `grant.stdout: protocol` annotation
on the command rather than by matching a path, and the target is resolved
with cobra.Command.Find, which strips flags and executes nothing. A
resolution failure just means no guard, i.e. the previous behaviour.
reserveStdout now nests, so the RunE reservation returns the outer guard's
writer with a no-op Release and the outer owner restores stdout.

**Layer 1 was described as sufficient. It is not.** Writers that capture
os.Stdout at init escape a pointer swap — github.com/pkg/browser's package
var and grant's own `log` are both examples, and the second is exactly what
leaked above. Only the descriptor layer contains them, and that layer does
not exist on Windows. The comments and CLAUDE.md now say which writers each
layer covers and state plainly that init-time captured writers are
uncontained on Windows, instead of claiming every named writer is handled.

**Two cmd tests asserted POSIX modes without a Windows skip**, so they
failed rather than skipped on the windows-latest leg — the branch was not
green, contrary to the earlier report. TestExecCredentialCacheFilePermissions
skips outright; TestKubeconfigFilePermissionsAndBackup keeps its portable
backup assertions and guards only the mode check. internal/k8s already
handled its equivalents this way.

README claimed an unconditional 0600 guarantee for the kubeconfig and the
credential cache. That is POSIX-only, and grant inspects neither ACLs nor
ownership on Windows. README now says so, matching CLAUDE.md.
aaearon added a commit that referenced this pull request Aug 14, 2026
…verstating it

Codex review of ed738ad. Three defects, one of them real.

**The guard did not cover PersistentPreRunE.** It was installed inside
RunE, and Cobra runs the root pre-run first — which on --verbose emits the
WSL keyring notice through the package-level `log`, an SDK logger built on
os.Stdout. That line went straight into kubectl's protocol stream. The
guard now goes up in executeWithKeyringOverride, where #59 already
established that startup work belongs, ahead of every Cobra hook.

Which commands get it is driven by a `grant.stdout: protocol` annotation
on the command rather than by matching a path, and the target is resolved
with cobra.Command.Find, which strips flags and executes nothing. A
resolution failure just means no guard, i.e. the previous behaviour.
reserveStdout now nests, so the RunE reservation returns the outer guard's
writer with a no-op Release and the outer owner restores stdout.

**Layer 1 was described as sufficient. It is not.** Writers that capture
os.Stdout at init escape a pointer swap — github.com/pkg/browser's package
var and grant's own `log` are both examples, and the second is exactly what
leaked above. Only the descriptor layer contains them, and that layer does
not exist on Windows. The comments and CLAUDE.md now say which writers each
layer covers and state plainly that init-time captured writers are
uncontained on Windows, instead of claiming every named writer is handled.

**Two cmd tests asserted POSIX modes without a Windows skip**, so they
failed rather than skipped on the windows-latest leg — the branch was not
green, contrary to the earlier report. TestExecCredentialCacheFilePermissions
skips outright; TestKubeconfigFilePermissionsAndBackup keeps its portable
backup assertions and guards only the mode check. internal/k8s already
handled its equivalents this way.

README claimed an unconditional 0600 guarantee for the kubeconfig and the
credential cache. That is POSIX-only, and grant inspects neither ACLs nor
ownership on Windows. README now says so, matching CLAUDE.md.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant