feat: implement M2 Chrome browser extension

Build the CookieBridge Chrome extension (Manifest V3) with:

- Background service worker: cookie monitoring via chrome.cookies.onChanged,
  WebSocket connection to relay server with auto-reconnect, HTTP polling
  fallback, device registration and pairing flow
- Browser-compatible crypto: libsodium-wrappers-sumo for XChaCha20-Poly1305
  encryption, Ed25519 signing, X25519 key exchange (mirrors server's
  sodium-native API)
- Popup UI: device registration, connection status indicator (gray/blue/
  green/red), cookie/device/sync stats, one-click current site sync,
  whitelist quick-add, device pairing with 6-digit code
- Options page: server URL config, connection mode (auto/WS/polling),
  poll interval slider, auto-sync toggle, domain whitelist/blacklist
  management, paired device list, key export/import, data clearing
- Sync engine: LWW conflict resolution with Lamport clocks (same as
  server), bidirectional cookie sync with all paired peers, echo
  suppression to prevent sync loops
- Badge management: icon color reflects state (gray=not logged in,
  blue=connected, green=syncing with count, red=error)
- Build system: esbuild bundling for Chrome 120+, TypeScript with
  strict mode, clean type checking

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
徐枫
2026-03-17 16:30:18 +08:00
parent 1bd7a34de8
commit dc3be4d73f
36 changed files with 3549 additions and 0 deletions

2
extension/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

View File

@@ -0,0 +1,31 @@
import * as esbuild from "esbuild";
const isWatch = process.argv.includes("--watch");
const buildOptions = {
entryPoints: [
"src/background/service-worker.ts",
"src/popup/popup.ts",
"src/options/options.ts",
],
bundle: true,
outdir: "dist",
format: "esm",
target: "chrome120",
sourcemap: true,
minify: !isWatch,
// Force CJS resolution for libsodium (ESM entry has broken sibling import)
alias: {
"libsodium-wrappers-sumo":
"./node_modules/libsodium-wrappers-sumo/dist/modules-sumo/libsodium-wrappers.js",
},
};
if (isWatch) {
const ctx = await esbuild.context(buildOptions);
await ctx.watch();
console.log("Watching for changes...");
} else {
await esbuild.build(buildOptions);
console.log("Build complete.");
}

View File

@@ -0,0 +1,139 @@
/**
* Generate simple PNG icons for the extension.
* Creates colored circle icons with a "C" letter.
* Run: node generate-icons.mjs
*/
import { writeFileSync } from "fs";
// Minimal PNG encoder for simple icons
function createPNG(size, r, g, b) {
// Create raw RGBA pixel data
const pixels = new Uint8Array(size * size * 4);
const center = size / 2;
const radius = size / 2 - 1;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const idx = (y * size + x) * 4;
const dist = Math.sqrt((x - center) ** 2 + (y - center) ** 2);
if (dist <= radius) {
pixels[idx] = r;
pixels[idx + 1] = g;
pixels[idx + 2] = b;
pixels[idx + 3] = 255;
} else if (dist <= radius + 1) {
// Anti-aliased edge
const alpha = Math.max(0, Math.round((radius + 1 - dist) * 255));
pixels[idx] = r;
pixels[idx + 1] = g;
pixels[idx + 2] = b;
pixels[idx + 3] = alpha;
}
}
}
// Encode as PNG
return encodePNG(size, size, pixels);
}
function encodePNG(width, height, pixels) {
const SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
c ^= buf[i];
for (let j = 0; j < 8; j++) {
c = (c >>> 1) ^ (c & 1 ? 0xedb88320 : 0);
}
}
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const typeBytes = Buffer.from(type);
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const combined = Buffer.concat([typeBytes, data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(combined));
return Buffer.concat([len, combined, crc]);
}
function adler32(buf) {
let a = 1, b = 0;
for (let i = 0; i < buf.length; i++) {
a = (a + buf[i]) % 65521;
b = (b + a) % 65521;
}
return ((b << 16) | a) >>> 0;
}
// IHDR
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // RGBA
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter
ihdr[12] = 0; // interlace
// IDAT - raw pixel data with filter bytes
const rawData = Buffer.alloc(height * (1 + width * 4));
for (let y = 0; y < height; y++) {
rawData[y * (1 + width * 4)] = 0; // no filter
for (let x = 0; x < width * 4; x++) {
rawData[y * (1 + width * 4) + 1 + x] = pixels[y * width * 4 + x];
}
}
// Deflate with store (no compression) - simple but works
const blocks = [];
let offset = 0;
while (offset < rawData.length) {
const remaining = rawData.length - offset;
const blockSize = Math.min(remaining, 65535);
const isLast = offset + blockSize >= rawData.length;
const header = Buffer.alloc(5);
header[0] = isLast ? 1 : 0;
header.writeUInt16LE(blockSize, 1);
header.writeUInt16LE(blockSize ^ 0xffff, 3);
blocks.push(header);
blocks.push(rawData.subarray(offset, offset + blockSize));
offset += blockSize;
}
const zlibHeader = Buffer.from([0x78, 0x01]); // deflate, no compression
const adler = Buffer.alloc(4);
adler.writeUInt32BE(adler32(rawData));
const compressed = Buffer.concat([zlibHeader, ...blocks, adler]);
// IEND
const iend = Buffer.alloc(0);
return Buffer.concat([
SIGNATURE,
chunk("IHDR", ihdr),
chunk("IDAT", compressed),
chunk("IEND", iend),
]);
}
const colors = {
gray: [156, 163, 175],
blue: [59, 130, 246],
green: [34, 197, 94],
red: [239, 68, 68],
};
const sizes = [16, 48, 128];
for (const [name, [r, g, b]] of Object.entries(colors)) {
for (const size of sizes) {
const png = createPNG(size, r, g, b);
const path = `src/icons/icon-${name}-${size}.png`;
writeFileSync(path, png);
console.log(`Generated ${path}`);
}
}

32
extension/manifest.json Normal file
View File

@@ -0,0 +1,32 @@
{
"manifest_version": 3,
"name": "CookieBridge",
"version": "0.1.0",
"description": "Cross-device cookie synchronization with end-to-end encryption",
"permissions": [
"cookies",
"storage",
"alarms",
"tabs",
"activeTab"
],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "dist/background/service-worker.js",
"type": "module"
},
"action": {
"default_popup": "src/popup/popup.html",
"default_icon": {
"16": "src/icons/icon-gray-16.png",
"48": "src/icons/icon-gray-48.png",
"128": "src/icons/icon-gray-128.png"
}
},
"options_page": "src/options/options.html",
"icons": {
"16": "src/icons/icon-blue-16.png",
"48": "src/icons/icon-blue-48.png",
"128": "src/icons/icon-blue-128.png"
}
}

550
extension/package-lock.json generated Normal file
View File

