diff --git a/.goreleaser.yml b/.goreleaser.yml index 0339e4d..ea66e4f 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -3,7 +3,7 @@ version: 2 builds: - binary: gh-slackdump env: - - CGO_ENABLED=0 + - CGO_ENABLED=1 ldflags: - -s -w -X main.version={{.Version}} goos: diff --git a/AGENTS.md b/AGENTS.md index 820180d..0c9c0b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,22 +9,30 @@ This is a GH CLI extension similar to [gh-slack](https://github.com/rneatherway/ ## References - [gh-hubber-skills](https://github.com/github/gh-hubber-skills) — Example of a modern GitHub internal `gh` CLI extension (Go + cobra pattern) -- [wham/impact](https://github.com/wham/impact) — Example of using slackdump in a Go app with Safari cookie auth and TLS fingerprinting for the GitHub Slack workspace. The cookie auth and TLS tricks in `internal/auth/safari.go` are ported from this project. +- [wham/impact](https://github.com/wham/impact) — Example of using slackdump in a Go app ## Architecture - `main.go` — Entry point with cobra root command, flags (`--test`, `-o`), and `slog`-based logging -- `internal/auth/safari.go` — Safari cookie auth provider with uTLS fingerprinting, binary cookie parsing, and Slack token extraction +- `internal/auth/safari.go` — Safari binary cookie parser: reads the `d` cookie from Safari's `Cookies.binarycookies` file (macOS only, no Keychain access needed) +- `internal/auth/desktop.go` — Auth provider with uTLS transport: reads the `d` cookie (Safari first, then Slack desktop app), exchanges it for a Slack API token via slackdump's `auth.NewCookieOnlyAuth` +- `internal/auth/cookie_password_darwin.go` — macOS Keychain access via `go-keychain` (only needed for Slack desktop app fallback) +- `internal/auth/cookie_password_linux.go` — Linux Secret Service access via `secret-tool` - `scripts/run` — Development script that builds and runs the binary directly - `scripts/test` — Runs `go test ./...` - `scripts/release` — Release script that bumps the semver tag (patch/minor/major) and pushes it to trigger GoReleaser ## Key Implementation Details -- Authentication reads Safari's `Cookies.binarycookies` file and exchanges cookies for a Slack API token via the `/ssb/redirect` endpoint +- Authentication tries Safari's cookie file first (macOS, no Keychain prompt), then falls back to the Slack desktop app's cookie database (requires Keychain/Secret Service access) +- The `d` cookie is exchanged for a Slack API token via slackdump's `auth.NewCookieOnlyAuth` +- The approach is based on how [gh-slack](https://github.com/rneatherway/gh-slack) handles auth via the [rneatherway/slack](https://github.com/rneatherway/slack) library +- On macOS, the cookie password is retrieved from the Keychain (`Slack Safe Storage`) using the `security` CLI +- On Linux, the cookie password is retrieved from the Secret Service using `secret-tool` +- Cookie decryption uses PBKDF2 + AES-CBC (Chromium's cookie encryption scheme) +- Handles Chromium's domain hash prefix (added in Chromium 128+) by stripping SHA256 domain hashes - The workspace URL is derived from the Slack link provided by the user - TLS connections use [uTLS](https://github.com/refraction-networking/utls) with `HelloSafari_Auto` to mimic Safari's TLS fingerprint -- The User-Agent is detected from the locally installed Safari version - `slackdump.WithForceEnterprise(true)` is automatically set when the link is an `*.enterprise.slack.com` URL - Logging uses `slog`; suppressed when outputting to stdout, enabled when `-o` is set diff --git a/README.md b/README.md index 7e328dc..b3522d2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A [GitHub CLI](https://cli.github.com/) extension that dumps Slack conversations into Slack's [JSON export format](https://slack.com/help/articles/220556107-How-to-read-Slack-data-exports) using [slackdump](https://github.com/rusq/slackdump). Inspired by [gh-slack](https://github.com/rneatherway/gh-slack), but can export entire channels and DMs, not just threads. -It authenticates via Safari's cookie storage and uses [TLS fingerprinting](https://github.com/rusq/slackdump/discussions/526#discussioncomment-14370498) to work with enterprise Slack workspaces without triggering [security notifications](https://slack.com/help/articles/37506096763283-Understand-Slack-Security-notifications). Currently macOS-only — requires Safari to be signed in to your Slack workspace. +It authenticates via Safari's cookie storage (macOS) or the Slack desktop app's local cookie storage, using the same approach as [gh-slack](https://github.com/rneatherway/gh-slack). Uses [TLS fingerprinting](https://github.com/rusq/slackdump/discussions/526#discussioncomment-14370498) to work with enterprise Slack workspaces without triggering [security notifications](https://slack.com/help/articles/37506096763283-Understand-Slack-Security-notifications). Requires Safari or the Slack desktop app to be signed in to your Slack workspace. ## Installation @@ -18,7 +18,7 @@ gh extension upgrade wham/gh-slackdump ## Usage -Sign in to your Slack workspace in **Safari** first. +Sign in to your Slack workspace in **Safari** or the **Slack desktop app** first. ``` gh slackdump @@ -43,7 +43,7 @@ gh slackdump --test | Flag | Description | |---|---| | `-o, --output ` | Write JSON output to a file instead of stdout. When set, progress is logged to stdout. | -| `--test` | Show the detected Safari User-Agent and parsed Slack cookies, then exit. Useful for verifying that cookie access is working. | +| `--test` | Show the detected Slack cookie source and value, then exit. Useful for verifying that cookie access is working. | | `-v, --version` | Print the version number and exit. | | `-h, --help` | Show help with all available flags and usage examples. | diff --git a/go.mod b/go.mod index 0f8af21..5fc6696 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,14 @@ module github.com/wham/gh-slackdump go 1.25.5 require ( + github.com/keybase/go-keychain v0.0.1 github.com/refraction-networking/utls v1.8.2 github.com/rusq/slack v0.9.6-0.20250408103104-dd80d1b6337f github.com/rusq/slackdump/v3 v3.1.13 github.com/spf13/cobra v1.10.2 + golang.org/x/crypto v0.48.0 golang.org/x/net v0.50.0 + modernc.org/sqlite v1.46.1 ) require ( @@ -58,8 +61,10 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/playwright-community/playwright-go v0.5200.1 // indirect github.com/pressly/goose/v3 v3.26.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rusq/chttp v1.1.0 // indirect github.com/rusq/fsadapter v1.1.0 // indirect @@ -74,11 +79,14 @@ require ( github.com/ysmood/gson v0.7.3 // indirect github.com/ysmood/leakless v0.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + modernc.org/libc v1.67.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 6e9f453..2e8d33a 100644 --- a/go.sum +++ b/go.sum @@ -90,17 +90,23 @@ github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -206,6 +212,8 @@ golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7 golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -251,6 +259,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= @@ -258,11 +268,31 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI= modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY= -modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/cookie_password_darwin.go b/internal/auth/cookie_password_darwin.go new file mode 100644 index 0000000..7813f2f --- /dev/null +++ b/internal/auth/cookie_password_darwin.go @@ -0,0 +1,42 @@ +package auth + +import ( + "errors" + + "github.com/keybase/go-keychain" +) + +func cookiePassword() ([]byte, error) { + accountNames := []string{"Slack Key", "Slack", "Slack App Store Key"} + var lastErr error + for _, name := range accountNames { + password, err := cookiePasswordFromKeychain(name) + if err == nil { + return password, nil + } + lastErr = err + } + return nil, lastErr +} + +func cookiePasswordFromKeychain(accountName string) ([]byte, error) { + query := keychain.NewItem() + query.SetSecClass(keychain.SecClassGenericPassword) + query.SetService("Slack Safe Storage") + query.SetAccount(accountName) + query.SetMatchLimit(keychain.MatchLimitOne) + query.SetReturnAttributes(true) + query.SetReturnData(true) + results, err := keychain.QueryItem(query) + if err != nil { + return nil, err + } + switch len(results) { + case 0: + return nil, errors.New("no matching keychain items found") + case 1: + return results[0].Data, nil + default: + return nil, errors.New("multiple keychain items found") + } +} diff --git a/internal/auth/desktop.go b/internal/auth/desktop.go new file mode 100644 index 0000000..47f778f --- /dev/null +++ b/internal/auth/desktop.go @@ -0,0 +1,337 @@ +package auth + +import ( + "bufio" + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/sha1" + "database/sql" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "path/filepath" + "regexp" + "time" + + utls "github.com/refraction-networking/utls" + "github.com/rusq/slack" + "github.com/rusq/slackdump/v3/auth" + "golang.org/x/crypto/pbkdf2" + "golang.org/x/net/http2" + "golang.org/x/net/publicsuffix" + _ "modernc.org/sqlite" +) + +// Provider wraps slackdump's ValueAuth with uTLS fingerprinting +// to mimic Safari's TLS fingerprint. +type Provider struct { + auth.ValueAuth +} + +func (p *Provider) HTTPClient() (*http.Client, error) { + jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + if err != nil { + return nil, err + } + u, _ := url.Parse(auth.SlackURL) + jar.SetCookies(u, p.Cookies()) + return &http.Client{ + Jar: jar, + Transport: &utlsTransport{h2: &http2.Transport{}}, + }, nil +} + +func (p *Provider) Test(ctx context.Context) (*slack.AuthTestResponse, error) { + cl, err := p.HTTPClient() + if err != nil { + return nil, err + } + return slack.New(p.SlackToken(), slack.OptionHTTPClient(cl)).AuthTestContext(ctx) +} + +// utlsTransport uses uTLS to mimic Safari's TLS fingerprint. +type utlsTransport struct { + h2 *http2.Transport +} + +func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) { + addr := req.URL.Host + if req.URL.Port() == "" { + addr += ":443" + } + + conn, err := net.DialTimeout("tcp", addr, 30*time.Second) + if err != nil { + return nil, err + } + + tlsConn := utls.UClient(conn, &utls.Config{ServerName: req.URL.Hostname()}, utls.HelloSafari_Auto) + if err := tlsConn.Handshake(); err != nil { + conn.Close() + return nil, err + } + + if tlsConn.ConnectionState().NegotiatedProtocol == "h2" { + cc, err := t.h2.NewClientConn(tlsConn) + if err != nil { + conn.Close() + return nil, err + } + return cc.RoundTrip(req) + } + + if err := req.Write(conn); err != nil { + conn.Close() + return nil, err + } + resp, err := http.ReadResponse(bufio.NewReader(conn), req) + if err != nil { + conn.Close() + return nil, err + } + return resp, nil +} + +// NewProvider creates a new auth provider by reading the Slack "d" cookie +// and exchanging it for a Slack API token. Tries Safari first, then the +// Slack desktop app. If a cookie is found but doesn't work for the target +// workspace, falls back to the next source. +// All connections use uTLS to mimic Safari's TLS fingerprint. +func NewProvider(ctx context.Context, workspaceURL string) (*Provider, error) { + type cookieSource struct { + name string + read func() (string, error) + } + sources := []cookieSource{ + {"Safari", readSafariCookie}, + {"Slack desktop app", readDesktopCookie}, + } + + var lastErr error + for _, src := range sources { + cookie, err := src.read() + if err != nil { + slog.Info("cookie not available", "source", src.name, "error", err) + continue + } + if cookie == "" { + slog.Info("cookie not found for slack.com", "source", src.name) + continue + } + + slog.Info("trying cookie", "source", src.name) + token, err := exchangeCookieForToken(workspaceURL, cookie) + if err != nil { + slog.Info("cookie did not work for workspace", "source", src.name, "error", err) + lastErr = err + continue + } + + slog.Info("authenticated", "source", src.name) + va, err := auth.NewValueAuth(token, cookie) + if err != nil { + return nil, fmt.Errorf("creating auth: %w", err) + } + return &Provider{ValueAuth: va}, nil + } + + if lastErr != nil { + return nil, fmt.Errorf("no cookie worked for this workspace: %w", lastErr) + } + return nil, errors.New("no Slack cookies found — sign in to Slack in Safari or the Slack desktop app") +} + +var apiTokenRE = regexp.MustCompile(`"api_token":"([^"]+)"`) + +// exchangeCookieForToken exchanges a Slack "d" cookie for an API token +// by hitting the workspace URL through uTLS. +func exchangeCookieForToken(workspaceURL, cookie string) (string, error) { + req, err := http.NewRequest("GET", workspaceURL, nil) + if err != nil { + return "", err + } + req.AddCookie(&http.Cookie{Name: "d", Value: cookie}) + + client := &http.Client{ + Transport: &utlsTransport{h2: &http2.Transport{}}, + } + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("status code %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + matches := apiTokenRE.FindSubmatch(body) + if len(matches) < 2 { + return "", errors.New("api token not found in response") + } + + return string(matches[1]), nil +} + +// ReadCookie reads the Slack "d" cookie, trying Safari first, +// then falling back to the Slack desktop app's cookie database. +func ReadCookie() (string, error) { + cookie, err := readSafariCookie() + if err != nil { + slog.Info("Safari cookie not available", "error", err) + } else if cookie != "" { + slog.Info("using Safari cookie") + return cookie, nil + } else { + slog.Info("Safari cookie not found for slack.com") + } + + slog.Info("trying Slack desktop app") + cookie, err = readDesktopCookie() + if err != nil { + return "", err + } + slog.Info("using Slack desktop cookie") + return cookie, nil +} + +// readDesktopCookie reads and decrypts the Slack "d" cookie from the +// Slack desktop app's local cookie database. +func readDesktopCookie() (string, error) { + dbPath, err := slackCookieDBPath() + if err != nil { + return "", err + } + + slog.Info("reading Slack cookie", "path", dbPath) + + db, err := sql.Open("sqlite", dbPath+"?mode=ro") + if err != nil { + return "", fmt.Errorf("opening cookie database: %w", err) + } + defer db.Close() + + var cookie string + var encryptedValue []byte + err = db.QueryRow(`SELECT value, encrypted_value FROM cookies WHERE host_key=".slack.com" AND name="d"`).Scan(&cookie, &encryptedValue) + if err != nil { + return "", fmt.Errorf("querying cookie: %w", err) + } + + if cookie != "" { + return cookie, nil + } + + if len(encryptedValue) < 4 { + return "", errors.New("encrypted cookie value too short") + } + + // Remove version prefix (e.g. "v11" = 3 bytes) + encryptedValue = encryptedValue[3:] + + key, err := cookiePassword() + if err != nil { + return "", fmt.Errorf("getting cookie password: %w", err) + } + + decrypted, err := decryptCookie(encryptedValue, key) + if err != nil { + return "", fmt.Errorf("decrypting cookie: %w", err) + } + + decrypted = removeDomainHashPrefix(decrypted) + + return string(decrypted), nil +} + +// decryptCookie decrypts a Chromium-encrypted cookie value using PBKDF2 + AES-CBC. +func decryptCookie(value, key []byte) ([]byte, error) { + dk := pbkdf2.Key(key, []byte("saltysalt"), 1003, 16, sha1.New) + + block, err := aes.NewCipher(dk) + if err != nil { + return nil, err + } + + iv := make([]byte, 16) + for i := range iv { + iv[i] = ' ' + } + + decrypted := make([]byte, len(value)) + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(decrypted, value) + + // Remove PKCS7 padding + if len(decrypted) > 0 { + pad := int(decrypted[len(decrypted)-1]) + if pad > 0 && pad <= aes.BlockSize && pad <= len(decrypted) { + decrypted = decrypted[:len(decrypted)-pad] + } + } + + return decrypted, nil +} + +// Chromium prefixes encrypted cookie values with a SHA256 hash of the domain. +// See https://chromium-review.googlesource.com/c/chromium/src/+/5792044 +var domainHashPrefixes = [][]byte{ + // slack.com + {3, 202, 236, 172, 132, 247, 212, 240, 217, 211, 68, 226, 103, 153, 245, 64, 85, 68, 2, 183, 83, 182, 186, 218, 14, 102, 237, 62, 231, 241, 231, 142}, + // .slack.com + {145, 28, 115, 68, 173, 92, 42, 78, 104, 243, 5, 63, 24, 206, 51, 190, 31, 169, 160, 244, 247, 106, 147, 228, 60, 68, 92, 134, 105, 199, 162, 120}, +} + +func removeDomainHashPrefix(value []byte) []byte { + for _, prefix := range domainHashPrefixes { + if bytes.HasPrefix(value, prefix) { + return value[len(prefix):] + } + } + return value +} + +// slackCookieDBPath returns the path to the Slack desktop app's cookie database. +func slackCookieDBPath() (string, error) { + dir, err := slackConfigDir() + if err != nil { + return "", err + } + + cookieFile := filepath.Join(dir, "Cookies") + + if _, err := os.Stat(cookieFile); err != nil { + return "", fmt.Errorf("Slack cookie database not found at %s — is the Slack desktop app installed and signed in?", cookieFile) + } + + return cookieFile, nil +} + +// slackConfigDir returns the Slack desktop app's configuration directory. +func slackConfigDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + first := filepath.Join(home, "Library", "Application Support", "Slack") + second := filepath.Join(home, "Library", "Containers", "com.tinyspeck.slackmacgap", "Data", "Library", "Application Support", "Slack") + if _, err := os.Stat(first); err == nil { + return first, nil + } + return second, nil +} + diff --git a/internal/auth/desktop_test.go b/internal/auth/desktop_test.go new file mode 100644 index 0000000..5820c07 --- /dev/null +++ b/internal/auth/desktop_test.go @@ -0,0 +1,160 @@ +package auth + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/sha1" + "net/http" + "testing" + + "github.com/rusq/slackdump/v3/auth" + "golang.org/x/crypto/pbkdf2" +) + +func TestProviderValidate(t *testing.T) { + tests := []struct { + name string + token string + wantErr bool + }{ + {name: "valid token", token: "xoxc-abc123", wantErr: false}, + {name: "empty token", token: "", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var p *Provider + if tt.token != "" { + va, err := auth.NewValueAuth(tt.token, "") + if err != nil { + t.Fatalf("NewValueAuth error: %v", err) + } + p = &Provider{ValueAuth: va} + } else { + p = &Provider{} + } + err := p.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestProviderAccessors(t *testing.T) { + cookies := []*http.Cookie{{Name: "d", Value: "abc"}} + va, err := auth.NewValueCookiesAuth("xoxc-test", cookies) + if err != nil { + t.Fatalf("NewValueCookiesAuth error: %v", err) + } + p := &Provider{ValueAuth: va} + + if got := p.SlackToken(); got != "xoxc-test" { + t.Errorf("SlackToken() = %q, want %q", got, "xoxc-test") + } + if got := p.Cookies(); len(got) == 0 { + t.Error("Cookies() returned empty slice") + } +} + +func TestProviderHTTPClient(t *testing.T) { + cookies := []*http.Cookie{{Name: "d", Value: "abc"}} + va, err := auth.NewValueCookiesAuth("xoxc-test", cookies) + if err != nil { + t.Fatalf("NewValueCookiesAuth error: %v", err) + } + p := &Provider{ValueAuth: va} + client, err := p.HTTPClient() + if err != nil { + t.Fatalf("HTTPClient() error: %v", err) + } + if client == nil { + t.Fatal("HTTPClient() returned nil") + } + if client.Transport == nil { + t.Fatal("HTTPClient().Transport is nil, expected utlsTransport") + } +} + +func TestDecryptCookie(t *testing.T) { + // Encrypt a known value with known key to test decryption + plaintext := []byte("test-cookie-value") + key := []byte("test-password") + + // Use the same PBKDF2 rounds that decryptCookie uses + dk := pbkdf2.Key(key, []byte("saltysalt"), 1003, 16, sha1.New) + + block, err := aes.NewCipher(dk) + if err != nil { + t.Fatalf("NewCipher error: %v", err) + } + + // Add PKCS7 padding + padLen := aes.BlockSize - len(plaintext)%aes.BlockSize + padded := make([]byte, len(plaintext)+padLen) + copy(padded, plaintext) + for i := len(plaintext); i < len(padded); i++ { + padded[i] = byte(padLen) + } + + // Encrypt with the same IV (all spaces) + iv := make([]byte, 16) + for i := range iv { + iv[i] = ' ' + } + mode := cipher.NewCBCEncrypter(block, iv) + encrypted := make([]byte, len(padded)) + mode.CryptBlocks(encrypted, padded) + + // Now test decryption + decrypted, err := decryptCookie(encrypted, key) + if err != nil { + t.Fatalf("decryptCookie error: %v", err) + } + + if string(decrypted) != string(plaintext) { + t.Errorf("decryptCookie() = %q, want %q", decrypted, plaintext) + } +} + +func TestRemoveDomainHashPrefix(t *testing.T) { + // slack.com prefix + slackPrefix := []byte{3, 202, 236, 172, 132, 247, 212, 240, 217, 211, 68, 226, 103, 153, 245, 64, 85, 68, 2, 183, 83, 182, 186, 218, 14, 102, 237, 62, 231, 241, 231, 142} + // .slack.com prefix + dotSlackPrefix := []byte{145, 28, 115, 68, 173, 92, 42, 78, 104, 243, 5, 63, 24, 206, 51, 190, 31, 169, 160, 244, 247, 106, 147, 228, 60, 68, 92, 134, 105, 199, 162, 120} + + tests := []struct { + name string + input []byte + want string + }{ + { + name: "with slack.com prefix", + input: append(slackPrefix, []byte("cookie-value")...), + want: "cookie-value", + }, + { + name: "with .slack.com prefix", + input: append(dotSlackPrefix, []byte("cookie-value")...), + want: "cookie-value", + }, + { + name: "without prefix", + input: []byte("cookie-value"), + want: "cookie-value", + }, + { + name: "empty input", + input: []byte{}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := removeDomainHashPrefix(tt.input) + if !bytes.Equal(got, []byte(tt.want)) { + t.Errorf("removeDomainHashPrefix() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/auth/safari.go b/internal/auth/safari.go index a53dfb6..7ac6458 100644 --- a/internal/auth/safari.go +++ b/internal/auth/safari.go @@ -1,237 +1,62 @@ package auth import ( - "bufio" "bytes" - "context" "encoding/binary" "fmt" "io" - "math" - "net" - "net/http" + "log/slog" "os" - "os/exec" "path/filepath" - "regexp" "strings" - "time" - - utls "github.com/refraction-networking/utls" - "github.com/rusq/slack" - "github.com/rusq/slackdump/v3/auth" - "golang.org/x/net/http2" ) -// SafariProvider implements auth.Provider with Safari cookie authentication -// and TLS fingerprinting to mimic real Safari browser connections. -type SafariProvider struct { - token, ua string - cookies []*http.Cookie -} - -func (p *SafariProvider) SlackToken() string { return p.token } -func (p *SafariProvider) Cookies() []*http.Cookie { return p.cookies } -func (p *SafariProvider) Validate() error { - if p.token == "" { - return auth.ErrNoToken - } - return nil -} - -func (p *SafariProvider) HTTPClient() (*http.Client, error) { - return &http.Client{ - Transport: &safariTransport{ua: p.ua, cookies: p.cookies, h2: &http2.Transport{}}, - }, nil -} - -func (p *SafariProvider) Test(ctx context.Context) (*slack.AuthTestResponse, error) { - cl, err := p.HTTPClient() - if err != nil { - return nil, err - } - return slack.New(p.token, slack.OptionHTTPClient(cl)).AuthTestContext(ctx) -} - -// safariTransport uses uTLS to mimic Safari's TLS fingerprint. -type safariTransport struct { - ua string - cookies []*http.Cookie - h2 *http2.Transport -} - -func (t *safariTransport) RoundTrip(req *http.Request) (*http.Response, error) { - r := req.Clone(req.Context()) - r.Header.Set("User-Agent", t.ua) - var cb strings.Builder - for i, c := range t.cookies { - if i > 0 { - cb.WriteString("; ") - } - cb.WriteString(c.Name + "=" + c.Value) - } - r.Header.Set("Cookie", cb.String()) - - addr := r.URL.Host - if r.URL.Port() == "" { - addr += ":443" - } - conn, err := net.DialTimeout("tcp", addr, 30*time.Second) - if err != nil { - return nil, err - } - tlsConn := utls.UClient(conn, &utls.Config{ServerName: r.URL.Hostname()}, utls.HelloSafari_Auto) - if err := tlsConn.Handshake(); err != nil { - conn.Close() - return nil, err - } - if tlsConn.ConnectionState().NegotiatedProtocol == "h2" { - cc, err := t.h2.NewClientConn(tlsConn) - if err != nil { - conn.Close() - return nil, err - } - return cc.RoundTrip(r) - } - if err := r.Write(conn); err != nil { - conn.Close() - return nil, err - } - resp, err := http.ReadResponse(bufio.NewReader(conn), r) - if err != nil { - conn.Close() - return nil, err - } - return resp, nil -} - -// ReadSafariCookies reads and parses Safari's binary cookies for Slack -// and detects the Safari User-Agent, without exchanging for a token. -func ReadSafariCookies() (cookies []*http.Cookie, userAgent string, err error) { +// readSafariCookie reads the Slack "d" cookie from Safari's binary cookie file. +// Returns empty string if Safari cookies are not available or the "d" cookie is not found. +func readSafariCookie() (string, error) { home, err := os.UserHomeDir() if err != nil { - return nil, "", fmt.Errorf("getting home directory: %w", err) - } - - safariCookiePath := filepath.Join(home, "Library", "Containers", "com.apple.Safari", "Data", "Library", "Cookies", "Cookies.binarycookies") - if _, err := os.Stat(safariCookiePath); os.IsNotExist(err) { - return nil, "", fmt.Errorf("Safari cookies not found at %s", safariCookiePath) - } - - cookies, err = parseCookieFile(safariCookiePath) - if err != nil { - return nil, "", fmt.Errorf("parsing Safari cookies: %w", err) - } - - return cookies, detectSafariUserAgent(), nil -} - -// NewSafariProvider creates a new auth provider by reading Safari cookies -// and exchanging them for a Slack API token. The workspaceURL is the base -// URL of the Slack workspace (e.g., "https://myteam.slack.com"). -func NewSafariProvider(ctx context.Context, workspaceURL string) (*SafariProvider, error) { - cookies, ua, err := ReadSafariCookies() - if err != nil { - return nil, err + return "", err } - token, allCookies, err := getTokenFromCookies(workspaceURL, cookies, ua) - if err != nil { - return nil, fmt.Errorf("getting Slack token from cookies: %w", err) + path := filepath.Join(home, "Library", "Containers", "com.apple.Safari", "Data", "Library", "Cookies", "Cookies.binarycookies") + if _, err := os.Stat(path); err != nil { + return "", fmt.Errorf("Safari cookies not found at %s", path) } - return &SafariProvider{token: token, cookies: allCookies, ua: ua}, nil -} + slog.Info("reading Safari cookies", "path", path) -func getTokenFromCookies(workspaceURL string, cookies []*http.Cookie, userAgent string) (string, []*http.Cookie, error) { - if userAgent == "" { - userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" - } - req, err := http.NewRequest("GET", workspaceURL+"/ssb/redirect", nil) - if err != nil { - return "", nil, fmt.Errorf("creating request: %w", err) - } - // Build raw Cookie header to avoid Go's cookie value sanitization - var cb strings.Builder - for i, c := range cookies { - if i > 0 { - cb.WriteString("; ") - } - cb.WriteString(c.Name + "=" + c.Value) - } - req.Header.Set("Cookie", cb.String()) - req.Header.Set("User-Agent", userAgent) - req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8") - req.Header.Set("Accept-Language", "en-US,en;q=0.9") - req.Header.Set("Sec-Fetch-Dest", "document") - req.Header.Set("Sec-Fetch-Mode", "navigate") - req.Header.Set("Sec-Fetch-Site", "none") - req.Header.Set("Sec-Fetch-User", "?1") - req.Header.Set("Sec-Ch-Ua-Mobile", "?0") - req.Header.Set("Sec-Ch-Ua-Platform", `"macOS"`) - req.Header.Set("Upgrade-Insecure-Requests", "1") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", nil, fmt.Errorf("making request: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", nil, fmt.Errorf("received status: %s", resp.Status) - } - responseBody, err := io.ReadAll(resp.Body) + data, err := os.ReadFile(path) if err != nil { - return "", nil, fmt.Errorf("reading body: %w", err) - } - re := regexp.MustCompile(`"api_token":"([^"]+)"`) - matches := re.FindSubmatch(responseBody) - if len(matches) < 2 { - return "", nil, fmt.Errorf("no Slack token found in response") + return "", err } - token := string(matches[1]) - // Merge input cookies with response cookies (response takes precedence) - cm := make(map[string]*http.Cookie, len(cookies)) - for _, c := range cookies { - cm[c.Name] = c - } - for _, c := range resp.Cookies() { - cm[c.Name] = c - } - merged := make([]*http.Cookie, 0, len(cm)) - for _, c := range cm { - merged = append(merged, c) - } - return token, merged, nil + return parseSafariDCookie(data) } -func parseCookieFile(path string) ([]*http.Cookie, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err +// parseSafariDCookie parses Apple's Cookies.binarycookies format and returns +// the value of the Slack "d" cookie. Returns empty string if not found. +// Format: "cook" magic, big-endian page count + sizes, then pages with little-endian cookie records. +func parseSafariDCookie(data []byte) (string, error) { + if len(data) < 4 { + return "", fmt.Errorf("cookie file too short") } - return parseSafariBinaryCookies(data) -} -// parseSafariBinaryCookies parses Apple's Cookies.binarycookies format. -// Format: "cook" magic, big-endian page count + sizes, then pages with little-endian cookie records. -func parseSafariBinaryCookies(data []byte) ([]*http.Cookie, error) { r := bytes.NewReader(data[4:]) // skip "cook" magic var numPages int32 if err := binary.Read(r, binary.BigEndian, &numPages); err != nil { - return nil, fmt.Errorf("reading page count: %w", err) + return "", fmt.Errorf("reading page count: %w", err) } pageSizes := make([]int32, numPages) for i := range pageSizes { if err := binary.Read(r, binary.BigEndian, &pageSizes[i]); err != nil { - return nil, fmt.Errorf("reading page size: %w", err) + return "", fmt.Errorf("reading page size: %w", err) } } - var cookies []*http.Cookie for _, ps := range pageSizes { pageData := make([]byte, ps) if _, err := io.ReadFull(r, pageData); err != nil { - return nil, fmt.Errorf("reading page: %w", err) + return "", fmt.Errorf("reading page: %w", err) } if len(pageData) < 8 { continue @@ -251,14 +76,9 @@ func parseSafariBinaryCookies(data []byte) ([]*http.Cookie, error) { continue } cd := pageData[start : start+cookieSize] - flags := binary.LittleEndian.Uint32(cd[4:8]) - urlOff := int(binary.LittleEndian.Uint32(cd[12:16])) - 4 - nameOff := int(binary.LittleEndian.Uint32(cd[16:20])) - 4 - pathOff := int(binary.LittleEndian.Uint32(cd[20:24])) - 4 - valueOff := int(binary.LittleEndian.Uint32(cd[24:28])) - 4 - expiryMac := math.Float64frombits(binary.LittleEndian.Uint64(cd[36:44])) - readStr := func(off int) string { + readStr := func(fieldOffset int) string { + off := int(binary.LittleEndian.Uint32(cd[fieldOffset:fieldOffset+4])) - 4 if off < 0 || off >= len(cd) { return "" } @@ -268,38 +88,18 @@ func parseSafariBinaryCookies(data []byte) ([]*http.Cookie, error) { } return string(cd[off : off+end]) } - domain := readStr(urlOff) + + domain := readStr(12) // url offset field if !strings.Contains(domain, "slack.com") { continue } - val := strings.ReplaceAll(readStr(valueOff), `"`, "") - c := &http.Cookie{ - Domain: domain, - Name: readStr(nameOff), - Path: readStr(pathOff), - Value: val, - Secure: flags&1 != 0, - HttpOnly: flags&4 != 0, - } - if expiryMac > 0 { - c.Expires = time.Unix(int64(expiryMac)+978307200, 0) + name := readStr(16) // name offset field + if name != "d" { + continue } - cookies = append(cookies, c) + value := readStr(24) // value offset field + return strings.ReplaceAll(value, `"`, ""), nil } } - return cookies, nil -} - -func detectSafariUserAgent() string { - safariVer, err := exec.Command("defaults", "read", "/Applications/Safari.app/Contents/Info", "CFBundleShortVersionString").Output() - if err != nil { - return "" - } - macVer, err := exec.Command("sw_vers", "-productVersion").Output() - if err != nil { - return "" - } - sv := strings.TrimSpace(string(safariVer)) - mv := strings.ReplaceAll(strings.TrimSpace(string(macVer)), ".", "_") - return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X %s) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/%s Safari/605.1.15", mv, sv) + return "", nil } diff --git a/internal/auth/safari_test.go b/internal/auth/safari_test.go deleted file mode 100644 index 9ab3d0b..0000000 --- a/internal/auth/safari_test.go +++ /dev/null @@ -1,203 +0,0 @@ -package auth - -import ( - "bytes" - "encoding/binary" - "math" - "net/http" - "testing" - "time" -) - -func TestSafariProviderValidate(t *testing.T) { - tests := []struct { - name string - token string - wantErr bool - }{ - {name: "valid token", token: "xoxc-abc123", wantErr: false}, - {name: "empty token", token: "", wantErr: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := &SafariProvider{token: tt.token} - err := p.Validate() - if (err != nil) != tt.wantErr { - t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func TestSafariProviderAccessors(t *testing.T) { - cookies := []*http.Cookie{{Name: "d", Value: "abc"}} - p := &SafariProvider{token: "xoxc-test", ua: "TestAgent", cookies: cookies} - - if got := p.SlackToken(); got != "xoxc-test" { - t.Errorf("SlackToken() = %q, want %q", got, "xoxc-test") - } - if got := p.Cookies(); len(got) != 1 || got[0].Name != "d" { - t.Errorf("Cookies() unexpected result: %v", got) - } -} - -// buildBinaryCookies constructs a minimal Cookies.binarycookies file for testing. -func buildBinaryCookies(cookies []testCookie) []byte { - var page bytes.Buffer - - // Page header: 0x00000100 magic, numCookies, offsets - binary.Write(&page, binary.LittleEndian, uint32(0x00000100)) - binary.Write(&page, binary.LittleEndian, uint32(len(cookies))) - - // We'll compute cookie data first, then backfill offsets - headerSize := 8 + len(cookies)*4 - var cookieBlobs [][]byte - offsets := make([]uint32, len(cookies)) - currentOffset := uint32(headerSize) - - for i, c := range cookies { - blob := encodeCookie(c) - cookieBlobs = append(cookieBlobs, blob) - offsets[i] = currentOffset - currentOffset += uint32(len(blob)) - } - - // Write offsets - for _, off := range offsets { - binary.Write(&page, binary.LittleEndian, off) - } - // Write cookie blobs - for _, blob := range cookieBlobs { - page.Write(blob) - } - - pageBytes := page.Bytes() - - // Build full file - var file bytes.Buffer - file.WriteString("cook") - binary.Write(&file, binary.BigEndian, int32(1)) // 1 page - binary.Write(&file, binary.BigEndian, int32(len(pageBytes))) - file.Write(pageBytes) - - return file.Bytes() -} - -type testCookie struct { - domain, name, path, value string - flags uint32 - expiry time.Time -} - -func encodeCookie(c testCookie) []byte { - // Build strings: url, name, path, value (null-terminated) - urlBytes := append([]byte(c.domain), 0) - nameBytes := append([]byte(c.name), 0) - pathBytes := append([]byte(c.path), 0) - valueBytes := append([]byte(c.value), 0) - - // Cookie record layout (after the 4-byte size prefix): - // 0-3: ignored (we set to 0) - // 4-7: flags - // 8-11: ignored - // 12-15: url offset (relative, +4 to account for size prefix) - // 16-19: name offset - // 20-23: path offset - // 24-27: value offset - // 28-35: ignored - // 36-43: expiry (float64, Mac epoch) - // 44+: string data - - stringsStart := uint32(44) - urlOff := stringsStart + 4 - nameOff := urlOff + uint32(len(urlBytes)) - pathOff := nameOff + uint32(len(nameBytes)) - valueOff := pathOff + uint32(len(pathBytes)) - - totalSize := int(valueOff) + len(valueBytes) - 4 // subtract the 4-byte size prefix - - var buf bytes.Buffer - // Size prefix (written before the cookie data, but parseSafariBinaryCookies reads it at offset) - binary.Write(&buf, binary.LittleEndian, uint32(totalSize)) - // cd[0:4] - ignored - binary.Write(&buf, binary.LittleEndian, uint32(0)) - // cd[4:8] - flags - binary.Write(&buf, binary.LittleEndian, c.flags) - // cd[8:12] - ignored - binary.Write(&buf, binary.LittleEndian, uint32(0)) - // cd[12:16] - url offset - binary.Write(&buf, binary.LittleEndian, urlOff) - // cd[16:20] - name offset - binary.Write(&buf, binary.LittleEndian, nameOff) - // cd[20:24] - path offset - binary.Write(&buf, binary.LittleEndian, pathOff) - // cd[24:28] - value offset - binary.Write(&buf, binary.LittleEndian, valueOff) - // cd[28:36] - ignored - binary.Write(&buf, binary.LittleEndian, uint64(0)) - // cd[36:44] - expiry as float64 (Mac epoch = Unix - 978307200) - var expiryMac float64 - if !c.expiry.IsZero() { - expiryMac = float64(c.expiry.Unix() - 978307200) - } - binary.Write(&buf, binary.LittleEndian, math.Float64bits(expiryMac)) - // String data - buf.Write(urlBytes) - buf.Write(nameBytes) - buf.Write(pathBytes) - buf.Write(valueBytes) - - return buf.Bytes() -} - -func TestParseSafariBinaryCookies(t *testing.T) { - expiry := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) - data := buildBinaryCookies([]testCookie{ - {domain: ".slack.com", name: "d", path: "/", value: "abc123", flags: 0x05, expiry: expiry}, - {domain: ".example.com", name: "session", path: "/", value: "xyz", flags: 0, expiry: expiry}, - {domain: ".enterprise.slack.com", name: "d-s", path: "/", value: "ent456", flags: 0x01, expiry: expiry}, - }) - - cookies, err := parseSafariBinaryCookies(data) - if err != nil { - t.Fatalf("parseSafariBinaryCookies() error: %v", err) - } - - // Should only have slack.com cookies (2), not example.com - if len(cookies) != 2 { - t.Fatalf("expected 2 cookies, got %d", len(cookies)) - } - - // Check first cookie - if cookies[0].Domain != ".slack.com" { - t.Errorf("cookie[0].Domain = %q, want %q", cookies[0].Domain, ".slack.com") - } - if cookies[0].Name != "d" { - t.Errorf("cookie[0].Name = %q, want %q", cookies[0].Name, "d") - } - if cookies[0].Value != "abc123" { - t.Errorf("cookie[0].Value = %q, want %q", cookies[0].Value, "abc123") - } - if !cookies[0].Secure { - t.Error("cookie[0].Secure should be true (flag 0x01)") - } - if !cookies[0].HttpOnly { - t.Error("cookie[0].HttpOnly should be true (flag 0x04)") - } - - // Check second cookie (enterprise) - if cookies[1].Domain != ".enterprise.slack.com" { - t.Errorf("cookie[1].Domain = %q, want %q", cookies[1].Domain, ".enterprise.slack.com") - } -} - -func TestParseSafariBinaryCookiesEmpty(t *testing.T) { - data := buildBinaryCookies(nil) - cookies, err := parseSafariBinaryCookies(data) - if err != nil { - t.Fatalf("parseSafariBinaryCookies() error: %v", err) - } - if len(cookies) != 0 { - t.Errorf("expected 0 cookies, got %d", len(cookies)) - } -} diff --git a/main.go b/main.go index 09b13b2..410c6d2 100644 --- a/main.go +++ b/main.go @@ -29,7 +29,8 @@ to stdout in Slack's JSON export format. Supports channels, threads, and direct messages in both regular (*.slack.com) and enterprise (*.enterprise.slack.com) workspaces. Authenticates via Safari's -cookie storage — requires Safari to be signed in to your Slack workspace.`, +cookie storage (macOS) or the Slack desktop app's local cookie storage — +requires Safari or the Slack desktop app to be signed in to your workspace.`, Example: ` gh slackdump https://myworkspace.slack.com/archives/C09036MGFJ4 gh slackdump -o output.json https://myworkspace.enterprise.slack.com/archives/CMH59UX4P gh slackdump --test`, @@ -40,7 +41,7 @@ cookie storage — requires Safari to be signed in to your Slack workspace.`, } func init() { - rootCmd.Flags().BoolVar(&testFlag, "test", false, "Show detected User-Agent and parsed cookies, then exit") + rootCmd.Flags().BoolVar(&testFlag, "test", false, "Show detected Slack cookie source and value, then exit") rootCmd.Flags().StringVarP(&outputFile, "output", "o", "", "Write output to file instead of stdout") rootCmd.Args = func(cmd *cobra.Command, args []string) error { if testFlag { @@ -70,7 +71,7 @@ func run(cmd *cobra.Command, args []string) error { } slog.Info("authenticating", "workspace", workspaceURL) - provider, err := sdauth.NewSafariProvider(ctx, workspaceURL) + provider, err := sdauth.NewProvider(ctx, workspaceURL) if err != nil { return err } @@ -126,22 +127,15 @@ func extractWorkspaceURL(slackLink string) (string, error) { } func runTest() error { - cookies, ua, err := sdauth.ReadSafariCookies() + cookie, err := sdauth.ReadCookie() if err != nil { return err } - if ua == "" { - ua = "(Safari not found)" + v := cookie + if len(v) > 40 { + v = v[:40] + "..." } - slog.Info("safari", "user-agent", ua) - for _, c := range cookies { - v := c.Value - if len(v) > 40 { - v = v[:40] + "..." - } - slog.Info("cookie", "name", c.Name, "secure", c.Secure, "httponly", c.HttpOnly, "value", v) - } - slog.Info("total", "cookies", len(cookies)) + slog.Info("cookie", "value", v) return nil }