diff --git a/cmd/extensions.go b/cmd/extensions.go index 68b880b..31101be 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,293 @@ 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 bundle:chrome in the browser-extension directory (builds and packs as CRX) + extDir := filepath.Join(repoDir, "examples", "browser-extension") + 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) + } + + // 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") + + // Copy background.mjs + bgSrc := filepath.Join(builtDir, "background.mjs") + if err := copyFile(bgSrc, filepath.Join(unpackedDir, "background.mjs")); err != nil { + return fmt.Errorf("failed to copy background.mjs: %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) + } + + 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) + } + + // 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) + } + + 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 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 { + 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: unpackedDir, 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 +680,72 @@ 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. + +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/ + +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();