@@ -0,0 +1,550 @@
{
"name": "cookiebridge-extension",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cookiebridge-extension",
"version": "0.1.0",
"dependencies": {
"libsodium-wrappers-sumo": "^0.7.15"
},
"devDependencies": {
"@types/chrome": "^0.0.287",
"esbuild": "^0.24.2",
"typescript": "^5.9.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/chrome": {
"version": "0.0.287",
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz",
"integrity": "sha512-wWhBNPNXZHwycHKNYnexUcpSbrihVZu++0rdp6GEk5ZgAglenLx+RwdEouh6FrHS0XQiOxSd62yaujM1OoQlZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filesystem": "*",
"@types/har-format": "*"
}
},
"node_modules/@types/filesystem": {
"version": "0.0.36",
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/filewriter": "*"
}
},
"node_modules/@types/filewriter": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/har-format": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
"integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
},
"node_modules/libsodium-sumo": {
"version": "0.7.16",
"resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.7.16.tgz",
"integrity": "sha512-x6atrz2AdXCJg6G709x9W9TTJRI6/0NcL5dD0l5GGVqNE48UJmDsjO4RUWYTeyXXUpg+NXZ2SHECaZnFRYzwGA==",
"license": "ISC"
},
"node_modules/libsodium-wrappers-sumo": {
"version": "0.7.16",
"resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.16.tgz",
"integrity": "sha512-gR0JEFPeN3831lB9+ogooQk0KH4K5LSMIO5Prd5Q5XYR2wHFtZfPg0eP7t1oJIWq+UIzlU4WVeBxZ97mt28tXw==",
"license": "ISC",
"dependencies": {
"libsodium-sumo": "^0.7.16"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

19
extension/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "cookiebridge-extension",
"version": "0.1.0",
"private": true,
"description": "CookieBridge Chrome Extension — cross-device cookie sync with E2E encryption",
"scripts": {
"build": "node esbuild.config.mjs",
"watch": "node esbuild.config.mjs --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"libsodium-wrappers-sumo": "^0.7.15"
},
"devDependencies": {
"@types/chrome": "^0.0.287",
"esbuild": "^0.24.2",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,315 @@
/**
* CookieBridge background service worker.
* Manages device identity, connection lifecycle, and cookie sync.
*/
import {
generateKeyPair,
deviceIdFromKeys,
serializeKeyPair,
deserializeKeyPair,
type DeviceKeyPair,
} from "../lib/crypto";
import { getState, setState, type PeerDevice } from "../lib/storage";
import { ConnectionManager, type ConnectionStatus } from "../lib/connection";
import { ApiClient } from "../lib/api-client";
import { SyncEngine } from "../lib/sync";
import { setIconState, clearSyncBadge } from "../lib/badge";
import { MESSAGE_TYPES, POLL_INTERVAL_MS, type Envelope } from "../lib/protocol";
let connection: ConnectionManager | null = null;
let syncEngine: SyncEngine | null = null;
let api: ApiClient | null = null;
let pollAlarmName = "cookiebridge-poll";
// --- Initialization ---
async function initialize() {
const state = await getState();
if (!state.keys || !state.apiToken) {
await setIconState("not_logged_in");
return;
}
const keys = deserializeKeyPair(state.keys);
api = new ApiClient(state.serverUrl, state.apiToken);
connection = new ConnectionManager(state.serverUrl, state.apiToken, keys, {
onMessage: (envelope) => handleIncomingMessage(envelope, state.peers),
onStatusChange: handleStatusChange,
});
syncEngine = new SyncEngine(keys, connection, api);
syncEngine.start();
if (state.connectionMode === "polling") {
startPolling(state.pollIntervalSec);
} else {
connection.connect();
}
}
function handleStatusChange(status: ConnectionStatus) {
switch (status) {
case "connected":
setIconState("connected");
break;
case "disconnected":
case "connecting":
setIconState("not_logged_in");
break;
case "error":
setIconState("error");
break;
}
}
async function handleIncomingMessage(envelope: Envelope, peers: PeerDevice[]) {
if (
envelope.type === MESSAGE_TYPES.COOKIE_SYNC ||
envelope.type === MESSAGE_TYPES.COOKIE_DELETE
) {
if (!syncEngine) return;
await syncEngine.handleIncomingEnvelope(envelope, peers);
await setIconState("syncing", 1);
clearSyncBadge();
}
}
function startPolling(intervalSec: number) {
chrome.alarms.create(pollAlarmName, {
periodInMinutes: Math.max(intervalSec / 60, 1 / 60),
});
}
function stopPolling() {
chrome.alarms.clear(pollAlarmName);
}
// --- Alarm handler for HTTP polling ---
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name !== pollAlarmName) return;
if (!api) return;
const state = await getState();
if (!state.lastSyncAt) return;
try {
const updates = await api.pullUpdates(state.lastSyncAt);
if (updates.length > 0) {
await setState({ lastSyncAt: new Date().toISOString() });
await setIconState("syncing", updates.length);
clearSyncBadge();
}
} catch {
// Polling failure — will retry on next alarm
}
});
// --- Message handling from popup/options ---
export interface ExtensionMessage {
type: string;
payload?: unknown;
}
chrome.runtime.onMessage.addListener(
(message: ExtensionMessage, _sender, sendResponse) => {
handleMessage(message).then(sendResponse).catch((err) => {
sendResponse({ error: err.message });
});
return true; // async response
},
);
async function handleMessage(msg: ExtensionMessage): Promise<unknown> {
switch (msg.type) {
case "GET_STATUS":
return getStatus();
case "REGISTER_DEVICE":
return registerDevice(msg.payload as { name: string });
case "INITIATE_PAIRING":
return initiatePairing();
case "ACCEPT_PAIRING":
return acceptPairing(msg.payload as { code: string });
case "SYNC_CURRENT_TAB":
if (syncEngine) await syncEngine.syncCurrentTab();
return { ok: true };
case "SYNC_DOMAIN":
if (syncEngine) {
await syncEngine.syncDomain(
(msg.payload as { domain: string }).domain,
);
}
return { ok: true };
case "ADD_WHITELIST": {
const state = await getState();
const domain = (msg.payload as { domain: string }).domain;
if (!state.whitelist.includes(domain)) {
await setState({ whitelist: [...state.whitelist, domain] });
}
return { ok: true };
}
case "DISCONNECT":
connection?.disconnect();
stopPolling();
return { ok: true };
case "RECONNECT":
await initialize();
return { ok: true };
case "EXPORT_KEYS": {
const state = await getState();
return { keys: state.keys };
}
case "IMPORT_KEYS": {
const keys = msg.payload as {
signPub: string;
signSec: string;
encPub: string;
encSec: string;
};
const kp = deserializeKeyPair(keys);
await setState({
keys,
deviceId: deviceIdFromKeys(kp),
});
await initialize();
return { ok: true };
}
case "LOGOUT":
connection?.disconnect();
syncEngine?.stop();
stopPolling();
await setState({
keys: null,
deviceId: null,
deviceName: null,
apiToken: null,
peers: [],
syncCount: 0,
lastSyncAt: null,
lamportClock: 0,
});
await setIconState("not_logged_in");
return { ok: true };
default:
throw new Error(`Unknown message type: ${msg.type}`);
}
}
async function getStatus(): Promise<{
loggedIn: boolean;
deviceId: string | null;
deviceName: string | null;
connectionStatus: ConnectionStatus;
peerCount: number;
syncCount: number;
lastSyncAt: string | null;
cookieCount: number;
}> {
const state = await getState();
// Count tracked cookies
let cookieCount = 0;
if (state.whitelist.length > 0) {
for (const domain of state.whitelist) {
const cookies = await chrome.cookies.getAll({ domain });
cookieCount += cookies.length;
}
}
return {
loggedIn: !!state.apiToken,
deviceId: state.deviceId,
deviceName: state.deviceName,
connectionStatus: connection?.status ?? "disconnected",
peerCount: state.peers.length,
syncCount: state.syncCount,
lastSyncAt: state.lastSyncAt,
cookieCount,
};
}
async function registerDevice(params: { name: string }) {
const keys = await generateKeyPair();
const serialized = serializeKeyPair(keys);
const deviceId = deviceIdFromKeys(keys);
const state = await getState();
const client = new ApiClient(state.serverUrl);
const device = await client.registerDevice({
deviceId,
name: params.name,
platform: "chrome-extension",
encPub: serialized.encPub,
});
await setState({
keys: serialized,
deviceId,
deviceName: params.name,
apiToken: device.token,
});
await initialize();
return { deviceId, name: params.name };
}
async function initiatePairing() {
const state = await getState();
if (!state.keys || !api) throw new Error("Not registered");
const result = await api.initiatePairing(
state.deviceId!,
state.keys.encPub,
);
return { pairingCode: result.pairingCode };
}
async function acceptPairing(params: { code: string }) {
const state = await getState();
if (!state.keys || !api) throw new Error("Not registered");
const result = await api.acceptPairing(
state.deviceId!,
state.keys.encPub,
params.code,
);
const peer: PeerDevice = {
deviceId: result.peerDeviceId,
name: "Paired Device",
platform: "unknown",
encPub: result.peerX25519PubKey,
pairedAt: new Date().toISOString(),
};
await setState({ peers: [...state.peers, peer] });
return { peerId: result.peerDeviceId };
}
// --- Start on install/startup ---
chrome.runtime.onInstalled.addListener(() => {
initialize();
});
chrome.runtime.onStartup.addListener(() => {
initialize();
});
// Initialize immediately for service worker restart
initialize();

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html>
<head><title>Generate CookieBridge Icons</title></head>
<body>
<script>
// Run this in a browser to generate icon PNGs, or use the canvas-based approach in service worker
const sizes = [16, 48, 128];
const colors = {
gray: '#9CA3AF',
blue: '#3B82F6',
green: '#22C55E',
red: '#EF4444',
};
for (const [colorName, color] of Object.entries(colors)) {
for (const size of sizes) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
// Background circle
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2 - 1, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
// Cookie icon (simplified)
ctx.fillStyle = '#FFFFFF';
ctx.font = `bold ${size * 0.5}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('C', size / 2, size / 2);
// Download
const link = document.createElement('a');
link.download = `icon-${colorName}-${size}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
}
}
document.body.textContent = 'Icons generated! Check downloads.';
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -0,0 +1,119 @@
/**
* HTTP client for the CookieBridge relay server REST API.
*/
import type {
DeviceRegisterRequest,
DeviceInfo,
PairingResult,
EncryptedCookieBlob,
} from "./protocol";
export class ApiClient {
constructor(
private baseUrl: string,
private token: string | null = null,
) {}
setToken(token: string) {
this.token = token;
}
setBaseUrl(url: string) {
this.baseUrl = url;
}
private async request<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (this.token) {
headers["Authorization"] = `Bearer ${this.token}`;
}
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`API ${method} ${path}: ${res.status} ${text}`);
}
return res.json();
}
// --- Device Registration ---
async registerDevice(req: DeviceRegisterRequest): Promise<DeviceInfo> {
return this.request("POST", "/api/devices/register", req);
}
// --- Pairing ---
async initiatePairing(
deviceId: string,
x25519PubKey: string,
): Promise<{ pairingCode: string }> {
return this.request("POST", "/api/pair", { deviceId, x25519PubKey });
}
async acceptPairing(
deviceId: string,
x25519PubKey: string,
pairingCode: string,
): Promise<PairingResult> {
return this.request("POST", "/api/pair/accept", {
deviceId,
x25519PubKey,
pairingCode,
});
}
// --- Cookie Storage (HTTP polling) ---
async pushCookies(
blobs: Array<{
domain: string;
cookieName: string;
path: string;
nonce: string;
ciphertext: string;
lamportTs: number;
}>,
): Promise<void> {
await this.request("POST", "/api/cookies", { cookies: blobs });
}
async pullCookies(domain?: string): Promise<EncryptedCookieBlob[]> {
const params = domain ? `?domain=${encodeURIComponent(domain)}` : "";
return this.request("GET", `/api/cookies${params}`);
}
async pullUpdates(since: string): Promise<EncryptedCookieBlob[]> {
return this.request(
"GET",
`/api/cookies/updates?since=${encodeURIComponent(since)}`,
);
}
async deleteCookie(
domain: string,
cookieName: string,
path: string,
): Promise<void> {
await this.request("DELETE", "/api/cookies", {
domain,
cookieName,
path,
});
}
// --- Health ---
async health(): Promise<{ status: string; connections: number }> {
return this.request("GET", "/health");
}
}

