From 7bf1759dd9e769e92686f50f0437dadc9495440a Mon Sep 17 00:00:00 2001 From: Roger Date: Sun, 12 Apr 2026 01:16:59 +0800 Subject: [PATCH] feat: add Safari cookie extraction from BinaryCookies format (#566) * feat: add Safari cookie extraction from BinaryCookies format * fix: use expiry presence instead of current time for HasExpire --- browser/safari/extract_cookie.go | 69 ++++++++++ browser/safari/extract_cookie_test.go | 174 ++++++++++++++++++++++++++ browser/safari/safari.go | 4 + browser/safari/safari_test.go | 30 ++++- browser/safari/source.go | 8 +- go.mod | 1 + go.sum | 2 + 7 files changed, 284 insertions(+), 4 deletions(-) create mode 100644 browser/safari/extract_cookie.go create mode 100644 browser/safari/extract_cookie_test.go diff --git a/browser/safari/extract_cookie.go b/browser/safari/extract_cookie.go new file mode 100644 index 0000000..2ccfb62 --- /dev/null +++ b/browser/safari/extract_cookie.go @@ -0,0 +1,69 @@ +package safari + +import ( + "fmt" + "os" + "sort" + + "github.com/moond4rk/binarycookies" + + "github.com/moond4rk/hackbrowserdata/types" +) + +func extractCookies(path string) ([]types.CookieEntry, error) { + pages, err := decodeBinaryCookies(path) + if err != nil { + return nil, err + } + + var cookies []types.CookieEntry + for _, page := range pages { + for _, c := range page.Cookies { + hasExpire := !c.Expires.IsZero() + cookies = append(cookies, types.CookieEntry{ + Host: string(c.Domain), + Path: string(c.Path), + Name: string(c.Name), + Value: string(c.Value), + IsSecure: c.Secure, + IsHTTPOnly: c.HTTPOnly, + HasExpire: hasExpire, + IsPersistent: hasExpire, + ExpireAt: c.Expires, + CreatedAt: c.Creation, + }) + } + } + + sort.Slice(cookies, func(i, j int) bool { + return cookies[i].CreatedAt.After(cookies[j].CreatedAt) + }) + return cookies, nil +} + +func countCookies(path string) (int, error) { + pages, err := decodeBinaryCookies(path) + if err != nil { + return 0, err + } + var total int + for _, page := range pages { + total += len(page.Cookies) + } + return total, nil +} + +func decodeBinaryCookies(path string) ([]binarycookies.Page, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open cookies file: %w", err) + } + defer f.Close() + + jar := binarycookies.New(f) + pages, err := jar.Decode() + if err != nil { + return nil, fmt.Errorf("decode cookies: %w", err) + } + return pages, nil +} diff --git a/browser/safari/extract_cookie_test.go b/browser/safari/extract_cookie_test.go new file mode 100644 index 0000000..8315752 --- /dev/null +++ b/browser/safari/extract_cookie_test.go @@ -0,0 +1,174 @@ +package safari + +import ( + "encoding/binary" + "math" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildTestBinaryCookies constructs a minimal valid Cookies.binarycookies file +// containing the given cookies. Each cookie is placed in its own page. +func buildTestBinaryCookies(t *testing.T, cookies []testCookie) string { + t.Helper() + + var pages [][]byte + for _, c := range cookies { + pages = append(pages, buildPage(c)) + } + + path := filepath.Join(t.TempDir(), "Cookies.binarycookies") + f, err := os.Create(path) + require.NoError(t, err) + defer f.Close() + + // File header: magic + numPages (big-endian) + _, err = f.WriteString("cook") + require.NoError(t, err) + require.NoError(t, binary.Write(f, binary.BigEndian, uint32(len(pages)))) + + // Page sizes (big-endian) + for _, p := range pages { + require.NoError(t, binary.Write(f, binary.BigEndian, uint32(len(p)))) + } + + // Page data + for _, p := range pages { + _, err = f.Write(p) + require.NoError(t, err) + } + + // Checksum (8 bytes, not validated by decoder) + _, err = f.Write(make([]byte, 8)) + require.NoError(t, err) + + return path +} + +type testCookie struct { + domain, name, path, value string + secure, httpOnly bool + expires, creation float64 // Core Data epoch seconds +} + +func buildPage(c testCookie) []byte { + // Cookie string data: domain\0 name\0 path\0 value\0 + domain := c.domain + "\x00" + name := c.name + "\x00" + cpath := c.path + "\x00" + value := c.value + "\x00" + + // Cookie binary layout (all offsets are from cookie start): + // size(4) + unknown1(4) + flags(4) + unknown2(4) + // + domainOff(4) + nameOff(4) + pathOff(4) + valueOff(4) + commentOff(4) + // + endHeader(4) + expires(8) + creation(8) + // = 56 bytes header, then string data + const headerSize = 56 + domainOffset := uint32(headerSize) + nameOffset := domainOffset + uint32(len(domain)) + pathOffset := nameOffset + uint32(len(name)) + valueOffset := pathOffset + uint32(len(cpath)) + cookieSize := valueOffset + uint32(len(value)) + + var flags uint32 + switch { + case c.secure && c.httpOnly: + flags = 0x5 + case c.httpOnly: + flags = 0x4 + case c.secure: + flags = 0x1 + } + + // Build cookie bytes (little-endian) + cookie := make([]byte, cookieSize) + binary.LittleEndian.PutUint32(cookie[0:], cookieSize) + // cookie[4:8] = unknown1 (zero) + binary.LittleEndian.PutUint32(cookie[8:], flags) + // cookie[12:16] = unknown2 (zero) + binary.LittleEndian.PutUint32(cookie[16:], domainOffset) + binary.LittleEndian.PutUint32(cookie[20:], nameOffset) + binary.LittleEndian.PutUint32(cookie[24:], pathOffset) + binary.LittleEndian.PutUint32(cookie[28:], valueOffset) + // cookie[32:36] = commentOffset (zero = no comment) + // cookie[36:40] = endHeader marker (zero) + binary.LittleEndian.PutUint64(cookie[40:], math.Float64bits(c.expires)) + binary.LittleEndian.PutUint64(cookie[48:], math.Float64bits(c.creation)) + copy(cookie[domainOffset:], domain) + copy(cookie[nameOffset:], name) + copy(cookie[pathOffset:], cpath) + copy(cookie[valueOffset:], value) + + // Page layout: marker(4) + cookieCount(4) + offsets(4*N) + endMarker(4) + cookies + const pageHeaderSize = 16 // marker + count + 1 offset + end marker + page := make([]byte, pageHeaderSize+len(cookie)) + copy(page[0:4], []byte{0x00, 0x00, 0x01, 0x00}) // page start marker + binary.LittleEndian.PutUint32(page[4:], 1) // 1 cookie + binary.LittleEndian.PutUint32(page[8:], pageHeaderSize) + // page[12:16] = page end marker (zero) + copy(page[pageHeaderSize:], cookie) + + return page +} + +func TestExtractCookies(t *testing.T) { + path := buildTestBinaryCookies(t, []testCookie{ + { + domain: ".example.com", name: "session", path: "/", value: "abc123", + secure: true, httpOnly: true, + expires: 2000000000.0, creation: 700000000.0, + }, + { + domain: ".go.dev", name: "lang", path: "/", value: "en", + secure: false, httpOnly: false, + expires: 2000000000.0, creation: 750000000.0, + }, + }) + + cookies, err := extractCookies(path) + require.NoError(t, err) + require.Len(t, cookies, 2) + + // Sorted by CreatedAt descending (newest first) + assert.Equal(t, ".go.dev", cookies[0].Host) + assert.Equal(t, ".example.com", cookies[1].Host) + + // Verify field mapping + c := cookies[1] // .example.com cookie + assert.Equal(t, "session", c.Name) + assert.Equal(t, "abc123", c.Value) + assert.Equal(t, "/", c.Path) + assert.True(t, c.IsSecure) + assert.True(t, c.IsHTTPOnly) + assert.False(t, c.CreatedAt.IsZero()) + assert.False(t, c.ExpireAt.IsZero()) +} + +func TestCountCookies(t *testing.T) { + path := buildTestBinaryCookies(t, []testCookie{ + {domain: ".a.com", name: "a", path: "/", value: "1", expires: 2000000000.0, creation: 700000000.0}, + {domain: ".b.com", name: "b", path: "/", value: "2", expires: 2000000000.0, creation: 700000000.0}, + {domain: ".c.com", name: "c", path: "/", value: "3", expires: 2000000000.0, creation: 700000000.0}, + }) + + count, err := countCookies(path) + require.NoError(t, err) + assert.Equal(t, 3, count) +} + +func TestExtractCookies_InvalidFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "bad.binarycookies") + require.NoError(t, os.WriteFile(path, []byte("not a cookies file"), 0o644)) + + _, err := extractCookies(path) + assert.Error(t, err) +} + +func TestExtractCookies_FileNotFound(t *testing.T) { + _, err := extractCookies("/nonexistent/Cookies.binarycookies") + assert.Error(t, err) +} diff --git a/browser/safari/safari.go b/browser/safari/safari.go index 22a0a20..a35e9f5 100644 --- a/browser/safari/safari.go +++ b/browser/safari/safari.go @@ -108,6 +108,8 @@ func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, p switch cat { case types.History: data.Histories, err = extractHistories(path) + case types.Cookie: + data.Cookies, err = extractCookies(path) default: return } @@ -123,6 +125,8 @@ func (b *Browser) countCategory(cat types.Category, path string) int { switch cat { case types.History: count, err = countHistories(path) + case types.Cookie: + count, err = countCookies(path) default: // Unsupported categories silently return 0. } diff --git a/browser/safari/safari_test.go b/browser/safari/safari_test.go index a4ec7d7..9ecd553 100644 --- a/browser/safari/safari_test.go +++ b/browser/safari/safari_test.go @@ -133,9 +133,17 @@ func TestCountCategory(t *testing.T) { assert.Equal(t, 1, b.countCategory(types.History, path)) }) + t.Run("Cookie", func(t *testing.T) { + path := buildTestBinaryCookies(t, []testCookie{ + {domain: ".example.com", name: "a", path: "/", value: "1", expires: 2000000000.0, creation: 700000000.0}, + {domain: ".go.dev", name: "b", path: "/", value: "2", expires: 2000000000.0, creation: 700000000.0}, + }) + b := &Browser{} + assert.Equal(t, 2, b.countCategory(types.Cookie, path)) + }) + t.Run("UnsupportedCategory", func(t *testing.T) { b := &Browser{} - assert.Equal(t, 0, b.countCategory(types.Cookie, "unused")) assert.Equal(t, 0, b.countCategory(types.CreditCard, "unused")) assert.Equal(t, 0, b.countCategory(types.SessionStorage, "unused")) }) @@ -160,12 +168,28 @@ func TestExtractCategory(t *testing.T) { assert.Equal(t, 1, data.Histories[1].VisitCount) }) + t.Run("Cookie", func(t *testing.T) { + path := buildTestBinaryCookies(t, []testCookie{ + { + domain: ".example.com", name: "session", path: "/", value: "abc", + secure: true, httpOnly: true, expires: 2000000000.0, creation: 700000000.0, + }, + }) + b := &Browser{} + data := &types.BrowserData{} + b.extractCategory(data, types.Cookie, path) + + require.Len(t, data.Cookies, 1) + assert.Equal(t, ".example.com", data.Cookies[0].Host) + assert.Equal(t, "session", data.Cookies[0].Name) + assert.True(t, data.Cookies[0].IsSecure) + assert.True(t, data.Cookies[0].IsHTTPOnly) + }) + t.Run("UnsupportedCategory", func(t *testing.T) { b := &Browser{} data := &types.BrowserData{} - b.extractCategory(data, types.Cookie, "unused") b.extractCategory(data, types.CreditCard, "unused") - assert.Empty(t, data.Cookies) assert.Empty(t, data.CreditCards) }) } diff --git a/browser/safari/source.go b/browser/safari/source.go index b903e5b..6548f43 100644 --- a/browser/safari/source.go +++ b/browser/safari/source.go @@ -13,11 +13,17 @@ type sourcePath struct { isDir bool // true for directory targets } -func file(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel), isDir: false} } +func file(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel)} } // safariSources defines the Safari file layout. // Each category maps to one or more candidate paths tried in priority order; // the first existing path wins. var safariSources = map[types.Category][]sourcePath{ types.History: {file("History.db")}, + types.Cookie: { + // macOS 14+ (containerized Safari) + file("../Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies"), + // macOS ≤13 (traditional path) + file("../Cookies/Cookies.binarycookies"), + }, } diff --git a/go.mod b/go.mod index 5ae978f..1b8154e 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( github.com/godbus/dbus/v5 v5.2.2 + github.com/moond4rk/binarycookies v1.0.2 github.com/moond4rk/keychainbreaker v0.2.5 github.com/otiai10/copy v1.14.1 github.com/ppacher/go-dbus-keyring v1.0.1 diff --git a/go.sum b/go.sum index d37d7a9..5d9d65a 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moond4rk/binarycookies v1.0.2 h1:moXSHYOj/V4Z1vEsEjHjdNML+4JcflOpxx53sHio0cA= +github.com/moond4rk/binarycookies v1.0.2/go.mod h1:iAJr8L7ZgzTKaZhzEZmzjuOFnAoIHaqHFzvn58h1b7c= github.com/moond4rk/keychainbreaker v0.2.5 h1:1f2qmgpt1sl+mXA8DTW9nnVhzo4oGO08bnkXu70DL04= github.com/moond4rk/keychainbreaker v0.2.5/go.mod h1:VVx2VXwL2EGhuU2WBD67w66JCKKqLFXGJg91y3FY4f0= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=