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"
}
}
}
153 changes: 153 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,153 @@
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");
}
}

function injectUserAgentOverride() {
if (process.env.USER_AGENT_OVERRIDE === undefined) {
return;
}

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. The source default is a
// non-empty string, so match any quoted value.
const userAgentOverride = process.env.USER_AGENT_OVERRIDE;
const replaced = content.replace(
/(?:const|var|let) userAgentOverride\s*=\s*["'][^"']*["'];/g,
`var userAgentOverride = ${JSON.stringify(userAgentOverride)};`
);

if (replaced !== content) {
fs.writeFileSync(backgroundPath, replaced);
console.log("Injected USER_AGENT_OVERRIDE:", userAgentOverride);
} else {
console.warn("USER_AGENT_OVERRIDE 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();

injectUserAgentOverride();

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();
119 changes: 119 additions & 0 deletions examples/kernel-browser-extension/src/background.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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 = '';

// User-Agent advertised on signed (main_frame) navigations so the request
// carries our verified bot identity. This is a temporary measure until the UA
// is set natively in the browser image; note it only rewrites the HTTP header,
// not navigator.userAgent. Overridable at build time via USER_AGENT_OVERRIDE
// (set to an empty string to disable the rewrite).
const userAgentOverride = 'KernelSearchBot';

// ── 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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Crypto used before libsodium ready

High Severity

The extension never awaits _sodium.ready before from_base64 / crypto_sign in the blocking onBeforeSendHeaders path. After a service-worker restart, the first main_frame sign can throw while WASM is still initializing, so the navigation goes out unsigned or fails outright.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 51ade52. Configure here.

}

// ── Request listener ────────────────────────────────────────────────────────

chrome.webRequest.onBeforeSendHeaders.addListener(
function (details) {
if (details.type !== "main_frame") {
return { requestHeaders: details.requestHeaders };
}

// Rewrite the UA before signing so the signed message and the headers
// actually sent stay consistent (the signature may cover user-agent).
if (userAgentOverride && details.requestHeaders) {
const existing = details.requestHeaders.find(
(h) => h.name.toLowerCase() === "user-agent",
);
if (existing) {
existing.value = userAgentOverride;
} else {
details.requestHeaders.push({
name: "User-Agent",
value: userAgentOverride,
});
}
}

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");
});