diff --git a/examples/kernel-browser-extension/.gitignore b/examples/kernel-browser-extension/.gitignore new file mode 100644 index 0000000..a7a2b75 --- /dev/null +++ b/examples/kernel-browser-extension/.gitignore @@ -0,0 +1,4 @@ +private_key.pem +policy/policy.json +policy/com.google.Chrome.managed.plist +dist/ diff --git a/examples/kernel-browser-extension/package.json b/examples/kernel-browser-extension/package.json new file mode 100644 index 0000000..28fc044 --- /dev/null +++ b/examples/kernel-browser-extension/package.json @@ -0,0 +1,34 @@ +{ + "name": "kernel-web-bot-auth-extension", + "version": "0.3.0", + "description": "Kernel browser extension adding HTTP Message signatures to allowlisted domains", + "scripts": { + "build:chrome": "tsup src/background.ts --format esm --platform browser --target chrome100 --clean --out-dir dist/mv3/chromium --external node:crypto", + "bundle:chrome": "npm run build:chrome && node ./scripts/build_web_artifacts.mjs", + "start:config": "http-server ./dist/web-ext-artifacts -p 8000", + "test": "echo \"Error: no test specified\" && exit 1", + "clean": "rimraf dist" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/kernel/web-bot-auth.git", + "directory": "examples/kernel-browser-extension" + }, + "keywords": [ + "chrome-extension", + "cryptography", + "typescript", + "http-message-signatures", + "rfc9421", + "kernel" + ], + "author": "Kernel", + "license": "Apache-2.0", + "devDependencies": { + "@types/chrome": "0.0.326", + "@types/libsodium-wrappers": "0.7.14", + "crx": "5.0.1", + "http-server": "14.1.1", + "libsodium-wrappers": "0.7.15" + } +} diff --git a/examples/kernel-browser-extension/platform/mv3/chromium/manifest.json b/examples/kernel-browser-extension/platform/mv3/chromium/manifest.json new file mode 100644 index 0000000..53747cc --- /dev/null +++ b/examples/kernel-browser-extension/platform/mv3/chromium/manifest.json @@ -0,0 +1,13 @@ +{ + "manifest_version": 3, + "name": "Kernel Web Bot Auth", + "permissions": ["webRequest", "webRequestBlocking"], + "host_permissions": [""], + "background": { + "service_worker": "background.mjs", + "type": "module" + }, + "content_security_policy": { + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';" + } +} diff --git a/examples/kernel-browser-extension/policy/com.google.Chrome.managed.plist.templ b/examples/kernel-browser-extension/policy/com.google.Chrome.managed.plist.templ new file mode 100644 index 0000000..94b5172 --- /dev/null +++ b/examples/kernel-browser-extension/policy/com.google.Chrome.managed.plist.templ @@ -0,0 +1,21 @@ + + + + + ExtensionSettings + + * + + installation_mode + blocked + + EXTENSION_ID_REPLACED_BY_NPM_RUN_BUNDLE_CHROME + + installation_mode + force_installed + update_url + http://localhost:8000/update.xml + + + + diff --git a/examples/kernel-browser-extension/policy/policy.json.templ b/examples/kernel-browser-extension/policy/policy.json.templ new file mode 100644 index 0000000..4ffb632 --- /dev/null +++ b/examples/kernel-browser-extension/policy/policy.json.templ @@ -0,0 +1,8 @@ +{ + "ExtensionSettings": { + "EXTENSION_ID_REPLACED_BY_NPM_RUN_BUNDLE_CHROME": { + "installation_mode": "force_installed", + "update_url": "http://localhost:8000/update.xml" + } + } +} diff --git a/examples/kernel-browser-extension/scripts/build_web_artifacts.mjs b/examples/kernel-browser-extension/scripts/build_web_artifacts.mjs new file mode 100644 index 0000000..b5c7ef1 --- /dev/null +++ b/examples/kernel-browser-extension/scripts/build_web_artifacts.mjs @@ -0,0 +1,120 @@ +import ChromeExtension from "crx"; +import * as fs from "node:fs"; +import path from "node:path"; +const { KeyObject } = await import("node:crypto"); +const { subtle } = globalThis.crypto; +import pkg from "../package.json" with { type: "json" }; + +function makePolicy(extensionID) { + const MarkerString = "EXTENSION_ID_REPLACED_BY_NPM_RUN_BUNDLE_CHROME"; + const policyPath = path.join(path.dirname("."), "policy"); + if (!fs.existsSync(policyPath)) { + fs.mkdirSync(policyPath, { recursive: true }); + } + + for (let fileName of ["com.google.Chrome.managed.plist", "policy.json"]) { + const template = fs.readFileSync( + path.join(policyPath, fileName + ".templ"), + "utf8" + ); + const fileContent = template.split(MarkerString).join(extensionID); + fs.writeFileSync(path.join(policyPath, fileName), fileContent); + } +} + +function setManifestVersion(version) { + const manifestInputPath = path.join( + path.dirname("."), + "platform", + "mv3", + "chromium", + "manifest.json" + ); + const manifestOutputPath = path.join( + path.dirname("."), + "dist", + "mv3", + "chromium", + "manifest.json" + ); + const manifestStr = fs.readFileSync(manifestInputPath, "utf8"); + const manifest = JSON.parse(manifestStr); + manifest.version = version; + fs.writeFileSync(manifestOutputPath, JSON.stringify(manifest, null, 2)); +} + +function injectSignatureAgent() { + const backgroundPath = path.join( + path.dirname("."), + "dist", + "mv3", + "chromium", + "background.mjs" + ); + + let content = fs.readFileSync(backgroundPath, "utf8"); + + // tsup may compile `const` to `var`, so match both. + const signatureAgentUrl = process.env.SIGNATURE_AGENT_URL || ""; + const replaced = content.replace( + /(?:const|var|let) signatureAgentUrl\s*=\s*["']{2};/g, + `var signatureAgentUrl = ${JSON.stringify(signatureAgentUrl)};` + ); + + if (replaced !== content) { + fs.writeFileSync(backgroundPath, replaced); + console.log("Injected SIGNATURE_AGENT_URL:", signatureAgentUrl); + } else if (signatureAgentUrl) { + console.warn("SIGNATURE_AGENT_URL set but no injection point found"); + } +} + +async function main() { + const distPath = path.join(path.dirname("."), "dist", "web-ext-artifacts"); + if (!fs.existsSync(distPath)) { + fs.mkdirSync(distPath, { recursive: true }); + } + + const { privateKey, publicKey } = await subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: Uint8Array.from([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"] + ); + + const skPEM = KeyObject.from(privateKey).export({ + type: "pkcs8", + format: "pem", + }); + const pkBytes = KeyObject.from(publicKey).export({ + type: "pkcs1", + format: "der", + }); + + const crx = new ChromeExtension({ + codebase: "http://localhost:8000/" + pkg.name + ".crx", + privateKey: skPEM, + publicKey: pkBytes, + }); + + setManifestVersion(pkg.version); + + injectSignatureAgent(); + + await crx.load(path.join(path.dirname("."), "dist", "mv3", "chromium")); + const extensionBytes = await crx.pack(); + const extensionID = crx.generateAppId(); + + fs.writeFileSync("private_key.pem", skPEM); + fs.writeFileSync(path.join(distPath, pkg.name + ".crx"), extensionBytes); + fs.writeFileSync(path.join(distPath, "update.xml"), crx.generateUpdateXML()); + makePolicy(extensionID); + + console.log(`Build Extension with ID: ${extensionID}`); +} + +await main(); diff --git a/examples/kernel-browser-extension/src/background.ts b/examples/kernel-browser-extension/src/background.ts new file mode 100644 index 0000000..7dda07f --- /dev/null +++ b/examples/kernel-browser-extension/src/background.ts @@ -0,0 +1,113 @@ +import { + Algorithm, + signatureHeadersSync, + helpers, + jwkToKeyID, +} from "web-bot-auth"; +import _sodium from "libsodium-wrappers"; +import jwk from "../../rfc9421-keys/ed25519.json" assert { type: "json" }; + +// ── Config ────────────────────────────────────────────────────────────────── + +// Build-time placeholder -- replaced by build_web_artifacts.mjs via env var. +const signatureAgentUrl = ''; + +// ── Signing ───────────────────────────────────────────────────────────────── + +let KEY_ID = "not-set-yet"; +jwkToKeyID(jwk, helpers.WEBCRYPTO_SHA256, helpers.BASE64URL_DECODE).then( + (kid) => (KEY_ID = kid), +); + +const MAX_AGE_IN_MS = 1000 * 60 * 60; // 1 hour + +class Ed25519Signer { + public alg: Algorithm = "ed25519"; + public keyid: string; + private privateKey: Uint8Array; + + constructor(public jwk: JsonWebKey) { + const sodium = _sodium; + const base64urlDecode = (str: string) => + sodium.from_base64(str, sodium.base64_variants.URLSAFE_NO_PADDING); + + const privateKey = base64urlDecode(jwk.d!); + const publicKey = base64urlDecode(jwk.x!); + + const fullSecretKey = new Uint8Array(64); + fullSecretKey.set(privateKey); + fullSecretKey.set(publicKey, 32); + + this.privateKey = fullSecretKey; + this.keyid = KEY_ID; + } + + signSync(data: string): Uint8Array { + const sodium = _sodium; + const message = sodium.from_string(data); + const signedMessage = sodium.crypto_sign(message, this.privateKey); + return signedMessage.slice(0, sodium.crypto_sign_BYTES); + } +} + +// ── Request listener ──────────────────────────────────────────────────────── + +// Requests whose path contains this segment are never signed. Cloudflare's +// challenge orchestration lives under /cdn-cgi/; signing those requests breaks +// challenge flows (e.g. Turnstile) with "Incompatible browser extension". +const EXCLUDED_PATH_SUBSTRING = "cdn-cgi/"; + +function isExcludedPath(url: string): boolean { + try { + return new URL(url).pathname.includes(EXCLUDED_PATH_SUBSTRING); + } catch { + return false; + } +} + +chrome.webRequest.onBeforeSendHeaders.addListener( + function (details) { + if (details.type !== "main_frame") { + return { requestHeaders: details.requestHeaders }; + } + + if (isExcludedPath(details.url)) { + return { requestHeaders: details.requestHeaders }; + } + + if (signatureAgentUrl) { + details.requestHeaders?.push({ + name: "Signature-Agent", + value: `"${signatureAgentUrl}"`, + }); + } + + const request = new Request(details.url, { + method: details.method, + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + headers: details.requestHeaders?.map((h) => [h.name, h.value!])!, + }); + const now = new Date(); + const headers = signatureHeadersSync(request, new Ed25519Signer(jwk), { + created: now, + expires: new Date(now.getTime() + MAX_AGE_IN_MS), + }); + + details.requestHeaders?.push({ + name: "Signature", + value: headers["Signature"], + }); + details.requestHeaders?.push({ + name: "Signature-Input", + value: headers["Signature-Input"], + }); + + return { requestHeaders: details.requestHeaders }; + }, + { urls: [""] }, + ["blocking", "requestHeaders"], +); + +chrome.runtime.onStartup.addListener(() => { + console.log("Kernel Web Bot Auth extension started"); +});