From e271fbca73c6c5a00775ad6fffb4c771f5fa1aa8 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 1 Dec 2025 15:42:40 -0800 Subject: [PATCH 1/2] feat: add build-web-bot-auth CLI command for Web Bot Auth extension Add a new `kernel extensions build-web-bot-auth` command that: - Downloads Cloudflare's web-bot-auth browser extension from GitHub - Builds it with a configurable Ed25519 signing key (defaults to RFC9421 test key) - Optionally uploads the built extension to Kernel Also adds a test script (scripts/test-web-bot-auth.ts) for verifying the extension works against Cloudflare's test site. --- cmd/extensions.go | 299 +++++++++++++++++++++++++++++++++++ scripts/test-web-bot-auth.ts | 113 +++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 scripts/test-web-bot-auth.ts diff --git a/cmd/extensions.go b/cmd/extensions.go index 68b880b..174c9ac 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -1,13 +1,18 @@ package cmd import ( + "archive/tar" "bytes" + "compress/gzip" "context" + "encoding/json" "fmt" "io" "net/http" "os" + "os/exec" "path/filepath" + "strings" "time" "github.com/onkernel/cli/pkg/util" @@ -49,6 +54,13 @@ type ExtensionsUploadInput struct { Name string } +type ExtensionsBuildWebBotAuthInput struct { + Output string + KeyFile string + Upload bool + Name string +} + // ExtensionsCmd handles extension operations independent of cobra. type ExtensionsCmd struct { extensions ExtensionsService @@ -307,6 +319,246 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err return nil } +// RFC9421 test key for Cloudflare's test site +const defaultWebBotAuthKey = `{"kty":"OKP","crv":"Ed25519","d":"n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU","x":"JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"}` + +func (e ExtensionsCmd) BuildWebBotAuth(ctx context.Context, in ExtensionsBuildWebBotAuthInput) error { + if in.Output == "" { + return fmt.Errorf("missing --to output directory") + } + + // Check npm is available + if _, err := exec.LookPath("npm"); err != nil { + return fmt.Errorf("npm is required but not found in PATH. Please install Node.js and npm") + } + + // Resolve output directory + outDir, err := filepath.Abs(in.Output) + if err != nil { + return fmt.Errorf("failed to resolve output path: %w", err) + } + + // Ensure output directory exists and is empty + if st, err := os.Stat(outDir); err == nil { + if !st.IsDir() { + return fmt.Errorf("output path exists and is not a directory: %s", outDir) + } + entries, _ := os.ReadDir(outDir) + if len(entries) > 0 { + return fmt.Errorf("output directory must be empty: %s", outDir) + } + } else { + if err := os.MkdirAll(outDir, 0o755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + } + + // Determine the signing key to use + var keyJSON string + if in.KeyFile != "" { + keyData, err := os.ReadFile(in.KeyFile) + if err != nil { + return fmt.Errorf("failed to read key file: %w", err) + } + // Validate it's valid JSON + var keyObj map[string]interface{} + if err := json.Unmarshal(keyData, &keyObj); err != nil { + return fmt.Errorf("key file is not valid JSON: %w", err) + } + keyJSON = string(keyData) + pterm.Info.Printf("Using signing key from: %s\n", in.KeyFile) + } else { + keyJSON = defaultWebBotAuthKey + pterm.Info.Println("Using default RFC9421 test key (for Cloudflare's test site)") + } + + // Create temp directory for building + tmpDir, err := os.MkdirTemp("", "kernel-web-bot-auth-*") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + // Download web-bot-auth repo tarball + pterm.Info.Println("Downloading web-bot-auth from GitHub...") + tarballURL := "https://github.com/cloudflare/web-bot-auth/archive/refs/heads/main.tar.gz" + resp, err := http.Get(tarballURL) + if err != nil { + return fmt.Errorf("failed to download web-bot-auth: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download web-bot-auth: HTTP %d", resp.StatusCode) + } + + // Extract tarball + pterm.Info.Println("Extracting...") + if err := extractTarGz(resp.Body, tmpDir); err != nil { + return fmt.Errorf("failed to extract tarball: %w", err) + } + + // Find the extracted directory (it will be named web-bot-auth-main) + repoDir := filepath.Join(tmpDir, "web-bot-auth-main") + if _, err := os.Stat(repoDir); err != nil { + return fmt.Errorf("extracted directory not found: %w", err) + } + + // Write the signing key + keyDir := filepath.Join(repoDir, "examples", "rfc9421-keys") + if err := os.MkdirAll(keyDir, 0o755); err != nil { + return fmt.Errorf("failed to create key directory: %w", err) + } + keyPath := filepath.Join(keyDir, "ed25519.json") + if err := os.WriteFile(keyPath, []byte(keyJSON), 0o644); err != nil { + return fmt.Errorf("failed to write signing key: %w", err) + } + + // Remove package-lock.json to work around npm optional dependencies bug + // See: https://github.com/npm/cli/issues/4828 + _ = os.Remove(filepath.Join(repoDir, "package-lock.json")) + + // Run npm install at the repo root (workspace root) to install all dependencies including tsup + pterm.Info.Println("Installing dependencies (npm install)...") + npmInstall := exec.CommandContext(ctx, "npm", "install") + npmInstall.Dir = repoDir + npmInstall.Stdout = os.Stdout + npmInstall.Stderr = os.Stderr + if err := npmInstall.Run(); err != nil { + return fmt.Errorf("npm install failed: %w", err) + } + + // Build the web-bot-auth package first (the browser extension depends on it) + pterm.Info.Println("Building web-bot-auth package...") + npmBuildPkg := exec.CommandContext(ctx, "npm", "run", "build") + npmBuildPkg.Dir = repoDir + npmBuildPkg.Stdout = os.Stdout + npmBuildPkg.Stderr = os.Stderr + if err := npmBuildPkg.Run(); err != nil { + return fmt.Errorf("npm run build failed: %w", err) + } + + // Run npm run build:chrome in the browser-extension directory + extDir := filepath.Join(repoDir, "examples", "browser-extension") + pterm.Info.Println("Building extension (npm run build:chrome)...") + npmBuild := exec.CommandContext(ctx, "npm", "run", "build:chrome") + npmBuild.Dir = extDir + npmBuild.Stdout = os.Stdout + npmBuild.Stderr = os.Stderr + if err := npmBuild.Run(); err != nil { + return fmt.Errorf("npm run build:chrome failed: %w", err) + } + + // Copy built extension to output directory + builtDir := filepath.Join(extDir, "dist", "mv3", "chromium") + manifestSrc := filepath.Join(extDir, "platform", "mv3", "chromium", "manifest.json") + + // Copy background.mjs + bgSrc := filepath.Join(builtDir, "background.mjs") + if err := copyFile(bgSrc, filepath.Join(outDir, "background.mjs")); err != nil { + return fmt.Errorf("failed to copy background.mjs: %w", err) + } + + // Read manifest and add version (the build script doesn't add it, only bundle does) + manifestData, err := os.ReadFile(manifestSrc) + if err != nil { + return fmt.Errorf("failed to read manifest.json: %w", err) + } + var manifest map[string]interface{} + if err := json.Unmarshal(manifestData, &manifest); err != nil { + return fmt.Errorf("failed to parse manifest.json: %w", err) + } + manifest["version"] = "1.0.0" + manifestOut, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal manifest.json: %w", err) + } + if err := os.WriteFile(filepath.Join(outDir, "manifest.json"), manifestOut, 0o644); err != nil { + return fmt.Errorf("failed to write manifest.json: %w", err) + } + + pterm.Success.Printf("Built extension to: %s\n", outDir) + + // Optionally upload + if in.Upload { + name := in.Name + if name == "" { + name = "web-bot-auth" + } + pterm.Info.Printf("Uploading extension as '%s'...\n", name) + if err := e.Upload(ctx, ExtensionsUploadInput{Dir: outDir, Name: name}); err != nil { + return err + } + } + + return nil +} + +// extractTarGz extracts a tar.gz stream to the destination directory +func extractTarGz(r io.Reader, destDir string) error { + gzr, err := gzip.NewReader(r) + if err != nil { + return err + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + target := filepath.Join(destDir, header.Name) + + // Protect against directory traversal + if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", header.Name) + } + + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode)) + if err != nil { + return err + } + if _, err := io.Copy(f, tr); err != nil { + f.Close() + return err + } + f.Close() + } + } + return nil +} + +// copyFile copies a file from src to dst +func copyFile(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + dstFile, err := os.Create(dst) + if err != nil { + return err + } + defer dstFile.Close() + + _, err = io.Copy(dstFile, srcFile) + return err +} + // --- Cobra wiring --- var extensionsCmd = &cobra.Command{ @@ -381,16 +633,63 @@ var extensionsUploadCmd = &cobra.Command{ }, } +var extensionsBuildWebBotAuthCmd = &cobra.Command{ + Use: "build-web-bot-auth", + Short: "Build Cloudflare's Web Bot Auth browser extension", + Long: `Build the Web Bot Auth browser extension for signing HTTP requests. + +This command downloads and builds Cloudflare's web-bot-auth browser extension, +which adds RFC 9421 HTTP Message Signatures to all outgoing requests. + +By default, it uses the RFC9421 test key that works with Cloudflare's test site +at https://http-message-signatures-example.research.cloudflare.com/ + +To use your own signing key, provide a JWK file with --key. + +Examples: + # Build with default test key + kernel extensions build-web-bot-auth --to ./web-bot-auth-ext + + # Build with custom key + kernel extensions build-web-bot-auth --to ./web-bot-auth-ext --key ./my-key.jwk + + # Build and upload to Kernel + kernel extensions build-web-bot-auth --to ./web-bot-auth-ext --upload --name my-web-bot-auth`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("to") + keyFile, _ := cmd.Flags().GetString("key") + upload, _ := cmd.Flags().GetBool("upload") + name, _ := cmd.Flags().GetString("name") + svc := client.Extensions + e := ExtensionsCmd{extensions: &svc} + return e.BuildWebBotAuth(cmd.Context(), ExtensionsBuildWebBotAuthInput{ + Output: output, + KeyFile: keyFile, + Upload: upload, + Name: name, + }) + }, +} + func init() { extensionsCmd.AddCommand(extensionsListCmd) extensionsCmd.AddCommand(extensionsDeleteCmd) extensionsCmd.AddCommand(extensionsDownloadCmd) extensionsCmd.AddCommand(extensionsDownloadWebStoreCmd) extensionsCmd.AddCommand(extensionsUploadCmd) + extensionsCmd.AddCommand(extensionsBuildWebBotAuthCmd) extensionsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") extensionsDownloadCmd.Flags().String("to", "", "Output zip file path") extensionsDownloadWebStoreCmd.Flags().String("to", "", "Output zip file path for the downloaded archive") extensionsDownloadWebStoreCmd.Flags().String("os", "", "Target OS: mac, win, or linux (default linux)") extensionsUploadCmd.Flags().String("name", "", "Optional unique extension name") + + extensionsBuildWebBotAuthCmd.Flags().String("to", "", "Output directory for the built extension (required)") + extensionsBuildWebBotAuthCmd.Flags().String("key", "", "Path to JWK file with Ed25519 signing key (defaults to RFC9421 test key)") + extensionsBuildWebBotAuthCmd.Flags().Bool("upload", false, "Upload the extension to Kernel after building") + extensionsBuildWebBotAuthCmd.Flags().String("name", "web-bot-auth", "Extension name when uploading") + _ = extensionsBuildWebBotAuthCmd.MarkFlagRequired("to") } diff --git a/scripts/test-web-bot-auth.ts b/scripts/test-web-bot-auth.ts new file mode 100644 index 0000000..37fe96f --- /dev/null +++ b/scripts/test-web-bot-auth.ts @@ -0,0 +1,113 @@ +#!/usr/bin/env npx tsx +/** + * Test script to verify the Web Bot Auth extension works with Kernel browsers. + * + * Prerequisites: + * 1. Build and upload the web-bot-auth extension: + * kernel extensions build-web-bot-auth --to ./web-bot-auth-ext --upload + * + * 2. Set your API key: + * export KERNEL_API_KEY=sk_... + * + * Usage: + * npx tsx scripts/test-web-bot-auth.ts + * + * This script: + * 1. Creates a Kernel browser with the web-bot-auth extension + * 2. Connects via Playwright + * 3. Navigates to Cloudflare's test site + * 4. Verifies the signature was accepted + */ + +import { Kernel } from "@onkernel/sdk"; +import { chromium } from "playwright"; + +const CLOUDFLARE_TEST_URL = + "https://http-message-signatures-example.research.cloudflare.com/"; + +async function main() { + const apiKey = process.env.KERNEL_API_KEY; + if (!apiKey) { + console.error("Error: KERNEL_API_KEY environment variable is required"); + process.exit(1); + } + + const kernel = new Kernel({ apiKey }); + + console.log("Creating Kernel browser with web-bot-auth extension..."); + + let browser; + try { + browser = await kernel.browsers.create({ + extensions: [{ name: "web-bot-auth" }], + }); + console.log(`Browser created: ${browser.id}`); + console.log(`CDP URL: ${browser.browser_url}`); + + // Connect via Playwright + console.log("\nConnecting via Playwright..."); + const pw = await chromium.connectOverCDP(browser.browser_url); + const context = pw.contexts()[0]; + const page = context?.pages()[0] || (await context.newPage()); + + // Navigate to Cloudflare's test site + console.log(`\nNavigating to ${CLOUDFLARE_TEST_URL}...`); + await page.goto(CLOUDFLARE_TEST_URL, { waitUntil: "networkidle" }); + + // Check for success indicators + const pageContent = await page.content(); + const pageText = await page.innerText("body"); + + // The test site shows different content based on whether the signature was valid + // Look for indicators of success + const hasValidSignature = + pageText.toLowerCase().includes("valid") || + pageText.toLowerCase().includes("signature verified") || + pageText.toLowerCase().includes("authenticated"); + + const hasSignatureHeader = + pageText.toLowerCase().includes("signature") || + pageContent.toLowerCase().includes("signature"); + + console.log("\n--- Page Content Preview ---"); + console.log(pageText.slice(0, 1000)); + console.log("----------------------------\n"); + + if (hasSignatureHeader) { + console.log("āœ“ Page mentions signatures"); + } + + // Take a screenshot for debugging + const screenshotPath = "/tmp/web-bot-auth-test.png"; + await page.screenshot({ path: screenshotPath, fullPage: true }); + console.log(`Screenshot saved to: ${screenshotPath}`); + + // Get the live view URL for manual inspection + const liveViewUrl = browser.live_url; + if (liveViewUrl) { + console.log(`\nLive view URL: ${liveViewUrl}`); + } + + await pw.close(); + console.log("\nāœ“ Test completed successfully!"); + console.log( + "Check the screenshot and page content above to verify the signature was accepted." + ); + } catch (error) { + console.error("Test failed:", error); + process.exit(1); + } finally { + // Clean up browser + if (browser) { + console.log("\nCleaning up browser..."); + try { + await kernel.browsers.delete(browser.id); + console.log("Browser deleted."); + } catch (e) { + console.error("Failed to delete browser:", e); + } + } + } +} + +main(); From bdd0ff1339cc26fcf3847c3ac4fea7e0f8f50183 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 1 Dec 2025 17:15:51 -0800 Subject: [PATCH 2/2] update to build packed web bot auth extension --- cmd/extensions.go | 108 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 82 insertions(+), 26 deletions(-) diff --git a/cmd/extensions.go b/cmd/extensions.go index 174c9ac..31101be 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -437,46 +437,93 @@ func (e ExtensionsCmd) BuildWebBotAuth(ctx context.Context, in ExtensionsBuildWe return fmt.Errorf("npm run build failed: %w", err) } - // Run npm run build:chrome in the browser-extension directory + // Run npm run bundle:chrome in the browser-extension directory (builds and packs as CRX) extDir := filepath.Join(repoDir, "examples", "browser-extension") - pterm.Info.Println("Building extension (npm run build:chrome)...") - npmBuild := exec.CommandContext(ctx, "npm", "run", "build:chrome") - npmBuild.Dir = extDir - npmBuild.Stdout = os.Stdout - npmBuild.Stderr = os.Stderr - if err := npmBuild.Run(); err != nil { - return fmt.Errorf("npm run build:chrome failed: %w", err) + pterm.Info.Println("Building and bundling extension (npm run bundle:chrome)...") + npmBundle := exec.CommandContext(ctx, "npm", "run", "bundle:chrome") + npmBundle.Dir = extDir + npmBundle.Stdout = os.Stdout + npmBundle.Stderr = os.Stderr + if err := npmBundle.Run(); err != nil { + return fmt.Errorf("npm run bundle:chrome failed: %w", err) } - // Copy built extension to output directory + // Create unpacked subdirectory for the extension files (used for upload) + unpackedDir := filepath.Join(outDir, "unpacked") + if err := os.MkdirAll(unpackedDir, 0o755); err != nil { + return fmt.Errorf("failed to create unpacked directory: %w", err) + } + + // Copy built extension files to unpacked/ builtDir := filepath.Join(extDir, "dist", "mv3", "chromium") - manifestSrc := filepath.Join(extDir, "platform", "mv3", "chromium", "manifest.json") // Copy background.mjs bgSrc := filepath.Join(builtDir, "background.mjs") - if err := copyFile(bgSrc, filepath.Join(outDir, "background.mjs")); err != nil { + if err := copyFile(bgSrc, filepath.Join(unpackedDir, "background.mjs")); err != nil { return fmt.Errorf("failed to copy background.mjs: %w", err) } - // Read manifest and add version (the build script doesn't add it, only bundle does) - manifestData, err := os.ReadFile(manifestSrc) - if err != nil { - return fmt.Errorf("failed to read manifest.json: %w", err) + // Copy manifest.json (bundle:chrome already adds version) + manifestSrc := filepath.Join(builtDir, "manifest.json") + if err := copyFile(manifestSrc, filepath.Join(unpackedDir, "manifest.json")); err != nil { + return fmt.Errorf("failed to copy manifest.json: %w", err) + } + + // Copy CRX bundle artifacts + artifactsDir := filepath.Join(extDir, "dist", "web-ext-artifacts") + crxSrc := filepath.Join(artifactsDir, "http-message-signatures-extension.crx") + if err := copyFile(crxSrc, filepath.Join(outDir, "extension.crx")); err != nil { + return fmt.Errorf("failed to copy extension.crx: %w", err) } - var manifest map[string]interface{} - if err := json.Unmarshal(manifestData, &manifest); err != nil { - return fmt.Errorf("failed to parse manifest.json: %w", err) + + updateXMLSrc := filepath.Join(artifactsDir, "update.xml") + if err := copyFile(updateXMLSrc, filepath.Join(outDir, "update.xml")); err != nil { + return fmt.Errorf("failed to copy update.xml: %w", err) } - manifest["version"] = "1.0.0" - manifestOut, err := json.MarshalIndent(manifest, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal manifest.json: %w", err) + + // Copy policy files + policyDir := filepath.Join(extDir, "policy") + policyJSONSrc := filepath.Join(policyDir, "policy.json") + if err := copyFile(policyJSONSrc, filepath.Join(outDir, "policy.json")); err != nil { + return fmt.Errorf("failed to copy policy.json: %w", err) } - if err := os.WriteFile(filepath.Join(outDir, "manifest.json"), manifestOut, 0o644); err != nil { - return fmt.Errorf("failed to write manifest.json: %w", err) + + plistSrc := filepath.Join(policyDir, "com.google.Chrome.managed.plist") + if err := copyFile(plistSrc, filepath.Join(outDir, "com.google.Chrome.managed.plist")); err != nil { + return fmt.Errorf("failed to copy plist: %w", err) + } + + // Copy RSA private key (useful for re-signing later) + privateKeySrc := filepath.Join(extDir, "private_key.pem") + if err := copyFile(privateKeySrc, filepath.Join(outDir, "private_key.pem")); err != nil { + return fmt.Errorf("failed to copy private_key.pem: %w", err) + } + + // Extract extension ID from update.xml and save it + updateXMLData, err := os.ReadFile(updateXMLSrc) + if err == nil { + // Parse extension ID from update.xml (it's in the appid attribute) + xmlStr := string(updateXMLData) + if idx := strings.Index(xmlStr, `appid="`); idx != -1 { + start := idx + 7 + if end := strings.Index(xmlStr[start:], `"`); end != -1 { + extID := xmlStr[start : start+end] + if err := os.WriteFile(filepath.Join(outDir, "extension-id.txt"), []byte(extID), 0o644); err != nil { + pterm.Warning.Printf("Failed to write extension-id.txt: %v\n", err) + } + } + } } - pterm.Success.Printf("Built extension to: %s\n", outDir) + pterm.Success.Printf("Built extension bundle to: %s\n", outDir) + pterm.Info.Println("Bundle contents:") + pterm.Info.Println(" - extension.crx (packed extension for policy installation)") + pterm.Info.Println(" - update.xml (Chrome update manifest)") + pterm.Info.Println(" - policy.json (Linux/Chrome OS policy file)") + pterm.Info.Println(" - com.google.Chrome.managed.plist (macOS policy file)") + pterm.Info.Println(" - private_key.pem (RSA key for re-signing)") + pterm.Info.Println(" - extension-id.txt (Chrome extension ID)") + pterm.Info.Println(" - unpacked/ (unpacked extension files)") // Optionally upload if in.Upload { @@ -485,7 +532,7 @@ func (e ExtensionsCmd) BuildWebBotAuth(ctx context.Context, in ExtensionsBuildWe name = "web-bot-auth" } pterm.Info.Printf("Uploading extension as '%s'...\n", name) - if err := e.Upload(ctx, ExtensionsUploadInput{Dir: outDir, Name: name}); err != nil { + if err := e.Upload(ctx, ExtensionsUploadInput{Dir: unpackedDir, Name: name}); err != nil { return err } } @@ -641,6 +688,15 @@ var extensionsBuildWebBotAuthCmd = &cobra.Command{ This command downloads and builds Cloudflare's web-bot-auth browser extension, which adds RFC 9421 HTTP Message Signatures to all outgoing requests. +The output includes: + - extension.crx (packed extension for policy installation) + - update.xml (Chrome update manifest) + - policy.json (Linux/Chrome OS policy file) + - com.google.Chrome.managed.plist (macOS policy file) + - private_key.pem (RSA key for re-signing) + - extension-id.txt (Chrome extension ID) + - unpacked/ (unpacked extension files for Kernel upload) + By default, it uses the RFC9421 test key that works with Cloudflare's test site at https://http-message-signatures-example.research.cloudflare.com/