View File

@@ -0,0 +1,69 @@
/**
* Badge/icon management — updates extension icon color and badge text
* based on connection status and sync activity.
*
* States:
* - gray: Not logged in / no device identity
* - blue: Connected, idle
* - green: Syncing (with count badge)
* - red: Error / disconnected
*/
type IconColor = "gray" | "blue" | "green" | "red";
function iconPath(color: IconColor, size: number): string {
return `src/icons/icon-${color}-${size}.png`;
}
function iconSet(color: IconColor): Record<string, string> {
return {
"16": iconPath(color, 16),
"48": iconPath(color, 48),
"128": iconPath(color, 128),
};
}
export async function setIconState(
state: "not_logged_in" | "connected" | "syncing" | "error",
syncCount?: number,
) {
switch (state) {
case "not_logged_in":
await chrome.action.setIcon({ path: iconSet("gray") });
await chrome.action.setBadgeText({ text: "" });
break;
case "connected":
await chrome.action.setIcon({ path: iconSet("blue") });
await chrome.action.setBadgeText({ text: "" });
break;
case "syncing":
await chrome.action.setIcon({ path: iconSet("green") });
if (syncCount && syncCount > 0) {
await chrome.action.setBadgeText({
text: syncCount > 99 ? "99+" : String(syncCount),
});
await chrome.action.setBadgeBackgroundColor({ color: "#22C55E" });
}
break;
case "error":
await chrome.action.setIcon({ path: iconSet("red") });
await chrome.action.setBadgeText({ text: "!" });
await chrome.action.setBadgeBackgroundColor({ color: "#EF4444" });
break;
}
}
/** Clear the sync badge after a delay. */
export function clearSyncBadge(delayMs = 3000) {
setTimeout(async () => {
const state = await chrome.storage.local.get(["apiToken"]);
if (state.apiToken) {
await setIconState("connected");
} else {
await setIconState("not_logged_in");
}
}, delayMs);
}

View File

@@ -0,0 +1,168 @@
/**
* Connection manager — handles WebSocket and HTTP polling connections to the relay server.
*/
import { toHex } from "./hex";
import type { DeviceKeyPair } from "./crypto";
import { deviceIdFromKeys, sign } from "./crypto";
import {
MESSAGE_TYPES,
PING_INTERVAL_MS,
PONG_TIMEOUT_MS,
type Envelope,
} from "./protocol";
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
export interface ConnectionEvents {
onMessage: (envelope: Envelope) => void;
onStatusChange: (status: ConnectionStatus) => void;
}
export class ConnectionManager {
private ws: WebSocket | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private pongTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectDelay = 1000;
private _status: ConnectionStatus = "disconnected";
private serverUrl: string;
private token: string;
private keys: DeviceKeyPair;
private events: ConnectionEvents;
constructor(
serverUrl: string,
token: string,
keys: DeviceKeyPair,
events: ConnectionEvents,
) {
this.serverUrl = serverUrl;
this.token = token;
this.keys = keys;
this.events = events;
}
get status(): ConnectionStatus {
return this._status;
}
private setStatus(s: ConnectionStatus) {
this._status = s;
this.events.onStatusChange(s);
}
/** Connect via WebSocket. */
connect() {
if (this.ws) this.disconnect();
this.setStatus("connecting");
const wsUrl = this.serverUrl
.replace(/^http/, "ws")
.replace(/\/$/, "");
this.ws = new WebSocket(`${wsUrl}/ws?token=${encodeURIComponent(this.token)}`);
this.ws.onopen = () => {
this.setStatus("connected");
this.reconnectDelay = 1000;
this.startPing();
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data as string);
if (data.type === MESSAGE_TYPES.PONG) {
this.handlePong();
return;
}
if (data.type === MESSAGE_TYPES.PING) {
this.sendRaw({ type: MESSAGE_TYPES.PONG });
return;
}
this.events.onMessage(data as Envelope);
} catch {
// Ignore malformed messages
}
};
this.ws.onclose = () => {
this.cleanup();
this.setStatus("disconnected");
this.scheduleReconnect();
};
this.ws.onerror = () => {
this.cleanup();
this.setStatus("error");
this.scheduleReconnect();
};
}
disconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.cleanup();
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.setStatus("disconnected");
}
/** Send an envelope over WebSocket. */
send(envelope: Envelope) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(envelope));
}
}
private sendRaw(data: unknown) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
private startPing() {
this.stopPing();
this.pingTimer = setInterval(() => {
this.sendRaw({ type: MESSAGE_TYPES.PING });
this.pongTimer = setTimeout(() => {
// No pong received, connection is dead
this.ws?.close();
}, PONG_TIMEOUT_MS);
}, PING_INTERVAL_MS);
}
private stopPing() {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
if (this.pongTimer) {
clearTimeout(this.pongTimer);
this.pongTimer = null;
}
}
private handlePong() {
if (this.pongTimer) {
clearTimeout(this.pongTimer);
this.pongTimer = null;
}
}
private cleanup() {
this.stopPing();
}
private scheduleReconnect() {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, this.reconnectDelay);
// Exponential backoff, max 30s
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
}
}

217
extension/src/lib/crypto.ts Normal file
View File

