Skip to content

Commit 7bf1759

Browse files
authored
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
1 parent 509cdc2 commit 7bf1759

7 files changed

Lines changed: 284 additions & 4 deletions

File tree

browser/safari/extract_cookie.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package safari
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"sort"
7+
8+
"github.com/moond4rk/binarycookies"
9+
10+
"github.com/moond4rk/hackbrowserdata/types"
11+
)
12+
13+
func extractCookies(path string) ([]types.CookieEntry, error) {
14+
pages, err := decodeBinaryCookies(path)
15+
if err != nil {
16+
return nil, err
17+
}
18+
19+
var cookies []types.CookieEntry
20+
for _, page := range pages {
21+
for _, c := range page.Cookies {
22+
hasExpire := !c.Expires.IsZero()
23+
cookies = append(cookies, types.CookieEntry{
24+
Host: string(c.Domain),
25+
Path: string(c.Path),
26+
Name: string(c.Name),
27+
Value: string(c.Value),
28+
IsSecure: c.Secure,
29+
IsHTTPOnly: c.HTTPOnly,
30+
HasExpire: hasExpire,
31+
IsPersistent: hasExpire,
32+
ExpireAt: c.Expires,
33+
CreatedAt: c.Creation,
34+
})
35+
}
36+
}
37+
38+
sort.Slice(cookies, func(i, j int) bool {
39+
return cookies[i].CreatedAt.After(cookies[j].CreatedAt)
40+
})
41+
return cookies, nil
42+
}
43+
44+
func countCookies(path string) (int, error) {
45+
pages, err := decodeBinaryCookies(path)
46+
if err != nil {
47+
return 0, err
48+
}
49+
var total int
50+
for _, page := range pages {
51+
total += len(page.Cookies)
52+
}
53+
return total, nil
54+
}
55+
56+
func decodeBinaryCookies(path string) ([]binarycookies.Page, error) {
57+
f, err := os.Open(path)
58+
if err != nil {
59+
return nil, fmt.Errorf("open cookies file: %w", err)
60+
}
61+
defer f.Close()
62+
63+
jar := binarycookies.New(f)
64+
pages, err := jar.Decode()
65+
if err != nil {
66+
return nil, fmt.Errorf("decode cookies: %w", err)
67+
}
68+
return pages, nil
69+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package safari
2+
3+
import (
4+
"encoding/binary"
5+
"math"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// buildTestBinaryCookies constructs a minimal valid Cookies.binarycookies file
15+
// containing the given cookies. Each cookie is placed in its own page.
16+
func buildTestBinaryCookies(t *testing.T, cookies []testCookie) string {
17+
t.Helper()
18+
19+
var pages [][]byte
20+
for _, c := range cookies {
21+
pages = append(pages, buildPage(c))
22+
}
23+
24+
path := filepath.Join(t.TempDir(), "Cookies.binarycookies")
25+
f, err := os.Create(path)
26+
require.NoError(t, err)
27+
defer f.Close()
28+
29+
// File header: magic + numPages (big-endian)
30+
_, err = f.WriteString("cook")
31+
require.NoError(t, err)
32+
require.NoError(t, binary.Write(f, binary.BigEndian, uint32(len(pages))))
33+
34+
// Page sizes (big-endian)
35+
for _, p := range pages {
36+
require.NoError(t, binary.Write(f, binary.BigEndian, uint32(len(p))))
37+
}
38+
39+
// Page data
40+
for _, p := range pages {
41+
_, err = f.Write(p)
42+
require.NoError(t, err)
43+
}
44+
45+
// Checksum (8 bytes, not validated by decoder)
46+
_, err = f.Write(make([]byte, 8))
47+
require.NoError(t, err)
48+
49+
return path
50+
}
51+
52+
type testCookie struct {
53+
domain, name, path, value string
54+
secure, httpOnly bool
55+
expires, creation float64 // Core Data epoch seconds
56+
}
57+
58+
func buildPage(c testCookie) []byte {
59+
// Cookie string data: domain\0 name\0 path\0 value\0
60+
domain := c.domain + "\x00"
61+
name := c.name + "\x00"
62+
cpath := c.path + "\x00"
63+
value := c.value + "\x00"
64+
65+
// Cookie binary layout (all offsets are from cookie start):
66+
// size(4) + unknown1(4) + flags(4) + unknown2(4)
67+
// + domainOff(4) + nameOff(4) + pathOff(4) + valueOff(4) + commentOff(4)
68+
// + endHeader(4) + expires(8) + creation(8)
69+
// = 56 bytes header, then string data
70+
const headerSize = 56
71+
domainOffset := uint32(headerSize)
72+
nameOffset := domainOffset + uint32(len(domain))
73+
pathOffset := nameOffset + uint32(len(name))
74+
valueOffset := pathOffset + uint32(len(cpath))
75+
cookieSize := valueOffset + uint32(len(value))
76+
77+
var flags uint32
78+
switch {
79+
case c.secure && c.httpOnly:
80+
flags = 0x5
81+
case c.httpOnly:
82+
flags = 0x4
83+
case c.secure:
84+
flags = 0x1
85+
}
86+
87+
// Build cookie bytes (little-endian)
88+
cookie := make([]byte, cookieSize)
89+
binary.LittleEndian.PutUint32(cookie[0:], cookieSize)
90+
// cookie[4:8] = unknown1 (zero)
91+
binary.LittleEndian.PutUint32(cookie[8:], flags)
92+
// cookie[12:16] = unknown2 (zero)
93+
binary.LittleEndian.PutUint32(cookie[16:], domainOffset)
94+
binary.LittleEndian.PutUint32(cookie[20:], nameOffset)
95+
binary.LittleEndian.PutUint32(cookie[24:], pathOffset)
96+
binary.LittleEndian.PutUint32(cookie[28:], valueOffset)
97+
// cookie[32:36] = commentOffset (zero = no comment)
98+
// cookie[36:40] = endHeader marker (zero)
99+
binary.LittleEndian.PutUint64(cookie[40:], math.Float64bits(c.expires))
100+
binary.LittleEndian.PutUint64(cookie[48:], math.Float64bits(c.creation))
101+
copy(cookie[domainOffset:], domain)
102+
copy(cookie[nameOffset:], name)
103+
copy(cookie[pathOffset:], cpath)
104+
copy(cookie[valueOffset:], value)
105+
106+
// Page layout: marker(4) + cookieCount(4) + offsets(4*N) + endMarker(4) + cookies
107+
const pageHeaderSize = 16 // marker + count + 1 offset + end marker
108+
page := make([]byte, pageHeaderSize+len(cookie))
109+
copy(page[0:4], []byte{0x00, 0x00, 0x01, 0x00}) // page start marker
110+
binary.LittleEndian.PutUint32(page[4:], 1) // 1 cookie
111+
binary.LittleEndian.PutUint32(page[8:], pageHeaderSize)
112+
// page[12:16] = page end marker (zero)
113+
copy(page[pageHeaderSize:], cookie)
114+
115+
return page
116+
}
117+
118+
func TestExtractCookies(t *testing.T) {
119+
path := buildTestBinaryCookies(t, []testCookie{
120+
{
121+
domain: ".example.com", name: "session", path: "/", value: "abc123",
122+
secure: true, httpOnly: true,
123+
expires: 2000000000.0, creation: 700000000.0,
124+
},
125+
{
126+
domain: ".go.dev", name: "lang", path: "/", value: "en",
127+
secure: false, httpOnly: false,
128+
expires: 2000000000.0, creation: 750000000.0,
129+
},
130+
})
131+
132+
cookies, err := extractCookies(path)
133+
require.NoError(t, err)
134+
require.Len(t, cookies, 2)
135+
136+
// Sorted by CreatedAt descending (newest first)
137+
assert.Equal(t, ".go.dev", cookies[0].Host)
138+
assert.Equal(t, ".example.com", cookies[1].Host)
139+
140+
// Verify field mapping
141+
c := cookies[1] // .example.com cookie
142+
assert.Equal(t, "session", c.Name)
143+
assert.Equal(t, "abc123", c.Value)
144+
assert.Equal(t, "/", c.Path)
145+
assert.True(t, c.IsSecure)
146+
assert.True(t, c.IsHTTPOnly)
147+
assert.False(t, c.CreatedAt.IsZero())
148+
assert.False(t, c.ExpireAt.IsZero())
149+
}
150+
151+
func TestCountCookies(t *testing.T) {
152+
path := buildTestBinaryCookies(t, []testCookie{
153+
{domain: ".a.com", name: "a", path: "/", value: "1", expires: 2000000000.0, creation: 700000000.0},
154+
{domain: ".b.com", name: "b", path: "/", value: "2", expires: 2000000000.0, creation: 700000000.0},
155+
{domain: ".c.com", name: "c", path: "/", value: "3", expires: 2000000000.0, creation: 700000000.0},
156+
})
157+
158+
count, err := countCookies(path)
159+
require.NoError(t, err)
160+
assert.Equal(t, 3, count)
161+
}
162+
163+
func TestExtractCookies_InvalidFile(t *testing.T) {
164+
path := filepath.Join(t.TempDir(), "bad.binarycookies")
165+
require.NoError(t, os.WriteFile(path, []byte("not a cookies file"), 0o644))
166+
167+
_, err := extractCookies(path)
168+
assert.Error(t, err)
169+
}
170+
171+
func TestExtractCookies_FileNotFound(t *testing.T) {
172+
_, err := extractCookies("/nonexistent/Cookies.binarycookies")
173+
assert.Error(t, err)
174+
}

browser/safari/safari.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, p
108108
switch cat {
109109
case types.History:
110110
data.Histories, err = extractHistories(path)
111+
case types.Cookie:
112+
data.Cookies, err = extractCookies(path)
111113
default:
112114
return
113115
}
@@ -123,6 +125,8 @@ func (b *Browser) countCategory(cat types.Category, path string) int {
123125
switch cat {
124126
case types.History:
125127
count, err = countHistories(path)
128+
case types.Cookie:
129+
count, err = countCookies(path)
126130
default:
127131
// Unsupported categories silently return 0.
128132
}

browser/safari/safari_test.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,17 @@ func TestCountCategory(t *testing.T) {
133133
assert.Equal(t, 1, b.countCategory(types.History, path))
134134
})
135135

136+
t.Run("Cookie", func(t *testing.T) {
137+
path := buildTestBinaryCookies(t, []testCookie{
138+
{domain: ".example.com", name: "a", path: "/", value: "1", expires: 2000000000.0, creation: 700000000.0},
139+
{domain: ".go.dev", name: "b", path: "/", value: "2", expires: 2000000000.0, creation: 700000000.0},
140+
})
141+
b := &Browser{}
142+
assert.Equal(t, 2, b.countCategory(types.Cookie, path))
143+
})
144+
136145
t.Run("UnsupportedCategory", func(t *testing.T) {
137146
b := &Browser{}
138-
assert.Equal(t, 0, b.countCategory(types.Cookie, "unused"))
139147
assert.Equal(t, 0, b.countCategory(types.CreditCard, "unused"))
140148
assert.Equal(t, 0, b.countCategory(types.SessionStorage, "unused"))
141149
})
@@ -160,12 +168,28 @@ func TestExtractCategory(t *testing.T) {
160168
assert.Equal(t, 1, data.Histories[1].VisitCount)
161169
})
162170

171+
t.Run("Cookie", func(t *testing.T) {
172+
path := buildTestBinaryCookies(t, []testCookie{
173+
{
174+
domain: ".example.com", name: "session", path: "/", value: "abc",
175+
secure: true, httpOnly: true, expires: 2000000000.0, creation: 700000000.0,
176+
},
177+
})
178+
b := &Browser{}
179+
data := &types.BrowserData{}
180+
b.extractCategory(data, types.Cookie, path)
181+
182+
require.Len(t, data.Cookies, 1)
183+
assert.Equal(t, ".example.com", data.Cookies[0].Host)
184+
assert.Equal(t, "session", data.Cookies[0].Name)
185+
assert.True(t, data.Cookies[0].IsSecure)
186+
assert.True(t, data.Cookies[0].IsHTTPOnly)
187+
})
188+
163189
t.Run("UnsupportedCategory", func(t *testing.T) {
164190
b := &Browser{}
165191
data := &types.BrowserData{}
166-
b.extractCategory(data, types.Cookie, "unused")
167192
b.extractCategory(data, types.CreditCard, "unused")
168-
assert.Empty(t, data.Cookies)
169193
assert.Empty(t, data.CreditCards)
170194
})
171195
}

