browser-cookie-bridge 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,54 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { appSupportDir, installedAppPath, projectRoot } from "./paths.js";
6
+
7
+ export function installApp({ home = os.homedir(), open = true } = {}) {
8
+ if (process.platform !== "darwin") throw new Error("The desktop app supports macOS only.");
9
+
10
+ const packagePath = path.join(projectRoot(), "macos-app");
11
+ run("swift", ["build", "-c", "release", "--package-path", packagePath]);
12
+ const binPath = run("swift", [
13
+ "build",
14
+ "-c",
15
+ "release",
16
+ "--package-path",
17
+ packagePath,
18
+ "--show-bin-path",
19
+ ]).trim();
20
+
21
+ const staging = path.join(appSupportDir(home), "app-build", "Browser Cookie Bridge.app");
22
+ const contents = path.join(staging, "Contents");
23
+ const macos = path.join(contents, "MacOS");
24
+ const resources = path.join(contents, "Resources");
25
+ fs.mkdirSync(macos, { recursive: true, mode: 0o700 });
26
+ fs.mkdirSync(resources, { recursive: true, mode: 0o700 });
27
+
28
+ fs.copyFileSync(path.join(binPath, "BraveCodexSyncApp"), path.join(macos, "BraveCodexSyncApp"));
29
+ fs.chmodSync(path.join(macos, "BraveCodexSyncApp"), 0o755);
30
+ fs.copyFileSync(path.join(packagePath, "Info.plist"), path.join(contents, "Info.plist"));
31
+ fs.cpSync(path.join(packagePath, "Resources"), resources, { recursive: true, force: true });
32
+
33
+ run("codesign", ["--force", "--deep", "--sign", "-", staging]);
34
+
35
+ const destination = installedAppPath(home);
36
+ fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o755 });
37
+ fs.rmSync(destination, { recursive: true, force: true });
38
+ fs.cpSync(staging, destination, { recursive: true, force: true });
39
+ for (const legacyName of ["Browser ChatGPT Sync.app", "Brave Codex Sync.app"]) {
40
+ const legacyDestination = path.join(home, "Applications", legacyName);
41
+ if (legacyDestination !== destination) fs.rmSync(legacyDestination, { recursive: true, force: true });
42
+ }
43
+ run("xattr", ["-dr", "com.apple.quarantine", destination], { allowFailure: true });
44
+ if (open) run("open", [destination]);
45
+ return destination;
46
+ }
47
+
48
+ function run(command, args, { allowFailure = false } = {}) {
49
+ const result = spawnSync(command, args, { encoding: "utf8" });
50
+ if (result.status !== 0 && !allowFailure) {
51
+ throw new Error(result.stderr.trim() || `${command} failed with exit code ${result.status}`);
52
+ }
53
+ return result.stdout || "";
54
+ }
package/src/broker.js ADDED
@@ -0,0 +1,248 @@
1
+ import crypto from "node:crypto";
2
+ import http from "node:http";
3
+ import { EXTENSION_ORIGIN } from "./paths.js";
4
+
5
+ const MAX_BODY_BYTES = 64 * 1024 * 1024;
6
+
7
+ export function createBroker({
8
+ token,
9
+ port,
10
+ timeoutMs = 300_000,
11
+ host = "127.0.0.1",
12
+ imports = { cookies: true, history: false },
13
+ sourceBrowser = "brave",
14
+ targetBrowser = "codex",
15
+ onEvent = () => {},
16
+ }) {
17
+ const state = {
18
+ runId: crypto.randomUUID(),
19
+ payload: null,
20
+ completed: false,
21
+ result: null,
22
+ sourceSeen: false,
23
+ targetSeen: false,
24
+ };
25
+
26
+ let resolveCompletion;
27
+ let rejectCompletion;
28
+ const completion = new Promise((resolve, reject) => {
29
+ resolveCompletion = resolve;
30
+ rejectCompletion = reject;
31
+ });
32
+
33
+ const server = http.createServer(async (request, response) => {
34
+ try {
35
+ setSecurityHeaders(response);
36
+
37
+ if (request.method === "OPTIONS") {
38
+ response.writeHead(204);
39
+ response.end();
40
+ return;
41
+ }
42
+
43
+ if (!authorized(request, token)) {
44
+ sendJson(response, 401, { error: "unauthorized" });
45
+ return;
46
+ }
47
+
48
+ const url = new URL(request.url, `http://${host}:${port}`);
49
+ if (request.method === "GET" && url.pathname === "/v1/status") {
50
+ recordPresence(request, state, sourceBrowser, targetBrowser);
51
+ sendJson(response, 200, {
52
+ runId: state.runId,
53
+ sourceNeeded: state.payload === null,
54
+ targetNeeded: state.payload !== null && !state.completed,
55
+ imports,
56
+ sourceBrowser,
57
+ targetBrowser,
58
+ });
59
+ return;
60
+ }
61
+
62
+ if (request.method === "POST" && url.pathname === "/v1/failure") {
63
+ const body = await readJson(request);
64
+ const endpoint = body.endpoint === "source" ? "source" : body.endpoint === "target" ? "target" : null;
65
+ if (!endpoint) throw new ClientError("endpoint must be source or target");
66
+ const browser = endpoint === "source" ? sourceBrowser : targetBrowser;
67
+ const detail = safeDetail(body.message);
68
+ const action = endpoint === "source"
69
+ ? `${browserName(browser)} could not read the selected data. Reload Browser Cookie Bridge on its Extensions page, allow Cookies/History access, and try again.`
70
+ : `${browserName(browser)} could not finish the import. Reload Browser Cookie Bridge on its Extensions page, allow site access, and try again.`;
71
+ const error = new Error(detail ? `${action} Browser message: ${detail}` : action);
72
+ onEvent({ type: "failure", endpoint, browser, message: error.message });
73
+ rejectCompletion(error);
74
+ sendJson(response, 202, { accepted: true });
75
+ return;
76
+ }
77
+
78
+ if (request.method === "POST" && url.pathname === "/v1/source") {
79
+ const body = await readJson(request);
80
+ if (!Array.isArray(body.cookies) || !Array.isArray(body.history)) {
81
+ throw new ClientError("cookies and history must be arrays");
82
+ }
83
+ state.payload = { cookies: body.cookies, history: body.history };
84
+ onEvent({ type: "source", cookies: body.cookies.length, history: body.history.length });
85
+ sendJson(response, 202, {
86
+ acceptedCookies: body.cookies.length,
87
+ acceptedHistory: body.history.length,
88
+ runId: state.runId,
89
+ });
90
+ return;
91
+ }
92
+
93
+ if (request.method === "GET" && url.pathname === "/v1/payload") {
94
+ if (state.payload === null) {
95
+ sendJson(response, 204, null);
96
+ return;
97
+ }
98
+ sendJson(response, 200, { runId: state.runId, ...state.payload });
99
+ return;
100
+ }
101
+
102
+ if (request.method === "POST" && url.pathname === "/v1/complete") {
103
+ const body = await readJson(request);
104
+ state.completed = true;
105
+ state.result = {
106
+ imported: positiveInteger(body.imported),
107
+ failed: positiveInteger(body.failed),
108
+ skipped: positiveInteger(body.skipped),
109
+ historyImported: positiveInteger(body.historyImported),
110
+ historyFailed: positiveInteger(body.historyFailed),
111
+ historySkipped: positiveInteger(body.historySkipped),
112
+ };
113
+ state.payload = null;
114
+ onEvent({ type: "complete", ...state.result });
115
+ resolveCompletion(state.result);
116
+ sendJson(response, 200, { ok: true });
117
+ return;
118
+ }
119
+
120
+ sendJson(response, 404, { error: "not_found" });
121
+ } catch (error) {
122
+ const status = error instanceof ClientError ? 400 : 500;
123
+ sendJson(response, status, { error: error.message });
124
+ }
125
+ });
126
+
127
+ const timer = setTimeout(() => {
128
+ const error = new Error(timeoutGuidance(state, sourceBrowser, targetBrowser));
129
+ rejectCompletion(error);
130
+ server.close();
131
+ }, timeoutMs);
132
+ timer.unref();
133
+
134
+ completion.finally(() => {
135
+ clearTimeout(timer);
136
+ server.close();
137
+ }).catch(() => {});
138
+
139
+ return {
140
+ async listen() {
141
+ await new Promise((resolve, reject) => {
142
+ server.once("error", reject);
143
+ server.listen(port, host, resolve);
144
+ });
145
+ onEvent({ type: "listening", host, port, runId: state.runId });
146
+ },
147
+ close() {
148
+ clearTimeout(timer);
149
+ state.payload = null;
150
+ server.close();
151
+ },
152
+ completion,
153
+ };
154
+ }
155
+
156
+ function authorized(request, token) {
157
+ const supplied = request.headers.authorization || "";
158
+ const expected = `Bearer ${token}`;
159
+ if (supplied.length !== expected.length) return false;
160
+ const tokenMatches = crypto.timingSafeEqual(Buffer.from(supplied), Buffer.from(expected));
161
+ const origin = request.headers.origin;
162
+ return tokenMatches && (origin === undefined || origin === EXTENSION_ORIGIN);
163
+ }
164
+
165
+ function setSecurityHeaders(response) {
166
+ response.setHeader("Access-Control-Allow-Origin", EXTENSION_ORIGIN);
167
+ response.setHeader("Access-Control-Allow-Headers", "authorization, content-type, x-sync-browser, x-sync-role");
168
+ response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
169
+ response.setHeader("Cache-Control", "no-store");
170
+ response.setHeader("Content-Security-Policy", "default-src 'none'");
171
+ response.setHeader("X-Content-Type-Options", "nosniff");
172
+ }
173
+
174
+ function sendJson(response, status, value) {
175
+ if (status === 204) {
176
+ response.writeHead(status);
177
+ response.end();
178
+ return;
179
+ }
180
+ const body = JSON.stringify(value);
181
+ response.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) });
182
+ response.end(body);
183
+ }
184
+
185
+ async function readJson(request) {
186
+ const chunks = [];
187
+ let size = 0;
188
+ for await (const chunk of request) {
189
+ size += chunk.length;
190
+ if (size > MAX_BODY_BYTES) throw new ClientError("request body is too large");
191
+ chunks.push(chunk);
192
+ }
193
+ try {
194
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
195
+ } catch {
196
+ throw new ClientError("invalid JSON");
197
+ }
198
+ }
199
+
200
+ function positiveInteger(value) {
201
+ return Number.isInteger(value) && value >= 0 ? value : 0;
202
+ }
203
+
204
+ function recordPresence(request, state, sourceBrowser, targetBrowser) {
205
+ const browser = request.headers["x-sync-browser"];
206
+ const role = request.headers["x-sync-role"];
207
+ if (browser === sourceBrowser && role === "browser") state.sourceSeen = true;
208
+ const targetRole = targetBrowser === "codex" ? "target" : "browser";
209
+ if (browser === targetBrowser && role === targetRole) state.targetSeen = true;
210
+ }
211
+
212
+ function timeoutGuidance(state, sourceBrowser, targetBrowser) {
213
+ const source = browserName(sourceBrowser);
214
+ const target = browserName(targetBrowser);
215
+ if (!state.sourceSeen && !state.targetSeen) {
216
+ return `Neither endpoint connected. Open ${source} and ${target}, then reload Browser Cookie Bridge on both Extensions pages.`;
217
+ }
218
+ if (!state.sourceSeen) {
219
+ return `${source} did not connect. Open it, then reload Browser Cookie Bridge on its Extensions page.`;
220
+ }
221
+ if (!state.targetSeen) {
222
+ return `${target} did not connect. Open it, then reload Browser Cookie Bridge on its Extensions page.`;
223
+ }
224
+ if (state.payload === null) {
225
+ return `${source} connected but did not provide data. Reload its extension, allow Cookies/History access, and try again.`;
226
+ }
227
+ return `${target} connected but did not finish the import. Reload its extension, allow site access, and try again.`;
228
+ }
229
+
230
+ function browserName(browser) {
231
+ return ({
232
+ brave: "Brave",
233
+ chrome: "Chrome",
234
+ edge: "Edge",
235
+ arc: "Arc",
236
+ vivaldi: "Vivaldi",
237
+ opera: "Opera",
238
+ comet: "Comet",
239
+ codex: "ChatGPT Codex",
240
+ })[browser] || browser;
241
+ }
242
+
243
+ function safeDetail(value) {
244
+ if (typeof value !== "string") return "";
245
+ return value.replace(/[\r\n\t]+/g, " ").trim().slice(0, 240);
246
+ }
247
+
248
+ class ClientError extends Error {}
@@ -0,0 +1,190 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { DatabaseSync } from "node:sqlite";
7
+
8
+ const CHROMIUM_EPOCH_OFFSET_SECONDS = 11_644_473_600;
9
+
10
+ const BROWSERS = {
11
+ brave: {
12
+ root: ["BraveSoftware", "Brave-Browser"],
13
+ safeStorageService: "Brave Safe Storage",
14
+ safeStorageAccount: "Brave",
15
+ },
16
+ chrome: {
17
+ root: ["Google", "Chrome"],
18
+ safeStorageService: "Chrome Safe Storage",
19
+ safeStorageAccount: "Chrome",
20
+ },
21
+ edge: {
22
+ root: ["Microsoft Edge"],
23
+ safeStorageService: "Microsoft Edge Safe Storage",
24
+ safeStorageAccount: "Microsoft Edge",
25
+ },
26
+ arc: {
27
+ root: ["Arc", "User Data"],
28
+ safeStorageService: "Arc Safe Storage",
29
+ safeStorageAccount: "Arc",
30
+ },
31
+ vivaldi: {
32
+ root: ["Vivaldi"],
33
+ safeStorageService: "Vivaldi Safe Storage",
34
+ safeStorageAccount: "Vivaldi",
35
+ },
36
+ opera: {
37
+ root: ["com.operasoftware.Opera"],
38
+ directProfile: true,
39
+ safeStorageService: "Opera Safe Storage",
40
+ safeStorageAccount: "Opera",
41
+ },
42
+ comet: {
43
+ root: ["Comet"],
44
+ safeStorageService: "Comet Safe Storage",
45
+ safeStorageAccount: "Comet",
46
+ },
47
+ };
48
+
49
+ export function readChromiumProfile({
50
+ browser,
51
+ imports = { cookies: true, history: false },
52
+ home = os.homedir(),
53
+ password,
54
+ } = {}) {
55
+ const definition = BROWSERS[browser];
56
+ if (!definition) throw new Error(`Unsupported Chromium source: ${browser}`);
57
+ const root = path.join(home, "Library", "Application Support", ...definition.root);
58
+ const profileName = definition.directProfile ? "Default" : activeProfileName(root);
59
+ const profilePath = definition.directProfile ? root : path.join(root, profileName);
60
+ if (!fs.existsSync(profilePath)) {
61
+ throw new Error(`${browserDisplayName(browser)} profile not found at ${profilePath}`);
62
+ }
63
+
64
+ const cookies = imports.cookies
65
+ ? readCookies({
66
+ databasePath: firstExisting([
67
+ path.join(profilePath, "Network", "Cookies"),
68
+ path.join(profilePath, "Cookies"),
69
+ ]),
70
+ password: password ?? readSafeStoragePassword(definition, browser),
71
+ })
72
+ : [];
73
+ const history = imports.history
74
+ ? readHistory(path.join(profilePath, "History"))
75
+ : [];
76
+
77
+ return { cookies, history, profileName, profilePath };
78
+ }
79
+
80
+ export function decryptChromiumCookieValue({ domain, encryptedValue, password }) {
81
+ const encoded = Buffer.from(encryptedValue);
82
+ if (encoded.length < 4 || (encoded.subarray(0, 3).toString() !== "v10" && encoded.subarray(0, 3).toString() !== "v11")) {
83
+ throw new Error("Unsupported Chromium cookie encryption format");
84
+ }
85
+ const key = crypto.pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1");
86
+ const decipher = crypto.createDecipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20));
87
+ const plaintext = Buffer.concat([decipher.update(encoded.subarray(3)), decipher.final()]);
88
+ if (plaintext.length < 32) throw new Error("Invalid Chromium cookie payload");
89
+ const expected = crypto.createHash("sha256").update(domain).digest();
90
+ if (!crypto.timingSafeEqual(plaintext.subarray(0, 32), expected)) {
91
+ throw new Error("Chromium cookie host verification failed");
92
+ }
93
+ return plaintext.subarray(32).toString();
94
+ }
95
+
96
+ function readCookies({ databasePath, password }) {
97
+ if (databasePath === null) throw new Error("Cookie database was not found");
98
+ const database = new DatabaseSync(databasePath, { readOnly: true });
99
+ try {
100
+ const rows = database.prepare(`
101
+ SELECT host_key, top_frame_site_key, name, value, encrypted_value, path,
102
+ CAST(expires_utc AS TEXT) AS expires_utc,
103
+ is_secure, is_httponly, has_expires, is_persistent, samesite,
104
+ has_cross_site_ancestor
105
+ FROM cookies
106
+ `).all();
107
+ const cookies = [];
108
+ for (const row of rows) {
109
+ try {
110
+ const value = row.value || decryptChromiumCookieValue({
111
+ domain: row.host_key,
112
+ encryptedValue: row.encrypted_value,
113
+ password,
114
+ });
115
+ const persistent = Boolean(row.has_expires && row.is_persistent && row.expires_utc > 0);
116
+ cookies.push({
117
+ name: row.name,
118
+ value,
119
+ domain: row.host_key,
120
+ hostOnly: !row.host_key.startsWith("."),
121
+ path: row.path,
122
+ secure: Boolean(row.is_secure),
123
+ httpOnly: Boolean(row.is_httponly),
124
+ sameSite: ({ "-1": "unspecified", 0: "no_restriction", 1: "lax", 2: "strict" })[row.samesite] || "unspecified",
125
+ session: !persistent,
126
+ ...(persistent ? { expirationDate: chromiumToUnixSeconds(row.expires_utc) } : {}),
127
+ ...(row.top_frame_site_key ? {
128
+ partitionKey: {
129
+ topLevelSite: row.top_frame_site_key,
130
+ hasCrossSiteAncestor: Boolean(row.has_cross_site_ancestor),
131
+ },
132
+ } : {}),
133
+ });
134
+ } catch {
135
+ // An individual malformed or obsolete cookie must not block the rest of the profile.
136
+ }
137
+ }
138
+ return cookies;
139
+ } finally {
140
+ database.close();
141
+ }
142
+ }
143
+
144
+ function readHistory(databasePath) {
145
+ if (!fs.existsSync(databasePath)) return [];
146
+ const database = new DatabaseSync(databasePath, { readOnly: true });
147
+ try {
148
+ return database.prepare("SELECT url FROM urls WHERE url LIKE 'http://%' OR url LIKE 'https://%'")
149
+ .all()
150
+ .flatMap((row) => typeof row.url === "string" ? [{ url: row.url }] : []);
151
+ } finally {
152
+ database.close();
153
+ }
154
+ }
155
+
156
+ function readSafeStoragePassword(definition, browser) {
157
+ const args = ["find-generic-password", "-w", "-s", definition.safeStorageService];
158
+ if (definition.safeStorageAccount) args.push("-a", definition.safeStorageAccount);
159
+ const result = spawnSync("/usr/bin/security", args, { encoding: "utf8", maxBuffer: 1024 * 1024 });
160
+ if (result.status !== 0) {
161
+ throw new Error(
162
+ `${browserDisplayName(browser)} Safe Storage is unavailable. Open ${browserDisplayName(browser)} once, then try again.`,
163
+ );
164
+ }
165
+ return result.stdout.trimEnd();
166
+ }
167
+
168
+ function activeProfileName(root) {
169
+ try {
170
+ const localState = JSON.parse(fs.readFileSync(path.join(root, "Local State"), "utf8"));
171
+ const lastUsed = localState.profile?.last_used;
172
+ if (typeof lastUsed === "string" && lastUsed && fs.existsSync(path.join(root, lastUsed))) return lastUsed;
173
+ } catch {}
174
+ if (fs.existsSync(path.join(root, "Default"))) return "Default";
175
+ const candidate = fs.readdirSync(root, { withFileTypes: true })
176
+ .find((entry) => entry.isDirectory() && /^Profile \d+$/.test(entry.name));
177
+ return candidate?.name || "Default";
178
+ }
179
+
180
+ function firstExisting(candidates) {
181
+ return candidates.find((candidate) => fs.existsSync(candidate)) || null;
182
+ }
183
+
184
+ function chromiumToUnixSeconds(value) {
185
+ return Number(value) / 1_000_000 - CHROMIUM_EPOCH_OFFSET_SECONDS;
186
+ }
187
+
188
+ function browserDisplayName(browser) {
189
+ return ({ brave: "Brave", chrome: "Chrome", edge: "Edge", arc: "Arc", vivaldi: "Vivaldi", opera: "Opera", comet: "Comet" })[browser] || browser;
190
+ }