@@ -0,0 +1,217 @@
/**
* Browser-compatible crypto module using libsodium-wrappers-sumo.
* Mirrors the server's sodium-native API but runs in the browser.
*/
import _sodium from "libsodium-wrappers-sumo";
import { toHex, fromHex, toBase64, fromBase64 } from "./hex";
let sodiumReady: Promise<typeof _sodium> | null = null;
async function getSodium(): Promise<typeof _sodium> {
if (!sodiumReady) {
sodiumReady = _sodium.ready.then(() => _sodium);
}
return sodiumReady;
}
// --- Key Types ---
export interface DeviceKeyPair {
signPub: Uint8Array;
signSec: Uint8Array;
encPub: Uint8Array;
encSec: Uint8Array;
}
export interface SerializedKeyPair {
signPub: string; // hex
signSec: string; // hex
encPub: string; // hex
encSec: string; // hex
}
// --- Key Generation ---
export async function generateKeyPair(): Promise<DeviceKeyPair> {
const sodium = await getSodium();
const signKp = sodium.crypto_sign_keypair();
const encKp = sodium.crypto_box_keypair();
return {
signPub: signKp.publicKey,
signSec: signKp.privateKey,
encPub: encKp.publicKey,
encSec: encKp.privateKey,
};
}
export function deviceIdFromKeys(keys: DeviceKeyPair): string {
return toHex(keys.signPub);
}
export function serializeKeyPair(keys: DeviceKeyPair): SerializedKeyPair {
return {
signPub: toHex(keys.signPub),
signSec: toHex(keys.signSec),
encPub: toHex(keys.encPub),
encSec: toHex(keys.encSec),
};
}
export function deserializeKeyPair(data: SerializedKeyPair): DeviceKeyPair {
return {
signPub: fromHex(data.signPub),
signSec: fromHex(data.signSec),
encPub: fromHex(data.encPub),
encSec: fromHex(data.encSec),
};
}
// --- Encryption ---
export async function deriveSharedKey(
ourEncSec: Uint8Array,
peerEncPub: Uint8Array,
): Promise<Uint8Array> {
const sodium = await getSodium();
const raw = sodium.crypto_scalarmult(ourEncSec, peerEncPub);
return sodium.crypto_generichash(32, raw);
}
export async function encrypt(
plaintext: Uint8Array,
sharedKey: Uint8Array,
): Promise<{ nonce: Uint8Array; ciphertext: Uint8Array }> {
const sodium = await getSodium();
const nonce = sodium.randombytes_buf(
sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES,
);
const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
plaintext,
null, // no additional data
null, // unused nsec
nonce,
sharedKey,
);
return { nonce, ciphertext };
}
export async function decrypt(
ciphertext: Uint8Array,
nonce: Uint8Array,
sharedKey: Uint8Array,
): Promise<Uint8Array> {
const sodium = await getSodium();
return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
null, // unused nsec
ciphertext,
null, // no additional data
nonce,
sharedKey,
);
}
// --- Signing ---
export async function sign(
message: Uint8Array,
signSec: Uint8Array,
): Promise<Uint8Array> {
const sodium = await getSodium();
return sodium.crypto_sign_detached(message, signSec);
}
export async function verify(
message: Uint8Array,
sig: Uint8Array,
signPub: Uint8Array,
): Promise<boolean> {
const sodium = await getSodium();
try {
return sodium.crypto_sign_verify_detached(sig, message, signPub);
} catch {
return false;
}
}
export function buildSignablePayload(fields: {
type: string;
from: string;
to: string;
nonce: string;
payload: string;
timestamp: string;
}): Uint8Array {
const str =
fields.type +
fields.from +
fields.to +
fields.nonce +
fields.payload +
fields.timestamp;
return new TextEncoder().encode(str);
}
// --- Envelope helpers ---
export async function buildEnvelope(
type: string,
payload: object,
senderKeys: DeviceKeyPair,
peerEncPub: Uint8Array,
peerDeviceId: string,
): Promise<{
type: string;
from: string;
to: string;
nonce: string;
payload: string;
timestamp: string;
sig: string;
}> {
const fromId = deviceIdFromKeys(senderKeys);
const sharedKey = await deriveSharedKey(senderKeys.encSec, peerEncPub);
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
const { nonce, ciphertext } = await encrypt(plaintext, sharedKey);
const timestamp = new Date().toISOString();
const nonceHex = toHex(nonce);
const payloadB64 = toBase64(ciphertext);
const signable = buildSignablePayload({
type,
from: fromId,
to: peerDeviceId,
nonce: nonceHex,
payload: payloadB64,
timestamp,
});
const sig = await sign(signable, senderKeys.signSec);
return {
type,
from: fromId,
to: peerDeviceId,
nonce: nonceHex,
payload: payloadB64,
timestamp,
sig: toHex(sig),
};
}
export async function openEnvelope(
envelope: {
nonce: string;
payload: string;
},
receiverKeys: DeviceKeyPair,
peerEncPub: Uint8Array,
): Promise<unknown> {
const sharedKey = await deriveSharedKey(receiverKeys.encSec, peerEncPub);
const nonce = fromHex(envelope.nonce);
const ciphertext = fromBase64(envelope.payload);
const plaintext = await decrypt(ciphertext, nonce, sharedKey);
return JSON.parse(new TextDecoder().decode(plaintext));
}
// Re-export hex utils
export { toHex, fromHex, toBase64, fromBase64 };

34
extension/src/lib/hex.ts Normal file
View File