browser/safari/source.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,17 @@ type sourcePath struct {
1313
isDir bool // true for directory targets
1414
}
1515

16-
func file(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel), isDir: false} }
16+
func file(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel)} }
1717

1818
// safariSources defines the Safari file layout.
1919
// Each category maps to one or more candidate paths tried in priority order;
2020
// the first existing path wins.
2121
var safariSources = map[types.Category][]sourcePath{
2222
types.History: {file("History.db")},
23+
types.Cookie: {
24+
// macOS 14+ (containerized Safari)
25+
file("../Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies"),
26+
// macOS ≤13 (traditional path)
27+
file("../Cookies/Cookies.binarycookies"),
28+
},
2329
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.20
44

55
require (
66
github.com/godbus/dbus/v5 v5.2.2
7+
github.com/moond4rk/binarycookies v1.0.2
78
github.com/moond4rk/keychainbreaker v0.2.5
89
github.com/otiai10/copy v1.14.1
910
github.com/ppacher/go-dbus-keyring v1.0.1

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
2121
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
2222
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
2323
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
24+
github.com/moond4rk/binarycookies v1.0.2 h1:moXSHYOj/V4Z1vEsEjHjdNML+4JcflOpxx53sHio0cA=
25+
github.com/moond4rk/binarycookies v1.0.2/go.mod h1:iAJr8L7ZgzTKaZhzEZmzjuOFnAoIHaqHFzvn58h1b7c=
2426
github.com/moond4rk/keychainbreaker v0.2.5 h1:1f2qmgpt1sl+mXA8DTW9nnVhzo4oGO08bnkXu70DL04=
2527
github.com/moond4rk/keychainbreaker v0.2.5/go.mod h1:VVx2VXwL2EGhuU2WBD67w66JCKKqLFXGJg91y3FY4f0=
2628
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=

0 commit comments

Comments
 (0)