-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add Kernel browser extension with domain allowlist support #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c40bbe3
bdf1f32
d731491
502f98d
2026c98
8c02137
536ac81
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| private_key.pem | ||
| policy/policy.json | ||
| policy/com.google.Chrome.managed.plist | ||
| dist/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| { | ||
| "manifest_version": 3, | ||
| "name": "Kernel Web Bot Auth", | ||
| "permissions": ["webRequest", "webRequestBlocking"], | ||
| "host_permissions": ["<all_urls>"], | ||
| "background": { | ||
| "service_worker": "background.mjs", | ||
| "type": "module" | ||
| }, | ||
| "content_security_policy": { | ||
| "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| <plist version="1.0"> | ||
| <dict> | ||
| <key>ExtensionSettings</key> | ||
| <dict> | ||
| <key>*</key> | ||
| <dict> | ||
| <key>installation_mode</key> | ||
| <string>blocked</string> | ||
| </dict> | ||
| <key>EXTENSION_ID_REPLACED_BY_NPM_RUN_BUNDLE_CHROME</key> | ||
| <dict> | ||
| <key>installation_mode</key> | ||
| <string>force_installed</string> | ||
| <key>update_url</key> | ||
| <string>http://localhost:8000/update.xml</string> | ||
| </dict> | ||
| </dict> | ||
| </dict> | ||
| </plist> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| { | ||
| "ExtensionSettings": { | ||
| "EXTENSION_ID_REPLACED_BY_NPM_RUN_BUNDLE_CHROME": { | ||
| "installation_mode": "force_installed", | ||
| "update_url": "http://localhost:8000/update.xml" | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: ["<all_urls>"] }, | ||
| ["blocking", "requestHeaders"], | ||
| ); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Domain allowlist filtering is completely missingHigh Severity The PR's core feature — domain allowlist support via Additional Locations (1)Reviewed by Cursor Bugbot for commit 8c02137. Configure here. |
||
|
|
||
| chrome.runtime.onStartup.addListener(() => { | ||
| console.log("Kernel Web Bot Auth extension started"); | ||
| }); | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Key ID set asynchronously
Medium Severity
KEY_IDis initialized to"not-set-yet"and updated only after asyncjwkToKeyIDresolves, while each signed request builds a newEd25519Signerthat copies the currentKEY_IDsynchronously. Early navigations can emit signatures verifiers reject.Additional Locations (1)
examples/kernel-browser-extension/src/background.ts#L90-L91Reviewed by Cursor Bugbot for commit 536ac81. Configure here.