-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add signature agent #71
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -435,7 +435,7 @@ var extensionsBuildWebBotAuthCmd = &cobra.Command{ | |
| url, _ := cmd.Flags().GetString("url") | ||
| keyPath, _ := cmd.Flags().GetString("key") | ||
| uploadName, _ := cmd.Flags().GetString("upload") | ||
|
|
||
| signatureAgentURL, _ := cmd.Flags().GetString("signature-agent") | ||
| // Use upload name for extension name, or default to "web-bot-auth" | ||
| extensionName := "web-bot-auth" | ||
| if uploadName != "" { | ||
|
|
@@ -444,11 +444,12 @@ var extensionsBuildWebBotAuthCmd = &cobra.Command{ | |
|
|
||
| // Build the extension | ||
| result, err := extensions.BuildWebBotAuth(cmd.Context(), extensions.ExtensionsBuildWebBotAuthInput{ | ||
| Output: output, | ||
| HostURL: url, | ||
| KeyPath: keyPath, | ||
| ExtensionName: extensionName, | ||
| AutoUpload: uploadName != "", | ||
| Output: output, | ||
| HostURL: url, | ||
| KeyPath: keyPath, | ||
| ExtensionName: extensionName, | ||
| AutoUpload: uploadName != "", | ||
| SignatureAgentURL: signatureAgentURL, | ||
| }) | ||
| if err != nil { | ||
| return err | ||
|
|
@@ -489,4 +490,5 @@ func init() { | |
| extensionsBuildWebBotAuthCmd.Flags().String("url", "http://127.0.0.1:10001", "Base URL for update.xml and policy templates") | ||
| extensionsBuildWebBotAuthCmd.Flags().String("key", "", "Path to Ed25519 private key file (JWK or PEM format)") | ||
| extensionsBuildWebBotAuthCmd.Flags().String("upload", "", "Upload extension to Kernel with specified name (e.g., --upload web-bot-auth)") | ||
| extensionsBuildWebBotAuthCmd.Flags().String("signature-agent", "", "URL of the signature agent") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: the description is vague. consider something like: "Base URL of the signature agent (e.g., https://agent.example.com). Verifiers will look up /.well-known/http-message-signatures-directory at this URL." |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,24 +18,24 @@ import ( | |
| ) | ||
|
|
||
| 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" | ||
| defaultLocalhostURL = "http://localhost:8000" | ||
| defaultDirMode = 0755 | ||
| defaultFileMode = 0644 | ||
| webBotAuthCommit = "3f437a1fb17dcfd31a33b268f2f9447a83122057" | ||
| webBotAuthDownloadURL = "https://github.com/kernel/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") | ||
| AutoUpload bool // Whether the extension will be automatically uploaded after building | ||
| 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") | ||
| AutoUpload bool // Whether the extension will be automatically uploaded after building | ||
| SignatureAgentURL string // URL of the signature agent | ||
| } | ||
|
|
||
| // BuildWebBotAuthOutput contains the result of building the extension | ||
|
|
@@ -100,7 +100,7 @@ func BuildWebBotAuth(ctx context.Context, in ExtensionsBuildWebBotAuthInput) (*B | |
| } | ||
|
|
||
| // Build extension | ||
| extensionID, err := buildWebBotAuthExtension(ctx, browserExtDir, in.HostURL, keyData, in.ExtensionName) | ||
| extensionID, err := buildWebBotAuthExtension(ctx, browserExtDir, in.HostURL, keyData, in.ExtensionName, in.SignatureAgentURL) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
@@ -219,27 +219,35 @@ func downloadAndExtractWebBotAuth(ctx context.Context) (browserExtDir string, cl | |
|
|
||
| // 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) { | ||
| func buildWebBotAuthExtension(ctx context.Context, browserExtDir, hostURL, keyData, extensionName, signatureAgentURL 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 | ||
| // JWK data is used for keyid signing in background.ts | ||
| var jwkData string | ||
|
|
||
| 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) | ||
| } | ||
|
|
||
| jwkData, err = util.ConvertPEMToJWK(keyData) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could just support JWK keys lol so we wouldn't need converting pack and forth |
||
| if err != nil { | ||
| return "", fmt.Errorf("failed to convert PEM to JWK: %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) | ||
| } | ||
| jwkData = keyData | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Raw JWK input injected without re-serializationLow Severity When a user provides a JWK file, |
||
| } | ||
|
|
||
| privateKeyPath := filepath.Join(browserExtDir, "private_key.pem") | ||
|
|
@@ -248,6 +256,14 @@ func buildWebBotAuthExtension(ctx context.Context, browserExtDir, hostURL, keyDa | |
| } | ||
| pterm.Success.Println("Private key written successfully") | ||
|
|
||
| // Inject the JWK into background.ts (replacing the hardcoded test key) | ||
| pterm.Info.Println("Injecting custom JWK into background.ts...") | ||
| backgroundTsPath := filepath.Join(browserExtDir, "src", "background.ts") | ||
| if err := injectJWKIntoBackgroundTs(backgroundTsPath, jwkData); err != nil { | ||
| return "", fmt.Errorf("failed to inject JWK: %w", err) | ||
| } | ||
| pterm.Success.Println("Custom JWK injected successfully") | ||
archandatta marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // Modify template files | ||
| pterm.Info.Println("Modifying templates with host URL...") | ||
|
|
||
|
|
@@ -293,6 +309,7 @@ func buildWebBotAuthExtension(ctx context.Context, browserExtDir, hostURL, keyDa | |
| pterm.Info.Println("Building extension...") | ||
| npmBuild := exec.CommandContext(ctx, "npm", "run", "build:chrome") | ||
| npmBuild.Dir = browserExtDir | ||
| npmBuild.Env = append(os.Environ(), "SIGNATURE_AGENT_URL="+signatureAgentURL) | ||
| npmBuild.Stdout = os.Stdout | ||
| npmBuild.Stderr = os.Stderr | ||
| if err := npmBuild.Run(); err != nil { | ||
|
|
@@ -303,6 +320,7 @@ func buildWebBotAuthExtension(ctx context.Context, browserExtDir, hostURL, keyDa | |
| pterm.Info.Println("Bundling extension...") | ||
| npmBundle := exec.CommandContext(ctx, "npm", "run", "bundle:chrome") | ||
| npmBundle.Dir = browserExtDir | ||
| npmBundle.Env = append(os.Environ(), "SIGNATURE_AGENT_URL="+signatureAgentURL) | ||
| var bundleOutput bytes.Buffer | ||
| npmBundle.Stdout = io.MultiWriter(os.Stdout, &bundleOutput) | ||
| npmBundle.Stderr = os.Stderr | ||
|
|
@@ -347,6 +365,34 @@ func buildWebBotAuthExtension(ctx context.Context, browserExtDir, hostURL, keyDa | |
| return extensionID, nil | ||
| } | ||
|
|
||
| // injectJWKIntoBackgroundTs replaces the hardcoded test key import with the custom JWK | ||
| func injectJWKIntoBackgroundTs(backgroundTsPath, jwkData string) error { | ||
| content, err := os.ReadFile(backgroundTsPath) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to read background.ts: %w", err) | ||
| } | ||
|
|
||
| contentStr := string(content) | ||
|
|
||
| // Replace the import line with an inline constant | ||
| // Find: import jwk from "../../rfc9421-keys/ed25519.json" assert { type: "json" }; | ||
| // Replace with: const jwk = {your-jwk-here}; | ||
| searchPattern := `import jwk from "../../rfc9421-keys/ed25519.json" assert { type: "json" };` | ||
| replacement := fmt.Sprintf("const jwk = %s;", jwkData) | ||
|
|
||
| if !strings.Contains(contentStr, searchPattern) { | ||
| return fmt.Errorf("could not find JWK import statement in background.ts") | ||
| } | ||
|
|
||
| contentStr = strings.Replace(contentStr, searchPattern, replacement, 1) | ||
|
Comment on lines
+377
to
+387
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This part seems kinda flakey. We are basically writing typescript code from Go to make a one line replacement. |
||
|
|
||
| if err := os.WriteFile(backgroundTsPath, []byte(contentStr), 0644); err != nil { | ||
| return fmt.Errorf("failed to write modified background.ts: %w", err) | ||
| } | ||
|
|
||
| return 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...") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -86,3 +86,50 @@ func ConvertJWKToPEM(jwkJSON string) ([]byte, error) { | |
|
|
||
| return pem.EncodeToMemory(pemBlock), nil | ||
| } | ||
|
|
||
| // ConvertPEMToJWK converts an Ed25519 PEM private key to JWK format | ||
| func ConvertPEMToJWK(pemData string) (string, error) { | ||
|
Comment on lines
+90
to
+91
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i wish there was easy util to make this, but it seems no. So looks good to me
Comment on lines
+90
to
+91
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe we wanna rename it so that it is explicit that it only converts Ed25519 PEM to JWK format |
||
| // Decode PEM block | ||
| 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) | ||
| } | ||
|
|
||
| // Parse PKCS#8 private key | ||
| privateKeyInterface, err := x509.ParsePKCS8PrivateKey(block.Bytes) | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to parse PKCS#8 private key: %w", err) | ||
| } | ||
|
|
||
| // Ensure it's an Ed25519 key | ||
| privateKey, ok := privateKeyInterface.(ed25519.PrivateKey) | ||
| if !ok { | ||
| return "", fmt.Errorf("invalid key type: expected Ed25519 private key, got %T", privateKeyInterface) | ||
| } | ||
|
|
||
| // Extract seed (first 32 bytes of Ed25519 private key) | ||
| seed := privateKey.Seed() | ||
|
|
||
| // Extract public key (last 32 bytes of Ed25519 private key) | ||
| publicKey := privateKey.Public().(ed25519.PublicKey) | ||
|
|
||
| // Encode to base64url (without padding) | ||
| jwk := jwkKey{ | ||
| Kty: "OKP", | ||
| Crv: "Ed25519", | ||
| D: base64.RawURLEncoding.EncodeToString(seed), | ||
| X: base64.RawURLEncoding.EncodeToString(publicKey), | ||
| } | ||
|
|
||
| // Marshal to JSON | ||
| jwkJSON, err := json.Marshal(jwk) | ||
| if err != nil { | ||
| return "", fmt.Errorf("failed to marshal JWK: %w", err) | ||
| } | ||
|
|
||
| return string(jwkJSON), nil | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
perhaps we should add that kernel is explicitly add to the helper that Kernel is expecting a ed25519 key. This reason being that ConvertPEMToJWK validates that
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That is mentioned in the
--keyflag