Skip to content

fix(secrets): make file keyring filenames Windows-safe (#502)#527

Closed
solomonneas wants to merge 1 commit intoopenclaw:mainfrom
solomonneas:fix/windows-keyring-filenames
Closed

fix(secrets): make file keyring filenames Windows-safe (#502)#527
solomonneas wants to merge 1 commit intoopenclaw:mainfrom
solomonneas:fix/windows-keyring-filenames

Conversation

@solomonneas
Copy link
Copy Markdown
Contributor

Closes #502.

Problem

The 99designs/keyring file backend writes one file per item, escaping only / in filenames via percent.Encode. NTFS rejects :, <, >, ", |, ?, *, and \, so token keys like token:default:user@gmail.com cannot be persisted on Windows. Any auth-requiring command exits with:

store token: open C:\Users\<user>\AppData\Roaming\gogcli\keyring\token:default:<email>: The filename, directory name, or volume label syntax is incorrect.

Fix

Wrap the keyring with a new fileSafeKeyring shim that:

  • Percent-encodes Windows-illegal characters (:, <, >, ", |, ?, *, \, plus % for self-inverse encoding) in item keys before they reach the on-disk filename builder.
  • Prepends a _v1_ sentinel to encoded keys so Keys() can unambiguously distinguish wrapper-written entries from legacy raw-form files. Avoids corrupting any pre-existing item whose key happened to contain a literal %XX triplet.
  • Falls back to the raw key form on read so existing ~/.local/share/gogcli/keyring/ directories on macOS/Linux keep working without manual migration.
  • Best-effort removes the legacy raw file once the new encoded form is persisted, so Keys() does not return duplicates.
  • Translates the platform's "filename invalid" error (ERROR_INVALID_NAME on Windows, surfaced as a *fs.PathError wrapping syscall.Errno(0x7B)) to keyring.ErrKeyNotFound. Detection is build-tagged: filename_safe_windows.go for the real check, filename_safe_other.go returning false everywhere else. This is what lets the raw-form fallback safely run on every platform without leaking a confusing error from a path the underlying filesystem could never have stored.

The shim is applied unconditionally rather than only when the file backend is explicitly selected. That covers two cases at once:

  1. Auto-fallback to file backend99designs/keyring's opener tries native backends in priority order and silently lands on the file backend when they fail (e.g. Windows + WinCred unavailable, Linux + secret-service+kwallet unavailable). The wrapper now catches those too.
  2. Native backends with legacy raw keys — for example Windows WinCred, which stores opaque credential targets and has always accepted colon-bearing keys. The raw-key fallback finds those, so existing credentials survive the upgrade. (For non-file backends the encoder is a near no-op for keys without Windows-illegal characters.)

Tests

internal/secrets/filename_safe_test.go, _other_test.go, _windows_test.go add 25 tests:

Encoder unit tests

  • Round-trip across primary, legacy single-colon, scoped, all-illegal-chars, percent-bearing, plain, and empty inputs.
  • Fast path: keys with no encodable character pass through unchanged.
  • Sentinel reservation: keys legitimately starting with _v1_ are still encoded, preserving the round trip.
  • Backslash is handled.
  • decodeFilenameKey does not corrupt legacy %XX-bearing names (no _v1_ prefix → returned as-is).

End-to-end fixture against a real 99designs/keyring file backend in a temp dir

  • On-disk filenames contain no NTFS-illegal characters.
  • Keys() returns the original (decoded) key set with duplicates collapsed.
  • Get falls back to legacy raw-form files (Skipf-guarded for filesystems that reject the legacy filename, so the test stays portable).
  • Set migrates a legacy raw file to the encoded form.
  • Remove cleans both encoded and legacy forms.
  • Get, GetMetadata, Remove of missing keys return keyring.ErrKeyNotFound.

Cross-platform error-translation behaviour via a fakeInnerKeyring plus a swappable isInvalidFilenameError

  • Get / GetMetadata / Remove translate the simulated "invalid filename" error to keyring.ErrKeyNotFound.
  • Set and Remove survive the inner backend rejecting the raw-form cleanup attempt with that error.
  • acceptingFakeKeyring covers the WinCred upgrade scenario: a non-file backend that accepts colon-bearing keys still serves legacy items via the raw-key fallback.

Platform-specific defaults

  • filename_safe_windows_test.go (build-tagged windows) verifies defaultIsInvalidFilenameError matches a *fs.PathError wrapping syscall.Errno(0x7B) and rejects unrelated errnos.
  • filename_safe_other_test.go (build-tagged !windows) verifies the default returns false.

Verification

  • make fmt clean, make lint 0 issues, make test green across all packages including cmd/gog and internal/cmd.
  • -race clean on the 25 new tests. (The pre-existing race in TestOpenKeyringWithTimeout_Timeout is upstream/main behaviour, untouched here.)
  • Cross-compile: GOOS=windows GOARCH=amd64 and GOOS=darwin GOARCH=arm64 both build the package and tests cleanly.
  • Coverage: 100% on encodeFilenameKey / decodeFilenameKey / needsFilenameEncoding / isNotFound; 79.5% package overall.

Notes

Out of scope on purpose, called out in a comment on safeFilenameMap so a future contributor knows where to extend:

  • Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9).
  • Trailing spaces / dots.
  • ASCII control characters.

The current gogcli key namespace (token:<client>:<email> and default_account[:<client>]) cannot produce any of those forms.

Distinct from #395 (CI test skips on Windows). This is the runtime crash path for real user machines whether they explicitly set keyring_backend: file or auto-fall through to it.

The 99designs/keyring file backend writes one file per item, escaping only
'/' in filenames via percent.Encode. NTFS rejects ':', '<', '>', '"', '|',
'?', '*', and '\', so token keys like 'token:default:user@gmail.com' cannot
be persisted on Windows: any auth-requiring command exits with
'open ...token:default:<email>: The filename, directory name, or volume
label syntax is incorrect.'

Wrap the keyring with fileSafeKeyring, which percent-encodes Windows-illegal
characters in item keys before they reach the on-disk filename builder and
prepends a '_v1_' sentinel so wrapper-written entries are unambiguously
distinguishable from legacy raw-form files. Reads fall back to the raw key
form so existing macOS/Linux keyring directories keep working without
manual migration; writes opportunistically remove the legacy file once the
new encoded form lands.

The shim is applied unconditionally rather than only when the file backend
is explicitly selected, so it also covers the auto-fallback case where
99designs/keyring's opener silently lands on the file backend (e.g.
Windows + WinCred broken, or Linux + secret-service+kwallet broken). For
non-file backends the shim is effectively a no-op for keys without
Windows-illegal characters and transparently migrates legacy items on
first read/write.

Fixes openclaw#502.
@steipete
Copy link
Copy Markdown
Collaborator

Thanks @solomonneas. I landed a narrower maintainer version on main as 8135cc3.

What changed from the PR version:

  • only the file backend gets portable encoded filenames; native keychain/WinCred-style backends keep their existing key names
  • legacy raw file-backend entries are still read and removed
  • added focused regression coverage plus README/spec/changelog notes

Closing this as landed.

@steipete steipete closed this Apr 27, 2026
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.

File keyring backend unusable on Windows: : in key name rejected by NTFS

2 participants