diff --git a/README.md b/README.md index 0667355..cc82733 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ showing 1/37 with the enter, e, r, and ? key hints](docs/timeline.png) ## get it -works on macos and linux. windows isn't supported, open an issue if you -want it. +works on macos and linux, including wsl with a windows firefox session. +native windows isn't supported yet. **with nix**: @@ -98,6 +98,22 @@ browser without starting over. scripting it? `xeet auth --browser firefox` skips the picker entirely. +**using wsl?** xeet can read the x.com session from firefox running on the +windows side: + +```bash +xeet auth --browser firefox +``` + +windows command interoperability must be enabled, with `cmd.exe`, +`powershell.exe`, and `wslpath` available on `PATH`. xeet asks windows for the +current user's firefox profile instead of assuming windows is mounted at +`/mnt/c`. its copy of the two session values is encrypted with windows dpapi +for that windows user; plaintext values are never written to the linux +filesystem. windows chrome, brave, helium, zen, and edge profiles are not +supported from wsl. browsers installed inside the linux distribution continue +to use their normal linux profile locations. + then just: ```bash @@ -236,8 +252,9 @@ xeet reuses the x.com session already in your browser and speaks the same unsupported internal graphql endpoints the website does. the imported `auth_token` and `ct0` cookies grant account-level access, so treat them like a password. they live in the macos keychain or linux secret service, never -in the yaml config file. `xeet logout` deletes xeet's copy (your browser -stays logged in). +in the yaml config file. on wsl, windows dpapi encrypts xeet's copy before the +ciphertext reaches the linux filesystem. `xeet logout` deletes xeet's copy +(your browser stays logged in).
details: query ids and retries diff --git a/SECURITY.md b/SECURITY.md index 16188ab..663213b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,9 +22,11 @@ output from an account you care about; redact first. ## Scope notes -- Cookies are stored in the macOS Keychain or Linux Secret Service, never in - the YAML config file. Anything that causes them to be written to disk, - logs, or terminal output is a vulnerability. +- Cookies are stored in the macOS Keychain or Linux Secret Service. Under WSL, + Windows DPAPI encrypts each value for the current Windows user before the + ciphertext is written to the Linux filesystem. Plaintext cookies never + belong in the YAML config file, another file, process arguments, environment + variables, logs, or terminal output. - Xeet talks only to X-operated hosts (`x.com`, `upload.twitter.com`, `*.twimg.com`, `t.co`). Any request to another host, especially one carrying cookies, would be a vulnerability. diff --git a/cmd/auth.go b/cmd/auth.go index 8fca2ce..4c8957b 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -133,7 +133,7 @@ func verifyAndSave(ctx context.Context, result *api.LoginResult, browser string) // runAuthPlain is the scripted path: no picker, no spinner, one line per step. func runAuthPlain(ctx context.Context, out io.Writer, browser string) error { - fmt.Fprintf(out, "Reading your session from %s (your OS may ask to unlock its keyring)...\n", browser) + fmt.Fprintf(out, "Reading your session from %s (your OS may ask to unlock secure storage)...\n", browser) result, resolved, err := api.ImportBrowserSession(browser) if err != nil { return err diff --git a/cmd/auth_ui.go b/cmd/auth_ui.go index 0466bd7..be8802a 100644 --- a/cmd/auth_ui.go +++ b/cmd/auth_ui.go @@ -230,7 +230,7 @@ func (p authPicker) View() string { switch p.phase { case authPhaseImport: return head + p.renderWorking(w, "reading your x.com session from "+p.chosen+"…", - "your OS may ask to unlock its keyring") + "your OS may ask to unlock secure storage") case authPhaseVerify: return head + p.renderWorking(w, "asking X to verify the session…", "") case authPhaseDone: diff --git a/cmd/auth_ui_test.go b/cmd/auth_ui_test.go index a2a6955..39bf362 100644 --- a/cmd/auth_ui_test.go +++ b/cmd/auth_ui_test.go @@ -81,8 +81,8 @@ func TestAuthPickerEnterStartsTheImport(t *testing.T) { if p.chosen != p.browsers[0] { t.Fatalf("chose %q, want %q", p.chosen, p.browsers[0]) } - if !strings.Contains(p.View(), "unlock its keyring") { - t.Error("the import step should warn about the keyring prompt") + if !strings.Contains(p.View(), "unlock secure storage") { + t.Error("the import step should warn about the secure-storage prompt") } } diff --git a/cmd/logout.go b/cmd/logout.go index 8857649..6aa751d 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -11,7 +11,7 @@ import ( var logoutCmd = &cobra.Command{ Use: "logout", Short: "disconnect and erase the saved session", - Long: `Removes the x.com session tokens from your OS keyring and deletes xeet's + Long: `Removes the x.com session tokens from secure storage and deletes xeet's config file. Your browser session is untouched; run 'xeet auth' to reconnect.`, RunE: func(cmd *cobra.Command, args []string) error { configMgr, err := config.NewConfigManager() @@ -21,7 +21,7 @@ config file. Your browser session is untouched; run 'xeet auth' to reconnect.`, if err := configMgr.Erase(); err != nil { return fmt.Errorf("logout incomplete: %w", err) } - fmt.Println("✓ Logged out. Session erased from keyring and config removed.") + fmt.Println("✓ Logged out. Session erased from secure storage and config removed.") return nil }, } diff --git a/internal/wsl/wsl.go b/internal/wsl/wsl.go new file mode 100644 index 0000000..ea38087 --- /dev/null +++ b/internal/wsl/wsl.go @@ -0,0 +1,215 @@ +package wsl + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" + "unicode/utf16" +) + +var ErrUnavailable = errors.New("windows interoperability is unavailable") + +type commandRunner interface { + Run(ctx context.Context, name string, args []string, stdin []byte) ([]byte, error) +} + +type execRunner struct{} + +func (execRunner) Run(ctx context.Context, name string, args []string, stdin []byte) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdin = bytes.NewReader(stdin) + var stdout bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = io.Discard + if err := cmd.Run(); err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + // Do not return an ExitError containing captured process output. The + // DPAPI process handles account-level session material. + return nil, errors.New("windows command failed") + } + return stdout.Bytes(), nil +} + +type bridge struct { + goos string + lookupEnv func(string) (string, bool) + stat func(string) (os.FileInfo, error) + lookPath func(string) (string, error) + runner commandRunner +} + +var systemBridge = bridge{ + goos: runtime.GOOS, + lookupEnv: os.LookupEnv, + stat: os.Stat, + lookPath: exec.LookPath, + runner: execRunner{}, +} + +// Active reports whether the current Linux process is running under WSL. +func Active() bool { + return systemBridge.active() +} + +func (b bridge) active() bool { + if b.goos != "linux" { + return false + } + if _, ok := b.lookupEnv("WSL_DISTRO_NAME"); ok { + return true + } + _, err := b.stat("/proc/sys/fs/binfmt_misc/WSLInterop") + return err == nil +} + +// AppData resolves the current Windows user's roaming AppData directory and +// converts it to a path that the WSL process can open. +func AppData(ctx context.Context) (string, error) { + return systemBridge.appData(ctx) +} + +func (b bridge) appData(ctx context.Context) (string, error) { + if !b.active() { + return "", ErrUnavailable + } + cmdPath, err := b.lookPath("cmd.exe") + if err != nil { + return "", fmt.Errorf("%w: cmd.exe is not on PATH", ErrUnavailable) + } + out, err := b.runner.Run(ctx, cmdPath, []string{"/d", "/c", "echo %APPDATA%"}, nil) + if err != nil { + return "", fmt.Errorf("%w: could not query %%APPDATA%%", ErrUnavailable) + } + windowsPath := strings.TrimSpace(strings.ReplaceAll(string(out), "\r", "")) + if windowsPath == "" || strings.Contains(windowsPath, "%APPDATA%") { + return "", fmt.Errorf("%w: Windows did not return %%APPDATA%%", ErrUnavailable) + } + + wslpath, err := b.lookPath("wslpath") + if err != nil { + return "", fmt.Errorf("%w: wslpath is not on PATH", ErrUnavailable) + } + out, err = b.runner.Run(ctx, wslpath, []string{"-u", windowsPath}, nil) + if err != nil { + return "", fmt.Errorf("%w: could not translate %%APPDATA%%", ErrUnavailable) + } + path := strings.TrimSpace(strings.ReplaceAll(string(out), "\r", "")) + if path == "" || !strings.HasPrefix(path, "/") { + return "", fmt.Errorf("%w: wslpath returned an invalid path", ErrUnavailable) + } + return path, nil +} + +type dpapiRequest struct { + Operation string `json:"operation"` + Key string `json:"key"` + Payload string `json:"payload"` +} + +// Protect encrypts plaintext with Windows DPAPI in the current Windows user's +// security context. The plaintext is sent only over the child process's stdin. +func Protect(ctx context.Context, key string, plaintext []byte) ([]byte, error) { + return systemBridge.dpapi(ctx, "protect", key, plaintext) +} + +// Unprotect decrypts a DPAPI ciphertext in the current Windows user's security +// context. The returned plaintext exists only in process memory. +func Unprotect(ctx context.Context, key string, ciphertext []byte) ([]byte, error) { + return systemBridge.dpapi(ctx, "unprotect", key, ciphertext) +} + +func (b bridge) dpapi(ctx context.Context, operation, key string, payload []byte) ([]byte, error) { + if !b.active() { + return nil, ErrUnavailable + } + if operation != "protect" && operation != "unprotect" { + return nil, errors.New("invalid DPAPI operation") + } + if !validKey(key) { + return nil, errors.New("invalid DPAPI key name") + } + powershell, err := b.lookPath("powershell.exe") + if err != nil { + return nil, fmt.Errorf("%w: powershell.exe is not on PATH", ErrUnavailable) + } + request, err := json.Marshal(dpapiRequest{ + Operation: operation, + Key: key, + Payload: base64.StdEncoding.EncodeToString(payload), + }) + if err != nil { + return nil, errors.New("could not prepare DPAPI request") + } + out, err := b.runner.Run(ctx, powershell, []string{ + "-NoLogo", "-NoProfile", "-NonInteractive", + "-EncodedCommand", encodedDPAPIScript, + }, request) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return nil, err + } + return nil, errors.New("windows DPAPI operation failed") + } + result, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(out))) + if err != nil || len(result) == 0 { + return nil, errors.New("windows DPAPI returned an invalid result") + } + return result, nil +} + +func validKey(key string) bool { + if key == "" || len(key) > 64 { + return false + } + for _, r := range key { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' { + return false + } + } + return true +} + +func encodePowerShell(script string) string { + encoded := utf16.Encode([]rune(script)) + raw := make([]byte, len(encoded)*2) + for i, value := range encoded { + raw[i*2] = byte(value) + raw[i*2+1] = byte(value >> 8) + } + return base64.StdEncoding.EncodeToString(raw) +} + +var encodedDPAPIScript = encodePowerShell(` +$ErrorActionPreference = 'Stop' +try { + $request = [Console]::In.ReadToEnd() | ConvertFrom-Json + $payload = [Convert]::FromBase64String([string]$request.payload) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $entropy = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes("xeet" + [char]0 + [string]$request.key)) + } finally { + $sha.Dispose() + } + $scope = [System.Security.Cryptography.DataProtectionScope]::CurrentUser + if ([string]$request.operation -eq 'protect') { + $result = [System.Security.Cryptography.ProtectedData]::Protect($payload, $entropy, $scope) + } elseif ([string]$request.operation -eq 'unprotect') { + $result = [System.Security.Cryptography.ProtectedData]::Unprotect($payload, $entropy, $scope) + } else { + throw 'invalid operation' + } + [Console]::Out.Write([Convert]::ToBase64String($result)) +} catch { + exit 1 +} +`) diff --git a/internal/wsl/wsl_test.go b/internal/wsl/wsl_test.go new file mode 100644 index 0000000..0b9b740 --- /dev/null +++ b/internal/wsl/wsl_test.go @@ -0,0 +1,166 @@ +package wsl + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "os" + "slices" + "strings" + "testing" +) + +type runCall struct { + name string + args []string + stdin []byte +} + +type fakeRunner struct { + calls []runCall + run func(runCall) ([]byte, error) +} + +func (f *fakeRunner) Run(_ context.Context, name string, args []string, stdin []byte) ([]byte, error) { + call := runCall{name: name, args: slices.Clone(args), stdin: slices.Clone(stdin)} + f.calls = append(f.calls, call) + return f.run(call) +} + +func activeBridge(runner commandRunner) bridge { + return bridge{ + goos: "linux", + lookupEnv: func(key string) (string, bool) { return "Ubuntu", key == "WSL_DISTRO_NAME" }, + stat: func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }, + lookPath: func(name string) (string, error) { + return "/interop/" + name, nil + }, + runner: runner, + } +} + +func TestActive(t *testing.T) { + tests := []struct { + name string + goos string + env bool + stat error + want bool + }{ + {"macOS ignores WSL variables", "darwin", true, nil, false}, + {"environment", "linux", true, os.ErrNotExist, true}, + {"interop marker", "linux", false, nil, true}, + {"ordinary Linux", "linux", false, os.ErrNotExist, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := bridge{ + goos: tt.goos, + lookupEnv: func(string) (string, bool) { + return "", tt.env + }, + stat: func(string) (os.FileInfo, error) { + return nil, tt.stat + }, + } + if got := b.active(); got != tt.want { + t.Fatalf("active() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAppDataUsesWindowsAndWSLPathTools(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(call runCall) ([]byte, error) { + switch call.name { + case "/interop/cmd.exe": + return []byte("C:\\Users\\Zoë Lovelace\\AppData\\Roaming\r\n"), nil + case "/interop/wslpath": + if !slices.Equal(call.args, []string{"-u", `C:\Users\Zoë Lovelace\AppData\Roaming`}) { + t.Fatalf("wslpath args = %#v", call.args) + } + return []byte("/windows/Users/Zoë Lovelace/AppData/Roaming\n"), nil + default: + t.Fatalf("unexpected command %q", call.name) + return nil, nil + } + } + got, err := activeBridge(runner).appData(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != "/windows/Users/Zoë Lovelace/AppData/Roaming" { + t.Fatalf("AppData = %q", got) + } +} + +func TestAppDataFailsClosed(t *testing.T) { + runner := &fakeRunner{run: func(runCall) ([]byte, error) { + return []byte("%APPDATA%\r\n"), nil + }} + _, err := activeBridge(runner).appData(context.Background()) + if !errors.Is(err, ErrUnavailable) { + t.Fatalf("error = %v, want ErrUnavailable", err) + } +} + +func TestDPAPISecretOnlyTravelsOnStdin(t *testing.T) { + const secret = "session-material-must-not-leak" + runner := &fakeRunner{} + runner.run = func(call runCall) ([]byte, error) { + for _, arg := range call.args { + if strings.Contains(arg, secret) { + t.Fatalf("secret leaked into argv: %#v", call.args) + } + } + var request dpapiRequest + if err := json.Unmarshal(call.stdin, &request); err != nil { + t.Fatal(err) + } + decoded, err := base64.StdEncoding.DecodeString(request.Payload) + if err != nil { + t.Fatal(err) + } + if string(decoded) != secret || request.Operation != "protect" || request.Key != "auth_token" { + t.Fatalf("request = %+v, payload %q", request, decoded) + } + return []byte(base64.StdEncoding.EncodeToString([]byte("ciphertext"))), nil + } + + got, err := activeBridge(runner).dpapi(context.Background(), "protect", "auth_token", []byte(secret)) + if err != nil { + t.Fatal(err) + } + if string(got) != "ciphertext" { + t.Fatalf("result = %q", got) + } +} + +func TestDPAPIErrorsDoNotExposeProcessMaterial(t *testing.T) { + const secret = "private-cookie" + runner := &fakeRunner{run: func(runCall) ([]byte, error) { + return []byte(secret), errors.New(secret) + }} + _, err := activeBridge(runner).dpapi(context.Background(), "protect", "ct0", []byte(secret)) + if err == nil { + t.Fatal("expected error") + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("error leaked secret: %v", err) + } +} + +func TestDPAPIRejectsInvalidOutputAndKeys(t *testing.T) { + runner := &fakeRunner{run: func(runCall) ([]byte, error) { + return []byte("not base64"), nil + }} + b := activeBridge(runner) + if _, err := b.dpapi(context.Background(), "protect", "../auth", []byte("x")); err == nil { + t.Fatal("invalid key accepted") + } + if _, err := b.dpapi(context.Background(), "protect", "auth_token", []byte("x")); err == nil { + t.Fatal("invalid PowerShell output accepted") + } +} diff --git a/pkg/api/cookies_darwin.go b/pkg/api/cookies_darwin.go index e7bbcd6..2e84be7 100644 --- a/pkg/api/cookies_darwin.go +++ b/pkg/api/cookies_darwin.go @@ -51,8 +51,8 @@ func chromiumBrowsers(home string) []chromiumBrowser { func geckoBrowsers(home string) []geckoBrowser { appSup := filepath.Join(home, "Library", "Application Support") return []geckoBrowser{ - {"Firefox", []string{filepath.Join(appSup, "Firefox", "Profiles")}}, - {"Zen", []string{filepath.Join(appSup, "zen", "Profiles")}}, + {name: "Firefox", roots: []string{filepath.Join(appSup, "Firefox", "Profiles")}}, + {name: "Zen", roots: []string{filepath.Join(appSup, "zen", "Profiles")}}, } } diff --git a/pkg/api/cookies_gecko.go b/pkg/api/cookies_gecko.go index 210e822..62b3fdb 100644 --- a/pkg/api/cookies_gecko.go +++ b/pkg/api/cookies_gecko.go @@ -17,8 +17,10 @@ import ( // geckoBrowser describes Firefox and Firefox-derived browsers. Their cookies // are stored in plain SQLite files, so no browser keyring access is needed. type geckoBrowser struct { - name string - roots []string + name string + roots []string + windowsRoot string + reader func(string) ([]*kooky.Cookie, error) } func (b geckoBrowser) cookieDBs() []string { @@ -81,13 +83,18 @@ func importGeckoSession(b geckoBrowser) (*LoginResult, string, error) { var lastErr error var best *LoginResult + bestBrowser := b.name + reader := b.reader + if reader == nil { + reader = readGeckoXCookies + } for _, db := range dbs { tmp, copied, err := copyDBAs(db, "cookies.sqlite") if err != nil { lastErr = err continue } - cookies, readErr := readGeckoXCookies(copied) + cookies, readErr := reader(copied) os.RemoveAll(tmp) if readErr != nil { lastErr = readErr @@ -102,11 +109,15 @@ func importGeckoSession(b geckoBrowser) (*LoginResult, string, error) { } if betterLoginResult(result, best) { best = result + bestBrowser = b.name + if withinRoot(db, b.windowsRoot) { + bestBrowser += " (Windows via WSL)" + } } } } if best != nil { - return best, b.name, nil + return best, bestBrowser, nil } if lastErr != nil { @@ -115,6 +126,14 @@ func importGeckoSession(b geckoBrowser) (*LoginResult, string, error) { return nil, "", fmt.Errorf("no logged-in x.com session found in %s; open x.com in it, log in, then try again", b.name) } +func withinRoot(path, root string) bool { + if root == "" { + return false + } + rel, err := filepath.Rel(root, path) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + func sessionFromCookies(cookies []*kooky.Cookie) *LoginResult { now := time.Now() values := make([]sessionCookieValue, 0, len(cookies)) diff --git a/pkg/api/cookies_linux.go b/pkg/api/cookies_linux.go index a7f510e..31a910e 100644 --- a/pkg/api/cookies_linux.go +++ b/pkg/api/cookies_linux.go @@ -10,11 +10,13 @@ import ( "path/filepath" "sort" "strings" + "time" "github.com/browserutils/kooky" "github.com/browserutils/kooky/browser/brave" "github.com/browserutils/kooky/browser/chrome" "github.com/browserutils/kooky/browser/chromium" + "github.com/melqtx/xeet/internal/wsl" ) type chromiumCookieReader func(context.Context, string, ...kooky.Filter) ([]*kooky.Cookie, error) @@ -44,14 +46,36 @@ func chromiumBrowsers(home string) []chromiumBrowser { } func geckoBrowsers(home string) []geckoBrowser { + browsers, _ := geckoBrowsersForEnvironment(home) + return browsers +} + +func geckoBrowsersForEnvironment(home string) ([]geckoBrowser, error) { + var windowsAppData string + var interopErr error + if wsl.Active() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + windowsAppData, interopErr = wsl.AppData(ctx) + } + return geckoBrowsersAt(home, windowsAppData), interopErr +} + +func geckoBrowsersAt(home, windowsAppData string) []geckoBrowser { flatpak := filepath.Join(home, ".var", "app") + firefoxRoots := []string{ + filepath.Join(home, ".mozilla", "firefox"), + filepath.Join(home, "snap", "firefox", "common", ".mozilla", "firefox"), + filepath.Join(flatpak, "org.mozilla.firefox", ".mozilla", "firefox"), + } + var windowsFirefoxRoot string + if windowsAppData != "" { + windowsFirefoxRoot = filepath.Join(windowsAppData, "Mozilla", "Firefox") + firefoxRoots = append(firefoxRoots, windowsFirefoxRoot) + } return []geckoBrowser{ - {"Firefox", []string{ - filepath.Join(home, ".mozilla", "firefox"), - filepath.Join(home, "snap", "firefox", "common", ".mozilla", "firefox"), - filepath.Join(flatpak, "org.mozilla.firefox", ".mozilla", "firefox"), - }}, - {"Zen", []string{ + {name: "Firefox", roots: firefoxRoots, windowsRoot: windowsFirefoxRoot}, + {name: "Zen", roots: []string{ filepath.Join(home, ".zen"), filepath.Join(flatpak, "app.zen_browser.zen", ".zen"), filepath.Join(flatpak, "io.github.zen_browser.zen", ".zen"), @@ -87,8 +111,13 @@ func ImportBrowserSession(name string) (*LoginResult, string, error) { if err != nil { return nil, "", err } - if browser, ok := findGeckoBrowser(name, geckoBrowsers(home)); ok { - return importGeckoSession(browser) + gecko, interopErr := geckoBrowsersForEnvironment(home) + if browser, ok := findGeckoBrowser(name, gecko); ok { + result, resolved, err := importGeckoSession(browser) + if err != nil && interopErr != nil { + return nil, "", fmt.Errorf("%w; WSL detected but Windows Firefox could not be checked: %v", err, interopErr) + } + return result, resolved, err } if browser, ok := findChromiumBrowser(name, chromiumBrowsers(home)); ok { return importChromiumSession(browser) diff --git a/pkg/api/cookies_linux_test.go b/pkg/api/cookies_linux_test.go index e4bdfa0..b99bbff 100644 --- a/pkg/api/cookies_linux_test.go +++ b/pkg/api/cookies_linux_test.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "slices" "strings" "testing" @@ -88,3 +89,55 @@ func TestLinuxLockedKeyringErrorIsActionable(t *testing.T) { t.Fatalf("error = %v, want unlock guidance", err) } } + +func TestWSLAddsOnlyWindowsFirefoxRoot(t *testing.T) { + home := t.TempDir() + appData := filepath.Join(t.TempDir(), "Users", "Ada Lovelace", "AppData", "Roaming") + browsers := geckoBrowsersAt(home, appData) + + firefox, ok := findGeckoBrowser("Firefox", browsers) + if !ok { + t.Fatal("Firefox missing") + } + want := filepath.Join(appData, "Mozilla", "Firefox") + if firefox.windowsRoot != want || !slices.Contains(firefox.roots, want) { + t.Fatalf("Firefox roots = %#v, windows root = %q", firefox.roots, firefox.windowsRoot) + } + + zen, ok := findGeckoBrowser("Zen", browsers) + if !ok { + t.Fatal("Zen missing") + } + if zen.windowsRoot != "" || slices.Contains(zen.roots, filepath.Join(appData, "Zen")) { + t.Fatalf("Windows roots leaked into Zen: %+v", zen) + } +} + +func TestWSLFirefoxImportRecordsWindowsSource(t *testing.T) { + windowsRoot := filepath.Join(t.TempDir(), "Mozilla", "Firefox") + profile := filepath.Join(windowsRoot, "Profiles", "default-release") + if err := os.MkdirAll(profile, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(profile, "cookies.sqlite"), []byte("db"), 0600); err != nil { + t.Fatal(err) + } + browser := geckoBrowser{ + name: "Firefox", + roots: []string{windowsRoot}, + windowsRoot: windowsRoot, + reader: func(string) ([]*kooky.Cookie, error) { + return []*kooky.Cookie{ + {Cookie: http.Cookie{Name: "auth_token", Value: "token", Domain: ".x.com"}}, + {Cookie: http.Cookie{Name: "ct0", Value: "csrf", Domain: ".x.com"}}, + }, nil + }, + } + result, name, err := importGeckoSession(browser) + if err != nil { + t.Fatal(err) + } + if result.Profile != "default-release" || name != "Firefox (Windows via WSL)" { + t.Fatalf("result = %+v, browser = %q", result, name) + } +} diff --git a/pkg/config/atomic_file.go b/pkg/config/atomic_file.go new file mode 100644 index 0000000..c09a83c --- /dev/null +++ b/pkg/config/atomic_file.go @@ -0,0 +1,50 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" +) + +// atomicWriteFile writes one private file using a same-directory temporary +// file, fsync, rename, and directory fsync. It refuses to replace a symlink. +func atomicWriteFile(path string, data []byte) error { + if info, err := os.Lstat(path); err == nil { + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%s is a symlink; refusing to replace it", path) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", path) + } + } else if !os.IsNotExist(err) { + return err + } + + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".xeet-*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if err := tmp.Chmod(0600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + return err + } + return syncDirectory(dir) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 12c1836..d7e032c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -10,16 +10,18 @@ import ( "path/filepath" "time" + "github.com/melqtx/xeet/internal/wsl" "github.com/zalando/go-keyring" "gopkg.in/yaml.v3" ) // Config is the whole of xeet's saved state: your x.com browser session plus // cached, non-secret operation ids and session metadata. AuthToken is the -// session cookie and CT0 is the matching CSRF token; both live in the OS keyring (macOS Keychain, Linux -// Secret Service), never on disk. The config file contains only non-secret -// state: X's rotating GraphQL query ids and browser-session source metadata used -// by `xeet doctor`. +// session cookie and CT0 is the matching CSRF token; both live in secure +// platform storage (macOS Keychain, Linux Secret Service, or Windows DPAPI +// while running under WSL), never in the YAML config. The config file contains +// only non-secret state: X's rotating GraphQL query ids and browser-session +// source metadata used by `xeet doctor`. type Config struct { AuthToken string `yaml:"-"` CT0 string `yaml:"-"` @@ -125,7 +127,14 @@ func NewConfigManager() (*ConfigManager, error) { if err != nil { return nil, err } - return newConfigManagerAt(homeDir, systemKeyring{}), nil + return newConfigManagerAt(homeDir, secretStoreFor(homeDir, wsl.Active())), nil +} + +func secretStoreFor(homeDir string, isWSL bool) SecretStore { + if isWSL { + return newWSLSecretStore(homeDir) + } + return systemKeyring{} } func newConfigManagerAt(dir string, secrets SecretStore) *ConfigManager { @@ -382,33 +391,7 @@ func (cm *ConfigManager) writeFile(fc *fileConfig) error { return err } - dir := filepath.Dir(cm.configPath) - tmp, err := os.CreateTemp(dir, ".xeet-*.tmp") - if err != nil { - return err - } - tmpPath := tmp.Name() - defer os.Remove(tmpPath) // no-op after successful rename - - if err := tmp.Chmod(0600); err != nil { - tmp.Close() - return err - } - if _, err := tmp.Write(data); err != nil { - tmp.Close() - return err - } - if err := tmp.Sync(); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := os.Rename(tmpPath, cm.configPath); err != nil { - return err - } - return syncDirectory(dir) + return atomicWriteFile(cm.configPath, data) } // legacyDecrypt undoes the old AES-GCM-with-key-on-disk scheme, used only to diff --git a/pkg/config/secrets_wsl.go b/pkg/config/secrets_wsl.go new file mode 100644 index 0000000..b38a87f --- /dev/null +++ b/pkg/config/secrets_wsl.go @@ -0,0 +1,137 @@ +package config + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/melqtx/xeet/internal/wsl" +) + +const wslSecretTimeout = 10 * time.Second + +type dataProtector interface { + Protect(context.Context, string, []byte) ([]byte, error) + Unprotect(context.Context, string, []byte) ([]byte, error) +} + +type windowsDPAPI struct{} + +func (windowsDPAPI) Protect(ctx context.Context, key string, plaintext []byte) ([]byte, error) { + return wsl.Protect(ctx, key, plaintext) +} + +func (windowsDPAPI) Unprotect(ctx context.Context, key string, ciphertext []byte) ([]byte, error) { + return wsl.Unprotect(ctx, key, ciphertext) +} + +// wslSecretStore keeps only Windows DPAPI ciphertext in the WSL filesystem. +// Plaintext crosses the Windows boundary over stdin/stdout and exists only in +// process memory. +type wslSecretStore struct { + dir string + protector dataProtector + timeout time.Duration +} + +func newWSLSecretStore(home string) *wslSecretStore { + return &wslSecretStore{ + dir: filepath.Join(home, ".local", "share", "xeet", "keyring"), + protector: windowsDPAPI{}, + timeout: wslSecretTimeout, + } +} + +func (s *wslSecretStore) Get(key string) (string, error) { + path, err := s.path(key) + if err != nil { + return "", err + } + ciphertext, err := secureReadFile(path) + if os.IsNotExist(err) { + return "", ErrSecretNotFound + } + if err != nil { + return "", fmt.Errorf("reading %s from Windows-backed secure storage: %w", key, err) + } + ctx, cancel := context.WithTimeout(context.Background(), s.operationTimeout()) + defer cancel() + plaintext, err := s.protector.Unprotect(ctx, key, ciphertext) + if err != nil { + return "", fmt.Errorf("decrypting %s with Windows secure storage: %w", key, err) + } + if len(plaintext) == 0 { + return "", ErrSecretNotFound + } + return string(plaintext), nil +} + +func (s *wslSecretStore) Set(key, value string) error { + path, err := s.path(key) + if err != nil { + return err + } + if value == "" { + return errors.New("refusing to store an empty secret") + } + if err := ensurePrivateDir(s.dir); err != nil { + return fmt.Errorf("preparing Windows-backed secure storage: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), s.operationTimeout()) + defer cancel() + ciphertext, err := s.protector.Protect(ctx, key, []byte(value)) + if err != nil { + return fmt.Errorf("encrypting %s with Windows secure storage: %w", key, err) + } + if len(ciphertext) == 0 { + return errors.New("windows secure storage returned empty ciphertext") + } + if err := atomicWriteFile(path, ciphertext); err != nil { + return fmt.Errorf("saving %s to Windows-backed secure storage: %w", key, err) + } + return nil +} + +func (s *wslSecretStore) Delete(key string) error { + path, err := s.path(key) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("deleting %s from Windows-backed secure storage: %w", key, err) + } + return nil +} + +func (s *wslSecretStore) path(key string) (string, error) { + switch key { + case keyAuthToken, keyCT0, keyLegacySessionCookies: + return filepath.Join(s.dir, key+".dpapi"), nil + default: + return "", fmt.Errorf("invalid secret key %q", key) + } +} + +func (s *wslSecretStore) operationTimeout() time.Duration { + if s.timeout > 0 { + return s.timeout + } + return wslSecretTimeout +} + +func ensurePrivateDir(path string) error { + if err := os.MkdirAll(path, 0700); err != nil { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("%s is not a private directory", path) + } + return os.Chmod(path, 0700) +} diff --git a/pkg/config/secrets_wsl_test.go b/pkg/config/secrets_wsl_test.go new file mode 100644 index 0000000..0a41c7a --- /dev/null +++ b/pkg/config/secrets_wsl_test.go @@ -0,0 +1,188 @@ +package config + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +type xorProtector struct { + protected [][]byte +} + +func (p *xorProtector) Protect(_ context.Context, _ string, plaintext []byte) ([]byte, error) { + p.protected = append(p.protected, bytes.Clone(plaintext)) + return xorBytes(plaintext), nil +} + +func (p *xorProtector) Unprotect(_ context.Context, _ string, ciphertext []byte) ([]byte, error) { + return xorBytes(ciphertext), nil +} + +func xorBytes(value []byte) []byte { + out := bytes.Clone(value) + for i := range out { + out[i] ^= 0xa5 + } + return out +} + +func testWSLStore(t *testing.T, protector dataProtector) *wslSecretStore { + t.Helper() + return &wslSecretStore{ + dir: filepath.Join(t.TempDir(), "keyring"), + protector: protector, + timeout: time.Second, + } +} + +func TestWSLSecretStoreRoundTripLeavesOnlyCiphertext(t *testing.T) { + protector := &xorProtector{} + store := testWSLStore(t, protector) + const secret = "account-level-session-material" + + if err := store.Set(keyAuthToken, secret); err != nil { + t.Fatal(err) + } + path, _ := store.path(keyAuthToken) + onDisk, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(onDisk, []byte(secret)) { + t.Fatal("plaintext secret reached disk") + } + if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("secret file info = %v, err = %v", info, err) + } + if info, err := os.Stat(store.dir); err != nil || info.Mode().Perm() != 0700 { + t.Fatalf("secret directory info = %v, err = %v", info, err) + } + + got, err := store.Get(keyAuthToken) + if err != nil { + t.Fatal(err) + } + if got != secret { + t.Fatalf("Get = %q", got) + } + if len(protector.protected) != 1 || string(protector.protected[0]) != secret { + t.Fatalf("protector inputs = %#v", protector.protected) + } +} + +func TestWSLSecretStoreMissingAndDelete(t *testing.T) { + store := testWSLStore(t, &xorProtector{}) + if _, err := store.Get(keyCT0); !errors.Is(err, ErrSecretNotFound) { + t.Fatalf("Get missing = %v", err) + } + if err := store.Set(keyCT0, "csrf"); err != nil { + t.Fatal(err) + } + if err := store.Delete(keyCT0); err != nil { + t.Fatal(err) + } + if err := store.Delete(keyCT0); err != nil { + t.Fatalf("second Delete = %v", err) + } + if _, err := store.Get(keyCT0); !errors.Is(err, ErrSecretNotFound) { + t.Fatalf("Get after Delete = %v", err) + } +} + +func TestWSLConfigEraseRemovesEveryCiphertext(t *testing.T) { + store := testWSLStore(t, &xorProtector{}) + manager := newConfigManagerAt(t.TempDir(), store) + if err := manager.Save(&Config{AuthToken: "auth", CT0: "csrf"}); err != nil { + t.Fatal(err) + } + if err := manager.Erase(); err != nil { + t.Fatal(err) + } + for _, key := range []string{keyAuthToken, keyCT0, keyLegacySessionCookies} { + path, _ := store.path(key) + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("%s remains after Erase: %v", key, err) + } + } +} + +func TestWSLSecretStoreRefusesSymlink(t *testing.T) { + store := testWSLStore(t, &xorProtector{}) + if err := ensurePrivateDir(store.dir); err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "target") + if err := os.WriteFile(target, []byte("safe"), 0600); err != nil { + t.Fatal(err) + } + path, _ := store.path(keyAuthToken) + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + if err := store.Set(keyAuthToken, "secret"); err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("Set through symlink = %v", err) + } + data, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(data) != "safe" { + t.Fatalf("symlink target changed to %q", data) + } +} + +func TestWSLSecretStoreRefusesSymlinkDirectory(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "target") + if err := os.Mkdir(target, 0700); err != nil { + t.Fatal(err) + } + store := &wslSecretStore{ + dir: filepath.Join(base, "keyring"), + protector: &xorProtector{}, + timeout: time.Second, + } + if err := os.Symlink(target, store.dir); err != nil { + t.Fatal(err) + } + if err := store.Set(keyAuthToken, "secret"); err == nil { + t.Fatal("Set accepted a symlinked keyring directory") + } +} + +type blockingProtector struct{} + +func (blockingProtector) Protect(ctx context.Context, _ string, _ []byte) ([]byte, error) { + <-ctx.Done() + return nil, ctx.Err() +} + +func (blockingProtector) Unprotect(ctx context.Context, _ string, _ []byte) ([]byte, error) { + <-ctx.Done() + return nil, ctx.Err() +} + +func TestWSLSecretStoreTimesOut(t *testing.T) { + store := testWSLStore(t, blockingProtector{}) + store.timeout = time.Millisecond + err := store.Set(keyAuthToken, "secret") + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Set error = %v", err) + } +} + +func TestWSLBackendSelectionIsDeterministic(t *testing.T) { + home := t.TempDir() + if _, ok := secretStoreFor(home, true).(*wslSecretStore); !ok { + t.Fatal("WSL did not select DPAPI store") + } + if _, ok := secretStoreFor(home, false).(systemKeyring); !ok { + t.Fatal("ordinary platform did not select system keyring") + } +}