@@ -0,0 +1,34 @@
/** Convert Uint8Array to hex string. */
export function toHex(buf: Uint8Array): string {
return Array.from(buf)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/** Convert hex string to Uint8Array. */
export function fromHex(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
/** Convert Uint8Array to base64 string. */
export function toBase64(buf: Uint8Array): string {
let binary = "";
for (let i = 0; i < buf.length; i++) {
binary += String.fromCharCode(buf[i]);
}
return btoa(binary);
}
/** Convert base64 string to Uint8Array. */
export function fromBase64(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}

View File

@@ -0,0 +1,40 @@
declare module "libsodium-wrappers-sumo" {
interface KeyPair {
publicKey: Uint8Array;
privateKey: Uint8Array;
keyType: string;
}
interface Sodium {
ready: Promise<void>;
crypto_sign_keypair(): KeyPair;
crypto_box_keypair(): KeyPair;
crypto_scalarmult(privateKey: Uint8Array, publicKey: Uint8Array): Uint8Array;
crypto_generichash(hashLength: number, message: Uint8Array): Uint8Array;
crypto_aead_xchacha20poly1305_ietf_encrypt(
message: Uint8Array,
additionalData: Uint8Array | null,
nsec: Uint8Array | null,
nonce: Uint8Array,
key: Uint8Array,
): Uint8Array;
crypto_aead_xchacha20poly1305_ietf_decrypt(
nsec: Uint8Array | null,
ciphertext: Uint8Array,
additionalData: Uint8Array | null,
nonce: Uint8Array,
key: Uint8Array,
): Uint8Array;
crypto_aead_xchacha20poly1305_ietf_NPUBBYTES: number;
crypto_sign_detached(message: Uint8Array, privateKey: Uint8Array): Uint8Array;
crypto_sign_verify_detached(
signature: Uint8Array,
message: Uint8Array,
publicKey: Uint8Array,
): boolean;
randombytes_buf(length: number): Uint8Array;
}
const sodium: Sodium;
export default sodium;
}

View File

@@ -0,0 +1,83 @@
/**
* Protocol types — mirrors the server's protocol/spec.ts for use in the extension.
*/
export const PROTOCOL_VERSION = "2.0.0";
export const MAX_STORED_COOKIES_PER_DEVICE = 10_000;
export const PAIRING_CODE_LENGTH = 6;
export const PAIRING_TTL_MS = 5 * 60 * 1000;
export const NONCE_BYTES = 24;
export const PING_INTERVAL_MS = 30_000;
export const PONG_TIMEOUT_MS = 10_000;
export const POLL_INTERVAL_MS = 5_000;
export const MESSAGE_TYPES = {
COOKIE_SYNC: "cookie_sync",
COOKIE_DELETE: "cookie_delete",
ACK: "ack",
PING: "ping",
PONG: "pong",
ERROR: "error",
} as const;
export type MessageType = (typeof MESSAGE_TYPES)[keyof typeof MESSAGE_TYPES];
export interface Envelope {
type: MessageType;
from: string;
to: string;
nonce: string;
payload: string;
timestamp: string;
sig: string;
}
export interface CookieEntry {
domain: string;
name: string;
value: string;
path: string;
secure: boolean;
httpOnly: boolean;
sameSite: "strict" | "lax" | "none";
expiresAt: string | null;
}
export interface CookieSyncPayload {
action: "set" | "delete";
cookies: CookieEntry[];
lamportTs: number;
}
export interface EncryptedCookieBlob {
id: string;
deviceId: string;
domain: string;
cookieName: string;
path: string;
nonce: string;
ciphertext: string;
lamportTs: number;
updatedAt: string;
}
export interface DeviceRegisterRequest {
deviceId: string;
name: string;
platform: string;
encPub: string;
}
export interface DeviceInfo {
deviceId: string;
name: string;
platform: string;
encPub: string;
token: string;
createdAt: string;
}
export interface PairingResult {
peerDeviceId: string;
peerX25519PubKey: string;
}

View File

@@ -0,0 +1,109 @@
/**
* Chrome storage wrapper — provides typed access to extension state.
*/
import type { SerializedKeyPair } from "./crypto";
export interface PeerDevice {
deviceId: string;
name: string;
platform: string;
encPub: string; // hex
pairedAt: string;
}
export interface ExtensionState {
// Device identity
keys: SerializedKeyPair | null;
deviceId: string | null;
deviceName: string | null;
apiToken: string | null;
// Server config
serverUrl: string;
connectionMode: "auto" | "websocket" | "polling";
pollIntervalSec: number;
// Sync settings
autoSync: boolean;
whitelist: string[]; // domains to sync
blacklist: string[]; // domains to never sync (banks, etc.)
// Paired devices
peers: PeerDevice[];
// Stats
syncCount: number;
lastSyncAt: string | null;
// Lamport clock
lamportClock: number;
}
const DEFAULT_STATE: ExtensionState = {
keys: null,
deviceId: null,
deviceName: null,
apiToken: null,
serverUrl: "http://localhost:3000",
connectionMode: "auto",
pollIntervalSec: 5,
autoSync: true,
whitelist: [],
blacklist: [
"*.bank.*",
"*.paypal.com",
"*.stripe.com",
"accounts.google.com",
"login.microsoftonline.com",
],
peers: [],
syncCount: 0,
lastSyncAt: null,
lamportClock: 0,
};
/** Get full extension state, merging defaults. */
export async function getState(): Promise<ExtensionState> {
const data = await chrome.storage.local.get(null);
return { ...DEFAULT_STATE, ...data } as ExtensionState;
}
/** Update specific state fields. */
export async function setState(
partial: Partial<ExtensionState>,
): Promise<void> {
await chrome.storage.local.set(partial);
}
/** Reset all state to defaults. */
export async function clearState(): Promise<void> {
await chrome.storage.local.clear();
}
/** Check if a domain matches a pattern (supports leading wildcard *.). */
function matchDomain(pattern: string, domain: string): boolean {
if (pattern.startsWith("*.")) {
const suffix = pattern.slice(1); // ".bank." or ".paypal.com"
return domain.includes(suffix);
}
return domain === pattern;
}
/** Check if a domain is allowed for syncing based on whitelist/blacklist. */
export function isDomainAllowed(
domain: string,
whitelist: string[],
blacklist: string[],
): boolean {
// Blacklist always wins
for (const pattern of blacklist) {
if (matchDomain(pattern, domain)) return false;
}
// If whitelist is empty, allow all (except blacklisted)
if (whitelist.length === 0) return true;
// If whitelist is non-empty, only allow whitelisted
for (const pattern of whitelist) {
if (matchDomain(pattern, domain)) return true;
}
return false;
}

269
extension/src/lib/sync.ts Normal file
View File

@@ -0,0 +1,269 @@
/**
* Cookie sync engine — monitors chrome.cookies, syncs with relay server.
*/
import type { CookieEntry, CookieSyncPayload, Envelope } from "./protocol";
import { MESSAGE_TYPES } from "./protocol";
import type { DeviceKeyPair } from "./crypto";
import {
deviceIdFromKeys,
buildEnvelope,
openEnvelope,
fromHex,
} from "./crypto";
import type { ConnectionManager } from "./connection";
import type { ApiClient } from "./api-client";
import { getState, setState, isDomainAllowed, type PeerDevice } from "./storage";
type CookieKey = string;
interface TrackedCookie {
entry: CookieEntry;
lamportTs: number;
sourceDeviceId: string;
}
function cookieKey(domain: string, name: string, path: string): CookieKey {
return `${domain}|${name}|${path}`;
}
function chromeCookieToEntry(cookie: chrome.cookies.Cookie): CookieEntry {
return {
domain: cookie.domain,
name: cookie.name,
value: cookie.value,
path: cookie.path,
secure: cookie.secure,
httpOnly: cookie.httpOnly,
sameSite: cookie.sameSite === "strict"
? "strict"
: cookie.sameSite === "lax"
? "lax"
: "none",
expiresAt: cookie.expirationDate
? new Date(cookie.expirationDate * 1000).toISOString()
: null,
};
}
export class SyncEngine {
private cookies = new Map<CookieKey, TrackedCookie>();
private lamportClock = 0;
private deviceId: string;
private keys: DeviceKeyPair;
private connection: ConnectionManager;
private api: ApiClient;
private suppressLocal = new Set<string>(); // keys to skip on local change (avoid echo)
constructor(
keys: DeviceKeyPair,
connection: ConnectionManager,
api: ApiClient,
) {
this.keys = keys;
this.deviceId = deviceIdFromKeys(keys);
this.connection = connection;
this.api = api;
}
/** Start monitoring cookie changes. */
start() {
chrome.cookies.onChanged.addListener(this.handleCookieChange);
}
stop() {
chrome.cookies.onChanged.removeListener(this.handleCookieChange);
}
/** Handle incoming envelope from WebSocket. */
async handleIncomingEnvelope(envelope: Envelope, peers: PeerDevice[]) {
const peer = peers.find((p) => p.deviceId === envelope.from);
if (!peer) return; // Unknown peer, ignore
const peerEncPub = fromHex(peer.encPub);
const payload = (await openEnvelope(
envelope,
this.keys,
peerEncPub,
)) as CookieSyncPayload;
const applied = this.applyRemote(payload, envelope.from);
for (const entry of applied) {
await this.applyCookieToBrowser(entry, payload.action);
}
// Update stats
const state = await getState();
await setState({
syncCount: state.syncCount + applied.length,
lastSyncAt: new Date().toISOString(),
lamportClock: this.lamportClock,
});
}
/** Sync a specific domain's cookies to all peers. */
async syncDomain(domain: string) {
const cookies = await chrome.cookies.getAll({ domain });
if (cookies.length === 0) return;
const entries = cookies.map(chromeCookieToEntry);
await this.syncEntriesToPeers(entries, "set");
}
/** Sync the current tab's cookies. */
async syncCurrentTab() {
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
if (!tab?.url) return;
try {
const url = new URL(tab.url);
await this.syncDomain(url.hostname);
} catch {
// Invalid URL
}
}
get currentLamportTs(): number {
return this.lamportClock;
}
private handleCookieChange = async (
changeInfo: chrome.cookies.CookieChangeInfo,
) => {
const { cookie, removed, cause } = changeInfo;
// Skip changes caused by us applying remote cookies
const key = cookieKey(cookie.domain, cookie.name, cookie.path);
if (this.suppressLocal.has(key)) {
this.suppressLocal.delete(key);
return;
}
// Only sync user-initiated changes (not expired, not evicted)
if (cause === "expired" || cause === "evicted") return;
const state = await getState();
if (!state.autoSync) return;
if (!isDomainAllowed(cookie.domain, state.whitelist, state.blacklist))
return;
const entry = chromeCookieToEntry(cookie);
const action = removed ? "delete" : "set";
if (!removed) {
this.lamportClock++;
this.cookies.set(key, {
entry,
lamportTs: this.lamportClock,
sourceDeviceId: this.deviceId,
});
} else {
this.cookies.delete(key);
}
await this.syncEntriesToPeers([entry], action);
await setState({ lamportClock: this.lamportClock });
};
private async syncEntriesToPeers(entries: CookieEntry[], action: "set" | "delete") {
const state = await getState();
this.lamportClock++;
const payload: CookieSyncPayload = {
action,
cookies: entries,
lamportTs: this.lamportClock,
};
for (const peer of state.peers) {
const peerEncPub = fromHex(peer.encPub);
const envelope = await buildEnvelope(
MESSAGE_TYPES.COOKIE_SYNC,
payload,
this.keys,
peerEncPub,
peer.deviceId,
);
this.connection.send(envelope as unknown as Envelope);
}
}
private applyRemote(
payload: CookieSyncPayload,
sourceDeviceId: string,
): CookieEntry[] {
this.lamportClock = Math.max(this.lamportClock, payload.lamportTs) + 1;
const applied: CookieEntry[] = [];
for (const entry of payload.cookies) {
const key = cookieKey(entry.domain, entry.name, entry.path);
const existing = this.cookies.get(key);
if (payload.action === "delete") {
if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) {
this.cookies.delete(key);
applied.push(entry);
}
continue;
}
if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) {
this.cookies.set(key, {
entry,
lamportTs: payload.lamportTs,
sourceDeviceId,
});
applied.push(entry);
}
}
return applied;
}
private shouldApply(
existing: TrackedCookie | undefined,
incomingTs: number,
incomingDeviceId: string,
): boolean {
if (!existing) return true;
if (incomingTs > existing.lamportTs) return true;
if (incomingTs === existing.lamportTs) {
return incomingDeviceId > existing.sourceDeviceId;
}
return false;
}
private async applyCookieToBrowser(entry: CookieEntry, action: "set" | "delete") {
const key = cookieKey(entry.domain, entry.name, entry.path);
this.suppressLocal.add(key);
const url = `http${entry.secure ? "s" : ""}://${entry.domain.replace(/^\./, "")}${entry.path}`;
if (action === "delete") {
await chrome.cookies.remove({ url, name: entry.name });
return;
}
const details: chrome.cookies.SetDetails = {
url,
name: entry.name,
value: entry.value,
path: entry.path,
secure: entry.secure,
httpOnly: entry.httpOnly,
sameSite: entry.sameSite === "strict"
? "strict"
: entry.sameSite === "lax"
? "lax"
: "no_restriction",
};
if (entry.expiresAt) {
details.expirationDate = new Date(entry.expiresAt).getTime() / 1000;
}
await chrome.cookies.set(details);
}
}

