diff --git a/cmd/extensions.go b/cmd/extensions.go index fc97e90..8a68eba 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -11,6 +11,7 @@ import ( "path/filepath" "time" + "github.com/kernel/cli/pkg/extensions" "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" @@ -422,12 +423,59 @@ var extensionsUploadCmd = &cobra.Command{ }, } +var extensionsPrepareWebBotAuthCmd = &cobra.Command{ + Use: "build-web-bot-auth", + Short: "Build the Cloudflare web-bot-auth extension for Kernel", + Long: `Download, build, and prepare the Cloudflare web-bot-auth extension with Kernel-specific configurations. + Defaults to RFC9421 test key (works with Cloudflare's test site). + Uploads it to Kernel as 'web-bot-auth'. Optionally accepts a custom JWK or PEM key file.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + output, _ := cmd.Flags().GetString("to") + url, _ := cmd.Flags().GetString("url") + keyPath, _ := cmd.Flags().GetString("key") + uploadName, _ := cmd.Flags().GetString("upload") + + // Use upload name for extension name, or default to "web-bot-auth" + extensionName := "web-bot-auth" + if uploadName != "" { + extensionName = uploadName + } + + // Build the extension + result, err := extensions.BuildWebBotAuth(cmd.Context(), extensions.ExtensionsBuildWebBotAuthInput{ + Output: output, + HostURL: url, + KeyPath: keyPath, + ExtensionName: extensionName, + }) + if err != nil { + return err + } + + // Upload if requested + if uploadName != "" { + client := getKernelClient(cmd) + svc := client.Extensions + e := ExtensionsCmd{extensions: &svc} + pterm.Info.Println("Uploading extension to Kernel...") + return e.Upload(cmd.Context(), ExtensionsUploadInput{ + Dir: result.OutputDir, + Name: extensionName, + }) + } + + return nil + }, +} + func init() { extensionsCmd.AddCommand(extensionsListCmd) extensionsCmd.AddCommand(extensionsDeleteCmd) extensionsCmd.AddCommand(extensionsDownloadCmd) extensionsCmd.AddCommand(extensionsDownloadWebStoreCmd) extensionsCmd.AddCommand(extensionsUploadCmd) + extensionsCmd.AddCommand(extensionsPrepareWebBotAuthCmd) extensionsListCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") extensionsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") @@ -436,4 +484,8 @@ func init() { extensionsDownloadWebStoreCmd.Flags().String("os", "", "Target OS: mac, win, or linux (default linux)") extensionsUploadCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") extensionsUploadCmd.Flags().String("name", "", "Optional unique extension name") + extensionsPrepareWebBotAuthCmd.Flags().String("to", "./web-bot-auth", "Output directory for the prepared extension") + extensionsPrepareWebBotAuthCmd.Flags().String("url", "http://127.0.0.1:10001", "Base URL for update.xml and policy templates") + extensionsPrepareWebBotAuthCmd.Flags().String("key", "", "Path to Ed25519 private key file (JWK or PEM format)") + extensionsPrepareWebBotAuthCmd.Flags().String("upload", "", "Upload extension to Kernel with specified name (e.g., --upload web-bot-auth)") } diff --git a/pkg/extensions/webbotauth.go b/pkg/extensions/webbotauth.go new file mode 100644 index 0000000..e70abe2 --- /dev/null +++ b/pkg/extensions/webbotauth.go @@ -0,0 +1,456 @@ +package extensions + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/kernel/cli/pkg/table" + "github.com/kernel/cli/pkg/util" + "github.com/pterm/pterm" +) + +const ( + defaultLocalhostURL = "http://localhost:8000" + defaultDirMode = 0755 + defaultFileMode = 0644 + // Current: v0.6.0 release (e3d76846b64be03ae00e2b9e53b697beab81541d) - Dec 19, 2025 + webBotAuthCommit = "e3d76846b64be03ae00e2b9e53b697beab81541d" + webBotAuthDownloadURL = "https://github.com/cloudflare/web-bot-auth/archive/" + webBotAuthCommit + ".zip" + downloadTimeout = 5 * time.Minute + // defaultWebBotAuthKey is the RFC9421 test key that works with Cloudflare's test site + // https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/ + defaultWebBotAuthKey = `{"kty":"OKP","crv":"Ed25519","d":"n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU","x":"JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"}` +) + +type ExtensionsBuildWebBotAuthInput struct { + Output string + HostURL string + KeyPath string // Path to user's JWK or PEM file (optional, defaults to RFC9421 test key) + ExtensionName string // Name for the extension paths (defaults to "web-bot-auth") +} + +// BuildWebBotAuthOutput contains the result of building the extension +type BuildWebBotAuthOutput struct { + ExtensionID string + OutputDir string +} + +func BuildWebBotAuth(ctx context.Context, in ExtensionsBuildWebBotAuthInput) (*BuildWebBotAuthOutput, error) { + pterm.Info.Println("Preparing web-bot-auth extension...") + + // Validate preconditions + if err := validateToolDependencies(); err != nil { + return nil, err + } + + outputDir, err := filepath.Abs(in.Output) + if err != nil { + return nil, fmt.Errorf("failed to resolve output path: %w", err) + } + if st, err := os.Stat(outputDir); err == nil { + if !st.IsDir() { + return nil, fmt.Errorf("output path exists and is not a directory: %s", outputDir) + } + entries, err := os.ReadDir(outputDir) + if err != nil { + return nil, fmt.Errorf("failed to read output directory: %w", err) + } + if len(entries) > 0 { + return nil, fmt.Errorf("output directory must be empty: %s", outputDir) + } + } else if os.IsNotExist(err) { + if err := os.MkdirAll(outputDir, defaultDirMode); err != nil { + return nil, fmt.Errorf("failed to create output directory: %w", err) + } + } else { + return nil, fmt.Errorf("failed to check output directory: %w", err) + } + + // Download and extract + browserExtDir, cleanup, err := downloadAndExtractWebBotAuth(ctx) + defer cleanup() + if err != nil { + return nil, err + } + + // Load key (custom or default) + var keyData string + var usingDefaultKey bool + if in.KeyPath != "" { + pterm.Info.Printf("Loading custom key from %s...\n", in.KeyPath) + keyBytes, err := os.ReadFile(in.KeyPath) + if err != nil { + return nil, fmt.Errorf("failed to read key file: %w", err) + } + keyData = string(keyBytes) + usingDefaultKey = false + } else { + pterm.Info.Println("Using default RFC9421 test key (works with Cloudflare test site)...") + keyData = defaultWebBotAuthKey + usingDefaultKey = true + } + + // Build extension + extensionID, err := buildWebBotAuthExtension(ctx, browserExtDir, in.HostURL, keyData, in.ExtensionName) + if err != nil { + return nil, err + } + + // Copy artifacts + if err := copyExtensionArtifacts(browserExtDir, outputDir); err != nil { + return nil, err + } + + // Display success message + displayWebBotAuthSuccess(outputDir, in.ExtensionName, extensionID, in.HostURL, usingDefaultKey) + + return &BuildWebBotAuthOutput{ + ExtensionID: extensionID, + OutputDir: outputDir, + }, nil +} + +// extractExtensionID extracts the extension ID from npm bundle output +func extractExtensionID(output string) string { + for _, line := range strings.Split(output, "\n") { + if after, found := strings.CutPrefix(line, "Build Extension with ID:"); found { + return strings.TrimSpace(after) + } + } + return "" +} + +// validateToolDependencies checks for required tools (node and npm) +func validateToolDependencies() error { + if _, err := exec.LookPath("node"); err != nil { + pterm.Error.Println("Node.js is required but not found in PATH") + pterm.Info.Println("Please install Node.js from https://nodejs.org/") + return fmt.Errorf("node not found") + } + if _, err := exec.LookPath("npm"); err != nil { + pterm.Error.Println("npm is required but not found in PATH") + pterm.Info.Println("Please install npm (usually comes with Node.js)") + return fmt.Errorf("npm not found") + } + return nil +} + +// downloadAndExtractWebBotAuth downloads and extracts the web-bot-auth repo, returns the browser-extension directory path +func downloadAndExtractWebBotAuth(ctx context.Context) (browserExtDir string, cleanup func(), err error) { + cleanup = func() {} + + // Download from GitHub + pterm.Info.Printf("Downloading web-bot-auth from GitHub...\n") + client := &http.Client{Timeout: downloadTimeout} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, webBotAuthDownloadURL, nil) + if err != nil { + return "", cleanup, fmt.Errorf("failed to create download request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return "", cleanup, fmt.Errorf("failed to download web-bot-auth: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", cleanup, fmt.Errorf("failed to download web-bot-auth: HTTP %d", resp.StatusCode) + } + + // Save to temporary file + tmpZip, err := os.CreateTemp("", "web-bot-auth-*.zip") + if err != nil { + return "", cleanup, fmt.Errorf("failed to create temp file: %w", err) + } + tmpZipPath := tmpZip.Name() + cleanup = func() { os.Remove(tmpZipPath) } + + if _, err := io.Copy(tmpZip, resp.Body); err != nil { + tmpZip.Close() + return "", cleanup, fmt.Errorf("failed to save download: %w", err) + } + tmpZip.Close() + + // Extract to temporary directory + tmpExtractDir, err := os.MkdirTemp("", "web-bot-auth-extract-*") + if err != nil { + return "", cleanup, fmt.Errorf("failed to create temp directory: %w", err) + } + cleanup = func() { + os.Remove(tmpZipPath) + os.RemoveAll(tmpExtractDir) + } + + pterm.Info.Println("Extracting archive...") + if err := util.Unzip(tmpZipPath, tmpExtractDir); err != nil { + return "", cleanup, fmt.Errorf("failed to extract archive: %w", err) + } + + entries, err := os.ReadDir(tmpExtractDir) + if err != nil { + return "", cleanup, fmt.Errorf("failed to read extracted directory: %w", err) + } + if len(entries) == 0 { + return "", cleanup, fmt.Errorf("extracted archive is empty") + } + + extractedDir := filepath.Join(tmpExtractDir, entries[0].Name()) + browserExtDir = filepath.Join(extractedDir, "examples", "browser-extension") + + // Verify the browser-extension directory exists + if _, err := os.Stat(browserExtDir); err != nil { + if os.IsNotExist(err) { + return "", cleanup, fmt.Errorf("browser-extension directory not found in archive") + } + return "", cleanup, fmt.Errorf("failed to access browser-extension directory: %w", err) + } + + return browserExtDir, cleanup, nil +} + +// buildWebBotAuthExtension modifies templates, builds the extension, and returns the extension ID +// extensionName is used for URL paths (e.g., "web-bot-auth") instead of the Chrome extension ID +func buildWebBotAuthExtension(ctx context.Context, browserExtDir, hostURL, keyData, extensionName string) (string, error) { + // Normalize hostURL by removing trailing slashes to prevent double slashes in URLs + hostURL = strings.TrimRight(hostURL, "/") + + // Validate key and write to browserExtDir before building + pterm.Info.Println("Validating key...") + var pemData []byte + var err error + + if util.IsPEMKey(keyData) { + // Key is already in PEM format, validate it + if err := util.ValidatePEMKey(keyData); err != nil { + return "", fmt.Errorf("failed to validate PEM key: %w", err) + } + pemData = []byte(keyData) + } else { + // Key is in JWK format, convert to PEM + pemData, err = util.ConvertJWKToPEM(keyData) + if err != nil { + return "", fmt.Errorf("failed to convert JWK to PEM: %w", err) + } + } + + privateKeyPath := filepath.Join(browserExtDir, "private_key.pem") + if err := os.WriteFile(privateKeyPath, pemData, 0600); err != nil { + return "", fmt.Errorf("failed to write private key: %w", err) + } + pterm.Success.Println("Private key written successfully") + + // Modify template files + pterm.Info.Println("Modifying templates with host URL...") + + policyTemplPath := filepath.Join(browserExtDir, "policy", "policy.json.templ") + if err := util.ModifyFile(policyTemplPath, defaultLocalhostURL, hostURL); err != nil { + return "", fmt.Errorf("failed to modify policy.json.templ: %w", err) + } + + plistTemplPath := filepath.Join(browserExtDir, "policy", "com.google.Chrome.managed.plist.templ") + if err := util.ModifyFile(plistTemplPath, defaultLocalhostURL, hostURL); err != nil { + return "", fmt.Errorf("failed to modify plist template: %w", err) + } + + buildScriptPath := filepath.Join(browserExtDir, "scripts", "build_web_artifacts.mjs") + if err := util.ModifyFile(buildScriptPath, defaultLocalhostURL+"/", hostURL+"/"); err != nil { + return "", fmt.Errorf("failed to modify build script: %w", err) + } + + // Get the root directory (parent of browser-extension) + extractedDir := filepath.Dir(filepath.Dir(browserExtDir)) + + // Install dependencies + pterm.Info.Println("Installing dependencies (this may take a minute)...") + npmInstall := exec.CommandContext(ctx, "npm", "install") + npmInstall.Dir = extractedDir + npmInstall.Stdout = os.Stdout + npmInstall.Stderr = os.Stderr + if err := npmInstall.Run(); err != nil { + return "", fmt.Errorf("npm install failed: %w", err) + } + + // Build workspace packages + pterm.Info.Println("Building workspace packages...") + npmBuildWorkspaces := exec.CommandContext(ctx, "npm", "run", "build") + npmBuildWorkspaces.Dir = extractedDir + npmBuildWorkspaces.Stdout = os.Stdout + npmBuildWorkspaces.Stderr = os.Stderr + if err := npmBuildWorkspaces.Run(); err != nil { + return "", fmt.Errorf("npm run build (workspaces) failed: %w", err) + } + + // Build the extension + pterm.Info.Println("Building extension...") + npmBuild := exec.CommandContext(ctx, "npm", "run", "build:chrome") + npmBuild.Dir = browserExtDir + npmBuild.Stdout = os.Stdout + npmBuild.Stderr = os.Stderr + if err := npmBuild.Run(); err != nil { + return "", fmt.Errorf("npm run build:chrome failed: %w", err) + } + + // Bundle the extension + pterm.Info.Println("Bundling extension...") + npmBundle := exec.CommandContext(ctx, "npm", "run", "bundle:chrome") + npmBundle.Dir = browserExtDir + var bundleOutput bytes.Buffer + npmBundle.Stdout = io.MultiWriter(os.Stdout, &bundleOutput) + npmBundle.Stderr = os.Stderr + if err := npmBundle.Run(); err != nil { + return "", fmt.Errorf("npm run bundle:chrome failed: %w", err) + } + + // Extract extension ID (still needed for logging/reference) + extensionID := extractExtensionID(bundleOutput.String()) + if extensionID == "" { + return "", fmt.Errorf("failed to extract extension ID from bundle output") + } + + // Update URLs with extension name paths (not extension ID) + // This allows using readable names like "web-bot-auth" instead of the Chrome extension ID + pterm.Info.Printf("Updating URLs to use extension name: %s (Chrome ID: %s)\n", extensionName, extensionID) + + updateXMLPath := filepath.Join(browserExtDir, "dist", "web-ext-artifacts", "update.xml") + extensionSpecificCodebase := fmt.Sprintf("%s/extensions/%s/http-message-signatures-extension.crx", hostURL, extensionName) + if err := util.ModifyFile(updateXMLPath, + fmt.Sprintf("%s/http-message-signatures-extension.crx", hostURL), + extensionSpecificCodebase); err != nil { + return "", fmt.Errorf("failed to update update.xml codebase: %w", err) + } + + pterm.Info.Println("Updating policy files with extension-specific paths...") + + policyJSONPath := filepath.Join(browserExtDir, "policy", "policy.json") + if err := util.ModifyFile(policyJSONPath, + fmt.Sprintf("%s/update.xml", hostURL), + fmt.Sprintf("%s/extensions/%s/update.xml", hostURL, extensionName)); err != nil { + return "", fmt.Errorf("failed to update policy.json: %w", err) + } + + plistPath := filepath.Join(browserExtDir, "policy", "com.google.Chrome.managed.plist") + if err := util.ModifyFile(plistPath, + fmt.Sprintf("%s/update.xml", hostURL), + fmt.Sprintf("%s/extensions/%s/update.xml", hostURL, extensionName)); err != nil { + return "", fmt.Errorf("failed to update plist: %w", err) + } + + return extensionID, nil +} + +// copyExtensionArtifacts copies built extension files to the output directory +func copyExtensionArtifacts(browserExtDir, outputDir string) error { + pterm.Info.Println("Copying extension files to output directory...") + + chromiumSrc := filepath.Join(browserExtDir, "dist", "mv3", "chromium") + entries, err := os.ReadDir(chromiumSrc) + if err != nil { + return fmt.Errorf("failed to read chromium directory: %w", err) + } + + for _, entry := range entries { + srcPath := filepath.Join(chromiumSrc, entry.Name()) + dstPath := filepath.Join(outputDir, entry.Name()) + + if entry.IsDir() { + if err := util.CopyDir(srcPath, dstPath); err != nil { + return fmt.Errorf("failed to copy %s: %w", entry.Name(), err) + } + } else { + if err := util.CopyFile(srcPath, dstPath); err != nil { + return fmt.Errorf("failed to copy %s: %w", entry.Name(), err) + } + } + } + + updateXMLSrc := filepath.Join(browserExtDir, "dist", "web-ext-artifacts", "update.xml") + updateXMLDst := filepath.Join(outputDir, "update.xml") + if err := util.CopyFile(updateXMLSrc, updateXMLDst); err != nil { + return fmt.Errorf("failed to copy update.xml: %w", err) + } + + crxSrc := filepath.Join(browserExtDir, "dist", "web-ext-artifacts", "http-message-signatures-extension.crx") + crxDst := filepath.Join(outputDir, "http-message-signatures-extension.crx") + if err := util.CopyFile(crxSrc, crxDst); err != nil { + return fmt.Errorf("failed to copy .crx file: %w", err) + } + + // Copy private key + privateKeySrc := filepath.Join(browserExtDir, "private_key.pem") + privateKeyDst := filepath.Join(outputDir, "private_key.pem") + if _, err := os.Stat(privateKeySrc); err == nil { + if err := util.CopyFile(privateKeySrc, privateKeyDst); err != nil { + return fmt.Errorf("failed to copy private_key.pem: %w", err) + } + + // Create .gitignore to prevent private key from being uploaded + gitignorePath := filepath.Join(outputDir, ".gitignore") + gitignoreContent := "# Exclude private key from uploads\nprivate_key.pem\n" + if err := os.WriteFile(gitignorePath, []byte(gitignoreContent), defaultFileMode); err != nil { + return fmt.Errorf("failed to create .gitignore: %w", err) + } + pterm.Info.Println("Private key preserved (private_key.pem)") + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to stat private_key.pem: %w", err) + } else { + pterm.Warning.Println("No private_key.pem found - extension ID may change on rebuild") + } + + // Copy policy directory (contains Chrome enterprise policy configuration) + // Note: This directory must exist since we modified files in it earlier during the build + policySrc := filepath.Join(browserExtDir, "policy") + policyDst := filepath.Join(outputDir, "policy") + if err := util.CopyDir(policySrc, policyDst); err != nil { + return fmt.Errorf("failed to copy policy directory: %w", err) + } + pterm.Info.Println("Policy files copied (required for Chrome configuration)") + + return nil +} + +// displayWebBotAuthSuccess displays success message and next steps +func displayWebBotAuthSuccess(outputDir, extensionName, extensionID, hostURL string, usingDefaultKey bool) { + pterm.Success.Println("Web-bot-auth extension prepared successfully!") + pterm.Println() + + rows := pterm.TableData{{"Property", "Value"}} + rows = append(rows, []string{"Extension Name", extensionName}) + rows = append(rows, []string{"Chrome Extension ID", extensionID}) + rows = append(rows, []string{"Output directory", outputDir}) + rows = append(rows, []string{"Host URL", hostURL}) + if usingDefaultKey { + rows = append(rows, []string{"Signing Key", "RFC9421 test key (Cloudflare test site)"}) + } else { + rows = append(rows, []string{"Signing Key", "Custom key"}) + } + table.PrintTableNoPad(rows, true) + + pterm.Println() + pterm.Info.Println("Next steps:") + pterm.Printf("1. Upload the extension:\n") + pterm.Printf(" kernel extensions upload %s --name web-bot-auth\n\n", outputDir) + pterm.Printf("2. Use in your browser:\n") + pterm.Printf(" kernel browsers create --extension web-bot-auth\n\n") + + pterm.Println() + pterm.Info.Println(" For testing with Cloudflare's test site:") + pterm.Printf(" • Test URL: https://http-message-signatures-example.research.cloudflare.com\n") + pterm.Printf(" • Or: https://webbotauth.io/test\n") + pterm.Println() + + if usingDefaultKey { + pterm.Info.Println("Using default RFC9421 test key - compatible with Cloudflare test sites") + } else { + pterm.Warning.Println("⚠️ Private key saved to private_key.pem - keep it secure!") + pterm.Info.Println(" It's automatically excluded when uploading via .gitignore") + } + +} diff --git a/pkg/extensions/webbotauth_test.go b/pkg/extensions/webbotauth_test.go new file mode 100644 index 0000000..c66e599 --- /dev/null +++ b/pkg/extensions/webbotauth_test.go @@ -0,0 +1,64 @@ +package extensions + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWebBotAuthDownloadable verifies that the web-bot-auth package can be downloaded from GitHub +func TestWebBotAuthDownloadable(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, webBotAuthDownloadURL, nil) + require.NoError(t, err, "Failed to create request") + + resp, err := client.Do(req) + require.NoError(t, err, "Failed to download web-bot-auth") + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode, "Expected status 200") + + // Verify Content-Type indicates a zip file + contentType := resp.Header.Get("Content-Type") + if contentType != "application/zip" && contentType != "application/x-zip-compressed" { + t.Logf("Warning: unexpected Content-Type: %s (expected application/zip)", contentType) + } + + // Verify Content-Length is reasonable (should be at least 1KB) + contentLength := resp.ContentLength + if contentLength > 0 { + assert.GreaterOrEqual(t, contentLength, int64(1024), "Content-Length should be at least 1KB") + } + + t.Logf("Successfully verified web-bot-auth is downloadable") + t.Logf("Content-Type: %s", contentType) + t.Logf("Content-Length: %d bytes", contentLength) +} + +// TestDownloadAndExtractWebBotAuth tests the full download and extraction process +func TestDownloadAndExtractWebBotAuth(t *testing.T) { + if testing.Short() { + t.Skip("Skipping download test in short mode") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + browserExtDir, cleanup, err := downloadAndExtractWebBotAuth(ctx) + defer cleanup() + + require.NoError(t, err, "Failed to download and extract web-bot-auth") + require.NotEmpty(t, browserExtDir, "Expected non-empty browser extension directory path") + + t.Logf("Successfully downloaded and extracted to: %s", browserExtDir) +} diff --git a/pkg/util/crypto.go b/pkg/util/crypto.go new file mode 100644 index 0000000..1e693e3 --- /dev/null +++ b/pkg/util/crypto.go @@ -0,0 +1,88 @@ +package util + +import ( + "crypto/ed25519" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" +) + +// jwkKey represents an Ed25519 JWK key +type jwkKey struct { + Kty string `json:"kty"` // Key type (should be "OKP") + Crv string `json:"crv"` // Curve (should be "Ed25519") + D string `json:"d"` // Private key (base64url encoded) + X string `json:"x"` // Public key (base64url encoded) +} + +// ValidatePEMKey validates that a PEM-encoded string contains a valid Ed25519 private key +func ValidatePEMKey(pemData string) error { + block, _ := pem.Decode([]byte(pemData)) + if block == nil { + return fmt.Errorf("failed to decode PEM block") + } + + if block.Type != "PRIVATE KEY" { + return fmt.Errorf("invalid PEM type: expected PRIVATE KEY, got %s", block.Type) + } + + // Validate that it's actually an Ed25519 key + privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return fmt.Errorf("failed to parse PKCS#8 private key: %w", err) + } + + if _, ok := privateKey.(ed25519.PrivateKey); !ok { + return fmt.Errorf("invalid key type: expected Ed25519 private key, got %T", privateKey) + } + + return nil +} + +// IsPEMFormat checks if the input string is in PEM format +func IsPEMKey(data string) bool { + block, _ := pem.Decode([]byte(data)) + return block != nil +} + +// ConvertJWKToPEM converts an Ed25519 JWK to PEM format +func ConvertJWKToPEM(jwkJSON string) ([]byte, error) { + // Parse as JWK + var key jwkKey + if err := json.Unmarshal([]byte(jwkJSON), &key); err != nil { + return nil, fmt.Errorf("failed to parse JWK: %w", err) + } + + if key.Kty != "OKP" || key.Crv != "Ed25519" { + return nil, fmt.Errorf("invalid key type: expected OKP/Ed25519, got %s/%s", key.Kty, key.Crv) + } + + // Decode private key from base64url + privateKeyBytes, err := base64.RawURLEncoding.DecodeString(key.D) + if err != nil { + return nil, fmt.Errorf("failed to decode private key: %w", err) + } + + if len(privateKeyBytes) != ed25519.SeedSize { + return nil, fmt.Errorf("invalid private key size: expected %d bytes, got %d", ed25519.SeedSize, len(privateKeyBytes)) + } + + // Generate Ed25519 private key from seed + privateKey := ed25519.NewKeyFromSeed(privateKeyBytes) + + // Marshal to PKCS#8 format using stdlib + pkcs8Bytes, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal PKCS#8: %w", err) + } + + // Encode to PEM + pemBlock := &pem.Block{ + Type: "PRIVATE KEY", + Bytes: pkcs8Bytes, + } + + return pem.EncodeToMemory(pemBlock), nil +} diff --git a/pkg/util/crypto_test.go b/pkg/util/crypto_test.go new file mode 100644 index 0000000..7e072d1 --- /dev/null +++ b/pkg/util/crypto_test.go @@ -0,0 +1,195 @@ +package util + +import ( + "crypto/ed25519" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidatePEMKey(t *testing.T) { + tests := []struct { + name string + pemData string + wantErr bool + errMsg string + }{ + { + name: "valid Ed25519 PEM key", + pemData: `-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIJ+DYvh6SEqVTm50DFtMDoQikTmiCqirVv9mWG9qfSnF +-----END PRIVATE KEY-----`, + wantErr: false, + }, + { + name: "invalid PEM format", + pemData: "not a pem key", + wantErr: true, + errMsg: "failed to decode PEM block", + }, + { + name: "wrong PEM type", + pemData: `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAJrQLj5P/89iXES9+vFgrIy29clF9CC/oPPsw3c5D0bs= +-----END PUBLIC KEY-----`, + wantErr: true, + errMsg: "invalid PEM type", + }, + { + name: "invalid PKCS8 data", + pemData: `-----BEGIN PRIVATE KEY----- +aW52YWxpZCBkYXRh +-----END PRIVATE KEY-----`, + wantErr: true, + errMsg: "failed to parse PKCS#8 private key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidatePEMKey(tt.pemData) + if tt.wantErr { + require.Error(t, err) + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestConvertJWKToPEM(t *testing.T) { + tests := []struct { + name string + jwkJSON string + wantErr bool + errMsg string + wantPubKey string // Expected base64url-encoded public key for validation + }{ + { + name: "valid JWK", + jwkJSON: `{ + "kty": "OKP", + "crv": "Ed25519", + "d": "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU", + "x": "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs" + }`, + wantErr: false, + wantPubKey: "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs", + }, + { + name: "invalid JSON", + jwkJSON: `{invalid json}`, + wantErr: true, + errMsg: "failed to parse JWK", + }, + { + name: "wrong key type", + jwkJSON: `{ + "kty": "RSA", + "crv": "Ed25519", + "d": "test" + }`, + wantErr: true, + errMsg: "invalid key type", + }, + { + name: "wrong curve", + jwkJSON: `{ + "kty": "OKP", + "crv": "Ed448", + "d": "test" + }`, + wantErr: true, + errMsg: "invalid key type", + }, + { + name: "invalid base64url encoding", + jwkJSON: `{ + "kty": "OKP", + "crv": "Ed25519", + "d": "not valid base64url!!!" + }`, + wantErr: true, + errMsg: "failed to decode private key", + }, + { + name: "invalid key size", + jwkJSON: `{ + "kty": "OKP", + "crv": "Ed25519", + "d": "dGVzdA" + }`, + wantErr: true, + errMsg: "invalid private key size", + }, + { + name: "missing private key component", + jwkJSON: `{ + "kty": "OKP", + "crv": "Ed25519", + "x": "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs" + }`, + wantErr: true, + errMsg: "invalid private key size", + }, + { + name: "missing key type", + jwkJSON: `{ + "crv": "Ed25519", + "d": "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU" + }`, + wantErr: true, + errMsg: "invalid key type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + pemData, err := ConvertJWKToPEM(tt.jwkJSON) + if tt.wantErr { + require.ErrorContains(t, err, tt.errMsg) + return + } + + require.NoError(t, err) + require.NotEmpty(t, pemData) + + // Decode and validate the PEM structure + block, rest := pem.Decode(pemData) + require.NotNil(t, block, "Failed to decode PEM block") + assert.Empty(t, rest, "Expected single PEM block, found extra data") + assert.Equal(t, "PRIVATE KEY", block.Type) + + // Parse as PKCS#8 and verify it's Ed25519 + privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + require.NoError(t, err, "Failed to parse PKCS#8") + + ed25519Key, ok := privateKey.(ed25519.PrivateKey) + require.True(t, ok, "Expected Ed25519 private key, got %T", privateKey) + assert.Len(t, ed25519Key, ed25519.PrivateKeySize, "Invalid private key size") + + // Verify the public key matches expected value (if provided) + if tt.wantPubKey != "" { + pubKey := ed25519Key.Public().(ed25519.PublicKey) + // Encode to base64url for comparison + actualPubKey := base64.RawURLEncoding.EncodeToString(pubKey) + assert.Equal(t, tt.wantPubKey, actualPubKey, "Public key mismatch") + } + + // Roundtrip test: verify the key can sign and verify + message := []byte("test message") + signature := ed25519.Sign(ed25519Key, message) + pubKey := ed25519Key.Public().(ed25519.PublicKey) + assert.True(t, ed25519.Verify(pubKey, message, signature), "Signature verification failed") + }) + } +} diff --git a/pkg/util/fileops.go b/pkg/util/fileops.go new file mode 100644 index 0000000..44684f2 --- /dev/null +++ b/pkg/util/fileops.go @@ -0,0 +1,95 @@ +package util + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// CopyFile copies a single file from src to dst +func CopyFile(src, dst string) error { + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + destFile, err := os.Create(dst) + if err != nil { + return err + } + defer destFile.Close() + + if _, err := io.Copy(destFile, sourceFile); err != nil { + return err + } + + // Copy file permissions + sourceInfo, err := os.Stat(src) + if err != nil { + return err + } + return os.Chmod(dst, sourceInfo.Mode()) +} + +// CopyDir recursively copies a directory from src to dst +func CopyDir(src, dst string) error { + // Get source directory info + srcInfo, err := os.Stat(src) + if err != nil { + return err + } + + // Create destination directory + if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil { + return err + } + + // Read source directory + entries, err := os.ReadDir(src) + if err != nil { + return err + } + + // Copy each entry + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + + if entry.IsDir() { + // Recursively copy subdirectory + if err := CopyDir(srcPath, dstPath); err != nil { + return err + } + } else { + // Copy file + if err := CopyFile(srcPath, dstPath); err != nil { + return err + } + } + } + + return nil +} + +// ModifyFile replaces all occurrences of oldStr with newStr in the file. +// Returns an error if no replacements were made +func ModifyFile(path, oldStr, newStr string) error { + content, err := os.ReadFile(path) + if err != nil { + return err + } + + original := string(content) + modified := strings.ReplaceAll(original, oldStr, newStr) + + // Error if no replacements were made + if modified == original { + return fmt.Errorf("pattern %q not found in file %s", oldStr, path) + } + + return os.WriteFile(path, []byte(modified), 0644) +} +