Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions examples/kernel-browser-extension/.gitignore
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/
34 changes: 34 additions & 0 deletions examples/kernel-browser-extension/package.json
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>
8 changes: 8 additions & 0 deletions examples/kernel-browser-extension/policy/policy.json.templ
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"
}
}
}
120 changes: 120 additions & 0 deletions examples/kernel-browser-extension/scripts/build_web_artifacts.mjs
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();
113 changes: 113 additions & 0 deletions examples/kernel-browser-extension/src/background.ts
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;
}

Copy link
Copy Markdown

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_ID is initialized to "not-set-yet" and updated only after async jwkToKeyID resolves, while each signed request builds a new Ed25519Signer that copies the current KEY_ID synchronously. Early navigations can emit signatures verifiers reject.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 536ac81. Configure here.


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"],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Domain allowlist filtering is completely missing

High Severity

The PR's core feature — domain allowlist support via signDomains glob patterns — is entirely absent from the implementation. The onBeforeSendHeaders listener only checks details.type !== "main_frame" but performs no domain filtering, so every main_frame request to every URL gets signed. The SIGN_DOMAINS and SIGN_TYPES env var injection described in the PR is also missing from build_web_artifacts.mjs, the chrome.storage.local runtime config code is absent, and the storage permission is not in manifest.json. The extension currently behaves identically to the upstream extension (minus sub-resource signing), defeating its stated purpose.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8c02137. Configure here.


chrome.runtime.onStartup.addListener(() => {
console.log("Kernel Web Bot Auth extension started");
});