View File

@@ -0,0 +1,305 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
color: #1f2937;
background: #f9fafb;
line-height: 1.5;
}
.container {
max-width: 640px;
margin: 0 auto;
padding: 32px 24px;
}
header {
margin-bottom: 32px;
}
header h1 {
font-size: 24px;
font-weight: 700;
color: #111827;
}
/* Sections */
.section {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.section h2 {
font-size: 16px;
font-weight: 600;
color: #374151;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #f3f4f6;
}
/* Fields */
.field {
margin-bottom: 14px;
}
.field label {
display: block;
font-size: 13px;
font-weight: 500;
color: #374151;
margin-bottom: 4px;
}
.field .value {
font-size: 14px;
color: #6b7280;
}
.field .value.mono {
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
.field input[type="text"],
.field select {
width: 100%;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
.field input[type="text"]:focus,
.field select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.field-hint {
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
}
.field-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* Range */
.range-group {
display: flex;
align-items: center;
gap: 12px;
}
.range-group input[type="range"] {
flex: 1;
}
.range-group span {
font-size: 13px;
font-weight: 600;
color: #374151;
min-width: 40px;
}
/* Toggle */
.toggle-label {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.toggle-label input[type="checkbox"] {
display: none;
}
.toggle {
width: 44px;
height: 24px;
background: #d1d5db;
border-radius: 12px;
position: relative;
transition: background 0.2s;
}
.toggle::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: #ffffff;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle-label input:checked + .toggle {
background: #3b82f6;
}
.toggle-label input:checked + .toggle::after {
transform: translateX(20px);
}
/* Tags */
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
min-height: 28px;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: #eff6ff;
color: #1d4ed8;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
.tag .remove {
cursor: pointer;
font-size: 14px;
color: #93c5fd;
margin-left: 2px;
}
.tag .remove:hover {
color: #dc2626;
}
.add-tag {
display: flex;
gap: 8px;
}
.add-tag input {
flex: 1;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
outline: none;
}
.add-tag input:focus {
border-color: #3b82f6;
}
/* Peer List */
.peer-list {
min-height: 40px;
}
.peer-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #f3f4f6;
}
.peer-item:last-child {
border-bottom: none;
}
.peer-info .peer-name {
font-weight: 500;
font-size: 14px;
}
.peer-info .peer-id {
font-size: 11px;
color: #9ca3af;
font-family: monospace;
}
.peer-info .peer-date {
font-size: 11px;
color: #9ca3af;
}
.empty-state {
color: #9ca3af;
font-size: 13px;
text-align: center;
padding: 12px;
}
/* Buttons */
.btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-primary {
background: #3b82f6;
color: #ffffff;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-secondary {
background: #f3f4f6;
color: #374151;
}
.btn-secondary:hover {
background: #e5e7eb;
}
.btn-danger {
background: #fef2f2;
color: #dc2626;
}
.btn-danger:hover {
background: #fee2e2;
}
.btn-small {
padding: 6px 12px;
font-size: 12px;
}
/* Save Bar */
.save-bar {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
}
.save-status {
font-size: 13px;
color: #059669;
}

View File

@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CookieBridge Settings</title>
<link rel="stylesheet" href="options.css">
</head>
<body>
<div class="container">
<header>
<h1>CookieBridge Settings</h1>
</header>
<!-- Account Section -->
<section class="section">
<h2>Account</h2>
<div class="field">
<label>Device Name</label>
<span id="opt-device-name" class="value"></span>
</div>
<div class="field">
<label>Device ID</label>
<span id="opt-device-id" class="value mono"></span>
</div>
<div class="field-actions">
<button id="btn-logout" class="btn btn-danger">Log Out</button>
</div>
</section>
<!-- Connection Section -->
<section class="section">
<h2>Connection</h2>
<div class="field">
<label for="opt-server-url">Server URL</label>
<input type="text" id="opt-server-url" placeholder="http://localhost:3000" />
</div>
<div class="field">
<label for="opt-connection-mode">Connection Mode</label>
<select id="opt-connection-mode">
<option value="auto">Auto (WebSocket preferred)</option>
<option value="websocket">WebSocket Only</option>
<option value="polling">HTTP Polling</option>
</select>
</div>
<div class="field" id="field-poll-interval">
<label for="opt-poll-interval">Poll Interval</label>
<div class="range-group">
<input type="range" id="opt-poll-interval" min="1" max="60" value="5" />
<span id="opt-poll-interval-label">5s</span>
</div>
</div>
</section>
<!-- Sync Section -->
<section class="section">
<h2>Sync</h2>
<div class="field">
<label class="toggle-label">
<span>Auto-sync cookies</span>
<input type="checkbox" id="opt-auto-sync" />
<span class="toggle"></span>
</label>
</div>
<div class="field">
<label>Whitelist (sync only these domains)</label>
<div class="tag-list" id="whitelist-tags"></div>
<div class="add-tag">
<input type="text" id="whitelist-input" placeholder="example.com" />
<button id="btn-add-whitelist" class="btn btn-small btn-secondary">Add</button>
</div>
<p class="field-hint">Leave empty to sync all domains (except blacklisted)</p>
</div>
<div class="field">
<label>Blacklist (never sync these domains)</label>
<div class="tag-list" id="blacklist-tags"></div>
<div class="add-tag">
<input type="text" id="blacklist-input" placeholder="*.bank.com" />
<button id="btn-add-blacklist" class="btn btn-small btn-secondary">Add</button>
</div>
</div>
</section>
<!-- Paired Devices Section -->
<section class="section">
<h2>Paired Devices</h2>
<div id="peer-list" class="peer-list">
<p class="empty-state">No paired devices yet</p>
</div>
</section>
<!-- Security Section -->
<section class="section">
<h2>Security</h2>
<div class="field-actions">
<button id="btn-export-keys" class="btn btn-secondary">Export Keys</button>
<button id="btn-import-keys" class="btn btn-secondary">Import Keys</button>
<input type="file" id="file-import-keys" accept=".json" style="display: none" />
</div>
<div class="field-actions" style="margin-top: 12px;">
<button id="btn-clear-data" class="btn btn-danger">Clear All Local Data</button>
</div>
</section>
<!-- Save -->
<div class="save-bar">
<button id="btn-save" class="btn btn-primary">Save Settings</button>
<span id="save-status" class="save-status"></span>
</div>
</div>
<script src="../../dist/options/options.js" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,257 @@
/**
* Options page script — manages extension settings.
*/
export {};
// --- Messaging helper ---
function sendMessage(type: string, payload?: unknown): Promise<any> {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type, payload }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (response?.error) {
reject(new Error(response.error));
return;
}
resolve(response);
});
});
}
// --- Elements ---
const optDeviceName = document.getElementById("opt-device-name")!;
const optDeviceId = document.getElementById("opt-device-id")!;
const btnLogout = document.getElementById("btn-logout") as HTMLButtonElement;
const optServerUrl = document.getElementById("opt-server-url") as HTMLInputElement;
const optConnectionMode = document.getElementById("opt-connection-mode") as HTMLSelectElement;
const optPollInterval = document.getElementById("opt-poll-interval") as HTMLInputElement;
const optPollIntervalLabel = document.getElementById("opt-poll-interval-label")!;
const fieldPollInterval = document.getElementById("field-poll-interval")!;
const optAutoSync = document.getElementById("opt-auto-sync") as HTMLInputElement;
const whitelistTags = document.getElementById("whitelist-tags")!;
const whitelistInput = document.getElementById("whitelist-input") as HTMLInputElement;
const btnAddWhitelist = document.getElementById("btn-add-whitelist") as HTMLButtonElement;
const blacklistTags = document.getElementById("blacklist-tags")!;
const blacklistInput = document.getElementById("blacklist-input") as HTMLInputElement;
const btnAddBlacklist = document.getElementById("btn-add-blacklist") as HTMLButtonElement;
const peerList = document.getElementById("peer-list")!;
const btnExportKeys = document.getElementById("btn-export-keys") as HTMLButtonElement;
const btnImportKeys = document.getElementById("btn-import-keys") as HTMLButtonElement;
const fileImportKeys = document.getElementById("file-import-keys") as HTMLInputElement;
const btnClearData = document.getElementById("btn-clear-data") as HTMLButtonElement;
const btnSave = document.getElementById("btn-save") as HTMLButtonElement;
const saveStatus = document.getElementById("save-status")!;
// --- State ---
let whitelist: string[] = [];
let blacklist: string[] = [];
// --- Load Settings ---
async function loadSettings() {
const state = await chrome.storage.local.get(null);
optDeviceName.textContent = state.deviceName || "—";
optDeviceId.textContent = state.deviceId || "—";
optServerUrl.value = state.serverUrl || "http://localhost:3000";
optConnectionMode.value = state.connectionMode || "auto";
optPollInterval.value = String(state.pollIntervalSec || 5);
optPollIntervalLabel.textContent = `${state.pollIntervalSec || 5}s`;
updatePollVisibility();
optAutoSync.checked = state.autoSync !== false;
whitelist = state.whitelist || [];
blacklist = state.blacklist || [
"*.bank.*",
"*.paypal.com",
"*.stripe.com",
"accounts.google.com",
"login.microsoftonline.com",
];
renderTags(whitelistTags, whitelist, "whitelist");
renderTags(blacklistTags, blacklist, "blacklist");
renderPeers(state.peers || []);
}
function renderTags(container: HTMLElement, items: string[], listName: string) {
container.innerHTML = "";
for (const item of items) {
const tag = document.createElement("span");
tag.className = "tag";
tag.innerHTML = `${item}<span class="remove" data-list="${listName}" data-value="${item}">&times;</span>`;
container.appendChild(tag);
}
}
function renderPeers(peers: Array<{ deviceId: string; name: string; platform: string; pairedAt: string }>) {
if (peers.length === 0) {
peerList.innerHTML = '<p class="empty-state">No paired devices yet</p>';
return;
}
peerList.innerHTML = "";
for (const peer of peers) {
const item = document.createElement("div");
item.className = "peer-item";
item.innerHTML = `
<div class="peer-info">
<div class="peer-name">${peer.name} (${peer.platform})</div>
<div class="peer-id">${peer.deviceId.slice(0, 16)}...</div>
<div class="peer-date">Paired: ${new Date(peer.pairedAt).toLocaleDateString()}</div>
</div>
`;
peerList.appendChild(item);
}
}
function updatePollVisibility() {
fieldPollInterval.style.display =
optConnectionMode.value === "polling" ? "block" : "none";
}
// --- Event Handlers ---
optConnectionMode.addEventListener("change", updatePollVisibility);
optPollInterval.addEventListener("input", () => {
optPollIntervalLabel.textContent = `${optPollInterval.value}s`;
});
// Tag removal (event delegation)
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (!target.classList.contains("remove")) return;
const listName = target.dataset.list;
const value = target.dataset.value;
if (!listName || !value) return;
if (listName === "whitelist") {
whitelist = whitelist.filter((d) => d !== value);
renderTags(whitelistTags, whitelist, "whitelist");
} else {
blacklist = blacklist.filter((d) => d !== value);
renderTags(blacklistTags, blacklist, "blacklist");
}
});
btnAddWhitelist.addEventListener("click", () => {
const domain = whitelistInput.value.trim();
if (domain && !whitelist.includes(domain)) {
whitelist.push(domain);
renderTags(whitelistTags, whitelist, "whitelist");
whitelistInput.value = "";
}
});
btnAddBlacklist.addEventListener("click", () => {
const domain = blacklistInput.value.trim();
if (domain && !blacklist.includes(domain)) {
blacklist.push(domain);
renderTags(blacklistTags, blacklist, "blacklist");
blacklistInput.value = "";
}
});
// Save
btnSave.addEventListener("click", async () => {
await chrome.storage.local.set({
serverUrl: optServerUrl.value.trim(),
connectionMode: optConnectionMode.value,
pollIntervalSec: parseInt(optPollInterval.value),
autoSync: optAutoSync.checked,
whitelist,
blacklist,
});
// Reconnect with new settings
await sendMessage("RECONNECT");
saveStatus.textContent = "Saved!";
setTimeout(() => {
saveStatus.textContent = "";
}, 2000);
});
// Logout
btnLogout.addEventListener("click", async () => {
if (!confirm("Are you sure you want to log out? This will remove your device identity.")) {
return;
}
await sendMessage("LOGOUT");
window.close();
});
// Export keys
btnExportKeys.addEventListener("click", async () => {
const result = await sendMessage("EXPORT_KEYS");
if (!result?.keys) {
alert("No keys to export");
return;
}
const blob = new Blob([JSON.stringify(result.keys, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "cookiebridge-keys.json";
a.click();
URL.revokeObjectURL(url);
});
// Import keys
btnImportKeys.addEventListener("click", () => {
fileImportKeys.click();
});
fileImportKeys.addEventListener("change", async () => {
const file = fileImportKeys.files?.[0];
if (!file) return;
try {
const text = await file.text();
const keys = JSON.parse(text);
if (!keys.signPub || !keys.signSec || !keys.encPub || !keys.encSec) {
throw new Error("Invalid key file");
}
await sendMessage("IMPORT_KEYS", keys);
alert("Keys imported successfully. The extension will reconnect.");
await loadSettings();
} catch (err) {
alert(`Failed to import keys: ${(err as Error).message}`);
}
});
// Clear data
btnClearData.addEventListener("click", async () => {
if (
!confirm(
"This will delete ALL local data including your encryption keys. Are you sure?",
)
) {
return;
}
await sendMessage("LOGOUT");
await chrome.storage.local.clear();
alert("All data cleared.");
window.close();
});
// --- Init ---
loadSettings();

View File

@@ -0,0 +1,317 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 340px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
color: #1f2937;
background: #ffffff;
}
.view {
padding: 16px;
}
/* Login View */
.logo-section {
text-align: center;
padding: 24px 0 16px;
}
.logo {
font-size: 48px;
margin-bottom: 8px;
}
h1 {
font-size: 20px;
font-weight: 600;
color: #111827;
}
.subtitle {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
}
.login-section {
display: flex;
flex-direction: column;
gap: 10px;
}
input[type="text"] {
width: 100%;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
input[type="text"]:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.link {
text-align: center;
font-size: 12px;
color: #3b82f6;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
/* Buttons */
.btn {
width: 100%;
padding: 10px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #3b82f6;
color: #ffffff;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: #f3f4f6;
color: #374151;
}
.btn-secondary:hover:not(:disabled) {
background: #e5e7eb;
}
.btn-ghost {
background: transparent;
color: #6b7280;
}
.btn-ghost:hover {
color: #374151;
}
.btn-small {
width: auto;
padding: 6px 12px;
font-size: 12px;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
}
.avatar {
width: 36px;
height: 36px;
background: #eff6ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.user-details {
display: flex;
flex-direction: column;
}
.name {
font-weight: 600;
font-size: 14px;
}
.device-id {
font-size: 10px;
color: #9ca3af;
font-family: monospace;
}
/* Status Badge */
.status-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
.status-badge .dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-badge.connected {
background: #ecfdf5;
color: #059669;
}
.status-badge.connected .dot {
background: #22c55e;
}
.status-badge.disconnected {
background: #f3f4f6;
color: #6b7280;
}
.status-badge.disconnected .dot {
background: #9ca3af;
}
.status-badge.error {
background: #fef2f2;
color: #dc2626;
}
.status-badge.error .dot {
background: #ef4444;
}
.status-badge.connecting {
background: #fffbeb;
color: #d97706;
}
.status-badge.connecting .dot {
background: #f59e0b;
}
/* Stats Row */
.stats-row {
display: flex;
justify-content: space-around;
padding: 12px 0;
margin-bottom: 16px;
border-top: 1px solid #f3f4f6;
border-bottom: 1px solid #f3f4f6;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.stat-value {
font-size: 20px;
font-weight: 700;
color: #111827;
}
.stat-label {
font-size: 11px;
color: #6b7280;
}
/* Actions */
.actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
/* Pairing Dialog */
.dialog {
background: #f9fafb;
border-radius: 10px;
padding: 16px;
margin-bottom: 16px;
}
.dialog-title {
font-weight: 600;
font-size: 13px;
margin-bottom: 8px;
text-align: center;
}
.pairing-code {
font-size: 32px;
font-weight: 700;
font-family: monospace;
text-align: center;
letter-spacing: 8px;
color: #3b82f6;
padding: 12px;
}
.pairing-input {
text-align: center;
font-size: 24px;
font-weight: 600;
letter-spacing: 6px;
font-family: monospace;
}
.dialog-hint {
font-size: 11px;
color: #6b7280;
text-align: center;
margin-top: 8px;
}
.dialog-actions {
display: flex;
gap: 8px;
margin-top: 12px;
justify-content: center;
}
/* Footer */
.footer {
display: flex;
justify-content: space-between;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
.footer-link {
font-size: 12px;
color: #6b7280;
text-decoration: none;
}
.footer-link:hover {
color: #3b82f6;
}

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CookieBridge</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<!-- Not Logged In View -->
<div id="view-login" class="view" style="display: none;">
<div class="logo-section">
<div class="logo">🍪</div>
<h1>CookieBridge</h1>
<p class="subtitle">Cross-device cookie sync</p>
</div>
<div class="login-section">
<input type="text" id="device-name" placeholder="Device name (e.g. MacBook Pro)" />
<button id="btn-register" class="btn btn-primary">Register Device</button>
<a href="#" id="link-first-use" class="link">First time setup guide</a>
</div>
</div>
<!-- Logged In View -->
<div id="view-main" class="view" style="display: none;">
<!-- Header -->
<div class="header">
<div class="user-info">
<div class="avatar">🍪</div>
<div class="user-details">
<span id="display-name" class="name"></span>
<span id="display-device-id" class="device-id"></span>
</div>
</div>
<div id="connection-status" class="status-badge disconnected">
<span class="dot"></span>
<span class="label">Disconnected</span>
</div>
</div>
<!-- Stats -->
<div class="stats-row">
<div class="stat">
<span id="stat-cookies" class="stat-value">0</span>
<span class="stat-label">Cookies</span>
</div>
<div class="stat">
<span id="stat-devices" class="stat-value">0</span>
<span class="stat-label">Devices</span>
</div>
<div class="stat">
<span id="stat-syncs" class="stat-value">0</span>
<span class="stat-label">Syncs</span>
</div>
</div>
<!-- Actions -->
<div class="actions">
<button id="btn-sync-tab" class="btn btn-primary">Sync Current Site</button>
<button id="btn-add-whitelist" class="btn btn-secondary">Add to Whitelist</button>
<button id="btn-pair" class="btn btn-secondary">Pair Device</button>
</div>
<!-- Pairing Dialog (hidden by default) -->
<div id="pairing-dialog" class="dialog" style="display: none;">
<div id="pairing-initiate" style="display: none;">
<p class="dialog-title">Your Pairing Code</p>
<div id="pairing-code" class="pairing-code"></div>
<p class="dialog-hint">Enter this code on the other device within 5 minutes</p>
</div>
<div id="pairing-accept">
<p class="dialog-title">Enter Pairing Code</p>
<input type="text" id="input-pairing-code" placeholder="000000" maxlength="6" class="pairing-input" />
<div class="dialog-actions">
<button id="btn-pairing-accept" class="btn btn-primary btn-small">Pair</button>
<button id="btn-pairing-generate" class="btn btn-secondary btn-small">Generate Code</button>
<button id="btn-pairing-cancel" class="btn btn-ghost btn-small">Cancel</button>
</div>
</div>
</div>
<!-- Footer -->
<div class="footer">
<a href="#" id="btn-settings" class="footer-link">Settings</a>
<a href="https://github.com/Rc707Agency/cookiebridge" target="_blank" class="footer-link">Help</a>
</div>
</div>
<script src="../../dist/popup/popup.js" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,206 @@
/**
* Popup script — controls the popup UI interactions.
*/
export {};
// --- Elements ---
const viewLogin = document.getElementById("view-login")!;
const viewMain = document.getElementById("view-main")!;
// Login
const inputDeviceName = document.getElementById("device-name") as HTMLInputElement;
const btnRegister = document.getElementById("btn-register") as HTMLButtonElement;
// Main header
const displayName = document.getElementById("display-name")!;
const displayDeviceId = document.getElementById("display-device-id")!;
const connectionStatus = document.getElementById("connection-status")!;
// Stats
const statCookies = document.getElementById("stat-cookies")!;
const statDevices = document.getElementById("stat-devices")!;
const statSyncs = document.getElementById("stat-syncs")!;
// Actions
const btnSyncTab = document.getElementById("btn-sync-tab") as HTMLButtonElement;
const btnAddWhitelist = document.getElementById("btn-add-whitelist") as HTMLButtonElement;
const btnPair = document.getElementById("btn-pair") as HTMLButtonElement;
const btnSettings = document.getElementById("btn-settings")!;
// Pairing
const pairingDialog = document.getElementById("pairing-dialog")!;
const pairingInitiate = document.getElementById("pairing-initiate")!;
const pairingAccept = document.getElementById("pairing-accept")!;
const pairingCode = document.getElementById("pairing-code")!;
const inputPairingCode = document.getElementById("input-pairing-code") as HTMLInputElement;
const btnPairingAccept = document.getElementById("btn-pairing-accept") as HTMLButtonElement;
const btnPairingGenerate = document.getElementById("btn-pairing-generate") as HTMLButtonElement;
const btnPairingCancel = document.getElementById("btn-pairing-cancel") as HTMLButtonElement;
// --- Messaging helper ---
function sendMessage(type: string, payload?: unknown): Promise<any> {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type, payload }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (response?.error) {
reject(new Error(response.error));
return;
}
resolve(response);
});
});
}
// --- UI Updates ---
function showView(loggedIn: boolean) {
viewLogin.style.display = loggedIn ? "none" : "block";
viewMain.style.display = loggedIn ? "block" : "none";
}
function updateConnectionBadge(status: string) {
connectionStatus.className = `status-badge ${status}`;
const label = connectionStatus.querySelector(".label")!;
const labels: Record<string, string> = {
connected: "Connected",
disconnected: "Disconnected",
connecting: "Connecting...",
error: "Error",
};
label.textContent = labels[status] || status;
}
async function refreshStatus() {
try {
const status = await sendMessage("GET_STATUS");
showView(status.loggedIn);
if (status.loggedIn) {
displayName.textContent = status.deviceName || "My Device";
displayDeviceId.textContent = status.deviceId
? status.deviceId.slice(0, 12) + "..."
: "";
updateConnectionBadge(status.connectionStatus);
statCookies.textContent = String(status.cookieCount);
statDevices.textContent = String(status.peerCount);
statSyncs.textContent = String(status.syncCount);
}
} catch (err) {
console.error("Failed to get status:", err);
showView(false);
}
}
// --- Event Handlers ---
btnRegister.addEventListener("click", async () => {
const name = inputDeviceName.value.trim();
if (!name) {
inputDeviceName.focus();
return;
}
btnRegister.disabled = true;
btnRegister.textContent = "Registering...";
try {
await sendMessage("REGISTER_DEVICE", { name });
await refreshStatus();
} catch (err) {
console.error("Registration failed:", err);
btnRegister.textContent = "Registration Failed";
setTimeout(() => {
btnRegister.textContent = "Register Device";
btnRegister.disabled = false;
}, 2000);
}
});
btnSyncTab.addEventListener("click", async () => {
btnSyncTab.disabled = true;
btnSyncTab.textContent = "Syncing...";
try {
await sendMessage("SYNC_CURRENT_TAB");
btnSyncTab.textContent = "Synced!";
setTimeout(() => {
btnSyncTab.textContent = "Sync Current Site";
btnSyncTab.disabled = false;
}, 1500);
} catch (err) {
console.error("Sync failed:", err);
btnSyncTab.textContent = "Sync Failed";
setTimeout(() => {
btnSyncTab.textContent = "Sync Current Site";
btnSyncTab.disabled = false;
}, 2000);
}
});
btnAddWhitelist.addEventListener("click", async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.url) return;
try {
const url = new URL(tab.url);
await sendMessage("ADD_WHITELIST", { domain: url.hostname });
btnAddWhitelist.textContent = "Added!";
setTimeout(() => {
btnAddWhitelist.textContent = "Add to Whitelist";
}, 1500);
} catch (err) {
console.error("Failed to add whitelist:", err);
}
});
// Pairing
btnPair.addEventListener("click", () => {
pairingDialog.style.display = "block";
pairingInitiate.style.display = "none";
pairingAccept.style.display = "block";
inputPairingCode.value = "";
inputPairingCode.focus();
});
btnPairingGenerate.addEventListener("click", async () => {
try {
const result = await sendMessage("INITIATE_PAIRING");
pairingAccept.style.display = "none";
pairingInitiate.style.display = "block";
pairingCode.textContent = result.pairingCode;
} catch (err) {
console.error("Failed to generate pairing code:", err);
}
});
btnPairingAccept.addEventListener("click", async () => {
const code = inputPairingCode.value.trim();
if (code.length !== 6) return;
btnPairingAccept.disabled = true;
try {
await sendMessage("ACCEPT_PAIRING", { code });
pairingDialog.style.display = "none";
await refreshStatus();
} catch (err) {
console.error("Pairing failed:", err);
btnPairingAccept.disabled = false;
}
});
btnPairingCancel.addEventListener("click", () => {
pairingDialog.style.display = "none";
});
btnSettings.addEventListener("click", (e) => {
e.preventDefault();
chrome.runtime.openOptionsPage();
});
// --- Init ---
refreshStatus();

18
extension/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"sourceMap": true,
"lib": ["ES2022", "DOM"],
"types": ["chrome"]
},
"include": ["src/**/*.ts"]
}