diff --git a/extension/.gitignore b/extension/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/extension/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/extension/esbuild.config.mjs b/extension/esbuild.config.mjs new file mode 100644 index 0000000..695c1f8 --- /dev/null +++ b/extension/esbuild.config.mjs @@ -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."); +} diff --git a/extension/generate-icons.mjs b/extension/generate-icons.mjs new file mode 100644 index 0000000..b23d2f1 --- /dev/null +++ b/extension/generate-icons.mjs @@ -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}`); + } +} diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..4a8efe1 --- /dev/null +++ b/extension/manifest.json @@ -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": [""], + "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" + } +} diff --git a/extension/package-lock.json b/extension/package-lock.json new file mode 100644 index 0000000..5a59ee5 --- /dev/null +++ b/extension/package-lock.json @@ -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" + } + } + } +} diff --git a/extension/package.json b/extension/package.json new file mode 100644 index 0000000..4242c91 --- /dev/null +++ b/extension/package.json @@ -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" + } +} diff --git a/extension/src/background/service-worker.ts b/extension/src/background/service-worker.ts new file mode 100644 index 0000000..20e37ef --- /dev/null +++ b/extension/src/background/service-worker.ts @@ -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 { + 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(); diff --git a/extension/src/icons/generate-icons.html b/extension/src/icons/generate-icons.html new file mode 100644 index 0000000..5304b40 --- /dev/null +++ b/extension/src/icons/generate-icons.html @@ -0,0 +1,45 @@ + + +Generate CookieBridge Icons + + + + diff --git a/extension/src/icons/icon-blue-128.png b/extension/src/icons/icon-blue-128.png new file mode 100644 index 0000000..b6785dd Binary files /dev/null and b/extension/src/icons/icon-blue-128.png differ diff --git a/extension/src/icons/icon-blue-16.png b/extension/src/icons/icon-blue-16.png new file mode 100644 index 0000000..bacdac5 Binary files /dev/null and b/extension/src/icons/icon-blue-16.png differ diff --git a/extension/src/icons/icon-blue-48.png b/extension/src/icons/icon-blue-48.png new file mode 100644 index 0000000..128994d Binary files /dev/null and b/extension/src/icons/icon-blue-48.png differ diff --git a/extension/src/icons/icon-gray-128.png b/extension/src/icons/icon-gray-128.png new file mode 100644 index 0000000..bd4a3be Binary files /dev/null and b/extension/src/icons/icon-gray-128.png differ diff --git a/extension/src/icons/icon-gray-16.png b/extension/src/icons/icon-gray-16.png new file mode 100644 index 0000000..7302acb Binary files /dev/null and b/extension/src/icons/icon-gray-16.png differ diff --git a/extension/src/icons/icon-gray-48.png b/extension/src/icons/icon-gray-48.png new file mode 100644 index 0000000..d5bc5c4 Binary files /dev/null and b/extension/src/icons/icon-gray-48.png differ diff --git a/extension/src/icons/icon-green-128.png b/extension/src/icons/icon-green-128.png new file mode 100644 index 0000000..f93c0bb Binary files /dev/null and b/extension/src/icons/icon-green-128.png differ diff --git a/extension/src/icons/icon-green-16.png b/extension/src/icons/icon-green-16.png new file mode 100644 index 0000000..76b53d1 Binary files /dev/null and b/extension/src/icons/icon-green-16.png differ diff --git a/extension/src/icons/icon-green-48.png b/extension/src/icons/icon-green-48.png new file mode 100644 index 0000000..36852ca Binary files /dev/null and b/extension/src/icons/icon-green-48.png differ diff --git a/extension/src/icons/icon-red-128.png b/extension/src/icons/icon-red-128.png new file mode 100644 index 0000000..68f0cf5 Binary files /dev/null and b/extension/src/icons/icon-red-128.png differ diff --git a/extension/src/icons/icon-red-16.png b/extension/src/icons/icon-red-16.png new file mode 100644 index 0000000..75ea2be Binary files /dev/null and b/extension/src/icons/icon-red-16.png differ diff --git a/extension/src/icons/icon-red-48.png b/extension/src/icons/icon-red-48.png new file mode 100644 index 0000000..3f26c3f Binary files /dev/null and b/extension/src/icons/icon-red-48.png differ diff --git a/extension/src/lib/api-client.ts b/extension/src/lib/api-client.ts new file mode 100644 index 0000000..13b8148 --- /dev/null +++ b/extension/src/lib/api-client.ts @@ -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( + method: string, + path: string, + body?: unknown, + ): Promise { + const headers: Record = { + "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 { + 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 { + 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 { + await this.request("POST", "/api/cookies", { cookies: blobs }); + } + + async pullCookies(domain?: string): Promise { + const params = domain ? `?domain=${encodeURIComponent(domain)}` : ""; + return this.request("GET", `/api/cookies${params}`); + } + + async pullUpdates(since: string): Promise { + return this.request( + "GET", + `/api/cookies/updates?since=${encodeURIComponent(since)}`, + ); + } + + async deleteCookie( + domain: string, + cookieName: string, + path: string, + ): Promise { + await this.request("DELETE", "/api/cookies", { + domain, + cookieName, + path, + }); + } + + // --- Health --- + + async health(): Promise<{ status: string; connections: number }> { + return this.request("GET", "/health"); + } +} diff --git a/extension/src/lib/badge.ts b/extension/src/lib/badge.ts new file mode 100644 index 0000000..205db28 --- /dev/null +++ b/extension/src/lib/badge.ts @@ -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 { + 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); +} diff --git a/extension/src/lib/connection.ts b/extension/src/lib/connection.ts new file mode 100644 index 0000000..2333300 --- /dev/null +++ b/extension/src/lib/connection.ts @@ -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 | null = null; + private pongTimer: ReturnType | null = null; + private reconnectTimer: ReturnType | 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); + } +} diff --git a/extension/src/lib/crypto.ts b/extension/src/lib/crypto.ts new file mode 100644 index 0000000..9e9b04b --- /dev/null +++ b/extension/src/lib/crypto.ts @@ -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 | null = null; + +async function getSodium(): Promise { + 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 { + 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 { + 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 { + 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 { + const sodium = await getSodium(); + return sodium.crypto_sign_detached(message, signSec); +} + +export async function verify( + message: Uint8Array, + sig: Uint8Array, + signPub: Uint8Array, +): Promise { + 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 { + 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 }; diff --git a/extension/src/lib/hex.ts b/extension/src/lib/hex.ts new file mode 100644 index 0000000..96c208e --- /dev/null +++ b/extension/src/lib/hex.ts @@ -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; +} diff --git a/extension/src/lib/libsodium-wrappers-sumo.d.ts b/extension/src/lib/libsodium-wrappers-sumo.d.ts new file mode 100644 index 0000000..b0fb125 --- /dev/null +++ b/extension/src/lib/libsodium-wrappers-sumo.d.ts @@ -0,0 +1,40 @@ +declare module "libsodium-wrappers-sumo" { + interface KeyPair { + publicKey: Uint8Array; + privateKey: Uint8Array; + keyType: string; + } + + interface Sodium { + ready: Promise; + 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; +} diff --git a/extension/src/lib/protocol.ts b/extension/src/lib/protocol.ts new file mode 100644 index 0000000..153121e --- /dev/null +++ b/extension/src/lib/protocol.ts @@ -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; +} diff --git a/extension/src/lib/storage.ts b/extension/src/lib/storage.ts new file mode 100644 index 0000000..d0a2095 --- /dev/null +++ b/extension/src/lib/storage.ts @@ -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 { + const data = await chrome.storage.local.get(null); + return { ...DEFAULT_STATE, ...data } as ExtensionState; +} + +/** Update specific state fields. */ +export async function setState( + partial: Partial, +): Promise { + await chrome.storage.local.set(partial); +} + +/** Reset all state to defaults. */ +export async function clearState(): Promise { + 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; +} diff --git a/extension/src/lib/sync.ts b/extension/src/lib/sync.ts new file mode 100644 index 0000000..868ab24 --- /dev/null +++ b/extension/src/lib/sync.ts @@ -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(); + private lamportClock = 0; + private deviceId: string; + private keys: DeviceKeyPair; + private connection: ConnectionManager; + private api: ApiClient; + private suppressLocal = new Set(); // 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); + } +} diff --git a/extension/src/options/options.css b/extension/src/options/options.css new file mode 100644 index 0000000..9fc2799 --- /dev/null +++ b/extension/src/options/options.css @@ -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; +} diff --git a/extension/src/options/options.html b/extension/src/options/options.html new file mode 100644 index 0000000..7080aec --- /dev/null +++ b/extension/src/options/options.html @@ -0,0 +1,114 @@ + + + + + + CookieBridge Settings + + + +
+
+

CookieBridge Settings

+
+ + +
+

Account

+
+ + +
+
+ + +
+
+ +
+
+ + +
+

Connection

+
+ + +
+
+ + +
+
+ +
+ + 5s +
+
+
+ + +
+

Sync

+
+ +
+
+ +
+
+ + +
+

Leave empty to sync all domains (except blacklisted)

+
+
+ +
+
+ + +
+
+
+ + +
+

Paired Devices

+
+

No paired devices yet

+
+
+ + +
+

Security

+
+ + + +
+
+ +
+
+ + +
+ + +
+
+ + + + diff --git a/extension/src/options/options.ts b/extension/src/options/options.ts new file mode 100644 index 0000000..2b0563d --- /dev/null +++ b/extension/src/options/options.ts @@ -0,0 +1,257 @@ +/** + * Options page script — manages extension settings. + */ +export {}; + +// --- Messaging helper --- + +function sendMessage(type: string, payload?: unknown): Promise { + 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}×`; + container.appendChild(tag); + } +} + +function renderPeers(peers: Array<{ deviceId: string; name: string; platform: string; pairedAt: string }>) { + if (peers.length === 0) { + peerList.innerHTML = '

No paired devices yet

'; + return; + } + + peerList.innerHTML = ""; + for (const peer of peers) { + const item = document.createElement("div"); + item.className = "peer-item"; + item.innerHTML = ` +
+
${peer.name} (${peer.platform})
+
${peer.deviceId.slice(0, 16)}...
+
Paired: ${new Date(peer.pairedAt).toLocaleDateString()}
+
+ `; + 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(); diff --git a/extension/src/popup/popup.css b/extension/src/popup/popup.css new file mode 100644 index 0000000..ba5a7f7 --- /dev/null +++ b/extension/src/popup/popup.css @@ -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; +} diff --git a/extension/src/popup/popup.html b/extension/src/popup/popup.html new file mode 100644 index 0000000..f9240d8 --- /dev/null +++ b/extension/src/popup/popup.html @@ -0,0 +1,91 @@ + + + + + + CookieBridge + + + + + + + + + + + + diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts new file mode 100644 index 0000000..8b01929 --- /dev/null +++ b/extension/src/popup/popup.ts @@ -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 { + 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 = { + 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(); diff --git a/extension/tsconfig.json b/extension/tsconfig.json new file mode 100644 index 0000000..c306868 --- /dev/null +++ b/extension/tsconfig.json @@ -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"] +}