Skip to content

Commit

Permalink
add finder interface, rework chrome impl, add examples
Browse files Browse the repository at this point in the history
  • Loading branch information
srlehn committed Oct 30, 2020
1 parent 652aada commit 9fada5d
Show file tree
Hide file tree
Showing 47 changed files with 2,238 additions and 810 deletions.
102 changes: 71 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,67 +6,107 @@ Since you've arrived here, you're almost certainly going to do it
anyway. Me too. And if we're going to do the Wrong Thing, at least
let's try to Do it Right.

Package kooky contains routines to reach into cookie stores for Chrome
and Safari, and retrieve the cookies.
Package kooky contains routines to reach into cookie stores for Chrome, Firefox, Safari, ... and retrieve the cookies.

It aspires to be pure Go (I spent quite a while making
[go-sqlite/sqlite3](https://github.com/go-sqlite/sqlite3) work for
it), but I guess the keychain parts
([keybase/go-keychain](http://github.com/keybase/go-keychain)) mess
that up.
it).

It also aspires to work for all three major browsers, on all three
major platforms. Naturally, half of that is TODOs.
It also aspires to work for all major browsers, on all three
major platforms.

## Status

[![No Maintenance Intended](http://unmaintained.tech/badge.svg)](http://unmaintained.tech/)

Basic functionality works, on MacOS. I expect Linux to work too, since
it doesn't encrypt. **The API is currently not expected to be at all
stable.**
Basic functionality works on Windows, MacOS and Linux.
Some functions might not yet be implemented on some platforms.
**The API is currently not expected to be at all stable.**

PRs more than welcome.

TODOs

- [ ] Make it work on Windows. (Look at
- [] Make it work on Windows. (Look at
[this](https://play.golang.org/p/fknP9AuLU-) and
[this](https://github.com/cfstras/chromecsv/blob/master/crypt_windows.go)
to learn how to decrypt.)
- [ ] Handle rows in Chrome's cookie DB with other than 14 columns (?)

## Example usage

### Chrome on macOS
### Any Browser - Cookie Filter Usage

```go
// import "github.com/zellyn/kooky/chrome"
package main

dir, _ := os.UserConfigDir() // "/<USER>/Library/Application Support/"
cookiesFile = dir + "/Google/Chrome/Default/Cookies"
cookies, err: = chrome.ReadCookies(cookiesFile)
if err != nil {
return err
}
for _, cookie := range cookies {
fmt.Println(cookie)
import (
"fmt"

"github.com/zellyn/kooky"
_ "github.com/zellyn/kooky/allbrowsers" // register cookie store finders!
)

func main() {
// uses registered finders to find cookie store files in default locations
// applies the passed filters "Valid", "DomainHasSuffix()" and "Name()" in order to the cookies
cookies := kooky.ReadCookies(kooky.Valid, kooky.DomainHasSuffix(`google.com`), kooky.Name(`NID`))

for _, cookie := range cookies {
fmt.Println(cookie.Domain, cookie.Name, cookie.Value)
}
}
```

### Chrome on macOS

```go
package main

import (
"fmt"
"log"
"os"

"github.com/zellyn/kooky/chrome"
)

func main() {
dir, _ := os.UserConfigDir() // "/<USER>/Library/Application Support/"
cookiesFile := dir + "/Google/Chrome/Default/Cookies"
cookies, err := chrome.ReadCookies(cookiesFile)
if err != nil {
log.Fatal(err)
}
for _, cookie := range cookies {
fmt.Println(cookie)
}
}
```

### Safari

```go
// import "github.com/zellyn/kooky/safari"

dir, _ := os.UserHomeDir()
cookiesFile = dir + "/Library/Cookies/Cookies.binarycookies"
cookies, err: = safari.ReadCookies(cookiesFile)
if err != nil {
return err
}
for _, cookie := range cookies {
fmt.Println(cookie)
package main

import (
"fmt"
"log"
"os"

"github.com/zellyn/kooky/safari"
)

func main() {
dir, _ := os.UserHomeDir()
cookiesFile := dir + "/Library/Cookies/Cookies.binarycookies"
cookies, err := safari.ReadCookies(cookiesFile)
if err != nil {
log.Fatal(err)
}
for _, cookie := range cookies {
fmt.Println(cookie)
}
}
```

Expand Down
22 changes: 22 additions & 0 deletions auto_example_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kooky_test

import (
"fmt"

"github.com/zellyn/kooky"
_ "github.com/zellyn/kooky/allbrowsers" // This registers all cookiestore finders!
// _ "github.com/zellyn/kooky/chrome" // load only the chrome cookiestore finder
)

func ExampleReadCookies_all() {
// try to find cookie stores in default locations and
// read the cookies from them.
// decryption is handled automatically.
cookies := kooky.ReadCookies()

for _, cookie := range cookies {
fmt.Println(cookie)
}
}

var _ struct{} // ignore this - for correct working of the documentation tool
212 changes: 44 additions & 168 deletions chrome/chrome.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,189 +2,65 @@ package chrome

import (
"errors"
"fmt"
"time"

"github.com/go-sqlite/sqlite3"

"github.com/zellyn/kooky"
"github.com/zellyn/kooky/internal/chrome"
)

func ReadCookies(filename string, filters ...kooky.Filter) ([]*kooky.Cookie, error) {
var cookies []*kooky.Cookie
db, err := sqlite3.Open(filename)
if err != nil {
return nil, err
s := &chromeCookieStore{
filename: filename,
browser: `chrome|chromium`, // TODO
}
defer db.Close()
defer s.Close()

/*
var version int
if err := db.VisitTableRecords("meta", func(rowId *int64, rec sqlite3.Record) error {
if len(rec.Values) != 2 {
return errors.New(`expected 2 columns for "meta" table`)
}
if key, ok := rec.Values[0].(string); ok && key == `version` {
if vStr, ok := rec.Values[1].(string); ok {
if v, err := strconv.Atoi(vStr); err == nil {
version = v
}
}
}
return nil
}); err != nil {
return nil, err
}
*/
return s.ReadCookies(filters...)
}

var columnIDs = map[string]int{
// fallback values
`host_key`: 1, // domain
`name`: 2,
`value`: 3,
`path`: 4,
`expires_utc`: 5,
`is_secure`: 6,
`is_httponly`: 7,
`encrypted_value`: 12,
func (s *chromeCookieStore) ReadCookies(filters ...kooky.Filter) ([]*kooky.Cookie, error) {
if s == nil {
return nil, errors.New(`cookie store is nil`)
}
cookiesTableName := `cookies`
var highestIndex int
for _, table := range db.Tables() {
if table.Name() == cookiesTableName {
for id, column := range table.Columns() {
name := column.Name()
if name == `CONSTRAINT` {
// github.com/go-sqlite/sqlite3.Table.Columns() might report pseudo-columns at the end
break
}
if id > highestIndex {
highestIndex = id
}
columnIDs[name] = id
}
}
if err := s.open(); err != nil {
return nil, err
} else if s.database == nil {
return nil, errors.New(`database is nil`)
}

err = db.VisitTableRecords(cookiesTableName, func(rowId *int64, rec sqlite3.Record) error {
if rowId == nil {
return errors.New(`unexpected nil RowID in Chrome sqlite database`)
}

// TODO(zellyn): handle older, shorter rows?
if lRec := len(rec.Values); lRec < 14 {
return fmt.Errorf("expected at least 14 columns in cookie file, got: %d", lRec)
} else if highestIndex > lRec {
return errors.New(`column index out of bound`)
}

cookie := &kooky.Cookie{}

/*
-- taken from chrome 80's cookies' sqlite_master
CREATE TABLE cookies(
creation_utc INTEGER NOT NULL,
host_key TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
path TEXT NOT NULL,
expires_utc INTEGER NOT NULL,
is_secure INTEGER NOT NULL,
is_httponly INTEGER NOT NULL,
last_access_utc INTEGER NOT NULL,
has_expires INTEGER NOT NULL DEFAULT 1,
is_persistent INTEGER NOT NULL DEFAULT 1,
priority INTEGER NOT NULL DEFAULT 1,
encrypted_value BLOB DEFAULT '',
samesite INTEGER NOT NULL DEFAULT -1,
source_scheme INTEGER NOT NULL DEFAULT 0,
UNIQUE (host_key, name, path)
)
*/

domain, ok := rec.Values[columnIDs[`host_key`]].(string)
if !ok {
return fmt.Errorf("expected column 2 (host_key) to to be string; got %T", rec.Values[columnIDs[`host_key`]])
}
name, ok := rec.Values[columnIDs[`name`]].(string)
if !ok {
return fmt.Errorf("expected column 3 (name) in cookie(domain:%s) to to be string; got %T", domain, rec.Values[columnIDs[`name`]])
}
value, ok := rec.Values[columnIDs[`value`]].(string)
if !ok {
return fmt.Errorf("expected column 4 (value) in cookie(domain:%s, name:%s) to to be string; got %T", domain, name, rec.Values[columnIDs[`value`]])
}
path, ok := rec.Values[columnIDs[`path`]].(string)
if !ok {
return fmt.Errorf("expected column 5 (path) in cookie(domain:%s, name:%s) to to be string; got %T", domain, name, rec.Values[columnIDs[`path`]])
}
var expires_utc int64
switch i := rec.Values[columnIDs[`expires_utc`]].(type) {
case int64:
expires_utc = i
case int:
if i != 0 {
return fmt.Errorf("expected column 6 (expires_utc) in cookie(domain:%s, name:%s) to to be int64 or int with value=0; got %T with value %[3]v", domain, name, rec.Values[columnIDs[`expires_utc`]])
}
default:
return fmt.Errorf("expected column 6 (expires_utc) in cookie(domain:%s, name:%s) to to be int64 or int with value=0; got %T with value %[3]v", domain, name, rec.Values[columnIDs[`expires_utc`]])
}
encrypted_value, ok := rec.Values[columnIDs[`encrypted_value`]].([]byte)
if !ok {
return fmt.Errorf("expected column 13 (encrypted_value) in cookie(domain:%s, name:%s) to to be []byte; got %T", domain, name, rec.Values[columnIDs[`encrypted_value`]])
}

var expiry time.Time
if expires_utc != 0 {
expiry = chromeCookieDate(expires_utc)
}
creation := chromeCookieDate(*rowId)

cookie.Domain = domain
cookie.Name = name
cookie.Path = path
cookie.Expires = expiry
cookie.Creation = creation
cookie.Secure = rec.Values[columnIDs[`is_secure`]] == 1
cookie.HttpOnly = rec.Values[columnIDs[`is_httponly`]] == 1

if len(encrypted_value) > 0 {
dbFile = filename
decrypted, err := decryptValue(encrypted_value)
if err != nil {
return fmt.Errorf("decrypting cookie %v: %w", cookie, err)
}
cookie.Value = decrypted
} else {
cookie.Value = value
}

if !kooky.FilterCookie(cookie, filters...) {
return nil
}
cookieStore := &chrome.CookieStore{
Filename: s.filename,
Database: s.database,
KeyringPassword: s.keyringPassword,
Password: s.password,
OS: s.os,
Browser: s.browser,
Profile: s.profile,
IsDefaultProfile: s.isDefaultProfile,
DecryptionMethod: s.decryptionMethod,
}

cookies = append(cookies, cookie)
cookies, err := cookieStore.ReadCookies(filters...)

return nil
})
if err != nil {
return nil, err
}
s.filename = cookieStore.Filename
s.database = cookieStore.Database
s.keyringPassword = cookieStore.KeyringPassword
s.password = cookieStore.Password
s.os = cookieStore.OS
s.browser = cookieStore.Browser
s.profile = cookieStore.Profile
s.isDefaultProfile = cookieStore.IsDefaultProfile
s.decryptionMethod = cookieStore.DecryptionMethod

return cookies, nil
return cookies, err
}

// See https://cs.chromium.org/chromium/src/base/time/time.h?l=452&rcl=fceb9a030c182e939a436a540e6dacc70f161cb1
const windowsToUnixMicrosecondsOffset = 116444736e8

// chromeCookieDate converts microseconds to a time.Time object,
// accounting for the switch to Windows epoch (Jan 1 1601).
func chromeCookieDate(timestamp_utc int64) time.Time {
if timestamp_utc > windowsToUnixMicrosecondsOffset {
timestamp_utc -= windowsToUnixMicrosecondsOffset
// returns the previous password for later restoration
// used in tests
func (s *chromeCookieStore) setKeyringPassword(password []byte) []byte {
if s == nil {
return nil
}

return time.Unix(timestamp_utc/1e6, (timestamp_utc%1e6)*1e3)
oldPassword := s.keyringPassword
s.keyringPassword = password
return oldPassword
}

var dbFile string // TODO (?)
Loading

0 comments on commit 9fada5d

Please sign in to comment.