Skip to content

Commit 01fb400

Browse files
committed
review: add bring your own key logic
1 parent a60c855 commit 01fb400

File tree

3 files changed

+224
-27
lines changed

3 files changed

+224
-27
lines changed

cmd/extensions.go

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -383,19 +383,41 @@ var extensionsUploadCmd = &cobra.Command{
383383
}
384384

385385
var extensionsPrepareWebBotAuthCmd = &cobra.Command{
386-
Use: "prepare-web-bot-auth",
387-
Short: "Prepare the Cloudflare web-bot-auth extension for Kernel",
386+
Use: "build-web-bot-auth",
387+
Short: "Build the Cloudflare web-bot-auth extension for Kernel",
388388
Long: `Download, build, and prepare the Cloudflare web-bot-auth extension with Kernel-specific configurations.
389-
This creates a directory ready to upload to Kernel.
390-
The extension will be configured to use the kernel-images server to host update.xml and the extension.crx file.`,
389+
Defaults to RFC9421 test key (works with Cloudflare's test site).
390+
Optionally accepts a custom Ed25519 JWK key file and can upload directly to Kernel.`,
391391
Args: cobra.NoArgs,
392392
RunE: func(cmd *cobra.Command, args []string) error {
393-
output, _ := cmd.Flags().GetString("output")
393+
output, _ := cmd.Flags().GetString("to")
394394
url, _ := cmd.Flags().GetString("url")
395-
return extensions.PrepareWebBotAuth(cmd.Context(), extensions.ExtensionsPrepareWebBotAuthInput{
395+
keyPath, _ := cmd.Flags().GetString("key")
396+
shouldUpload, _ := cmd.Flags().GetBool("upload")
397+
398+
// Build the extension
399+
result, err := extensions.BuildWebBotAuth(cmd.Context(), extensions.ExtensionsBuildWebBotAuthInput{
396400
Output: output,
397401
HostURL: url,
402+
KeyPath: keyPath,
398403
})
404+
if err != nil {
405+
return err
406+
}
407+
408+
// Upload if requested
409+
if shouldUpload {
410+
client := getKernelClient(cmd)
411+
svc := client.Extensions
412+
e := ExtensionsCmd{extensions: &svc}
413+
pterm.Info.Println("Uploading extension to Kernel...")
414+
return e.Upload(cmd.Context(), ExtensionsUploadInput{
415+
Dir: result.OutputDir,
416+
Name: result.ExtensionID,
417+
})
418+
}
419+
420+
return nil
399421
},
400422
}
401423

@@ -412,6 +434,8 @@ func init() {
412434
extensionsDownloadWebStoreCmd.Flags().String("to", "", "Output zip file path for the downloaded archive")
413435
extensionsDownloadWebStoreCmd.Flags().String("os", "", "Target OS: mac, win, or linux (default linux)")
414436
extensionsUploadCmd.Flags().String("name", "", "Optional unique extension name")
415-
extensionsPrepareWebBotAuthCmd.Flags().String("output", "./web-bot-auth", "Output directory for the prepared extension")
437+
extensionsPrepareWebBotAuthCmd.Flags().String("to", "./web-bot-auth", "Output directory for the prepared extension")
416438
extensionsPrepareWebBotAuthCmd.Flags().String("url", "http://127.0.0.1:10001", "Base URL for update.xml and policy templates")
439+
extensionsPrepareWebBotAuthCmd.Flags().String("key", "", "Path to custom Ed25519 JWK key file (defaults to RFC9421 test key)")
440+
extensionsPrepareWebBotAuthCmd.Flags().Bool("upload", false, "Upload extension to Kernel after building")
417441
}

pkg/extensions/webbotauth.go

Lines changed: 90 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,61 +23,91 @@ const (
2323
defaultFileMode = 0644
2424
webBotAuthDownloadURL = "https://github.com/cloudflare/web-bot-auth/archive/refs/heads/main.zip"
2525
downloadTimeout = 5 * time.Minute
26+
// defaultWebBotAuthKey is the RFC9421 test key that works with Cloudflare's test site
27+
// https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/
28+
defaultWebBotAuthKey = `{"kty":"OKP","crv":"Ed25519","d":"n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU","x":"JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"}`
2629
)
2730

28-
type ExtensionsPrepareWebBotAuthInput struct {
31+
type ExtensionsBuildWebBotAuthInput struct {
2932
Output string
3033
HostURL string
34+
KeyPath string // Path to user's JWK file (optional, defaults to RFC9421 test key)
3135
}
3236

33-
func PrepareWebBotAuth(ctx context.Context, in ExtensionsPrepareWebBotAuthInput) error {
37+
// BuildWebBotAuthOutput contains the result of building the extension
38+
type BuildWebBotAuthOutput struct {
39+
ExtensionID string
40+
OutputDir string
41+
}
42+
43+
func BuildWebBotAuth(ctx context.Context, in ExtensionsBuildWebBotAuthInput) (*BuildWebBotAuthOutput, error) {
3444
pterm.Info.Println("Preparing web-bot-auth extension...")
3545

3646
// Validate preconditions
3747
if err := validateToolDependencies(); err != nil {
38-
return err
48+
return nil, err
3949
}
4050

4151
outputDir, err := filepath.Abs(in.Output)
4252
if err != nil {
43-
return fmt.Errorf("failed to resolve output path: %w", err)
53+
return nil, fmt.Errorf("failed to resolve output path: %w", err)
4454
}
4555
if st, err := os.Stat(outputDir); err == nil {
4656
if !st.IsDir() {
47-
return fmt.Errorf("output path exists and is not a directory: %s", outputDir)
57+
return nil, fmt.Errorf("output path exists and is not a directory: %s", outputDir)
4858
}
4959
entries, _ := os.ReadDir(outputDir)
5060
if len(entries) > 0 {
51-
return fmt.Errorf("output directory must be empty: %s", outputDir)
61+
return nil, fmt.Errorf("output directory must be empty: %s", outputDir)
5262
}
5363
} else {
5464
if err := os.MkdirAll(outputDir, defaultDirMode); err != nil {
55-
return fmt.Errorf("failed to create output directory: %w", err)
65+
return nil, fmt.Errorf("failed to create output directory: %w", err)
5666
}
5767
}
5868

5969
// Download and extract
6070
browserExtDir, cleanup, err := downloadAndExtractWebBotAuth(ctx)
6171
defer cleanup()
6272
if err != nil {
63-
return err
73+
return nil, err
74+
}
75+
76+
// Load key (custom or default)
77+
var jwkData string
78+
var usingDefaultKey bool
79+
if in.KeyPath != "" {
80+
pterm.Info.Printf("Loading custom JWK from %s...\n", in.KeyPath)
81+
keyBytes, err := os.ReadFile(in.KeyPath)
82+
if err != nil {
83+
return nil, fmt.Errorf("failed to read key file: %w", err)
84+
}
85+
jwkData = string(keyBytes)
86+
usingDefaultKey = false
87+
} else {
88+
pterm.Info.Println("Using default RFC9421 test key (works with Cloudflare test site)...")
89+
jwkData = defaultWebBotAuthKey
90+
usingDefaultKey = true
6491
}
6592

6693
// Build extension
67-
extensionID, err := buildWebBotAuthExtension(ctx, browserExtDir, in.HostURL)
94+
extensionID, err := buildWebBotAuthExtension(ctx, browserExtDir, in.HostURL, jwkData)
6895
if err != nil {
69-
return err
96+
return nil, err
7097
}
7198

7299
// Copy artifacts
73100
if err := copyExtensionArtifacts(browserExtDir, outputDir); err != nil {
74-
return err
101+
return nil, err
75102
}
76103

77104
// Display success message
78-
displayWebBotAuthSuccess(outputDir, extensionID, in.HostURL)
105+
displayWebBotAuthSuccess(outputDir, extensionID, in.HostURL, usingDefaultKey)
79106

80-
return nil
107+
return &BuildWebBotAuthOutput{
108+
ExtensionID: extensionID,
109+
OutputDir: outputDir,
110+
}, nil
81111
}
82112

83113
// extractExtensionID extracts the extension ID from npm bundle output
@@ -90,6 +120,7 @@ func extractExtensionID(output string) string {
90120
return ""
91121
}
92122

123+
93124
// validateToolDependencies checks for required tools (node and npm)
94125
func validateToolDependencies() error {
95126
if _, err := exec.LookPath("node"); err != nil {
@@ -179,10 +210,23 @@ func downloadAndExtractWebBotAuth(ctx context.Context) (browserExtDir string, cl
179210
}
180211

181212
// buildWebBotAuthExtension modifies templates, builds the extension, and returns the extension ID
182-
func buildWebBotAuthExtension(ctx context.Context, browserExtDir, hostURL string) (string, error) {
213+
func buildWebBotAuthExtension(ctx context.Context, browserExtDir, hostURL, jwkData string) (string, error) {
183214
// Normalize hostURL by removing trailing slashes to prevent double slashes in URLs
184215
hostURL = strings.TrimRight(hostURL, "/")
185216

217+
// Convert JWK to PEM and write to browserExtDir before building
218+
pterm.Info.Println("Converting JWK to PEM format...")
219+
pemData, err := util.JWKToPEM(jwkData)
220+
if err != nil {
221+
return "", fmt.Errorf("failed to convert JWK to PEM: %w", err)
222+
}
223+
224+
privateKeyPath := filepath.Join(browserExtDir, "private_key.pem")
225+
if err := os.WriteFile(privateKeyPath, pemData, 0600); err != nil {
226+
return "", fmt.Errorf("failed to write private key: %w", err)
227+
}
228+
pterm.Success.Println("Private key written successfully")
229+
186230
// Modify template files
187231
pterm.Info.Println("Modifying templates with host URL...")
188232

@@ -337,27 +381,53 @@ func copyExtensionArtifacts(browserExtDir, outputDir string) error {
337381
pterm.Warning.Println("No private_key.pem found - extension ID may change on rebuild")
338382
}
339383

384+
// Copy policy directory (contains Chrome enterprise policy configuration)
385+
policySrc := filepath.Join(browserExtDir, "policy")
386+
policyDst := filepath.Join(outputDir, "policy")
387+
if _, err := os.Stat(policySrc); err == nil {
388+
if err := util.CopyDir(policySrc, policyDst); err != nil {
389+
return fmt.Errorf("failed to copy policy directory: %w", err)
390+
}
391+
pterm.Info.Println("Policy files copied (required for Chrome configuration)")
392+
}
393+
340394
return nil
341395
}
342396

343397
// displayWebBotAuthSuccess displays success message and next steps
344-
func displayWebBotAuthSuccess(outputDir, extensionID, hostURL string) {
398+
func displayWebBotAuthSuccess(outputDir, extensionID, hostURL string, usingDefaultKey bool) {
345399
pterm.Success.Println("Web-bot-auth extension prepared successfully!")
346400
pterm.Println()
347401

348402
rows := pterm.TableData{{"Property", "Value"}}
349403
rows = append(rows, []string{"Extension ID", extensionID})
350404
rows = append(rows, []string{"Output directory", outputDir})
351405
rows = append(rows, []string{"Host URL", hostURL})
406+
if usingDefaultKey {
407+
rows = append(rows, []string{"Signing Key", "RFC9421 test key (Cloudflare test site)"})
408+
} else {
409+
rows = append(rows, []string{"Signing Key", "Custom JWK"})
410+
}
352411
table.PrintTableNoPad(rows, true)
353412

354413
pterm.Println()
355414
pterm.Info.Println("Next steps:")
356415
pterm.Printf("1. Upload using the extension ID as the name:\n")
357416
pterm.Printf(" kernel extensions upload %s --name %s\n\n", outputDir, extensionID)
358-
pterm.Printf("2. Use in your browser, or upload to a session:\n")
359-
pterm.Printf(" kernel browsers create --extension %s\n", extensionID)
360-
pterm.Printf(" or run kernel browsers extensions upload <session-id> %s\n\n", outputDir)
361-
pterm.Warning.Println("⚠️ Private key saved to private_key.pem - keep it secure!")
362-
pterm.Info.Println(" It's automatically excluded when uploading via .gitignore")
417+
pterm.Printf("2. Use in your browser:\n")
418+
pterm.Printf(" kernel browsers create --extension %s\n\n", extensionID)
419+
420+
pterm.Println()
421+
pterm.Info.Println(" For testing with Cloudflare's test site:")
422+
pterm.Printf(" • Test URL: https://http-message-signatures-example.research.cloudflare.com\n")
423+
pterm.Printf(" • Or: https://webbotauth.io/test\n")
424+
pterm.Println()
425+
426+
if usingDefaultKey {
427+
pterm.Info.Println("Using default RFC9421 test key - compatible with Cloudflare test sites")
428+
} else {
429+
pterm.Warning.Println("⚠️ Private key saved to private_key.pem - keep it secure!")
430+
pterm.Info.Println(" It's automatically excluded when uploading via .gitignore")
431+
}
432+
363433
}

pkg/util/crypto.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package util
2+
3+
import (
4+
"crypto/ed25519"
5+
"encoding/base64"
6+
"encoding/json"
7+
"encoding/pem"
8+
"fmt"
9+
)
10+
11+
// jwkKey represents an Ed25519 JWK key
12+
type jwkKey struct {
13+
Kty string `json:"kty"` // Key type (should be "OKP")
14+
Crv string `json:"crv"` // Curve (should be "Ed25519")
15+
D string `json:"d"` // Private key (base64url encoded)
16+
X string `json:"x"` // Public key (base64url encoded)
17+
}
18+
19+
// JWKToPEM converts an Ed25519 JWK to PEM format (PKCS#8)
20+
// If the input is already in PEM format, it validates and returns it as-is
21+
func JWKToPEM(jwkJSON string) ([]byte, error) {
22+
// Check if input is already PEM-encoded
23+
if block, _ := pem.Decode([]byte(jwkJSON)); block != nil {
24+
if block.Type != "PRIVATE KEY" {
25+
return nil, fmt.Errorf("invalid PEM type: expected PRIVATE KEY, got %s", block.Type)
26+
}
27+
// TODO: Could add validation that it's actually an Ed25519 key
28+
return []byte(jwkJSON), nil
29+
}
30+
31+
// Parse as JWK
32+
var key jwkKey
33+
if err := json.Unmarshal([]byte(jwkJSON), &key); err != nil {
34+
return nil, fmt.Errorf("failed to parse as JWK or PEM: %w", err)
35+
}
36+
37+
if key.Kty != "OKP" || key.Crv != "Ed25519" {
38+
return nil, fmt.Errorf("invalid key type: expected OKP/Ed25519, got %s/%s", key.Kty, key.Crv)
39+
}
40+
41+
// Decode private key from base64url
42+
privateKeyBytes, err := base64.RawURLEncoding.DecodeString(key.D)
43+
if err != nil {
44+
return nil, fmt.Errorf("failed to decode private key: %w", err)
45+
}
46+
47+
if len(privateKeyBytes) != ed25519.SeedSize {
48+
return nil, fmt.Errorf("invalid private key size: expected %d bytes, got %d", ed25519.SeedSize, len(privateKeyBytes))
49+
}
50+
51+
// Generate Ed25519 private key from seed
52+
privateKey := ed25519.NewKeyFromSeed(privateKeyBytes)
53+
54+
// Create PKCS#8 structure
55+
pkcs8Bytes, err := MarshalPKCS8PrivateKey(privateKey)
56+
if err != nil {
57+
return nil, fmt.Errorf("failed to marshal PKCS#8: %w", err)
58+
}
59+
60+
// Encode to PEM
61+
pemBlock := &pem.Block{
62+
Type: "PRIVATE KEY",
63+
Bytes: pkcs8Bytes,
64+
}
65+
66+
return pem.EncodeToMemory(pemBlock), nil
67+
}
68+
69+
// MarshalPKCS8PrivateKey creates a PKCS#8 structure for Ed25519 private key
70+
func MarshalPKCS8PrivateKey(key ed25519.PrivateKey) ([]byte, error) {
71+
// PKCS#8 structure for Ed25519:
72+
// SEQUENCE {
73+
// INTEGER 0 (version)
74+
// SEQUENCE {
75+
// OBJECT IDENTIFIER 1.3.101.112 (Ed25519)
76+
// }
77+
// OCTET STRING (containing the 32-byte seed as OCTET STRING)
78+
// }
79+
80+
// Ed25519 OID: 1.3.101.112
81+
oid := []byte{0x06, 0x03, 0x2b, 0x65, 0x70}
82+
83+
// Extract seed (first 32 bytes of private key)
84+
seed := key.Seed()
85+
86+
// Inner OCTET STRING (seed)
87+
innerOctetString := append([]byte{0x04, byte(len(seed))}, seed...)
88+
89+
// Algorithm identifier SEQUENCE
90+
algSeq := append([]byte{0x30, byte(len(oid))}, oid...)
91+
92+
// Version (INTEGER 0)
93+
version := []byte{0x02, 0x01, 0x00}
94+
95+
// Combine all parts
96+
content := append(version, algSeq...)
97+
content = append(content, innerOctetString...)
98+
99+
// Outer SEQUENCE
100+
result := append([]byte{0x30, byte(len(content))}, content...)
101+
102+
return result, nil
103+
}

0 commit comments

Comments
 (0)