Skip to content
Draft
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
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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>
<summary>details: query ids and retries</summary>
Expand Down
8 changes: 5 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/auth_ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions cmd/auth_ui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down
4 changes: 2 additions & 2 deletions cmd/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
},
}
Expand Down
215 changes: 215 additions & 0 deletions internal/wsl/wsl.go
Original file line number Diff line number Diff line change
@@ -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
}
`)
Loading